diff --git a/.github/actions/flutter_build/action.yml b/.github/actions/flutter_build/action.yml index 81b2845949..bfcb501327 100644 --- a/.github/actions/flutter_build/action.yml +++ b/.github/actions/flutter_build/action.yml @@ -58,19 +58,24 @@ runs: - 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 - 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 + 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 @@ -94,4 +99,4 @@ runs: - uses: actions/upload-artifact@v4 with: name: ${{ github.run_id }}-${{ matrix.os }} - path: appflowy_flutter.tar.gz \ No newline at end of file + path: appflowy_flutter.tar.gz diff --git a/.github/actions/flutter_integration_test/action.yml b/.github/actions/flutter_integration_test/action.yml index 6df3ec005d..e0fa508ade 100644 --- a/.github/actions/flutter_integration_test/action.yml +++ b/.github/actions/flutter_integration_test/action.yml @@ -75,4 +75,4 @@ runs: 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 \ No newline at end of file + shell: bash diff --git a/.github/workflows/android_ci.yaml.bak b/.github/workflows/android_ci.yaml.bak index 8732558927..81e132cbf8 100644 --- a/.github/workflows/android_ci.yaml.bak +++ b/.github/workflows/android_ci.yaml.bak @@ -1,126 +1,196 @@ -# name: Android CI +name: Android CI -# on: -# push: -# branches: -# - "main" -# paths: -# - ".github/workflows/mobile_ci.yaml" -# - "frontend/**" -# - "!frontend/appflowy_tauri/**" +on: + push: + branches: + - "main" + paths: + - ".github/workflows/mobile_ci.yaml" + - "frontend/**" -# pull_request: -# 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.19.0" -# RUST_TOOLCHAIN: "1.77.2" -# CARGO_MAKE_VERSION: "0.36.6" +env: + CARGO_TERM_COLOR: always + FLUTTER_VERSION: "3.27.4" + RUST_TOOLCHAIN: "1.81.0" + 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 +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: [macos-14] -# runs-on: ${{ matrix.os }} +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 + 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 -# sudo rm -rf $ANDROID_HOME/ndk + # 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: Check storage space + run: df -h -# - name: Checkout source code -# uses: actions/checkout@v4 + - name: Checkout appflowy cloud code + uses: actions/checkout@v4 + with: + repository: AppFlowy-IO/AppFlowy-Cloud + path: AppFlowy-Cloud -# - uses: actions/setup-java@v4 -# with: -# distribution: temurin -# java-version: 11 + - 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: Install Rust toolchain -# id: rust_toolchain -# uses: actions-rs/toolchain@v1 -# with: -# toolchain: ${{ env.RUST_TOOLCHAIN }} -# override: true -# profile: minimal + - 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 -# - name: Install flutter -# id: flutter -# uses: subosito/flutter-action@v2 -# with: -# channel: "stable" -# flutter-version: ${{ env.FLUTTER_VERSION }} + # 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 -# - uses: gradle/gradle-build-action@v3 -# with: -# gradle-version: 7.4.2 + - name: Checkout source code + uses: actions/checkout@v4 -# - uses: davidB/rust-cargo-make@v1 -# with: -# version: "0.36.6" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 11 -# - name: Install prerequisites -# working-directory: frontend -# run: | -# rustup target install aarch64-linux-android -# rustup target install x86_64-linux-android -# cargo install --force 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: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + override: true + profile: minimal -# - name: Build AppFlowy -# working-directory: frontend -# run: | -# cargo make --profile development-android appflowy-android-dev-ci + - 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 -# - name: Run integration tests -# # https://github.com/ReactiveCircus/android-emulator-runner -# uses: reactivecircus/android-emulator-runner@v2 -# with: -# api-level: 32 -# arch: arm64-v8a -# disk-size: 2048M -# working-directory: frontend/appflowy_flutter -# script: flutter test integration_test/runner.dart \ No newline at end of file + - 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/build_bot.yaml b/.github/workflows/build_bot.yaml deleted file mode 100644 index 65854b94d1..0000000000 --- a/.github/workflows/build_bot.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: Build Bot - -on: - issue_comment: - types: [created] - -jobs: - dispatch_slash_command: - runs-on: ubuntu-latest - steps: - - name: Checkout source code - uses: actions/checkout@v4 - - # get build name from pubspec.yaml - - name: Get build version - working-directory: frontend/appflowy_flutter - id: get_build_name - run: | - echo "fetching version from pubspec.yaml..." - echo "build_name=$(grep 'version: ' pubspec.yaml | awk '{print $2}')" >> $GITHUB_OUTPUT - - - uses: peter-evans/slash-command-dispatch@v4 - with: - token: ${{ secrets.PAT }} - commands: build - static-args: | - ref=refs/pull/${{ github.event.issue.number }}/head - build_name=${{ steps.get_build_name.outputs.build_name }} diff --git a/.github/workflows/deploy_test_web.yaml b/.github/workflows/deploy_test_web.yaml deleted file mode 100644 index 49a96458f9..0000000000 --- a/.github/workflows/deploy_test_web.yaml +++ /dev/null @@ -1,72 +0,0 @@ -name: Deploy Web (Test) - -on: - push: - branches: - - build/test -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: - deploy: - runs-on: ubuntu-latest - env: - SSH_PRIVATE_KEY: ${{ secrets.WEB_TEST_SSH_PRIVATE_KEY }} - REMOTE_HOST: ${{ secrets.WEB_TEST_REMOTE_HOST }} - REMOTE_USER: ${{ secrets.WEB_TEST_REMOTE_USER }} - SSL_CERTIFICATE: ${{ secrets.WEB_TEST_SSL_CERTIFICATE }} - SSL_CERTIFICATE_KEY: ${{ secrets.WEB_TEST_SSL_CERTIFICATE_KEY }} - ENV_FILE: test.env - steps: - - uses: actions/checkout@v4 - - 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: copy env file - working-directory: frontend/appflowy_web_app - run: | - cp ${{ env.ENV_FILE }} .env - - name: test and lint - working-directory: frontend/appflowy_web_app - run: | - pnpm run lint - - name: build - working-directory: frontend/appflowy_web_app - run: | - pnpm run build - - name: generate SSL certificate - run: | - echo "${{ env.SSL_CERTIFICATE }}" > nginx-signed.crt - echo "${{ env.SSL_CERTIFICATE_KEY }}" > nginx-signed.key - - name: Deploy to EC2 - uses: easingthemes/ssh-deploy@main - with: - SSH_PRIVATE_KEY: ${{ env.SSH_PRIVATE_KEY }} - ARGS: "-rlgoDzvc -i" - SOURCE: "frontend/appflowy_web_app/dist frontend/appflowy_web_app/Dockerfile frontend/appflowy_web_app/nginx.conf frontend/appflowy_web_app/.env nginx-signed.crt nginx-signed.key" - REMOTE_HOST: ${{ env.REMOTE_HOST }} - REMOTE_USER: ${{ env.REMOTE_USER }} - EXCLUDE: "frontend/appflowy_web_app/dist/, frontend/appflowy_web_app/node_modules/" - SCRIPT_AFTER: | - 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 diff --git a/.github/workflows/docker_ci.yml b/.github/workflows/docker_ci.yml index 2c2143c1fe..51e8a2ac28 100644 --- a/.github/workflows/docker_ci.yml +++ b/.github/workflows/docker_ci.yml @@ -2,18 +2,10 @@ name: Docker-CI on: push: - branches: - - main - - release/* - paths: - - frontend/** + branches: [ "main", "release/*" ] pull_request: - branches: - - main - - release/* - paths: - - frontend/** - types: [opened, synchronize, reopened, unlocked, ready_for_review] + branches: [ "main", "release/*" ] + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -27,25 +19,29 @@ jobs: - name: Checkout source code uses: actions/checkout@v4 - - name: Set up Docker Compose - run: | - docker-compose --version || { - echo "Docker Compose not found, installing..." - sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose - sudo chmod +x /usr/local/bin/docker-compose - docker-compose --version - } + - 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 - shell: bash - run: | - set -eu -o pipefail - cd frontend/scripts/docker-buildfiles - docker-compose build --no-cache --progress=plain \ - | while read line; do \ - if [[ "$line" =~ ^Step[[:space:]] ]]; then \ - echo "$(date -u '+%H:%M:%S') | $line"; \ - else \ - echo "$line"; \ - fi; \ - done + 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 index 6ec1c76682..1fc1b0e052 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -25,9 +25,10 @@ on: env: CARGO_TERM_COLOR: always - FLUTTER_VERSION: "3.19.0" - RUST_TOOLCHAIN: "1.77.2" - CARGO_MAKE_VERSION: "0.36.6" + FLUTTER_VERSION: "3.27.4" + RUST_TOOLCHAIN: "1.81.0" + CARGO_MAKE_VERSION: "0.37.18" + CLOUD_VERSION: 0.6.54-amd64 concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -39,7 +40,7 @@ jobs: strategy: fail-fast: true matrix: - os: [ ubuntu-latest ] + os: [ubuntu-latest] include: - os: ubuntu-latest flutter_profile: development-linux-x86_64 @@ -73,7 +74,7 @@ jobs: strategy: fail-fast: true matrix: - os: [ windows-latest ] + os: [windows-latest] include: - os: windows-latest flutter_profile: development-windows-x86 @@ -89,6 +90,7 @@ jobs: 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 }} @@ -99,7 +101,7 @@ jobs: strategy: fail-fast: true matrix: - os: [ macos-latest ] + os: [macos-latest] include: - os: macos-latest flutter_profile: development-mac-x86_64 @@ -121,12 +123,12 @@ jobs: flutter_profile: ${{ matrix.flutter_profile }} unit_test: - needs: [ prepare-linux ] + needs: [prepare-linux] if: github.event.pull_request.draft != true strategy: fail-fast: false matrix: - os: [ ubuntu-latest ] + os: [ubuntu-latest] include: - os: ubuntu-latest flutter_profile: development-linux-x86_64 @@ -202,6 +204,7 @@ jobs: - name: Run Flutter unit tests env: DISABLE_EVENT_LOG: true + DISABLE_CI_TEST_LOG: "true" working-directory: frontend run: | if [ "$RUNNER_OS" == "macOS" ]; then @@ -214,11 +217,11 @@ jobs: shell: bash cloud_integration_test: - needs: [ prepare-linux ] + needs: [prepare-linux] strategy: fail-fast: false matrix: - os: [ ubuntu-latest ] + os: [ubuntu-latest] include: - os: ubuntu-latest flutter_profile: development-linux-x86_64 @@ -239,17 +242,50 @@ jobs: 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: - BACKEND_VERSION: 0.3.24-amd64 + APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }} + APPFLOWY_HISTORY_VERSION: ${{ env.CLOUD_VERSION }} + APPFLOWY_WORKER_VERSION: ${{ env.CLOUD_VERSION }} run: | - docker compose down -v --remove-orphans - docker compose pull - docker compose up -d - sleep 10 + 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 @@ -300,78 +336,30 @@ jobs: sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & sudo apt-get install network-manager docker ps -a - flutter test integration_test/cloud/cloud_runner.dart -d Linux --coverage + flutter test integration_test/desktop/cloud/cloud_runner.dart -d Linux --coverage shell: bash - # split the integration tests into different machines to minimize the time - integration_test_1: - needs: [ prepare-linux ] + integration_test: + needs: [prepare-linux] if: github.event.pull_request.draft != true strategy: fail-fast: false matrix: - os: [ ubuntu-latest ] + os: [ubuntu-latest] + test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9] include: - os: ubuntu-latest - target: 'x86_64-unknown-linux-gnu' + target: "x86_64-unknown-linux-gnu" runs-on: ${{ matrix.os }} steps: - name: Checkout source code uses: actions/checkout@v4 - - name: Flutter Integration Test 1 + - name: Flutter Integration Test ${{ matrix.test_number }} uses: ./.github/actions/flutter_integration_test with: - test_path: integration_test/desktop_runner_1.dart + 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 }} - - integration_test_2: - needs: [ prepare-linux ] - if: github.event.pull_request.draft != true - strategy: - fail-fast: false - matrix: - os: [ ubuntu-latest ] - 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 2 - uses: ./.github/actions/flutter_integration_test - with: - test_path: integration_test/desktop_runner_2.dart - flutter_version: ${{ env.FLUTTER_VERSION }} - rust_toolchain: ${{ env.RUST_TOOLCHAIN }} - cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} - rust_target: ${{ matrix.target }} - - integration_test_3: - needs: [ prepare-linux ] - if: github.event.pull_request.draft != true - strategy: - fail-fast: false - matrix: - os: [ ubuntu-latest ] - 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 3 - uses: ./.github/actions/flutter_integration_test - with: - test_path: integration_test/desktop_runner_3.dart - flutter_version: ${{ env.FLUTTER_VERSION }} - rust_toolchain: ${{ env.RUST_TOOLCHAIN }} - cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} - rust_target: ${{ matrix.target }} \ No newline at end of file diff --git a/.github/workflows/ios_ci.yaml b/.github/workflows/ios_ci.yaml index e6b6b741fd..e13863f4a7 100644 --- a/.github/workflows/ios_ci.yaml +++ b/.github/workflows/ios_ci.yaml @@ -7,7 +7,6 @@ on: paths: - ".github/workflows/mobile_ci.yaml" - "frontend/**" - - "!frontend/appflowy_tauri/**" - "!frontend/appflowy_web_app/**" pull_request: @@ -16,32 +15,46 @@ on: paths: - ".github/workflows/mobile_ci.yaml" - "frontend/**" - - "!frontend/appflowy_tauri/**" - "!frontend/appflowy_web_app/**" env: - FLUTTER_VERSION: "3.19.0" - RUST_TOOLCHAIN: "1.77.2" + FLUTTER_VERSION: "3.27.4" + RUST_TOOLCHAIN: "1.81.0" 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: [ macos-14 ] - runs-on: ${{ matrix.os }} + 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 - id: rust_toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} @@ -49,8 +62,7 @@ jobs: override: true profile: minimal - - name: Install flutter - id: flutter + - name: Install Flutter uses: subosito/flutter-action@v2 with: channel: "stable" @@ -59,19 +71,19 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - prefix-key: ${{ matrix.os }} + prefix-key: macos-latest workspaces: | frontend/rust-lib - uses: davidB/rust-cargo-make@v1 with: - version: "0.36.6" + version: "0.37.15" - name: Install prerequisites working-directory: frontend run: | rustup target install aarch64-apple-ios-sim - cargo install --force duckscript_cli + cargo install --force --locked duckscript_cli cargo install cargo-lipo cargo make appflowy-flutter-deps-tools shell: bash @@ -85,10 +97,23 @@ jobs: - uses: futureware-tech/simulator-action@v3 id: simulator-action with: - model: 'iPhone 15' + model: "iPhone 15" shutdown_after_job: false - # enable it again if the 12 mins timeout is fixed - # - name: Run integration tests - # working-directory: frontend/appflowy_flutter - # run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }} + - 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 0000000000..4606a67799 --- /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/release.yml b/.github/workflows/release.yml index ee065c6e9e..a4582ffa74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,8 @@ on: - "*" env: - FLUTTER_VERSION: "3.19.0" - RUST_TOOLCHAIN: "1.77.2" + FLUTTER_VERSION: "3.27.4" + RUST_TOOLCHAIN: "1.81.0" jobs: create-release: @@ -73,8 +73,8 @@ jobs: working-directory: frontend run: | vcpkg integrate install - cargo install --force cargo-make - cargo install --force duckscript_cli + cargo install --force --locked cargo-make + cargo install --force --locked duckscript_cli - name: Build Windows app working-directory: frontend @@ -135,7 +135,7 @@ jobs: fail-fast: false matrix: job: - - { target: x86_64-apple-darwin, os: macos-11, extra-build-args: "" } + - { target: x86_64-apple-darwin, os: macos-13, extra-build-args: "" } steps: - name: Checkout source code uses: actions/checkout@v4 @@ -158,8 +158,8 @@ jobs: - name: Install prerequisites working-directory: frontend run: | - cargo install --force cargo-make - cargo install --force duckscript_cli + cargo install --force --locked cargo-make + cargo install --force --locked duckscript_cli - name: Build AppFlowy working-directory: frontend @@ -232,10 +232,10 @@ jobs: matrix: job: - { - targets: "aarch64-apple-darwin,x86_64-apple-darwin", - os: macos-11, - extra-build-args: "", - } + targets: "aarch64-apple-darwin,x86_64-apple-darwin", + os: macos-latest, + extra-build-args: "", + } steps: - name: Checkout source code uses: actions/checkout@v4 @@ -256,8 +256,8 @@ jobs: - name: Install prerequisites working-directory: frontend run: | - cargo install --force cargo-make - cargo install --force duckscript_cli + cargo install --force --locked cargo-make + cargo install --force --locked duckscript_cli - name: Build AppFlowy working-directory: frontend @@ -336,12 +336,12 @@ jobs: matrix: job: - { - arch: x86_64, - target: x86_64-unknown-linux-gnu, - os: ubuntu-20.04, - extra-build-args: "", - flutter_profile: production-linux-x86_64, - } + arch: x86_64, + target: x86_64-unknown-linux-gnu, + os: ubuntu-22.04, + extra-build-args: "", + flutter_profile: production-linux-x86_64, + } steps: - name: Checkout source code uses: actions/checkout@v4 @@ -367,11 +367,11 @@ jobs: 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 libnotify-dev - sudo apt-get -y install alien + sudo apt-get install keybinder-3.0 + sudo apt-get install -y alien libnotify-dev source $HOME/.cargo/env - cargo install --force cargo-make - cargo install --force duckscript_cli + cargo install --force --locked cargo-make + cargo install --force --locked duckscript_cli rustup target add ${{ matrix.job.target }} - name: Install gcc-aarch64-linux-gnu @@ -479,6 +479,24 @@ jobs: 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: diff --git a/.github/workflows/rust_ci.yaml b/.github/workflows/rust_ci.yaml index c59d45c58c..36c2e82064 100644 --- a/.github/workflows/rust_ci.yaml +++ b/.github/workflows/rust_ci.yaml @@ -8,60 +8,50 @@ on: - "release/*" paths: - "frontend/rust-lib/**" + - ".github/workflows/rust_ci.yaml" pull_request: branches: - "main" - "develop" - "release/*" - paths: - - "frontend/rust-lib/**" env: CARGO_TERM_COLOR: always - RUST_TOOLCHAIN: "1.77.2" + CLOUD_VERSION: 0.8.3-amd64 + RUST_TOOLCHAIN: "1.81.0" jobs: - test-on-ubuntu: + ubuntu-job: runs-on: ubuntu-latest steps: - # - name: Maximize build space - # uses: easimon/maximize-build-space@master - # with: - # root-reserve-mb: 2048 - # swap-size-mb: 1024 - # remove-dotnet: 'true' + - name: Set timezone for action + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: "US/Pacific" - # # 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" - # sudo docker image prune --all --force + - 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 - id: rust_toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} override: true components: rustfmt, clippy profile: minimal - - - name: Install prerequisites - working-directory: frontend - run: | - cargo install --force cargo-make - cargo install --force duckscript_cli - - uses: Swatinem/rust-cache@v2 with: - prefix-key: "ubuntu-latest" + prefix-key: ${{ runner.os }} + cache-on-failure: true workspaces: | frontend/rust-lib @@ -74,18 +64,38 @@ jobs: - 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_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env - - name: Run Docker-Compose + - name: Ensure AppFlowy-Cloud is Running with Correct Version working-directory: AppFlowy-Cloud env: - BACKEND_VERSION: 0.3.24-amd64 + APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }} + APPFLOWY_HISTORY_VERSION: ${{ env.CLOUD_VERSION }} + APPFLOWY_WORKER_VERSION: ${{ env.CLOUD_VERSION }} run: | - docker pull appflowyinc/appflowy_cloud:latest + # 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 @@ -106,6 +116,12 @@ jobs: 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 diff --git a/.github/workflows/rust_coverage.yml b/.github/workflows/rust_coverage.yml index 4d8e9cbad8..53a5f66748 100644 --- a/.github/workflows/rust_coverage.yml +++ b/.github/workflows/rust_coverage.yml @@ -10,8 +10,8 @@ on: env: CARGO_TERM_COLOR: always - FLUTTER_VERSION: "3.19.0" - RUST_TOOLCHAIN: "1.77.2" + FLUTTER_VERSION: "3.27.4" + RUST_TOOLCHAIN: "1.81.0" jobs: tests: @@ -40,8 +40,8 @@ jobs: - name: Install prerequisites working-directory: frontend run: | - cargo install --force cargo-make - cargo install --force duckscript_cli + cargo install --force --locked cargo-make + cargo install --force --locked duckscript_cli - uses: Swatinem/rust-cache@v2 with: diff --git a/.github/workflows/tauri2_ci.yaml b/.github/workflows/tauri2_ci.yaml deleted file mode 100644 index 28414f0997..0000000000 --- a/.github/workflows/tauri2_ci.yaml +++ /dev/null @@ -1,113 +0,0 @@ -name: Tauri-CI -on: - pull_request: - paths: - - ".github/workflows/tauri2_ci.yaml" - - "frontend/rust-lib/**" - - "frontend/appflowy_web_app/**" - - "frontend/resources/**" - -env: - NODE_VERSION: "18.16.0" - PNPM_VERSION: "8.5.0" - RUST_TOOLCHAIN: "1.77.2" - -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_web_app/src-tauri -> target" - - - name: Node_modules cache - uses: actions/cache@v2 - with: - path: frontend/appflowy_web_app/node_modules - key: node-modules-${{ runner.os }} - - - name: install dependencies (windows only) - if: matrix.platform == 'windows-latest' - working-directory: frontend - run: | - cargo install --force 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 cargo-make - cargo make appflowy-tauri-deps-tools - - - 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" \ No newline at end of file diff --git a/.github/workflows/tauri_ci.yaml b/.github/workflows/tauri_ci.yaml deleted file mode 100644 index 70ad621451..0000000000 --- a/.github/workflows/tauri_ci.yaml +++ /dev/null @@ -1,111 +0,0 @@ -name: Tauri-CI -on: - push: - branches: - - build/tauri - -env: - NODE_VERSION: "18.16.0" - PNPM_VERSION: "8.5.0" - RUST_TOOLCHAIN: "1.77.2" - -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 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 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" \ No newline at end of file diff --git a/.github/workflows/tauri_release.yml b/.github/workflows/tauri_release.yml deleted file mode 100644 index 7de80b017e..0000000000 --- a/.github/workflows/tauri_release.yml +++ /dev/null @@ -1,153 +0,0 @@ -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.77.2" - -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 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 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/web2_ci.yaml b/.github/workflows/web2_ci.yaml deleted file mode 100644 index 68df8d805f..0000000000 --- a/.github/workflows/web2_ci.yaml +++ /dev/null @@ -1,75 +0,0 @@ -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: test and lint - working-directory: frontend/appflowy_web_app - run: | - pnpm run lint - pnpm run test:unit - - 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_ci.yaml b/.github/workflows/web_ci.yaml deleted file mode 100644 index 9c568e1916..0000000000 --- a/.github/workflows/web_ci.yaml +++ /dev/null @@ -1,83 +0,0 @@ -name: WEB-CI - -on: - workflow_dispatch: - inputs: - build: - description: 'Build the web app' - required: true - default: 'true' - -env: - CARGO_TERM_COLOR: always - NODE_VERSION: "18.16.0" - PNPM_VERSION: "8.5.0" - RUST_TOOLCHAIN: "1.77.2" - CARGO_MAKE_VERSION: "0.36.6" - -jobs: - web-build: - if: github.event.pull_request.draft != true - strategy: - fail-fast: false - matrix: - platform: [ ubuntu-latest ] - - runs-on: ${{ matrix.platform }} - steps: - - uses: actions/checkout@v4 - - name: setup node - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Cache Rust Dependencies - uses: Swatinem/rust-cache@v2 - with: - key: rust-dependencies-${{ runner.os }} - workspaces: | - frontend/rust-lib - frontend/appflowy_web/appflowy_wasm - - # TODO: Can combine caching deps and node_modules in one - # See Glob patterns: https://github.com/actions/toolkit/tree/main/packages/glob - - name: Cache Node.js dependencies - uses: actions/cache@v4 - with: - path: ~/.npm - key: npm-${{ runner.os }} - - - name: Cache node_modules - uses: actions/cache@v4 - with: - path: frontend/appflowy_web/node_modules - key: node-modules-${{ runner.os }} - - - name: Install Rust toolchain - id: rust_toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - override: true - profile: minimal - - - name: Install wasm-pack - run: cargo install wasm-pack - - - uses: taiki-e/install-action@v2 - with: - tool: cargo-make@${{ env.CARGO_MAKE_VERSION }} - - - name: install dependencies - if: matrix.platform == 'ubuntu-latest' - working-directory: frontend - run: | - sudo apt-get update - npm install -g pnpm@${{ env.PNPM_VERSION }} - cargo make install_web_protobuf - - - name: Build - working-directory: frontend/appflowy_web - run: | - pnpm install - pnpm run build_release_wasm diff --git a/.github/workflows/web_cypress_ci.yaml b/.github/workflows/web_cypress_ci.yaml deleted file mode 100644 index 15e52d3e8d..0000000000 --- a/.github/workflows/web_cypress_ci.yaml +++ /dev/null @@ -1,48 +0,0 @@ -name: Cypress Tests - -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: - cypress-run: - 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 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index acbf0b7999..a5e7e268a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,413 @@ # Release Notes +## Version 0.8.9 - 16/04/2025 +### Desktop +#### New Features +- Supported pasting a link as a mention, providing a more condensed visualization of linked content +- Supported converting between link formats (e.g. transforming a mention into a bookmark) +- Improved the link editing experience with enhanced UX +- Added OTP (One-Time Password) support for sign-in authentication +- Added latest AI models: GPT-4.1, GPT-4.1-mini, and Claude 3.7 Sonnet +#### Bug Fixes +- Fixed an issue where properties were not displaying in the row detail page +- Fixed a bug where Undo didn't work in the row detail page +- Fixed an issue where blocks didn't grow when the grid got bigger +- Fixed several bugs related to AI writers +### Mobile +#### New Features +- Added sign-in with OTP (One-Time Password) +#### Bug Fixes +- Fixed an issue where the slash menu sometimes failed to display +- Updated the mention page block to handle page selection with more context. + +## Version 0.8.8 - 01/04/2025 +### New Features +- Added support for selecting AI models in AI writer +- Revamped link menu in toolbar +- Added support for using ":" to add emojis in documents +- Passed the history of past AI prompts and responses to AI writer +### Bug Fixes +- Improved AI writer scrolling user experience +- Fixed issue where checklist items would disappear during reordering +- Fixed numbered lists generated by AI to maintain the same index as the input + +## Version 0.8.7 - 18/03/2025 +### New Features +- Made local AI free and integrated with Ollama +- Supported nested lists within callout and quote blocks +- Revamped the document's floating toolbar and added Turn Into +- Enabled custom icons in callout blocks +### Bug Fixes +- Fixed occasional incorrect positioning of the slash menu +- Improved AI Chat and AI Writers with various bug fixes +- Adjusted the columns block to match the width of the editor +- Fixed a potential segfault caused by infinite recursion in the trash view +- Resolved an issue where the first added cover might be invisible +- Fixed adding cover images via Unsplash + +## Version 0.8.6 - 06/03/2025 +### Bug Fixes +- Fix the incorrect title positioning when adjusting the document width setting +- Enhance the user experience of the icon color picker for smoother interactions +- Add missing icons to the database to ensure completeness and consistency +- Resolve the issue with links not functioning correctly on Linux systems +- Improve the outline feature to work seamlessly within columns +- Center the bulleted list icon within columns for better visual alignment +- Enable dragging blocks under tables in the second column to enhance flexibility +- Disable the AI writer feature within tables to prevent conflicts and improve usability +- Automatically enable the header row when converting content from Markdown to ensure proper formatting +- Use the "Undo" function to revert the auto-formatting + +## Version 0.8.5 - 04/03/2025 +### New Features +- Columns in Documents: Arrange content side by side using drag-and-drop or the slash menu +- AI Writers: New AI assistants in documents with response formatting options (list, table, text with images, image-only), follow-up questions, contextual memory, and more +- Compact Mode for Databases: Enable compact mode for grid and kanban views (full-page and inline) to increase information density, displaying more data per screen +### Bug Fixes +- Fixed an issue where callout blocks couldn’t be deleted when appearing as the first line in a document +- Fixed a bug preventing the relation field in databases from opening +- Fixed an issue where links in documents were unclickable on Linux + +## Version 0.8.4 - 18/02/2025 +### New Features +- Switch AI mode on mobile +- Support locking page +- Support uploading svg file as icon +- Support the slash, at, and plus menus on mobile +### Bug Fixes +- Gallery not rendering in row page +- Save image should not copy the image (mobile) +- Support exporting more content to markdown + +## Version 0.8.2 - 23/01/2025 +### New Features +- Customized database view icons +- Support for uploading images as custom icons +- Enabled selecting multiple AI messages to save into a document +- Added the ability to scale the app's display size on mobile +- Support for pasting image links without file extensions +### Bug Fixes +- Fixed an issue where pasting tables from other apps wasn't working +- Fixed homepage URL issues in Settings +- Fixed an issue where the 'Cancel' button was not visible on the Shortcuts page + +## Version 0.8.1 - 14/01/2025 +### New Features +- AI Chat Layout Options: Customize how AI responses appear with new layouts—List, Table, Image with Text, and Media Only +- DALL-E Integration: Generate stunning AI images from text prompts, now available in AI Chat +- Improved Desktop Search: Find what you need faster using keywords or by asking questions in natural language +- Self-Hosting: Configure web server URLs directly in Settings to enable features like Publish, Copy Link to Share, Custom URLs, and more +- Sidebar Enhancement: Drag to reorder your favorited pages in the Sidebar +- Mobile Table Resizing: Adjust column widths in Simple Tables by long pressing the column borders on mobile +### Bug Fixes +- Resolved an icon rendering issue in callout blocks, tab bars, and search results +- Enhanced image reliability: Retry functionality ensures images load successfully if the first attempt fails + +## Version 0.8.0 - 06/01/2025 +### Bug Fixes +- Fixed error displaying in the page style menu +- Fixed filter logic in the icon picker +- Fixed error displaying in the Favorite/Recent page +- Fixed the color picker displaying when tapping down +- Fixed icons not being supported in subpage blocks +- Fixed recent icon functionality in the space icon menu +- Fixed "Insert Below" not auto-scrolling the table +- Fixed a to-do item with an emoji automatically creating a soft break +- Fixed header row/column tap areas being too small +- Fixed simple table alignment not working for items that wrap +- Fixed web content reverting after removing the inline code format on desktop +- Fixed inability to make changes to a row or column in the table when opening a new tab +- Fixed changing the language to CKB-KU causing a gray screen on mobile + +## Version 0.7.9 - 30/12/2024 +### New Features +- Meet AppFlowy Web (Lite): Use AppFlowy directly in your browser. + - Create beautiful documents with 22 content types and markdown support + - Use Quick Note to save anything you want to remember—like meeting notes, a grocery list, or to-dos + - Invite members to your workspace for seamless collaboration + - Create multiple public/private spaces to better organize your content +- Simple Table is now available on Mobile, designed specifically for mobile devices. + - Create and manage Simple Table blocks on Mobile with easy-to-use action menus. + - Use the '+' button in the fixed toolbar to easily add a content block into a table cell on Mobile + - Use '/' to insert a content block into a table cell on Desktop +- Add pages as AI sources in AI chat, enabling you to ask questions about the selected sources +- Add messages to an editable document while chatting with AI side by side +- The new Emoji menu now includes Icons with a Recent section for quickly reusing emojis/icons +- Drag a page from the sidebar into a document to easily mention the page without typing its title +- Paste as plain text, a new option in the right-click paste menu +### Bug Fixes +- Fixed misalignment in numbered lists +- Resolved several bugs in the emoji menu +- Fixed a bug with checklist items + +## 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 @@ -70,7 +479,7 @@ - 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.19.0. +- 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 @@ -708,4 +1117,4 @@ Bug fixes and improvements - Increased height of action - 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 580fa98a48..565908e756 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@

- AppFlowy.IO
+ AppFlowy
⭐️ The Open Source Alternative To Notion ⭐️

-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,21 +18,37 @@ You are in charge of your data and customizations.

- Website • + Website • + ForumDiscord • + RedditTwitter

-

AppFlowy Docs & Notes & Wikis

-

AppFlowy Databases for Tasks and Projects

-

AppFlowy Kanban Board for To-Dos

-

AppFlowy Calendars for Plan and Manage Content

-

AppFlowy OpenAI GPT Writers

+

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://docs.appflowy.io/docs/appflowy/install-appflowy/installation-methods/mac-windows-linux-packages) -- [Docker](https://docs.appflowy.io/docs/appflowy/install-appflowy/installation-methods/installing-with-docker) +- [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://appflowy.com/docs/self-host-appflowy-overview) - [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source) ## Built With @@ -47,32 +63,41 @@ You are in charge of your data and customizations. ## Getting Started with development -Please view the [documentation](https://docs.appflowy.io/docs/documentation/appflowy/from-source) 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://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 a 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** -Please see the [changelog](https://www.appflowy.io/whatsnew) for more details about a given release. +Please see the [changelog](https://appflowy.com/what-is-new) for more details about a given release. ## Contributing -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. +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 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. +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. ## 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. +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 @@ -82,16 +107,30 @@ To add translations, you can manually edit the JSON translation files in `/front ## Why Are We Building This? -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. +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 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. +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, 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. +- 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: @@ -99,16 +138,20 @@ We decided to achieve this mission by upholding the three most fundamental value - Reliable native experience - Community-driven extensibility -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: +Special thanks to these amazing projects which help power AppFlowy: -- [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) diff --git a/codemagic.yaml b/codemagic.yaml new file mode 100644 index 0000000000..9ba2a1a562 --- /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.27.4 + 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 0000000000..d264c81695 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 0000000000..d9cdbe5fc1 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 0000000000..8c3c7658ff 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 0000000000..744fdf29dc 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 0000000000..d92c0295c6 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 0000000000..9e3cc52d92 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 0000000000..b39e03c251 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 0000000000..9083b80bed 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 72d398e0fa..d4ff85a2dd 100644 --- a/frontend/.vscode/launch.json +++ b/frontend/.vscode/launch.json @@ -1,141 +1,125 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - // 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", - "env": { - "RUST_LOG": "debug", - "RUST_BACKTRACE": "1" - }, - // uncomment the following line to testing performance. - // "flutterMode": "profile", - "cwd": "${workspaceRoot}/appflowy_flutter" - }, - { - // 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 Appflowy Core", - "env": { - "RUST_LOG": "trace", - "RUST_BACKTRACE": "1" - }, - "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-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" - }, - { - "name": "AF-iOS: Build All", - "request": "launch", - "program": "./lib/main.dart", - "type": "dart", - "preLaunchTask": "AF: Build Appflowy Core For iOS", - "env": { - "RUST_LOG": "trace" - }, - "cwd": "${workspaceRoot}/appflowy_flutter" - }, - { - "name": "AF-iOS: Clean + Rebuild All", - "request": "launch", - "program": "./lib/main.dart", - "type": "dart", - "preLaunchTask": "AF: Clean + Rebuild All (iOS)", - "env": { - "RUST_LOG": "trace" - }, - "cwd": "${workspaceRoot}/appflowy_flutter" - }, - { - "name": "AF-iOS-Simulator: Build All", - "request": "launch", - "program": "./lib/main.dart", - "type": "dart", - "preLaunchTask": "AF: Build Appflowy Core For iOS Simulator", - "env": { - "RUST_LOG": "trace" - }, - "cwd": "${workspaceRoot}/appflowy_flutter" - }, - { - "name": "AF-iOS-Simulator: Clean + Rebuild All", - "request": "launch", - "program": "./lib/main.dart", - "type": "dart", - "preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)", - "env": { - "RUST_LOG": "trace" - }, - "cwd": "${workspaceRoot}/appflowy_flutter" - }, - { - "name": "AF-Android: Build All", - "request": "launch", - "program": "./lib/main.dart", - "type": "dart", - "preLaunchTask": "AF: Build Appflowy Core For Android", - "env": { - "RUST_LOG": "trace" - }, - "cwd": "${workspaceRoot}/appflowy_flutter" - }, - { - "name": "AF-Android: Clean + Rebuild All", - "request": "launch", - "program": "./lib/main.dart", - "type": "dart", - "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 + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // 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", + "env": { + "RUST_LOG": "debug", + }, + // uncomment the following line to testing performance. + // "flutterMode": "profile", + "cwd": "${workspaceRoot}/appflowy_flutter" + }, + { + // 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 Appflowy Core", + "env": { + "RUST_LOG": "trace", + "RUST_BACKTRACE": "1" + }, + "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-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" + }, + { + "name": "AF-iOS: Build All", + "request": "launch", + "program": "./lib/main.dart", + "type": "dart", + "preLaunchTask": "AF: Build Appflowy Core For iOS", + "env": { + "RUST_LOG": "trace" + }, + "cwd": "${workspaceRoot}/appflowy_flutter" + }, + { + "name": "AF-iOS: Clean + Rebuild All", + "request": "launch", + "program": "./lib/main.dart", + "type": "dart", + "preLaunchTask": "AF: Clean + Rebuild All (iOS)", + "env": { + "RUST_LOG": "trace" + }, + "cwd": "${workspaceRoot}/appflowy_flutter" + }, + { + "name": "AF-iOS-Simulator: Build All", + "request": "launch", + "program": "./lib/main.dart", + "type": "dart", + "preLaunchTask": "AF: Build Appflowy Core For iOS Simulator", + "env": { + "RUST_LOG": "trace" + }, + "cwd": "${workspaceRoot}/appflowy_flutter" + }, + { + "name": "AF-iOS-Simulator: Clean + Rebuild All", + "request": "launch", + "program": "./lib/main.dart", + "type": "dart", + "preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)", + "env": { + "RUST_LOG": "trace" + }, + "cwd": "${workspaceRoot}/appflowy_flutter" + }, + { + "name": "AF-Android: Build All", + "request": "launch", + "program": "./lib/main.dart", + "type": "dart", + "preLaunchTask": "AF: Build Appflowy Core For Android", + "env": { + "RUST_LOG": "trace" + }, + "cwd": "${workspaceRoot}/appflowy_flutter" + }, + { + "name": "AF-Android: Clean + Rebuild All", + "request": "launch", + "program": "./lib/main.dart", + "type": "dart", + "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]", + }, + ] +} diff --git a/frontend/.vscode/tasks.json b/frontend/.vscode/tasks.json index d940eef0a8..0be167fb12 100644 --- a/frontend/.vscode/tasks.json +++ b/frontend/.vscode/tasks.json @@ -245,51 +245,6 @@ "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", diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 5c6950e179..41fdffb1af 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -APPFLOWY_VERSION = "0.5.6" +APPFLOWY_VERSION = "0.8.9" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" @@ -50,7 +50,6 @@ APP_ENVIRONMENT = "local" 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" -WEB_LIB_PATH = "appflowy_web/wasm-libs/af-wasm" TAURI_APP_BACKEND_SERVICE_PATH = "appflowy_web_app/src/application/services/tauri-services/backend" # Test default config TEST_CRATE_TYPE = "cdylib" diff --git a/frontend/appflowy_flutter/analysis_options.yaml b/frontend/appflowy_flutter/analysis_options.yaml index c8066c92a5..4579b2d8c5 100644 --- a/frontend/appflowy_flutter/analysis_options.yaml +++ b/frontend/appflowy_flutter/analysis_options.yaml @@ -1,32 +1,12 @@ -# 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/**/*.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 @@ -51,8 +31,5 @@ linter: - sort_constructors_first - unawaited_futures -# 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/appflowy_flutter/android/app/build.gradle b/frontend/appflowy_flutter/android/app/build.gradle index d14c7016c1..0b96e32472 100644 --- a/frontend/appflowy_flutter/android/app/build.gradle +++ b/frontend/appflowy_flutter/android/app/build.gradle @@ -52,8 +52,8 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "io.appflowy.appflowy" - minSdkVersion 23 - targetSdkVersion 33 + minSdkVersion 29 + targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName multiDexEnabled true @@ -87,6 +87,13 @@ android { path "src/main/CMakeLists.txt" } } + + // only support arm64-v8a + defaultConfig { + ndk { + abiFilters "arm64-v8a" + } + } } flutter { diff --git a/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml b/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml index 351994354d..f746eeb610 100644 --- a/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml +++ b/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml @@ -36,7 +36,6 @@ - + - + @@ -58,4 +60,12 @@ + + + + \ No newline at end of file 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 0000000000..c691e14bdc 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/res/drawable/launch_background.xml b/frontend/appflowy_flutter/android/app/src/main/res/drawable/launcher_background.xml similarity index 100% rename from frontend/appflowy_flutter/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 0000000000..c7ec6fdd6f --- /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 0000000000..ba42ab6878 --- /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 0000000000..036d09bc5f --- /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 index b00c03fd17..911ee844c7 100644 Binary files a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png 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 0000000000..1b466c0eb2 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 0000000000..56ea852799 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 0000000000..f4d14c0d60 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 index e76d95c5be..fe7a94797a 100644 Binary files a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png 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 0000000000..15fb3c4ddf 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 0000000000..63fa775f58 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 0000000000..fda3c7fa3e 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 index c5188d2de4..61e49810e8 100644 Binary files a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png 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 0000000000..132a0e9ff0 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 0000000000..f9e393537d 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 0000000000..8efe0ff281 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 index 3cc1a254c9..be4cf46069 100644 Binary files a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png 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 0000000000..95a312fbc5 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 0000000000..a63acece70 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 0000000000..727cb0c58a 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 index c8f21cf1b3..c9e8059fe3 100644 Binary files a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png 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 0000000000..d5ce932756 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 0000000000..ad1543e064 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 0000000000..010733d23d 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/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 0000000000..c5d5899fdf --- /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/appflowy_flutter/assets/fonts/.gitkeep b/frontend/appflowy_flutter/assets/fonts/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf b/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf deleted file mode 100644 index 8f03a5c8f9..0000000000 Binary files a/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf and /dev/null differ diff --git a/frontend/appflowy_flutter/assets/icons/icons.json b/frontend/appflowy_flutter/assets/icons/icons.json new file mode 100644 index 0000000000..4ad858c414 --- /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/appearance/dark.png b/frontend/appflowy_flutter/assets/images/appearance/dark.png new file mode 100644 index 0000000000..f40e6a884b 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 0000000000..49f32bf3aa 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 0000000000..4097cae1ed Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/appearance/system.png differ diff --git a/frontend/appflowy_flutter/assets/test/images/sample.svg b/frontend/appflowy_flutter/assets/test/images/sample.svg new file mode 100644 index 0000000000..7dcd6907d8 --- /dev/null +++ b/frontend/appflowy_flutter/assets/test/images/sample.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file 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 0000000000..9c497cff5d --- /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/translations/mr-IN.json b/frontend/appflowy_flutter/assets/translations/mr-IN.json new file mode 100644 index 0000000000..f86a1e0081 --- /dev/null +++ b/frontend/appflowy_flutter/assets/translations/mr-IN.json @@ -0,0 +1,3210 @@ +{ + "appName": "AppFlowy", + "defaultUsername": "मी", + "welcomeText": "@:appName मध्ये आ पले स्वागत आहे.", + "welcomeTo": "मध्ये आ पले स्वागत आ हे", + "githubStarText": "GitHub वर स्टार करा", + "subscribeNewsletterText": "वृत्तपत्राची सदस्यता घ्या", + "letsGoButtonText": "क्विक स्टार्ट", + "title": "Title", + "youCanAlso": "तुम्ही देखील", + "and": "आ णि", + "failedToOpenUrl": "URL उघडण्यात अयशस्वी: {}", + "blockActions": { + "addBelowTooltip": "खाली जोडण्यासाठी क्लिक करा", + "addAboveCmd": "Alt+click", + "addAboveMacCmd": "Option+click", + "addAboveTooltip": "वर जोडण्यासाठी", + "dragTooltip": "Drag to move", + "openMenuTooltip": "मेनू उघडण्यासाठी क्लिक करा" + }, + "signUp": { + "buttonText": "साइन अप", + "title": "साइन अप to @:appName", + "getStartedText": "सुरुवात करा", + "emptyPasswordError": "पासवर्ड रिकामा असू शकत नाही", + "repeatPasswordEmptyError": "Repeat पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "emailHint": "Email", + "passwordHint": "Password", + "repeatPasswordHint": "पासवर्ड पुन्हा लिहा", + "signUpWith": "यामध्ये साइन अप करा:" + }, + "signIn": { + "loginTitle": "@:appName मध्ये लॉगिन करा", + "loginButtonText": "लॉगिन", + "loginStartWithAnonymous": "अनामिक सत्रासह पुढे जा", + "continueAnonymousUser": "अनामिक सत्रासह पुढे जा", + "anonymous": "अनामिक", + "buttonText": "साइन इन", + "signingInText": "साइन इन होत आहे...", + "forgotPassword": "पासवर्ड विसरलात?", + "emailHint": "ईमेल", + "passwordHint": "पासवर्ड", + "dontHaveAnAccount": "तुमचं खाते नाही?", + "createAccount": "खाते तयार करा", + "repeatPasswordEmptyError": "पुन्हा पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "syncPromptMessage": "डेटा सिंक होण्यास थोडा वेळ लागू शकतो. कृपया हे पृष्ठ बंद करू नका", + "or": "किंवा", + "signInWithGoogle": "Google सह पुढे जा", + "signInWithGithub": "GitHub सह पुढे जा", + "signInWithDiscord": "Discord सह पुढे जा", + "signInWithApple": "Apple सह पुढे जा", + "continueAnotherWay": "इतर पर्यायांनी पुढे जा", + "signUpWithGoogle": "Google सह साइन अप करा", + "signUpWithGithub": "GitHub सह साइन अप करा", + "signUpWithDiscord": "Discord सह साइन अप करा", + "signInWith": "यासह पुढे जा:", + "signInWithEmail": "ईमेलसह पुढे जा", + "signInWithMagicLink": "पुढे जा", + "signUpWithMagicLink": "Magic Link सह साइन अप करा", + "pleaseInputYourEmail": "कृपया तुमचा ईमेल पत्ता टाका", + "settings": "सेटिंग्ज", + "magicLinkSent": "Magic Link पाठवण्यात आली आहे!", + "invalidEmail": "कृपया वैध ईमेल पत्ता टाका", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "logIn": "लॉगिन", + "generalError": "काहीतरी चुकलं. कृपया नंतर प्रयत्न करा", + "limitRateError": "सुरक्षेच्या कारणास्तव, तुम्ही दर ६० सेकंदांतून एकदाच Magic Link मागवू शकता", + "magicLinkSentDescription": "तुमच्या ईमेलवर Magic Link पाठवण्यात आली आहे. लॉगिन पूर्ण करण्यासाठी लिंकवर क्लिक करा. ही लिंक ५ मिनिटांत कालबाह्य होईल." + }, + "workspace": { + "chooseWorkspace": "तुमचे workspace निवडा", + "defaultName": "माझे Workspace", + "create": "नवीन workspace तयार करा", + "new": "नवीन workspace", + "importFromNotion": "Notion मधून आयात करा", + "learnMore": "अधिक जाणून घ्या", + "reset": "workspace रीसेट करा", + "renameWorkspace": "workspace चे नाव बदला", + "workspaceNameCannotBeEmpty": "workspace चे नाव रिकामे असू शकत नाही", + "resetWorkspacePrompt": "workspace रीसेट केल्यास त्यातील सर्व पृष्ठे आणि डेटा हटवले जातील. तुम्हाला workspace रीसेट करायचे आहे का? पर्यायी म्हणून तुम्ही सपोर्ट टीमशी संपर्क करू शकता.", + "hint": "workspace", + "notFoundError": "workspace सापडले नाही", + "failedToLoad": "काहीतरी चूक झाली! workspace लोड होण्यात अयशस्वी. कृपया @:appName चे कोणतेही उघडे instance बंद करा आणि पुन्हा प्रयत्न करा.", + "errorActions": { + "reportIssue": "समस्या नोंदवा", + "reportIssueOnGithub": "Github वर समस्या नोंदवा", + "exportLogFiles": "लॉग फाइल्स निर्यात करा", + "reachOut": "Discord वर संपर्क करा" + }, + "menuTitle": "कार्यक्षेत्रे", + "deleteWorkspaceHintText": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील.", + "createSuccess": "कार्यक्षेत्र यशस्वीरित्या तयार झाले", + "createFailed": "कार्यक्षेत्र तयार करण्यात अयशस्वी", + "createLimitExceeded": "तुम्ही तुमच्या खात्यासाठी परवानगी दिलेल्या कार्यक्षेत्र मर्यादेपर्यंत पोहोचलात. अधिक कार्यक्षेत्रासाठी GitHub वर विनंती करा.", + "deleteSuccess": "कार्यक्षेत्र यशस्वीरित्या हटवले गेले", + "deleteFailed": "कार्यक्षेत्र हटवण्यात अयशस्वी", + "openSuccess": "कार्यक्षेत्र यशस्वीरित्या उघडले", + "openFailed": "कार्यक्षेत्र उघडण्यात अयशस्वी", + "renameSuccess": "कार्यक्षेत्राचे नाव यशस्वीरित्या बदलले", + "renameFailed": "कार्यक्षेत्राचे नाव बदलण्यात अयशस्वी", + "updateIconSuccess": "कार्यक्षेत्राचे चिन्ह यशस्वीरित्या अद्यतनित केले", + "updateIconFailed": "कार्यक्षेत्राचे चिन्ह अद्यतनित करण्यात अयशस्वी", + "cannotDeleteTheOnlyWorkspace": "फक्त एकच कार्यक्षेत्र असल्यास ते हटवता येत नाही", + "fetchWorkspacesFailed": "कार्यक्षेत्रे मिळवण्यात अयशस्वी", + "leaveCurrentWorkspace": "कार्यक्षेत्र सोडा", + "leaveCurrentWorkspacePrompt": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का?" + }, + "shareAction": { + "buttonText": "शेअर करा", + "workInProgress": "लवकरच येत आहे", + "markdown": "Markdown", + "html": "HTML", + "clipboard": "क्लिपबोर्डवर कॉपी करा", + "csv": "CSV", + "copyLink": "लिंक कॉपी करा", + "publishToTheWeb": "वेबवर प्रकाशित करा", + "publishToTheWebHint": "AppFlowy सह वेबसाइट तयार करा", + "publish": "प्रकाशित करा", + "unPublish": "अप्रकाशित करा", + "visitSite": "साइटला भेट द्या", + "exportAsTab": "या स्वरूपात निर्यात करा", + "publishTab": "प्रकाशित करा", + "shareTab": "शेअर करा", + "publishOnAppFlowy": "AppFlowy वर प्रकाशित करा", + "shareTabTitle": "सहकार्य करण्यासाठी आमंत्रित करा", + "shareTabDescription": "कोणासही सहज सहकार्य करण्यासाठी", + "copyLinkSuccess": "लिंक क्लिपबोर्डवर कॉपी केली", + "copyShareLink": "शेअर लिंक कॉपी करा", + "copyLinkFailed": "लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "copyLinkToBlockSuccess": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी केली", + "copyLinkToBlockFailed": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "manageAllSites": "सर्व साइट्स व्यवस्थापित करा", + "updatePathName": "पथाचे नाव अपडेट करा" + }, + "moreAction": { + "small": "लहान", + "medium": "मध्यम", + "large": "मोठा", + "fontSize": "फॉन्ट आकार", + "import": "Import", + "moreOptions": "अधिक पर्याय", + "wordCount": "शब्द संख्या: {}", + "charCount": "अक्षर संख्या: {}", + "createdAt": "निर्मिती: {}", + "deleteView": "हटवा", + "duplicateView": "प्रत बनवा", + "wordCountLabel": "शब्द संख्या: ", + "charCountLabel": "अक्षर संख्या: ", + "createdAtLabel": "निर्मिती: ", + "syncedAtLabel": "सिंक केले: ", + "saveAsNewPage": "संदेश पृष्ठात जोडा", + "saveAsNewPageDisabled": "उपलब्ध संदेश नाहीत" + }, + "importPanel": { + "textAndMarkdown": "मजकूर आणि Markdown", + "documentFromV010": "v0.1.0 पासून दस्तऐवज", + "databaseFromV010": "v0.1.0 पासून डेटाबेस", + "notionZip": "Notion निर्यात केलेली Zip फाईल", + "csv": "CSV", + "database": "डेटाबेस" + }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "फाईल ड्रॅग आणि ड्रॉप करा, क्लिक करा ", + "placeholderUpload": "अपलोड", + "placeholderRight": ", किंवा इमेज लिंक पेस्ट करा.", + "dropToUpload": "अपलोडसाठी फाईल ड्रॉप करा", + "change": "बदला" + } + }, + "disclosureAction": { + "rename": "नाव बदला", + "delete": "हटवा", + "duplicate": "प्रत बनवा", + "unfavorite": "आवडतीतून काढा", + "favorite": "आवडतीत जोडा", + "openNewTab": "नवीन टॅबमध्ये उघडा", + "moveTo": "या ठिकाणी हलवा", + "addToFavorites": "आवडतीत जोडा", + "copyLink": "लिंक कॉपी करा", + "changeIcon": "आयकॉन बदला", + "collapseAllPages": "सर्व उपपृष्ठे संकुचित करा", + "movePageTo": "पृष्ठ हलवा", + "move": "हलवा", + "lockPage": "पृष्ठ लॉक करा" + }, + "blankPageTitle": "रिक्त पृष्ठ", + "newPageText": "नवीन पृष्ठ", + "newDocumentText": "नवीन दस्तऐवज", + "newGridText": "नवीन ग्रिड", + "newCalendarText": "नवीन कॅलेंडर", + "newBoardText": "नवीन बोर्ड", + "chat": { + "newChat": "AI गप्पा", + "inputMessageHint": "@:appName AI ला विचार करा", + "inputLocalAIMessageHint": "@:appName लोकल AI ला विचार करा", + "unsupportedCloudPrompt": "ही सुविधा फक्त @:appName Cloud वापरताना उपलब्ध आहे", + "relatedQuestion": "सूचवलेले", + "serverUnavailable": "कनेक्शन गमावले. कृपया तुमचे इंटरनेट तपासा आणि पुन्हा प्रयत्न करा", + "aiServerUnavailable": "AI सेवा सध्या अनुपलब्ध आहे. कृपया नंतर पुन्हा प्रयत्न करा.", + "retry": "पुन्हा प्रयत्न करा", + "clickToRetry": "पुन्हा प्रयत्न करण्यासाठी क्लिक करा", + "regenerateAnswer": "उत्तर पुन्हा तयार करा", + "question1": "Kanban वापरून कामे कशी व्यवस्थापित करायची", + "question2": "GTD पद्धत समजावून सांगा", + "question3": "Rust का वापरावा", + "question4": "माझ्या स्वयंपाकघरात असलेल्या वस्तूंनी रेसिपी", + "question5": "माझ्या पृष्ठासाठी एक चित्र तयार करा", + "question6": "या आठवड्याची माझी कामांची यादी तयार करा", + "aiMistakePrompt": "AI चुकू शकतो. महत्त्वाची माहिती तपासा.", + "chatWithFilePrompt": "तुम्हाला फाइलसोबत गप्पा मारायच्या आहेत का?", + "indexFileSuccess": "फाईल यशस्वीरित्या अनुक्रमित केली गेली", + "inputActionNoPages": "काहीही पृष्ठे सापडली नाहीत", + "referenceSource": { + "zero": "0 स्रोत सापडले", + "one": "{count} स्रोत सापडला", + "other": "{count} स्रोत सापडले" + } + }, + "clickToMention": "पृष्ठाचा उल्लेख करा", + "uploadFile": "PDFs, मजकूर किंवा markdown फाइल्स जोडा", + "questionDetail": "नमस्कार {}! मी तुम्हाला कशी मदत करू शकतो?", + "indexingFile": "{} अनुक्रमित करत आहे", + "generatingResponse": "उत्तर तयार होत आहे", + "selectSources": "स्रोत निवडा", + "currentPage": "सध्याचे पृष्ठ", + "sourcesLimitReached": "तुम्ही फक्त ३ मुख्य दस्तऐवज आणि त्यांची उपदस्तऐवज निवडू शकता", + "sourceUnsupported": "सध्या डेटाबेससह चॅटिंगसाठी आम्ही समर्थन करत नाही", + "regenerate": "पुन्हा प्रयत्न करा", + "addToPageButton": "संदेश पृष्ठावर जोडा", + "addToPageTitle": "या पृष्ठात संदेश जोडा...", + "addToNewPage": "नवीन पृष्ठ तयार करा", + "addToNewPageName": "\"{}\" मधून काढलेले संदेश", + "addToNewPageSuccessToast": "संदेश जोडण्यात आला", + "openPagePreviewFailedToast": "पृष्ठ उघडण्यात अयशस्वी", + "changeFormat": { + "actionButton": "फॉरमॅट बदला", + "confirmButton": "या फॉरमॅटसह पुन्हा तयार करा", + "textOnly": "मजकूर", + "imageOnly": "फक्त प्रतिमा", + "textAndImage": "मजकूर आणि प्रतिमा", + "text": "परिच्छेद", + "bullet": "बुलेट यादी", + "number": "क्रमांकित यादी", + "table": "सारणी", + "blankDescription": "उत्तराचे फॉरमॅट ठरवा", + "defaultDescription": "स्वयंचलित उत्तर फॉरमॅट", + "textWithImageDescription": "@:chat.changeFormat.text प्रतिमेसह", + "numberWithImageDescription": "@:chat.changeFormat.number प्रतिमेसह", + "bulletWithImageDescription": "@:chat.changeFormat.bullet प्रतिमेसह", + " tableWithImageDescription": "@:chat.changeFormat.table प्रतिमेसह" + }, + "switchModel": { + "label": "मॉडेल बदला", + "localModel": "स्थानिक मॉडेल", + "cloudModel": "क्लाऊड मॉडेल", + "autoModel": "स्वयंचलित" + }, + "selectBanner": { + "saveButton": "… मध्ये जोडा", + "selectMessages": "संदेश निवडा", + "nSelected": "{} निवडले गेले", + "allSelected": "सर्व निवडले गेले" + }, + "stopTooltip": "उत्पन्न करणे थांबवा", + "trash": { + "text": "कचरा", + "restoreAll": "सर्व पुनर्संचयित करा", + "restore": "पुनर्संचयित करा", + "deleteAll": "सर्व हटवा", + "pageHeader": { + "fileName": "फाईलचे नाव", + "lastModified": "शेवटचा बदल", + "created": "निर्मिती" + } + }, + "confirmDeleteAll": { + "title": "कचरापेटीतील सर्व पृष्ठे", + "caption": "तुम्हाला कचरापेटीतील सर्व काही हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "confirmRestoreAll": { + "title": "कचरापेटीतील सर्व पृष्ठे पुनर्संचयित करा", + "caption": "ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "restorePage": { + "title": "पुनर्संचयित करा: {}", + "caption": "तुम्हाला हे पृष्ठ पुनर्संचयित करायचे आहे का?" + }, + "mobile": { + "actions": "कचरा क्रिया", + "empty": "कचरापेटीत कोणतीही पृष्ठे किंवा जागा नाहीत", + "emptyDescription": "जे आवश्यक नाही ते कचरापेटीत हलवा.", + "isDeleted": "हटवले गेले आहे", + "isRestored": "पुनर्संचयित केले गेले आहे" + }, + "confirmDeleteTitle": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का?", + "deletePagePrompt": { + "text": "हे पृष्ठ कचरापेटीत आहे", + "restore": "पृष्ठ पुनर्संचयित करा", + "deletePermanent": "कायमचे हटवा", + "deletePermanentDescription": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "dialogCreatePageNameHint": "पृष्ठाचे नाव", + "questionBubble": { + "shortcuts": "शॉर्टकट्स", + "whatsNew": "नवीन काय आहे?", + "help": "मदत आणि समर्थन", + "markdown": "Markdown", + "debug": { + "name": "डीबग माहिती", + "success": "डीबग माहिती क्लिपबोर्डवर कॉपी केली!", + "fail": "डीबग माहिती कॉपी करता आली नाही" + }, + "feedback": "अभिप्राय" + }, + "menuAppHeader": { + "moreButtonToolTip": "हटवा, नाव बदला आणि अधिक...", + "addPageTooltip": "तत्काळ एक पृष्ठ जोडा", + "defaultNewPageName": "शीर्षक नसलेले", + "renameDialog": "नाव बदला", + "pageNameSuffix": "प्रत" + }, + "noPagesInside": "अंदर कोणतीही पृष्ठे नाहीत", + "toolbar": { + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "bold": "ठळक", + "italic": "तिरकस", + "underline": "अधोरेखित", + "strike": "मागे ओढलेले", + "numList": "क्रमांकित यादी", + "bulletList": "बुलेट यादी", + "checkList": "चेक यादी", + "inlineCode": "इनलाइन कोड", + "quote": "उद्धरण ब्लॉक", + "header": "शीर्षक", + "highlight": "हायलाइट", + "color": "रंग", + "addLink": "लिंक जोडा" + }, + "tooltip": { + "lightMode": "लाइट मोडमध्ये स्विच करा", + "darkMode": "डार्क मोडमध्ये स्विच करा", + "openAsPage": "पृष्ठ म्हणून उघडा", + "addNewRow": "नवीन पंक्ती जोडा", + "openMenu": "मेनू उघडण्यासाठी क्लिक करा", + "dragRow": "पंक्तीचे स्थान बदलण्यासाठी ड्रॅग करा", + "viewDataBase": "डेटाबेस पहा", + "referencePage": "हे {name} संदर्भित आहे", + "addBlockBelow": "खाली एक ब्लॉक जोडा", + "aiGenerate": "निर्मिती करा" + }, + "sideBar": { + "closeSidebar": "साइडबार बंद करा", + "openSidebar": "साइडबार उघडा", + "expandSidebar": "पूर्ण पृष्ठावर विस्तारित करा", + "personal": "वैयक्तिक", + "private": "खाजगी", + "workspace": "कार्यक्षेत्र", + "favorites": "आवडती", + "clickToHidePrivate": "खाजगी जागा लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने फक्त तुम्हाला दिसतील", + "clickToHideWorkspace": "कार्यक्षेत्र लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने सर्व सदस्यांना दिसतील", + "clickToHidePersonal": "वैयक्तिक जागा लपवण्यासाठी क्लिक करा", + "clickToHideFavorites": "आवडती जागा लपवण्यासाठी क्लिक करा", + "addAPage": "नवीन पृष्ठ जोडा", + "addAPageToPrivate": "खाजगी जागेत पृष्ठ जोडा", + "addAPageToWorkspace": "कार्यक्षेत्रात पृष्ठ जोडा", + "recent": "अलीकडील", + "today": "आज", + "thisWeek": "या आठवड्यात", + "others": "पूर्वीच्या आवडती", + "earlier": "पूर्वीचे", + "justNow": "आत्ताच", + "minutesAgo": "{count} मिनिटांपूर्वी", + "lastViewed": "शेवटी पाहिलेले", + "favoriteAt": "आवडते म्हणून चिन्हांकित", + "emptyRecent": "अलीकडील पृष्ठे नाहीत", + "emptyRecentDescription": "तुम्ही पाहिलेली पृष्ठे येथे सहज पुन्हा मिळवण्यासाठी दिसतील.", + "emptyFavorite": "आवडती पृष्ठे नाहीत", + "emptyFavoriteDescription": "पृष्ठांना आवडते म्हणून चिन्हांकित करा—ते येथे झपाट्याने प्रवेशासाठी दिसतील!", + "removePageFromRecent": "हे पृष्ठ अलीकडील यादीतून काढायचे?", + "removeSuccess": "यशस्वीरित्या काढले गेले", + "favoriteSpace": "आवडती", + "RecentSpace": "अलीकडील", + "Spaces": "जागा", + "upgradeToPro": "Pro मध्ये अपग्रेड करा", + "upgradeToAIMax": "अमर्यादित AI अनलॉक करा", + "storageLimitDialogTitle": "तुमचा मोफत स्टोरेज संपला आहे. अमर्यादित स्टोरेजसाठी अपग्रेड करा", + "storageLimitDialogTitleIOS": "तुमचा मोफत स्टोरेज संपला आहे.", + "aiResponseLimitTitle": "तुमचे मोफत AI प्रतिसाद संपले आहेत. कृपया Pro प्लॅनला अपग्रेड करा किंवा AI add-on खरेदी करा", + "aiResponseLimitDialogTitle": "AI प्रतिसाद मर्यादा गाठली आहे", + "aiResponseLimit": "तुमचे मोफत AI प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max किंवा Pro प्लॅन क्लिक करा", + "askOwnerToUpgradeToPro": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे. कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा", + "askOwnerToUpgradeToProIOS": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे.", + "askOwnerToUpgradeToAIMax": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला प्लॅन अपग्रेड किंवा AI add-ons खरेदी करण्यास सांगा", + "askOwnerToUpgradeToAIMaxIOS": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत.", + "purchaseAIMax": "तुमच्या कार्यक्षेत्राचे AI प्रतिमा प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला AI Max खरेदी करण्यास सांगा", + "aiImageResponseLimit": "तुमचे AI प्रतिमा प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max क्लिक करा", + "purchaseStorageSpace": "स्टोरेज स्पेस खरेदी करा", + "singleFileProPlanLimitationDescription": "तुम्ही मोफत प्लॅनमध्ये परवानगी दिलेल्या फाइल अपलोड आकाराची मर्यादा ओलांडली आहे. कृपया Pro प्लॅनमध्ये अपग्रेड करा", + "purchaseAIResponse": "AI प्रतिसाद खरेदी करा", + "askOwnerToUpgradeToLocalAI": "कार्यक्षेत्र मालकाला ऑन-डिव्हाइस AI सक्षम करण्यास सांगा", + "upgradeToAILocal": "अत्याधिक गोपनीयतेसाठी तुमच्या डिव्हाइसवर लोकल मॉडेल चालवा", + "upgradeToAILocalDesc": "PDFs सोबत गप्पा मारा, तुमचे लेखन सुधारा आणि लोकल AI वापरून टेबल्स आपोआप भरा" +}, + "notifications": { + "export": { + "markdown": "टीप Markdown मध्ये निर्यात केली", + "path": "Documents/flowy" + } + }, + "contactsPage": { + "title": "संपर्क", + "whatsHappening": "या आठवड्यात काय घडत आहे?", + "addContact": "संपर्क जोडा", + "editContact": "संपर्क संपादित करा" + }, + "button": { + "ok": "ठीक आहे", + "confirm": "खात्री करा", + "done": "पूर्ण", + "cancel": "रद्द करा", + "signIn": "साइन इन", + "signOut": "साइन आउट", + "complete": "पूर्ण करा", + "save": "जतन करा", + "generate": "निर्माण करा", + "esc": "ESC", + "keep": "ठेवा", + "tryAgain": "पुन्हा प्रयत्न करा", + "discard": "टाका", + "replace": "बदला", + "insertBelow": "खाली घाला", + "insertAbove": "वर घाला", + "upload": "अपलोड करा", + "edit": "संपादित करा", + "delete": "हटवा", + "copy": "कॉपी करा", + "duplicate": "प्रत बनवा", + "putback": "परत ठेवा", + "update": "अद्यतनित करा", + "share": "शेअर करा", + "removeFromFavorites": "आवडतीतून काढा", + "removeFromRecent": "अलीकडील यादीतून काढा", + "addToFavorites": "आवडतीत जोडा", + "favoriteSuccessfully": "आवडतीत यशस्वीरित्या जोडले", + "unfavoriteSuccessfully": "आवडतीतून यशस्वीरित्या काढले", + "duplicateSuccessfully": "प्रत यशस्वीरित्या तयार झाली", + "rename": "नाव बदला", + "helpCenter": "मदत केंद्र", + "add": "जोड़ा", + "yes": "होय", + "no": "नाही", + "clear": "साफ करा", + "remove": "काढा", + "dontRemove": "काढू नका", + "copyLink": "लिंक कॉपी करा", + "align": "जुळवा", + "login": "लॉगिन", + "logout": "लॉगआउट", + "deleteAccount": "खाते हटवा", + "back": "मागे", + "signInGoogle": "Google सह पुढे जा", + "signInGithub": "GitHub सह पुढे जा", + "signInDiscord": "Discord सह पुढे जा", + "more": "अधिक", + "create": "तयार करा", + "close": "बंद करा", + "next": "पुढे", + "previous": "मागील", + "submit": "सबमिट करा", + "download": "डाउनलोड करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "viewing": "पाहत आहात", + "editing": "संपादन करत आहात", + "gotIt": "समजले", + "retry": "पुन्हा प्रयत्न करा", + "uploadFailed": "अपलोड अयशस्वी.", + "copyLinkOriginal": "मूळ दुव्याची कॉपी करा" + }, + "label": { + "welcome": "स्वागत आहे!", + "firstName": "पहिले नाव", + "middleName": "मधले नाव", + "lastName": "आडनाव", + "stepX": "पायरी {X}" + }, + "oAuth": { + "err": { + "failedTitle": "तुमच्या खात्याशी कनेक्ट होता आले नाही.", + "failedMsg": "कृपया खात्री करा की तुम्ही ब्राउझरमध्ये साइन-इन प्रक्रिया पूर्ण केली आहे." + }, + "google": { + "title": "GOOGLE साइन-इन", + "instruction1": "तुमचे Google Contacts आयात करण्यासाठी, तुम्हाला तुमच्या वेब ब्राउझरचा वापर करून या अ‍ॅप्लिकेशनला अधिकृत करणे आवश्यक आहे.", + "instruction2": "ही कोड आयकॉनवर क्लिक करून किंवा मजकूर निवडून क्लिपबोर्डवर कॉपी करा:", + "instruction3": "तुमच्या वेब ब्राउझरमध्ये खालील दुव्यावर जा आणि वरील कोड टाका:", + "instruction4": "साइनअप पूर्ण झाल्यावर खालील बटण क्लिक करा:" + } + }, + "settings": { + "title": "सेटिंग्ज", + "popupMenuItem": { + "settings": "सेटिंग्ज", + "members": "सदस्य", + "trash": "कचरा", + "helpAndSupport": "मदत आणि समर्थन" + }, + "sites": { + "title": "साइट्स", + "namespaceTitle": "नेमस्पेस", + "namespaceDescription": "तुमचा नेमस्पेस आणि मुख्यपृष्ठ व्यवस्थापित करा", + "namespaceHeader": "नेमस्पेस", + "homepageHeader": "मुख्यपृष्ठ", + "updateNamespace": "नेमस्पेस अद्यतनित करा", + "removeHomepage": "मुख्यपृष्ठ हटवा", + "selectHomePage": "एक पृष्ठ निवडा", + "clearHomePage": "या नेमस्पेससाठी मुख्यपृष्ठ साफ करा", + "customUrl": "स्वतःची URL", + "namespace": { + "description": "हे बदल सर्व प्रकाशित पृष्ठांवर लागू होतील जे या नेमस्पेसवर चालू आहेत", + "tooltip": "कोणताही अनुचित नेमस्पेस आम्ही काढून टाकण्याचा अधिकार राखून ठेवतो", + "updateExistingNamespace": "विद्यमान नेमस्पेस अद्यतनित करा", + "upgradeToPro": "मुख्यपृष्ठ सेट करण्यासाठी Pro प्लॅनमध्ये अपग्रेड करा", + "redirectToPayment": "पेमेंट पृष्ठावर वळवत आहे...", + "onlyWorkspaceOwnerCanSetHomePage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ सेट करू शकतो", + "pleaseAskOwnerToSetHomePage": "कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा" + }, + "publishedPage": { + "title": "सर्व प्रकाशित पृष्ठे", + "description": "तुमची प्रकाशित पृष्ठे व्यवस्थापित करा", + "page": "पृष्ठ", + "pathName": "पथाचे नाव", + "date": "प्रकाशन तारीख", + "emptyHinText": "या कार्यक्षेत्रात तुमच्याकडे कोणतीही प्रकाशित पृष्ठे नाहीत", + "noPublishedPages": "प्रकाशित पृष्ठे नाहीत", + "settings": "प्रकाशन सेटिंग्ज", + "clickToOpenPageInApp": "पृष्ठ अ‍ॅपमध्ये उघडा", + "clickToOpenPageInBrowser": "पृष्ठ ब्राउझरमध्ये उघडा" + } + } + }, + "error": { + "failedToGeneratePaymentLink": "Pro प्लॅनसाठी पेमेंट लिंक तयार करण्यात अयशस्वी", + "failedToUpdateNamespace": "नेमस्पेस अद्यतनित करण्यात अयशस्वी", + "proPlanLimitation": "नेमस्पेस अद्यतनित करण्यासाठी तुम्हाला Pro प्लॅनमध्ये अपग्रेड करणे आवश्यक आहे", + "namespaceAlreadyInUse": "नेमस्पेस आधीच वापरात आहे, कृपया दुसरे प्रयत्न करा", + "invalidNamespace": "अवैध नेमस्पेस, कृपया दुसरे प्रयत्न करा", + "namespaceLengthAtLeast2Characters": "नेमस्पेस किमान २ अक्षरे लांब असावे", + "onlyWorkspaceOwnerCanUpdateNamespace": "फक्त कार्यक्षेत्र मालकच नेमस्पेस अद्यतनित करू शकतो", + "onlyWorkspaceOwnerCanRemoveHomepage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ हटवू शकतो", + "setHomepageFailed": "मुख्यपृष्ठ सेट करण्यात अयशस्वी", + "namespaceTooLong": "नेमस्पेस खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "namespaceTooShort": "नेमस्पेस खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "namespaceIsReserved": "हा नेमस्पेस राखीव आहे, कृपया दुसरे प्रयत्न करा", + "updatePathNameFailed": "पथाचे नाव अद्यतनित करण्यात अयशस्वी", + "removeHomePageFailed": "मुख्यपृष्ठ हटवण्यात अयशस्वी", + "publishNameContainsInvalidCharacters": "पथाच्या नावामध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishNameTooShort": "पथाचे नाव खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "publishNameTooLong": "पथाचे नाव खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "publishNameAlreadyInUse": "हे पथाचे नाव आधीच वापरले गेले आहे, कृपया दुसरे प्रयत्न करा", + "namespaceContainsInvalidCharacters": "नेमस्पेसमध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishPermissionDenied": "फक्त कार्यक्षेत्र मालक किंवा पृष्ठ प्रकाशकच प्रकाशन सेटिंग्ज व्यवस्थापित करू शकतो", + "publishNameCannotBeEmpty": "पथाचे नाव रिकामे असू शकत नाही, कृपया दुसरे प्रयत्न करा" + }, + "success": { + "namespaceUpdated": "नेमस्पेस यशस्वीरित्या अद्यतनित केला", + "setHomepageSuccess": "मुख्यपृष्ठ यशस्वीरित्या सेट केले", + "updatePathNameSuccess": "पथाचे नाव यशस्वीरित्या अद्यतनित केले", + "removeHomePageSuccess": "मुख्यपृष्ठ यशस्वीरित्या हटवले" + }, + "accountPage": { + "menuLabel": "खाते आणि अ‍ॅप", + "title": "माझे खाते", + "general": { + "title": "खात्याचे नाव आणि प्रोफाइल प्रतिमा", + "changeProfilePicture": "प्रोफाइल प्रतिमा बदला" + }, + "email": { + "title": "ईमेल", + "actions": { + "change": "ईमेल बदला" + } + }, + "login": { + "title": "खाते लॉगिन", + "loginLabel": "लॉगिन", + "logoutLabel": "लॉगआउट" + }, + "isUpToDate": "@:appName अद्ययावत आहे!", + "officialVersion": "आवृत्ती {version} (अधिकृत बिल्ड)" +}, + "workspacePage": { + "menuLabel": "कार्यक्षेत्र", + "title": "कार्यक्षेत्र", + "description": "तुमचे कार्यक्षेत्र स्वरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक/वेळ फॉरमॅट आणि भाषा सानुकूलित करा.", + "workspaceName": { + "title": "कार्यक्षेत्राचे नाव" + }, + "workspaceIcon": { + "title": "कार्यक्षेत्राचे चिन्ह", + "description": "तुमच्या कार्यक्षेत्रासाठी प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे चिन्ह साइडबार आणि सूचना मध्ये दिसेल." + }, + "appearance": { + "title": "दृश्यरूप", + "description": "कार्यक्षेत्राचे दृश्यरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक, वेळ आणि भाषा सानुकूलित करा.", + "options": { + "system": "स्वयंचलित", + "light": "लाइट", + "dark": "डार्क" + } + } + }, + "resetCursorColor": { + "title": "दस्तऐवज कर्सरचा रंग रीसेट करा", + "description": "तुम्हाला कर्सरचा रंग रीसेट करायचा आहे का?" + }, + "resetSelectionColor": { + "title": "दस्तऐवज निवडीचा रंग रीसेट करा", + "description": "तुम्हाला निवडीचा रंग रीसेट करायचा आहे का?" + }, + "resetWidth": { + "resetSuccess": "दस्तऐवजाची रुंदी यशस्वीरित्या रीसेट केली" + }, + "theme": { + "title": "थीम", + "description": "पूर्व-निर्धारित थीम निवडा किंवा तुमची स्वतःची थीम अपलोड करा.", + "uploadCustomThemeTooltip": "स्वतःची थीम अपलोड करा" + }, + "workspaceFont": { + "title": "कार्यक्षेत्र फॉन्ट", + "noFontHint": "कोणताही फॉन्ट सापडला नाही, कृपया दुसरा शब्द वापरून पहा." + }, + "textDirection": { + "title": "मजकूर दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे", + "auto": "स्वयंचलित", + "enableRTLItems": "RTL टूलबार घटक सक्षम करा" + }, + "layoutDirection": { + "title": "लेआउट दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे" + }, + "dateTime": { + "title": "दिनांक आणि वेळ", + "example": "{} वाजता {} ({})", + "24HourTime": "२४-तास वेळ", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "सुलभ", + "dmy": "D/M/Y" + } + }, + "language": { + "title": "भाषा" + }, + "deleteWorkspacePrompt": { + "title": "कार्यक्षेत्र हटवा", + "content": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही, आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील." + }, + "leaveWorkspacePrompt": { + "title": "कार्यक्षेत्र सोडा", + "content": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का? यानंतर तुम्हाला यामधील सर्व पृष्ठे आणि डेटावर प्रवेश मिळणार नाही.", + "success": "तुम्ही कार्यक्षेत्र यशस्वीरित्या सोडले.", + "fail": "कार्यक्षेत्र सोडण्यात अयशस्वी." + }, + "manageWorkspace": { + "title": "कार्यक्षेत्र व्यवस्थापित करा", + "leaveWorkspace": "कार्यक्षेत्र सोडा", + "deleteWorkspace": "कार्यक्षेत्र हटवा" + }, + "manageDataPage": { + "menuLabel": "डेटा व्यवस्थापित करा", + "title": "डेटा व्यवस्थापन", + "description": "@:appName मध्ये स्थानिक डेटा संचयन व्यवस्थापित करा किंवा तुमचा विद्यमान डेटा आयात करा.", + "dataStorage": { + "title": "फाइल संचयन स्थान", + "tooltip": "जिथे तुमच्या फाइल्स संग्रहित आहेत ते स्थान", + "actions": { + "change": "मार्ग बदला", + "open": "फोल्डर उघडा", + "openTooltip": "सध्याच्या डेटा फोल्डरचे स्थान उघडा", + "copy": "मार्ग कॉपी करा", + "copiedHint": "मार्ग कॉपी केला!", + "resetTooltip": "मूलभूत स्थानावर रीसेट करा" + }, + "resetDialog": { + "title": "तुम्हाला खात्री आहे का?", + "description": "पथ मूलभूत डेटा स्थानावर रीसेट केल्याने तुमचा डेटा हटवला जाणार नाही. तुम्हाला सध्याचा डेटा पुन्हा आयात करायचा असल्यास, कृपया आधी त्याचा पथ कॉपी करा." + } + }, + "importData": { + "title": "डेटा आयात करा", + "tooltip": "@:appName बॅकअप/डेटा फोल्डरमधून डेटा आयात करा", + "description": "बाह्य @:appName डेटा फोल्डरमधून डेटा कॉपी करा", + "action": "फाइल निवडा" + }, + "encryption": { + "title": "एनक्रिप्शन", + "tooltip": "तुमचा डेटा कसा संग्रहित आणि एनक्रिप्ट केला जातो ते व्यवस्थापित करा", + "descriptionNoEncryption": "एनक्रिप्शन चालू केल्याने सर्व डेटा एनक्रिप्ट केला जाईल. ही क्रिया पूर्ववत केली जाऊ शकत नाही.", + "descriptionEncrypted": "तुमचा डेटा एनक्रिप्टेड आहे.", + "action": "डेटा एनक्रिप्ट करा", + "dialog": { + "title": "संपूर्ण डेटा एनक्रिप्ट करायचा?", + "description": "तुमचा संपूर्ण डेटा एनक्रिप्ट केल्याने तो सुरक्षित राहील. ही क्रिया पूर्ववत केली जाऊ शकत नाही. तुम्हाला पुढे जायचे आहे का?" + } + }, + "cache": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "dialog": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "successHint": "कॅशे साफ झाली!" + } + }, + "data": { + "fixYourData": "तुमचा डेटा सुधारा", + "fixButton": "सुधारा", + "fixYourDataDescription": "तुमच्या डेटामध्ये काही अडचण येत असल्यास, तुम्ही येथे ती सुधारू शकता." + } + }, + "shortcutsPage": { + "menuLabel": "शॉर्टकट्स", + "title": "शॉर्टकट्स", + "editBindingHint": "नवीन बाइंडिंग टाका", + "searchHint": "शोधा", + "actions": { + "resetDefault": "मूलभूत रीसेट करा" + }, + "errorPage": { + "message": "शॉर्टकट्स लोड करण्यात अयशस्वी: {}", + "howToFix": "कृपया पुन्हा प्रयत्न करा. समस्या कायम राहिल्यास GitHub वर संपर्क साधा." + }, + "resetDialog": { + "title": "शॉर्टकट्स रीसेट करा", + "description": "हे सर्व कीबाइंडिंग्जना मूळ स्थितीत रीसेट करेल. ही क्रिया पूर्ववत करता येणार नाही. तुम्हाला खात्री आहे का?", + "buttonLabel": "रीसेट करा" + }, + "conflictDialog": { + "title": "{} आधीच वापरले जात आहे", + "descriptionPrefix": "हे कीबाइंडिंग सध्या ", + "descriptionSuffix": " यामध्ये वापरले जात आहे. तुम्ही हे कीबाइंडिंग बदलल्यास, ते {} मधून काढले जाईल.", + "confirmLabel": "पुढे जा" + }, + "editTooltip": "कीबाइंडिंग संपादित करण्यासाठी क्लिक करा", + "keybindings": { + "toggleToDoList": "टू-डू सूची चालू/बंद करा", + "insertNewParagraphInCodeblock": "नवीन परिच्छेद टाका", + "pasteInCodeblock": "कोडब्लॉकमध्ये पेस्ट करा", + "selectAllCodeblock": "सर्व निवडा", + "indentLineCodeblock": "ओळीच्या सुरुवातीला दोन स्पेस टाका", + "outdentLineCodeblock": "ओळीच्या सुरुवातीची दोन स्पेस काढा", + "twoSpacesCursorCodeblock": "कर्सरवर दोन स्पेस टाका", + "copy": "निवड कॉपी करा", + "paste": "मजकुरात पेस्ट करा", + "cut": "निवड कट करा", + "alignLeft": "मजकूर डावीकडे संरेखित करा", + "alignCenter": "मजकूर मधोमध संरेखित करा", + "alignRight": "मजकूर उजवीकडे संरेखित करा", + "insertInlineMathEquation": "इनलाइन गणितीय सूत्र टाका", + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "convertToParagraph": "ब्लॉक परिच्छेदात रूपांतरित करा", + "backspace": "हटवा", + "deleteLeftWord": "डावीकडील शब्द हटवा", + "deleteLeftSentence": "डावीकडील वाक्य हटवा", + "delete": "उजवीकडील अक्षर हटवा", + "deleteMacOS": "डावीकडील अक्षर हटवा", + "deleteRightWord": "उजवीकडील शब्द हटवा", + "moveCursorLeft": "कर्सर डावीकडे हलवा", + "moveCursorBeginning": "कर्सर सुरुवातीला हलवा", + "moveCursorLeftWord": "कर्सर एक शब्द डावीकडे हलवा", + "moveCursorLeftSelect": "निवडा आणि कर्सर डावीकडे हलवा", + "moveCursorBeginSelect": "निवडा आणि कर्सर सुरुवातीला हलवा", + "moveCursorLeftWordSelect": "निवडा आणि कर्सर एक शब्द डावीकडे हलवा", + "moveCursorRight": "कर्सर उजवीकडे हलवा", + "moveCursorEnd": "कर्सर शेवटी हलवा", + "moveCursorRightWord": "कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorRightSelect": "निवडा आणि कर्सर उजवीकडे हलवा", + "moveCursorEndSelect": "निवडा आणि कर्सर शेवटी हलवा", + "moveCursorRightWordSelect": "निवडा आणि कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorUp": "कर्सर वर हलवा", + "moveCursorTopSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorTop": "कर्सर वर हलवा", + "moveCursorUpSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorBottomSelect": "निवडा आणि कर्सर खाली हलवा", + "moveCursorBottom": "कर्सर खाली हलवा", + "moveCursorDown": "कर्सर खाली हलवा", + "moveCursorDownSelect": "निवडा आणि कर्सर खाली हलवा", + "home": "वर स्क्रोल करा", + "end": "खाली स्क्रोल करा", + "toggleBold": "बोल्ड चालू/बंद करा", + "toggleItalic": "इटालिक चालू/बंद करा", + "toggleUnderline": "अधोरेखित चालू/बंद करा", + "toggleStrikethrough": "स्ट्राईकथ्रू चालू/बंद करा", + "toggleCode": "इनलाइन कोड चालू/बंद करा", + "toggleHighlight": "हायलाईट चालू/बंद करा", + "showLinkMenu": "लिंक मेनू दाखवा", + "openInlineLink": "इनलाइन लिंक उघडा", + "openLinks": "सर्व निवडलेले लिंक उघडा", + "indent": "इंडेंट", + "outdent": "आउटडेंट", + "exit": "संपादनातून बाहेर पडा", + "pageUp": "एक पृष्ठ वर स्क्रोल करा", + "pageDown": "एक पृष्ठ खाली स्क्रोल करा", + "selectAll": "सर्व निवडा", + "pasteWithoutFormatting": "फॉरमॅटिंगशिवाय पेस्ट करा", + "showEmojiPicker": "इमोजी निवडकर्ता दाखवा", + "enterInTableCell": "टेबलमध्ये नवीन ओळ जोडा", + "leftInTableCell": "टेबलमध्ये डावीकडील सेलमध्ये जा", + "rightInTableCell": "टेबलमध्ये उजवीकडील सेलमध्ये जा", + "upInTableCell": "टेबलमध्ये वरील सेलमध्ये जा", + "downInTableCell": "टेबलमध्ये खालील सेलमध्ये जा", + "tabInTableCell": "टेबलमध्ये पुढील सेलमध्ये जा", + "shiftTabInTableCell": "टेबलमध्ये मागील सेलमध्ये जा", + "backSpaceInTableCell": "सेलच्या सुरुवातीला थांबा" + }, + "commands": { + "codeBlockNewParagraph": "कोडब्लॉकच्या शेजारी नवीन परिच्छेद टाका", + "codeBlockIndentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीला दोन स्पेस टाका", + "codeBlockOutdentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीची दोन स्पेस काढा", + "codeBlockAddTwoSpaces": "कोडब्लॉकमध्ये कर्सरच्या ठिकाणी दोन स्पेस टाका", + "codeBlockSelectAll": "कोडब्लॉकमधील सर्व मजकूर निवडा", + "codeBlockPasteText": "कोडब्लॉकमध्ये मजकूर पेस्ट करा", + "textAlignLeft": "मजकूर डावीकडे संरेखित करा", + "textAlignCenter": "मजकूर मधोमध संरेखित करा", + "textAlignRight": "मजकूर उजवीकडे संरेखित करा" + }, + "couldNotLoadErrorMsg": "शॉर्टकट लोड करता आले नाहीत. पुन्हा प्रयत्न करा", + "couldNotSaveErrorMsg": "शॉर्टकट सेव्ह करता आले नाहीत. पुन्हा प्रयत्न करा" +}, + "aiPage": { + "title": "AI सेटिंग्ज", + "menuLabel": "AI सेटिंग्ज", + "keys": { + "enableAISearchTitle": "AI शोध", + "aiSettingsDescription": "AppFlowy AI चालवण्यासाठी तुमचा पसंतीचा मॉडेल निवडा. आता GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet आणि Ollama मधील मॉडेल्सचा समावेश आहे.", + "loginToEnableAIFeature": "AI वैशिष्ट्ये फक्त @:appName Cloud मध्ये लॉगिन केल्यानंतरच सक्षम होतात. तुमच्याकडे @:appName खाते नसेल तर 'माय अकाउंट' मध्ये जाऊन साइन अप करा.", + "llmModel": "भाषा मॉडेल", + "llmModelType": "भाषा मॉडेल प्रकार", + "downloadLLMPrompt": "{} डाउनलोड करा", + "downloadAppFlowyOfflineAI": "AI ऑफलाइन पॅकेज डाउनलोड केल्याने तुमच्या डिव्हाइसवर AI चालवता येईल. तुम्हाला पुढे जायचे आहे का?", + "downloadLLMPromptDetail": "{} स्थानिक मॉडेल डाउनलोड करण्यासाठी सुमारे {} संचयन लागेल. तुम्हाला पुढे जायचे आहे का?", + "downloadBigFilePrompt": "डाउनलोड पूर्ण होण्यासाठी सुमारे १० मिनिटे लागू शकतात", + "downloadAIModelButton": "डाउनलोड करा", + "downloadingModel": "डाउनलोड करत आहे", + "localAILoaded": "स्थानिक AI मॉडेल यशस्वीरित्या जोडले गेले आणि वापरण्यास सज्ज आहे", + "localAIStart": "स्थानिक AI सुरू होत आहे. जर हळू वाटत असेल, तर ते बंद करून पुन्हा सुरू करून पहा", + "localAILoading": "स्थानिक AI Chat मॉडेल लोड होत आहे...", + "localAIStopped": "स्थानिक AI थांबले आहे", + "localAIRunning": "स्थानिक AI चालू आहे", + "localAINotReadyRetryLater": "स्थानिक AI सुरू होत आहे, कृपया नंतर पुन्हा प्रयत्न करा", + "localAIDisabled": "तुम्ही स्थानिक AI वापरत आहात, पण ते अकार्यक्षम आहे. कृपया सेटिंग्जमध्ये जाऊन ते सक्षम करा किंवा दुसरे मॉडेल वापरून पहा", + "localAIInitializing": "स्थानिक AI लोड होत आहे. तुमच्या डिव्हाइसवर अवलंबून याला काही मिनिटे लागू शकतात", + "localAINotReadyTextFieldPrompt": "स्थानिक AI लोड होत असताना संपादन करता येणार नाही", + "failToLoadLocalAI": "स्थानिक AI सुरू करण्यात अयशस्वी.", + "restartLocalAI": "पुन्हा सुरू करा", + "disableLocalAITitle": "स्थानिक AI अकार्यक्षम करा", + "disableLocalAIDescription": "तुम्हाला स्थानिक AI अकार्यक्षम करायचा आहे का?", + "localAIToggleTitle": "AppFlowy स्थानिक AI (LAI)", + "localAIToggleSubTitle": "AppFlowy मध्ये अत्याधुनिक स्थानिक AI मॉडेल्स वापरून गोपनीयता आणि सुरक्षा सुनिश्चित करा", + "offlineAIInstruction1": "हे अनुसरा", + "offlineAIInstruction2": "सूचना", + "offlineAIInstruction3": "ऑफलाइन AI सक्षम करण्यासाठी.", + "offlineAIDownload1": "जर तुम्ही AppFlowy AI डाउनलोड केले नसेल, तर कृपया", + "offlineAIDownload2": "डाउनलोड", + "offlineAIDownload3": "करा", + "activeOfflineAI": "सक्रिय", + "downloadOfflineAI": "डाउनलोड करा", + "openModelDirectory": "फोल्डर उघडा", + "laiNotReady": "स्थानिक AI अ‍ॅप योग्य प्रकारे इन्स्टॉल झालेले नाही.", + "ollamaNotReady": "Ollama सर्व्हर तयार नाही.", + "pleaseFollowThese": "कृपया हे अनुसरा", + "instructions": "सूचना", + "installOllamaLai": "Ollama आणि AppFlowy स्थानिक AI सेटअप करण्यासाठी.", + "modelsMissing": "आवश्यक मॉडेल्स सापडत नाहीत.", + "downloadModel": "त्यांना डाउनलोड करण्यासाठी." + } +}, + "planPage": { + "menuLabel": "योजना", + "title": "दर योजना", + "planUsage": { + "title": "योजनेचा वापर सारांश", + "storageLabel": "स्टोरेज", + "storageUsage": "{} पैकी {} GB", + "unlimitedStorageLabel": "अमर्यादित स्टोरेज", + "collaboratorsLabel": "सदस्य", + "collaboratorsUsage": "{} पैकी {}", + "aiResponseLabel": "AI प्रतिसाद", + "aiResponseUsage": "{} पैकी {}", + "unlimitedAILabel": "अमर्यादित AI प्रतिसाद", + "proBadge": "प्रो", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "मॅकसाठी ऑन-डिव्हाइस AI", + "memberProToggle": "अधिक सदस्य आणि अमर्यादित AI", + "aiMaxToggle": "अमर्यादित AI आणि प्रगत मॉडेल्सचा प्रवेश", + "aiOnDeviceToggle": "जास्त गोपनीयतेसाठी स्थानिक AI", + "aiCredit": { + "title": "@:appName AI क्रेडिट जोडा", + "price": "{}", + "priceDescription": "1,000 क्रेडिट्ससाठी", + "purchase": "AI खरेदी करा", + "info": "प्रत्येक कार्यक्षेत्रासाठी 1,000 AI क्रेडिट्स जोडा आणि आपल्या कामाच्या प्रवाहात सानुकूलनीय AI सहजपणे एकत्र करा — अधिक स्मार्ट आणि जलद निकालांसाठी:", + "infoItemOne": "प्रत्येक डेटाबेससाठी 10,000 प्रतिसाद", + "infoItemTwo": "प्रत्येक कार्यक्षेत्रासाठी 1,000 प्रतिसाद" + }, + "currentPlan": { + "bannerLabel": "सद्य योजना", + "freeTitle": "फ्री", + "proTitle": "प्रो", + "teamTitle": "टीम", + "freeInfo": "2 सदस्यांपर्यंत वैयक्तिक वापरासाठी उत्तम", + "proInfo": "10 सदस्यांपर्यंत लहान आणि मध्यम टीमसाठी योग्य", + "teamInfo": "सर्व उत्पादनक्षम आणि सुव्यवस्थित टीमसाठी योग्य", + "upgrade": "योजना बदला", + "canceledInfo": "तुमची योजना रद्द करण्यात आली आहे, {} रोजी तुम्हाला फ्री योजनेवर डाऊनग्रेड केले जाईल." + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "activeLabel": "जोडले गेले", + "aiMax": { + "title": "AI Max", + "description": "प्रगत AI मॉडेल्ससह अमर्यादित AI प्रतिसाद आणि दरमहा 50 AI प्रतिमा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)" + }, + "aiOnDevice": { + "title": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर Mistral 7B, LLAMA 3 आणि अधिक स्थानिक मॉडेल्स चालवा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)", + "recommend": "M1 किंवा नवीनतम शिफारस केली जाते" + } + }, + "deal": { + "bannerLabel": "नववर्षाचे विशेष ऑफर!", + "title": "तुमची टीम वाढवा!", + "info": "Pro आणि Team योजनांवर 10% सूट मिळवा! @:appName AI सह शक्तिशाली नवीन वैशिष्ट्यांसह तुमचे कार्यक्षेत्र अधिक कार्यक्षम बनवा.", + "viewPlans": "योजना पहा" + } + } +}, + "billingPage": { + "menuLabel": "बिलिंग", + "title": "बिलिंग", + "plan": { + "title": "योजना", + "freeLabel": "फ्री", + "proLabel": "प्रो", + "planButtonLabel": "योजना बदला", + "billingPeriod": "बिलिंग कालावधी", + "periodButtonLabel": "कालावधी संपादित करा" + }, + "paymentDetails": { + "title": "पेमेंट तपशील", + "methodLabel": "पेमेंट पद्धत", + "methodButtonLabel": "पद्धत संपादित करा" + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "removeLabel": "काढा", + "renewLabel": "नवीन करा", + "aiMax": { + "label": "AI Max", + "description": "अमर्यादित AI आणि प्रगत मॉडेल्स अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "AI Max {} पर्यंत उपलब्ध असेल" + }, + "aiOnDevice": { + "label": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर अमर्यादित ऑन-डिव्हाइस AI अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "मॅकसाठी ऑन-डिव्हाइस AI {} पर्यंत उपलब्ध असेल" + }, + "removeDialog": { + "title": "{} काढा", + "description": "तुम्हाला {plan} काढायचे आहे का? तुम्हाला तत्काळ {plan} चे सर्व फिचर्स आणि फायदे वापरण्याचा अधिकार गमावावा लागेल." + } + }, + "currentPeriodBadge": "सद्य कालावधी", + "changePeriod": "कालावधी बदला", + "planPeriod": "{} कालावधी", + "monthlyInterval": "मासिक", + "monthlyPriceInfo": "प्रति सदस्य, मासिक बिलिंग", + "annualInterval": "वार्षिक", + "annualPriceInfo": "प्रति सदस्य, वार्षिक बिलिंग" +}, + "comparePlanDialog": { + "title": "योजना तुलना आणि निवड", + "planFeatures": "योजनेची\nवैशिष्ट्ये", + "current": "सध्याची", + "actions": { + "upgrade": "अपग्रेड करा", + "downgrade": "डाऊनग्रेड करा", + "current": "सध्याची" + }, + "freePlan": { + "title": "फ्री", + "description": "२ सदस्यांपर्यंत व्यक्तींसाठी सर्व काही व्यवस्थित करण्यासाठी", + "price": "{}", + "priceInfo": "सदैव फ्री" + }, + "proPlan": { + "title": "प्रो", + "description": "लहान टीम्ससाठी प्रोजेक्ट्स व ज्ञान व्यवस्थापनासाठी", + "price": "{}", + "priceInfo": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग\n\n{} मासिक बिलिंगसाठी" + }, + "planLabels": { + "itemOne": "वर्कस्पेसेस", + "itemTwo": "सदस्य", + "itemThree": "स्टोरेज", + "itemFour": "रिअल-टाइम सहकार्य", + "itemFive": "मोबाईल अ‍ॅप", + "itemSix": "AI प्रतिसाद", + "itemSeven": "AI प्रतिमा", + "itemFileUpload": "फाइल अपलोड", + "customNamespace": "सानुकूल नेमस्पेस", + "tooltipSix": "‘Lifetime’ म्हणजे या प्रतिसादांची मर्यादा कधीही रीसेट केली जात नाही", + "intelligentSearch": "स्मार्ट शोध", + "tooltipSeven": "तुमच्या कार्यक्षेत्रासाठी URL चा भाग सानुकूलित करू देते", + "customNamespaceTooltip": "सानुकूल प्रकाशित साइट URL" + }, + "freeLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "२ पर्यंत", + "itemThree": "५ GB", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "१० कायमस्वरूपी", + "itemSeven": "२ कायमस्वरूपी", + "itemFileUpload": "७ MB पर्यंत", + "intelligentSearch": "स्मार्ट शोध" + }, + "proLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "१० पर्यंत", + "itemThree": "अमर्यादित", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "अमर्यादित", + "itemSeven": "दर महिन्याला १० प्रतिमा", + "itemFileUpload": "अमर्यादित", + "intelligentSearch": "स्मार्ट शोध" + }, + "paymentSuccess": { + "title": "तुम्ही आता {} योजनेवर आहात!", + "description": "तुमचे पेमेंट यशस्वीरित्या पूर्ण झाले असून तुमची योजना @:appName {} मध्ये अपग्रेड झाली आहे. तुम्ही 'योजना' पृष्ठावर तपशील पाहू शकता." + }, + "downgradeDialog": { + "title": "तुम्हाला योजना डाऊनग्रेड करायची आहे का?", + "description": "तुमची योजना डाऊनग्रेड केल्यास तुम्ही फ्री योजनेवर परत जाल. काही सदस्यांना या कार्यक्षेत्राचा प्रवेश मिळणार नाही आणि तुम्हाला स्टोरेज मर्यादेप्रमाणे जागा रिकामी करावी लागू शकते.", + "downgradeLabel": "योजना डाऊनग्रेड करा" + } +}, + "cancelSurveyDialog": { + "title": "तुम्ही जात आहात याचे दुःख आहे", + "description": "तुम्ही जात आहात याचे आम्हाला खरोखरच दुःख वाटते. @:appName सुधारण्यासाठी तुमचा अभिप्राय आम्हाला महत्त्वाचा वाटतो. कृपया काही क्षण घेऊन काही प्रश्नांची उत्तरे द्या.", + "commonOther": "इतर", + "otherHint": "तुमचे उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही तुमची @:appName Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षांनुसार नव्हती", + "answerThree": "यापेक्षा चांगला पर्याय सापडला", + "answerFour": "वापर फारसा केला नाही, त्यामुळे खर्च योग्य वाटला नाही", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "भविष्यात @:appName Pro सदस्यता पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता", + "answerFive": "एकदम कमी शक्यता" + }, + "questionThree": { + "question": "तुमच्या सदस्यत्वादरम्यान कोणते Pro वैशिष्ट्य सर्वात उपयुक्त वाटले?", + "answerOne": "अनेक वापरकर्त्यांशी सहकार्य", + "answerTwo": "लांब कालावधीची आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "@:appName वापरण्याचा तुमचा एकूण अनुभव कसा होता?", + "answerOne": "खूप छान", + "answerTwo": "चांगला", + "answerThree": "सरासरी", + "answerFour": "सरासरीपेक्षा कमी", + "answerFive": "असंतोषजनक" + } +}, + "common": { + "uploadingFile": "फाईल अपलोड होत आहे. कृपया अ‍ॅप बंद करू नका", + "uploadNotionSuccess": "तुमची Notion zip फाईल यशस्वीरित्या अपलोड झाली आहे. आयात पूर्ण झाल्यानंतर तुम्हाला पुष्टीकरण ईमेल मिळेल", + "reset": "रीसेट करा" +}, + "menu": { + "appearance": "दृश्यरूप", + "language": "भाषा", + "user": "वापरकर्ता", + "files": "फाईल्स", + "notifications": "सूचना", + "open": "सेटिंग्ज उघडा", + "logout": "लॉगआउट", + "logoutPrompt": "तुम्हाला नक्की लॉगआउट करायचे आहे का?", + "selfEncryptionLogoutPrompt": "तुम्हाला खात्रीने लॉगआउट करायचे आहे का? कृपया खात्री करा की तुम्ही एनक्रिप्शन गुप्तकी कॉपी केली आहे", + "syncSetting": "सिंक्रोनायझेशन सेटिंग", + "cloudSettings": "क्लाऊड सेटिंग्ज", + "enableSync": "सिंक्रोनायझेशन सक्षम करा", + "enableSyncLog": "सिंक लॉगिंग सक्षम करा", + "enableSyncLogWarning": "सिंक समस्यांचे निदान करण्यात मदतीसाठी धन्यवाद. हे तुमचे डॉक्युमेंट संपादन स्थानिक फाईलमध्ये लॉग करेल. कृपया सक्षम केल्यानंतर अ‍ॅप बंद करून पुन्हा उघडा", + "enableEncrypt": "डेटा एन्क्रिप्ट करा", + "cloudURL": "बेस URL", + "webURL": "वेब URL", + "invalidCloudURLScheme": "अवैध स्कीम", + "cloudServerType": "क्लाऊड सर्व्हर", + "cloudServerTypeTip": "कृपया लक्षात घ्या की क्लाऊड सर्व्हर बदलल्यास तुम्ही सध्या लॉगिन केलेले खाते लॉगआउट होऊ शकते", + "cloudLocal": "स्थानिक", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName क्लाऊड सेल्फ-होस्टेड", + "appFlowyCloudUrlCanNotBeEmpty": "क्लाऊड URL रिकामा असू शकत नाही", + "clickToCopy": "क्लिपबोर्डवर कॉपी करा", + "selfHostStart": "जर तुमच्याकडे सर्व्हर नसेल, तर कृपया हे पाहा", + "selfHostContent": "दस्तऐवज", + "selfHostEnd": "तुमचा स्वतःचा सर्व्हर कसा होस्ट करावा यासाठी मार्गदर्शनासाठी", + "pleaseInputValidURL": "कृपया वैध URL टाका", + "changeUrl": "सेल्फ-होस्टेड URL {} मध्ये बदला", + "cloudURLHint": "तुमच्या सर्व्हरचा बेस URL टाका", + "webURLHint": "तुमच्या वेब सर्व्हरचा बेस URL टाका", + "cloudWSURL": "वेबसॉकेट URL", + "cloudWSURLHint": "तुमच्या सर्व्हरचा वेबसॉकेट पत्ता टाका", + "restartApp": "अ‍ॅप रीस्टार्ट करा", + "restartAppTip": "बदल प्रभावी होण्यासाठी अ‍ॅप रीस्टार्ट करा. कृपया लक्षात घ्या की यामुळे सध्याचे खाते लॉगआउट होऊ शकते.", + "changeServerTip": "सर्व्हर बदलल्यानंतर, बदल लागू करण्यासाठी तुम्हाला 'रीस्टार्ट' बटणावर क्लिक करणे आवश्यक आहे", + "enableEncryptPrompt": "तुमचा डेटा सुरक्षित करण्यासाठी एन्क्रिप्शन सक्रिय करा. ही गुप्तकी सुरक्षित ठेवा; एकदा सक्षम केल्यावर ती बंद करता येणार नाही. जर हरवली तर तुमचा डेटा पुन्हा मिळवता येणार नाही. कॉपी करण्यासाठी क्लिक करा", + "inputEncryptPrompt": "कृपया खालीलसाठी तुमची एनक्रिप्शन गुप्तकी टाका:", + "clickToCopySecret": "गुप्तकी कॉपी करण्यासाठी क्लिक करा", + "configServerSetting": "तुमच्या सर्व्हर सेटिंग्ज कॉन्फिगर करा", + "configServerGuide": "`Quick Start` निवडल्यानंतर, `Settings` → \"Cloud Settings\" मध्ये जा आणि तुमचा सेल्फ-होस्टेड सर्व्हर कॉन्फिगर करा.", + "inputTextFieldHint": "तुमची गुप्तकी", + "historicalUserList": "वापरकर्ता लॉगिन इतिहास", + "historicalUserListTooltip": "ही यादी तुमची अनामिक खाती दर्शवते. तपशील पाहण्यासाठी खात्यावर क्लिक करा. अनामिक खाती 'सुरुवात करा' बटणावर क्लिक करून तयार केली जातात", + "openHistoricalUser": "अनामिक खाते उघडण्यासाठी क्लिक करा", + "customPathPrompt": "@:appName डेटा फोल्डर Google Drive सारख्या क्लाऊड-सिंक फोल्डरमध्ये साठवणे धोकादायक ठरू शकते. फोल्डरमधील डेटाबेस अनेक ठिकाणांवरून एकाच वेळी ऍक्सेस/संपादित केल्यास डेटा सिंक समस्या किंवा भ्रष्ट होण्याची शक्यता असते.", + "importAppFlowyData": "बाह्य @:appName फोल्डरमधून डेटा आयात करा", + "importingAppFlowyDataTip": "डेटा आयात सुरू आहे. कृपया अ‍ॅप बंद करू नका", + "importAppFlowyDataDescription": "बाह्य @:appName फोल्डरमधून डेटा कॉपी करून सध्याच्या AppFlowy डेटा फोल्डरमध्ये आयात करा", + "importSuccess": "@:appName डेटा फोल्डर यशस्वीरित्या आयात झाला", + "importFailed": "@:appName डेटा फोल्डर आयात करण्यात अयशस्वी", + "importGuide": "अधिक माहितीसाठी, कृपया संदर्भित दस्तऐवज पहा" +}, + "notifications": { + "enableNotifications": { + "label": "सूचना सक्षम करा", + "hint": "स्थानिक सूचना दिसू नयेत यासाठी बंद करा." + }, + "showNotificationsIcon": { + "label": "सूचना चिन्ह दाखवा", + "hint": "साइडबारमध्ये सूचना चिन्ह लपवण्यासाठी बंद करा." + }, + "archiveNotifications": { + "allSuccess": "सर्व सूचना यशस्वीरित्या संग्रहित केल्या", + "success": "सूचना यशस्वीरित्या संग्रहित केली" + }, + "markAsReadNotifications": { + "allSuccess": "सर्व वाचलेल्या म्हणून चिन्हांकित केल्या", + "success": "वाचलेले म्हणून चिन्हांकित केले" + }, + "action": { + "markAsRead": "वाचलेले म्हणून चिन्हांकित करा", + "multipleChoice": "अधिक निवडा", + "archive": "संग्रहित करा" + }, + "settings": { + "settings": "सेटिंग्ज", + "markAllAsRead": "सर्व वाचलेले म्हणून चिन्हांकित करा", + "archiveAll": "सर्व संग्रहित करा" + }, + "emptyInbox": { + "title": "इनबॉक्स झिरो!", + "description": "इथे सूचना मिळवण्यासाठी रिमाइंडर सेट करा." + }, + "emptyUnread": { + "title": "कोणतीही न वाचलेली सूचना नाही", + "description": "तुम्ही सर्व वाचले आहे!" + }, + "emptyArchived": { + "title": "कोणतीही संग्रहित सूचना नाही", + "description": "संग्रहित सूचना इथे दिसतील." + }, + "tabs": { + "inbox": "इनबॉक्स", + "unread": "न वाचलेले", + "archived": "संग्रहित" + }, + "refreshSuccess": "सूचना यशस्वीरित्या रीफ्रेश केल्या", + "titles": { + "notifications": "सूचना", + "reminder": "रिमाइंडर" + } +}, + "appearance": { + "resetSetting": "रीसेट", + "fontFamily": { + "label": "फॉन्ट फॅमिली", + "search": "शोध", + "defaultFont": "सिस्टम" + }, + "themeMode": { + "label": "थीम मोड", + "light": "लाइट मोड", + "dark": "डार्क मोड", + "system": "सिस्टमशी जुळवा" + }, + "fontScaleFactor": "फॉन्ट स्केल घटक", + "displaySize": "डिस्प्ले आकार", + "documentSettings": { + "cursorColor": "डॉक्युमेंट कर्सरचा रंग", + "selectionColor": "डॉक्युमेंट निवडीचा रंग", + "width": "डॉक्युमेंटची रुंदी", + "changeWidth": "बदला", + "pickColor": "रंग निवडा", + "colorShade": "रंगाची छटा", + "opacity": "अपारदर्शकता", + "hexEmptyError": "Hex रंग रिकामा असू शकत नाही", + "hexLengthError": "Hex व्हॅल्यू 6 अंकांची असावी", + "hexInvalidError": "अवैध Hex व्हॅल्यू", + "opacityEmptyError": "अपारदर्शकता रिकामी असू शकत नाही", + "opacityRangeError": "अपारदर्शकता 1 ते 100 दरम्यान असावी", + "app": "अ‍ॅप", + "flowy": "Flowy", + "apply": "लागू करा" + }, + "layoutDirection": { + "label": "लेआउट दिशा", + "hint": "तुमच्या स्क्रीनवरील कंटेंटचा प्रवाह नियंत्रित करा — डावीकडून उजवीकडे किंवा उजवीकडून डावीकडे.", + "ltr": "LTR", + "rtl": "RTL" + }, + "textDirection": { + "label": "मूलभूत मजकूर दिशा", + "hint": "मजकूर डावीकडून सुरू व्हावा की उजवीकडून याचे पूर्वनिर्धारित निर्धारण करा.", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयं", + "fallback": "लेआउट दिशेशी जुळवा" + }, + "themeUpload": { + "button": "अपलोड", + "uploadTheme": "थीम अपलोड करा", + "description": "खालील बटण वापरून तुमची स्वतःची @:appName थीम अपलोड करा.", + "loading": "कृपया प्रतीक्षा करा. आम्ही तुमची थीम पडताळत आहोत आणि अपलोड करत आहोत...", + "uploadSuccess": "तुमची थीम यशस्वीरित्या अपलोड झाली आहे", + "deletionFailure": "थीम हटवण्यात अयशस्वी. कृपया ती मॅन्युअली हटवून पाहा.", + "filePickerDialogTitle": ".flowy_plugin फाईल निवडा", + "urlUploadFailure": "URL उघडण्यात अयशस्वी: {}" + }, + "theme": "थीम", + "builtInsLabel": "अंतर्गत थीम्स", + "pluginsLabel": "प्लगइन्स", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "अनौपचारिक", + "dmy": "D/M/Y" + }, + "timeFormat": { + "label": "वेळ फॉरमॅट", + "twelveHour": "१२ तास", + "twentyFourHour": "२४ तास" + }, + "showNamingDialogWhenCreatingPage": "पृष्ठ तयार करताना नाव विचारणारा डायलॉग दाखवा", + "enableRTLToolbarItems": "RTL टूलबार आयटम्स सक्षम करा", + "members": { + "title": "सदस्य सेटिंग्ज", + "inviteMembers": "सदस्यांना आमंत्रण द्या", + "inviteHint": "ईमेलद्वारे आमंत्रण द्या", + "sendInvite": "आमंत्रण पाठवा", + "copyInviteLink": "आमंत्रण दुवा कॉपी करा", + "label": "सदस्य", + "user": "वापरकर्ता", + "role": "भूमिका", + "removeFromWorkspace": "वर्कस्पेसमधून काढा", + "removeFromWorkspaceSuccess": "वर्कस्पेसमधून यशस्वीरित्या काढले", + "removeFromWorkspaceFailed": "वर्कस्पेसमधून काढण्यात अयशस्वी", + "owner": "मालक", + "guest": "अतिथी", + "member": "सदस्य", + "memberHintText": "सदस्य पृष्ठे वाचू व संपादित करू शकतो", + "guestHintText": "अतिथी पृष्ठे वाचू शकतो, प्रतिक्रिया देऊ शकतो, टिप्पणी करू शकतो, आणि परवानगी असल्यास काही पृष्ठे संपादित करू शकतो.", + "emailInvalidError": "अवैध ईमेल, कृपया तपासा व पुन्हा प्रयत्न करा", + "emailSent": "ईमेल पाठवला गेला आहे, कृपया इनबॉक्स तपासा", + "members": "सदस्य", + "membersCount": { + "zero": "{} सदस्य", + "one": "{} सदस्य", + "other": "{} सदस्य" + }, + "inviteFailedDialogTitle": "आमंत्रण पाठवण्यात अयशस्वी", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, अधिक सदस्यांसाठी कृपया अपग्रेड करा.", + "inviteFailedMemberLimitMobile": "तुमच्या वर्कस्पेसने सदस्य मर्यादा गाठली आहे.", + "memberLimitExceeded": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आमंत्रित करण्यासाठी कृपया ", + "memberLimitExceededUpgrade": "अपग्रेड करा", + "memberLimitExceededPro": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आवश्यक असल्यास संपर्क करा", + "memberLimitExceededProContact": "support@appflowy.io", + "failedToAddMember": "सदस्य जोडण्यात अयशस्वी", + "addMemberSuccess": "सदस्य यशस्वीरित्या जोडला गेला", + "removeMember": "सदस्य काढा", + "areYouSureToRemoveMember": "तुम्हाला हा सदस्य काढायचा आहे का?", + "inviteMemberSuccess": "आमंत्रण यशस्वीरित्या पाठवले गेले", + "failedToInviteMember": "सदस्य आमंत्रित करण्यात अयशस्वी", + "workspaceMembersError": "अरे! काहीतरी चूक झाली आहे", + "workspaceMembersErrorDescription": "आम्ही सध्या सदस्यांची यादी लोड करू शकत नाही. कृपया नंतर पुन्हा प्रयत्न करा" + } +}, + "files": { + "copy": "कॉपी करा", + "defaultLocation": "फाईल्स आणि डेटाचा संचय स्थान", + "exportData": "तुमचा डेटा निर्यात करा", + "doubleTapToCopy": "पथ कॉपी करण्यासाठी दोनदा टॅप करा", + "restoreLocation": "@:appName चे मूळ स्थान पुनर्संचयित करा", + "customizeLocation": "इतर फोल्डर उघडा", + "restartApp": "बदल लागू करण्यासाठी कृपया अ‍ॅप रीस्टार्ट करा.", + "exportDatabase": "डेटाबेस निर्यात करा", + "selectFiles": "निर्यात करण्यासाठी फाईल्स निवडा", + "selectAll": "सर्व निवडा", + "deselectAll": "सर्व निवड रद्द करा", + "createNewFolder": "नवीन फोल्डर तयार करा", + "createNewFolderDesc": "तुमचा डेटा कुठे साठवायचा हे सांगा", + "defineWhereYourDataIsStored": "तुमचा डेटा कुठे साठवला जातो हे ठरवा", + "open": "उघडा", + "openFolder": "आधीक फोल्डर उघडा", + "openFolderDesc": "तुमच्या विद्यमान @:appName फोल्डरमध्ये वाचन व लेखन करा", + "folderHintText": "फोल्डरचे नाव", + "location": "नवीन फोल्डर तयार करत आहे", + "locationDesc": "तुमच्या @:appName डेटासाठी नाव निवडा", + "browser": "ब्राउझ करा", + "create": "तयार करा", + "set": "सेट करा", + "folderPath": "फोल्डर साठवण्याचा मार्ग", + "locationCannotBeEmpty": "मार्ग रिकामा असू शकत नाही", + "pathCopiedSnackbar": "फाईल संचय मार्ग क्लिपबोर्डवर कॉपी केला!", + "changeLocationTooltips": "डेटा डिरेक्टरी बदला", + "change": "बदला", + "openLocationTooltips": "इतर डेटा डिरेक्टरी उघडा", + "openCurrentDataFolder": "सध्याची डेटा डिरेक्टरी उघडा", + "recoverLocationTooltips": "@:appName च्या मूळ डेटा डिरेक्टरीवर रीसेट करा", + "exportFileSuccess": "फाईल यशस्वीरित्या निर्यात झाली!", + "exportFileFail": "फाईल निर्यात करण्यात अयशस्वी!", + "export": "निर्यात करा", + "clearCache": "कॅशे साफ करा", + "clearCacheDesc": "प्रतिमा लोड होत नाहीत किंवा फॉन्ट अयोग्यरित्या दिसत असल्यास, कॅशे साफ करून पाहा. ही क्रिया तुमचा वापरकर्ता डेटा हटवणार नाही.", + "areYouSureToClearCache": "तुम्हाला नक्की कॅशे साफ करायची आहे का?", + "clearCacheSuccess": "कॅशे यशस्वीरित्या साफ झाली!" +}, + "user": { + "name": "नाव", + "email": "ईमेल", + "tooltipSelectIcon": "चिन्ह निवडा", + "selectAnIcon": "चिन्ह निवडा", + "pleaseInputYourOpenAIKey": "कृपया तुमची AI की टाका", + "clickToLogout": "सध्याचा वापरकर्ता लॉगआउट करण्यासाठी क्लिक करा" +}, + "mobile": { + "personalInfo": "वैयक्तिक माहिती", + "username": "वापरकर्तानाव", + "usernameEmptyError": "वापरकर्तानाव रिकामे असू शकत नाही", + "about": "विषयी", + "pushNotifications": "पुश सूचना", + "support": "सपोर्ट", + "joinDiscord": "Discord मध्ये सहभागी व्हा", + "privacyPolicy": "गोपनीयता धोरण", + "userAgreement": "वापरकर्ता करार", + "termsAndConditions": "अटी व शर्ती", + "userprofileError": "वापरकर्ता प्रोफाइल लोड करण्यात अयशस्वी", + "userprofileErrorDescription": "कृपया लॉगआउट करून पुन्हा लॉगिन करा आणि त्रुटी अजूनही येते का ते पहा.", + "selectLayout": "लेआउट निवडा", + "selectStartingDay": "सप्ताहाचा प्रारंभ दिवस निवडा", + "version": "आवृत्ती" +}, + "grid": { + "deleteView": "तुम्हाला हे दृश्य हटवायचे आहे का?", + "createView": "नवीन", + "title": { + "placeholder": "नाव नाही" + }, + "settings": { + "filter": "फिल्टर", + "sort": "क्रमवारी", + "sortBy": "यावरून क्रमवारी लावा", + "properties": "गुणधर्म", + "reorderPropertiesTooltip": "गुणधर्मांचे स्थान बदला", + "group": "समूह", + "addFilter": "फिल्टर जोडा", + "deleteFilter": "फिल्टर हटवा", + "filterBy": "यावरून फिल्टर करा", + "typeAValue": "मूल्य लिहा...", + "layout": "लेआउट", + "compactMode": "कॉम्पॅक्ट मोड", + "databaseLayout": "लेआउट", + "viewList": { + "zero": "० दृश्ये", + "one": "{count} दृश्य", + "other": "{count} दृश्ये" + }, + "editView": "दृश्य संपादित करा", + "boardSettings": "बोर्ड सेटिंग", + "calendarSettings": "कॅलेंडर सेटिंग", + "createView": "नवीन दृश्य", + "duplicateView": "दृश्याची प्रत बनवा", + "deleteView": "दृश्य हटवा", + "numberOfVisibleFields": "{} दर्शविले" + }, + "filter": { + "empty": "कोणतेही सक्रिय फिल्टर नाहीत", + "addFilter": "फिल्टर जोडा", + "cannotFindCreatableField": "फिल्टर करण्यासाठी योग्य फील्ड सापडले नाही", + "conditon": "अट", + "where": "जिथे" + }, + "textFilter": { + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "endsWith": "याने समाप्त होते", + "startWith": "याने सुरू होते", + "is": "आहे", + "isNot": "नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही", + "choicechipPrefix": { + "isNot": "नाही", + "startWith": "याने सुरू होते", + "endWith": "याने समाप्त होते", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } + }, + "checkboxFilter": { + "isChecked": "निवडलेले आहे", + "isUnchecked": "निवडलेले नाही", + "choicechipPrefix": { + "is": "आहे" + } + }, + "checklistFilter": { + "isComplete": "पूर्ण झाले आहे", + "isIncomplted": "अपूर्ण आहे" + }, + "selectOptionFilter": { + "is": "आहे", + "isNot": "नाही", + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"dateFilter": { + "is": "या दिवशी आहे", + "before": "पूर्वी आहे", + "after": "नंतर आहे", + "onOrBefore": "या दिवशी किंवा त्याआधी आहे", + "onOrAfter": "या दिवशी किंवा त्यानंतर आहे", + "between": "दरम्यान आहे", + "empty": "रिकामे आहे", + "notEmpty": "रिकामे नाही", + "startDate": "सुरुवातीची तारीख", + "endDate": "शेवटची तारीख", + "choicechipPrefix": { + "before": "पूर्वी", + "after": "नंतर", + "between": "दरम्यान", + "onOrBefore": "या दिवशी किंवा त्याआधी", + "onOrAfter": "या दिवशी किंवा त्यानंतर", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } +}, +"numberFilter": { + "equal": "बरोबर आहे", + "notEqual": "बरोबर नाही", + "lessThan": "पेक्षा कमी आहे", + "greaterThan": "पेक्षा जास्त आहे", + "lessThanOrEqualTo": "किंवा कमी आहे", + "greaterThanOrEqualTo": "किंवा जास्त आहे", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"field": { + "label": "गुणधर्म", + "hide": "गुणधर्म लपवा", + "show": "गुणधर्म दर्शवा", + "insertLeft": "डावीकडे जोडा", + "insertRight": "उजवीकडे जोडा", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "wrapCellContent": "पाठ लपेटा", + "clear": "सेल्स रिकामे करा", + "switchPrimaryFieldTooltip": "प्राथमिक फील्डचा प्रकार बदलू शकत नाही", + "textFieldName": "मजकूर", + "checkboxFieldName": "चेकबॉक्स", + "dateFieldName": "तारीख", + "updatedAtFieldName": "शेवटचे अपडेट", + "createdAtFieldName": "तयार झाले", + "numberFieldName": "संख्या", + "singleSelectFieldName": "सिंगल सिलेक्ट", + "multiSelectFieldName": "मल्टीसिलेक्ट", + "urlFieldName": "URL", + "checklistFieldName": "चेकलिस्ट", + "relationFieldName": "संबंध", + "summaryFieldName": "AI सारांश", + "timeFieldName": "वेळ", + "mediaFieldName": "फाईल्स आणि मीडिया", + "translateFieldName": "AI भाषांतर", + "translateTo": "मध्ये भाषांतर करा", + "numberFormat": "संख्या स्वरूप", + "dateFormat": "तारीख स्वरूप", + "includeTime": "वेळ जोडा", + "isRange": "शेवटची तारीख", + "dateFormatFriendly": "महिना दिवस, वर्ष", + "dateFormatISO": "वर्ष-महिना-दिनांक", + "dateFormatLocal": "महिना/दिवस/वर्ष", + "dateFormatUS": "वर्ष/महिना/दिवस", + "dateFormatDayMonthYear": "दिवस/महिना/वर्ष", + "timeFormat": "वेळ स्वरूप", + "invalidTimeFormat": "अवैध स्वरूप", + "timeFormatTwelveHour": "१२ तास", + "timeFormatTwentyFourHour": "२४ तास", + "clearDate": "तारीख हटवा", + "dateTime": "तारीख व वेळ", + "startDateTime": "सुरुवातीची तारीख व वेळ", + "endDateTime": "शेवटची तारीख व वेळ", + "failedToLoadDate": "तारीख मूल्य लोड करण्यात अयशस्वी", + "selectTime": "वेळ निवडा", + "selectDate": "तारीख निवडा", + "visibility": "दृश्यता", + "propertyType": "गुणधर्माचा प्रकार", + "addSelectOption": "पर्याय जोडा", + "typeANewOption": "नवीन पर्याय लिहा", + "optionTitle": "पर्याय", + "addOption": "पर्याय जोडा", + "editProperty": "गुणधर्म संपादित करा", + "newProperty": "नवीन गुणधर्म", + "openRowDocument": "पृष्ठ म्हणून उघडा", + "deleteFieldPromptMessage": "तुम्हाला खात्री आहे का? हा गुणधर्म आणि त्याचा डेटा हटवला जाईल", + "clearFieldPromptMessage": "तुम्हाला खात्री आहे का? या कॉलममधील सर्व सेल्स रिकामे होतील", + "newColumn": "नवीन कॉलम", + "format": "स्वरूप", + "reminderOnDateTooltip": "या सेलमध्ये अनुस्मारक शेड्यूल केले आहे", + "optionAlreadyExist": "पर्याय आधीच अस्तित्वात आहे" +}, + "rowPage": { + "newField": "नवीन फील्ड जोडा", + "fieldDragElementTooltip": "मेनू उघडण्यासाठी क्लिक करा", + "showHiddenFields": { + "one": "{count} लपलेले फील्ड दाखवा", + "many": "{count} लपलेली फील्ड दाखवा", + "other": "{count} लपलेली फील्ड दाखवा" + }, + "hideHiddenFields": { + "one": "{count} लपलेले फील्ड लपवा", + "many": "{count} लपलेली फील्ड लपवा", + "other": "{count} लपलेली फील्ड लपवा" + }, + "openAsFullPage": "पूर्ण पृष्ठ म्हणून उघडा", + "moreRowActions": "अधिक पंक्ती क्रिया" +}, +"sort": { + "ascending": "चढत्या क्रमाने", + "descending": "उतरत्या क्रमाने", + "by": "द्वारे", + "empty": "सक्रिय सॉर्ट्स नाहीत", + "cannotFindCreatableField": "सॉर्टसाठी योग्य फील्ड सापडले नाही", + "deleteAllSorts": "सर्व सॉर्ट्स हटवा", + "addSort": "सॉर्ट जोडा", + "sortsActive": "सॉर्टिंग करत असताना {intention} करू शकत नाही", + "removeSorting": "या दृश्यातील सर्व सॉर्ट्स हटवायचे आहेत का?", + "fieldInUse": "या फील्डवर आधीच सॉर्टिंग चालू आहे" +}, +"row": { + "label": "पंक्ती", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "titlePlaceholder": "शीर्षक नाही", + "textPlaceholder": "रिक्त", + "copyProperty": "गुणधर्म क्लिपबोर्डवर कॉपी केला", + "count": "संख्या", + "newRow": "नवीन पंक्ती", + "loadMore": "अधिक लोड करा", + "action": "क्रिया", + "add": "खाली जोडा वर क्लिक करा", + "drag": "हलवण्यासाठी ड्रॅग करा", + "deleteRowPrompt": "तुम्हाला खात्री आहे का की ही पंक्ती हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "deleteCardPrompt": "तुम्हाला खात्री आहे का की हे कार्ड हटवायचे आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "dragAndClick": "हलवण्यासाठी ड्रॅग करा, मेनू उघडण्यासाठी क्लिक करा", + "insertRecordAbove": "वर रेकॉर्ड जोडा", + "insertRecordBelow": "खाली रेकॉर्ड जोडा", + "noContent": "माहिती नाही", + "reorderRowDescription": "पंक्तीचे पुन्हा क्रमांकन", + "createRowAboveDescription": "वर पंक्ती तयार करा", + "createRowBelowDescription": "खाली पंक्ती जोडा" +}, +"selectOption": { + "create": "तयार करा", + "purpleColor": "जांभळा", + "pinkColor": "गुलाबी", + "lightPinkColor": "फिकट गुलाबी", + "orangeColor": "नारंगी", + "yellowColor": "पिवळा", + "limeColor": "लिंबू", + "greenColor": "हिरवा", + "aquaColor": "आक्वा", + "blueColor": "निळा", + "deleteTag": "टॅग हटवा", + "colorPanelTitle": "रंग", + "panelTitle": "पर्याय निवडा किंवा नवीन तयार करा", + "searchOption": "पर्याय शोधा", + "searchOrCreateOption": "पर्याय शोधा किंवा तयार करा", + "createNew": "नवीन तयार करा", + "orSelectOne": "किंवा पर्याय निवडा", + "typeANewOption": "नवीन पर्याय टाइप करा", + "tagName": "टॅग नाव" +}, +"checklist": { + "taskHint": "कार्याचे वर्णन", + "addNew": "नवीन कार्य जोडा", + "submitNewTask": "तयार करा", + "hideComplete": "पूर्ण कार्ये लपवा", + "showComplete": "सर्व कार्ये दाखवा" +}, +"url": { + "launch": "बाह्य ब्राउझरमध्ये लिंक उघडा", + "copy": "लिंक क्लिपबोर्डवर कॉपी करा", + "textFieldHint": "URL टाका", + "copiedNotification": "क्लिपबोर्डवर कॉपी केले!" +}, +"relation": { + "relatedDatabasePlaceLabel": "संबंधित डेटाबेस", + "relatedDatabasePlaceholder": "काही नाही", + "inRelatedDatabase": "या मध्ये", + "rowSearchTextFieldPlaceholder": "शोध", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया खालील यादीतून एक निवडा:", + "emptySearchResult": "कोणतीही नोंद सापडली नाही", + "linkedRowListLabel": "{count} लिंक केलेल्या पंक्ती", + "unlinkedRowListLabel": "आणखी एक पंक्ती लिंक करा" +}, +"menuName": "ग्रिड", +"referencedGridPrefix": "दृश्य", +"calculate": "गणना करा", +"calculationTypeLabel": { + "none": "काही नाही", + "average": "सरासरी", + "max": "कमाल", + "median": "मध्यम", + "min": "किमान", + "sum": "बेरीज", + "count": "मोजणी", + "countEmpty": "रिकाम्यांची मोजणी", + "countEmptyShort": "रिक्त", + "countNonEmpty": "रिक्त नसलेल्यांची मोजणी", + "countNonEmptyShort": "भरलेले" +}, +"media": { + "rename": "पुन्हा नाव द्या", + "download": "डाउनलोड करा", + "expand": "मोठे करा", + "delete": "हटवा", + "moreFilesHint": "+{}", + "addFileOrImage": "फाईल किंवा लिंक जोडा", + "attachmentsHint": "{}", + "addFileMobile": "फाईल जोडा", + "extraCount": "+{}", + "deleteFileDescription": "तुम्हाला खात्री आहे का की ही फाईल हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "showFileNames": "फाईलचे नाव दाखवा", + "downloadSuccess": "फाईल डाउनलोड झाली", + "downloadFailedToken": "फाईल डाउनलोड अयशस्वी, वापरकर्ता टोकन अनुपलब्ध", + "setAsCover": "कव्हर म्हणून सेट करा", + "openInBrowser": "ब्राउझरमध्ये उघडा", + "embedLink": "फाईल लिंक एम्बेड करा" + } +}, + "document": { + "menuName": "दस्तऐवज", + "date": { + "timeHintTextInTwelveHour": "01:00 PM", + "timeHintTextInTwentyFourHour": "13:00" + }, + "creating": "तयार करत आहे...", + "slashMenu": { + "board": { + "selectABoardToLinkTo": "लिंक करण्यासाठी बोर्ड निवडा", + "createANewBoard": "नवीन बोर्ड तयार करा" + }, + "grid": { + "selectAGridToLinkTo": "लिंक करण्यासाठी ग्रिड निवडा", + "createANewGrid": "नवीन ग्रिड तयार करा" + }, + "calendar": { + "selectACalendarToLinkTo": "लिंक करण्यासाठी दिनदर्शिका निवडा", + "createANewCalendar": "नवीन दिनदर्शिका तयार करा" + }, + "document": { + "selectADocumentToLinkTo": "लिंक करण्यासाठी दस्तऐवज निवडा" + }, + "name": { + "textStyle": "मजकुराची शैली", + "list": "यादी", + "toggle": "टॉगल", + "fileAndMedia": "फाईल व मीडिया", + "simpleTable": "सोपे टेबल", + "visuals": "दृश्य घटक", + "document": "दस्तऐवज", + "advanced": "प्रगत", + "text": "मजकूर", + "heading1": "शीर्षक 1", + "heading2": "शीर्षक 2", + "heading3": "शीर्षक 3", + "image": "प्रतिमा", + "bulletedList": "बुलेट यादी", + "numberedList": "क्रमांकित यादी", + "todoList": "करण्याची यादी", + "doc": "दस्तऐवज", + "linkedDoc": "पृष्ठाशी लिंक करा", + "grid": "ग्रिड", + "linkedGrid": "लिंक केलेला ग्रिड", + "kanban": "कानबन", + "linkedKanban": "लिंक केलेला कानबन", + "calendar": "दिनदर्शिका", + "linkedCalendar": "लिंक केलेली दिनदर्शिका", + "quote": "उद्धरण", + "divider": "विभाजक", + "table": "टेबल", + "callout": "महत्त्वाचा मजकूर", + "outline": "रूपरेषा", + "mathEquation": "गणिती समीकरण", + "code": "कोड", + "toggleList": "टॉगल यादी", + "toggleHeading1": "टॉगल शीर्षक 1", + "toggleHeading2": "टॉगल शीर्षक 2", + "toggleHeading3": "टॉगल शीर्षक 3", + "emoji": "इमोजी", + "aiWriter": "AI ला काहीही विचारा", + "dateOrReminder": "दिनांक किंवा स्मरणपत्र", + "photoGallery": "फोटो गॅलरी", + "file": "फाईल", + "twoColumns": "२ स्तंभ", + "threeColumns": "३ स्तंभ", + "fourColumns": "४ स्तंभ" + }, + "subPage": { + "name": "दस्तऐवज", + "keyword1": "उपपृष्ठ", + "keyword2": "पृष्ठ", + "keyword3": "चाइल्ड पृष्ठ", + "keyword4": "पृष्ठ जोडा", + "keyword5": "एम्बेड पृष्ठ", + "keyword6": "नवीन पृष्ठ", + "keyword7": "पृष्ठ तयार करा", + "keyword8": "दस्तऐवज" + } + }, + "selectionMenu": { + "outline": "रूपरेषा", + "codeBlock": "कोड ब्लॉक" + }, + "plugins": { + "referencedBoard": "संदर्भित बोर्ड", + "referencedGrid": "संदर्भित ग्रिड", + "referencedCalendar": "संदर्भित दिनदर्शिका", + "referencedDocument": "संदर्भित दस्तऐवज", + "aiWriter": { + "userQuestion": "AI ला काहीही विचारा", + "continueWriting": "लेखन सुरू ठेवा", + "fixSpelling": "स्पेलिंग व व्याकरण सुधारणा", + "improveWriting": "लेखन सुधारित करा", + "summarize": "सारांश द्या", + "explain": "स्पष्टीकरण द्या", + "makeShorter": "लहान करा", + "makeLonger": "मोठे करा" + }, + "autoGeneratorMenuItemName": "AI लेखक", +"autoGeneratorTitleName": "AI: काहीही लिहिण्यासाठी AI ला विचारा...", +"autoGeneratorLearnMore": "अधिक जाणून घ्या", +"autoGeneratorGenerate": "उत्पन्न करा", +"autoGeneratorHintText": "AI ला विचारा...", +"autoGeneratorCantGetOpenAIKey": "AI की मिळवू शकलो नाही", +"autoGeneratorRewrite": "पुन्हा लिहा", +"smartEdit": "AI ला विचारा", +"aI": "AI", +"smartEditFixSpelling": "स्पेलिंग आणि व्याकरण सुधारा", +"warning": "⚠️ AI उत्तरं चुकीची किंवा दिशाभूल करणारी असू शकतात.", +"smartEditSummarize": "सारांश द्या", +"smartEditImproveWriting": "लेखन सुधारित करा", +"smartEditMakeLonger": "लांब करा", +"smartEditCouldNotFetchResult": "AI कडून उत्तर मिळवता आले नाही", +"smartEditCouldNotFetchKey": "AI की मिळवता आली नाही", +"smartEditDisabled": "सेटिंग्जमध्ये AI कनेक्ट करा", +"appflowyAIEditDisabled": "AI वैशिष्ट्ये सक्षम करण्यासाठी साइन इन करा", +"discardResponse": "AI उत्तर फेकून द्यायचं आहे का?", +"createInlineMathEquation": "समीकरण तयार करा", +"fonts": "फॉन्ट्स", +"insertDate": "तारीख जोडा", +"emoji": "इमोजी", +"toggleList": "टॉगल यादी", +"emptyToggleHeading": "रिकामे टॉगल h{}. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleList": "रिकामी टॉगल यादी. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleHeadingWeb": "रिकामे टॉगल h{level}. मजकूर जोडण्यासाठी क्लिक करा", +"quoteList": "उद्धरण यादी", +"numberedList": "क्रमांकित यादी", +"bulletedList": "बुलेट यादी", +"todoList": "करण्याची यादी", +"callout": "ठळक मजकूर", +"simpleTable": { + "moreActions": { + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "insertLeft": "डावीकडे घाला", + "insertRight": "उजवीकडे घाला", + "insertAbove": "वर घाला", + "insertBelow": "खाली घाला", + "headerColumn": "हेडर स्तंभ", + "headerRow": "हेडर ओळ", + "clearContents": "सामग्री साफ करा", + "setToPageWidth": "पृष्ठाच्या रुंदीप्रमाणे सेट करा", + "distributeColumnsWidth": "स्तंभ समान करा", + "duplicateRow": "ओळ डुप्लिकेट करा", + "duplicateColumn": "स्तंभ डुप्लिकेट करा", + "textColor": "मजकूराचा रंग", + "cellBackgroundColor": "सेलचा पार्श्वभूमी रंग", + "duplicateTable": "टेबल डुप्लिकेट करा" + }, + "clickToAddNewRow": "नवीन ओळ जोडण्यासाठी क्लिक करा", + "clickToAddNewColumn": "नवीन स्तंभ जोडण्यासाठी क्लिक करा", + "clickToAddNewRowAndColumn": "नवीन ओळ आणि स्तंभ जोडण्यासाठी क्लिक करा", + "headerName": { + "table": "टेबल", + "alignText": "मजकूर पंक्तिबद्ध करा" + } +}, +"cover": { + "changeCover": "कव्हर बदला", + "colors": "रंग", + "images": "प्रतिमा", + "clearAll": "सर्व साफ करा", + "abstract": "ऍबस्ट्रॅक्ट", + "addCover": "कव्हर जोडा", + "addLocalImage": "स्थानिक प्रतिमा जोडा", + "invalidImageUrl": "अवैध प्रतिमा URL", + "failedToAddImageToGallery": "प्रतिमा गॅलरीत जोडता आली नाही", + "enterImageUrl": "प्रतिमा URL लिहा", + "add": "जोडा", + "back": "मागे", + "saveToGallery": "गॅलरीत जतन करा", + "removeIcon": "आयकॉन काढा", + "removeCover": "कव्हर काढा", + "pasteImageUrl": "प्रतिमा URL पेस्ट करा", + "or": "किंवा", + "pickFromFiles": "फाईल्समधून निवडा", + "couldNotFetchImage": "प्रतिमा मिळवता आली नाही", + "imageSavingFailed": "प्रतिमा जतन करणे अयशस्वी", + "addIcon": "आयकॉन जोडा", + "changeIcon": "आयकॉन बदला", + "coverRemoveAlert": "हे हटवल्यानंतर कव्हरमधून काढले जाईल.", + "alertDialogConfirmation": "तुम्हाला खात्री आहे का? तुम्हाला पुढे जायचे आहे?" +}, +"mathEquation": { + "name": "गणिती समीकरण", + "addMathEquation": "TeX समीकरण जोडा", + "editMathEquation": "गणिती समीकरण संपादित करा" +}, +"optionAction": { + "click": "क्लिक", + "toOpenMenu": "मेनू उघडण्यासाठी", + "drag": "ओढा", + "toMove": "हलवण्यासाठी", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "turnInto": "मध्ये बदला", + "moveUp": "वर हलवा", + "moveDown": "खाली हलवा", + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "left": "डावीकडे", + "center": "मध्यभागी", + "right": "उजवीकडे", + "defaultColor": "डिफॉल्ट", + "depth": "खोली", + "copyLinkToBlock": "ब्लॉकसाठी लिंक कॉपी करा" +}, + "image": { + "addAnImage": "प्रतिमा जोडा", + "copiedToPasteBoard": "प्रतिमेची लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "addAnImageDesktop": "प्रतिमा जोडा", + "addAnImageMobile": "एक किंवा अधिक प्रतिमा जोडण्यासाठी क्लिक करा", + "dropImageToInsert": "प्रतिमा ड्रॉप करून जोडा", + "imageUploadFailed": "प्रतिमा अपलोड करण्यात अयशस्वी", + "imageDownloadFailed": "प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "imageDownloadFailedToken": "युजर टोकन नसल्यामुळे प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "errorCode": "त्रुटी कोड" +}, +"photoGallery": { + "name": "फोटो गॅलरी", + "imageKeyword": "प्रतिमा", + "imageGalleryKeyword": "प्रतिमा गॅलरी", + "photoKeyword": "फोटो", + "photoBrowserKeyword": "फोटो ब्राउझर", + "galleryKeyword": "गॅलरी", + "addImageTooltip": "प्रतिमा जोडा", + "changeLayoutTooltip": "लेआउट बदला", + "browserLayout": "ब्राउझर", + "gridLayout": "ग्रिड", + "deleteBlockTooltip": "संपूर्ण गॅलरी हटवा" +}, +"math": { + "copiedToPasteBoard": "समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे" +}, +"urlPreview": { + "copiedToPasteBoard": "लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "convertToLink": "एंबेड लिंकमध्ये रूपांतर करा" +}, +"outline": { + "addHeadingToCreateOutline": "सामग्री यादी तयार करण्यासाठी शीर्षके जोडा.", + "noMatchHeadings": "जुळणारी शीर्षके आढळली नाहीत." +}, +"table": { + "addAfter": "नंतर जोडा", + "addBefore": "आधी जोडा", + "delete": "हटा", + "clear": "सामग्री साफ करा", + "duplicate": "डुप्लिकेट करा", + "bgColor": "पार्श्वभूमीचा रंग" +}, +"contextMenu": { + "copy": "कॉपी करा", + "cut": "कापा", + "paste": "पेस्ट करा", + "pasteAsPlainText": "साध्या मजकूराच्या स्वरूपात पेस्ट करा" +}, +"action": "कृती", +"database": { + "selectDataSource": "डेटा स्रोत निवडा", + "noDataSource": "डेटा स्रोत नाही", + "selectADataSource": "डेटा स्रोत निवडा", + "toContinue": "पुढे जाण्यासाठी", + "newDatabase": "नवीन डेटाबेस", + "linkToDatabase": "डेटाबेसशी लिंक करा" +}, +"date": "तारीख", +"video": { + "label": "व्हिडिओ", + "emptyLabel": "व्हिडिओ जोडा", + "placeholder": "व्हिडिओ लिंक पेस्ट करा", + "copiedToPasteBoard": "व्हिडिओ लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "insertVideo": "व्हिडिओ जोडा", + "invalidVideoUrl": "ही URL सध्या समर्थित नाही.", + "invalidVideoUrlYouTube": "YouTube सध्या समर्थित नाही.", + "supportedFormats": "समर्थित स्वरूप: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" +}, +"file": { + "name": "फाईल", + "uploadTab": "अपलोड", + "uploadMobile": "फाईल निवडा", + "uploadMobileGallery": "फोटो गॅलरीमधून", + "networkTab": "लिंक एम्बेड करा", + "placeholderText": "फाईल अपलोड किंवा एम्बेड करा", + "placeholderDragging": "फाईल ड्रॉप करून अपलोड करा", + "dropFileToUpload": "फाईल ड्रॉप करून अपलोड करा", + "fileUploadHint": "फाईल ड्रॅग आणि ड्रॉप करा किंवा क्लिक करा ", + "fileUploadHintSuffix": "ब्राउझ करा", + "networkHint": "फाईल लिंक पेस्ट करा", + "networkUrlInvalid": "अवैध URL. कृपया तपासा आणि पुन्हा प्रयत्न करा.", + "networkAction": "एम्बेड", + "fileTooBigError": "फाईल खूप मोठी आहे, कृपया 10MB पेक्षा कमी फाईल अपलोड करा", + "renameFile": { + "title": "फाईलचे नाव बदला", + "description": "या फाईलसाठी नवीन नाव लिहा", + "nameEmptyError": "फाईलचे नाव रिकामे असू शकत नाही." + }, + "uploadedAt": "{} रोजी अपलोड केले", + "linkedAt": "{} रोजी लिंक जोडली", + "failedToOpenMsg": "उघडण्यात अयशस्वी, फाईल सापडली नाही" +}, +"subPage": { + "handlingPasteHint": " - (पेस्ट प्रक्रिया करत आहे)", + "errors": { + "failedDeletePage": "पृष्ठ हटवण्यात अयशस्वी", + "failedCreatePage": "पृष्ठ तयार करण्यात अयशस्वी", + "failedMovePage": "हे पृष्ठ हलवण्यात अयशस्वी", + "failedDuplicatePage": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी", + "failedDuplicateFindView": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी - मूळ दृश्य सापडले नाही" + } +}, + "cannotMoveToItsChildren": "मुलांमध्ये हलवू शकत नाही" +}, +"outlineBlock": { + "placeholder": "सामग्री सूची" +}, +"textBlock": { + "placeholder": "कमांडसाठी '/' टाइप करा" +}, +"title": { + "placeholder": "शीर्षक नाही" +}, +"imageBlock": { + "placeholder": "प्रतिमा जोडण्यासाठी क्लिक करा", + "upload": { + "label": "अपलोड", + "placeholder": "प्रतिमा अपलोड करण्यासाठी क्लिक करा" + }, + "url": { + "label": "प्रतिमेची URL", + "placeholder": "प्रतिमेची URL टाका" + }, + "ai": { + "label": "AI द्वारे प्रतिमा तयार करा", + "placeholder": "AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "stability_ai": { + "label": "Stability AI द्वारे प्रतिमा तयार करा", + "placeholder": "Stability AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "support": "प्रतिमेचा कमाल आकार 5MB आहे. समर्थित स्वरूप: JPEG, PNG, GIF, SVG", + "error": { + "invalidImage": "अवैध प्रतिमा", + "invalidImageSize": "प्रतिमेचा आकार 5MB पेक्षा कमी असावा", + "invalidImageFormat": "प्रतिमेचे स्वरूप समर्थित नाही. समर्थित स्वरूप: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "अवैध प्रतिमेची URL", + "noImage": "अशी फाईल किंवा निर्देशिका नाही", + "multipleImagesFailed": "एक किंवा अधिक प्रतिमा अपलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा" + }, + "embedLink": { + "label": "लिंक एम्बेड करा", + "placeholder": "प्रतिमेची लिंक पेस्ट करा किंवा टाका" + }, + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "प्रतिमा शोधा", + "pleaseInputYourOpenAIKey": "कृपया सेटिंग्ज पृष्ठात आपला AI की प्रविष्ट करा", + "saveImageToGallery": "प्रतिमा जतन करा", + "failedToAddImageToGallery": "प्रतिमा जतन करण्यात अयशस्वी", + "successToAddImageToGallery": "प्रतिमा 'Photos' मध्ये जतन केली", + "unableToLoadImage": "प्रतिमा लोड करण्यात अयशस्वी", + "maximumImageSize": "कमाल प्रतिमा अपलोड आकार 10MB आहे", + "uploadImageErrorImageSizeTooBig": "प्रतिमेचा आकार 10MB पेक्षा कमी असावा", + "imageIsUploading": "प्रतिमा अपलोड होत आहे", + "openFullScreen": "पूर्ण स्क्रीनमध्ये उघडा", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "मागील प्रतिमा", + "nextImageTooltip": "पुढील प्रतिमा", + "zoomOutTooltip": "लहान करा", + "zoomInTooltip": "मोठी करा", + "changeZoomLevelTooltip": "झूम पातळी बदला", + "openLocalImage": "प्रतिमा उघडा", + "downloadImage": "प्रतिमा डाउनलोड करा", + "closeViewer": "इंटरअॅक्टिव्ह व्ह्युअर बंद करा", + "scalePercentage": "{}%", + "deleteImageTooltip": "प्रतिमा हटवा" + } + } +}, + "codeBlock": { + "language": { + "label": "भाषा", + "placeholder": "भाषा निवडा", + "auto": "स्वयंचलित" + }, + "copyTooltip": "कॉपी करा", + "searchLanguageHint": "भाषा शोधा", + "codeCopiedSnackbar": "कोड क्लिपबोर्डवर कॉपी झाला!" +}, +"inlineLink": { + "placeholder": "लिंक पेस्ट करा किंवा टाका", + "openInNewTab": "नवीन टॅबमध्ये उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "url": { + "label": "लिंक URL", + "placeholder": "लिंक URL टाका" + }, + "title": { + "label": "लिंक शीर्षक", + "placeholder": "लिंक शीर्षक टाका" + } +}, +"mention": { + "placeholder": "कोणीतरी, पृष्ठ किंवा तारीख नमूद करा...", + "page": { + "label": "पृष्ठाला लिंक करा", + "tooltip": "पृष्ठ उघडण्यासाठी क्लिक करा" + }, + "deleted": "हटवले गेले", + "deletedContent": "ही सामग्री अस्तित्वात नाही किंवा हटवण्यात आली आहे", + "noAccess": "प्रवेश नाही", + "deletedPage": "हटवलेले पृष्ठ", + "trashHint": " - ट्रॅशमध्ये", + "morePages": "अजून पृष्ठे" +}, +"toolbar": { + "resetToDefaultFont": "डीफॉल्ट फॉन्टवर परत जा", + "textSize": "मजकूराचा आकार", + "textColor": "मजकूराचा रंग", + "h1": "मथळा 1", + "h2": "मथळा 2", + "h3": "मथळा 3", + "alignLeft": "डावीकडे संरेखित करा", + "alignRight": "उजवीकडे संरेखित करा", + "alignCenter": "मध्यभागी संरेखित करा", + "link": "लिंक", + "textAlign": "मजकूर संरेखन", + "moreOptions": "अधिक पर्याय", + "font": "फॉन्ट", + "inlineCode": "इनलाइन कोड", + "suggestions": "सूचना", + "turnInto": "मध्ये रूपांतरित करा", + "equation": "समीकरण", + "insert": "घाला", + "linkInputHint": "लिंक पेस्ट करा किंवा पृष्ठे शोधा", + "pageOrURL": "पृष्ठ किंवा URL", + "linkName": "लिंकचे नाव", + "linkNameHint": "लिंकचे नाव प्रविष्ट करा" +}, +"errorBlock": { + "theBlockIsNotSupported": "ब्लॉक सामग्री पार्स करण्यात अक्षम", + "clickToCopyTheBlockContent": "ब्लॉक सामग्री कॉपी करण्यासाठी क्लिक करा", + "blockContentHasBeenCopied": "ब्लॉक सामग्री कॉपी केली आहे.", + "parseError": "{} ब्लॉक पार्स करताना त्रुटी आली.", + "copyBlockContent": "ब्लॉक सामग्री कॉपी करा" +}, +"mobilePageSelector": { + "title": "पृष्ठ निवडा", + "failedToLoad": "पृष्ठ यादी लोड करण्यात अयशस्वी", + "noPagesFound": "कोणतीही पृष्ठे सापडली नाहीत" +}, +"attachmentMenu": { + "choosePhoto": "फोटो निवडा", + "takePicture": "फोटो काढा", + "chooseFile": "फाईल निवडा" + } + }, + "board": { + "column": { + "label": "स्तंभ", + "createNewCard": "नवीन", + "renameGroupTooltip": "गटाचे नाव बदलण्यासाठी क्लिक करा", + "createNewColumn": "नवीन गट जोडा", + "addToColumnTopTooltip": "वर नवीन कार्ड जोडा", + "addToColumnBottomTooltip": "खाली नवीन कार्ड जोडा", + "renameColumn": "स्तंभाचे नाव बदला", + "hideColumn": "लपवा", + "newGroup": "नवीन गट", + "deleteColumn": "हटवा", + "deleteColumnConfirmation": "हा गट आणि त्यामधील सर्व कार्ड्स हटवले जातील. तुम्हाला खात्री आहे का?" + }, + "hiddenGroupSection": { + "sectionTitle": "लपवलेले गट", + "collapseTooltip": "लपवलेले गट लपवा", + "expandTooltip": "लपवलेले गट पाहा" + }, + "cardDetail": "कार्ड तपशील", + "cardActions": "कार्ड क्रिया", + "cardDuplicated": "कार्डची प्रत तयार झाली", + "cardDeleted": "कार्ड हटवले गेले", + "showOnCard": "कार्ड तपशिलावर दाखवा", + "setting": "सेटिंग", + "propertyName": "गुणधर्माचे नाव", + "menuName": "बोर्ड", + "showUngrouped": "गटात नसलेली कार्ड्स दाखवा", + "ungroupedButtonText": "गट नसलेली", + "ungroupedButtonTooltip": "ज्या कार्ड्स कोणत्याही गटात नाहीत", + "ungroupedItemsTitle": "बोर्डमध्ये जोडण्यासाठी क्लिक करा", + "groupBy": "या आधारावर गट करा", + "groupCondition": "गट स्थिती", + "referencedBoardPrefix": "याचे दृश्य", + "notesTooltip": "नोट्स आहेत", + "mobile": { + "editURL": "URL संपादित करा", + "showGroup": "गट दाखवा", + "showGroupContent": "हा गट बोर्डवर दाखवायचा आहे का?", + "failedToLoad": "बोर्ड दृश्य लोड होण्यात अयशस्वी" + }, + "dateCondition": { + "weekOf": "{} - {} ची आठवडा", + "today": "आज", + "yesterday": "काल", + "tomorrow": "उद्या", + "lastSevenDays": "शेवटचे ७ दिवस", + "nextSevenDays": "पुढील ७ दिवस", + "lastThirtyDays": "शेवटचे ३० दिवस", + "nextThirtyDays": "पुढील ३० दिवस" + }, + "noGroup": "गटासाठी कोणताही गुणधर्म निवडलेला नाही", + "noGroupDesc": "बोर्ड दृश्य दाखवण्यासाठी गट करण्याचा एक गुणधर्म आवश्यक आहे", + "media": { + "cardText": "{} {}", + "fallbackName": "फायली" + } +}, + "calendar": { + "menuName": "कॅलेंडर", + "defaultNewCalendarTitle": "नाव नाही", + "newEventButtonTooltip": "नवीन इव्हेंट जोडा", + "navigation": { + "today": "आज", + "jumpToday": "आजवर जा", + "previousMonth": "मागील महिना", + "nextMonth": "पुढील महिना", + "views": { + "day": "दिवस", + "week": "आठवडा", + "month": "महिना", + "year": "वर्ष" + } + }, + "mobileEventScreen": { + "emptyTitle": "सध्या कोणतेही इव्हेंट नाहीत", + "emptyBody": "या दिवशी इव्हेंट तयार करण्यासाठी प्लस बटणावर क्लिक करा." + }, + "settings": { + "showWeekNumbers": "आठवड्याचे क्रमांक दाखवा", + "showWeekends": "सप्ताहांत दाखवा", + "firstDayOfWeek": "आठवड्याची सुरुवात", + "layoutDateField": "कॅलेंडर मांडणी दिनांकानुसार", + "changeLayoutDateField": "मांडणी फील्ड बदला", + "noDateTitle": "तारीख नाही", + "noDateHint": { + "zero": "नियोजित नसलेली इव्हेंट्स येथे दिसतील", + "one": "{count} नियोजित नसलेली इव्हेंट", + "other": "{count} नियोजित नसलेल्या इव्हेंट्स" + }, + "unscheduledEventsTitle": "नियोजित नसलेल्या इव्हेंट्स", + "clickToAdd": "कॅलेंडरमध्ये जोडण्यासाठी क्लिक करा", + "name": "कॅलेंडर सेटिंग्ज", + "clickToOpen": "रेकॉर्ड उघडण्यासाठी क्लिक करा" + }, + "referencedCalendarPrefix": "याचे दृश्य", + "quickJumpYear": "या वर्षावर जा", + "duplicateEvent": "इव्हेंट डुप्लिकेट करा" +}, + "errorDialog": { + "title": "@:appName त्रुटी", + "howToFixFallback": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया GitHub पेजवर त्रुटीबद्दल माहिती देणारे एक issue सबमिट करा.", + "howToFixFallbackHint1": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया ", + "howToFixFallbackHint2": " पेजवर त्रुटीचे वर्णन करणारे issue सबमिट करा.", + "github": "GitHub वर पहा" +}, +"search": { + "label": "शोध", + "sidebarSearchIcon": "पृष्ठ शोधा आणि पटकन जा", + "placeholder": { + "actions": "कृती शोधा..." + } +}, +"message": { + "copy": { + "success": "कॉपी झाले!", + "fail": "कॉपी करू शकत नाही" + } +}, +"unSupportBlock": "सध्याचा व्हर्जन या ब्लॉकला समर्थन देत नाही.", +"views": { + "deleteContentTitle": "तुम्हाला हे {pageType} हटवायचे आहे का?", + "deleteContentCaption": "हे {pageType} हटवल्यास, तुम्ही ते trash मधून पुनर्संचयित करू शकता." +}, + "colors": { + "custom": "सानुकूल", + "default": "डीफॉल्ट", + "red": "लाल", + "orange": "संत्रा", + "yellow": "पिवळा", + "green": "हिरवा", + "blue": "निळा", + "purple": "जांभळा", + "pink": "गुलाबी", + "brown": "तपकिरी", + "gray": "करड्या रंगाचा" +}, + "emoji": { + "emojiTab": "इमोजी", + "search": "इमोजी शोधा", + "noRecent": "अलीकडील कोणतेही इमोजी नाहीत", + "noEmojiFound": "कोणतेही इमोजी सापडले नाहीत", + "filter": "फिल्टर", + "random": "योगायोगाने", + "selectSkinTone": "त्वचेचा टोन निवडा", + "remove": "इमोजी काढा", + "categories": { + "smileys": "स्मायली आणि भावना", + "people": "लोक", + "animals": "प्राणी आणि निसर्ग", + "food": "अन्न", + "activities": "क्रिया", + "places": "स्थळे", + "objects": "वस्तू", + "symbols": "चिन्हे", + "flags": "ध्वज", + "nature": "निसर्ग", + "frequentlyUsed": "नेहमी वापरलेले" + }, + "skinTone": { + "default": "डीफॉल्ट", + "light": "हलका", + "mediumLight": "मध्यम-हलका", + "medium": "मध्यम", + "mediumDark": "मध्यम-गडद", + "dark": "गडद" + }, + "openSourceIconsFrom": "खुल्या स्रोताचे आयकॉन्स" +}, + "inlineActions": { + "noResults": "निकाल नाही", + "recentPages": "अलीकडील पृष्ठे", + "pageReference": "पृष्ठ संदर्भ", + "docReference": "दस्तऐवज संदर्भ", + "boardReference": "बोर्ड संदर्भ", + "calReference": "कॅलेंडर संदर्भ", + "gridReference": "ग्रिड संदर्भ", + "date": "तारीख", + "reminder": { + "groupTitle": "स्मरणपत्र", + "shortKeyword": "remind" + }, + "createPage": "\"{}\" उप-पृष्ठ तयार करा" +}, + "datePicker": { + "dateTimeFormatTooltip": "सेटिंग्जमध्ये तारीख आणि वेळ फॉरमॅट बदला", + "dateFormat": "तारीख फॉरमॅट", + "includeTime": "वेळ समाविष्ट करा", + "isRange": "शेवटची तारीख", + "timeFormat": "वेळ फॉरमॅट", + "clearDate": "तारीख साफ करा", + "reminderLabel": "स्मरणपत्र", + "selectReminder": "स्मरणपत्र निवडा", + "reminderOptions": { + "none": "काहीही नाही", + "atTimeOfEvent": "इव्हेंटच्या वेळी", + "fiveMinsBefore": "५ मिनिटे आधी", + "tenMinsBefore": "१० मिनिटे आधी", + "fifteenMinsBefore": "१५ मिनिटे आधी", + "thirtyMinsBefore": "३० मिनिटे आधी", + "oneHourBefore": "१ तास आधी", + "twoHoursBefore": "२ तास आधी", + "onDayOfEvent": "इव्हेंटच्या दिवशी", + "oneDayBefore": "१ दिवस आधी", + "twoDaysBefore": "२ दिवस आधी", + "oneWeekBefore": "१ आठवडा आधी", + "custom": "सानुकूल" + } +}, + "relativeDates": { + "yesterday": "काल", + "today": "आज", + "tomorrow": "उद्या", + "oneWeek": "१ आठवडा" +}, + "notificationHub": { + "title": "सूचना", + "mobile": { + "title": "अपडेट्स" + }, + "emptyTitle": "सर्व पूर्ण झाले!", + "emptyBody": "कोणतीही प्रलंबित सूचना किंवा कृती नाहीत. शांततेचा आनंद घ्या.", + "tabs": { + "inbox": "इनबॉक्स", + "upcoming": "आगामी" + }, + "actions": { + "markAllRead": "सर्व वाचलेल्या म्हणून चिन्हित करा", + "showAll": "सर्व", + "showUnreads": "न वाचलेल्या" + }, + "filters": { + "ascending": "आरोही", + "descending": "अवरोही", + "groupByDate": "तारीखेनुसार गटबद्ध करा", + "showUnreadsOnly": "फक्त न वाचलेल्या दाखवा", + "resetToDefault": "डीफॉल्टवर रीसेट करा" + } +}, + "reminderNotification": { + "title": "स्मरणपत्र", + "message": "तुम्ही विसरण्याआधी हे तपासण्याचे लक्षात ठेवा!", + "tooltipDelete": "हटवा", + "tooltipMarkRead": "वाचले म्हणून चिन्हित करा", + "tooltipMarkUnread": "न वाचले म्हणून चिन्हित करा" +}, + "findAndReplace": { + "find": "शोधा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "close": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "noResult": "कोणतेही निकाल नाहीत", + "caseSensitive": "केस सेंसिटिव्ह", + "searchMore": "अधिक निकालांसाठी शोधा" +}, + "error": { + "weAreSorry": "आम्ही क्षमस्व आहोत", + "loadingViewError": "हे दृश्य लोड करण्यात अडचण येत आहे. कृपया तुमचे इंटरनेट कनेक्शन तपासा, अ‍ॅप रीफ्रेश करा, आणि समस्या कायम असल्यास आमच्याशी संपर्क साधा.", + "syncError": "इतर डिव्हाइसमधून डेटा सिंक झाला नाही", + "syncErrorHint": "कृपया हे पृष्ठ शेवटचे जिथे संपादित केले होते त्या डिव्हाइसवर उघडा, आणि मग सध्याच्या डिव्हाइसवर पुन्हा उघडा.", + "clickToCopy": "एरर कोड कॉपी करण्यासाठी क्लिक करा" +}, + "editor": { + "bold": "जाड", + "bulletedList": "बुलेट यादी", + "bulletedListShortForm": "बुलेट", + "checkbox": "चेकबॉक्स", + "embedCode": "कोड एम्बेड करा", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "हायलाइट", + "color": "रंग", + "image": "प्रतिमा", + "date": "तारीख", + "page": "पृष्ठ", + "italic": "तिरका", + "link": "लिंक", + "numberedList": "क्रमांकित यादी", + "numberedListShortForm": "क्रमांकित", + "toggleHeading1ShortForm": "Toggle H1", + "toggleHeading2ShortForm": "Toggle H2", + "toggleHeading3ShortForm": "Toggle H3", + "quote": "कोट", + "strikethrough": "ओढून टाका", + "text": "मजकूर", + "underline": "अधोरेखित", + "fontColorDefault": "डीफॉल्ट", + "fontColorGray": "धूसर", + "fontColorBrown": "तपकिरी", + "fontColorOrange": "केशरी", + "fontColorYellow": "पिवळा", + "fontColorGreen": "हिरवा", + "fontColorBlue": "निळा", + "fontColorPurple": "जांभळा", + "fontColorPink": "पिंग", + "fontColorRed": "लाल", + "backgroundColorDefault": "डीफॉल्ट पार्श्वभूमी", + "backgroundColorGray": "धूसर पार्श्वभूमी", + "backgroundColorBrown": "तपकिरी पार्श्वभूमी", + "backgroundColorOrange": "केशरी पार्श्वभूमी", + "backgroundColorYellow": "पिवळी पार्श्वभूमी", + "backgroundColorGreen": "हिरवी पार्श्वभूमी", + "backgroundColorBlue": "निळी पार्श्वभूमी", + "backgroundColorPurple": "जांभळी पार्श्वभूमी", + "backgroundColorPink": "पिंग पार्श्वभूमी", + "backgroundColorRed": "लाल पार्श्वभूमी", + "backgroundColorLime": "लिंबू पार्श्वभूमी", + "backgroundColorAqua": "पाण्याचा पार्श्वभूमी", + "done": "पूर्ण", + "cancel": "रद्द करा", + "tint1": "टिंट 1", + "tint2": "टिंट 2", + "tint3": "टिंट 3", + "tint4": "टिंट 4", + "tint5": "टिंट 5", + "tint6": "टिंट 6", + "tint7": "टिंट 7", + "tint8": "टिंट 8", + "tint9": "टिंट 9", + "lightLightTint1": "जांभळा", + "lightLightTint2": "पिंग", + "lightLightTint3": "फिकट पिंग", + "lightLightTint4": "केशरी", + "lightLightTint5": "पिवळा", + "lightLightTint6": "लिंबू", + "lightLightTint7": "हिरवा", + "lightLightTint8": "पाणी", + "lightLightTint9": "निळा", + "urlHint": "URL", + "mobileHeading1": "Heading 1", + "mobileHeading2": "Heading 2", + "mobileHeading3": "Heading 3", + "mobileHeading4": "Heading 4", + "mobileHeading5": "Heading 5", + "mobileHeading6": "Heading 6", + "textColor": "मजकूराचा रंग", + "backgroundColor": "पार्श्वभूमीचा रंग", + "addYourLink": "तुमची लिंक जोडा", + "openLink": "लिंक उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "editLink": "लिंक संपादित करा", + "linkText": "मजकूर", + "linkTextHint": "कृपया मजकूर प्रविष्ट करा", + "linkAddressHint": "कृपया URL प्रविष्ट करा", + "highlightColor": "हायलाइट रंग", + "clearHighlightColor": "हायलाइट काढा", + "customColor": "स्वतःचा रंग", + "hexValue": "Hex मूल्य", + "opacity": "अपारदर्शकता", + "resetToDefaultColor": "डीफॉल्ट रंगावर रीसेट करा", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयंचलित", + "cut": "कट", + "copy": "कॉपी", + "paste": "पेस्ट", + "find": "शोधा", + "select": "निवडा", + "selectAll": "सर्व निवडा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "closeFind": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "regex": "Regex", + "caseSensitive": "केस सेंसिटिव्ह", + "uploadImage": "प्रतिमा अपलोड करा", + "urlImage": "URL प्रतिमा", + "incorrectLink": "चुकीची लिंक", + "upload": "अपलोड", + "chooseImage": "प्रतिमा निवडा", + "loading": "लोड करत आहे", + "imageLoadFailed": "प्रतिमा लोड करण्यात अयशस्वी", + "divider": "विभाजक", + "table": "तक्त्याचे स्वरूप", + "colAddBefore": "यापूर्वी स्तंभ जोडा", + "rowAddBefore": "यापूर्वी पंक्ती जोडा", + "colAddAfter": "यानंतर स्तंभ जोडा", + "rowAddAfter": "यानंतर पंक्ती जोडा", + "colRemove": "स्तंभ काढा", + "rowRemove": "पंक्ती काढा", + "colDuplicate": "स्तंभ डुप्लिकेट", + "rowDuplicate": "पंक्ती डुप्लिकेट", + "colClear": "सामग्री साफ करा", + "rowClear": "सामग्री साफ करा", + "slashPlaceHolder": "'/' टाइप करा आणि घटक जोडा किंवा टाइप सुरू करा", + "typeSomething": "काहीतरी लिहा...", + "toggleListShortForm": "टॉगल", + "quoteListShortForm": "कोट", + "mathEquationShortForm": "सूत्र", + "codeBlockShortForm": "कोड" +}, + "favorite": { + "noFavorite": "कोणतेही आवडते पृष्ठ नाही", + "noFavoriteHintText": "पृष्ठाला डावीकडे स्वाइप करा आणि ते आवडत्या यादीत जोडा", + "removeFromSidebar": "साइडबारमधून काढा", + "addToSidebar": "साइडबारमध्ये पिन करा" +}, +"cardDetails": { + "notesPlaceholder": "/ टाइप करा ब्लॉक घालण्यासाठी, किंवा टाइप करायला सुरुवात करा" +}, +"blockPlaceholders": { + "todoList": "करण्याची यादी", + "bulletList": "यादी", + "numberList": "क्रमांकित यादी", + "quote": "कोट", + "heading": "मथळा {}" +}, +"titleBar": { + "pageIcon": "पृष्ठ चिन्ह", + "language": "भाषा", + "font": "फॉन्ट", + "actions": "क्रिया", + "date": "तारीख", + "addField": "फील्ड जोडा", + "userIcon": "वापरकर्त्याचे चिन्ह" +}, +"noLogFiles": "कोणतीही लॉग फाइल्स नाहीत", +"newSettings": { + "myAccount": { + "title": "माझे खाते", + "subtitle": "तुमचा प्रोफाइल सानुकूल करा, खाते सुरक्षा व्यवस्थापित करा, AI कीज पहा किंवा लॉगिन करा.", + "profileLabel": "खाते नाव आणि प्रोफाइल चित्र", + "profileNamePlaceholder": "तुमचे नाव प्रविष्ट करा", + "accountSecurity": "खाते सुरक्षा", + "2FA": "2-स्टेप प्रमाणीकरण", + "aiKeys": "AI कीज", + "accountLogin": "खाते लॉगिन", + "updateNameError": "नाव अपडेट करण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "aboutAppFlowy": "@:appName विषयी", + "deleteAccount": { + "title": "खाते हटवा", + "subtitle": "तुमचे खाते आणि सर्व डेटा कायमचे हटवा.", + "description": "तुमचे खाते कायमचे हटवले जाईल आणि सर्व वर्कस्पेसमधून प्रवेश काढून टाकला जाईल.", + "deleteMyAccount": "माझे खाते हटवा", + "dialogTitle": "खाते हटवा", + "dialogContent1": "तुम्हाला खात्री आहे की तुम्ही तुमचे खाते कायमचे हटवू इच्छिता?", + "dialogContent2": "ही क्रिया पूर्ववत केली जाऊ शकत नाही. हे सर्व वर्कस्पेसमधून प्रवेश हटवेल, खाजगी वर्कस्पेस मिटवेल, आणि सर्व शेअर्ड वर्कस्पेसमधून काढून टाकेल.", + "confirmHint1": "कृपया पुष्टीसाठी \"@:newSettings.myAccount.deleteAccount.confirmHint3\" टाइप करा.", + "confirmHint2": "मला समजले आहे की ही क्रिया अपरिवर्तनीय आहे आणि माझे खाते व सर्व संबंधित डेटा कायमचा हटवला जाईल.", + "confirmHint3": "DELETE MY ACCOUNT", + "checkToConfirmError": "हटवण्यासाठी पुष्टी बॉक्स निवडणे आवश्यक आहे", + "failedToGetCurrentUser": "वर्तमान वापरकर्त्याचा ईमेल मिळवण्यात अयशस्वी", + "confirmTextValidationFailed": "तुमचा पुष्टी मजकूर \"@:newSettings.myAccount.deleteAccount.confirmHint3\" शी जुळत नाही", + "deleteAccountSuccess": "खाते यशस्वीरित्या हटवले गेले" + } + }, + "workplace": { + "name": "वर्कस्पेस", + "title": "वर्कस्पेस सेटिंग्स", + "subtitle": "तुमचा वर्कस्पेस लुक, थीम, फॉन्ट, मजकूर लेआउट, तारीख, वेळ आणि भाषा सानुकूल करा.", + "workplaceName": "वर्कस्पेसचे नाव", + "workplaceNamePlaceholder": "वर्कस्पेसचे नाव टाका", + "workplaceIcon": "वर्कस्पेस चिन्ह", + "workplaceIconSubtitle": "एक प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे साइडबार आणि सूचना मध्ये दर्शवले जाईल.", + "renameError": "वर्कस्पेसचे नाव बदलण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "chooseAnIcon": "चिन्ह निवडा", + "appearance": { + "name": "दृश्यरूप", + "themeMode": { + "auto": "स्वयंचलित", + "light": "प्रकाश मोड", + "dark": "गडद मोड" + }, + "language": "भाषा" + } + }, + "syncState": { + "syncing": "सिंक्रोनायझ करत आहे", + "synced": "सिंक्रोनायझ झाले", + "noNetworkConnected": "नेटवर्क कनेक्ट केलेले नाही" + } +}, + "pageStyle": { + "title": "पृष्ठ शैली", + "layout": "लेआउट", + "coverImage": "मुखपृष्ठ प्रतिमा", + "pageIcon": "पृष्ठ चिन्ह", + "colors": "रंग", + "gradient": "ग्रेडियंट", + "backgroundImage": "पार्श्वभूमी प्रतिमा", + "presets": "पूर्वनियोजित", + "photo": "फोटो", + "unsplash": "Unsplash", + "pageCover": "पृष्ठ कव्हर", + "none": "काही नाही", + "openSettings": "सेटिंग्स उघडा", + "photoPermissionTitle": "@:appName तुमच्या फोटो लायब्ररीमध्ये प्रवेश करू इच्छित आहे", + "photoPermissionDescription": "तुमच्या कागदपत्रांमध्ये प्रतिमा जोडण्यासाठी @:appName ला तुमच्या फोटोंमध्ये प्रवेश आवश्यक आहे", + "cameraPermissionTitle": "@:appName तुमच्या कॅमेऱ्याला प्रवेश करू इच्छित आहे", + "cameraPermissionDescription": "कॅमेऱ्यातून प्रतिमा जोडण्यासाठी @:appName ला तुमच्या कॅमेऱ्याचा प्रवेश आवश्यक आहे", + "doNotAllow": "परवानगी देऊ नका", + "image": "प्रतिमा" +}, +"commandPalette": { + "placeholder": "शोधा किंवा प्रश्न विचारा...", + "bestMatches": "सर्वोत्तम जुळवणी", + "recentHistory": "अलीकडील इतिहास", + "navigateHint": "नेव्हिगेट करण्यासाठी", + "loadingTooltip": "आम्ही निकाल शोधत आहोत...", + "betaLabel": "बेटा", + "betaTooltip": "सध्या आम्ही फक्त दस्तऐवज आणि पृष्ठ शोध समर्थन करतो", + "fromTrashHint": "कचरापेटीतून", + "noResultsHint": "आपण जे शोधत आहात ते सापडले नाही, कृपया दुसरा शब्द वापरून शोधा.", + "clearSearchTooltip": "शोध फील्ड साफ करा" +}, +"space": { + "delete": "हटवा", + "deleteConfirmation": "हटवा: ", + "deleteConfirmationDescription": "या स्पेसमधील सर्व पृष्ठे हटवली जातील आणि कचरापेटीत टाकली जातील, आणि प्रकाशित पृष्ठे अनपब्लिश केली जातील.", + "rename": "स्पेसचे नाव बदला", + "changeIcon": "चिन्ह बदला", + "manage": "स्पेस व्यवस्थापित करा", + "addNewSpace": "स्पेस तयार करा", + "collapseAllSubPages": "सर्व उपपृष्ठे संकुचित करा", + "createNewSpace": "नवीन स्पेस तयार करा", + "createSpaceDescription": "तुमचे कार्य अधिक चांगल्या प्रकारे आयोजित करण्यासाठी अनेक सार्वजनिक व खाजगी स्पेस तयार करा.", + "spaceName": "स्पेसचे नाव", + "spaceNamePlaceholder": "उदा. मार्केटिंग, अभियांत्रिकी, HR", + "permission": "स्पेस परवानगी", + "publicPermission": "सार्वजनिक", + "publicPermissionDescription": "पूर्ण प्रवेशासह सर्व वर्कस्पेस सदस्य", + "privatePermission": "खाजगी", + "privatePermissionDescription": "फक्त तुम्हाला या स्पेसमध्ये प्रवेश आहे", + "spaceIconBackground": "पार्श्वभूमीचा रंग", + "spaceIcon": "चिन्ह", + "dangerZone": "धोकादायक क्षेत्र", + "unableToDeleteLastSpace": "शेवटची स्पेस हटवता येणार नाही", + "unableToDeleteSpaceNotCreatedByYou": "तुमच्याद्वारे तयार न केलेली स्पेस हटवता येणार नाही", + "enableSpacesForYourWorkspace": "तुमच्या वर्कस्पेससाठी स्पेस सक्षम करा", + "title": "स्पेसेस", + "defaultSpaceName": "सामान्य", + "upgradeSpaceTitle": "स्पेस सक्षम करा", + "upgradeSpaceDescription": "तुमच्या वर्कस्पेसचे अधिक चांगल्या प्रकारे व्यवस्थापन करण्यासाठी अनेक सार्वजनिक आणि खाजगी स्पेस तयार करा.", + "upgrade": "अपग्रेड", + "upgradeYourSpace": "अनेक स्पेस तयार करा", + "quicklySwitch": "पुढील स्पेसवर पटकन स्विच करा", + "duplicate": "स्पेस डुप्लिकेट करा", + "movePageToSpace": "पृष्ठ स्पेसमध्ये हलवा", + "cannotMovePageToDatabase": "पृष्ठ डेटाबेसमध्ये हलवता येणार नाही", + "switchSpace": "स्पेस स्विच करा", + "spaceNameCannotBeEmpty": "स्पेसचे नाव रिकामे असू शकत नाही", + "success": { + "deleteSpace": "स्पेस यशस्वीरित्या हटवली", + "renameSpace": "स्पेसचे नाव यशस्वीरित्या बदलले", + "duplicateSpace": "स्पेस यशस्वीरित्या डुप्लिकेट केली", + "updateSpace": "स्पेस यशस्वीरित्या अपडेट केली" + }, + "error": { + "deleteSpace": "स्पेस हटवण्यात अयशस्वी", + "renameSpace": "स्पेसचे नाव बदलण्यात अयशस्वी", + "duplicateSpace": "स्पेस डुप्लिकेट करण्यात अयशस्वी", + "updateSpace": "स्पेस अपडेट करण्यात अयशस्वी" + }, + "createSpace": "स्पेस तयार करा", + "manageSpace": "स्पेस व्यवस्थापित करा", + "renameSpace": "स्पेसचे नाव बदला", + "mSpaceIconColor": "स्पेस चिन्हाचा रंग", + "mSpaceIcon": "स्पेस चिन्ह" +}, + "publish": { + "hasNotBeenPublished": "हे पृष्ठ अजून प्रकाशित केलेले नाही", + "spaceHasNotBeenPublished": "स्पेस प्रकाशित करण्यासाठी समर्थन नाही", + "reportPage": "पृष्ठाची तक्रार करा", + "databaseHasNotBeenPublished": "डेटाबेस प्रकाशित करण्यास समर्थन नाही.", + "createdWith": "यांनी तयार केले", + "downloadApp": "AppFlowy डाउनलोड करा", + "copy": { + "codeBlock": "कोड ब्लॉकची सामग्री क्लिपबोर्डवर कॉपी केली गेली आहे", + "imageBlock": "प्रतिमा लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "mathBlock": "गणितीय समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे", + "fileBlock": "फाइल लिंक क्लिपबोर्डवर कॉपी केली गेली आहे" + }, + "containsPublishedPage": "या पृष्ठात एक किंवा अधिक प्रकाशित पृष्ठे आहेत. तुम्ही पुढे गेल्यास ती अनपब्लिश होतील. तुम्हाला हटवणे चालू ठेवायचे आहे का?", + "publishSuccessfully": "यशस्वीरित्या प्रकाशित झाले", + "unpublishSuccessfully": "यशस्वीरित्या अनपब्लिश झाले", + "publishFailed": "प्रकाशित करण्यात अयशस्वी", + "unpublishFailed": "अनपब्लिश करण्यात अयशस्वी", + "noAccessToVisit": "या पृष्ठावर प्रवेश नाही...", + "createWithAppFlowy": "AppFlowy ने वेबसाइट तयार करा", + "fastWithAI": "AI सह जलद आणि सोपे.", + "tryItNow": "आत्ताच वापरून पहा", + "onlyGridViewCanBePublished": "फक्त Grid view प्रकाशित केला जाऊ शकतो", + "database": { + "zero": "{} निवडलेले दृश्य प्रकाशित करा", + "one": "{} निवडलेली दृश्ये प्रकाशित करा", + "many": "{} निवडलेली दृश्ये प्रकाशित करा", + "other": "{} निवडलेली दृश्ये प्रकाशित करा" + }, + "mustSelectPrimaryDatabase": "प्राथमिक दृश्य निवडणे आवश्यक आहे", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया किमान एक डेटाबेस निवडा.", + "unableToDeselectPrimaryDatabase": "प्राथमिक डेटाबेस अननिवड करता येणार नाही", + "saveThisPage": "या टेम्पलेटपासून सुरू करा", + "duplicateTitle": "तुम्हाला हे कुठे जोडायचे आहे", + "selectWorkspace": "वर्कस्पेस निवडा", + "addTo": "मध्ये जोडा", + "duplicateSuccessfully": "तुमच्या वर्कस्पेसमध्ये जोडले गेले", + "duplicateSuccessfullyDescription": "AppFlowy स्थापित केले नाही? तुम्ही 'डाउनलोड' वर क्लिक केल्यावर डाउनलोड आपोआप सुरू होईल.", + "downloadIt": "डाउनलोड करा", + "openApp": "अ‍ॅपमध्ये उघडा", + "duplicateFailed": "डुप्लिकेट करण्यात अयशस्वी", + "membersCount": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "useThisTemplate": "हा टेम्पलेट वापरा" +}, +"web": { + "continue": "पुढे जा", + "or": "किंवा", + "continueWithGoogle": "Google सह पुढे जा", + "continueWithGithub": "GitHub सह पुढे जा", + "continueWithDiscord": "Discord सह पुढे जा", + "continueWithApple": "Apple सह पुढे जा", + "moreOptions": "अधिक पर्याय", + "collapse": "आकुंचन", + "signInAgreement": "\"पुढे जा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "signInLocalAgreement": "\"सुरुवात करा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "and": "आणि", + "termOfUse": "वापर अटी", + "privacyPolicy": "गोपनीयता धोरण", + "signInError": "साइन इन त्रुटी", + "login": "साइन अप किंवा लॉग इन करा", + "fileBlock": { + "uploadedAt": "{time} रोजी अपलोड केले", + "linkedAt": "{time} रोजी लिंक जोडली", + "empty": "फाईल अपलोड करा किंवा एम्बेड करा", + "uploadFailed": "अपलोड अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "retry": "पुन्हा प्रयत्न करा" + }, + "importNotion": "Notion वरून आयात करा", + "import": "आयात करा", + "importSuccess": "यशस्वीरित्या अपलोड केले", + "importSuccessMessage": "आम्ही तुम्हाला आयात पूर्ण झाल्यावर सूचित करू. त्यानंतर, तुम्ही साइडबारमध्ये तुमची आयात केलेली पृष्ठे पाहू शकता.", + "importFailed": "आयात अयशस्वी, कृपया फाईल फॉरमॅट तपासा", + "dropNotionFile": "तुमची Notion zip फाईल येथे ड्रॉप करा किंवा ब्राउझ करा", + "error": { + "pageNameIsEmpty": "पृष्ठाचे नाव रिकामे आहे, कृपया दुसरे नाव वापरून पहा" + } +}, + "globalComment": { + "comments": "टिप्पण्या", + "addComment": "टिप्पणी जोडा", + "reactedBy": "यांनी प्रतिक्रिया दिली", + "addReaction": "प्रतिक्रिया जोडा", + "reactedByMore": "आणि {count} इतर", + "showSeconds": { + "one": "1 सेकंदापूर्वी", + "other": "{count} सेकंदांपूर्वी", + "zero": "आत्ताच", + "many": "{count} सेकंदांपूर्वी" + }, + "showMinutes": { + "one": "1 मिनिटापूर्वी", + "other": "{count} मिनिटांपूर्वी", + "many": "{count} मिनिटांपूर्वी" + }, + "showHours": { + "one": "1 तासापूर्वी", + "other": "{count} तासांपूर्वी", + "many": "{count} तासांपूर्वी" + }, + "showDays": { + "one": "1 दिवसापूर्वी", + "other": "{count} दिवसांपूर्वी", + "many": "{count} दिवसांपूर्वी" + }, + "showMonths": { + "one": "1 महिन्यापूर्वी", + "other": "{count} महिन्यांपूर्वी", + "many": "{count} महिन्यांपूर्वी" + }, + "showYears": { + "one": "1 वर्षापूर्वी", + "other": "{count} वर्षांपूर्वी", + "many": "{count} वर्षांपूर्वी" + }, + "reply": "उत्तर द्या", + "deleteComment": "टिप्पणी हटवा", + "youAreNotOwner": "तुम्ही या टिप्पणीचे मालक नाही", + "confirmDeleteDescription": "तुम्हाला ही टिप्पणी हटवायची आहे याची खात्री आहे का?", + "hasBeenDeleted": "हटवले गेले", + "replyingTo": "याला उत्तर देत आहे", + "noAccessDeleteComment": "तुम्हाला ही टिप्पणी हटवण्याची परवानगी नाही", + "collapse": "संकुचित करा", + "readMore": "अधिक वाचा", + "failedToAddComment": "टिप्पणी जोडण्यात अयशस्वी", + "commentAddedSuccessfully": "टिप्पणी यशस्वीरित्या जोडली गेली.", + "commentAddedSuccessTip": "तुम्ही नुकतीच एक टिप्पणी जोडली किंवा उत्तर दिले आहे. वर जाऊन ताजी टिप्पण्या पाहायच्या का?" +}, + "template": { + "asTemplate": "टेम्पलेट म्हणून जतन करा", + "name": "टेम्पलेट नाव", + "description": "टेम्पलेट वर्णन", + "about": "टेम्पलेट माहिती", + "deleteFromTemplate": "टेम्पलेटमधून हटवा", + "preview": "टेम्पलेट पूर्वदृश्य", + "categories": "टेम्पलेट श्रेणी", + "isNewTemplate": "नवीन टेम्पलेटमध्ये पिन करा", + "featured": "वैशिष्ट्यीकृतमध्ये पिन करा", + "relatedTemplates": "संबंधित टेम्पलेट्स", + "requiredField": "{field} आवश्यक आहे", + "addCategory": "\"{category}\" जोडा", + "addNewCategory": "नवीन श्रेणी जोडा", + "addNewCreator": "नवीन निर्माता जोडा", + "deleteCategory": "श्रेणी हटवा", + "editCategory": "श्रेणी संपादित करा", + "editCreator": "निर्माता संपादित करा", + "category": { + "name": "श्रेणीचे नाव", + "icon": "श्रेणी चिन्ह", + "bgColor": "श्रेणी पार्श्वभूमीचा रंग", + "priority": "श्रेणी प्राधान्य", + "desc": "श्रेणीचे वर्णन", + "type": "श्रेणी प्रकार", + "icons": "श्रेणी चिन्हे", + "colors": "श्रेणी रंग", + "byUseCase": "वापराच्या आधारे", + "byFeature": "वैशिष्ट्यांनुसार", + "deleteCategory": "श्रेणी हटवा", + "deleteCategoryDescription": "तुम्हाला ही श्रेणी हटवायची आहे का?", + "typeToSearch": "श्रेणी शोधण्यासाठी टाइप करा..." + }, + "creator": { + "label": "टेम्पलेट निर्माता", + "name": "निर्मात्याचे नाव", + "avatar": "निर्मात्याचा अवतार", + "accountLinks": "निर्मात्याचे खाते दुवे", + "uploadAvatar": "अवतार अपलोड करण्यासाठी क्लिक करा", + "deleteCreator": "निर्माता हटवा", + "deleteCreatorDescription": "तुम्हाला हा निर्माता हटवायचा आहे का?", + "typeToSearch": "निर्माते शोधण्यासाठी टाइप करा..." + }, + "uploadSuccess": "टेम्पलेट यशस्वीरित्या अपलोड झाले", + "uploadSuccessDescription": "तुमचे टेम्पलेट यशस्वीरित्या अपलोड झाले आहे. आता तुम्ही ते टेम्पलेट गॅलरीमध्ये पाहू शकता.", + "viewTemplate": "टेम्पलेट पहा", + "deleteTemplate": "टेम्पलेट हटवा", + "deleteSuccess": "टेम्पलेट यशस्वीरित्या हटवले गेले", + "deleteTemplateDescription": "याचा वर्तमान पृष्ठ किंवा प्रकाशित स्थितीवर परिणाम होणार नाही. तुम्हाला हे टेम्पलेट हटवायचे आहे का?", + "addRelatedTemplate": "संबंधित टेम्पलेट जोडा", + "removeRelatedTemplate": "संबंधित टेम्पलेट हटवा", + "uploadAvatar": "अवतार अपलोड करा", + "searchInCategory": "{category} मध्ये शोधा", + "label": "टेम्पलेट्स" +}, + "fileDropzone": { + "dropFile": "फाइल अपलोड करण्यासाठी येथे क्लिक करा किंवा ड्रॅग करा", + "uploading": "अपलोड करत आहे...", + "uploadFailed": "अपलोड अयशस्वी", + "uploadSuccess": "अपलोड यशस्वी", + "uploadSuccessDescription": "फाइल यशस्वीरित्या अपलोड झाली आहे", + "uploadFailedDescription": "फाइल अपलोड अयशस्वी झाली आहे", + "uploadingDescription": "फाइल अपलोड होत आहे" +}, + "gallery": { + "preview": "पूर्ण स्क्रीनमध्ये उघडा", + "copy": "कॉपी करा", + "download": "डाउनलोड", + "prev": "मागील", + "next": "पुढील", + "resetZoom": "झूम रिसेट करा", + "zoomIn": "झूम इन", + "zoomOut": "झूम आउट" +}, + "invitation": { + "join": "सामील व्हा", + "on": "वर", + "invitedBy": "यांनी आमंत्रित केले", + "membersCount": { + "zero": "{count} सदस्य", + "one": "{count} सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "tip": "तुम्हाला खालील माहितीच्या आधारे या कार्यक्षेत्रात सामील होण्यासाठी आमंत्रित करण्यात आले आहे. ही माहिती चुकीची असल्यास, कृपया प्रशासकाशी संपर्क साधा.", + "joinWorkspace": "वर्कस्पेसमध्ये सामील व्हा", + "success": "तुम्ही यशस्वीरित्या वर्कस्पेसमध्ये सामील झाला आहात", + "successMessage": "आता तुम्ही सर्व पृष्ठे आणि कार्यक्षेत्रे वापरू शकता.", + "openWorkspace": "AppFlowy उघडा", + "alreadyAccepted": "तुम्ही आधीच आमंत्रण स्वीकारले आहे", + "errorModal": { + "title": "काहीतरी चुकले आहे", + "description": "तुमचे सध्याचे खाते {email} कदाचित या वर्कस्पेसमध्ये प्रवेशासाठी पात्र नाही. कृपया योग्य खात्याने लॉग इन करा किंवा वर्कस्पेस मालकाशी संपर्क साधा.", + "contactOwner": "मालकाशी संपर्क करा", + "close": "मुख्यपृष्ठावर परत जा", + "changeAccount": "खाते बदला" + } +}, + "requestAccess": { + "title": "या पृष्ठासाठी प्रवेश नाही", + "subtitle": "तुम्ही या पृष्ठाच्या मालकाकडून प्रवेशासाठी विनंती करू शकता. मंजुरीनंतर तुम्हाला हे पृष्ठ पाहता येईल.", + "requestAccess": "प्रवेशाची विनंती करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "tip": "तुम्ही सध्या म्हणून लॉग इन आहात.", + "mightBe": "कदाचित तुम्हाला दुसऱ्या खात्याने लॉग इन करणे आवश्यक आहे.", + "successful": "विनंती यशस्वीपणे पाठवली गेली", + "successfulMessage": "मालकाने मंजुरी दिल्यावर तुम्हाला सूचित केले जाईल.", + "requestError": "प्रवेशाची विनंती अयशस्वी", + "repeatRequestError": "तुम्ही यासाठी आधीच विनंती केली आहे" +}, + "approveAccess": { + "title": "वर्कस्पेसमध्ये सामील होण्यासाठी विनंती मंजूर करा", + "requestSummary": " यांनी मध्ये सामील होण्यासाठी आणि पाहण्यासाठी विनंती केली आहे", + "upgrade": "अपग्रेड", + "downloadApp": "AppFlowy डाउनलोड करा", + "approveButton": "मंजूर करा", + "approveSuccess": "मंजूर यशस्वी", + "approveError": "मंजुरी अयशस्वी. कृपया वर्कस्पेस मर्यादा ओलांडलेली नाही याची खात्री करा", + "getRequestInfoError": "विनंतीची माहिती मिळवण्यात अयशस्वी", + "memberCount": { + "zero": "कोणतेही सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "alreadyProTitle": "वर्कस्पेस योजना मर्यादा गाठली आहे", + "alreadyProMessage": "अधिक सदस्यांसाठी शी संपर्क साधा", + "repeatApproveError": "तुम्ही ही विनंती आधीच मंजूर केली आहे", + "ensurePlanLimit": "कृपया खात्री करा की योजना मर्यादा ओलांडलेली नाही. जर ओलांडली असेल तर वर्कस्पेस योजना करा किंवा करा.", + "requestToJoin": "मध्ये सामील होण्यासाठी विनंती केली", + "asMember": "सदस्य म्हणून" +}, + "upgradePlanModal": { + "title": "Pro प्लॅनवर अपग्रेड करा", + "message": "{name} ने फ्री सदस्य मर्यादा गाठली आहे. अधिक सदस्य आमंत्रित करण्यासाठी Pro प्लॅनवर अपग्रेड करा.", + "upgradeSteps": "AppFlowy वर तुमची योजना कशी अपग्रेड करावी:", + "step1": "1. सेटिंग्जमध्ये जा", + "step2": "2. 'योजना' वर क्लिक करा", + "step3": "3. 'योजना बदला' निवडा", + "appNote": "नोंद:", + "actionButton": "अपग्रेड करा", + "downloadLink": "अ‍ॅप डाउनलोड करा", + "laterButton": "नंतर", + "refreshNote": "यशस्वी अपग्रेडनंतर, तुमची नवीन वैशिष्ट्ये सक्रिय करण्यासाठी वर क्लिक करा.", + "refresh": "येथे" +}, + "breadcrumbs": { + "label": "ब्रेडक्रम्स" +}, + "time": { + "justNow": "आत्ताच", + "seconds": { + "one": "1 सेकंद", + "other": "{count} सेकंद" + }, + "minutes": { + "one": "1 मिनिट", + "other": "{count} मिनिटे" + }, + "hours": { + "one": "1 तास", + "other": "{count} तास" + }, + "days": { + "one": "1 दिवस", + "other": "{count} दिवस" + }, + "weeks": { + "one": "1 आठवडा", + "other": "{count} आठवडे" + }, + "months": { + "one": "1 महिना", + "other": "{count} महिने" + }, + "years": { + "one": "1 वर्ष", + "other": "{count} वर्षे" + }, + "ago": "पूर्वी", + "yesterday": "काल", + "today": "आज" +}, + "members": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" +}, + "tabMenu": { + "close": "बंद करा", + "closeDisabledHint": "पिन केलेले टॅब बंद करता येत नाही, कृपया आधी अनपिन करा", + "closeOthers": "इतर टॅब बंद करा", + "closeOthersHint": "हे सर्व अनपिन केलेले टॅब्स बंद करेल या टॅब वगळता", + "closeOthersDisabledHint": "सर्व टॅब पिन केलेले आहेत, बंद करण्यासाठी कोणतेही टॅब सापडले नाहीत", + "favorite": "आवडते", + "unfavorite": "आवडते काढा", + "favoriteDisabledHint": "हे दृश्य आवडते म्हणून जतन करता येत नाही", + "pinTab": "पिन करा", + "unpinTab": "अनपिन करा" +}, + "openFileMessage": { + "success": "फाइल यशस्वीरित्या उघडली", + "fileNotFound": "फाइल सापडली नाही", + "noAppToOpenFile": "ही फाइल उघडण्यासाठी कोणतेही अ‍ॅप उपलब्ध नाही", + "permissionDenied": "ही फाइल उघडण्यासाठी परवानगी नाही", + "unknownError": "फाइल उघडण्यात अयशस्वी" +}, + "inviteMember": { + "requestInviteMembers": "तुमच्या वर्कस्पेसमध्ये आमंत्रित करा", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, कृपया ", + "upgrade": "अपग्रेड करा", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "आमंत्रण पाठवा", + "inviteAlready": "तुम्ही या ईमेलला आधीच आमंत्रित केले आहे: {email}", + "inviteSuccess": "आमंत्रण यशस्वीपणे पाठवले", + "description": "खाली ईमेल कॉमा वापरून टाका. शुल्क सदस्य संख्येवर आधारित असते.", + "emails": "ईमेल" +}, + "quickNote": { + "label": "झटपट नोंद", + "quickNotes": "झटपट नोंदी", + "search": "झटपट नोंदी शोधा", + "collapseFullView": "पूर्ण दृश्य लपवा", + "expandFullView": "पूर्ण दृश्य उघडा", + "createFailed": "झटपट नोंद तयार करण्यात अयशस्वी", + "quickNotesEmpty": "कोणत्याही झटपट नोंदी नाहीत", + "emptyNote": "रिकामी नोंद", + "deleteNotePrompt": "निवडलेली नोंद कायमची हटवली जाईल. तुम्हाला नक्की ती हटवायची आहे का?", + "addNote": "नवीन नोंद", + "noAdditionalText": "अधिक माहिती नाही" +}, + "subscribe": { + "upgradePlanTitle": "योजना तुलना करा आणि निवडा", + "yearly": "वार्षिक", + "save": "{discount}% बचत", + "monthly": "मासिक", + "priceIn": "किंमत येथे: ", + "free": "फ्री", + "pro": "प्रो", + "freeDescription": "2 सदस्यांपर्यंत वैयक्तिक वापरकर्त्यांसाठी सर्व काही आयोजित करण्यासाठी", + "proDescription": "प्रकल्प आणि टीम नॉलेज व्यवस्थापित करण्यासाठी लहान टीमसाठी", + "proDuration": { + "monthly": "प्रति सदस्य प्रति महिना\nमासिक बिलिंग", + "yearly": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग" + }, + "cancel": "खालच्या योजनेवर जा", + "changePlan": "प्रो योजनेवर अपग्रेड करा", + "everythingInFree": "फ्री योजनेतील सर्व काही +", + "currentPlan": "सध्याची योजना", + "freeDuration": "कायम", + "freePoints": { + "first": "1 सहकार्यात्मक वर्कस्पेस (2 सदस्यांपर्यंत)", + "second": "अमर्यादित पृष्ठे आणि ब्लॉक्स", + "three": "5 GB संचयन", + "four": "बुद्धिमान शोध", + "five": "20 AI प्रतिसाद", + "six": "मोबाईल अ‍ॅप", + "seven": "रिअल-टाइम सहकार्य" + }, + "proPoints": { + "first": "अमर्यादित संचयन", + "second": "10 वर्कस्पेस सदस्यांपर्यंत", + "three": "अमर्यादित AI प्रतिसाद", + "four": "अमर्यादित फाइल अपलोड्स", + "five": "कस्टम नेमस्पेस" + }, + "cancelPlan": { + "title": "आपल्याला जाताना पाहून वाईट वाटते", + "success": "आपली सदस्यता यशस्वीरित्या रद्द झाली आहे", + "description": "आपल्याला जाताना वाईट वाटते. कृपया आम्हाला सुधारण्यासाठी तुमचे अभिप्राय कळवा. खालील काही प्रश्नांना उत्तर द्या.", + "commonOther": "इतर", + "otherHint": "आपले उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही AppFlowy Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षेनुसार नव्हती", + "answerThree": "चांगला पर्याय सापडला", + "answerFour": "खर्च योग्य ठरण्यासाठी वापर पुरेसा नव्हता", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "तुम्ही भविष्यात AppFlowy Pro पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता आहे", + "answerFive": "शक्यता नाही" + }, + "questionThree": { + "question": "सदस्यतेदरम्यान कोणते Pro फिचर तुम्हाला सर्वात जास्त उपयोगी वाटले?", + "answerOne": "मल्टी-यूजर सहकार्य", + "answerTwo": "लांब कालावधीसाठी आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "AppFlowy बाबत तुमचा एकूण अनुभव कसा होता?", + "answerOne": "छान", + "answerTwo": "चांगला", + "answerThree": "सामान्य", + "answerFour": "थोडासा वाईट", + "answerFive": "असंतोषजनक" + } + } +}, + "ai": { + "contentPolicyViolation": "संवेदनशील सामग्रीमुळे प्रतिमा निर्मिती अयशस्वी झाली. कृपया तुमचे इनपुट पुन्हा लिहा आणि पुन्हा प्रयत्न करा.", + "textLimitReachedDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अनलिमिटेड प्रतिसादांसाठी प्रो योजना घेण्याचा किंवा AI अ‍ॅड-ऑन खरेदी करण्याचा विचार करा.", + "imageLimitReachedDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया प्रो योजना घ्या किंवा AI अ‍ॅड-ऑन खरेदी करा.", + "limitReachedAction": { + "textDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अधिक प्रतिसाद मिळवण्यासाठी कृपया", + "imageDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया", + "upgrade": "अपग्रेड करा", + "toThe": "या योजनेवर", + "proPlan": "प्रो योजना", + "orPurchaseAn": "किंवा खरेदी करा", + "aiAddon": "AI अ‍ॅड-ऑन" + }, + "editing": "संपादन करत आहे", + "analyzing": "विश्लेषण करत आहे", + "continueWritingEmptyDocumentTitle": "लेखन सुरू ठेवता आले नाही", + "continueWritingEmptyDocumentDescription": "तुमच्या दस्तऐवजातील मजकूर वाढवण्यात अडचण येत आहे. कृपया एक छोटं परिचय लिहा, मग आम्ही पुढे नेऊ!", + "more": "अधिक" +}, + "autoUpdate": { + "criticalUpdateTitle": "अद्यतन आवश्यक आहे", + "criticalUpdateDescription": "तुमचा अनुभव सुधारण्यासाठी आम्ही सुधारणा केल्या आहेत! कृपया {currentVersion} वरून {newVersion} वर अद्यतन करा.", + "criticalUpdateButton": "अद्यतन करा", + "bannerUpdateTitle": "नवीन आवृत्ती उपलब्ध!", + "bannerUpdateDescription": "नवीन वैशिष्ट्ये आणि सुधारणांसाठी. अद्यतनासाठी 'Update' क्लिक करा.", + "bannerUpdateButton": "अद्यतन करा", + "settingsUpdateTitle": "नवीन आवृत्ती ({newVersion}) उपलब्ध!", + "settingsUpdateDescription": "सध्याची आवृत्ती: {currentVersion} (अधिकृत बिल्ड) → {newVersion}", + "settingsUpdateButton": "अद्यतन करा", + "settingsUpdateWhatsNew": "काय नवीन आहे" +}, + "lockPage": { + "lockPage": "लॉक केलेले", + "reLockPage": "पुन्हा लॉक करा", + "lockTooltip": "अनवधानाने संपादन टाळण्यासाठी पृष्ठ लॉक केले आहे. अनलॉक करण्यासाठी क्लिक करा.", + "pageLockedToast": "पृष्ठ लॉक केले आहे. कोणी अनलॉक करेपर्यंत संपादन अक्षम आहे.", + "lockedOperationTooltip": "पृष्ठ अनवधानाने संपादनापासून वाचवण्यासाठी लॉक केले आहे." +}, + "suggestion": { + "accept": "स्वीकारा", + "keep": "जसे आहे तसे ठेवा", + "discard": "रद्द करा", + "close": "बंद करा", + "tryAgain": "पुन्हा प्रयत्न करा", + "rewrite": "पुन्हा लिहा", + "insertBelow": "खाली टाका" +} +} diff --git a/frontend/appflowy_flutter/build.yaml b/frontend/appflowy_flutter/build.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/appflowy_flutter/dart_dependency_validator.yaml b/frontend/appflowy_flutter/dart_dependency_validator.yaml new file mode 100644 index 0000000000..cb1df68bb6 --- /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/distribute_options.yaml b/frontend/appflowy_flutter/distribute_options.yaml new file mode 100644 index 0000000000..60f603a938 --- /dev/null +++ b/frontend/appflowy_flutter/distribute_options.yaml @@ -0,0 +1,12 @@ +output: dist/ +releases: + - name: dev + jobs: + - name: release-dev-linux-deb + package: + platform: linux + target: deb + - name: release-dev-linux-rpm + package: + platform: linux + target: rpm diff --git a/frontend/appflowy_flutter/dsa_pub.pem b/frontend/appflowy_flutter/dsa_pub.pem new file mode 100644 index 0000000000..6a9d213b8a --- /dev/null +++ b/frontend/appflowy_flutter/dsa_pub.pem @@ -0,0 +1,36 @@ +-----BEGIN PUBLIC KEY----- +MIIGQzCCBDUGByqGSM44BAEwggQoAoICAQDlkozRmUnVH1MJFqOamAmUYu0YruaT +rrt6rCIZ0LFrfNnmHA4LOQEcXwBTTyn5sBmkPq+lb/rjmERKhmvl1rfo6q7tJ8mG +4TWqSu0tOJQ6QxexnNW4yhzK/r9MS5MQus4Al+y2hQLaAMOUIOnaWIrC9OHy7xyw ++sVipECVKyQqipS4shGUSqbcN+ocQuTB+I0MtIjBii0DGSEY3pxQrfNWjHFL7iTV +KiTn3YOWPJQvv3FvEDrN+5xU5JZpD97ZhXaJpLUyOQaNvcPaOELPWcOSJwqHOpf5 +b5N/VZ8SGbHNdxy9d5sSChBgtuAOihEhSp6SjFQ9eVHOf4NyJwSEMmi0gpdpqm4Z +QRJUnM2zIi0p9twR9FRYXzrxOs6yGCQEY+xFG93ShTLTj3zMrIyFqBsqEwFyJiJW +YWe/zp0V7UlLP+jXO9u9eghNmly7QVqD2P0qs/1V0jZFRuLWpsv4inau/qMZ5EhG +G4xCJZXfN1pkehy6e05/h+vs5anK3Wa/H8AtY6cK4CpzAanELvn3AH7VLbAhLswu +6d5CV+DoFgxCWMzGBSdmCYU+2wRLaL8Q9TZHDR+pvQlunEFdfFoGES9WjBPhAsVA +6Mq22U8XSje9yHI3X9Eqe/7a+ajSgcGmB7oQ11+4xf5h2PtubRW/JL0KMjxCxMTp +q1md6Ndx/ptBUwIdAIOyiKb2YcTLWAOt+LAlRXMsY1+W4pTXJfV6RcMCggIAPxbd +0HNj2O/aQhJxNZDMBIcx6+cZ+LKch7qLcaEpVqWHvDSnR2eOJJzWn0RoKK+Vuix/ +4T8texSQkWxAeFFdo6kyrR9XNL7hqEFFq8o9VpmvRzvG6h/bBgh3AHAQE3p/8Wrb +K13IhnlWqd0MjFufSphm63o0gaWl95j+6KeUoKQnioetu9HiMtFKx0d/KYqTQJg7 +hvR6VNCU2oShfXR3ce7RnUYwD37+djrUjUkoAZkZq2KoxBiKyeoSIeqAme19tKcO +s6b17mhALELuJ+NtDwlDunyiCDUYX9lTPijHwKeIFtBs38+OtRk3aIqmWTQdbsCz +Axp+kUMA5ESBME/RBNCSPHuDvtA3wfWvNbA5DXfZLwCgNSxhekq8XntIsRzfJ4v4 +uPzKFcVM3+sUUfSF04HHC9ol+PpLqXUyMnskiizqxFPq7H+6tyFZ7X2HiG6TjcfV +Wthmv+JyfcABjVnk2qFH7GagENbdtYmfUox13LhE59Sh5chaJnCFtCDp8NClWgZn +ixCOFQ9EgTLaH6MovTvWpEgG2MfBCu5SMUHi2qSflorqpRFH+rA7NZSnyz3wm7NB ++fJSOP0IjEkOh7MafU6Z61oK9WY/Fc+F1zIENVv8PUc3p75y/4RAp4xzyKcTilaN +C9U/3MRr3QmWwY7ejtZx6xdOxsvWBRDRSNbDdIkDggIGAAKCAgEAt1DHYZoeXY0r +vYXmxdNO6zfnbz1GGZHXpakzm9h4BrxPDP5J8DQ9ZeVVKg5+cU9AyMO3cZHp7wkx +k6IB+ZDUpqO1D3lWriRl2fI8cS4edI0fzpnW1nyhhFD4MbKmP+v27aH+DhZ4Up3y +GMmJTLmKiYx1EgZp7Sx77PBYDVMsKKd3h9+Hjp2YtUTfD2lleAmC+wcQGZiNtGw/ +eKpsmUVnWrepOdntWTtCQi1OvfcHaF2QmgktCq+68hbDNYWaXmzVIiQqrdv/zzOG +hCFIrRGWemrxL0iFG4Pzc4UfOINsISQLcUxRuF6pQWPxF8O/mWKfzAeqWxmIujUM +EoSEuI3yQ8VjlYpW/8FSK7UhgnHBHOpCJWPWs/vQXAnaUR2PYyzuIzhVEhFs8YA8 +iBIKnixIC2hu0YbEk3TBr/TRcbd7mDw9Mq7NT88xzdU13+Wh+4zhdX3rtBHYzBtI +7GaONGUNyY4h0duoyLpH6dxevaeKN6/bEdzYESjoE58QA88CpnAZGhJVphAba4cb +w6GTDhK3RlPWh6hRqJwLDILGtnJS3UKeBDRmKMqNuqmHqPjyAAvt9JBO8lzjoLgf +1cDsXHNWBVwA2jsX2CukNJPlY1Fa3MWhdaUXmy6QGMSisr1sptvBt1Phry8T2u+P +Y29SB4jvwqls268rP0cWqy4WXwlVwuc= +-----END PUBLIC KEY----- diff --git a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart b/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart deleted file mode 100644 index 0c8b96fa20..0000000000 --- a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart +++ /dev/null @@ -1,92 +0,0 @@ -// ignore_for_file: unused_import - -import 'dart:io'; -import 'dart:ui'; - -import 'package:flutter/material.dart'; - -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/af_cloud_mock_auth_service.dart'; -import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.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_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/uuid.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 'package:intl/intl.dart'; -import 'package:path/path.dart' as p; - -import '../desktop/board/board_hide_groups_test.dart'; -import '../shared/dir.dart'; -import '../shared/mock/mock_file_picker.dart'; -import '../shared/util.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('appflowy cloud', () { - testWidgets('anon user and then sign in', (tester) async { - await tester.initializeAppFlowy( - cloudType: AuthenticatorType.appflowyCloudSelfHost, - ); - - tester.expectToSeeText(LocaleKeys.signIn_loginStartWithAnonymous.tr()); - await tester.tapAnonymousSignInButton(); - await tester.expectToSeeHomePageWithGetStartedPage(); - - // reanme 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(SignInOutButton), - 100, - scrollable: find.findSettingsScrollable(), - ); - - await tester.tapButton(find.byType(SignInOutButton)); - - // sign up with Google - await tester.tapGoogleLoginInButton(); - - // sign out - await tester.expectToSeeHomePage(); - await tester.openSettings(); - await tester.openSettingsPage(SettingsPage.account); - - // Scroll to sign-out - await tester.scrollUntilVisible( - find.byType(SignInOutButton), - 100, - scrollable: find.findSettingsScrollable(), - ); - - await tester.logout(); - await tester.pumpAndSettle(); - - // tap the continue as anonymous button - await tester - .tapButton(find.text(LocaleKeys.signIn_loginStartWithAnonymous.tr())); - await tester.expectToSeeHomePage(); - - // New anon user name - await tester.openSettings(); - await tester.openSettingsPage(SettingsPage.account); - final userNameInput = - tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting; - expect(userNameInput.name, 'Me'); - }); - }); -} diff --git a/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart b/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart deleted file mode 100644 index 5aa3a02d83..0000000000 --- a/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart +++ /dev/null @@ -1,101 +0,0 @@ -// ignore_for_file: unused_import - -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/auth/af_cloud_mock_auth_service.dart'; -import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.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 'package:path/path.dart' as p; - -import '../shared/mock/mock_file_picker.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(SignInOutButton), - 100, - scrollable: find.findSettingsScrollable(), - ); - await tester.tapButton(find.byType(SignInOutButton)); - - tester.expectToSeeText(LocaleKeys.button_confirm.tr()); - await tester.tapButtonWithName(LocaleKeys.button_confirm.tr()); - - // Go to the sign in page again - await tester.pumpAndSettle(const Duration(seconds: 1)); - 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(SignInOutButton), - 100, - scrollable: find.findSettingsScrollable(), - ); - await tester.tapButton(find.byType(SignInOutButton)); - - 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); - - // the switch should be on by default - tester.assertAppFlowyCloudEnableSyncSwitchValue(true); - await tester.toggleEnableSync(AppFlowyCloudEnableSync); - - // the switch should be off - tester.assertAppFlowyCloudEnableSyncSwitchValue(false); - - // the switch should be on after toggling - await tester.toggleEnableSync(AppFlowyCloudEnableSync); - tester.assertAppFlowyCloudEnableSyncSwitchValue(true); - }); - }); -} diff --git a/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart b/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart deleted file mode 100644 index c66cdd5cc1..0000000000 --- a/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'anon_user_continue_test.dart' as anon_user_continue_test; -import 'appflowy_cloud_auth_test.dart' as appflowy_cloud_auth_test; -import 'empty_test.dart' as preset_af_cloud_env_test; -// import 'document_sync_test.dart' as document_sync_test; -import 'user_setting_sync_test.dart' as user_sync_test; -import 'workspace/change_name_and_icon_test.dart' - as change_workspace_name_and_icon_test; -import 'workspace/collaborative_workspace_test.dart' - as collaboration_workspace_test; - -Future main() async { - preset_af_cloud_env_test.main(); - - appflowy_cloud_auth_test.main(); - - // document_sync_test.main(); - - user_sync_test.main(); - - anon_user_continue_test.main(); - - // workspace - collaboration_workspace_test.main(); - change_workspace_name_and_icon_test.main(); -} diff --git a/frontend/appflowy_flutter/integration_test/cloud/document_sync_test.dart b/frontend/appflowy_flutter/integration_test/cloud/document_sync_test.dart deleted file mode 100644 index c5f2c0d1aa..0000000000 --- a/frontend/appflowy_flutter/integration_test/cloud/document_sync_test.dart +++ /dev/null @@ -1,71 +0,0 @@ -// ignore_for_file: unused_import - -import 'dart:io'; - -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/auth/af_cloud_mock_auth_service.dart'; -import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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 'package:path/path.dart' as p; - -import '../shared/dir.dart'; -import '../shared/mock/mock_file_picker.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); - - // TODO(nathan): remove the await - // 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/cloud/empty_test.dart b/frontend/appflowy_flutter/integration_test/cloud/empty_test.dart deleted file mode 100644 index 9f7d3ce9ed..0000000000 --- a/frontend/appflowy_flutter/integration_test/cloud/empty_test.dart +++ /dev/null @@ -1,18 +0,0 @@ -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/cloud/supabase_auth_test.dart b/frontend/appflowy_flutter/integration_test/cloud/supabase_auth_test.dart deleted file mode 100644 index 15c9c3c347..0000000000 --- a/frontend/appflowy_flutter/integration_test/cloud/supabase_auth_test.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:appflowy/env/cloud_env.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_supabase_cloud.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../shared/util.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('supabase auth', () { - testWidgets('sign in with supabase', (tester) async { - await tester.initializeAppFlowy(cloudType: AuthenticatorType.supabase); - await tester.tapGoogleLoginInButton(); - await tester.expectToSeeHomePageWithGetStartedPage(); - }); - - testWidgets('sign out with supabase', (tester) async { - await tester.initializeAppFlowy(cloudType: AuthenticatorType.supabase); - await tester.tapGoogleLoginInButton(); - - // Open the setting page and sign out - await tester.openSettings(); - await tester.openSettingsPage(SettingsPage.account); - await tester.logout(); - - // Go to the sign in page again - await tester.pumpAndSettle(const Duration(seconds: 1)); - tester.expectToSeeGoogleLoginButton(); - }); - - testWidgets('sign in as anonymous', (tester) async { - await tester.initializeAppFlowy(cloudType: AuthenticatorType.supabase); - 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-out - await tester.scrollUntilVisible( - find.byType(SignInOutButton), - 100, - scrollable: find.findSettingsScrollable(), - ); - await tester.tapButton(find.byType(SignInOutButton)); - - tester.expectToSeeGoogleLoginButton(); - }); - - // testWidgets('enable encryption', (tester) async { - // await tester.initializeAppFlowy(cloudType: CloudType.supabase); - // await tester.tapGoogleLoginInButton(); - - // // Open the setting page and sign out - // await tester.openSettings(); - // await tester.openSettingsPage(SettingsPage.cloud); - - // // the switch should be off by default - // tester.assertEnableEncryptSwitchValue(false); - // await tester.toggleEnableEncrypt(); - - // // the switch should be on after toggling - // tester.assertEnableEncryptSwitchValue(true); - - // // the switch can not be toggled back to off - // await tester.toggleEnableEncrypt(); - // tester.assertEnableEncryptSwitchValue(true); - // }); - - testWidgets('enable sync', (tester) async { - await tester.initializeAppFlowy(cloudType: AuthenticatorType.supabase); - await tester.tapGoogleLoginInButton(); - - // Open the setting page and sign out - await tester.openSettings(); - await tester.openSettingsPage(SettingsPage.cloud); - - // the switch should be on by default - tester.assertSupabaseEnableSyncSwitchValue(true); - await tester.toggleEnableSync(SupabaseEnableSync); - - // the switch should be off - tester.assertSupabaseEnableSyncSwitchValue(false); - - // the switch should be on after toggling - await tester.toggleEnableSync(SupabaseEnableSync); - tester.assertSupabaseEnableSyncSwitchValue(true); - }); - }); -} diff --git a/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart b/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart deleted file mode 100644 index 5791803a0e..0000000000 --- a/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart +++ /dev/null @@ -1,75 +0,0 @@ -// ignore_for_file: unused_import - -import 'dart:io'; - -import 'package:flutter/material.dart'; - -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/af_cloud_mock_auth_service.dart'; -import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; -import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/uuid.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 'package:path/path.dart' as p; - -import '../desktop/board/board_hide_groups_test.dart'; -import '../shared/database_test_op.dart'; -import '../shared/dir.dart'; -import '../shared/emoji.dart'; -import '../shared/mock/mock_file_picker.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.tapEscButton(); - - // wait 2 seconds for the sync to finish - 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(UserProfileSetting)) as UserProfileSetting; - - expect(profileSetting.name, name); - }); -} diff --git a/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart b/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart deleted file mode 100644 index ddfd86acb1..0000000000 --- a/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart +++ /dev/null @@ -1,98 +0,0 @@ -// ignore_for_file: unused_import - -import 'dart:io'; - -import 'package:flutter/material.dart'; - -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/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/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_workspace.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:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; -import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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 'package:path/path.dart' as p; - -import '../../shared/database_test_op.dart'; -import '../../shared/dir.dart'; -import '../../shared/emoji.dart'; -import '../../shared/mock/mock_file_picker.dart'; -import '../../shared/util.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - final email = '${uuid()}@appflowy.io'; - - 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, - email: email, - ); - 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; - - // delete the newly created workspace - await tester.openCollaborativeWorkspaceMenu(); - final Finder items = find.byType(WorkspaceMenuItem); - 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); - }, - ); - }); - }); -} 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 index d850115632..84db6a5be0 100644 --- 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 @@ -1,4 +1,5 @@ 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'; @@ -82,23 +83,19 @@ void main() { findsOneWidget, ); - await tester.tap( - find - .descendant( - of: find.byType(AppFlowyGroupFooter), - matching: find.byType(FlowySvg), - ) - .at(1), + await tester.tapButton( + find.byType(BoardColumnFooter).at(1), ); const newCardName = 'Card 4'; await tester.enterText( find.descendant( - of: lastCard, + 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)); 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 0000000000..bdd0ecdb2c --- /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 index 68848503c4..3eedbdb3bf 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/board/board_group_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_group_test.dart @@ -1,17 +1,21 @@ +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/widgets.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', () { + group('board group test:', () { testWidgets('move row to another group', (tester) async { const card1Name = 'Card 1'; await tester.initializeAppFlowy(); @@ -46,5 +50,105 @@ void main() { 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 index b1f3d0fb45..6a012ac763 100644 --- 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 @@ -1,13 +1,13 @@ 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:flutter/material.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/database_test_op.dart'; import '../../shared/util.dart'; void main() { @@ -23,24 +23,24 @@ void main() { 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 + // Collapse hidden groups await tester.tap(expandFinder); await tester.pumpAndSettle(); - // Is expanded + // Is collapsed expect(collapseFinder, findsOneWidget); expect(expandFinder, findsNothing); + + // Expand hidden groups + await tester.tap(collapseFinder); + await tester.pumpAndSettle(); + + // Is expanded + expect(collapseFinder, findsNothing); + expect(expandFinder, findsOneWidget); }); testWidgets('hide first group, and show it again', (tester) async { @@ -48,6 +48,9 @@ void main() { await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s); + await tester.tapButton(expandFinder); + // Tap the options of the first group final optionsFinder = find .descendant( @@ -77,65 +80,46 @@ void main() { 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); + 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); + 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); + // 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(); + // 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 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.tapDialogOkButton(); + // 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); + // Expect number of groups to decrease by one + expect(tester.widgetList(find.byType(BoardColumnHeader)).length, 3); + }); }); } - -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; - } -} 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 index 239510fba7..868c27d302 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/board/board_row_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_row_test.dart @@ -6,6 +6,7 @@ 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'; @@ -14,6 +15,31 @@ 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(); @@ -29,6 +55,7 @@ void main() { }, ); await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); + await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); expect(find.text(name), findsNothing); }); @@ -50,6 +77,37 @@ void main() { 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(); 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 index 932c266bda..75323a1c80 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/board/board_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_test_runner.dart @@ -1,14 +1,18 @@ import 'package:integration_test/integration_test.dart'; -import 'board_row_test.dart' as board_row_test; 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 startTesting() { +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 0000000000..a8c05d5f80 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart @@ -0,0 +1,35 @@ +import 'data_migration/data_migration_test_runner.dart' + as data_migration_test_runner; +import 'database/database_test_runner.dart' as database_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_icon_test.dart' as sidebar_icon_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(); + sidebar_icon_test.main(); + + // database + database_test_runner.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 0000000000..e34ac02aab --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart @@ -0,0 +1,50 @@ +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.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.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 0000000000..a69c0480ce --- /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/database/database_image_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_image_test.dart new file mode 100644 index 0000000000..5561d40033 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_image_test.dart @@ -0,0 +1,80 @@ +import 'dart:io'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide UploadImageMenu, ResizableImage; +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/constants.dart'; +import '../../../shared/database_test_op.dart'; +import '../../../shared/mock/mock_file_picker.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // copy link to block + group('database image:', () { + testWidgets('insert image', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // open the first row detail page and upload an image + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Grid, + pageName: 'database image', + ); + await tester.openFirstRowDetailPage(); + + // insert an image block + { + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_image.tr(), + ); + } + + // upload an image + { + 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(); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_test_runner.dart new file mode 100644 index 0000000000..4d1a623f07 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_test_runner.dart @@ -0,0 +1,9 @@ +import 'package:integration_test/integration_test.dart'; + +import 'database_image_test.dart' as database_image_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + database_image_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 0000000000..f163608ccb --- /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 0000000000..24106cf99a --- /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 0000000000..1bc9bd8f92 --- /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(120, -20), + ); + + // 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 0000000000..7877143116 --- /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 0000000000..58a9d7398b --- /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 0000000000..b24c0faf27 --- /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_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_icon_test.dart new file mode 100644 index 0000000000..5bcca50153 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_icon_test.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:appflowy/env/cloud_env.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/sidebar_space_header.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:flowy_svg/flowy_svg.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; + }); + + testWidgets('Change slide bar space icon', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + final emojiIconData = await tester.loadIcon(); + final firstIcon = IconsData.fromJson(jsonDecode(emojiIconData.emoji)); + + await tester.hoverOnWidget( + find.byType(SidebarSpaceHeader), + onHover: () async { + final moreOption = find.byType(SpaceMorePopup); + await tester.tapButton(moreOption); + expect(find.byType(FlowyIconEmojiPicker), findsNothing); + await tester.tapSvgButton(SpaceMoreActionType.changeIcon.leftIconSvg); + expect(find.byType(FlowyIconEmojiPicker), findsOneWidget); + }, + ); + + final icons = find.byWidgetPredicate( + (w) => w is FlowySvg && w.svgString == firstIcon.svgString, + ); + expect(icons, findsOneWidget); + await tester.tapIcon(EmojiIconData.icon(firstIcon)); + + final spaceHeader = find.byType(SidebarSpaceHeader); + final spaceIcon = find.descendant( + of: spaceHeader, + matching: find.byWidgetPredicate( + (w) => w is FlowySvg && w.svgString == firstIcon.svgString, + ), + ); + expect(spaceIcon, findsOneWidget); + }); +} 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 0000000000..37abd19ebc --- /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 0000000000..8226b68b26 --- /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 0000000000..fd65c29927 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart @@ -0,0 +1,91 @@ +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); + + 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 0000000000..b6b4ecf025 --- /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 0000000000..278d880965 --- /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 0000000000..e666289bf5 --- /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/cloud/workspace/change_name_and_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/change_name_and_icon_test.dart similarity index 80% rename from frontend/appflowy_flutter/integration_test/cloud/workspace/change_name_and_icon_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/change_name_and_icon_test.dart index 75e420baac..f205b35354 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/workspace/change_name_and_icon_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/change_name_and_icon_test.dart @@ -1,23 +1,14 @@ -// ignore_for_file: unused_import - 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/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/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.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 'package:path/path.dart' as p; -import '../../shared/mock/mock_file_picker.dart'; -import '../../shared/util.dart'; -import '../../shared/workspace.dart'; +import '../../../shared/util.dart'; +import '../../../shared/workspace.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -49,6 +40,10 @@ void main() { await tester.changeWorkspaceIcon(icon); await tester.changeWorkspaceName(name); + await tester.pumpUntilNotFound( + find.text(LocaleKeys.workspace_renameSuccess.tr()), + ); + workspaceIcon = tester.widget( find.byType(WorkspaceIcon), ); 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 0000000000..4d2e027646 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/collaborative_workspace_test.dart @@ -0,0 +1,212 @@ +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/shared/loading.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/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('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('workspace menu popover behavior test', (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, + ); + + // the workspace menu shouldn't conflict with logout + 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); + expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget); + + final logoutButton = find.byType(WorkspaceMoreButton); + await tester.tapButton(logoutButton); + expect(find.text(LocaleKeys.button_logout.tr()), findsOneWidget); + expect(moreButton, findsNothing); + + await tester.tapButton(moreButton); + expect(find.text(LocaleKeys.button_logout.tr()), findsNothing); + expect(moreButton, findsOneWidget); + }, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // clicking on the more action button for the same workspace shouldn't do + // anything + await tester.openCollaborativeWorkspaceMenu(); + 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); + expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget); + + // click it again + await tester.tapButton(moreButton); + + // nothing should happen + expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget); + }, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // clicking on the more button of another workspace should close the menu + // for this one + await tester.openCollaborativeWorkspaceMenu(); + final moreButton = find.byWidgetPredicate( + (w) => w is WorkspaceMoreActionList && w.workspace.name == name, + ); + await tester.hoverOnWidget( + workspaceItem, + onHover: () async { + expect(moreButton, findsOneWidget); + await tester.tapButton(moreButton); + expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget); + }, + ); + + final otherWorspaceItem = find.byWidgetPredicate( + (w) => w is WorkspaceMenuItem && w.workspace.name != name, + ); + final otherMoreButton = find.byWidgetPredicate( + (w) => w is WorkspaceMoreActionList && w.workspace.name != name, + ); + await tester.hoverOnWidget( + otherWorspaceItem, + onHover: () async { + expect(otherMoreButton, findsOneWidget); + await tester.tapButton(otherMoreButton); + expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget); + + expect(moreButton, findsNothing); + }, + ); + }); + }); +} 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 0000000000..70bb46279e --- /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('${ShareConstants.defaultBaseWebDomain}/app/', '') + .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 0000000000..5c07d99afa --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/tabs_test.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/shared/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); + }); + + testWidgets('the space view should not be opened', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + expect(find.byType(AppFlowyEditorPage), findsNothing); + expect(find.text('Blank page'), findsOne); + }); + }); +} 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 0000000000..e9ad06caee --- /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 0000000000..a58fea25b8 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_settings_test.dart @@ -0,0 +1,353 @@ +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, + ); + if (pageItem.evaluate().isEmpty) { + return; + } + + 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(2000); + + // check if the page is published in sites page + final pageItem = find.byWidgetPredicate( + (widget) => + widget is PublishedViewItem && + widget.publishInfoView.view.name == pageName, + ); + if (pageItem.evaluate().isEmpty) { + return; + } + + 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 0000000000..4d2862038e --- /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/folder_search_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart index 80c907b9e9..0b77a0167b 100644 --- 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 @@ -1,6 +1,15 @@ +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/icon_picker.dart'; 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:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.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/flowy_infra_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -8,7 +17,9 @@ import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + }); group('Folder Search', () { testWidgets('Search for views', (tester) async { @@ -33,21 +44,106 @@ void main() { await tester.pumpAndSettle(const Duration(milliseconds: 200)); // Expect two search results "ViewOna" and "ViewOne" (Distance 1 to ViewOna) - expect(find.byType(SearchResultTile), findsNWidgets(2)); + expect(find.byType(SearchResultCell), 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); + .widget(find.byType(SearchResultCell).first) as SearchResultCell; + expect(secondDocumentWidget.item.displayName, 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); + final firstDocumentWidget = tester.widget( + find.byType(SearchResultCell).first, + ) as SearchResultCell; + expect(firstDocumentWidget.item.displayName, firstDocument); + }); + + testWidgets('Displaying icons in search results', (tester) async { + final randomValue = Random().nextInt(10000) + 10000; + final pageNames = ['First Page-$randomValue', 'Second Page-$randomValue']; + + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + final emojiIconData = await tester.loadIcon(); + + /// create two pages + for (final pageName in pageNames) { + await tester.createNewPageWithNameUnderParent(name: pageName); + await tester.updatePageIconInTitleBarByName( + name: pageName, + layout: ViewLayoutPB.Document, + icon: emojiIconData, + ); + } + + await tester.toggleCommandPalette(); + + /// search for `Page` + final searchFieldFinder = find.descendant( + of: find.byType(SearchField), + matching: find.byType(FlowyTextField), + ); + await tester.enterText(searchFieldFinder, 'Page-$randomValue'); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + expect(find.byType(SearchResultCell), findsNWidgets(2)); + + /// check results + final svgs = find.descendant( + of: find.byType(SearchResultCell), + matching: find.byType(FlowySvg), + ); + expect(svgs, findsNWidgets(2)); + + final firstSvg = svgs.first.evaluate().first.widget as FlowySvg, + lastSvg = svgs.last.evaluate().first.widget as FlowySvg; + final iconData = IconsData.fromJson(jsonDecode(emojiIconData.emoji)); + + /// icon displayed correctly + expect(firstSvg.svgString, iconData.svgString); + expect(lastSvg.svgString, iconData.svgString); + + testWidgets('select the content in document and search', (tester) async { + const firstDocument = ''; // empty document + + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(name: firstDocument); + await tester.editor.updateSelection( + Selection( + start: Position( + path: [0], + ), + end: Position( + path: [0], + offset: 10, + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byType(FloatingToolbar), + findsOneWidget, + ); + + await tester.toggleCommandPalette(); + expect(find.byType(CommandPaletteModal), findsOneWidget); + + expect( + find.text(LocaleKeys.menuAppHeader_defaultNewPageName.tr()), + findsOneWidget, + ); + + expect( + find.text(firstDocument), + findsOneWidget, + ); + }); }); }); } 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 index 277ae8f21e..b9495ae0e7 100644 --- 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 @@ -1,5 +1,5 @@ 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/search_recent_view_cell.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'; @@ -27,11 +27,12 @@ void main() { expect(find.byType(RecentViewsList), findsOneWidget); // Expect three recent history items - expect(find.byType(RecentViewTile), findsNWidgets(3)); + expect(find.byType(SearchRecentViewCell), findsNWidgets(3)); // Expect the first item to be the last viewed document final firstDocumentWidget = - tester.widget(find.byType(RecentViewTile).first) as RecentViewTile; + tester.widget(find.byType(SearchRecentViewCell).first) + as SearchRecentViewCell; 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 index a9912e3ef3..3a565cbee9 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart @@ -1,5 +1,6 @@ import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_editor.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.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'; @@ -9,7 +10,14 @@ import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); + + tearDownAll(() { + RecentIcons.enable = true; + }); group('calendar', () { testWidgets('update calendar layout', (tester) async { @@ -277,5 +285,74 @@ void main() { 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 index cb8338fbb6..ca565474ec 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart @@ -1,3 +1,4 @@ +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'; @@ -41,7 +42,7 @@ void main() { name: 'my grid', layout: ViewLayoutPB.Grid, ); - await tester.createField(FieldType.RichText, 'description'); + await tester.createField(FieldType.RichText, name: 'description'); await tester.editCell( rowIndex: 0, @@ -81,7 +82,7 @@ void main() { const fieldType = FieldType.Number; // Create a number field - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType); await tester.editCell( rowIndex: 0, @@ -157,7 +158,7 @@ void main() { const fieldType = FieldType.CreatedTime; // Create a create time field // The create time field is not editable - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); @@ -175,7 +176,7 @@ void main() { const fieldType = FieldType.LastEditedTime; // Create a last time field // The last time field is not editable - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); @@ -191,7 +192,7 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.DateTime; - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType); // Tap the cell to invoke the field editor await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); @@ -210,21 +211,21 @@ void main() { await tester.toggleIncludeTime(); // Select a date - final today = DateTime.now(); - await tester.selectDay(content: today.day); + 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(today), + content: DateFormat('MMM dd, y').format(now), ); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); // Toggle include time - final now = DateTime.now(); + now = DateTime.now(); await tester.toggleIncludeTime(); await tester.dismissCellEditor(); @@ -298,7 +299,7 @@ void main() { await tester.dismissCellEditor(); // Make sure the option is created and displayed in the cell - await tester.findSelectOptionWithNameInGrid( + tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: 'tag 1', ); @@ -310,12 +311,12 @@ void main() { await tester.createOption(name: 'tag 2'); await tester.dismissCellEditor(); - await tester.findSelectOptionWithNameInGrid( + tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: 'tag 2', ); - await tester.assertNumberOfSelectedOptionsInGrid( + tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsOneWidget, ); @@ -327,12 +328,12 @@ void main() { await tester.selectOption(name: 'tag 1'); await tester.dismissCellEditor(); - await tester.findSelectOptionWithNameInGrid( + tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: 'tag 1', ); - await tester.assertNumberOfSelectedOptionsInGrid( + tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsOneWidget, ); @@ -344,7 +345,7 @@ void main() { await tester.selectOption(name: 'tag 1'); await tester.dismissCellEditor(); - await tester.assertNumberOfSelectedOptionsInGrid( + tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsNothing, ); @@ -366,7 +367,7 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.MultiSelect; - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType, name: fieldType.i18n); // Tap the cell to invoke the selection option editor await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); @@ -377,7 +378,7 @@ void main() { await tester.dismissCellEditor(); // Make sure the option is created and displayed in the cell - await tester.findSelectOptionWithNameInGrid( + tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: tags.first, ); @@ -392,13 +393,13 @@ void main() { await tester.dismissCellEditor(); for (final tag in tags) { - await tester.findSelectOptionWithNameInGrid( + tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: tag, ); } - await tester.assertNumberOfSelectedOptionsInGrid( + tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsNWidgets(4), ); @@ -412,7 +413,7 @@ void main() { } await tester.dismissCellEditor(); - await tester.assertNumberOfSelectedOptionsInGrid( + tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsNothing, ); @@ -425,16 +426,16 @@ void main() { await tester.selectOption(name: tags[3]); await tester.dismissCellEditor(); - await tester.findSelectOptionWithNameInGrid( + tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: tags[1], ); - await tester.findSelectOptionWithNameInGrid( + tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: tags[3], ); - await tester.assertNumberOfSelectedOptionsInGrid( + tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsNWidgets(2), ); @@ -449,7 +450,7 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.Checklist; - await tester.createField(fieldType, fieldType.name); + await tester.createField(fieldType); // assert that there is no progress bar in the grid tester.assertChecklistCellInGrid(rowIndex: 0, percent: null); @@ -461,22 +462,22 @@ void main() { tester.assertChecklistEditorVisible(visible: true); // create a new task with enter - await tester.createNewChecklistTask(name: "task 0", enter: true); + await tester.createNewChecklistTask(name: "task 1", enter: true); // assert that the task is displayed tester.assertChecklistTaskInEditor( index: 0, - name: "task 0", + name: "task 1", isChecked: false, ); // update the task's name - await tester.renameChecklistTask(index: 0, name: "task 1"); + await tester.renameChecklistTask(index: 0, name: "task 11"); // assert that the task's name is updated tester.assertChecklistTaskInEditor( index: 0, - name: "task 1", + name: "task 11", isChecked: false, ); 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 index 865ea15479..a71110f1e0 100644 --- 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 @@ -1,5 +1,5 @@ import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.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'; @@ -10,11 +10,12 @@ import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('database field settings', () { + group('grid field settings test:', () { testWidgets('field visibility', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); + // create a database and add a linked database view await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); @@ -29,6 +30,11 @@ void main() { await tester.tapHidePropertyButton(); tester.noFieldWithName('New field 1'); + // create another field, New field 1 to be hidden still + await tester.tapNewPropertyButton(); + await tester.dismissFieldEditor(); + 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'); @@ -50,6 +56,50 @@ void main() { 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"); + }); + + testWidgets('field cell width', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a database and add a linked database view + 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(); + + // check the width of the field + expect(tester.getFieldWidth('New field 1'), 150); + + // change the width of the field + await tester.changeFieldWidth('New field 1', 200); + expect(tester.getFieldWidth('New field 1'), 205); + + // create another field, New field 1 to be same width + await tester.tapNewPropertyButton(); + await tester.dismissFieldEditor(); + expect(tester.getFieldWidth('New field 1'), 205); + + // go back to inline database view, expect New field 1 to be 150px + await tester.tapTabBarLinkedViewByViewName('Untitled'); + expect(tester.getFieldWidth('New field 1'), 150); + + // go back to linked database view, expect New field 1 to be 205px + await tester.tapTabBarLinkedViewByViewName('Grid'); + expect(tester.getFieldWidth('New field 1'), 205); }); }); } 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 index cc1187da21..6ce248a8a1 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart @@ -1,20 +1,29 @@ +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/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-database2/protobuf.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_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(); + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); - group('grid field editor:', () { + tearDownAll(() { + RecentIcons.enable = true; + }); + + group('grid edit field test:', () { testWidgets('rename existing field', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); @@ -23,7 +32,6 @@ void main() { // Invoke the field editor await tester.tapGridFieldWithName('Name'); - await tester.tapEditFieldButton(); await tester.renameField('hello world'); await tester.dismissFieldEditor(); @@ -32,6 +40,32 @@ void main() { 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(); @@ -56,11 +90,22 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // create a field - await tester.createField(FieldType.Checklist, 'checklist'); + await tester.createField(FieldType.Checklist); + tester.findFieldWithName(FieldType.Checklist.i18n); - // check the field is created successfully - tester.findFieldWithName('checklist'); - await tester.pumpAndSettle(); + // 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 { @@ -70,14 +115,14 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // create a field - await tester.createField(FieldType.Checkbox, 'New field 1'); + 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.tapDialogOkButton(); + await tester.tapButtonWithName(LocaleKeys.space_delete.tr()); tester.noFieldWithName('New field 1'); await tester.pumpAndSettle(); @@ -90,10 +135,7 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // create a field - await tester.scrollToRight(find.byType(GridPage)); - await tester.tapNewPropertyButton(); - await tester.renameField('New field 1'); - await tester.dismissFieldEditor(); + await tester.createField(FieldType.RichText, name: 'New field 1'); // duplicate the field await tester.tapGridFieldWithName('New field 1'); @@ -117,7 +159,7 @@ void main() { await tester.dismissFieldEditor(); tester.findFieldWithName('Right'); - // insert new field to the right + // insert new field to the left await tester.tapGridFieldWithName('Type'); await tester.tapInsertFieldButton(left: true, name: "Left"); await tester.dismissFieldEditor(); @@ -126,26 +168,6 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('create checklist field', (tester) async { - await tester.initializeAppFlowy(); - await tester.tapAnonymousSignInButton(); - - await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); - - await tester.scrollToRight(find.byType(GridPage)); - await tester.tapNewPropertyButton(); - - // Open the type option menu - await tester.tapSwitchFieldTypeButton(); - - await tester.selectFieldType(FieldType.Checklist); - - // After update the field type, the cells should be updated - await tester.findCellByFieldType(FieldType.Checklist); - - await tester.pumpAndSettle(); - }); - testWidgets('create list of fields', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); @@ -162,18 +184,10 @@ void main() { FieldType.CreatedTime, FieldType.Checkbox, ]) { - await tester.scrollToRight(find.byType(GridPage)); - await tester.tapNewPropertyButton(); - await tester.renameField(fieldType.name); - - // Open the type option menu - await tester.tapSwitchFieldTypeButton(); - - await tester.selectFieldType(fieldType); - await tester.dismissFieldEditor(); + await tester.createField(fieldType); // After update the field type, the cells should be updated - await tester.findCellByFieldType(fieldType); + tester.findCellByFieldType(fieldType); await tester.pumpAndSettle(); } }); @@ -190,15 +204,7 @@ void main() { FieldType.Checklist, FieldType.URL, ]) { - // create the field - await tester.scrollToRight(find.byType(GridPage)); - await tester.tapNewPropertyButton(); - await tester.renameField(fieldType.i18n); - - // change field type - await tester.tapSwitchFieldTypeButton(); - await tester.selectFieldType(fieldType); - await tester.dismissFieldEditor(); + await tester.createField(fieldType); // open the field editor await tester.tapGridFieldWithName(fieldType.i18n); @@ -218,11 +224,7 @@ void main() { await tester.scrollToRight(find.byType(GridPage)); // create a number field - await tester.tapNewPropertyButton(); - await tester.renameField("Number"); - await tester.tapSwitchFieldTypeButton(); - await tester.selectFieldType(FieldType.Number); - await tester.dismissFieldEditor(); + await tester.createField(FieldType.Number); // enter some data into the first number cell await tester.editCell( @@ -243,7 +245,7 @@ void main() { ); // open editor and change number format - await tester.tapGridFieldWithName('Number'); + await tester.tapGridFieldWithName(FieldType.Number.i18n); await tester.tapEditFieldButton(); await tester.changeNumberFieldFormat(); await tester.dismissFieldEditor(); @@ -276,7 +278,6 @@ void main() { matching: find.byType(TextField), ); await tester.enterText(inputField, text); - await tester.pumpAndSettle(); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(const Duration(milliseconds: 500)); @@ -292,11 +293,7 @@ void main() { await tester.scrollToRight(find.byType(GridPage)); // create a date field - await tester.tapNewPropertyButton(); - await tester.renameField(FieldType.DateTime.i18n); - await tester.tapSwitchFieldTypeButton(); - await tester.selectFieldType(FieldType.DateTime); - await tester.dismissFieldEditor(); + await tester.createField(FieldType.DateTime); // edit the first date cell await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); @@ -327,6 +324,30 @@ void main() { ); }); + 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 { @@ -392,5 +413,188 @@ void main() { // 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 index b6db3e1a62..8e79445503 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart @@ -1,17 +1,21 @@ +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_backend/protobuf/flowy-database2/field_entities.pbenum.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.openV020database(); + await tester.openTestDatabase(v020GridFileName); // create a filter await tester.tapDatabaseFilterButton(); @@ -19,68 +23,68 @@ void main() { await tester.tapFilterButtonInGrid('Name'); // enter 'A' in the filter text field - await tester.assertNumberOfRowsInGridPage(10); + tester.assertNumberOfRowsInGridPage(10); await tester.enterTextInTextFilter('A'); - await tester.assertNumberOfRowsInGridPage(1); + tester.assertNumberOfRowsInGridPage(1); // after remove the filter, the grid should show all rows await tester.enterTextInTextFilter(''); - await tester.assertNumberOfRowsInGridPage(10); + tester.assertNumberOfRowsInGridPage(10); await tester.enterTextInTextFilter('B'); - await tester.assertNumberOfRowsInGridPage(1); + tester.assertNumberOfRowsInGridPage(1); // open the menu to delete the filter await tester.tapDisclosureButtonInFinder(find.byType(TextFilterEditor)); await tester.tapDeleteFilterButtonInGrid(); - await tester.assertNumberOfRowsInGridPage(10); + tester.assertNumberOfRowsInGridPage(10); await tester.pumpAndSettle(); }); testWidgets('add checkbox filter', (tester) async { - await tester.openV020database(); + await tester.openTestDatabase(v020GridFileName); // create a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType(FieldType.Checkbox, 'Done'); - await tester.assertNumberOfRowsInGridPage(5); + tester.assertNumberOfRowsInGridPage(5); await tester.tapFilterButtonInGrid('Done'); await tester.tapCheckboxFilterButtonInGrid(); await tester.tapUnCheckedButtonOnCheckboxFilter(); - await tester.assertNumberOfRowsInGridPage(5); + tester.assertNumberOfRowsInGridPage(5); await tester .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); await tester.tapDeleteFilterButtonInGrid(); - await tester.assertNumberOfRowsInGridPage(10); + tester.assertNumberOfRowsInGridPage(10); await tester.pumpAndSettle(); }); testWidgets('add checklist filter', (tester) async { - await tester.openV020database(); + 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' - await tester.assertNumberOfRowsInGridPage(9); + tester.assertNumberOfRowsInGridPage(9); await tester.tapFilterButtonInGrid('checklist'); await tester.tapChecklistFilterButtonInGrid(); await tester.tapCompletedButtonOnChecklistFilter(); - await tester.assertNumberOfRowsInGridPage(1); + tester.assertNumberOfRowsInGridPage(1); await tester.pumpAndSettle(); }); testWidgets('add single select filter', (tester) async { - await tester.openV020database(); + await tester.openTestDatabase(v020GridFileName); // create a filter await tester.tapDatabaseFilterButton(); @@ -90,27 +94,27 @@ void main() { // select the option 's6' await tester.tapOptionFilterWithName('s6'); - await tester.assertNumberOfRowsInGridPage(0); + tester.assertNumberOfRowsInGridPage(0); // unselect the option 's6' await tester.tapOptionFilterWithName('s6'); - await tester.assertNumberOfRowsInGridPage(10); + tester.assertNumberOfRowsInGridPage(10); // select the option 's5' await tester.tapOptionFilterWithName('s5'); - await tester.assertNumberOfRowsInGridPage(1); + tester.assertNumberOfRowsInGridPage(1); // select the option 's4' await tester.tapOptionFilterWithName('s4'); // The row with 's4' should be shown. - await tester.assertNumberOfRowsInGridPage(1); + tester.assertNumberOfRowsInGridPage(2); await tester.pumpAndSettle(); }); testWidgets('add multi select filter', (tester) async { - await tester.openV020database(); + await tester.openTestDatabase(v020GridFileName); // create a filter await tester.tapDatabaseFilterButton(); @@ -124,17 +128,95 @@ void main() { // select the option 'm1'. Any option with 'm1' should be shown. await tester.tapOptionFilterWithName('m1'); - await tester.assertNumberOfRowsInGridPage(5); + tester.assertNumberOfRowsInGridPage(5); await tester.tapOptionFilterWithName('m1'); // select the option 'm2'. Any option with 'm2' should be shown. await tester.tapOptionFilterWithName('m2'); - await tester.assertNumberOfRowsInGridPage(4); + tester.assertNumberOfRowsInGridPage(4); await tester.tapOptionFilterWithName('m2'); // select the option 'm4'. Any option with 'm4' should be shown. await tester.tapOptionFilterWithName('m4'); - await tester.assertNumberOfRowsInGridPage(1); + 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_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_icon_test.dart new file mode 100644 index 0000000000..e6a629ded5 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_icon_test.dart @@ -0,0 +1,190 @@ +import 'dart:convert'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.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/database_layout_ext.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.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/shared/sidebar_folder.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.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/emoji.dart'; +import '../../shared/util.dart'; + +void main() { + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); + + tearDownAll(() { + RecentIcons.enable = true; + }); + + testWidgets('change icon', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + final iconData = await tester.loadIcon(); + + const pageName = 'Database'; + await tester.createNewPageWithNameUnderParent( + layout: ViewLayoutPB.Grid, + name: pageName, + ); + + /// create board + final addButton = find.byType(AddDatabaseViewButton); + await tester.tapButton(addButton); + await tester.tapButton( + find.text( + '${LocaleKeys.grid_createView.tr()} ${DatabaseLayoutPB.Board.layoutName}', + findRichText: true, + ), + ); + + /// create calendar + await tester.tapButton(addButton); + await tester.tapButton( + find.text( + '${LocaleKeys.grid_createView.tr()} ${DatabaseLayoutPB.Calendar.layoutName}', + findRichText: true, + ), + ); + + final databaseTabBarItem = find.byType(DatabaseTabBarItem); + expect(databaseTabBarItem, findsNWidgets(3)); + final gridItem = databaseTabBarItem.first, + boardItem = databaseTabBarItem.at(1), + calendarItem = databaseTabBarItem.last; + + /// change the icon of grid + /// the first tapping is to select specific item + /// the second tapping is to show the menu + await tester.tapButton(gridItem); + await tester.tapButton(gridItem); + + /// change icon + await tester + .tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr())); + await tester.tapIcon(iconData, enableColor: false); + final gridIcon = find.descendant( + of: gridItem, + matching: find.byType(RawEmojiIconWidget), + ); + final gridIconWidget = + gridIcon.evaluate().first.widget as RawEmojiIconWidget; + final iconsData = IconsData.fromJson(jsonDecode(iconData.emoji)); + final gridIconsData = + IconsData.fromJson(jsonDecode(gridIconWidget.emoji.emoji)); + expect(gridIconsData.iconName, iconsData.iconName); + + /// change the icon of board + await tester.tapButton(boardItem); + await tester.tapButton(boardItem); + await tester + .tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr())); + await tester.tapIcon(iconData, enableColor: false); + final boardIcon = find.descendant( + of: boardItem, + matching: find.byType(RawEmojiIconWidget), + ); + final boardIconWidget = + boardIcon.evaluate().first.widget as RawEmojiIconWidget; + final boardIconsData = + IconsData.fromJson(jsonDecode(boardIconWidget.emoji.emoji)); + expect(boardIconsData.iconName, iconsData.iconName); + + /// change the icon of calendar + await tester.tapButton(calendarItem); + await tester.tapButton(calendarItem); + await tester + .tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr())); + await tester.tapIcon(iconData, enableColor: false); + final calendarIcon = find.descendant( + of: calendarItem, + matching: find.byType(RawEmojiIconWidget), + ); + final calendarIconWidget = + calendarIcon.evaluate().first.widget as RawEmojiIconWidget; + final calendarIconsData = + IconsData.fromJson(jsonDecode(calendarIconWidget.emoji.emoji)); + expect(calendarIconsData.iconName, iconsData.iconName); + }); + + testWidgets('change database icon from sidebar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + final iconData = await tester.loadIcon(); + final icon = IconsData.fromJson(jsonDecode(iconData.emoji)), emoji = '😄'; + + const pageName = 'Database'; + await tester.createNewPageWithNameUnderParent( + layout: ViewLayoutPB.Grid, + name: pageName, + ); + final viewItem = find.descendant( + of: find.byType(SidebarFolder), + matching: find.byWidgetPredicate( + (w) => w is ViewItem && w.view.name == pageName, + ), + ); + + /// change icon to emoji + await tester.tapButton( + find.descendant( + of: viewItem, + matching: find.byType(FlowySvg), + ), + ); + await tester.tapEmoji(emoji); + final iconWidget = find.descendant( + of: viewItem, + matching: find.byType(RawEmojiIconWidget), + ); + expect( + (iconWidget.evaluate().first.widget as RawEmojiIconWidget).emoji.emoji, + emoji, + ); + + /// the icon will not be displayed in database item + Finder databaseIcon = find.descendant( + of: find.byType(DatabaseTabBarItem), + matching: find.byType(FlowySvg), + ); + expect( + (databaseIcon.evaluate().first.widget as FlowySvg).svg, + FlowySvgs.icon_grid_s, + ); + + /// change emoji to icon + await tester.tapButton(iconWidget); + await tester.tapIcon(iconData); + expect( + (iconWidget.evaluate().first.widget as RawEmojiIconWidget).emoji.emoji, + iconData.emoji, + ); + + databaseIcon = find.descendant( + of: find.byType(DatabaseTabBarItem), + matching: find.byType(RawEmojiIconWidget), + ); + final databaseIconWidget = + databaseIcon.evaluate().first.widget as RawEmojiIconWidget; + final databaseIconsData = + IconsData.fromJson(jsonDecode(databaseIconWidget.emoji.emoji)); + expect(icon.svgString, databaseIconsData.svgString); + expect(icon.color, isNotEmpty); + expect(icon.color, databaseIconsData.color); + + /// the icon in database item should not show the color + expect(databaseIconWidget.enableColor, false); + }); +} 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 0000000000..cb24a949bb --- /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_row_cover_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_cover_test.dart new file mode 100644 index 0000000000..8741dcd75f --- /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 index 0934e7721b..22f059d199 100644 --- 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 @@ -1,10 +1,19 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/plugins/database/widgets/row/row_banner.dart'; +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:flowy_infra_ui/style_widget/text.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'; @@ -13,7 +22,14 @@ import '../../shared/emoji.dart'; import '../../shared/util.dart'; void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); + + tearDownAll(() { + RecentIcons.enable = true; + }); group('grid row detail page:', () { testWidgets('opens', (tester) async { @@ -33,26 +49,7 @@ void main() { await tester.assertDocumentExistInRowDetailPage(); }); - testWidgets('add 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('😀'); - - // After select the emoji, the EmojiButton will show up - await tester.tapButton(find.byType(EmojiButton)); - }); - - testWidgets('update emoji', (tester) async { + testWidgets('add and update emoji', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); @@ -65,8 +62,18 @@ void main() { await tester.openEmojiPicker(); await tester.tapEmoji('😀'); - // Update existing selected emoji - await tester.tapButton(find.byType(EmojiButton)); + // 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 @@ -77,6 +84,24 @@ void main() { // 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 { @@ -93,7 +118,9 @@ void main() { await tester.tapEmoji('😀'); // Remove the emoji - await tester.tapButton(find.byType(RemoveEmojiButton)); + 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 == '😀', ); @@ -121,15 +148,24 @@ void main() { FieldType.Checkbox, ]) { await tester.tapRowDetailPageCreatePropertyButton(); - await tester.renameField(fieldType.name); // 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 - await tester.findCellByFieldType(fieldType); + tester.findCellByFieldType(fieldType); await tester.scrollRowDetailByOffset(const Offset(0, -50)); } }); @@ -309,7 +345,7 @@ void main() { await tester.tapRowDetailPageDeleteRowButton(); await tester.tapEscButton(); - await tester.assertNumberOfRowsInGridPage(2); + tester.assertNumberOfRowsInGridPage(2); }); testWidgets('duplicate row', (tester) async { @@ -326,7 +362,147 @@ void main() { await tester.tapRowDetailPageDuplicateRowButton(); await tester.tapEscButton(); - await tester.assertNumberOfRowsInGridPage(4); + 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_row_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_test.dart deleted file mode 100644 index 6721de2d16..0000000000 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_row_test.dart +++ /dev/null @@ -1,65 +0,0 @@ -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('create row of the grid', (tester) async { - await tester.initializeAppFlowy(); - await tester.tapAnonymousSignInButton(); - - await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); - await tester.tapCreateRowButtonInGrid(); - - // 3 initial rows + 1 created - await tester.assertNumberOfRowsInGridPage(4); - await tester.pumpAndSettle(); - }); - - testWidgets('create row from row menu of the grid', (tester) async { - await tester.initializeAppFlowy(); - await tester.tapAnonymousSignInButton(); - - await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); - - await tester.hoverOnFirstRowOfGrid(); - - await tester.tapCreateRowButtonInRowMenuOfGrid(); - - // 3 initial rows + 1 created - await tester.assertNumberOfRowsInGridPage(4); - await tester.pumpAndSettle(); - }); - - testWidgets('delete row of the grid', (tester) async { - await tester.initializeAppFlowy(); - await tester.tapAnonymousSignInButton(); - - await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); - await tester.hoverOnFirstRowOfGrid(); - - // Open the row menu and then click the delete - await tester.tapRowMenuButtonInGrid(); - await tester.tapDeleteOnRowMenu(); - - // 3 initial rows - 1 deleted - await tester.assertNumberOfRowsInGridPage(2); - await tester.pumpAndSettle(); - }); - - testWidgets('check number of row indicator in the initial grid', - (tester) async { - await tester.initializeAppFlowy(); - await tester.tapAnonymousSignInButton(); - - await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); - - 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 index bd3adff7cc..2beb74a5f2 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_share_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_share_test.dart @@ -9,7 +9,7 @@ void main() { group('database', () { testWidgets('import v0.2.0 database data', (tester) async { - await tester.openV020database(); + await tester.openTestDatabase(v020GridFileName); // wait the database data is loaded await tester.pumpAndSettle(const Duration(microseconds: 500)); @@ -115,7 +115,7 @@ void main() { [], ]; for (final (index, contents) in multiSelectCells.indexed) { - await tester.assertMultiSelectOption( + tester.assertMultiSelectOption( rowIndex: index, contents: contents, ); 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 index e28072cebc..e09d8718be 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_sort_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_sort_test.dart @@ -1,3 +1,4 @@ +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'; @@ -7,9 +8,9 @@ import '../../shared/database_test_op.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('grid', () { - testWidgets('add text sort', (tester) async { - await tester.openV020database(); + group('grid sort:', () { + testWidgets('text sort', (tester) async { + await tester.openTestDatabase(v020GridFileName); // create a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); @@ -37,7 +38,7 @@ void main() { // open the sort menu and select order by descending await tester.tapSortMenuInSettingBar(); - await tester.tapSortButtonByName('Name'); + await tester.tapEditSortConditionButtonByFieldName('Name'); await tester.tapSortByDescending(); for (final (index, content) in [ 'E', @@ -60,7 +61,7 @@ void main() { // delete all sorts await tester.tapSortMenuInSettingBar(); - await tester.tapAllSortButton(); + await tester.tapDeleteAllSortsButton(); // check the text cell order for (final (index, content) in [ @@ -84,8 +85,8 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('add checkbox sort', (tester) async { - await tester.openV020database(); + testWidgets('checkbox', (tester) async { + await tester.openTestDatabase(v020GridFileName); // create a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.Checkbox, 'Done'); @@ -111,7 +112,7 @@ void main() { // open the sort menu and select order by descending await tester.tapSortMenuInSettingBar(); - await tester.tapSortButtonByName('Done'); + await tester.tapEditSortConditionButtonByFieldName('Done'); await tester.tapSortByDescending(); for (final (index, content) in [ true, @@ -134,8 +135,8 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('add number sort', (tester) async { - await tester.openV020database(); + testWidgets('number', (tester) async { + await tester.openTestDatabase(v020GridFileName); // create a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.Number, 'number'); @@ -162,7 +163,7 @@ void main() { // open the sort menu and select order by descending await tester.tapSortMenuInSettingBar(); - await tester.tapSortButtonByName('number'); + await tester.tapEditSortConditionButtonByFieldName('number'); await tester.tapSortByDescending(); for (final (index, content) in [ '12', @@ -186,15 +187,15 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('add checkbox and number sort', (tester) async { - await tester.openV020database(); + 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.tapSortButtonByName('Done'); + await tester.tapEditSortConditionButtonByFieldName('Done'); await tester.tapSortByDescending(); for (final (index, content) in [ true, @@ -220,7 +221,7 @@ void main() { FieldType.Number, 'number', ); - await tester.tapSortButtonByName('number'); + await tester.tapEditSortConditionButtonByFieldName('number'); await tester.tapSortByDescending(); // check checkbox cell order @@ -266,14 +267,14 @@ void main() { }); testWidgets('reorder sort', (tester) async { - await tester.openV020database(); + 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.tapSortButtonByName('Done'); + await tester.tapEditSortConditionButtonByFieldName('Done'); await tester.tapSortByDescending(); // add another sort, this time by number descending @@ -282,7 +283,7 @@ void main() { FieldType.Number, 'number', ); - await tester.tapSortButtonByName('number'); + await tester.tapEditSortConditionButtonByFieldName('number'); await tester.tapSortByDescending(); // check checkbox cell order @@ -370,5 +371,101 @@ void main() { ); } }); + + 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 0000000000..3a5854bc1b --- /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 0000000000..26b64af495 --- /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 index e35c9cc9d8..71656c1ea6 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart @@ -1,5 +1,10 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -73,5 +78,37 @@ void main() { await tester.pumpAndSettle(); }); + + testWidgets('insert grid in column', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// create page and show slash menu + await tester.createNewPageWithNameUnderParent(name: 'test page'); + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + + /// create a column + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_twoColumns.tr(), + ); + final actionList = find.byType(BlockActionList); + expect(actionList, findsNWidgets(2)); + final position = tester.getCenter(actionList.last); + + /// tap the second child of column + await tester.tapAt(position.copyWith(dx: position.dx + 50)); + + /// create a grid + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_grid.tr(), + ); + + final grid = find.byType(GridPageContent); + expect(grid, findsOneWidget); + }); }); } 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 index de7401652e..1a8a3fcda8 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart @@ -27,8 +27,9 @@ void main() { await tester.pumpAndSettle(); // click the align center - await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s); - await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s); + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m); + await tester + .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_center_m); // expect to see the align center final editorState = tester.editor.getCurrentEditorState(); @@ -36,13 +37,15 @@ void main() { 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); + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m); + await tester + .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_right_m); 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); + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m); + await tester + .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_left_m); expect(first.attributes[blockComponentAlign], 'left'); }); @@ -66,6 +69,7 @@ void main() { LogicalKeyboardKey.keyR, ], tester: tester, + withKeyUp: true, ); expect(first.attributes[blockComponentAlign], rightAlignmentKey); @@ -74,9 +78,10 @@ void main() { [ LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyC, ], tester: tester, + withKeyUp: true, ); expect(first.attributes[blockComponentAlign], centerAlignmentKey); @@ -88,6 +93,7 @@ void main() { 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 0000000000..fdde8bbeb8 --- /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 0000000000..76e5dfcb6c --- /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_callout_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_callout_test.dart new file mode 100644 index 0000000000..b5449ec622 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_callout_test.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/base/icon/icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; +import 'package:easy_localization/easy_localization.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; + }); + + testWidgets('callout with emoji icon picker', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + final emojiIconData = await tester.loadIcon(); + + /// create a new document + await tester.createNewPageWithNameUnderParent(); + + /// tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + + /// create callout + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_callout.tr(), + ); + + /// select an icon + final emojiPickerButton = find.descendant( + of: find.byType(CalloutBlockComponentWidget), + matching: find.byType(EmojiPickerButton), + ); + await tester.tapButton(emojiPickerButton); + await tester.tapIcon(emojiIconData); + + /// verification results + final iconData = IconsData.fromJson(jsonDecode(emojiIconData.emoji)); + final iconWidget = find + .descendant( + of: emojiPickerButton, + matching: find.byType(IconWidget), + ) + .evaluate() + .first + .widget as IconWidget; + final iconWidgetData = iconWidget.iconsData; + expect(iconWidgetData.svgString, iconData.svgString); + expect(iconWidgetData.iconName, iconData.iconName); + expect(iconWidgetData.groupName, iconData.groupName); + }); +} 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 index f06a273fac..a498086952 100644 --- 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 @@ -1,10 +1,10 @@ import 'dart:io'; -import 'package:flutter/services.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_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'; @@ -13,18 +13,21 @@ import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('paste in codeblock', () { + 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(); + 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); @@ -51,7 +54,9 @@ Future insertCodeBlockInDocument(WidgetTester tester) async { // open the actions menu and insert the codeBlock await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( - LocaleKeys.document_selectionMenu_codeBlock.tr(), + 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 index 0ea1391790..d1e34edcb5 100644 --- 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 @@ -1,29 +1,40 @@ +import 'dart:async'; 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/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/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.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', () { + 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, 3); + 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( - editorState.getNodeAtPath([i])!.delta!.toPlainText(), + textLines[i], 'line $i', ); } @@ -116,146 +127,410 @@ void main() { ]); }); }); - }); - 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, 2); - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ImageBlockKeys.type); - expect(node.attributes[ImageBlockKeys.url], isNotNull); - }); - }); + 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"}]}}]}}'; - 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, 2); - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ImageBlockKeys.type); - expect(node.attributes[ImageBlockKeys.url], isNotNull); - }); - }); - - testWidgets('paste image(gif) from memory', (tester) async { - // It's not supported yet. - // 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, 2); - // 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, + inAppJson: inAppJson, beforeTest: (editorState) async { - await tester.ime.insertText(text); - await tester.editor.updateSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: text.length, - ), + 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 node = editorState.getNodeAtPath([0])!; - expect(node.type, ParagraphBlockKeys.type); - expect(node.delta!.toJson(), [ - { - 'insert': text, - 'attributes': {'href': url}, - } - ]); + 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); }, ); - }, - ); + }); - // 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'''; + 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( - html: html, - image: ('png', bytes), - (editorState) { - expect(editorState.document.root.children.length, 2); - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ImageBlockKeys.type); - expect( - node.attributes[ImageBlockKeys.url], - 'https://user-images.githubusercontent.com/9403740/262918875-603f4adb-58dd-49b5-8201-341d354935fd.png', - ); - }, - ); - }, - ); + 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 the html content contains section', (tester) async { - const html = - '''
AppFlowy
Hello World
'''; - await tester.pasteContent( - html: html, - (editorState) { + 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) { + 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', - (widgetTester) async { - const url = 'https://appflowy.io'; - await widgetTester.pasteContent( - plainText: url, - (editorState) { - expect(editorState.document.root.children.length, 2); + testWidgets( + 'auto convert url to link preview block', + (tester) async { + const url = 'https://appflowy.io'; + await tester.pasteContent(plainText: url, (editorState) async { + final pasteAsMenu = find.byType(PasteAsMenu); + expect(pasteAsMenu, findsOneWidget); + final bookmarkButton = find.text( + LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(), + ); + await tester.tapButton(bookmarkButton); + // the second one is the paragraph node + expect(editorState.document.root.children.length, 1); 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 { + /// show menu + final menu = find.byType(CustomLinkPreviewMenu); + expect(menu, findsOneWidget); + await tester.tapButton(menu); + + final convertToLinkButton = find.text( + LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(), + ); + expect(convertToLinkButton, findsOneWidget); + await tester.tapButton(convertToLinkButton); + }, + ); + + 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 { + final pasteAsMenu = find.byType(PasteAsMenu); + expect(pasteAsMenu, findsOneWidget); + final bookmarkButton = find.text( + LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(), + ); + await tester.tapButton(bookmarkButton); + // the second one is the paragraph node + expect(editorState.document.root.children.length, 1); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, LinkPreviewBlockKeys.type); + expect(node.attributes[LinkPreviewBlockKeys.url], url); + }); + + 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 = 'http://example.com/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, { + FutureOr Function(EditorState editorState) test, { Future Function(EditorState editorState)? beforeTest, String? plainText, String? html, + String? inAppJson, + bool pasteAsPlain = false, (String, Uint8List?)? image, }) async { await initializeAppFlowy(); @@ -263,6 +538,8 @@ extension on WidgetTester { // create a new document await createNewPageWithNameUnderParent(); + // tap the editor + await tapButton(find.byType(AppFlowyEditor)); await beforeTest?.call(editor.getCurrentEditorState()); @@ -271,6 +548,7 @@ extension on WidgetTester { ClipboardServiceData( plainText: plainText, html: html, + inAppJson: inAppJson, image: image, ), ); @@ -279,10 +557,11 @@ extension on WidgetTester { await simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, + isShiftPressed: pasteAsPlain, isMetaPressed: Platform.isMacOS, ); - await pumpAndSettle(); + await pumpAndSettle(const Duration(milliseconds: 1000)); - test(editor.getCurrentEditorState()); + await 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 index cf45afc828..c2e00a4b48 100644 --- 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 @@ -1,6 +1,4 @@ -import 'package:appflowy/generated/locale_keys.g.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'; @@ -15,14 +13,15 @@ void main() { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); + final finder = find.text(gettingStarted, findRichText: true); + await tester.pumpUntilFound(finder, timeout: const Duration(seconds: 2)); // create a new document - await tester.createNewPageWithNameUnderParent(); + const pageName = 'Test Document'; + await tester.createNewPageWithNameUnderParent(name: pageName); // expect to see a new document - tester.expectToSeePageName( - LocaleKeys.menuAppHeader_defaultNewPageName.tr(), - ); + tester.expectToSeePageName(pageName); // and with one paragraph block expect(find.byType(ParagraphBlockComponentWidget), findsOneWidget); }); 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 0000000000..5cbb133f9d --- /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 0000000000..f38138ce8a --- /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 0000000000..6212e7d9cf --- /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 index 0bbd64c82b..f169910840 100644 --- 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 @@ -111,6 +111,7 @@ Future triggerReferenceDocumentBySlashMenu(WidgetTester tester) async { LogicalKeyboardKey.enter, ], tester: tester, + withKeyUp: true, ); await tester.pumpAndSettle(); @@ -129,6 +130,7 @@ Future enterDocumentText(WidgetTester tester) async { 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 0000000000..30e115774a --- /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_link_preview_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart new file mode 100644 index 0000000000..39f8bfd4f6 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart @@ -0,0 +1,453 @@ +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/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_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/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(); + + const avaliableLink = 'https://appflowy.io/', + unavailableLink = 'www.thereIsNoting.com'; + + Future preparePage(WidgetTester tester, {String? pageName}) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: pageName); + await tester.editor.tapLineOfEditorAt(0); + } + + Future pasteLink(WidgetTester tester, String link) async { + await getIt() + .setData(ClipboardServiceData(plainText: link)); + + /// paste the link + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(Duration(seconds: 1)); + } + + Future pasteAs( + WidgetTester tester, + String link, + PasteMenuType type, { + Duration waitTime = const Duration(milliseconds: 500), + }) async { + await pasteLink(tester, link); + final convertToMentionButton = find.text(type.title); + await tester.tapButton(convertToMentionButton); + await tester.pumpAndSettle(waitTime); + } + + void checkUrl(Node node, String link) { + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + { + 'insert': link, + 'attributes': {'href': link}, + } + ]); + } + + void checkMention(Node node, String link) { + 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.externalLink.name); + expect(mention[MentionBlockKeys.url], avaliableLink); + } + + void checkBookmark(Node node, String link) { + expect(node.type, LinkPreviewBlockKeys.type); + expect(node.attributes[LinkPreviewBlockKeys.url], link); + } + + void checkEmbed(Node node, String link) { + expect(node.type, LinkPreviewBlockKeys.type); + expect(node.attributes[LinkEmbedKeys.previewType], LinkEmbedKeys.embed); + expect(node.attributes[LinkPreviewBlockKeys.url], link); + } + + group('Paste as URL', () { + Future pasteAndTurnInto( + WidgetTester tester, + String link, + String title, + ) async { + await pasteLink(tester, link); + final convertToLinkButton = find + .text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr()); + await tester.tapButton(convertToLinkButton); + + /// hover link and turn into mention + await tester.hoverOnWidget( + find.byType(LinkHoverTrigger), + onHover: () async { + final turnintoButton = find.byFlowySvg(FlowySvgs.turninto_m); + await tester.tapButton(turnintoButton); + final convertToButton = find.text(title); + await tester.tapButton(convertToButton); + await tester.pumpAndSettle(Duration(seconds: 1)); + }, + ); + } + + testWidgets('paste a link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteLink(tester, link); + final convertToLinkButton = find + .text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr()); + await tester.tapButton(convertToLinkButton); + final node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste a link and turn into mention', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAndTurnInto( + tester, + link, + LinkConvertMenuCommand.toMention.title, + ); + + /// check metion values + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste a link and turn into bookmark', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAndTurnInto( + tester, + link, + LinkConvertMenuCommand.toBookmark.title, + ); + + /// check metion values + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + + testWidgets('paste a link and turn into embed', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAndTurnInto( + tester, + link, + LinkConvertMenuCommand.toEmbed.title, + ); + + /// check metion values + final node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + }); + + group('Paste as Mention', () { + Future pasteAsMention(WidgetTester tester, String link) => + pasteAs(tester, link, PasteMenuType.mention); + + String getMentionLink(Node node) { + final insert = node.delta?.first as TextInsert?; + final mention = insert?.attributes?[MentionBlockKeys.mention] + as Map?; + return mention?[MentionBlockKeys.url] ?? ''; + } + + Future hoverMentionAndClick( + WidgetTester tester, + String command, + ) async { + final mentionLink = find.byType(MentionLinkBlock); + expect(mentionLink, findsOneWidget); + await tester.hoverOnWidget( + mentionLink, + onHover: () async { + final errorPreview = find.byType(MentionLinkErrorPreview); + expect(errorPreview, findsOneWidget); + final convertButton = find.byFlowySvg(FlowySvgs.turninto_m); + await tester.tapButton(convertButton); + final menuButton = find.text(command); + await tester.tapButton(menuButton); + }, + ); + } + + testWidgets('paste a link as mention', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste as mention and copy link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + final mentionLink = find.byType(MentionLinkBlock); + expect(mentionLink, findsOneWidget); + await tester.hoverOnWidget( + mentionLink, + onHover: () async { + final preview = find.byType(MentionLinkPreview); + if (!preview.hasFound) { + final copyButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); + await tester.tapButton(copyButton); + } else { + final moreOptionButton = find.byFlowySvg(FlowySvgs.toolbar_more_m); + await tester.tapButton(moreOptionButton); + final copyButton = + find.text(MentionLinktMenuCommand.copyLink.title); + await tester.tapButton(copyButton); + } + }, + ); + final clipboardContent = await getIt().getData(); + expect(clipboardContent.plainText, link); + }); + + testWidgets('paste as error mention and turninto url', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.toURL.title, + ); + node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste as error mention and turninto embed', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.toEmbed.title, + ); + node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + + testWidgets('paste as error mention and turninto bookmark', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.toBookmark.title, + ); + node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + + testWidgets('paste as error mention and remove link', (tester) async { + String link = unavailableLink; + await preparePage(tester); + await pasteAsMention(tester, link); + Node node = tester.editor.getNodeAtPath([0]); + link = getMentionLink(node); + await hoverMentionAndClick( + tester, + MentionLinktErrorMenuCommand.removeLink.title, + ); + node = tester.editor.getNodeAtPath([0]); + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + {'insert': link}, + ]); + }); + }); + + group('Paste as Bookmark', () { + Future pasteAsBookmark(WidgetTester tester, String link) => + pasteAs(tester, link, PasteMenuType.bookmark); + + Future hoverAndClick( + WidgetTester tester, + LinkPreviewMenuCommand command, + ) async { + final bookmark = find.byType(CustomLinkPreviewBlockComponent); + expect(bookmark, findsOneWidget); + await tester.hoverOnWidget( + bookmark, + onHover: () async { + final menuButton = find.byFlowySvg(FlowySvgs.toolbar_more_m); + await tester.tapButton(menuButton); + final commandButton = find.text(command.title); + await tester.tapButton(commandButton); + }, + ); + } + + testWidgets('paste a link as bookmark', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + + testWidgets('paste a link as bookmark and convert to mention', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.convertToMention); + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste a link as bookmark and convert to url', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.convertToUrl); + final node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste a link as bookmark and convert to embed', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.convertToEmbed); + final node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + + testWidgets('paste a link as bookmark and copy link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.copyLink); + final clipboardContent = await getIt().getData(); + expect(clipboardContent.plainText, link); + }); + + testWidgets('paste a link as bookmark and replace link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.replace); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyA, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.simulateKeyEvent(LogicalKeyboardKey.delete); + await tester.enterText(find.byType(TextFormField), unavailableLink); + await tester.tapButton(find.text(LocaleKeys.button_replace.tr())); + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, unavailableLink); + }); + + testWidgets('paste a link as bookmark and remove link', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsBookmark(tester, link); + await hoverAndClick(tester, LinkPreviewMenuCommand.removeLink); + final node = tester.editor.getNodeAtPath([0]); + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + {'insert': link}, + ]); + }); + }); + group('Paste as Embed', () { + Future pasteAsEmbed(WidgetTester tester, String link) => + pasteAs(tester, link, PasteMenuType.embed); + + Future hoverAndConvert( + WidgetTester tester, + LinkEmbedConvertCommand command, + ) async { + final embed = find.byType(LinkEmbedBlockComponent); + expect(embed, findsOneWidget); + await tester.hoverOnWidget( + embed, + onHover: () async { + final menuButton = find.byFlowySvg(FlowySvgs.turninto_m); + await tester.tapButton(menuButton); + final commandButton = find.text(command.title); + await tester.tapButton(commandButton); + }, + ); + } + + testWidgets('paste a link as embed', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + final node = tester.editor.getNodeAtPath([0]); + checkEmbed(node, link); + }); + + testWidgets('paste a link as bookmark and convert to mention', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + await hoverAndConvert(tester, LinkEmbedConvertCommand.toMention); + final node = tester.editor.getNodeAtPath([0]); + checkMention(node, link); + }); + + testWidgets('paste a link as bookmark and convert to url', (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + await hoverAndConvert(tester, LinkEmbedConvertCommand.toURL); + final node = tester.editor.getNodeAtPath([0]); + checkUrl(node, link); + }); + + testWidgets('paste a link as bookmark and convert to bookmark', + (tester) async { + final link = avaliableLink; + await preparePage(tester); + await pasteAsEmbed(tester, link); + await hoverAndConvert(tester, LinkEmbedConvertCommand.toBookmark); + final node = tester.editor.getNodeAtPath([0]); + checkBookmark(node, link); + }); + }); +} 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 0000000000..eeb2ea3925 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_more_actions_test.dart @@ -0,0 +1,140 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.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: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('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)); + }); + }); + + testWidgets('count title towards word count', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(); + + Finder title = tester.editor.findDocumentTitle(''); + + await tester.openMoreViewActions(); + final viewMetaInfo = find.byType(ViewMetaInfo); + expect(viewMetaInfo, findsOneWidget); + + ViewMetaInfo viewMetaInfoWidget = + viewMetaInfo.evaluate().first.widget as ViewMetaInfo; + Counters titleCounter = viewMetaInfoWidget.titleCounters!; + + expect(titleCounter.charCount, 0); + expect(titleCounter.wordCount, 0); + + /// input [str1] within title + const str1 = 'Hello', + str2 = '$str1 AppFlowy', + str3 = '$str2!', + str4 = 'Hello world'; + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.tapButton(title); + await tester.enterText(title, str1); + await tester.pumpAndSettle(const Duration(seconds: 1)); + await tester.openMoreViewActions(); + viewMetaInfoWidget = viewMetaInfo.evaluate().first.widget as ViewMetaInfo; + titleCounter = viewMetaInfoWidget.titleCounters!; + expect(titleCounter.charCount, str1.length); + expect(titleCounter.wordCount, 1); + + /// input [str2] within title + title = tester.editor.findDocumentTitle(str1); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.tapButton(title); + await tester.enterText(title, str2); + await tester.pumpAndSettle(const Duration(seconds: 1)); + await tester.openMoreViewActions(); + viewMetaInfoWidget = viewMetaInfo.evaluate().first.widget as ViewMetaInfo; + titleCounter = viewMetaInfoWidget.titleCounters!; + expect(titleCounter.charCount, str2.length); + expect(titleCounter.wordCount, 2); + + /// input [str3] within title + title = tester.editor.findDocumentTitle(str2); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.tapButton(title); + await tester.enterText(title, str3); + await tester.pumpAndSettle(const Duration(seconds: 1)); + await tester.openMoreViewActions(); + viewMetaInfoWidget = viewMetaInfo.evaluate().first.widget as ViewMetaInfo; + titleCounter = viewMetaInfoWidget.titleCounters!; + expect(titleCounter.charCount, str3.length); + expect(titleCounter.wordCount, 2); + + /// input [str4] within document + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.editor + .updateSelection(Selection.collapsed(Position(path: [0]))); + await tester.pumpAndSettle(); + await tester.editor + .getCurrentEditorState() + .insertTextAtCurrentSelection(str4); + await tester.pumpAndSettle(const Duration(seconds: 1)); + await tester.openMoreViewActions(); + final texts = + find.descendant(of: viewMetaInfo, matching: find.byType(FlowyText)); + expect(texts, findsNWidgets(3)); + viewMetaInfoWidget = viewMetaInfo.evaluate().first.widget as ViewMetaInfo; + titleCounter = viewMetaInfoWidget.titleCounters!; + final Counters documentCounters = viewMetaInfoWidget.documentCounters!; + final wordCounter = texts.evaluate().elementAt(0).widget as FlowyText, + charCounter = texts.evaluate().elementAt(1).widget as FlowyText; + final numberFormat = NumberFormat(); + expect( + wordCounter.text, + LocaleKeys.moreAction_wordCount.tr( + args: [ + numberFormat + .format(titleCounter.wordCount + documentCounters.wordCount) + .toString(), + ], + ), + ); + expect( + charCounter.text, + LocaleKeys.moreAction_charCount.tr( + args: [ + numberFormat + .format( + titleCounter.charCount + documentCounters.charCount, + ) + .toString(), + ], + ), + ); + }); +} 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 index cfea4381e0..6ec12287a8 100644 --- 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 @@ -1,3 +1,8 @@ +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'; @@ -7,9 +12,23 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // +, ... button beside the block component. - group('document with option action button', () { - testWidgets( - 'click + to add a block after current selection, and click + and option key to add a block before current selection', + 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(); @@ -40,5 +59,120 @@ void main() { 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.editor_bulletedListShortForm.tr(): + BulletedListBlockKeys.type, + LocaleKeys.editor_numberedListShortForm.tr(): + NumberedListBlockKeys.type, + LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type, + LocaleKeys.editor_checkbox.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.editor_bulletedListShortForm.tr(): + BulletedListBlockKeys.type, + LocaleKeys.editor_numberedListShortForm.tr(): + NumberedListBlockKeys.type, + LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type, + LocaleKeys.editor_checkbox.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 0000000000..de1cb880a5 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart @@ -0,0 +1,88 @@ +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 '../../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); + }); + + testWidgets('select and delete text', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// create a new document + await tester.createNewPageWithNameUnderParent(); + + /// input text + final editor = tester.editor; + final editorState = editor.getCurrentEditorState(); + + const inputText = 'Test for text selection and deletion'; + final texts = inputText.split(' '); + await editor.tapLineOfEditorAt(0); + await tester.ime.insertText(inputText); + + /// selecte and delete + int index = 0; + while (texts.isNotEmpty) { + final text = texts.removeAt(0); + await tester.editor.updateSelection( + Selection( + start: Position(path: [0], offset: index), + end: Position(path: [0], offset: index + text.length), + ), + ); + await tester.simulateKeyEvent(LogicalKeyboardKey.delete); + index++; + } + + /// excpete the text value is correct + final node = editorState.getNodeAtPath([0])!; + final nodeText = node.delta?.toPlainText() ?? ''; + expect(nodeText, ' ' * (index - 1)); + }); + }); +} 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 0000000000..cf33a66947 --- /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 0000000000..50f0f903bc --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_sub_page_test.dart @@ -0,0 +1,528 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.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/emoji.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() { + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); + + tearDownAll(() { + RecentIcons.enable = true; + }); + + 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(); + + 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.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.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.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.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.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.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.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.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.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); + + 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)); + }); + + testWidgets('Displaying icon of subpage', (tester) async { + const firstPage = 'FirstPage'; + + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: firstPage); + final icon = await tester.loadIcon(); + + /// create subpage + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_subPage_name.tr(), + offset: 100, + ); + + /// add icon + await tester.editor.hoverOnCoverToolbar(); + await tester.editor.tapAddIconButton(); + await tester.tapIcon(icon); + await tester.pumpAndSettle(); + await tester.openPage(firstPage); + + await tester.expandOrCollapsePage( + pageName: firstPage, + layout: ViewLayoutPB.Document, + ); + + /// check if there is a icon in document + final iconWidget = find.byWidgetPredicate((w) { + if (w is! RawEmojiIconWidget) return false; + final iconData = w.emoji.emoji; + return iconData == icon.emoji; + }); + expect(iconWidget, findsOneWidget); + }); + }); +} + +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.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart deleted file mode 100644 index 42462c2658..0000000000 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart +++ /dev/null @@ -1,41 +0,0 @@ -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_create_and_delete_test.dart' - as document_create_and_delete_test; -import 'document_option_action_test.dart' as document_option_action_test; -import 'document_text_direction_test.dart' as document_text_direction_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_image_block_test.dart' as document_with_image_block_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 'document_with_outline_block_test.dart' as document_with_outline_block; -import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test; -import 'edit_document_test.dart' as document_edit_test; -import 'document_inline_page_reference_test.dart' - as document_inline_page_reference_test; - -void startTesting() { - 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(); - 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(); - document_option_action_test.main(); - document_with_image_block_test.main(); - document_inline_page_reference_test.main(); -} 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 0000000000..6a4ad5cb62 --- /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 0000000000..f32db64aa7 --- /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 0000000000..cecdaca580 --- /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 0000000000..bc0671834b --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart @@ -0,0 +1,33 @@ +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; +import 'document_link_preview_test.dart' as document_link_preview_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(); + document_link_preview_test.main(); +} 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 0000000000..c694ba8d6b --- /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 0000000000..f455cd479d --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart @@ -0,0 +1,370 @@ +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/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart'; +import 'package:appflowy/startup/startup.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(); + + Future selectText(WidgetTester tester, String text) async { + await tester.editor.updateSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: text.length, + ), + ); + } + + Future prepareForToolbar(WidgetTester tester, String text) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText(text); + await selectText(tester, text); + } + + group('document toolbar:', () { + testWidgets('font family', (tester) async { + await prepareForToolbar(tester, 'font family'); + + // tap more options button + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_more_m); + // 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, + ); + }); + + testWidgets('heading 1~3', (tester) async { + const text = 'heading'; + await prepareForToolbar(tester, text); + + Future testChangeHeading( + FlowySvgData svg, + String title, + int level, + ) async { + /// tap suggestions item + final suggestionsButton = find.byKey(kSuggestionsItemKey); + await tester.tapButton(suggestionsButton); + + /// tap item + await tester.ensureVisible(find.byFlowySvg(svg)); + await tester.tapButton(find.byFlowySvg(svg)); + + /// check the type of node is [HeadingBlockKeys.type] + await selectText(tester, text); + final editorState = tester.editor.getCurrentEditorState(); + final selection = editorState.selection!; + final node = editorState.getNodeAtPath(selection.start.path)!, + nodeLevel = node.attributes[HeadingBlockKeys.level]!; + expect(node.type, HeadingBlockKeys.type); + expect(nodeLevel, level); + + /// show toolbar again + await selectText(tester, text); + + /// the text of suggestions item should be changed + expect( + find.descendant(of: suggestionsButton, matching: find.text(title)), + findsOneWidget, + ); + } + + await testChangeHeading( + FlowySvgs.type_h1_m, + LocaleKeys.document_toolbar_h1.tr(), + 1, + ); + + await testChangeHeading( + FlowySvgs.type_h2_m, + LocaleKeys.document_toolbar_h2.tr(), + 2, + ); + await testChangeHeading( + FlowySvgs.type_h3_m, + LocaleKeys.document_toolbar_h3.tr(), + 3, + ); + }); + + testWidgets('toggle 1~3', (tester) async { + const text = 'toggle'; + await prepareForToolbar(tester, text); + + Future testChangeToggle( + FlowySvgData svg, + String title, + int? level, + ) async { + /// tap suggestions item + final suggestionsButton = find.byKey(kSuggestionsItemKey); + await tester.tapButton(suggestionsButton); + + /// tap item + await tester.ensureVisible(find.byFlowySvg(svg)); + await tester.tapButton(find.byFlowySvg(svg)); + + /// check the type of node is [HeadingBlockKeys.type] + await selectText(tester, text); + final editorState = tester.editor.getCurrentEditorState(); + final selection = editorState.selection!; + final node = editorState.getNodeAtPath(selection.start.path)!, + nodeLevel = node.attributes[ToggleListBlockKeys.level]; + expect(node.type, ToggleListBlockKeys.type); + expect(nodeLevel, level); + + /// show toolbar again + await selectText(tester, text); + + /// the text of suggestions item should be changed + expect( + find.descendant(of: suggestionsButton, matching: find.text(title)), + findsOneWidget, + ); + } + + await testChangeToggle( + FlowySvgs.type_toggle_list_m, + LocaleKeys.editor_toggleListShortForm.tr(), + null, + ); + + await testChangeToggle( + FlowySvgs.type_toggle_h1_m, + LocaleKeys.editor_toggleHeading1ShortForm.tr(), + 1, + ); + + await testChangeToggle( + FlowySvgs.type_toggle_h2_m, + LocaleKeys.editor_toggleHeading2ShortForm.tr(), + 2, + ); + + await testChangeToggle( + FlowySvgs.type_toggle_h3_m, + LocaleKeys.editor_toggleHeading3ShortForm.tr(), + 3, + ); + }); + + testWidgets('toolbar will not rebuild after click item', (tester) async { + const text = 'Test rebuilding'; + await prepareForToolbar(tester, text); + Finder toolbar = find.byType(DesktopFloatingToolbar); + Element toolbarElement = toolbar.evaluate().first; + final elementHashcode = toolbarElement.hashCode; + final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m), + underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m), + italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m); + + /// tap format buttons + await tester.tapButton(boldButton); + await tester.tapButton(underlineButton); + await tester.tapButton(italicButton); + toolbar = find.byType(DesktopFloatingToolbar); + toolbarElement = toolbar.evaluate().first; + + /// check if the toolbar is not rebuilt + expect(elementHashcode, toolbarElement.hashCode); + final editorState = tester.editor.getCurrentEditorState(); + + /// check text formats + expect( + editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold), + true, + ); + expect( + editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic), + true, + ); + expect( + editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline), + true, + ); + }); + }); + + group('document toolbar: link', () { + String? getLinkFromNode(Node node) { + for (final insert in node.delta!) { + final link = insert.attributes?.href; + if (link != null) return link; + } + return null; + } + + bool isPageLink(Node node) { + for (final insert in node.delta!) { + final isPage = insert.attributes?.isPage; + if (isPage == true) return true; + } + return false; + } + + String getNodeText(Node node) { + for (final insert in node.delta!) { + if (insert is TextInsert) return insert.text; + } + return ''; + } + + testWidgets('insert link and remove link', (tester) async { + const text = 'insert link', link = 'https://test.appflowy.cloud'; + await prepareForToolbar(tester, text); + + final toolbar = find.byType(DesktopFloatingToolbar); + expect(toolbar, findsOneWidget); + + /// tap link button to show CreateLinkMenu + final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); + await tester.tapButton(linkButton); + final createLinkMenu = find.byType(LinkCreateMenu); + expect(createLinkMenu, findsOneWidget); + + /// test esc to close + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + expect(toolbar, findsNothing); + + /// show toolbar again + await tester.editor.tapLineOfEditorAt(0); + await selectText(tester, text); + await tester.tapButton(linkButton); + + /// insert link + final textField = find.descendant( + of: createLinkMenu, + matching: find.byType(TextFormField), + ); + await tester.enterText(textField, link); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + Node node = tester.editor.getNodeAtPath([0]); + expect(getLinkFromNode(node), link); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + + /// hover link + await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); + final hoverMenu = find.byType(LinkHoverMenu); + expect(hoverMenu, findsOneWidget); + + /// copy link + final copyButton = find.descendant( + of: hoverMenu, + matching: find.byFlowySvg(FlowySvgs.toolbar_link_m), + ); + await tester.tapButton(copyButton); + final clipboardContent = await getIt().getData(); + final plainText = clipboardContent.plainText; + expect(plainText, link); + + /// remove link + await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); + await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m)); + node = tester.editor.getNodeAtPath([0]); + expect(getLinkFromNode(node), null); + }); + + testWidgets('insert link and edit link', (tester) async { + const text = 'edit link', + link = 'https://test.appflowy.cloud', + afterText = '$text after'; + await prepareForToolbar(tester, text); + + /// tap link button to show CreateLinkMenu + final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); + await tester.tapButton(linkButton); + + /// search for page and select it + final textField = find.descendant( + of: find.byType(LinkCreateMenu), + matching: find.byType(TextFormField), + ); + await tester.enterText(textField, gettingStarted); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + + Node node = tester.editor.getNodeAtPath([0]); + expect(isPageLink(node), true); + expect(getLinkFromNode(node) == link, false); + + /// hover link + await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); + + /// click edit button to show LinkEditMenu + final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m); + await tester.tapButton(editButton); + final linkEditMenu = find.byType(LinkEditMenu); + expect(linkEditMenu, findsOneWidget); + + /// change the link text + final titleField = find.descendant( + of: linkEditMenu, + matching: find.byType(TextFormField), + ); + await tester.enterText(titleField, afterText); + await tester.pumpAndSettle(); + await tester.tapButton( + find.descendant(of: linkEditMenu, matching: find.text(gettingStarted)), + ); + final linkField = find.ancestor( + of: find.text(LocaleKeys.document_toolbar_linkInputHint.tr()), + matching: find.byType(TextFormField), + ); + await tester.enterText(linkField, link); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + + /// apply the change + final applyButton = + find.text(LocaleKeys.settings_appearance_documentSettings_apply.tr()); + await tester.tapButton(applyButton); + + node = tester.editor.getNodeAtPath([0]); + expect(isPageLink(node), false); + expect(getLinkFromNode(node), link); + expect(getNodeText(node), afterText); + }); + }); +} 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 index 2b42ef7451..84b6790403 100644 --- 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 @@ -1,19 +1,36 @@ +import 'dart:io'; + import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.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/gestures.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:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import '../../shared/emoji.dart'; +import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); - group('cover image', () { + tearDownAll(() { + RecentIcons.enable = true; + }); + + group('cover image:', () { testWidgets('document cover tests', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); @@ -51,6 +68,59 @@ void main() { tester.expectToSeeNoDocumentCover(); }); + testWidgets('document cover local image 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 local image image + final imagePath = await rootBundle.load('assets/test/images/sample.jpeg'); + final tempDirectory = await getTemporaryDirectory(); + final localImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final imageFile = File(localImagePath) + ..writeAsBytesSync(imagePath.buffer.asUint8List()); + + await tester.editor.hoverOnCover(); + await tester.editor.tapOnChangeCover(); + + final uploadButton = find.findTextInFlowyText( + LocaleKeys.document_imageBlock_upload_label.tr(), + ); + await tester.tapButton(uploadButton); + + mockPickFilePaths(paths: [localImagePath]); + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_upload_placeholder.tr(), + ); + + await tester.pumpAndSettle(); + tester.expectToSeeDocumentCover(CoverType.file); + + // Remove the cover + await tester.editor.hoverOnCover(); + await tester.editor.tapOnRemoveCover(); + tester.expectToSeeNoDocumentCover(); + + // Test if deleteImageFromLocalStorage(localImagePath) function is called once + await tester.pump(kDoubleTapTimeout); + expect(deleteImageTestCounter, 1); + + // delete temp files + await imageFile.delete(); + }); + testWidgets('document icon tests', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); @@ -130,24 +200,24 @@ void main() { final searchEmojiTextField = find.byWidgetPredicate( (widget) => widget is TextField && - widget.decoration!.hintText == LocaleKeys.emoji_search.tr(), + widget.decoration!.hintText == LocaleKeys.search_label.tr(), ); await tester.enterText( searchEmojiTextField, - 'hand', + 'punch', ); // change skin tone await tester.editor.changeEmojiSkinTone(EmojiSkinTone.dark); // select an icon with skin tone - const hand = '👋🏿'; - await tester.tapEmoji(hand); - tester.expectToSeeDocumentIcon(hand); + const punch = '👊🏿'; + await tester.tapEmoji(punch); + tester.expectToSeeDocumentIcon(punch); tester.expectViewHasIcon( gettingStarted, ViewLayoutPB.Document, - hand, + 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 index 915004133f..158eb501e3 100644 --- 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 @@ -1,14 +1,15 @@ -import 'package:appflowy/generated/locale_keys.g.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/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:easy_localization/easy_localization.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'; @@ -22,7 +23,7 @@ void main() { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); - await insertReferenceDatabase(tester, ViewLayoutPB.Grid); + await insertLinkedDatabase(tester, ViewLayoutPB.Grid); // validate the referenced grid is inserted expect( @@ -50,23 +51,74 @@ void main() { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); - await insertReferenceDatabase(tester, ViewLayoutPB.Board); + await insertLinkedDatabase(tester, ViewLayoutPB.Board); // validate the referenced board is inserted expect( find.descendant( of: find.byType(AppFlowyEditor), - matching: find.byType(BoardPage), + 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 insertReferenceDatabase(tester, ViewLayoutPB.Calendar); + await insertLinkedDatabase(tester, ViewLayoutPB.Calendar); // validate the referenced grid is inserted expect( @@ -104,7 +156,7 @@ void main() { expect( find.descendant( of: find.byType(AppFlowyEditor), - matching: find.byType(BoardPage), + matching: find.byType(DesktopBoardPage), ), findsOneWidget, ); @@ -125,11 +177,112 @@ void main() { 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 insertReferenceDatabase( +Future insertLinkedDatabase( WidgetTester tester, ViewLayoutPB layout, ) async { @@ -150,7 +303,7 @@ Future insertReferenceDatabase( // insert a referenced view await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( - layout.referencedMenuName, + layout.slashMenuLinkedName, ); final linkToPageMenu = find.byType(InlineActionsHandler); @@ -176,16 +329,9 @@ Future createInlineDatabase( await tester.editor.tapLineOfEditorAt(0); // insert a referenced view await tester.editor.showSlashMenu(); - final name = switch (layout) { - ViewLayoutPB.Grid => LocaleKeys.document_slashMenu_grid_createANewGrid.tr(), - ViewLayoutPB.Board => - LocaleKeys.document_slashMenu_board_createANewBoard.tr(), - ViewLayoutPB.Calendar => - LocaleKeys.document_slashMenu_calendar_createANewCalendar.tr(), - _ => '', - }; await tester.editor.tapSlashMenuItemWithName( - name, + layout.slashMenuName, + offset: 100, ); await tester.pumpAndSettle(); 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 0000000000..ccfdbae76e --- /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 0000000000..9d7a97e6a8 --- /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 index 976d812da1..3dcd6be8ae 100644 --- 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 @@ -3,24 +3,20 @@ 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.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_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/resizeable_image.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.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_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'; @@ -36,13 +32,15 @@ void main() { // create a new document await tester.createNewPageWithNameUnderParent( - name: LocaleKeys.document_plugins_image_addAnImage.tr(), + 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('Image'); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_image.tr(), + ); expect(find.byType(CustomImageBlockComponent), findsOneWidget); expect(find.byType(ImagePlaceholder), findsOneWidget); expect( @@ -78,19 +76,21 @@ void main() { file.deleteSync(); }); - testWidgets('insert an image from network', (tester) async { + 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_addAnImage.tr(), + 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('Image'); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_image.tr(), + ); expect(find.byType(CustomImageBlockComponent), findsOneWidget); expect(find.byType(ImagePlaceholder), findsOneWidget); expect( @@ -102,64 +102,42 @@ void main() { ); 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_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.tapButton( - find.descendant( - of: find.byType(EmbedImageUrlWidget), - matching: find.text( - LocaleKeys.document_imageBlock_embedLink_label.tr(), - findRichText: true, - ), - ), + 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], url); - }); - testWidgets('insert an image from unsplash', (tester) async { - await runWithNetworkImages(() async { - await tester.initializeAppFlowy(); - await tester.tapAnonymousSignInButton(); + expect(find.byType(ResizableImage), findsNWidgets(2)); - // create a new document - await tester.createNewPageWithNameUnderParent( - name: LocaleKeys.document_plugins_image_addAnImage.tr(), - ); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(firstNode.type, ImageBlockKeys.type); + expect(firstNode.attributes[ImageBlockKeys.url], isNotEmpty); - // tap the first line of the document - await tester.editor.tapLineOfEditorAt(0); - await tester.editor.showSlashMenu(); - await tester.editor.tapSlashMenuItemWithName('Image'); - 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 secondNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(secondNode.type, ImageBlockKeys.type); + expect(secondNode.attributes[ImageBlockKeys.url], isNotEmpty); - await tester.tapButtonWithName( - 'Unsplash', - ); - expect(find.byType(UnsplashImageWidget), findsOneWidget); - }); + // 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 index 27f02f17cd..67e0149cd1 100644 --- 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 @@ -1,9 +1,11 @@ +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/style_widget/button.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'; @@ -32,9 +34,15 @@ void main() { Selection.single(path: [0], startOffset: 0, endOffset: formula.length), ); + // tap the more options button + final moreOptionButton = find.findFlowyTooltip( + LocaleKeys.document_toolbar_moreOptions.tr(), + ); + await tester.tapButton(moreOptionButton); + // tap the inline math equation button - final inlineMathEquationButton = find.byTooltip( - LocaleKeys.document_plugins_createInlineMathEquation.tr(), + final inlineMathEquationButton = find.text( + LocaleKeys.document_toolbar_equation.tr(), ); await tester.tapButton(inlineMathEquationButton); @@ -77,10 +85,15 @@ void main() { Selection.single(path: [0], startOffset: 0, endOffset: formula.length), ); - // tap the inline math equation button - var inlineMathEquationButton = find.byTooltip( - LocaleKeys.document_plugins_createInlineMathEquation.tr(), + // tap the more options button + final moreOptionButton = find.findFlowyTooltip( + LocaleKeys.document_toolbar_moreOptions.tr(), ); + await tester.tapButton(moreOptionButton); + + // tap the inline math equation button + final inlineMathEquationButton = + find.byFlowySvg(FlowySvgs.type_formula_m); await tester.tapButton(inlineMathEquationButton); // expect to see the math equation block @@ -92,17 +105,7 @@ void main() { Selection.single(path: [0], startOffset: 0, endOffset: 1), ); - // expect to the see the inline math equation button is highlighted - inlineMathEquationButton = find.byWidgetPredicate( - (widget) => - widget is SVGIconItemWidget && - widget.tooltip == - LocaleKeys.document_plugins_createInlineMathEquation.tr(), - ); - expect( - tester.widget(inlineMathEquationButton).isHighlight, - isTrue, - ); + await tester.tapButton(moreOptionButton); // cancel the format await tester.tapButton(inlineMathEquationButton); @@ -113,5 +116,110 @@ void main() { 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 more options button + final moreOptionButton = find.findFlowyTooltip( + LocaleKeys.document_toolbar_moreOptions.tr(), + ); + await tester.tapButton(moreOptionButton); + + // tap the inline math equation button + final inlineMathEquationButton = + find.byFlowySvg(FlowySvgs.type_formula_m); + 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), + ); + }); + + testWidgets('insert inline math equation by shortcut', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'insert inline math equation by shortcut', + ); + + // 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), + ); + + // mock key event + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyE, + isShiftPressed: true, + isControlPressed: true, + ); + + // 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_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_page_test.dart index 335f9a377f..12047bd37f 100644 --- 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 @@ -1,7 +1,7 @@ +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:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -92,7 +92,21 @@ void main() { ); expect(finder, findsOneWidget); await tester.tapButton(finder); - expect(find.byType(FlowyErrorPage), findsOneWidget); + 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); }); }); } 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 0000000000..d8b0784a39 --- /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: 100, + ); + 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: 100, + ); + 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 index bfd8198295..2f3f8c80b9 100644 --- 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 @@ -1,9 +1,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.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/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -46,6 +44,9 @@ void main() { * # Heading 1 * ## Heading 2 * ### Heading 3 + * > # Heading 1 + * > ## Heading 2 + * > ### Heading 3 */ await tester.editor.tapLineOfEditorAt(3); @@ -56,7 +57,7 @@ void main() { of: find.byType(OutlineBlockWidget), matching: find.text(heading1), ), - findsOneWidget, + findsNWidgets(2), ); // Heading 2 is prefixed with a bullet @@ -65,7 +66,7 @@ void main() { of: find.byType(OutlineBlockWidget), matching: find.text(heading2), ), - findsOneWidget, + findsNWidgets(2), ); // Heading 3 is prefixed with a dash @@ -74,7 +75,7 @@ void main() { of: find.byType(OutlineBlockWidget), matching: find.text(heading3), ), - findsOneWidget, + findsNWidgets(2), ); // update the Heading 1 to Heading 1Hello world @@ -102,13 +103,16 @@ void main() { * # Heading 1 * ## Heading 2 * ### Heading 3 + * > # Heading 1 + * > ## Heading 2 + * > ### Heading 3 */ - await tester.editor.tapLineOfEditorAt(3); + await tester.editor.tapLineOfEditorAt(7); await insertOutlineInDocument(tester); // expect to find only the `heading1` widget under the [OutlineBlockWidget] - await hoverAndClickDepthOptionAction(tester, [3], 1); + await hoverAndClickDepthOptionAction(tester, [6], 1); expect( find.descendant( of: find.byType(OutlineBlockWidget), @@ -126,7 +130,7 @@ void main() { ////// /// expect to find only the 'heading1' and 'heading2' under the [OutlineBlockWidget] - await hoverAndClickDepthOptionAction(tester, [3], 2); + await hoverAndClickDepthOptionAction(tester, [6], 2); expect( find.descendant( of: find.byType(OutlineBlockWidget), @@ -137,13 +141,13 @@ void main() { ////// // expect to find all the headings under the [OutlineBlockWidget] - await hoverAndClickDepthOptionAction(tester, [3], 3); + await hoverAndClickDepthOptionAction(tester, [6], 3); expect( find.descendant( of: find.byType(OutlineBlockWidget), matching: find.text(heading1), ), - findsOneWidget, + findsNWidgets(2), ); expect( @@ -151,7 +155,7 @@ void main() { of: find.byType(OutlineBlockWidget), matching: find.text(heading2), ), - findsOneWidget, + findsNWidgets(2), ); expect( @@ -159,7 +163,7 @@ void main() { of: find.byType(OutlineBlockWidget), matching: find.text(heading3), ), - findsOneWidget, + findsNWidgets(2), ); ////// }); @@ -171,7 +175,7 @@ Future insertOutlineInDocument(WidgetTester tester) async { // open the actions menu and insert the outline block await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( - LocaleKeys.document_selectionMenu_outline.tr(), + LocaleKeys.document_slashMenu_name_outline.tr(), ); await tester.pumpAndSettle(); } @@ -181,19 +185,25 @@ Future hoverAndClickDepthOptionAction( List path, int level, ) async { - await tester.editor.hoverAndClickOptionMenuButton([3]); - await tester.tap(find.byType(AppFlowyPopover).hitTestable().last); - await tester.pumpAndSettle(); - - // Find a total of 4 HoverButtons under the [BlockOptionButton], - // in addition to 3 HoverButtons under the [DepthOptionAction] - (child of BlockOptionButton) - await tester.tap(find.byType(HoverButton).hitTestable().at(3 + level)); + 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 0000000000..bcf3fde24f --- /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 0000000000..c4aa289855 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart @@ -0,0 +1,123 @@ +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/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.byFlowySvg(FlowySvgs.toolbar_text_format_m)); + + // tap the H1 button + await tester.tapButton(find.byFlowySvg(FlowySvgs.type_h1_m).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 index 0f3bab2f8e..a4d011dccb 100644 --- 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 @@ -1,7 +1,9 @@ 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'; @@ -214,5 +216,73 @@ void main() { 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/first_test/first_test.dart b/frontend/appflowy_flutter/integration_test/desktop/first_test/first_test.dart new file mode 100644 index 0000000000..36c0e391fb --- /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_edit_row_test.dart b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_edit_row_test.dart new file mode 100644 index 0000000000..64b7a40ad1 --- /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 0000000000..c9fed5b02e --- /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 0000000000..a4363f7a83 --- /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 0000000000..40f7252a91 --- /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 0000000000..d7efb797f0 --- /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 0000000000..c5a0b404e7 --- /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 0000000000..ff42bb6cc2 --- /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 index 2a1f0fe3e4..bdfe2dae9f 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/reminder/document_reminder_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/reminder/document_reminder_test.dart @@ -15,7 +15,7 @@ import 'package:integration_test/integration_test.dart'; import '../../shared/base.dart'; import '../../shared/common_operations.dart'; -import '../../shared/editor_test_operations.dart'; +import '../../shared/document_test_operations.dart'; import '../../shared/expectation.dart'; import '../../shared/keyboard.dart'; 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 index 46550aa81a..570c482fb5 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart @@ -1,5 +1,5 @@ import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; -import 'package:flutter/material.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -8,8 +8,8 @@ import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('board add row test', () { - testWidgets('Add card from header', (tester) async { + group('notification test', () { + testWidgets('enable notification', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); @@ -17,25 +17,25 @@ void main() { await tester.openSettingsPage(SettingsPage.notifications); await tester.pumpAndSettle(); - final switchFinder = find.byType(Switch); + final toggleFinder = find.byType(Toggle).first; // Defaults to enabled - Switch switchWidget = tester.widget(switchFinder); - expect(switchWidget.value, true); + Toggle toggleWidget = tester.widget(toggleFinder); + expect(toggleWidget.value, true); // Disable - await tester.tap(switchFinder); + await tester.tap(toggleFinder); await tester.pumpAndSettle(); - switchWidget = tester.widget(switchFinder); - expect(switchWidget.value, false); + toggleWidget = tester.widget(toggleFinder); + expect(toggleWidget.value, false); // Enable again - await tester.tap(switchFinder); + await tester.tap(toggleFinder); await tester.pumpAndSettle(); - switchWidget = tester.widget(switchFinder); - expect(switchWidget.value, true); + 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 0000000000..a311eb8377 --- /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 index 3be373537b..617d495265 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart @@ -1,11 +1,15 @@ import 'package:integration_test/integration_test.dart'; import 'notifications_settings_test.dart' as notifications_settings_test; -import 'user_language_test.dart' as user_language_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(); - user_language_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 0000000000..fe91becba6 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/shortcuts_settings_test.dart @@ -0,0 +1,94 @@ +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), + ) + .first, + 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 0000000000..047e02da36 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/sign_in_page_settings_test.dart @@ -0,0 +1,116 @@ +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:appflowy/workspace/presentation/widgets/dialogs.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(); + + 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://self-hosted.appflowy.cloud'; + await tester.enterText( + find.byKey(kSelfHostedTextInputFieldKey), + serverUrl, + ); + await tester.pumpAndSettle(); + // update the web url + const webUrl = 'https://self-hosted.appflowy.com'; + await tester.enterText( + find.byKey(kSelfHostedWebTextInputFieldKey), + webUrl, + ); + 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(DesktopToast)); + await tester.pumpAndSettle(const Duration(milliseconds: 250)); + + // open settings page to check the result + await tester.tapButton(settingsButton); + await tester.pumpAndSettle(const Duration(milliseconds: 250)); + + // check the server type + expect( + findServerType(AuthenticatorType.appflowyCloudSelfHost), + findsOneWidget, + ); + // check the server url + expect( + find.text(serverUrl), + findsOneWidget, + ); + // check the web url + expect( + find.text(webUrl), + 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(DesktopToast)); + 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/settings/user_language_test.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/user_language_test.dart deleted file mode 100644 index 6f56d5864e..0000000000 --- a/frontend/appflowy_flutter/integration_test/desktop/settings/user_language_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:ui'; - -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../../shared/util.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('Settings: user language tests', () { - testWidgets('select language, language changed', (tester) async { - await tester.initializeAppFlowy(); - - await tester.tapAnonymousSignInButton(); - await tester.expectToSeeHomePageWithGetStartedPage(); - await tester.openSettings(); - - await tester.openSettingsPage(SettingsPage.language); - - final userLanguageFinder = find.descendant( - of: find.byType(SettingsLanguageView), - matching: find.byType(LanguageSelector), - ); - - // Grab current locale - LanguageSelector userLanguage = - tester.widget(userLanguageFinder); - Locale currentLocale = userLanguage.currentLocale; - - // Open language selector - await tester.tap(userLanguageFinder); - await tester.pumpAndSettle(); - - // Select first option that isn't default - await tester.tap(find.byType(LanguageItem).at(1)); - await tester.pumpAndSettle(); - - // Make sure the new locale is not the same as previous one - userLanguage = tester.widget(userLanguageFinder); - expect( - userLanguage.currentLocale, - isNot(equals(currentLocale)), - reason: "new language shouldn't equal the previous selected language", - ); - - // Update the current locale to a new one - currentLocale = userLanguage.currentLocale; - - // Tried the same flow for the second time - // Open language selector - await tester.tap(userLanguageFinder); - await tester.pumpAndSettle(); - - // Select second option that isn't default - await tester.tap(find.byType(LanguageItem).at(2)); - await tester.pumpAndSettle(); - - // Make sure the new locale is not the same as previous one - userLanguage = tester.widget(userLanguageFinder); - expect( - userLanguage.currentLocale, - isNot(equals(currentLocale)), - reason: "new language shouldn't equal the previous selected language", - ); - }); - }); -} 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 index ff0df1c7da..ad18cf3de6 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart @@ -1,8 +1,12 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.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:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -12,8 +16,8 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('sidebar expand test', () { - bool isExpanded({required FolderCategoryType type}) { - if (type == FolderCategoryType.private) { + bool isExpanded({required FolderSpaceType type}) { + if (type == FolderSpaceType.private) { return find .descendant( of: find.byType(PrivateSectionFolder), @@ -30,19 +34,96 @@ void main() { await tester.tapAnonymousSignInButton(); // first time is expanded - expect(isExpanded(type: FolderCategoryType.private), true); + expect(isExpanded(type: FolderSpaceType.private), true); // collapse the personal folder await tester.tapButton( find.byTooltip(LocaleKeys.sideBar_clickToHidePrivate.tr()), ); - expect(isExpanded(type: FolderCategoryType.private), false); + expect(isExpanded(type: FolderSpaceType.private), false); // expand the personal folder await tester.tapButton( find.byTooltip(LocaleKeys.sideBar_clickToHidePrivate.tr()), ); - expect(isExpanded(type: FolderCategoryType.private), true); + expect(isExpanded(type: FolderSpaceType.private), true); + }); + + testWidgets('Expanding with subpage', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + const page1 = 'SubPageBloc', page2 = '$page1 2'; + await tester.createNewPageWithNameUnderParent(name: page1); + await tester.createNewPageWithNameUnderParent( + name: page2, + parentName: page1, + ); + + await tester.expandOrCollapsePage( + pageName: gettingStarted, + layout: ViewLayoutPB.Document, + ); + + await tester.tapNewPageButton(); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + + final slashMenu = find + .ancestor( + of: find.byType(SelectionMenuItemWidget), + matching: find.byWidgetPredicate( + (widget) => widget is Scrollable, + ), + ) + .first; + final slashMenuItem = find.text( + LocaleKeys.document_slashMenu_name_linkedDoc.tr(), + ); + await tester.scrollUntilVisible( + slashMenuItem, + 100, + scrollable: slashMenu, + duration: const Duration(milliseconds: 250), + ); + + final menuItemFinder = find.byWidgetPredicate( + (w) => + w is SelectionMenuItemWidget && + w.item.name == LocaleKeys.document_slashMenu_name_linkedDoc.tr(), + ); + + final menuItem = + menuItemFinder.evaluate().first.widget as SelectionMenuItemWidget; + + /// tapSlashMenuItemWithName is not working, so invoke this function directly + menuItem.item.handler( + menuItem.editorState, + menuItem.menuService, + menuItemFinder.evaluate().first, + ); + + await tester.pumpAndSettle(); + final actionHandler = find.byType(InlineActionsHandler); + final subPage = find.descendant( + of: actionHandler, + matching: find.text(page2, findRichText: true), + ); + await tester.tapButton(subPage); + + final subpageBlock = find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text(page2, findRichText: true), + ); + + expect(find.text(page2, findRichText: true), findsOneWidget); + await tester.tapButton(subpageBlock); + + /// one is in SectionFolder, another one is in CoverTitle + /// the last one is in FlowyNavigation + expect(find.text(page2, findRichText: true), findsNWidgets(3)); }); }); } 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 index 072764217c..3345ed30ab 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_favorites_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_favorites_test.dart @@ -1,5 +1,5 @@ import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/_favorite_folder.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'; @@ -46,7 +46,7 @@ void main() { await tester.favoriteViewByName(names[1]); expect( tester.findFavoritePageName(names[1]), - findsNWidgets(2), + findsNWidgets(1), ); await tester.unfavoriteViewByName(gettingStarted); @@ -120,9 +120,9 @@ void main() { (widget) => widget is SingleInnerViewItem && widget.view.isFavorite && - widget.categoryType == FolderCategoryType.favorite, + widget.spaceType == FolderSpaceType.favorite, ), - findsNWidgets(6), + findsNWidgets(3), ); await tester.hoverOnPageName( @@ -135,7 +135,7 @@ void main() { expect( tester.findAllFavoritePages(), - findsNWidgets(3), + findsNWidgets(2), ); await tester.hoverOnPageName( @@ -168,7 +168,7 @@ void main() { widget.isSelected != null && widget.isSelected!(), ), - findsNWidgets(2), + findsNWidgets(1), ); }, ); @@ -196,5 +196,58 @@ void main() { await tester.pumpAndSettle(); }, ); + + testWidgets( + 'reorder favorites', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// there are no favorite views + final favorites = find.descendant( + of: find.byType(FavoriteFolder), + matching: find.byType(ViewItem), + ); + expect(favorites, findsNothing); + + /// create views and then favorite them + const pageNames = ['001', '002', '003']; + for (final name in pageNames) { + await tester.createNewPageWithNameUnderParent(name: name); + } + for (final name in pageNames) { + await tester.favoriteViewByName(name); + } + expect(favorites, findsNWidgets(pageNames.length)); + + final oldNames = favorites + .evaluate() + .map((e) => (e.widget as ViewItem).view.name) + .toList(); + expect(oldNames, pageNames); + + /// drag first to last + await tester.reorderFavorite( + fromName: '001', + toName: '003', + ); + List newNames = favorites + .evaluate() + .map((e) => (e.widget as ViewItem).view.name) + .toList(); + expect(newNames, ['002', '003', '001']); + + /// drag first to second + await tester.reorderFavorite( + fromName: '002', + toName: '003', + ); + newNames = favorites + .evaluate() + .map((e) => (e.widget as ViewItem).view.name) + .toList(); + expect(newNames, ['003', '002', '001']); + }, + ); }); } 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 index 1782c0d0ba..2236f03960 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart @@ -1,76 +1,346 @@ +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/recent_icons.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:flowy_svg/flowy_svg.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() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final emoji = EmojiIconData.emoji('😁'); - const emoji = '😁'; + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); - group('Icon', () { - testWidgets('Update page icon in sidebar', (tester) async { - await tester.initializeAppFlowy(); - await tester.tapAnonymousSignInButton(); + tearDownAll(() { + RecentIcons.enable = true; + }); - // create document, board, grid and calendar views - for (final value in ViewLayoutPB.values) { - await tester.createNewPageWithNameUnderParent( - name: value.name, - parentName: gettingStarted, - layout: value, - ); + testWidgets('Update page emoji in sidebar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); - // update its icon - await tester.updatePageIconInSidebarByName( - name: value.name, - parentName: gettingStarted, - layout: value, - icon: emoji, - ); - - tester.expectViewHasIcon( - value.name, - value, - emoji, - ); + // 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, + ); - testWidgets('Update page icon in title bar', (tester) async { - await tester.initializeAppFlowy(); - await tester.tapAnonymousSignInButton(); + // update its emoji + await tester.updatePageIconInSidebarByName( + name: value.name, + parentName: gettingStarted, + layout: value, + icon: emoji, + ); - // create document, board, grid and calendar views - for (final value in ViewLayoutPB.values) { - await tester.createNewPageWithNameUnderParent( - name: value.name, - parentName: gettingStarted, - layout: value, - ); + tester.expectViewHasIcon( + value.name, + value, + emoji, + ); + } + }); - // update its icon - await tester.updatePageIconInTitleBarByName( - name: value.name, - layout: value, - icon: emoji, - ); + testWidgets('Update page emoji in title bar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); - tester.expectViewHasIcon( - value.name, - value, - emoji, - ); - - tester.expectViewTitleHasIcon( - value.name, - value, - emoji, - ); + // 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 tester.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 tester.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, + ); + } + }); + + testWidgets('Update page custom image icon in title bar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// prepare local image + final iconData = await tester.prepareImageIcon(); + + // 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, + ); + } + }); + + testWidgets('Update page custom svg icon in title bar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// prepare local image + final iconData = await tester.prepareSvgIcon(); + + // 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, + ); + } + }); + + testWidgets('Update page custom svg icon in title bar by pasting a link', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// prepare local image + const testIconLink = + 'https://beta.appflowy.cloud/api/file_storage/008e6f23-516b-4d8d-b1fe-2b75c51eee26/v1/blob/6bdf8dff%2D0e54%2D4d35%2D9981%2Dcde68bef1141/BGpLnRtb3AGBNgSJsceu70j83zevYKrMLzqsTIJcBeI=.svg'; + + /// 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.updatePageIconInTitleBarByPasteALink( + name: value.name, + layout: value, + iconLink: testIconLink, + ); + + /// check if there is a svg in page + final pageName = tester.findPageName( + value.name, + layout: value, + ); + final imageInPage = find.descendant( + of: pageName, + matching: find.byType(SvgPicture), + ); + expect(imageInPage, findsOneWidget); + + /// check if there is a svg in title + final imageInTitle = find.descendant( + of: find.byType(ViewTitleBar), + matching: find.byWidgetPredicate((w) { + if (w is! SvgPicture) return false; + final loader = w.bytesLoader; + if (loader is! SvgFileLoader) return false; + return loader.file.path.endsWith('.svg'); + }), + ); + expect(imageInTitle, findsOneWidget); + } }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_recent_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_recent_icon_test.dart new file mode 100644 index 0000000000..2b724ffac1 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_recent_icon_test.dart @@ -0,0 +1,56 @@ +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.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../shared/base.dart'; +import '../../shared/common_operations.dart'; +import '../../shared/expectation.dart'; + +void main() { + testWidgets('Skip the empty group name icon in recent icons', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// clear local data + RecentIcons.clear(); + await loadIconGroups(); + final groups = kIconGroups!; + final List localIcons = []; + for (final e in groups) { + localIcons.addAll(e.icons.map((e) => RecentIcon(e, e.name)).toList()); + } + await RecentIcons.putIcon(RecentIcon(localIcons.first.icon, '')); + await tester.openPage(gettingStarted); + final title = find.descendant( + of: find.byType(ViewTitleBar), + matching: find.text(gettingStarted), + ); + await tester.tapButton(title); + + /// tap emoji picker button + await tester.tapButton(find.byType(EmojiPickerButton)); + expect(find.byType(FlowyIconEmojiPicker), findsOneWidget); + + /// tap icon tab + final pickTab = find.byType(PickerTab); + final iconTab = find.descendant( + of: pickTab, + matching: find.text(PickerTabType.icon.tr), + ); + await tester.tapButton(iconTab); + + expect(find.byType(FlowyIconPicker), findsOneWidget); + + /// no recent icons + final recentText = find.descendant( + of: find.byType(FlowyIconPicker), + matching: find.text('Recent'), + ); + expect(recentText, findsNothing); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart index d6e5431527..304e8e2e35 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart @@ -1,4 +1,3 @@ -import 'package:appflowy/generated/locale_keys.g.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'; @@ -8,7 +7,6 @@ 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:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -17,7 +15,7 @@ import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('sidebar test', () { + group('sidebar:', () { testWidgets('create a new page', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); @@ -26,9 +24,7 @@ void main() { await tester.tapNewPageButton(); // expect to see a new document - tester.expectToSeePageName( - LocaleKeys.menuAppHeader_defaultNewPageName.tr(), - ); + tester.expectToSeePageName(''); // and with one paragraph block expect(find.byType(ParagraphBlockComponentWidget), findsOneWidget); }); @@ -39,6 +35,9 @@ void main() { 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( @@ -61,11 +60,13 @@ void main() { expect(find.byType(GridPage), findsOneWidget); break; case ViewLayoutPB.Board: - expect(find.byType(BoardPage), findsOneWidget); + expect(find.byType(DesktopBoardPage), findsOneWidget); break; case ViewLayoutPB.Calendar: expect(find.byType(CalendarPage), findsOneWidget); break; + case ViewLayoutPB.Chat: + break; } await tester.openPage(gettingStarted); @@ -197,7 +198,7 @@ void main() { layout: ViewLayoutPB.Grid, onHover: () async { expect(find.byType(ViewAddButton), findsNothing); - expect(find.byType(ViewMoreActionButton), findsOneWidget); + 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 index 35bcf599ab..ef7d3dbc8b 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test_runner.dart @@ -2,9 +2,11 @@ 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_recent_icon_test.dart' as sidebar_recent_icon_test; import 'sidebar_test.dart' as sidebar_test; +import 'sidebar_view_item_test.dart' as sidebar_view_item_test; -void startTesting() { +void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Sidebar integration tests @@ -12,4 +14,6 @@ void startTesting() { // sidebar_expanded_test.main(); sidebar_favorite_test.main(); sidebar_icon_test.main(); + sidebar_view_item_test.main(); + sidebar_recent_icon_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 0000000000..f2b721e686 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_view_item_test.dart @@ -0,0 +1,57 @@ +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/shared/icon_emoji_picker/recent_icons.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() { + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); + + tearDownAll(() { + RecentIcons.enable = true; + }); + + 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/appearance_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/appearance_settings_test.dart deleted file mode 100644 index 4b7848fd08..0000000000 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/appearance_settings_test.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:appflowy/util/font_family_extension.dart'; -import 'package:appflowy/workspace/application/appearance_defaults.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.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('appearance settings tests', () { - testWidgets('after editing text field, button should be able to be clicked', - (tester) async { - await tester.initializeAppFlowy(); - - await tester.tapAnonymousSignInButton(); - await tester.expectToSeeHomePageWithGetStartedPage(); - await tester.openSettings(); - - await tester.openSettingsPage(SettingsPage.appearance); - - final dropDown = find.byKey(ThemeFontFamilySetting.popoverKey); - await tester.tap(dropDown); - await tester.pumpAndSettle(); - - final textField = find.byKey(ThemeFontFamilySetting.textFieldKey); - await tester.tap(textField); - await tester.pumpAndSettle(); - - await tester.enterText(textField, 'Abel'); - await tester.pumpAndSettle(); - final fontFamilyButton = find.byKey(const Key('Abel')); - - expect(fontFamilyButton, findsOneWidget); - await tester.tap(fontFamilyButton); - await tester.pumpAndSettle(); - - // just switch the page and verify that the font family was set after that - await tester.openSettingsPage(SettingsPage.files); - await tester.openSettingsPage(SettingsPage.appearance); - - expect(find.textContaining('Abel'), findsOneWidget); - }); - - testWidgets('reset the font family', (tester) async { - await tester.initializeAppFlowy(); - - await tester.tapAnonymousSignInButton(); - await tester.expectToSeeHomePageWithGetStartedPage(); - await tester.openSettings(); - - await tester.openSettingsPage(SettingsPage.appearance); - - final dropDown = find.byKey(ThemeFontFamilySetting.popoverKey); - await tester.tap(dropDown); - await tester.pumpAndSettle(); - - final textField = find.byKey(ThemeFontFamilySetting.textFieldKey); - await tester.tap(textField); - await tester.pumpAndSettle(); - - await tester.enterText(textField, 'Abel'); - await tester.pumpAndSettle(); - final fontFamilyButton = find.byKey(const Key('Abel')); - - expect(fontFamilyButton, findsOneWidget); - await tester.tap(fontFamilyButton); - await tester.pumpAndSettle(); - - // just switch the page and verify that the font family was set after that - await tester.openSettingsPage(SettingsPage.files); - await tester.openSettingsPage(SettingsPage.appearance); - - final resetButton = find.byKey(ThemeFontFamilySetting.resetButtonKey); - await tester.tap(resetButton); - await tester.pumpAndSettle(); - - // just switch the page and verify that the font family was set after that - await tester.openSettingsPage(SettingsPage.files); - await tester.openSettingsPage(SettingsPage.appearance); - - expect( - find.textContaining( - DefaultAppearanceSettings.kDefaultFontFamily.fontFamilyDisplayName, - ), - findsNWidgets(2), - ); - }); - }); -} 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 0000000000..e522e2fc73 --- /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 index 554a6eecbf..d3226a3ad0 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart @@ -1,42 +1,166 @@ 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:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/emoji/emoji_handler.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.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(); + + Future prepare(WidgetTester tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(); + await tester.editor.tapLineOfEditorAt(0); + } + // 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(); + await prepare(tester); - final Finder editor = find.byType(AppFlowyEditor); - await tester.tap(editor); - await tester.pumpAndSettle(); + expect(find.byType(EmojiHandler), findsNothing); - expect(find.byType(EmojiSelectionMenu), findsNothing); - - await FlowyTestKeyboard.simulateKeyDownEvent( - [ - Platform.isMacOS - ? LogicalKeyboardKey.meta - : LogicalKeyboardKey.control, - LogicalKeyboardKey.alt, - LogicalKeyboardKey.keyE, - ], - tester: tester, + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyE, + isAltPressed: true, + isMetaPressed: Platform.isMacOS, + isControlPressed: !Platform.isMacOS, ); + await tester.pumpAndSettle(Duration(seconds: 1)); + expect(find.byType(EmojiHandler), findsOneWidget); - expect(find.byType(EmojiSelectionMenu), findsOneWidget); + /// press backspace to hide the emoji picker + await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); + expect(find.byType(EmojiHandler), findsNothing); + }); + + testWidgets('insert emoji by slash menu', (tester) async { + await prepare(tester); + await tester.editor.showSlashMenu(); + + /// show emoji picler + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_emoji.tr(), + offset: 100, + ); + await tester.pumpAndSettle(Duration(seconds: 1)); + expect(find.byType(EmojiHandler), findsOneWidget); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + + /// except the emoji is in document + expect(firstNode.delta!.toPlainText().contains('😀'), true); + }); + }); + + group('insert emoji by colon', () { + Future createNewDocumentAndShowEmojiList( + WidgetTester tester, { + String? search, + }) async { + await prepare(tester); + await tester.ime.insertText(':${search ?? 'a'}'); + await tester.pumpAndSettle(Duration(seconds: 1)); + } + + testWidgets('insert with click', (tester) async { + await createNewDocumentAndShowEmojiList(tester); + + /// emoji list is showing + final emojiHandler = find.byType(EmojiHandler); + expect(emojiHandler, findsOneWidget); + final emojiButtons = + find.descendant(of: emojiHandler, matching: find.byType(FlowyButton)); + final firstTextFinder = find.descendant( + of: emojiButtons.first, + matching: find.byType(FlowyText), + ); + final emojiText = + (firstTextFinder.evaluate().first.widget as FlowyText).text; + + /// click first emoji item + await tester.tapButton(emojiButtons.first); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + + /// except the emoji is in document + expect(emojiText.contains(firstNode.delta!.toPlainText()), true); + }); + + testWidgets('insert with arrow and enter', (tester) async { + await createNewDocumentAndShowEmojiList(tester); + + /// emoji list is showing + final emojiHandler = find.byType(EmojiHandler); + expect(emojiHandler, findsOneWidget); + final emojiButtons = + find.descendant(of: emojiHandler, matching: find.byType(FlowyButton)); + + /// tap arrow down and arrow up + await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown); + + final firstTextFinder = find.descendant( + of: emojiButtons.first, + matching: find.byType(FlowyText), + ); + final emojiText = + (firstTextFinder.evaluate().first.widget as FlowyText).text; + + /// tap enter + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + + /// except the emoji is in document + expect(emojiText.contains(firstNode.delta!.toPlainText()), true); + }); + + testWidgets('insert with searching', (tester) async { + await createNewDocumentAndShowEmojiList(tester, search: 's'); + + /// search for `smiling eyes`, IME is not working, use keyboard input + final searchText = [ + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyI, + LogicalKeyboardKey.keyL, + LogicalKeyboardKey.keyI, + LogicalKeyboardKey.keyN, + LogicalKeyboardKey.keyG, + LogicalKeyboardKey.space, + LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyY, + LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyS, + ]; + + for (final key in searchText) { + await tester.simulateKeyEvent(key); + } + + /// tap enter + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + + /// except the emoji is in document + expect(firstNode.delta!.toPlainText().contains('😄'), true); + }); + + testWidgets('start searching with sapce', (tester) async { + await createNewDocumentAndShowEmojiList(tester, search: ' '); + + /// emoji list is showing + final emojiHandler = find.byType(EmojiHandler); + expect(emojiHandler, findsNothing); }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_test.dart deleted file mode 100644 index a058ff2281..0000000000 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_test.dart +++ /dev/null @@ -1,16 +0,0 @@ -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('toggle theme mode', (tester) async { - await tester.initializeAppFlowy(); - await tester.tapAnonymousSignInButton(); - }); - }); -} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart index e4226b0f5f..4a38dde920 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart @@ -1,11 +1,13 @@ 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/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -23,31 +25,35 @@ void main() { await tester.expectToSeeHomePageWithGetStartedPage(); await tester.openSettings(); - await tester.openSettingsPage(SettingsPage.appearance); + await tester.openSettingsPage(SettingsPage.workspace); await tester.pumpAndSettle(); - tester.expectToSeeText( - LocaleKeys.settings_appearance_themeMode_system.tr(), - ); + final appFinder = find.byType(MaterialApp).first; + ThemeMode? themeMode = tester.widget(appFinder).themeMode; + + expect(themeMode, ThemeMode.system); await tester.tapButton( find.bySemanticsLabel( - LocaleKeys.settings_appearance_themeMode_system.tr(), + 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_appearance_themeMode_dark.tr(), + LocaleKeys.settings_workspacePage_appearance_options_dark.tr(), ), ); + await tester.pumpAndSettle(); - await tester.pumpAndSettle(const Duration(seconds: 1)); + themeMode = tester.widget(appFinder).themeMode; + expect(themeMode, ThemeMode.dark); await tester.tap(find.byType(SettingsDialog)); - await tester.pumpAndSettle(); await FlowyTestKeyboard.simulateKeyDownEvent( @@ -60,12 +66,10 @@ void main() { ], tester: tester, ); - await tester.pumpAndSettle(); - tester.expectToSeeText( - LocaleKeys.settings_appearance_themeMode_light.tr(), - ); + themeMode = tester.widget(appFinder).themeMode; + expect(themeMode, ThemeMode.light); }); testWidgets('show or hide home menu', (tester) async { 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 index ffe65ea7cc..84da89f6b7 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/import_files_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/import_files_test.dart @@ -1,5 +1,6 @@ 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'; @@ -82,12 +83,12 @@ void main() { HeadingBlockKeys.type, ); expect( - importedPageEditorState.getNodeAtPath([2])!.type, + importedPageEditorState.getNodeAtPath([1])!.type, HeadingBlockKeys.type, ); expect( - importedPageEditorState.getNodeAtPath([4])!.type, - TableBlockKeys.type, + 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 index c48fcd8028..3c07a2df5e 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; +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'; diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/open_ai_smart_menu_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/open_ai_smart_menu_test.dart deleted file mode 100644 index f739820d04..0000000000 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/open_ai_smart_menu_test.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../../shared/mock/mock_openai_repository.dart'; -import '../../shared/util.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - const service = TestWorkspaceService(TestWorkspace.aiWorkSpace); - - group('integration tests for open-ai smart menu', () { - setUpAll(() async => service.setUpAll()); - setUp(() async => service.setUp()); - - testWidgets('testing selection on open-ai smart menu replace', - (tester) async { - final appFlowyEditor = await setUpOpenAITesting(tester); - final editorState = appFlowyEditor.editorState; - - editorState.service.selectionService.updateSelection( - Selection( - start: Position(path: [1], offset: 4), - end: Position(path: [1], offset: 10), - ), - ); - await tester.pumpAndSettle(const Duration(milliseconds: 500)); - await tester.pumpAndSettle(); - - expect(find.byType(ToolbarWidget), findsAtLeastNWidgets(1)); - - await tester.tap(find.byTooltip('AI Assistants')); - await tester.pumpAndSettle(const Duration(milliseconds: 500)); - - await tester.tap(find.text('Summarize')); - await tester.pumpAndSettle(); - - await tester - .tap(find.byType(FlowyRichTextButton, skipOffstage: false).first); - await tester.pumpAndSettle(); - - expect( - editorState.service.selectionService.currentSelection.value, - Selection( - start: Position(path: [1], offset: 4), - end: Position(path: [1], offset: 84), - ), - ); - }); - testWidgets('testing selection on open-ai smart menu insert', - (tester) async { - final appFlowyEditor = await setUpOpenAITesting(tester); - final editorState = appFlowyEditor.editorState; - - editorState.service.selectionService.updateSelection( - Selection( - start: Position(path: [1]), - end: Position(path: [1], offset: 5), - ), - ); - await tester.pumpAndSettle(const Duration(milliseconds: 500)); - await tester.pumpAndSettle(); - expect(find.byType(ToolbarWidget), findsAtLeastNWidgets(1)); - - await tester.tap(find.byTooltip('AI Assistants')); - await tester.pumpAndSettle(const Duration(milliseconds: 500)); - - await tester.tap(find.text('Summarize')); - await tester.pumpAndSettle(); - - await tester - .tap(find.byType(FlowyRichTextButton, skipOffstage: false).at(1)); - await tester.pumpAndSettle(); - - expect( - editorState.service.selectionService.currentSelection.value, - Selection( - start: Position(path: [2]), - end: Position(path: [3]), - ), - ); - }); - }); -} - -Future setUpOpenAITesting(WidgetTester tester) async { - await tester.initializeAppFlowy(); - await mockOpenAIRepository(); - - await simulateKeyDownEvent(LogicalKeyboardKey.controlLeft); - await simulateKeyDownEvent(LogicalKeyboardKey.backslash); - await tester.pumpAndSettle(); - - final Finder editor = find.byType(AppFlowyEditor); - await tester.tap(editor); - await tester.pumpAndSettle(); - return tester.state(editor).widget as AppFlowyEditor; -} - -Future mockOpenAIRepository() async { - await getIt.unregister(); - getIt.registerFactoryAsync( - () => Future.value( - MockOpenAIRepository(), - ), - ); - return; -} 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 index f73e61ea82..8c3c29ab77 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart @@ -1,12 +1,16 @@ +import 'dart:convert'; import 'dart:io'; -import 'package:appflowy/plugins/document/presentation/share/share_button.dart'; +import 'package:appflowy/plugins/shared/share/share_button.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:archive/archive.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'; +import '../document/document_with_database_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -18,7 +22,7 @@ void main() { // mock the file picker final path = await mockSaveFilePath( - p.join(context.applicationDataDirectory, 'test.md'), + p.join(context.applicationDataDirectory, 'test.zip'), ); // click the share button and select markdown await tester.tapShareButton(); @@ -28,10 +32,14 @@ void main() { tester.expectToExportSuccess(); final file = File(path); - final isExist = file.existsSync(); - expect(isExist, true); - final markdown = file.readAsStringSync(); - expect(markdown, expectedMarkdown); + expect(file.existsSync(), true); + final archive = ZipDecoder().decodeBytes(file.readAsBytesSync()); + for (final entry in archive) { + if (entry.isFile && entry.name.endsWith('.md')) { + final markdown = utf8.decode(entry.content); + expect(markdown, expectedMarkdown); + } + } }); testWidgets( @@ -51,13 +59,13 @@ void main() { }, ); - final shareButton = find.byType(ShareActionList); - final shareButtonState = - tester.state(shareButton) as ShareActionListState; + final shareButton = find.byType(ShareButton); + final shareButtonState = tester.widget(shareButton) as ShareButton; + final path = await mockSaveFilePath( p.join( context.applicationDataDirectory, - '${shareButtonState.name}.md', + '${shareButtonState.view.name}.zip', ), ); @@ -69,10 +77,44 @@ void main() { tester.expectToExportSuccess(); final file = File(path); - final isExist = file.existsSync(); - expect(isExist, true); + expect(file.existsSync(), true); + final archive = ZipDecoder().decodeBytes(file.readAsBytesSync()); + for (final entry in archive) { + if (entry.isFile && entry.name.endsWith('.md')) { + final markdown = utf8.decode(entry.content); + expect(markdown, expectedMarkdown); + } + } }, ); + + testWidgets('share the markdown with database', (tester) async { + final context = await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await insertLinkedDatabase(tester, ViewLayoutPB.Grid); + + // mock the file picker + final path = await mockSaveFilePath( + p.join(context.applicationDataDirectory, 'test.zip'), + ); + // 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); + expect(file.existsSync(), true); + final archive = ZipDecoder().decodeBytes(file.readAsBytesSync()); + bool hasCsvFile = false; + for (final entry in archive) { + if (entry.isFile && entry.name.endsWith('.csv')) { + hasCsvFile = true; + } + } + expect(hasCsvFile, true); + }); }); } 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 index 68db03d429..b9e1303279 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/switch_folder_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/switch_folder_test.dart @@ -101,7 +101,7 @@ void main() { // open settings and restore the location await tester.openSettings(); - await tester.openSettingsPage(SettingsPage.files); + await tester.openSettingsPage(SettingsPage.manageData); await tester.restoreLocation(); expect( diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart index 7deea4aae4..63ec958c54 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart @@ -1,17 +1,23 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:appflowy/generated/locale_keys.g.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/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:flowy_svg/flowy_svg.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/base.dart'; -import '../../shared/common_operations.dart'; -import '../../shared/expectation.dart'; import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; const _documentName = 'First Doc'; const _documentTwoName = 'Second Doc'; @@ -20,17 +26,12 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Tabs', () { - testWidgets('Open AppFlowy and open/navigate/close tabs', (tester) async { + testWidgets('open/navigate/close tabs', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); - expect( - find.descendant( - of: find.byType(TabsManager), - matching: find.byType(TabBar), - ), - findsNothing, - ); + // No tabs rendered yet + expect(find.byType(FlowyTab), findsNothing); await tester.createNewPageWithNameUnderParent(name: _documentName); @@ -44,7 +45,7 @@ void main() { expect( find.descendant( - of: find.byType(TabBar), + of: find.byType(TabsManager), matching: find.byType(FlowyTab), ), findsNWidgets(3), @@ -71,11 +72,300 @@ void main() { expect( find.descendant( - of: find.byType(TabBar), + 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); + }); + + testWidgets('displaying icons in tab', (tester) async { + RecentIcons.enable = false; + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final icon = await tester.loadIcon(); + // update emoji + await tester.updatePageIconInSidebarByName( + name: gettingStarted, + parentName: gettingStarted, + layout: ViewLayoutPB.Document, + icon: icon, + ); + + /// create new page + await tester.createNewPageWithNameUnderParent(name: _documentName); + + /// open new tab for [gettingStarted] + await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); + + final tabs = find.descendant( + of: find.byType(TabsManager), + matching: find.byType(FlowyTab), + ); + expect(tabs, findsNWidgets(2)); + + final svgInTab = + find.descendant(of: tabs.last, matching: find.byType(FlowySvg)); + final svgWidget = svgInTab.evaluate().first.widget as FlowySvg; + final iconsData = IconsData.fromJson(jsonDecode(icon.emoji)); + expect(svgWidget.svgString, iconsData.svgString); + }); }); } + +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 0000000000..836cfe4ccd --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart @@ -0,0 +1,20 @@ +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(); + 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 0000000000..f0cddadf68 --- /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 index e972f49fbb..c91ba21edb 100644 --- a/frontend/appflowy_flutter/integration_test/desktop_runner_1.dart +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_1.dart @@ -1,7 +1,6 @@ import 'package:integration_test/integration_test.dart'; -import 'desktop/document/document_test_runner.dart' as document_test_runner; -import 'desktop/uncategorized/empty_test.dart' as first_test; +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 { @@ -11,11 +10,7 @@ Future main() async { Future runIntegration1OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - // This test must be run first, otherwise the CI will fail. - first_test.main(); - switch_folder_test.main(); - document_test_runner.startTesting(); - - // DON'T add more tests here. This is the first test runner for desktop. + 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 index 9053da8d18..99d6f7d58f 100644 --- a/frontend/appflowy_flutter/integration_test/desktop_runner_2.dart +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_2.dart @@ -1,18 +1,7 @@ import 'package:integration_test/integration_test.dart'; -import 'desktop/database/database_calendar_test.dart' as database_calendar_test; -import 'desktop/database/database_cell_test.dart' as database_cell_test; -import 'desktop/database/database_field_settings_test.dart' - as database_field_settings_test; -import 'desktop/database/database_field_test.dart' as database_field_test; -import 'desktop/database/database_filter_test.dart' as database_filter_test; -import 'desktop/database/database_row_page_test.dart' as database_row_page_test; -import 'desktop/database/database_row_test.dart' as database_row_test; -import 'desktop/database/database_setting_test.dart' as database_setting_test; -import 'desktop/database/database_share_test.dart' as database_share_test; -import 'desktop/database/database_sort_test.dart' as database_sort_test; -import 'desktop/database/database_view_test.dart' as database_view_test; -import 'desktop/uncategorized/empty_test.dart' as first_test; +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(); @@ -21,20 +10,8 @@ Future main() async { Future runIntegration2OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - // This test must be run first, otherwise the CI will fail. first_test.main(); - database_cell_test.main(); - database_field_test.main(); - database_field_settings_test.main(); - database_share_test.main(); - database_row_page_test.main(); - database_row_test.main(); - database_setting_test.main(); - database_filter_test.main(); - database_sort_test.main(); - database_view_test.main(); - database_calendar_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 index 09a784d4fc..a9d3783f1d 100644 --- a/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart @@ -1,16 +1,8 @@ import 'package:integration_test/integration_test.dart'; import 'desktop/board/board_test_runner.dart' as board_test_runner; -import 'desktop/settings/settings_runner.dart' as settings_test_runner; -import 'desktop/sidebar/sidebar_test_runner.dart' as sidebar_test_runner; -import 'desktop/uncategorized/appearance_settings_test.dart' - as appearance_test_runner; -import 'desktop/uncategorized/emoji_shortcut_test.dart' as emoji_shortcut_test; -import 'desktop/uncategorized/empty_test.dart' as first_test; -import 'desktop/uncategorized/hotkeys_test.dart' as hotkeys_test; -import 'desktop/uncategorized/import_files_test.dart' as import_files_test; -import 'desktop/uncategorized/share_markdown_test.dart' as share_markdown_test; -import 'desktop/uncategorized/tabs_test.dart' as tabs_test; +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(); @@ -19,18 +11,9 @@ Future main() async { Future runIntegration3OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - // This test must be run first, otherwise the CI will fail. first_test.main(); - hotkeys_test.main(); - emoji_shortcut_test.main(); - hotkeys_test.main(); - emoji_shortcut_test.main(); - appearance_test_runner.main(); - settings_test_runner.main(); - share_markdown_test.main(); - import_files_test.main(); - sidebar_test_runner.startTesting(); - board_test_runner.startTesting(); - tabs_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 0000000000..e51c711549 --- /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 0000000000..be393e90c7 --- /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 0000000000..a1c5627b20 --- /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 0000000000..0200591c57 --- /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 0000000000..5a706e5dec --- /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 0000000000..451e24cdc1 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_9.dart @@ -0,0 +1,20 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/database/database_icon_test.dart' as database_icon_test; +import 'desktop/first_test/first_test.dart' as first_test; +import 'desktop/uncategorized/code_block_language_selector_test.dart' + as code_language_selector; +import 'desktop/uncategorized/tabs_test.dart' as tabs_test; + +Future main() async { + await runIntegration9OnDesktop(); +} + +Future runIntegration9OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + first_test.main(); + tabs_test.main(); + code_language_selector.main(); + database_icon_test.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 0000000000..c2f3d7103a --- /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 0000000000..e6015d0896 --- /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 0000000000..bf0ddc8711 --- /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 0000000000..e7bf3afcc7 --- /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 0000000000..210d1bcf0e --- /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/at_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart new file mode 100644 index 0000000000..2b348d3a2e --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart @@ -0,0 +1,56 @@ +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const title = 'Test At Menu'; + + group('at menu', () { + testWidgets('show at menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowAtMenu(title); + final menuWidget = find.byType(MobileInlineActionsMenu); + expect(menuWidget, findsOneWidget); + }); + + testWidgets('search by at menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowAtMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + expect(actionWidgets, findsNWidgets(2)); + }); + + testWidgets('tap at menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowAtMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + await tester.tap(actionWidgets.last); + expect(find.byType(MentionPageBlock), findsOneWidget); + }); + + testWidgets('create subpage with at menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile(title); + await tester.editor.tapLineOfEditorAt(0); + const subpageName = 'Subpage'; + await tester.ime.insertText('[[$subpageName'); + await tester.pumpAndSettle(); + final actionWidgets = find.byType(MobileInlineActionsWidget); + await tester.tapButton(actionWidgets.first); + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0]); + assert(firstNode != null); + expect(firstNode!.delta?.toPlainText().contains('['), false); + }); + }); +} 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 0000000000..90d5ca6d0d --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart @@ -0,0 +1,24 @@ +import 'package:integration_test/integration_test.dart'; + +import 'at_menu_test.dart' as at_menu; +import 'at_menu_test.dart' as at_menu_test; +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 'slash_menu_test.dart' as slash_menu; +import 'title_test.dart' as title_test; +import 'toolbar_test.dart' as toolbar_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Document integration tests + title_test.main(); + page_style_test.main(); + plus_menu_test.main(); + at_menu_test.main(); + simple_table_test.main(); + toolbar_test.main(); + slash_menu.main(); + at_menu.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart new file mode 100644 index 0000000000..da7c7e92e7 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart @@ -0,0 +1,104 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart'; +import 'package:appflowy/mobile/presentation/presentation.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.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:flutter/material.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('document title:', () { + testWidgets('update page custom image icon in title bar', (tester) async { + await tester.launchInAnonymousMode(); + + /// prepare local image + final iconData = await tester.prepareImageIcon(); + + /// create an empty page + await tester + .tapButton(find.byKey(BottomNavigationBarItemType.add.valueKey)); + + /// show Page style page + await tester.tapButton(find.byType(MobileViewPageLayoutButton)); + final pageStyleIcon = find.byType(PageStyleIcon); + final iconInPageStyleIcon = find.descendant( + of: pageStyleIcon, + matching: find.byType(RawEmojiIconWidget), + ); + expect(iconInPageStyleIcon, findsNothing); + + /// show icon picker + await tester.tapButton(pageStyleIcon); + + /// upload custom icon + await tester.pickImage(iconData); + + /// check result + final documentPage = find.byType(MobileDocumentScreen); + final rawEmojiIconFinder = find + .descendant( + of: documentPage, + matching: find.byType(RawEmojiIconWidget), + ) + .last; + final rawEmojiIconWidget = + rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget; + final iconDataInWidget = rawEmojiIconWidget.emoji; + expect(iconDataInWidget.type, FlowyIconType.custom); + final imageFinder = + find.descendant(of: rawEmojiIconFinder, matching: find.byType(Image)); + expect(imageFinder, findsOneWidget); + }); + + testWidgets('update page custom svg icon in title bar', (tester) async { + await tester.launchInAnonymousMode(); + + /// prepare local image + final iconData = await tester.prepareSvgIcon(); + + /// create an empty page + await tester + .tapButton(find.byKey(BottomNavigationBarItemType.add.valueKey)); + + /// show Page style page + await tester.tapButton(find.byType(MobileViewPageLayoutButton)); + final pageStyleIcon = find.byType(PageStyleIcon); + final iconInPageStyleIcon = find.descendant( + of: pageStyleIcon, + matching: find.byType(RawEmojiIconWidget), + ); + expect(iconInPageStyleIcon, findsNothing); + + /// show icon picker + await tester.tapButton(pageStyleIcon); + + /// upload custom icon + await tester.pickImage(iconData); + + /// check result + final documentPage = find.byType(MobileDocumentScreen); + final rawEmojiIconFinder = find + .descendant( + of: documentPage, + matching: find.byType(RawEmojiIconWidget), + ) + .last; + final rawEmojiIconWidget = + rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget; + final iconDataInWidget = rawEmojiIconWidget.emoji; + expect(iconDataInWidget.type, FlowyIconType.custom); + final svgFinder = find.descendant( + of: rawEmojiIconFinder, + matching: find.byType(SvgPicture), + ); + expect(svgFinder, findsOneWidget); + }); + }); +} 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 0000000000..e3d3bc093f --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/page_style_test.dart @@ -0,0 +1,163 @@ +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/base/view_page/app_bar_buttons.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart'; +import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.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/page_style/_page_style_icon.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.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/emoji.dart'; +import '../../shared/util.dart'; + +void main() { + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); + + tearDownAll(() { + RecentIcons.enable = true; + }); + + 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); + }); + + testWidgets('page style icon', (tester) async { + await tester.launchInAnonymousMode(); + + final createPageButton = + find.byKey(BottomNavigationBarItemType.add.valueKey); + await tester.tapButton(createPageButton); + + /// toggle the preset button + await tester.tapSvgButton(FlowySvgs.m_layout_s); + + /// select document plugins emoji + final pageStyleIcon = find.byType(PageStyleIcon); + + /// there should be none of emoji + final noneText = find.text(LocaleKeys.pageStyle_none.tr()); + expect(noneText, findsOneWidget); + await tester.tapButton(pageStyleIcon); + + /// select an emoji + const emoji = '😄'; + await tester.tapEmoji(emoji); + await tester.tapSvgButton(FlowySvgs.m_layout_s); + expect(noneText, findsNothing); + expect( + find.descendant( + of: pageStyleIcon, + matching: find.text(emoji), + ), + 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 0000000000..b54c543f7e --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart'; +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_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]))), + ); + }); + + const title = 'Test Plus Menu'; + testWidgets('show plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowPlusMenu(title); + final menuWidget = find.byType(MobileInlineActionsMenu); + expect(menuWidget, findsOneWidget); + }); + + testWidgets('search by plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowPlusMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + expect(actionWidgets, findsNWidgets(2)); + }); + + testWidgets('tap plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowPlusMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + await tester.tap(actionWidgets.last); + expect(find.byType(MentionPageBlock), findsOneWidget); + }); + }); +} 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 0000000000..546baebb31 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/simple_table_test.dart @@ -0,0 +1,554 @@ +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/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:flutter/cupertino.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); + + // disable header column + { + // 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.clickColumnMenuButton(0); + + final toggleButton = find.descendant( + of: find.byType(SimpleTableHeaderActionButton), + matching: find.byType(CupertinoSwitch), + ); + await tester.tapButton(toggleButton); + } + + // 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 + final toggleButton = find.descendant( + of: find.byType(SimpleTableHeaderActionButton), + matching: find.byType(CupertinoSwitch), + ); + await tester.tapButton(toggleButton); + } + + // check the table is updated + expect(table.isHeaderColumnEnabled, isFalse); + expect(table.isHeaderRowEnabled, isFalse); + + // 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); + + final clearContents = find.findTextInFlowyText( + LocaleKeys.document_plugins_simpleTable_moreActions_clearContents + .tr(), + ); + + // clear content + final scrollable = find.descendant( + of: find.byType(SimpleTableBottomSheet), + matching: find.byType(Scrollable), + ); + await tester.scrollUntilVisible( + clearContents, + 100, + scrollable: scrollable, + ); + await tester.tapButton(clearContents); + 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_heading1.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)); + } + }); + + testWidgets(''' +1. insert a simple table via + menu +2. resize 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 beforeWidth = editorState.getNodeAtPath([0, 0, 0])!.columnWidth; + + // find the first cell + { + final resizeHandle = find.byType(SimpleTableColumnResizeHandle).first; + final offset = tester.getCenter(resizeHandle); + final gesture = await tester.startGesture(offset, pointer: 7); + await tester.pumpAndSettle(); + + await gesture.moveBy(const Offset(100, 0)); + await tester.pumpAndSettle(); + + await gesture.up(); + await tester.pumpAndSettle(); + } + + // check the table is updated + final afterWidth1 = editorState.getNodeAtPath([0, 0, 0])!.columnWidth; + expect(afterWidth1, greaterThan(beforeWidth)); + + // resize back to the original width + { + final resizeHandle = find.byType(SimpleTableColumnResizeHandle).first; + final offset = tester.getCenter(resizeHandle); + final gesture = await tester.startGesture(offset, pointer: 7); + await tester.pumpAndSettle(); + + await gesture.moveBy(const Offset(-100, 0)); + await tester.pumpAndSettle(); + + await gesture.up(); + await tester.pumpAndSettle(); + } + + // check the table is updated + final afterWidth2 = editorState.getNodeAtPath([0, 0, 0])!.columnWidth; + expect(afterWidth2, equals(beforeWidth)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart new file mode 100644 index 0000000000..11031d2b71 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart @@ -0,0 +1,84 @@ +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart'; +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart'; +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const title = 'Test Slash Menu'; + + group('slash menu', () { + testWidgets('show slash menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowSlashMenu(title); + final menuWidget = find.byType(MobileSelectionMenuWidget); + expect(menuWidget, findsOneWidget); + final items = + (menuWidget.evaluate().first.widget as MobileSelectionMenuWidget) + .items; + int i = 0; + for (final item in items) { + final localItem = mobileItems[i]; + expect(item.name, localItem.name); + i++; + } + }); + + testWidgets('search by slash menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowSlashMenu(title); + const searchText = 'Heading'; + await tester.ime.insertText(searchText); + final itemWidgets = find.byType(MobileSelectionMenuItemWidget); + int number = 0; + for (final item in mobileItems) { + if (item is MobileSelectionMenuItem) { + for (final childItem in item.children) { + if (childItem.name + .toLowerCase() + .contains(searchText.toLowerCase())) { + number++; + } + } + } else { + if (item.name.toLowerCase().contains(searchText.toLowerCase())) { + number++; + } + } + } + expect(itemWidgets, findsNWidgets(number)); + }); + + testWidgets('tap to show submenu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile(title); + await tester.editor.tapLineOfEditorAt(0); + final listview = find.descendant( + of: find.byType(MobileSelectionMenuWidget), + matching: find.byType(ListView), + ); + for (final item in mobileItems) { + if (item is! MobileSelectionMenuItem) continue; + await tester.editor.showSlashMenu(); + await tester.scrollUntilVisible( + find.text(item.name), + 50, + scrollable: listview, + duration: const Duration(milliseconds: 250), + ); + await tester.tap(find.text(item.name)); + final childrenLength = ((listview.evaluate().first.widget as ListView) + .childrenDelegate as SliverChildListDelegate) + .children + .length; + expect(childrenLength, item.children.length); + } + }); + }); +} 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 0000000000..01b1d574ce --- /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/document/toolbar_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/toolbar_test.dart new file mode 100644 index 0000000000..72da283cd6 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/toolbar_test.dart @@ -0,0 +1,117 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/editor/mobile_editor_screen.dart'; +import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.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/util.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.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/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('toolbar menu:', () { + testWidgets('insert links', (tester) async { + await tester.launchInAnonymousMode(); + + final createPageButton = find.byKey( + BottomNavigationBarItemType.add.valueKey, + ); + await tester.tapButton(createPageButton); + expect(find.byType(MobileDocumentScreen), findsOneWidget); + + final editor = find.byType(AppFlowyEditor); + expect(editor, findsOneWidget); + final editorState = tester.editor.getCurrentEditorState(); + + /// move cursor to content + final root = editorState.document.root; + final lastNode = root.children.lastOrNull; + await editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: lastNode!.path)), + ); + await tester.pumpAndSettle(); + + /// insert two lines of text + const strFirst = 'FirstLine', + strSecond = 'SecondLine', + link = 'google.com'; + await editorState.insertTextAtCurrentSelection(strFirst); + await tester.pumpAndSettle(); + await editorState.insertNewLine(); + await tester.pumpAndSettle(); + await editorState.insertTextAtCurrentSelection(strSecond); + await tester.pumpAndSettle(); + final firstLine = find.text(strFirst, findRichText: true), + secondLine = find.text(strSecond, findRichText: true); + expect(firstLine, findsOneWidget); + expect(secondLine, findsOneWidget); + + /// select the first line + await tester.longPress(firstLine); + await tester.pumpAndSettle(); + + /// find aa item and tap it + final aaItem = find.byWidgetPredicate( + (widget) => + widget is AppFlowyMobileToolbarIconItem && + widget.icon == FlowySvgs.m_toolbar_aa_m, + ); + expect(aaItem, findsOneWidget); + await tester.tapButton(aaItem); + + /// find link button and tap it + final linkButton = find.byWidgetPredicate( + (widget) => + widget is MobileToolbarMenuItemWrapper && + widget.icon == FlowySvgs.m_toolbar_link_m, + ); + expect(linkButton, findsOneWidget); + await tester.tapButton(linkButton); + + /// input the link + final linkField = find.byWidgetPredicate( + (w) => + w is FlowyTextField && + w.hintText == LocaleKeys.document_inlineLink_url_placeholder.tr(), + ); + await tester.enterText(linkField, link); + await tester.pumpAndSettle(); + + /// complete inputting + await tester.tapButton(find.text(LocaleKeys.button_done.tr())); + + /// do it again + /// select the second line + await tester.longPress(secondLine); + await tester.pumpAndSettle(); + await tester.tapButton(aaItem); + await tester.tapButton(linkButton); + await tester.enterText(linkField, link); + await tester.pumpAndSettle(); + await tester.tapButton(find.text(LocaleKeys.button_done.tr())); + + final firstNode = editorState.getNodeAtPath([0]); + final secondNode = editorState.getNodeAtPath([1]); + + Map commonDeltaJson(String insert) => { + "insert": insert, + "attributes": {"href": link}, + }; + + expect( + firstNode?.delta?.toJson(), + commonDeltaJson(strFirst), + ); + expect( + secondNode?.delta?.toJson(), + commonDeltaJson(strSecond), + ); + }); + }); +} 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 index 8e3724a583..d64ab094de 100644 --- 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 @@ -1,47 +1,25 @@ -// ignore_for_file: unused_import - -import 'dart:io'; - -import 'package:appflowy/env/cloud_env.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/editor/mobile_editor_screen.dart'; -import 'package:appflowy/mobile/presentation/home/home.dart'; -import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart'; -import 'package:appflowy/plugins/document/document_page.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/screens/sign_in_screen/widgets/widgets.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/uuid.dart'; +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 'package:path/path.dart' as p; -import '../../shared/dir.dart'; -import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('create new page', () { + group('create new page in home page:', () { testWidgets('create document', (tester) async { - await tester.initializeAppFlowy( - cloudType: AuthenticatorType.local, - ); - - // click the anonymousSignInButton - final anonymousSignInButton = find.byType(SignInAnonymousButtonV2); - expect(anonymousSignInButton, findsOneWidget); - await tester.tapButton(anonymousSignInButton); + await tester.launchInAnonymousMode(); // tap the create page button - final createPageButton = find.byKey(mobileCreateNewPageButtonKey); + 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/settings/default_text_direction_test.dart b/frontend/appflowy_flutter/integration_test/mobile/settings/default_text_direction_test.dart new file mode 100644 index 0000000000..158264cad1 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/settings/default_text_direction_test.dart @@ -0,0 +1,81 @@ +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/home/setting/settings_popup_menu.dart'; +import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Change default text direction', (tester) async { + await tester.launchInAnonymousMode(); + + /// tap [Setting] button + await tester.tapButton(find.byType(HomePageSettingsPopupMenu)); + await tester + .tapButton(find.text(LocaleKeys.settings_popupMenuItem_settings.tr())); + + /// tap [Default Text Direction] + await tester.tapButton( + find.text(LocaleKeys.settings_appearance_textDirection_label.tr()), + ); + + /// there are 3 items: LTR-RTL-AUTO + final bottomSheet = find.ancestor( + of: find.byType(FlowyOptionTile), + matching: find.byType(SafeArea), + ); + final items = find.descendant( + of: bottomSheet, + matching: find.byType(FlowyOptionTile), + ); + expect(items, findsNWidgets(3)); + + /// select [Auto] + await tester.tapButton(items.last); + expect( + find.text(LocaleKeys.settings_appearance_textDirection_auto.tr()), + findsOneWidget, + ); + + /// go back home + await tester.tapButton(find.byType(AppBarImmersiveBackButton)); + + /// create new page + final createPageButton = + find.byKey(BottomNavigationBarItemType.add.valueKey); + await tester.tapButton(createPageButton); + + final editorState = tester.editor.getCurrentEditorState(); + // focus on the editor + await tester.editor.tapLineOfEditorAt(0); + + const testEnglish = 'English', testArabic = 'إنجليزي'; + + /// insert [testEnglish] + await editorState.insertTextAtCurrentSelection(testEnglish); + await tester.pumpAndSettle(); + await editorState.insertNewLine(position: editorState.selection!.end); + await tester.pumpAndSettle(); + + /// insert [testArabic] + await editorState.insertTextAtCurrentSelection(testArabic); + await tester.pumpAndSettle(); + final testEnglishFinder = find.text(testEnglish, findRichText: true), + testArabicFinder = find.text(testArabic, findRichText: true); + final testEnglishRenderBox = + testEnglishFinder.evaluate().first.renderObject as RenderBox, + testArabicRenderBox = + testArabicFinder.evaluate().first.renderObject as RenderBox; + final englishPosition = testEnglishRenderBox.localToGlobal(Offset.zero), + arabicPosition = testArabicRenderBox.localToGlobal(Offset.zero); + expect(englishPosition.dx > arabicPosition.dx, true); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/settings/scale_factor_test.dart b/frontend/appflowy_flutter/integration_test/mobile/settings/scale_factor_test.dart new file mode 100644 index 0000000000..908caa89d5 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/settings/scale_factor_test.dart @@ -0,0 +1,48 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/home/setting/settings_popup_menu.dart'; +import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.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(); + + testWidgets('test for change scale factor', (tester) async { + await tester.launchInAnonymousMode(); + + /// tap [Setting] button + await tester.tapButton(find.byType(HomePageSettingsPopupMenu)); + await tester + .tapButton(find.text(LocaleKeys.settings_popupMenuItem_settings.tr())); + + /// tap [Font Scale Factor] + await tester.tapButton( + find.text(LocaleKeys.settings_appearance_fontScaleFactor.tr()), + ); + + /// drag slider + final slider = find.descendant( + of: find.byType(FontSizeStepper), + matching: find.byType(Slider), + ); + await tester.slideToValue(slider, 0.8); + expect(appflowyScaleFactor, 0.8); + + await tester.slideToValue(slider, 0.9); + expect(appflowyScaleFactor, 0.9); + + await tester.slideToValue(slider, 1.0); + expect(appflowyScaleFactor, 1.0); + + await tester.slideToValue(slider, 1.1); + expect(appflowyScaleFactor, 1.1); + + await tester.slideToValue(slider, 1.2); + expect(appflowyScaleFactor, 1.2); + }); +} 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 index 65f48a87ff..ab98ca190a 100644 --- 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 @@ -1,38 +1,15 @@ -// ignore_for_file: unused_import - -import 'dart:io'; - -import 'package:appflowy/env/cloud_env.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/home.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/screens/sign_in_screen/widgets/widgets.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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 'package:path/path.dart' as p; -import '../../shared/dir.dart'; -import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('anonymous sign in on mobile', () { + group('anonymous sign in on mobile:', () { testWidgets('anon user and then sign in', (tester) async { - await tester.initializeAppFlowy(); - - // click the anonymousSignInButton - final anonymousSignInButton = find.byType(SignInAnonymousButtonV2); - expect(anonymousSignInButton, findsOneWidget); - await tester.tapButton(anonymousSignInButton); + await tester.launchInAnonymousMode(); // expect to see the home page expect(find.byType(MobileHomeScreen), findsOneWidget); diff --git a/frontend/appflowy_flutter/integration_test/mobile_runner.dart b/frontend/appflowy_flutter/integration_test/mobile_runner.dart deleted file mode 100644 index ca1a7ae0d3..0000000000 --- a/frontend/appflowy_flutter/integration_test/mobile_runner.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:integration_test/integration_test.dart'; - -import 'mobile/sign_in/anonymous_sign_in_test.dart' as anonymous_sign_in_test; - -Future runIntegrationOnMobile() async { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - anonymous_sign_in_test.main(); -} 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 0000000000..4d92db7d25 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile_runner_1.dart @@ -0,0 +1,23 @@ +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/settings/default_text_direction_test.dart' + as default_text_direction_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(); + default_text_direction_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/runner.dart b/frontend/appflowy_flutter/integration_test/runner.dart index cb7d2d6e33..0fc3c5d826 100644 --- a/frontend/appflowy_flutter/integration_test/runner.dart +++ b/frontend/appflowy_flutter/integration_test/runner.dart @@ -3,7 +3,13 @@ import 'dart:io'; import 'desktop_runner_1.dart'; import 'desktop_runner_2.dart'; import 'desktop_runner_3.dart'; -import 'mobile_runner.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. /// @@ -17,8 +23,14 @@ Future main() async { 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 runIntegrationOnMobile(); + 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 index 873e6244b6..88f9634afd 100644 --- a/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart +++ b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart @@ -1,33 +1,35 @@ -import 'package:flutter/material.dart'; - 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/settings_account_view.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/settings/widgets/setting_supabase_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() async { - await tapButton(find.byKey(const Key('signInWithGoogleButton'))); + 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(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: scrollable, ); - await tapButton(find.byType(SignInOutButton)); + await tapButton(find.byType(AccountSignInOutButton)); - expectToSeeText(LocaleKeys.button_confirm.tr()); - await tapButtonWithName(LocaleKeys.button_confirm.tr()); + expectToSeeText(LocaleKeys.button_ok.tr()); + await tapButtonWithName(LocaleKeys.button_ok.tr()); } Future tapSignInAsGuest() async { @@ -35,7 +37,7 @@ extension AppFlowyAuthTest on WidgetTester { } void expectToSeeGoogleLoginButton() { - expect(find.byKey(const Key('signInWithGoogleButton')), findsOneWidget); + expect(find.byKey(signInWithGoogleButtonKey), findsOneWidget); } void assertSwitchValue(Finder finder, bool value) { @@ -44,49 +46,26 @@ extension AppFlowyAuthTest on WidgetTester { assert(isSwitched == value); } - void assertEnableEncryptSwitchValue(bool value) { - assertSwitchValue( - find.descendant( - of: find.byType(EnableEncrypt), - matching: find.byWidgetPredicate((widget) => widget is Switch), - ), - value, - ); - } - - void assertSupabaseEnableSyncSwitchValue(bool value) { - assertSwitchValue( - find.descendant( - of: find.byType(SupabaseEnableSync), - matching: find.byWidgetPredicate((widget) => widget is Switch), - ), - value, - ); + void assertToggleValue(Finder finder, bool value) { + final Toggle switchWidget = widget(finder); + final isSwitched = switchWidget.value; + assert(isSwitched == value); } void assertAppFlowyCloudEnableSyncSwitchValue(bool value) { - assertSwitchValue( + assertToggleValue( find.descendant( of: find.byType(AppFlowyCloudEnableSync), - matching: find.byWidgetPredicate((widget) => widget is Switch), + matching: find.byWidgetPredicate((widget) => widget is Toggle), ), value, ); } - Future toggleEnableEncrypt() async { - final finder = find.descendant( - of: find.byType(EnableEncrypt), - matching: find.byWidgetPredicate((widget) => widget is Switch), - ); - - await tapButton(finder); - } - Future toggleEnableSync(Type syncButton) async { final finder = find.descendant( of: find.byType(syncButton), - matching: find.byWidgetPredicate((widget) => widget is Switch), + 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 index 47f5337fce..493cb4c1f0 100644 --- a/frontend/appflowy_flutter/integration_test/shared/base.dart +++ b/frontend/appflowy_flutter/integration_test/shared/base.dart @@ -1,24 +1,24 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/services.dart'; - 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/application/auth/supabase_mock_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/material.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}); @@ -56,8 +56,6 @@ extension AppFlowyTestBase on WidgetTester { switch (cloudType) { case AuthenticatorType.local: break; - case AuthenticatorType.supabase: - break; case AuthenticatorType.appflowyCloudSelfHost: rustEnvs["GOTRUE_ADMIN_EMAIL"] = "admin@example.com"; rustEnvs["GOTRUE_ADMIN_PASSWORD"] = "password"; @@ -76,13 +74,6 @@ extension AppFlowyTestBase on WidgetTester { case AuthenticatorType.local: await useLocalServer(); break; - case AuthenticatorType.supabase: - await useTestSupabaseCloud(); - getIt.unregister(); - getIt.registerFactory( - () => SupabaseMockAuthService(), - ); - break; case AuthenticatorType.appflowyCloudSelfHost: await useTestSelfHostedAppFlowyCloud(); getIt.unregister(); @@ -116,7 +107,7 @@ extension AppFlowyTestBase on WidgetTester { } Future waitUntilSignInPageShow() async { - if (isAuthEnabled) { + if (isAuthEnabled || UniversalPlatform.isMobile) { final finder = find.byType(SignInAnonymousButtonV2); await pumpUntilFound(finder, timeout: const Duration(seconds: 30)); expect(finder, findsOneWidget); @@ -169,31 +160,65 @@ extension AppFlowyTestBase on WidgetTester { Future tapButton( Finder finder, { - int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = false, int milliseconds = 500, + bool pumpAndSettle = true, }) async { - await tap( - finder, - buttons: buttons, - warnIfMissed: warnIfMissed, - ); - await pumpAndSettle( - Duration(milliseconds: milliseconds), - EnginePhase.sendSemanticsUpdate, - const Duration(seconds: 5), - ); + 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}) async { + Future tapDown( + Finder finder, { + int? pointer, + int buttons = kPrimaryButton, + PointerDeviceKind kind = PointerDeviceKind.touch, + bool pumpAndSettle = true, + int milliseconds = 500, + }) async { + final location = getCenter(finder); + final TestGesture gesture = await startGesture( + location, + pointer: pointer, + buttons: buttons, + kind: kind, + ); + await gesture.cancel(); + await gesture.down(location); + await gesture.cancel(); + 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); + await tapButton( + button, + milliseconds: milliseconds, + pumpAndSettle: pumpAndSettle, + ); } Future doubleTapAt( @@ -211,6 +236,25 @@ extension AppFlowyTestBase on WidgetTester { Future wait(int milliseconds) async { await pumpAndSettle(Duration(milliseconds: milliseconds)); } + + Future slideToValue( + Finder slider, + double value, { + double paddingOffset = 24.0, + }) async { + final sliderWidget = slider.evaluate().first.widget as Slider; + final range = sliderWidget.max - sliderWidget.min; + final initialRate = (value - sliderWidget.min) / range; + final totalWidth = getSize(slider).width - (2 * paddingOffset); + final zeroPoint = getTopLeft(slider) + + Offset( + paddingOffset + initialRate * totalWidth, + getSize(slider).height / 2, + ); + final calculatedOffset = value * (totalWidth / 100); + await dragFrom(zeroPoint, Offset(calculatedOffset, 0)); + await pumpAndSettle(); + } } extension AppFlowyFinderTestBase on CommonFinders { @@ -219,13 +263,16 @@ extension AppFlowyFinderTestBase on CommonFinders { (widget) => widget is FlowyText && widget.text == text, ); } -} -Future useTestSupabaseCloud() async { - await useSupabaseCloud( - url: TestEnv.supabaseUrl, - anonKey: TestEnv.supabaseAnonKey, - ); + 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 { diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart index 0a0858beec..d7a505d152 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -1,37 +1,58 @@ import 'dart:io'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -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/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/share/share_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/icon_emoji_picker/icon_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/presentation/home/menu/sidebar/sidebar_new_page_button.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.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/space/space_icon_popup.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_language_view.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:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:universal_platform/universal_platform.dart'; import 'emoji.dart'; import 'util.dart'; @@ -46,9 +67,15 @@ extension CommonOperations on WidgetTester { } else { // cloud version final anonymousButton = find.byType(SignInAnonymousButtonV2); - await tapButton(anonymousButton); + await tapButton(anonymousButton, warnIfMissed: true); } + 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)); } @@ -57,6 +84,7 @@ extension CommonOperations on WidgetTester { /// Tap the + button on the home page. Future tapAddViewButton({ String name = gettingStarted, + ViewLayoutPB layout = ViewLayoutPB.Document, }) async { await hoverOnPageName( name, @@ -167,6 +195,21 @@ extension CommonOperations on WidgetTester { } } + /// 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, { @@ -181,7 +224,10 @@ extension CommonOperations on WidgetTester { /// /// Must call [hoverOnPageName] first. Future tapPageOptionButton() async { - final optionButton = find.byType(ViewMoreActionButton); + final optionButton = find.descendant( + of: find.byType(ViewMoreActionPopover), + matching: find.byFlowySvg(FlowySvgs.workspace_three_dots_s), + ); await tapButton(optionButton); } @@ -222,6 +268,10 @@ extension CommonOperations on WidgetTester { await tapOKButton(); } + Future tapTrashButton() async { + await tap(find.byType(SidebarTrashButton)); + } + Future tapOKButton() async { final okButton = find.byWidgetPredicate( (widget) => @@ -231,6 +281,20 @@ extension CommonOperations on WidgetTester { 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. @@ -243,22 +307,33 @@ extension CommonOperations on WidgetTester { /// Tap the delete permanently button. /// - /// the restore button will show after the current page is deleted. + /// the delete permanently button will show after the current page is deleted. Future tapDeletePermanentlyButton() async { - final restoreButton = find.textContaining( + final deleteButton = find.textContaining( LocaleKeys.deletePagePrompt_deletePermanent.tr(), ); - await tapButton(restoreButton); + 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 DocumentShareButton, + (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. @@ -276,7 +351,7 @@ extension CommonOperations on WidgetTester { bool openAfterCreated = true, }) async { // create a new page - await tapAddViewButton(name: parentName ?? gettingStarted); + await tapAddViewButton(name: parentName ?? gettingStarted, layout: layout); await tapButtonWithName(layout.menuName); final settingsOrFailure = await getIt().getWithFormat( KVKeys.showRenameDialogWhenCreatingNewFile, @@ -291,7 +366,7 @@ extension CommonOperations on WidgetTester { // hover on it and change it's name if (name != null) { await hoverOnPageName( - LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layout.defaultName, layout: layout, onHover: () async { await renamePage(name); @@ -305,13 +380,110 @@ extension CommonOperations on WidgetTester { if (openAfterCreated) { await openPage( // if the name is null, use the default name - name ?? LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + 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, @@ -325,6 +497,7 @@ extension CommonOperations on WidgetTester { bool isShiftPressed = false, bool isAltPressed = false, bool isMetaPressed = false, + PhysicalKeyboardKey? physicalKey, }) async { if (isControlPressed) { await simulateKeyDownEvent(LogicalKeyboardKey.control); @@ -338,8 +511,14 @@ extension CommonOperations on WidgetTester { if (isMetaPressed) { await simulateKeyDownEvent(LogicalKeyboardKey.meta); } - await simulateKeyDownEvent(key); - await simulateKeyUpEvent(key); + await simulateKeyDownEvent( + key, + physicalKey: physicalKey, + ); + await simulateKeyUpEvent( + key, + physicalKey: physicalKey, + ); if (isControlPressed) { await simulateKeyUpEvent(LogicalKeyboardKey.control); } @@ -422,6 +601,23 @@ extension CommonOperations on WidgetTester { await pumpAndSettle(); } + Future reorderFavorite({ + required String fromName, + required String toName, + }) async { + final from = find.descendant( + of: find.byType(FavoriteFolder), + matching: find.text(fromName), + ), + to = find.descendant( + of: find.byType(FavoriteFolder), + matching: find.text(toName), + ); + final distanceY = getCenter(to).dy - getCenter(from).dx; + await drag(from, Offset(0, distanceY)); + await pumpAndSettle(const Duration(seconds: 1)); + } + // tap the button with [FlowySvgData] Future tapButtonWithFlowySvgData(FlowySvgData svg) async { final button = find.byWidgetPredicate( @@ -433,9 +629,9 @@ extension CommonOperations on WidgetTester { // update the page icon in the sidebar Future updatePageIconInSidebarByName({ required String name, - required String parentName, + String? parentName, required ViewLayoutPB layout, - required String icon, + required EmojiIconData icon, }) async { final iconButton = find.descendant( of: findPageName( @@ -447,7 +643,11 @@ extension CommonOperations on WidgetTester { find.byTooltip(LocaleKeys.document_plugins_cover_changeIcon.tr()), ); await tapButton(iconButton); - await tapEmoji(icon); + if (icon.type == FlowyIconType.emoji) { + await tapEmoji(icon.emoji); + } else if (icon.type == FlowyIconType.icon) { + await tapIcon(icon); + } await pumpAndSettle(); } @@ -455,7 +655,7 @@ extension CommonOperations on WidgetTester { Future updatePageIconInTitleBarByName({ required String name, required ViewLayoutPB layout, - required String icon, + required EmojiIconData icon, }) async { await openPage( name, @@ -467,7 +667,32 @@ extension CommonOperations on WidgetTester { ); await tapButton(title); await tapButton(find.byType(EmojiPickerButton)); - await tapEmoji(icon); + if (icon.type == FlowyIconType.emoji) { + await tapEmoji(icon.emoji); + } else if (icon.type == FlowyIconType.icon) { + await tapIcon(icon); + } else if (icon.type == FlowyIconType.custom) { + await pickImage(icon); + } + await pumpAndSettle(); + } + + Future updatePageIconInTitleBarByPasteALink({ + required String name, + required ViewLayoutPB layout, + required String iconLink, + }) 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)); + await pasteImageLinkAsIcon(iconLink); await pumpAndSettle(); } @@ -510,8 +735,9 @@ extension CommonOperations on WidgetTester { final workspace = find.byType(SidebarWorkspace); expect(workspace, findsOneWidget); - // click it - await tapButton(workspace, milliseconds: 2000); + + await tapButton(workspace, pumpAndSettle: false); + await pump(const Duration(seconds: 5)); } Future createCollaborativeWorkspace(String name) async { @@ -526,16 +752,246 @@ extension CommonOperations on WidgetTester { // click the create button final createButton = find.byKey(createWorkspaceButtonKey); expect(createButton, findsOneWidget); - await tapButton(createButton); + 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 - await enterText(find.byType(TextField), name); + final workspaceNameInput = find.descendant( + of: createWorkspaceDialog, + matching: find.byType(TextField), + ); + await enterText(workspaceNameInput, name); - await tapButtonWithName(LocaleKeys.button_ok.tr()); + 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); + } + + /// load icon list and return the first one + Future loadIcon() async { + await loadIconGroups(); + final groups = kIconGroups!; + final firstGroup = groups.first; + final firstIcon = firstGroup.icons.first; + return EmojiIconData.icon( + IconsData( + firstGroup.name, + firstIcon.name, + builtInSpaceColors.first, + ), + ); + } + + Future prepareImageIcon() async { + final imagePath = await rootBundle.load('assets/test/images/sample.jpeg'); + final tempDirectory = await getTemporaryDirectory(); + final localImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final imageFile = File(localImagePath) + ..writeAsBytesSync(imagePath.buffer.asUint8List()); + return EmojiIconData.custom(imageFile.path); + } + + Future prepareSvgIcon() async { + final imagePath = await rootBundle.load('assets/test/images/sample.svg'); + final tempDirectory = await getTemporaryDirectory(); + final localImagePath = p.join(tempDirectory.path, 'sample.svg'); + final imageFile = File(localImagePath) + ..writeAsBytesSync(imagePath.buffer.asUint8List()); + return EmojiIconData.custom(imageFile.path); + } + + /// create new page and show slash menu + Future createPageAndShowSlashMenu(String title) async { + await createNewDocumentOnMobile(title); + await editor.tapLineOfEditorAt(0); + await editor.showSlashMenu(); + } + + /// create new page and show at menu + Future createPageAndShowAtMenu(String title) async { + await createNewDocumentOnMobile(title); + await editor.tapLineOfEditorAt(0); + await editor.showAtMenu(); + } + + /// create new page and show plus menu + Future createPageAndShowPlusMenu(String title) async { + await createNewDocumentOnMobile(title); + await editor.tapLineOfEditorAt(0); + await editor.showPlusMenu(); } } @@ -551,6 +1007,37 @@ extension SettingsFinder on CommonFinders { 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 { @@ -581,4 +1068,34 @@ extension ViewLayoutPBTest on ViewLayoutPB { 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 0000000000..bfe3349b10 --- /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 index 6a2ad830bb..c1777638d3 100644 --- a/frontend/appflowy_flutter/integration_test/shared/data.dart +++ b/frontend/appflowy_flutter/integration_test/shared/data.dart @@ -1,9 +1,8 @@ import 'dart:io'; -import 'package:flutter/services.dart'; - 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'; @@ -60,7 +59,7 @@ class TestWorkspaceService { final inputStream = InputFileStream(await workspace.zip.then((value) => value.path)); final archive = ZipDecoder().decodeBuffer(inputStream); - extractArchiveToDisk( + 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 index 6b5b0cc1cf..970965f294 100644 --- a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart @@ -1,12 +1,9 @@ import 'dart:io'; -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/application/field/filter_entities.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'; @@ -20,13 +17,15 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations 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/checklist.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/date.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/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/create_filter_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_menu_item.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'; @@ -38,9 +37,11 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filt 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/desktop_row_detail/desktop_row_detail_checklist_cell.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'; @@ -48,8 +49,9 @@ import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/ti 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_editor.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'; @@ -68,11 +70,12 @@ import 'package:appflowy/plugins/database/widgets/setting/database_setting_actio 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/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.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/dialogs.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'; @@ -83,6 +86,9 @@ 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; // Non-exported member of the table_calendar library @@ -94,8 +100,11 @@ 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 openV020database() async { + Future openTestDatabase(String fileName) async { final context = await initializeAppFlowy(); await tapAnonymousSignInButton(); @@ -105,37 +114,32 @@ extension AppFlowyDatabaseTest on WidgetTester { await tapAddViewButton(); await tapImportButton(); - final testFileNames = ['v020.afdb']; - final paths = []; - for (final fileName in testFileNames) { - // 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"); + // 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, - ); - paths.add(path); - File(path).writeAsStringSync(str); - } + // 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: paths, + paths: [path], ); await tapDatabaseRawDataButton(); - await pumpAndSettle(); - await openPage('v020', layout: ViewLayoutPB.Grid); + await openPage(pageName, layout: ViewLayoutPB.Grid); } - Future hoverOnFirstRowOfGrid() async { + Future hoverOnFirstRowOfGrid([Future Function()? onHover]) async { final findRow = find.byType(GridRow); expect(findRow, findsWidgets); final firstRow = findRow.first; - await hoverOnWidget(firstRow); + await hoverOnWidget(firstRow, onHover: onHover); } Future editCell({ @@ -148,6 +152,7 @@ extension AppFlowyDatabaseTest on WidgetTester { expect(cell, findsOneWidget); await enterText(cell, input); + await testTextInput.receiveAction(TextInputAction.done); await pumpAndSettle(); } @@ -243,10 +248,10 @@ extension AppFlowyDatabaseTest on WidgetTester { } } - Future assertMultiSelectOption({ + void assertMultiSelectOption({ required int rowIndex, required List contents, - }) async { + }) { final findCell = cellFinder(rowIndex, FieldType.MultiSelect); for (final content in contents) { if (content.isNotEmpty) { @@ -407,17 +412,20 @@ extension AppFlowyDatabaseTest on WidgetTester { } Future selectOption({required String name}) async { - final option = find.byWidgetPredicate( - (widget) => widget is SelectOptionTagCell && widget.option.name == name, + final option = find.descendant( + of: find.byType(SelectOptionCellEditor), + matching: find.byWidgetPredicate( + (widget) => widget is SelectOptionTagCell && widget.option.name == name, + ), ); await tapButton(option); } - Future findSelectOptionWithNameInGrid({ + void findSelectOptionWithNameInGrid({ required int rowIndex, required String name, - }) async { + }) { final findRow = find.byType(GridRow); final option = find.byWidgetPredicate( (widget) => @@ -429,10 +437,10 @@ extension AppFlowyDatabaseTest on WidgetTester { expect(cell, findsOneWidget); } - Future assertNumberOfSelectedOptionsInGrid({ + void assertNumberOfSelectedOptionsInGrid({ required int rowIndex, required Matcher matcher, - }) async { + }) { final findRow = find.byType(GridRow); final options = find.byWidgetPredicate( @@ -474,7 +482,7 @@ extension AppFlowyDatabaseTest on WidgetTester { await pumpAndSettle(); if (enter) { await testTextInput.receiveAction(TextInputAction.done); - await pumpAndSettle(); + await pumpAndSettle(const Duration(milliseconds: 500)); } else { await tapButton( find.descendant( @@ -500,6 +508,7 @@ extension AppFlowyDatabaseTest on WidgetTester { Future renameChecklistTask({ required int index, required String name, + bool enter = true, }) async { final textField = find .descendant( @@ -509,7 +518,9 @@ extension AppFlowyDatabaseTest on WidgetTester { .at(index); await enterText(textField, name); - await testTextInput.receiveAction(TextInputAction.done); + if (enter) { + await testTextInput.receiveAction(TextInputAction.done); + } await pumpAndSettle(); } @@ -527,14 +538,38 @@ extension AppFlowyDatabaseTest on WidgetTester { Future deleteChecklistTask({required int index}) async { final task = find.byType(ChecklistItem).at(index); - await startGesture(getCenter(task), kind: PointerDeviceKind.mouse); - await pumpAndSettle(); - - final button = find.byWidgetPredicate( - (widget) => widget is FlowySvg && widget.svg == FlowySvgs.delete_s, + await hoverOnWidget( + task, + onHover: () async { + final button = find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.delete_s, + ); + await tapButton(button); + }, ); + } - 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 { @@ -566,12 +601,25 @@ extension AppFlowyDatabaseTest on WidgetTester { final banner = find.byType(RowBanner); expect(banner, findsOneWidget); - await startGesture(getCenter(banner), kind: PointerDeviceKind.mouse); + 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.byType(AddEmojiButton)); + tapButton(find.text(LocaleKeys.document_plugins_cover_addIcon.tr())); Future tapDateCellInRowDetailPage() async { final findDateCell = find.byType(EditableDateCell); @@ -627,12 +675,7 @@ extension AppFlowyDatabaseTest on WidgetTester { (w) => w is FieldActionCell && w.action == FieldAction.delete, ); await tapButton(deleteButton); - - final confirmButton = find.descendant( - of: find.byType(NavigatorAlertDialog), - matching: find.byType(PrimaryTextButton), - ); - await tapButton(confirmButton); + await tapButtonWithName(LocaleKeys.space_delete.tr()); } Future scrollRowDetailByOffset(Offset offset) async { @@ -661,16 +704,66 @@ extension AppFlowyDatabaseTest on WidgetTester { Future changeFieldTypeOfFieldWithName( String name, - FieldType type, - ) async { + FieldType type, { + ViewLayoutPB layout = ViewLayoutPB.Grid, + }) async { await tapGridFieldWithName(name); - await tapEditFieldButton(); + 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(); @@ -783,12 +876,12 @@ extension AppFlowyDatabaseTest on WidgetTester { /// Each field has its own cell, so we can find the corresponding cell by /// the field type after create a new field. - Future findCellByFieldType(FieldType fieldType) async { + void findCellByFieldType(FieldType fieldType) { final finder = finderForFieldType(fieldType); expect(finder, findsWidgets); } - Future assertNumberOfRowsInGridPage(int num) async { + void assertNumberOfRowsInGridPage(int num) { expect( find.byType(GridRow, skipOffstage: false), findsNWidgets(num), @@ -849,11 +942,41 @@ extension AppFlowyDatabaseTest on WidgetTester { await pumpAndSettle(const Duration(milliseconds: 200)); } + Future changeFieldWidth(String fieldName, double width) async { + final field = find.byWidgetPredicate( + (widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName, + ); + await hoverOnWidget( + field, + onHover: () async { + final dragHandle = find.descendant( + of: field, + matching: find.byType(DragToExpandLine), + ); + await drag(dragHandle, Offset(width - getSize(field).width, 0)); + await pumpAndSettle(const Duration(milliseconds: 200)); + }, + ); + } + + double getFieldWidth(String fieldName) { + final field = find.byWidgetPredicate( + (widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName, + ); + + return getSize(field).width; + } + 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); @@ -868,7 +991,7 @@ extension AppFlowyDatabaseTest on WidgetTester { await tapButton(find.byType(GridAddRowButton)); } - Future tapCreateRowButtonInRowMenuOfGrid() async { + Future tapCreateRowButtonAfterHoveringOnGridRow() async { await tapButton(find.byType(InsertRowButton)); } @@ -876,18 +999,57 @@ extension AppFlowyDatabaseTest on WidgetTester { 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 createField(FieldType fieldType, String name) async { - await scrollToRight(find.byType(GridPage)); + 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(); - await renameField(name); + if (name != null) { + await renameField(name); + } await tapSwitchFieldTypeButton(); await selectFieldType(fieldType); - await dismissFieldEditor(); } Future tapDatabaseSettingButton() async { @@ -905,7 +1067,7 @@ extension AppFlowyDatabaseTest on WidgetTester { Future tapCreateFilterByFieldType(FieldType type, String title) async { final findFilter = find.byWidgetPredicate( (widget) => - widget is GridFilterPropertyCell && + widget is FilterableFieldButton && widget.fieldInfo.fieldType == type && widget.fieldInfo.name == title, ); @@ -913,8 +1075,9 @@ extension AppFlowyDatabaseTest on WidgetTester { } Future tapFilterButtonInGrid(String name) async { - final findFilter = find.byType(FilterMenuItem); - final button = find.descendant(of: findFilter, matching: find.text(name)); + final button = find.byWidgetPredicate( + (widget) => widget is ChoiceChipButton && widget.fieldInfo.name == name, + ); await tapButton(button); } @@ -952,12 +1115,15 @@ extension AppFlowyDatabaseTest on WidgetTester { } /// Must call [tapSortMenuInSettingBar] first. - Future tapSortButtonByName(String name) async { - final findSortItem = find.byWidgetPredicate( - (widget) => - widget is DatabaseSortItem && widget.sortInfo.fieldInfo.name == name, + 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(findSortItem); + await tapButton(sortItem); } /// Must call [tapSortMenuInSettingBar] first. @@ -965,18 +1131,26 @@ extension AppFlowyDatabaseTest on WidgetTester { (FieldType, String) from, (FieldType, String) to, ) async { - final fromSortItem = find.byWidgetPredicate( - (widget) => - widget is DatabaseSortItem && - widget.sortInfo.fieldInfo.fieldType == from.$1 && - widget.sortInfo.fieldInfo.name == from.$2, + final fromSortItem = find.ancestor( + of: find.text(from.$2), + matching: find.byType(DatabaseSortItem), ); - final toSortItem = find.byWidgetPredicate( - (widget) => - widget is DatabaseSortItem && - widget.sortInfo.fieldInfo.fieldType == to.$1 && - widget.sortInfo.fieldInfo.name == to.$2, + 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), @@ -985,16 +1159,13 @@ extension AppFlowyDatabaseTest on WidgetTester { await pumpAndSettle(const Duration(milliseconds: 200)); } - /// Must call [tapSortButtonByName] first. + /// Must call [tapEditSortConditionButtonByFieldName] first. Future tapSortByDescending() async { await tapButton( - find.descendant( - of: find.byType(OrderPannelItem), - matching: find.byWidgetPredicate( - (widget) => - widget is FlowyText && - widget.text == LocaleKeys.grid_sort_descending.tr(), - ), + find.byWidgetPredicate( + (widget) => + widget is OrderPanelItem && + widget.condition == SortConditionPB.Descending, ), ); await sendKeyEvent(LogicalKeyboardKey.escape); @@ -1002,7 +1173,7 @@ extension AppFlowyDatabaseTest on WidgetTester { } /// Must call [tapSortMenuInSettingBar] first. - Future tapAllSortButton() async { + Future tapDeleteAllSortsButton() async { await tapButton(find.byType(DeleteAllSortsButton)); } @@ -1077,6 +1248,44 @@ extension AppFlowyDatabaseTest on WidgetTester { 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); @@ -1239,7 +1448,7 @@ extension AppFlowyDatabaseTest on WidgetTester { matching: find.byType(EventCard), ); - await tapButton(cards.at(index)); + await tapButton(cards.at(index), milliseconds: 1000); } void assertEventEditorOpen() => @@ -1255,6 +1464,7 @@ extension AppFlowyDatabaseTest on WidgetTester { ); await enterText(textField, title); + await testTextInput.receiveAction(TextInputAction.done); await pumpAndSettle(const Duration(milliseconds: 300)); } @@ -1278,6 +1488,7 @@ extension AppFlowyDatabaseTest on WidgetTester { ); await tapButton(button); + await tapButtonWithName(LocaleKeys.button_delete.tr()); } Future dragDropRescheduleCalendarEvent() async { @@ -1385,7 +1596,7 @@ extension AppFlowyDatabaseTest on WidgetTester { of: textField, matching: find.byWidgetPredicate( (widget) => - widget is FlowySvg && widget.svg == FlowySvgs.close_filled_m, + widget is FlowySvg && widget.svg == FlowySvgs.close_filled_s, ), ), ); @@ -1463,7 +1674,7 @@ extension AppFlowyDatabaseTest on WidgetTester { void assertCurrentDatabaseTagIs(DatabaseLayoutPB layout) => switch (layout) { DatabaseLayoutPB.Board => - expect(find.byType(BoardPage), findsOneWidget), + expect(find.byType(DesktopBoardPage), findsOneWidget), DatabaseLayoutPB.Calendar => expect(find.byType(CalendarPage), findsOneWidget), DatabaseLayoutPB.Grid => expect(find.byType(GridPage), findsOneWidget), @@ -1521,7 +1732,7 @@ extension AppFlowyDatabaseTest on WidgetTester { } Finder finderForDatabaseLayoutType(DatabaseLayoutPB layout) => switch (layout) { - DatabaseLayoutPB.Board => find.byType(BoardPage), + DatabaseLayoutPB.Board => find.byType(DesktopBoardPage), DatabaseLayoutPB.Calendar => find.byType(CalendarPage), DatabaseLayoutPB.Grid => find.byType(GridPage), _ => throw Exception('Unknown database layout type: $layout'), @@ -1569,6 +1780,8 @@ Finder finderForFieldType(FieldType fieldType) { 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/document_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart new file mode 100644 index 0000000000..398a3f9657 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart @@ -0,0 +1,439 @@ +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(), + ) + .first, + ); + 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/editor_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart deleted file mode 100644 index 0bdcf06367..0000000000 --- a/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart +++ /dev/null @@ -1,254 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; -import 'package:appflowy/plugins/base/icon/icon_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/header/cover_editor.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart'; -import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; -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 '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; - - /// 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); - await tester.tapAt(tester.getTopRight(textBlocks.at(index))); - 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 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 { - Finder button = - find.text(LocaleKeys.document_plugins_cover_removeIcon.tr()); - if (isInPicker) { - button = find.descendant( - of: find.byType(FlowyIconPicker), - matching: button, - ); - } - - 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('@'); - } - - /// Tap the slash menu item with [name] - /// - /// Must call [showSlashMenu] first. - Future tapSlashMenuItemWithName(String name) async { - final slashMenuItem = find.text(name, findRichText: true); - 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), - ), - ); - }, - ); - } -} diff --git a/frontend/appflowy_flutter/integration_test/shared/emoji.dart b/frontend/appflowy_flutter/integration_test/shared/emoji.dart index d439a9b3f7..cccd00a3f6 100644 --- a/frontend/appflowy_flutter/integration_test/shared/emoji.dart +++ b/frontend/appflowy_flutter/integration_test/shared/emoji.dart @@ -1,7 +1,24 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +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/icon_uploader.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flowy_infra_ui/style_widget/primary_rounded_button.dart'; +import 'package:flowy_svg/flowy_svg.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 'base.dart'; +import 'common_operations.dart'; extension EmojiTestExtension on WidgetTester { Future tapEmoji(String emoji) async { @@ -11,4 +28,117 @@ extension EmojiTestExtension on WidgetTester { ); await tapButton(emojiWidget); } + + Future tapIcon(EmojiIconData icon, {bool enableColor = true}) 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); + await tapButton(iconTab); + final selectedSvg = find.descendant( + of: find.byType(FlowyIconPicker), + matching: find.byWidgetPredicate( + (w) => w is FlowySvg && w.svgString == iconsData.svgString, + ), + ); + + await tapButton(selectedSvg.first); + if (enableColor) { + 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); + } + } + + Future pickImage(EmojiIconData icon) async { + final pickTab = find.byType(PickerTab); + expect(pickTab, findsOneWidget); + await pumpAndSettle(); + + /// switch to custom tab + final iconTab = find.descendant( + of: pickTab, + matching: find.text(PickerTabType.custom.tr), + ); + expect(iconTab, findsOneWidget); + await tapButton(iconTab); + + /// mock for dragging image + final dropTarget = find.descendant( + of: find.byType(IconUploader), + matching: find.byType(DropTarget), + ); + expect(dropTarget, findsOneWidget); + final dropTargetWidget = dropTarget.evaluate().first.widget as DropTarget; + dropTargetWidget.onDragDone?.call( + DropDoneDetails( + files: [DropItemFile(icon.emoji)], + localPosition: Offset.zero, + globalPosition: Offset.zero, + ), + ); + await pumpAndSettle(const Duration(seconds: 3)); + + /// confirm to upload + final confirmButton = find.descendant( + of: find.byType(IconUploader), + matching: find.byType(PrimaryRoundedButton), + ); + await tapButton(confirmButton); + } + + Future pasteImageLinkAsIcon(String link) async { + final pickTab = find.byType(PickerTab); + expect(pickTab, findsOneWidget); + await pumpAndSettle(); + + /// switch to custom tab + final iconTab = find.descendant( + of: pickTab, + matching: find.text(PickerTabType.custom.tr), + ); + expect(iconTab, findsOneWidget); + await tapButton(iconTab); + + // mock the clipboard + await getIt() + .setData(ClipboardServiceData(plainText: link)); + + // paste the link + await simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await pumpAndSettle(const Duration(seconds: 5)); + + /// confirm to upload + final confirmButton = find.descendant( + of: find.byType(IconUploader), + matching: find.byType(PrimaryRoundedButton), + ); + await tapButton(confirmButton); + } } diff --git a/frontend/appflowy_flutter/integration_test/shared/expectation.dart b/frontend/appflowy_flutter/integration_test/shared/expectation.dart index 5f831f3d28..3b9ef0d75c 100644 --- a/frontend/appflowy_flutter/integration_test/shared/expectation.dart +++ b/frontend/appflowy_flutter/integration_test/shared/expectation.dart @@ -1,10 +1,16 @@ -import 'package:flutter/material.dart'; +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_header_node_widget.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/appflowy_network_image.dart'; +import 'package:appflowy/shared/appflowy_network_svg.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'; @@ -14,7 +20,11 @@ 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:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; import 'util.dart'; @@ -24,9 +34,15 @@ 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 { - final finder = find.byType(HomeStack); - await pumpUntilFound(finder); - expect(finder, findsOneWidget); + 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); @@ -109,7 +125,7 @@ extension Expectation on WidgetTester { return; } final iconWidget = find.byWidgetPredicate( - (widget) => widget is EmojiIconWidget && widget.emoji == emoji, + (widget) => widget is EmojiIconWidget && widget.emoji.emoji == emoji, ); expect(iconWidget, findsOneWidget); } @@ -164,7 +180,7 @@ extension Expectation on WidgetTester { (widget) => widget is SingleInnerViewItem && widget.view.isFavorite && - widget.categoryType == FolderCategoryType.favorite && + widget.spaceType == FolderSpaceType.favorite && widget.view.name == name && widget.view.layout == layout, skipOffstage: false, @@ -174,7 +190,7 @@ extension Expectation on WidgetTester { (widget) => widget is SingleInnerViewItem && widget.view.isFavorite && - widget.categoryType == FolderCategoryType.favorite, + widget.spaceType == FolderSpaceType.favorite, ); Finder findPageName( @@ -183,46 +199,125 @@ extension Expectation on WidgetTester { String? parentName, ViewLayoutPB parentLayout = ViewLayoutPB.Document, }) { - if (parentName == null) { - return find.byWidgetPredicate( - (widget) => - widget is SingleInnerViewItem && - widget.view.name == name && - widget.view.layout == layout, - skipOffstage: false, + 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.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, String emoji) { + void expectViewHasIcon(String name, ViewLayoutPB layout, EmojiIconData data) { final pageName = findPageName( name, layout: layout, ); - final icon = find.descendant( - of: pageName, - matching: find.text(emoji), - ); - expect(icon, findsOneWidget); + 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.svgString, + ), + ); + expect(icon, findsOneWidget); + } else if (type == FlowyIconType.custom) { + final isSvg = data.emoji.endsWith('.svg'); + if (isURL(data.emoji)) { + final image = find.descendant( + of: pageName, + matching: isSvg + ? find.byType(FlowyNetworkSvg) + : find.byType(FlowyNetworkImage), + ); + expect(image, findsOneWidget); + } else { + final image = find.descendant( + of: pageName, + matching: isSvg ? find.byType(SvgPicture) : find.byType(Image), + ); + expect(image, findsOneWidget); + } + } } - void expectViewTitleHasIcon(String name, ViewLayoutPB layout, String emoji) { - final icon = find.descendant( - of: find.byType(ViewTitleBar), - matching: find.text(emoji), - ); - 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.svgString, + ), + ); + expect(icon, findsOneWidget); + } else if (type == FlowyIconType.custom) { + final isSvg = data.emoji.endsWith('.svg'); + if (isURL(data.emoji)) { + final image = find.descendant( + of: find.byType(ViewTitleBar), + matching: isSvg + ? find.byType(FlowyNetworkSvg) + : find.byType(FlowyNetworkImage), + ); + expect(image, findsOneWidget); + } else { + final image = find.descendant( + of: find.byType(ViewTitleBar), + matching: isSvg + ? find.byWidgetPredicate((w) { + if (w is! SvgPicture) return false; + final loader = w.bytesLoader; + if (loader is! SvgFileLoader) return false; + return loader.file.path.endsWith('.svg'); + }) + : find.byType(Image), + ); + expect(image, findsOneWidget); + } + } } void expectSelectedReminder(ReminderOption option) { diff --git a/frontend/appflowy_flutter/integration_test/shared/keyboard.dart b/frontend/appflowy_flutter/integration_test/shared/keyboard.dart index d792b92c66..567e7e548c 100644 --- a/frontend/appflowy_flutter/integration_test/shared/keyboard.dart +++ b/frontend/appflowy_flutter/integration_test/shared/keyboard.dart @@ -5,10 +5,18 @@ 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_openai_repository.dart b/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart deleted file mode 100644 index 78445a2f4e..0000000000 --- a/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart +++ /dev/null @@ -1,81 +0,0 @@ -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(OpenAIError 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/settings.dart b/frontend/appflowy_flutter/integration_test/shared/settings.dart index cd6564b7cb..bfc5efedde 100644 --- a/frontend/appflowy_flutter/integration_test/shared/settings.dart +++ b/frontend/appflowy_flutter/integration_test/shared/settings.dart @@ -1,26 +1,35 @@ 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/sidebar_setting.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.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_appearance/direction_setting.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 '../desktop/board/board_hide_groups_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); - final settingsDialog = find.byType(SettingsDialog); + expect(settingsDialog, findsOneWidget); return; } @@ -30,6 +39,14 @@ extension AppFlowySettings on WidgetTester { 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; @@ -37,10 +54,14 @@ extension AppFlowySettings on WidgetTester { /// Restore the AppFlowy data storage location Future restoreLocation() async { - final button = - find.byTooltip(LocaleKeys.settings_files_recoverLocationTooltips.tr()); + 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; } @@ -57,14 +78,14 @@ extension AppFlowySettings on WidgetTester { Future enterUserName(String name) async { // Enable editing username final editUsernameFinder = find.descendant( - of: find.byType(UserProfileSetting), - matching: find.byFlowySvg(FlowySvgs.edit_s), + of: find.byType(AccountUserProfile), + matching: find.byFlowySvg(FlowySvgs.toolbar_link_edit_m), ); - await tap(editUsernameFinder); + await tap(editUsernameFinder, warnIfMissed: false); await pumpAndSettle(); final userNameFinder = find.descendant( - of: find.byType(UserProfileSetting), + of: find.byType(AccountUserProfile), matching: find.byType(FlowyTextField), ); await enterText(userNameFinder, name); @@ -77,15 +98,40 @@ extension AppFlowySettings on WidgetTester { // go to settings page and toggle enable RTL toolbar items Future toggleEnableRTLToolbarItems() async { await openSettings(); - await openSettingsPage(SettingsPage.appearance); + await openSettingsPage(SettingsPage.workspace); - final switchButton = - find.byKey(EnableRTLToolbarItemsSetting.enableRTLSwitchKey); - expect(switchButton, findsOneWidget); - await tapButton(switchButton); + 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 index 283db5f4c0..5073425cad 100644 --- a/frontend/appflowy_flutter/integration_test/shared/util.dart +++ b/frontend/appflowy_flutter/integration_test/shared/util.dart @@ -1,9 +1,9 @@ +export 'auth_operation.dart'; export 'base.dart'; export 'common_operations.dart'; -export 'settings.dart'; export 'data.dart'; +export 'document_test_operations.dart'; export 'expectation.dart'; -export 'editor_test_operations.dart'; -export 'mock/mock_url_launcher.dart'; export 'ime.dart'; -export 'auth_operation.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 index 5137944364..1b2f22b944 100644 --- a/frontend/appflowy_flutter/integration_test/shared/workspace.dart +++ b/frontend/appflowy_flutter/integration_test/shared/workspace.dart @@ -1,14 +1,14 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/base/icon/icon_picker.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_workspace.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 'base.dart'; +import 'util.dart'; extension AppFlowyWorkspace on WidgetTester { /// Open workspace menu @@ -36,12 +36,23 @@ extension AppFlowyWorkspace on WidgetTester { matching: find.byType(WorkspaceMoreActionList), ); expect(moreButton, findsOneWidget); - await tapButton(moreButton); - await tapButton(find.findTextInFlowyText(LocaleKeys.button_rename.tr())); - final input = find.byType(TextFormField); - expect(input, findsOneWidget); - await enterText(input, name); - await tapButton(find.text(LocaleKeys.button_ok.tr())); + 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 { @@ -51,7 +62,7 @@ extension AppFlowyWorkspace on WidgetTester { ); expect(iconButton, findsOneWidget); await tapButton(iconButton); - final iconPicker = find.byType(FlowyIconPicker); + final iconPicker = find.byType(FlowyIconEmojiPicker); expect(iconPicker, findsOneWidget); await tapButton(find.findTextInFlowyText(icon)); } diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index e62299792d..4b7ed5d639 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - app_links (0.0.1): + - app_links (0.0.2): - Flutter - appflowy_backend (0.0.1): - Flutter @@ -48,8 +48,6 @@ PODS: - fluttertoast (0.0.2): - Flutter - Toast - - image_gallery_saver (2.0.2): - - Flutter - image_picker_ios (0.0.1): - Flutter - integration_test (0.0.1): @@ -58,6 +56,8 @@ PODS: - 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): @@ -66,15 +66,22 @@ PODS: - permission_handler_apple (9.3.0): - Flutter - ReachabilitySwift (5.0.0) + - saver_gallery (0.0.1): + - Flutter - 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): + - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - super_native_extensions (0.0.1): @@ -83,6 +90,9 @@ PODS: - Toast (4.0.0) - url_launcher_ios (0.0.1): - Flutter + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) @@ -93,19 +103,22 @@ DEPENDENCIES: - flowy_infra_ui (from `.symlinks/plugins/flowy_infra_ui/ios`) - Flutter (from `Flutter`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - - image_gallery_saver (from `.symlinks/plugins/image_gallery_saver/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`) + - saver_gallery (from `.symlinks/plugins/saver_gallery/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`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/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/darwin`) SPEC REPOS: trunk: @@ -113,6 +126,7 @@ SPEC REPOS: - DKPhotoGallery - ReachabilitySwift - SDWebImage + - Sentry - SwiftyGif - Toast @@ -133,8 +147,6 @@ EXTERNAL SOURCES: :path: Flutter fluttertoast: :path: ".symlinks/plugins/fluttertoast/ios" - image_gallery_saver: - :path: ".symlinks/plugins/image_gallery_saver/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: @@ -143,52 +155,64 @@ EXTERNAL SOURCES: :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" + saver_gallery: + :path: ".symlinks/plugins/saver_gallery/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" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/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/darwin" SPEC CHECKSUMS: - app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 - appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88 - connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d + app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874 + appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a + connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf + device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 - flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc + file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517 + flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 - image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb - image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425 - integration_test: 13825b8a9334a850581300559b8839134b124670 - irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9 - keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86 - package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c - path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c - permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 + keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05 + open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 + saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490 SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 - share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 - shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 - sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec - super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7 + Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 + sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj index aa53cf9b88..804ad052be 100644 --- a/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj @@ -372,6 +372,7 @@ 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)", @@ -383,6 +384,8 @@ 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; @@ -511,6 +514,7 @@ 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)", @@ -522,6 +526,8 @@ 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; @@ -545,6 +551,7 @@ 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)", @@ -556,6 +563,8 @@ 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; diff --git a/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift b/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift index 70693e4a8c..b636303481 100644 --- a/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift +++ b/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import UIKit import Flutter -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, diff --git a/frontend/appflowy_flutter/ios/Runner/Info.plist b/frontend/appflowy_flutter/ios/Runner/Info.plist index 8c605b9d3a..5d6a52bd2e 100644 --- a/frontend/appflowy_flutter/ios/Runner/Info.plist +++ b/frontend/appflowy_flutter/ios/Runner/Info.plist @@ -2,10 +2,6 @@ - NSCameraUsageDescription - AppFlowy requires access to the camera. - NSPhotoLibraryUsageDescription - AppFlowy requires access to the photo library. CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -20,8 +16,6 @@ en - FLTEnableImpeller - CFBundleName AppFlowy CFBundlePackageType @@ -43,10 +37,24 @@ CFBundleVersion $(FLUTTER_BUILD_NUMBER) + FLTEnableImpeller + LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSPhotoLibraryUsageDescription + AppFlowy needs access to your photos to let you add images to your documents + NSPhotoLibraryAddUsageDescription + AppFlowy needs access to your photos to let you add images to your photo library UIApplicationSupportsIndirectInputEvents + NSCameraUsageDescription + AppFlowy needs access to your camera to let you add images to your documents from + camera UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -54,16 +62,16 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UISupportsDocumentBrowser + UIViewControllerBasedStatusBarAppearance diff --git a/frontend/appflowy_flutter/ios/Runner/Runner.entitlements b/frontend/appflowy_flutter/ios/Runner/Runner.entitlements index 903def2af5..e3bc137465 100644 --- a/frontend/appflowy_flutter/ios/Runner/Runner.entitlements +++ b/frontend/appflowy_flutter/ios/Runner/Runner.entitlements @@ -4,5 +4,16 @@ 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/ai/ai.dart b/frontend/appflowy_flutter/lib/ai/ai.dart new file mode 100644 index 0000000000..9bfeeb4e00 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/ai.dart @@ -0,0 +1,19 @@ +export 'service/ai_entities.dart'; +export 'service/ai_prompt_input_bloc.dart'; +export 'service/appflowy_ai_service.dart'; +export 'service/error.dart'; +export 'service/ai_model_state_notifier.dart'; +export 'service/select_model_bloc.dart'; +export 'widgets/loading_indicator.dart'; +export 'widgets/prompt_input/action_buttons.dart'; +export 'widgets/prompt_input/desktop_prompt_text_field.dart'; +export 'widgets/prompt_input/file_attachment_list.dart'; +export 'widgets/prompt_input/layout_define.dart'; +export 'widgets/prompt_input/mention_page_bottom_sheet.dart'; +export 'widgets/prompt_input/mention_page_menu.dart'; +export 'widgets/prompt_input/mentioned_page_text_span.dart'; +export 'widgets/prompt_input/predefined_format_buttons.dart'; +export 'widgets/prompt_input/select_sources_bottom_sheet.dart'; +export 'widgets/prompt_input/select_sources_menu.dart'; +export 'widgets/prompt_input/select_model_menu.dart'; +export 'widgets/prompt_input/send_button.dart'; diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart b/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart new file mode 100644 index 0000000000..b08fadb7f8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart @@ -0,0 +1,107 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:equatable/equatable.dart'; + +class AIStreamEventPrefix { + static const data = 'data:'; + static const error = 'error:'; + static const metadata = 'metadata:'; + static const start = 'start:'; + static const finish = 'finish:'; + static const comment = 'comment:'; + static const aiResponseLimit = 'AI_RESPONSE_LIMIT'; + static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT'; + static const aiMaxRequired = 'AI_MAX_REQUIRED:'; + static const localAINotReady = 'LOCAL_AI_NOT_READY'; + static const localAIDisabled = 'LOCAL_AI_DISABLED'; +} + +enum AiType { + cloud, + local; + + bool get isCloud => this == cloud; + bool get isLocal => this == local; +} + +class PredefinedFormat extends Equatable { + const PredefinedFormat({ + required this.imageFormat, + required this.textFormat, + }); + + final ImageFormat imageFormat; + final TextFormat? textFormat; + + PredefinedFormatPB toPB() { + return PredefinedFormatPB( + imageFormat: switch (imageFormat) { + ImageFormat.text => ResponseImageFormatPB.TextOnly, + ImageFormat.image => ResponseImageFormatPB.ImageOnly, + ImageFormat.textAndImage => ResponseImageFormatPB.TextAndImage, + }, + textFormat: switch (textFormat) { + TextFormat.paragraph => ResponseTextFormatPB.Paragraph, + TextFormat.bulletList => ResponseTextFormatPB.BulletedList, + TextFormat.numberedList => ResponseTextFormatPB.NumberedList, + TextFormat.table => ResponseTextFormatPB.Table, + _ => null, + }, + ); + } + + @override + List get props => [imageFormat, textFormat]; +} + +enum ImageFormat { + text, + image, + textAndImage; + + bool get hasText => this == text || this == textAndImage; + + FlowySvgData get icon { + return switch (this) { + ImageFormat.text => FlowySvgs.ai_text_s, + ImageFormat.image => FlowySvgs.ai_image_s, + ImageFormat.textAndImage => FlowySvgs.ai_text_image_s, + }; + } + + String get i18n { + return switch (this) { + ImageFormat.text => LocaleKeys.chat_changeFormat_textOnly.tr(), + ImageFormat.image => LocaleKeys.chat_changeFormat_imageOnly.tr(), + ImageFormat.textAndImage => + LocaleKeys.chat_changeFormat_textAndImage.tr(), + }; + } +} + +enum TextFormat { + paragraph, + bulletList, + numberedList, + table; + + FlowySvgData get icon { + return switch (this) { + TextFormat.paragraph => FlowySvgs.ai_paragraph_s, + TextFormat.bulletList => FlowySvgs.ai_list_s, + TextFormat.numberedList => FlowySvgs.ai_number_list_s, + TextFormat.table => FlowySvgs.ai_table_s, + }; + } + + String get i18n { + return switch (this) { + TextFormat.paragraph => LocaleKeys.chat_changeFormat_text.tr(), + TextFormat.bulletList => LocaleKeys.chat_changeFormat_bullet.tr(), + TextFormat.numberedList => LocaleKeys.chat_changeFormat_number.tr(), + TextFormat.table => LocaleKeys.chat_changeFormat_table.tr(), + }; + } +} diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart new file mode 100644 index 0000000000..0bcc41da9b --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart @@ -0,0 +1,181 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.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_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:universal_platform/universal_platform.dart'; + +typedef OnModelStateChangedCallback = void Function(AiType, bool, String); +typedef OnAvailableModelsChangedCallback = void Function( + List, + AIModelPB?, +); + +class AIModelStateNotifier { + AIModelStateNotifier({required this.objectId}) + : _localAIListener = + UniversalPlatform.isDesktop ? LocalAIStateListener() : null, + _aiModelSwitchListener = AIModelSwitchListener(objectId: objectId) { + _startListening(); + _init(); + } + + final String objectId; + final LocalAIStateListener? _localAIListener; + final AIModelSwitchListener _aiModelSwitchListener; + LocalAIPB? _localAIState; + AvailableModelsPB? _availableModels; + + // callbacks + final List _stateChangedCallbacks = []; + final List + _availableModelsChangedCallbacks = []; + + void _startListening() { + if (UniversalPlatform.isDesktop) { + _localAIListener?.start( + stateCallback: (state) async { + _localAIState = state; + _notifyStateChanged(); + + if (state.state == RunningStatePB.Running || + state.state == RunningStatePB.Stopped) { + await _loadAvailableModels(); + _notifyAvailableModelsChanged(); + } + }, + ); + } + + _aiModelSwitchListener.start( + onUpdateSelectedModel: (model) async { + final updatedModels = _availableModels?.deepCopy() + ?..selectedModel = model; + _availableModels = updatedModels; + _notifyAvailableModelsChanged(); + + if (model.isLocal && UniversalPlatform.isDesktop) { + await _loadLocalAiState(); + } + _notifyStateChanged(); + }, + ); + } + + void _init() async { + await Future.wait([_loadLocalAiState(), _loadAvailableModels()]); + _notifyStateChanged(); + _notifyAvailableModelsChanged(); + } + + void addListener({ + OnModelStateChangedCallback? onStateChanged, + OnAvailableModelsChangedCallback? onAvailableModelsChanged, + }) { + if (onStateChanged != null) { + _stateChangedCallbacks.add(onStateChanged); + } + if (onAvailableModelsChanged != null) { + _availableModelsChangedCallbacks.add(onAvailableModelsChanged); + } + } + + void removeListener({ + OnModelStateChangedCallback? onStateChanged, + OnAvailableModelsChangedCallback? onAvailableModelsChanged, + }) { + if (onStateChanged != null) { + _stateChangedCallbacks.remove(onStateChanged); + } + if (onAvailableModelsChanged != null) { + _availableModelsChangedCallbacks.remove(onAvailableModelsChanged); + } + } + + Future dispose() async { + _stateChangedCallbacks.clear(); + _availableModelsChangedCallbacks.clear(); + await _localAIListener?.stop(); + await _aiModelSwitchListener.stop(); + } + + (AiType, String, bool) getState() { + if (UniversalPlatform.isMobile) { + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + + final availableModels = _availableModels; + final localAiState = _localAIState; + + if (availableModels == null) { + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + if (localAiState == null) { + Log.warn("Cannot get local AI state"); + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + + if (!availableModels.selectedModel.isLocal) { + return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true); + } + + final editable = localAiState.state == RunningStatePB.Running; + final hintText = editable + ? LocaleKeys.chat_inputLocalAIMessageHint.tr() + : LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(); + + return (AiType.local, hintText, editable); + } + + (List, AIModelPB?) getAvailableModels() { + final availableModels = _availableModels; + if (availableModels == null) { + return ([], null); + } + return (availableModels.models, availableModels.selectedModel); + } + + void _notifyAvailableModelsChanged() { + final (models, selectedModel) = getAvailableModels(); + for (final callback in _availableModelsChangedCallbacks) { + callback(models, selectedModel); + } + } + + void _notifyStateChanged() { + final (type, hintText, isEditable) = getState(); + for (final callback in _stateChangedCallbacks) { + callback(type, isEditable, hintText); + } + } + + Future _loadAvailableModels() { + final payload = AvailableModelsQueryPB(source: objectId); + return AIEventGetAvailableModels(payload).send().fold( + (models) => _availableModels = models, + (err) => Log.error("Failed to get available models: $err"), + ); + } + + Future _loadLocalAiState() { + return AIEventGetLocalAIState().send().fold( + (localAIState) => _localAIState = localAIState, + (error) => Log.error("Failed to get local AI state: $error"), + ); + } +} + +extension AiModelExtension on AIModelPB { + bool get isDefault { + return name == "Auto"; + } + + String get i18n { + return isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name; + } +} diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart new file mode 100644 index 0000000000..95854ab047 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:appflowy/ai/service/ai_model_state_notifier.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'ai_entities.dart'; + +part 'ai_prompt_input_bloc.freezed.dart'; + +class AIPromptInputBloc extends Bloc { + AIPromptInputBloc({ + required String objectId, + required PredefinedFormat? predefinedFormat, + }) : aiModelStateNotifier = AIModelStateNotifier(objectId: objectId), + super(AIPromptInputState.initial(predefinedFormat)) { + _dispatch(); + _startListening(); + _init(); + } + + final AIModelStateNotifier aiModelStateNotifier; + + @override + Future close() async { + await aiModelStateNotifier.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) { + event.when( + updateAIState: (aiType, editable, hintText) { + emit( + state.copyWith( + aiType: aiType, + editable: editable, + hintText: hintText, + ), + ); + }, + toggleShowPredefinedFormat: () { + final showPredefinedFormats = !state.showPredefinedFormats; + final predefinedFormat = + showPredefinedFormats && state.predefinedFormat == null + ? PredefinedFormat( + imageFormat: ImageFormat.text, + textFormat: TextFormat.paragraph, + ) + : null; + emit( + state.copyWith( + showPredefinedFormats: showPredefinedFormats, + predefinedFormat: predefinedFormat, + ), + ); + }, + updatePredefinedFormat: (format) { + if (!state.showPredefinedFormats) { + return; + } + emit(state.copyWith(predefinedFormat: format)); + }, + 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() { + aiModelStateNotifier.addListener( + onStateChanged: (aiType, editable, hintText) { + add(AIPromptInputEvent.updateAIState(aiType, editable, hintText)); + }, + ); + } + + void _init() { + final (aiType, hintText, isEditable) = aiModelStateNotifier.getState(); + add(AIPromptInputEvent.updateAIState(aiType, isEditable, hintText)); + } + + 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.updateAIState( + AiType aiType, + bool editable, + String hintText, + ) = _UpdateAIState; + + const factory AIPromptInputEvent.toggleShowPredefinedFormat() = + _ToggleShowPredefinedFormat; + const factory AIPromptInputEvent.updatePredefinedFormat( + PredefinedFormat format, + ) = _UpdatePredefinedFormat; + 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 bool showPredefinedFormats, + required PredefinedFormat? predefinedFormat, + required List attachedFiles, + required List mentionedPages, + required bool editable, + required String hintText, + }) = _AIPromptInputState; + + factory AIPromptInputState.initial(PredefinedFormat? format) => + AIPromptInputState( + aiType: AiType.cloud, + supportChatWithFile: false, + showPredefinedFormats: format != null, + predefinedFormat: format, + attachedFiles: [], + mentionedPages: [], + editable: true, + hintText: '', + ); +} diff --git a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart new file mode 100644 index 0000000000..39487652f8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart @@ -0,0 +1,204 @@ +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/ai/operations/ai_writer_entities.dart'; +import 'package:appflowy/shared/list_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_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fixnum/fixnum.dart' as fixnum; + +import 'ai_entities.dart'; +import 'error.dart'; + +enum LocalAIStreamingState { + notReady, + disabled, +} + +abstract class AIRepository { + Future streamCompletion({ + String? objectId, + required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, + required Future Function() onEnd, + required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, + }); +} + +class AppFlowyAIService implements AIRepository { + @override + Future<(String, CompletionStream)?> streamCompletion({ + String? objectId, + required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, + required Future Function() onEnd, + required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, + }) async { + final stream = AppFlowyCompletionStream( + onStart: onStart, + processMessage: processMessage, + processAssistMessage: processAssistMessage, + processError: onError, + onLocalAIStreamingStateChange: onLocalAIStreamingStateChange, + onEnd: onEnd, + ); + + final records = history.map((record) => record.toPB()).toList(); + + final payload = CompleteTextPB( + text: text, + completionType: completionType, + format: format?.toPB(), + streamPort: fixnum.Int64(stream.nativePort), + objectId: objectId ?? '', + ragIds: [ + if (objectId != null) objectId, + ...sourceIds, + ].unique(), + history: records, + ); + + return AIEventCompleteText(payload).send().fold( + (task) => (task.taskId, stream), + (error) { + Log.error(error); + return null; + }, + ); + } +} + +abstract class CompletionStream { + CompletionStream({ + required this.onStart, + required this.processMessage, + required this.processAssistMessage, + required this.processError, + required this.onLocalAIStreamingStateChange, + required this.onEnd, + }); + + final Future Function() onStart; + final Future Function(String text) processMessage; + final Future Function(String text) processAssistMessage; + final void Function(AIError error) processError; + final void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange; + final Future Function() onEnd; +} + +class AppFlowyCompletionStream extends CompletionStream { + AppFlowyCompletionStream({ + required super.onStart, + required super.processMessage, + required super.processAssistMessage, + required super.processError, + required super.onEnd, + required super.onLocalAIStreamingStateChange, + }) { + _startListening(); + } + + final RawReceivePort _port = RawReceivePort(); + final StreamController _controller = StreamController.broadcast(); + late StreamSubscription _subscription; + int get nativePort => _port.sendPort.nativePort; + + void _startListening() { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + (event) async { + await _handleEvent(event); + }, + ); + } + + Future dispose() async { + await _controller.close(); + await _subscription.cancel(); + _port.close(); + } + + Future _handleEvent(String event) async { + // Check simple matches first + if (event == AIStreamEventPrefix.aiResponseLimit) { + processError( + AIError( + message: LocaleKeys.ai_textLimitReachedDescription.tr(), + code: AIErrorCode.aiResponseLimitExceeded, + ), + ); + return; + } + + if (event == AIStreamEventPrefix.aiImageResponseLimit) { + processError( + AIError( + message: LocaleKeys.ai_imageLimitReachedDescription.tr(), + code: AIErrorCode.aiImageResponseLimitExceeded, + ), + ); + return; + } + + // Otherwise, parse out prefix:content + if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) { + processError( + AIError( + message: event.substring(AIStreamEventPrefix.aiMaxRequired.length), + code: AIErrorCode.other, + ), + ); + } else if (event.startsWith(AIStreamEventPrefix.start)) { + await onStart(); + } else if (event.startsWith(AIStreamEventPrefix.data)) { + await processMessage( + event.substring(AIStreamEventPrefix.data.length), + ); + } else if (event.startsWith(AIStreamEventPrefix.comment)) { + await processAssistMessage( + event.substring(AIStreamEventPrefix.comment.length), + ); + } else if (event.startsWith(AIStreamEventPrefix.finish)) { + await onEnd(); + } else if (event.startsWith(AIStreamEventPrefix.localAIDisabled)) { + onLocalAIStreamingStateChange( + LocalAIStreamingState.disabled, + ); + } else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) { + onLocalAIStreamingStateChange( + LocalAIStreamingState.notReady, + ); + } else if (event.startsWith(AIStreamEventPrefix.error)) { + processError( + AIError( + message: event.substring(AIStreamEventPrefix.error.length), + code: AIErrorCode.other, + ), + ); + } else { + Log.debug('Unknown AI event: $event'); + } + } +} diff --git a/frontend/appflowy_flutter/lib/ai/service/error.dart b/frontend/appflowy_flutter/lib/ai/service/error.dart new file mode 100644 index 0000000000..0c98e83172 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/error.dart @@ -0,0 +1,28 @@ +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, + required AIErrorCode code, + }) = _AIError; + + factory AIError.fromJson(Map json) => + _$AIErrorFromJson(json); +} + +enum AIErrorCode { + @JsonValue('AIResponseLimitExceeded') + aiResponseLimitExceeded, + @JsonValue('AIImageResponseLimitExceeded') + aiImageResponseLimitExceeded, + @JsonValue('Other') + other, +} + +extension AIErrorExtension on AIError { + bool get isLimitExceeded => code == AIErrorCode.aiResponseLimitExceeded; +} diff --git a/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart new file mode 100644 index 0000000000..7ad52b9ec4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:appflowy/ai/service/ai_model_state_notifier.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbserver.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'select_model_bloc.freezed.dart'; + +class SelectModelBloc extends Bloc { + SelectModelBloc({ + required AIModelStateNotifier aiModelStateNotifier, + }) : _aiModelStateNotifier = aiModelStateNotifier, + super(SelectModelState.initial(aiModelStateNotifier)) { + on( + (event, emit) { + event.when( + selectModel: (model) { + AIEventUpdateSelectedModel( + UpdateSelectedModelPB( + source: _aiModelStateNotifier.objectId, + selectedModel: model, + ), + ).send(); + + emit(state.copyWith(selectedModel: model)); + }, + didLoadModels: (models, selectedModel) { + emit( + SelectModelState( + models: models, + selectedModel: selectedModel, + ), + ); + }, + ); + }, + ); + + _aiModelStateNotifier.addListener( + onAvailableModelsChanged: _onAvailableModelsChanged, + ); + } + + final AIModelStateNotifier _aiModelStateNotifier; + + @override + Future close() async { + _aiModelStateNotifier.removeListener( + onAvailableModelsChanged: _onAvailableModelsChanged, + ); + await super.close(); + } + + void _onAvailableModelsChanged( + List models, + AIModelPB? selectedModel, + ) { + if (!isClosed) { + add(SelectModelEvent.didLoadModels(models, selectedModel)); + } + } +} + +@freezed +class SelectModelEvent with _$SelectModelEvent { + const factory SelectModelEvent.selectModel( + AIModelPB model, + ) = _SelectModel; + + const factory SelectModelEvent.didLoadModels( + List models, + AIModelPB? selectedModel, + ) = _DidLoadModels; +} + +@freezed +class SelectModelState with _$SelectModelState { + const factory SelectModelState({ + required List models, + required AIModelPB? selectedModel, + }) = _SelectModelState; + + factory SelectModelState.initial(AIModelStateNotifier notifier) { + final (models, selectedModel) = notifier.getAvailableModels(); + return SelectModelState( + models: models, + selectedModel: selectedModel, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart b/frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart new file mode 100644 index 0000000000..3a9c96b255 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart @@ -0,0 +1,78 @@ +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 AILoadingIndicator extends StatelessWidget { + const AILoadingIndicator({ + 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 SelectionContainer.disabled( + 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/ai/widgets/prompt_input/action_buttons.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/action_buttons.dart new file mode 100644 index 0000000000..9dd370b39b --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/action_buttons.dart @@ -0,0 +1,67 @@ +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'; + +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, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart new file mode 100644 index 0000000000..a2676f2c15 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart @@ -0,0 +1,702 @@ +import 'package:appflowy/ai/ai.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/ai_chat/presentation/layout_define.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/theme_extension.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'; + +class DesktopPromptInput extends StatefulWidget { + const DesktopPromptInput({ + super.key, + required this.isStreaming, + required this.textController, + required this.onStopStreaming, + required this.onSubmitted, + required this.selectedSourcesNotifier, + required this.onUpdateSelectedSources, + this.hideDecoration = false, + this.hideFormats = false, + this.extraBottomActionButton, + }); + + final bool isStreaming; + final TextEditingController textController; + final void Function() onStopStreaming; + final void Function(String, PredefinedFormat?, Map) + onSubmitted; + final ValueNotifier> selectedSourcesNotifier; + final void Function(List) onUpdateSelectedSources; + final bool hideDecoration; + final bool hideFormats; + final Widget? extraBottomActionButton; + + @override + State createState() => _DesktopPromptInputState(); +} + +class _DesktopPromptInputState extends State { + final textFieldKey = GlobalKey(); + final layerLink = LayerLink(); + final overlayController = OverlayPortalController(); + final inputControlCubit = ChatInputControlCubit(); + final focusNode = FocusNode(); + + late SendButtonState sendButtonState; + bool isComposing = false; + + @override + void initState() { + super.initState(); + + widget.textController.addListener(handleTextControllerChanged); + focusNode + ..addListener( + () { + if (!widget.hideDecoration) { + setState(() {}); // refresh border color + } + if (!focusNode.hasFocus) { + cancelMentionPage(); // hide menu when lost focus + } + }, + ) + ..onKeyEvent = handleKeyEvent; + + updateSendButtonState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + } + + @override + void didUpdateWidget(covariant oldWidget) { + updateSendButtonState(); + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + focusNode.dispose(); + widget.textController.removeListener(handleTextControllerChanged); + 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 PromptInputMentionPageMenu( + anchor: PromptInputAnchor(textFieldKey, layerLink), + textController: widget.textController, + onPageSelected: handlePageSelected, + ); + }, + child: DecoratedBox( + decoration: decoration(context), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + DesktopAIPromptSizes.attachedFilesBarPadding.vertical + + DesktopAIPromptSizes.attachedFilesPreviewHeight, + ), + child: TextFieldTapRegion( + child: PromptInputFile( + onDeleted: (file) => context + .read() + .add(AIPromptInputEvent.removeFile(file)), + ), + ), + ), + const VSpace(4.0), + BlocBuilder( + builder: (context, state) { + return Stack( + children: [ + ConstrainedBox( + constraints: getTextFieldConstraints( + state.showPredefinedFormats && !widget.hideFormats, + ), + child: inputTextField(), + ), + if (state.showPredefinedFormats && !widget.hideFormats) + Positioned.fill( + bottom: null, + child: TextFieldTapRegion( + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: 8.0, + ), + child: ChangeFormatBar( + showImageFormats: state.aiType.isCloud, + predefinedFormat: state.predefinedFormat, + spacing: 4.0, + onSelectPredefinedFormat: (format) => + context.read().add( + AIPromptInputEvent + .updatePredefinedFormat(format), + ), + ), + ), + ), + ), + Positioned.fill( + top: null, + child: TextFieldTapRegion( + child: _PromptBottomActions( + showPredefinedFormatBar: + state.showPredefinedFormats, + showPredefinedFormatButton: !widget.hideFormats, + onTogglePredefinedFormatSection: () => + context.read().add( + AIPromptInputEvent + .toggleShowPredefinedFormat(), + ), + onStartMention: startMentionPageFromButton, + sendButtonState: sendButtonState, + onSendPressed: handleSend, + onStopStreaming: widget.onStopStreaming, + selectedSourcesNotifier: + widget.selectedSourcesNotifier, + onUpdateSelectedSources: + widget.onUpdateSelectedSources, + extraBottomActionButton: + widget.extraBottomActionButton, + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ); + } + + BoxDecoration decoration(BuildContext context) { + if (widget.hideDecoration) { + return BoxDecoration(); + } + return BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: focusNode.hasFocus + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, + width: focusNode.hasFocus ? 1.5 : 1.0, + ), + borderRadius: const BorderRadius.all(Radius.circular(12.0)), + ); + } + + void startMentionPageFromButton() { + if (overlayController.isShowing) { + return; + } + if (!focusNode.hasFocus) { + focusNode.requestFocus(); + } + widget.textController.text += '@'; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + context + .read() + .startSearching(widget.textController.value); + overlayController.show(); + } + }); + } + + void cancelMentionPage() { + if (overlayController.isShowing) { + inputControlCubit.reset(); + overlayController.hide(); + } + } + + void updateSendButtonState() { + if (widget.isStreaming) { + sendButtonState = SendButtonState.streaming; + } else if (widget.textController.text.trim().isEmpty) { + sendButtonState = SendButtonState.disabled; + } else { + sendButtonState = SendButtonState.enabled; + } + } + + void handleSend() { + if (widget.isStreaming) { + return; + } + final trimmedText = inputControlCubit.formatIntputText( + widget.textController.text.trim(), + ); + widget.textController.clear(); + if (trimmedText.isEmpty) { + return; + } + + // get the attached files and mentioned pages + final metadata = context.read().consumeMetadata(); + + final bloc = context.read(); + final showPredefinedFormats = bloc.state.showPredefinedFormats; + final predefinedFormat = bloc.state.predefinedFormat; + + widget.onSubmitted( + trimmedText, + showPredefinedFormats ? predefinedFormat : null, + metadata, + ); + } + + void handleTextControllerChanged() { + setState(() { + // update whether send button is clickable + updateSendButtonState(); + isComposing = !widget.textController.value.composing.isCollapsed; + }); + + if (isComposing) { + return; + } + + // disable mention + return; + + // handle text and selection changes ONLY when mentioning a page + // ignore: dead_code + if (!overlayController.isShowing || + inputControlCubit.filterStartPosition == -1) { + return; + } + + // handle cases where mention a page is cancelled + final textController = widget.textController; + 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); + } + } + + KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { + // if (event.character == '@') { + // WidgetsBinding.instance.addPostFrameCallback((_) { + // inputControlCubit.startSearching(widget.textController.value); + // overlayController.show(); + // }); + // } + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + node.unfocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void handlePageSelected(ViewPB view) { + final newText = widget.textController.text.replaceRange( + inputControlCubit.filterStartPosition, + inputControlCubit.filterEndPosition, + view.id, + ); + widget.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 Shortcuts( + shortcuts: buildShortcuts(), + child: Actions( + actions: buildActions(), + child: CompositedTransformTarget( + link: layerLink, + child: BlocBuilder( + builder: (context, state) { + Widget textField = PromptInputTextField( + key: textFieldKey, + editable: state.editable, + cubit: inputControlCubit, + textController: widget.textController, + textFieldFocusNode: focusNode, + contentPadding: + calculateContentPadding(state.showPredefinedFormats), + hintText: state.hintText, + ); + + if (!state.editable) { + textField = FlowyTooltip( + message: LocaleKeys + .settings_aiPage_keys_localAINotReadyTextFieldPrompt + .tr(), + child: textField, + ); + } + + return textField; + }, + ), + ), + ), + ); + } + + BoxConstraints getTextFieldConstraints(bool showPredefinedFormats) { + double minHeight = DesktopAIPromptSizes.textFieldMinHeight + + DesktopAIPromptSizes.actionBarSendButtonSize + + DesktopAIChatSizes.inputActionBarMargin.vertical; + double maxHeight = 300; + if (showPredefinedFormats) { + minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight; + maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight; + } + return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight); + } + + EdgeInsetsGeometry calculateContentPadding(bool showPredefinedFormats) { + final top = showPredefinedFormats + ? DesktopAIPromptSizes.predefinedFormatButtonHeight + : 0.0; + final bottom = DesktopAIPromptSizes.actionBarSendButtonSize + + DesktopAIChatSizes.inputActionBarMargin.vertical; + + return DesktopAIPromptSizes.textFieldContentPadding + .add(EdgeInsets.only(top: top, bottom: bottom)); + } + + Map buildShortcuts() { + if (isComposing) { + return const {}; + } + + return const { + SingleActivator(LogicalKeyboardKey.arrowUp): _FocusPreviousItemIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): _FocusNextItemIntent(), + SingleActivator(LogicalKeyboardKey.escape): _CancelMentionPageIntent(), + SingleActivator(LogicalKeyboardKey.enter): _SubmitOrMentionPageIntent(), + }; + } + + 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 { + handleSend(); + } + return; + }, + ), + }; + } +} + +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 PromptInputTextField extends StatelessWidget { + const PromptInputTextField({ + super.key, + required this.editable, + required this.cubit, + required this.textController, + required this.textFieldFocusNode, + required this.contentPadding, + this.hintText = "", + }); + + final ChatInputControlCubit cubit; + final TextEditingController textController; + final FocusNode textFieldFocusNode; + final EdgeInsetsGeometry contentPadding; + final bool editable; + final String hintText; + + @override + Widget build(BuildContext context) { + return ExtendedTextField( + controller: textController, + focusNode: textFieldFocusNode, + readOnly: !editable, + enabled: editable, + decoration: InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: contentPadding, + hintText: hintText, + hintStyle: inputHintTextStyle(context), + isCollapsed: true, + isDense: true, + ), + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + minLines: 1, + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium, + specialTextSpanBuilder: PromptInputTextSpanBuilder( + inputControlCubit: cubit, + specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + TextStyle? inputHintTextStyle(BuildContext context) { + return Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).isLightMode + ? const Color(0xFFBDC2C8) + : const Color(0xFF3C3E51), + ); + } +} + +class _PromptBottomActions extends StatelessWidget { + const _PromptBottomActions({ + required this.sendButtonState, + required this.showPredefinedFormatBar, + required this.showPredefinedFormatButton, + required this.onTogglePredefinedFormatSection, + required this.onStartMention, + required this.onSendPressed, + required this.onStopStreaming, + required this.selectedSourcesNotifier, + required this.onUpdateSelectedSources, + this.extraBottomActionButton, + }); + + final bool showPredefinedFormatBar; + final bool showPredefinedFormatButton; + final void Function() onTogglePredefinedFormatSection; + final void Function() onStartMention; + final SendButtonState sendButtonState; + final void Function() onSendPressed; + final void Function() onStopStreaming; + final ValueNotifier> selectedSourcesNotifier; + final void Function(List) onUpdateSelectedSources; + final Widget? extraBottomActionButton; + + @override + Widget build(BuildContext context) { + return Container( + height: DesktopAIPromptSizes.actionBarSendButtonSize, + margin: DesktopAIChatSizes.inputActionBarMargin, + child: BlocBuilder( + builder: (context, state) { + return Row( + children: [ + if (showPredefinedFormatButton) ...[ + _predefinedFormatButton(), + const HSpace( + DesktopAIChatSizes.inputActionBarButtonSpacing, + ), + ], + SelectModelMenu( + aiModelStateNotifier: + context.read().aiModelStateNotifier, + ), + const Spacer(), + if (state.aiType.isCloud) ...[ + _selectSourcesButton(), + const HSpace( + DesktopAIChatSizes.inputActionBarButtonSpacing, + ), + ], + if (extraBottomActionButton != null) ...[ + extraBottomActionButton!, + const HSpace( + DesktopAIChatSizes.inputActionBarButtonSpacing, + ), + ], + // _mentionButton(context), + // const HSpace( + // DesktopAIPromptSizes.actionBarButtonSpacing, + // ), + if (state.supportChatWithFile) ...[ + _attachmentButton(context), + const HSpace( + DesktopAIChatSizes.inputActionBarButtonSpacing, + ), + ], + _sendButton(), + ], + ); + }, + ), + ); + } + + Widget _predefinedFormatButton() { + return PromptInputDesktopToggleFormatButton( + showFormatBar: showPredefinedFormatBar, + onTap: onTogglePredefinedFormatSection, + ); + } + + Widget _selectSourcesButton() { + return PromptInputDesktopSelectSourcesButton( + onUpdateSelectedSources: onUpdateSelectedSources, + selectedSourcesNotifier: selectedSourcesNotifier, + ); + } + + // Widget _mentionButton(BuildContext context) { + // return PromptInputMentionButton( + // iconSize: DesktopAIPromptSizes.actionBarIconSize, + // buttonSize: DesktopAIPromptSizes.actionBarButtonSize, + // onTap: onStartMention, + // ); + // } + + 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( + state: sendButtonState, + onSendPressed: onSendPressed, + onStopStreaming: onStopStreaming, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart new file mode 100644 index 0000000000..cd68205506 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart @@ -0,0 +1,162 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/ai/service/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 PromptInputFile extends StatelessWidget { + const PromptInputFile({ + super.key, + required this.onDeleted, + }); + + 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( + file: files[index], + onDeleted: () => onDeleted(files[index]), + ), + ); + }, + ); + } +} + +class ChatFilePreview extends StatefulWidget { + const ChatFilePreview({ + required this.file, + required this.onDeleted, + super.key, + }); + + 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/ai/widgets/prompt_input/layout_define.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/layout_define.dart new file mode 100644 index 0000000000..e5c7e54522 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/layout_define.dart @@ -0,0 +1,42 @@ +import 'package:flutter/widgets.dart'; + +class DesktopAIPromptSizes { + const DesktopAIPromptSizes._(); + + static const attachedFilesBarPadding = + EdgeInsets.only(left: 8.0, top: 8.0, right: 8.0); + static const attachedFilesPreviewHeight = 48.0; + static const attachedFilesPreviewSpacing = 12.0; + + static const predefinedFormatButtonHeight = 28.0; + static const predefinedFormatIconHeight = 16.0; + + static const textFieldMinHeight = 36.0; + static const textFieldContentPadding = + EdgeInsetsDirectional.fromSTEB(14.0, 8.0, 14.0, 8.0); + + static const actionBarButtonSize = 28.0; + static const actionBarIconSize = 16.0; + static const actionBarSendButtonSize = 32.0; + static const actionBarSendButtonIconSize = 24.0; +} + +class MobileAIPromptSizes { + const MobileAIPromptSizes._(); + + 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 predefinedFormatButtonHeight = 32.0; + static const predefinedFormatIconHeight = 20.0; + + static const textFieldMinHeight = 32.0; + static const textFieldContentPadding = + EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0); + + static const mentionIconSize = 20.0; + static const sendButtonSize = 32.0; +} diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_bottom_sheet.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_bottom_sheet.dart new file mode 100644 index 0000000000..6e17f311f3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/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 '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/ai/widgets/prompt_input/mention_page_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_menu.dart new file mode 100644 index 0000000000..ae2dbe5f26 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_menu.dart @@ -0,0 +1,435 @@ +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 PromptInputAnchor { + PromptInputAnchor(this.anchorKey, this.layerLink); + + final GlobalKey> anchorKey; + final LayerLink layerLink; +} + +class PromptInputMentionPageMenu extends StatefulWidget { + const PromptInputMentionPageMenu({ + super.key, + required this.anchor, + required this.textController, + required this.onPageSelected, + }); + + final PromptInputAnchor anchor; + final TextEditingController textController; + final void Function(ViewPB view) onPageSelected; + + @override + State createState() => + _PromptInputMentionPageMenuState(); +} + +class _PromptInputMentionPageMenuState + extends State { + @override + void initState() { + super.initState(); + Future.delayed(Duration.zero, () { + if (mounted) { + 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: PromptInputMentionPageList( + 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 PromptInputMentionPageList extends StatefulWidget { + const PromptInputMentionPageList({ + super.key, + required this.onPageSelected, + }); + + final void Function(ViewPB view) onPageSelected; + + @override + State createState() => + _PromptInputMentionPageListState(); +} + +class _PromptInputMentionPageListState + 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: RawEmojiIconWidget( + 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/ai/widgets/prompt_input/mentioned_page_text_span.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mentioned_page_text_span.dart new file mode 100644 index 0000000000..7b519226a3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mentioned_page_text_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 PromptInputTextSpanBuilder extends SpecialTextSpanBuilder { + PromptInputTextSpanBuilder({ + 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/ai/widgets/prompt_input/predefined_format_buttons.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart new file mode 100644 index 0000000000..403b978905 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart @@ -0,0 +1,215 @@ +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'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../service/ai_entities.dart'; +import 'layout_define.dart'; + +class PromptInputDesktopToggleFormatButton extends StatelessWidget { + const PromptInputDesktopToggleFormatButton({ + super.key, + required this.showFormatBar, + required this.onTap, + }); + + final bool showFormatBar; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + tooltipText: showFormatBar + ? LocaleKeys.chat_changeFormat_defaultDescription.tr() + : LocaleKeys.chat_changeFormat_blankDescription.tr(), + width: 28.0, + onPressed: onTap, + icon: showFormatBar + ? const FlowySvg( + FlowySvgs.m_aa_text_s, + size: Size.square(16.0), + color: Color(0xFF666D76), + ) + : const FlowySvg( + FlowySvgs.ai_text_image_s, + size: Size(21.0, 16.0), + color: Color(0xFF666D76), + ), + ); + } +} + +class ChangeFormatBar extends StatelessWidget { + const ChangeFormatBar({ + super.key, + required this.predefinedFormat, + required this.spacing, + required this.onSelectPredefinedFormat, + this.showImageFormats = true, + }); + + final PredefinedFormat? predefinedFormat; + final double spacing; + final void Function(PredefinedFormat) onSelectPredefinedFormat; + final bool showImageFormats; + + @override + Widget build(BuildContext context) { + final showTextFormats = predefinedFormat?.imageFormat.hasText ?? true; + return SizedBox( + height: DesktopAIPromptSizes.predefinedFormatButtonHeight, + child: SeparatedRow( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => HSpace(spacing), + children: [ + if (showImageFormats) ...[ + _buildFormatButton(context, ImageFormat.text), + _buildFormatButton(context, ImageFormat.textAndImage), + _buildFormatButton(context, ImageFormat.image), + ], + if (showImageFormats && showTextFormats) _buildDivider(), + if (showTextFormats) ...[ + _buildTextFormatButton(context, TextFormat.paragraph), + _buildTextFormatButton(context, TextFormat.bulletList), + _buildTextFormatButton(context, TextFormat.numberedList), + _buildTextFormatButton(context, TextFormat.table), + ], + ], + ), + ); + } + + Widget _buildFormatButton(BuildContext context, ImageFormat format) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (predefinedFormat != null && + format == predefinedFormat!.imageFormat) { + return; + } + if (format.hasText) { + final textFormat = + predefinedFormat?.textFormat ?? TextFormat.paragraph; + onSelectPredefinedFormat( + PredefinedFormat(imageFormat: format, textFormat: textFormat), + ); + } else { + onSelectPredefinedFormat( + PredefinedFormat(imageFormat: format, textFormat: null), + ); + } + }, + child: FlowyTooltip( + message: format.i18n, + preferBelow: false, + child: SizedBox.square( + dimension: _buttonSize, + child: FlowyHover( + isSelected: () => format == predefinedFormat?.imageFormat, + child: Center( + child: FlowySvg( + format.icon, + size: format == ImageFormat.textAndImage + ? Size(21.0 / 16.0 * _iconSize, _iconSize) + : Size.square(_iconSize), + ), + ), + ), + ), + ), + ); + } + + Widget _buildDivider() { + return VerticalDivider( + indent: 6.0, + endIndent: 6.0, + width: 1.0 + spacing * 2, + ); + } + + Widget _buildTextFormatButton( + BuildContext context, + TextFormat format, + ) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (predefinedFormat != null && + format == predefinedFormat!.textFormat) { + return; + } + onSelectPredefinedFormat( + PredefinedFormat( + imageFormat: predefinedFormat?.imageFormat ?? ImageFormat.text, + textFormat: format, + ), + ); + }, + child: FlowyTooltip( + message: format.i18n, + preferBelow: false, + child: SizedBox.square( + dimension: _buttonSize, + child: FlowyHover( + isSelected: () => format == predefinedFormat?.textFormat, + child: Center( + child: FlowySvg( + format.icon, + size: Size.square(_iconSize), + ), + ), + ), + ), + ), + ); + } + + double get _buttonSize { + return UniversalPlatform.isMobile + ? MobileAIPromptSizes.predefinedFormatButtonHeight + : DesktopAIPromptSizes.predefinedFormatButtonHeight; + } + + double get _iconSize { + return UniversalPlatform.isMobile + ? MobileAIPromptSizes.predefinedFormatIconHeight + : DesktopAIPromptSizes.predefinedFormatIconHeight; + } +} + +class PromptInputMobileToggleFormatButton extends StatelessWidget { + const PromptInputMobileToggleFormatButton({ + super.key, + required this.showFormatBar, + required this.onTap, + }); + + final bool showFormatBar; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 32.0, + child: FlowyButton( + radius: const BorderRadius.all(Radius.circular(8.0)), + margin: EdgeInsets.zero, + expandText: false, + text: showFormatBar + ? const FlowySvg( + FlowySvgs.m_aa_text_s, + size: Size.square(20.0), + ) + : const FlowySvg( + FlowySvgs.ai_text_image_s, + size: Size(26.25, 20.0), + ), + onTap: onTap, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart new file mode 100644 index 0000000000..a611d84310 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart @@ -0,0 +1,264 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/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 SelectModelMenu extends StatefulWidget { + const SelectModelMenu({ + super.key, + required this.aiModelStateNotifier, + }); + + final AIModelStateNotifier aiModelStateNotifier; + + @override + State createState() => _SelectModelMenuState(); +} + +class _SelectModelMenuState extends State { + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SelectModelBloc( + aiModelStateNotifier: widget.aiModelStateNotifier, + ), + child: BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + offset: Offset(-12.0, 0.0), + constraints: BoxConstraints(maxWidth: 250, maxHeight: 600), + direction: PopoverDirection.topWithLeftAligned, + margin: EdgeInsets.zero, + controller: popoverController, + popupBuilder: (popoverContext) { + return SelectModelPopoverContent( + models: state.models, + selectedModel: state.selectedModel, + onSelectModel: (model) { + if (model != state.selectedModel) { + context + .read() + .add(SelectModelEvent.selectModel(model)); + } + popoverController.close(); + }, + ); + }, + child: _CurrentModelButton( + model: state.selectedModel, + onTap: () { + if (state.selectedModel != null) { + popoverController.show(); + } + }, + ), + ); + }, + ), + ); + } +} + +class SelectModelPopoverContent extends StatelessWidget { + const SelectModelPopoverContent({ + super.key, + required this.models, + required this.selectedModel, + this.onSelectModel, + }); + + final List models; + final AIModelPB? selectedModel; + final void Function(AIModelPB)? onSelectModel; + + @override + Widget build(BuildContext context) { + if (models.isEmpty) { + return const SizedBox.shrink(); + } + + // separate models into local and cloud models + final localModels = models.where((model) => model.isLocal).toList(); + final cloudModels = models.where((model) => !model.isLocal).toList(); + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (localModels.isNotEmpty) ...[ + _ModelSectionHeader( + title: LocaleKeys.chat_switchModel_localModel.tr(), + ), + const VSpace(4.0), + ], + ...localModels.map( + (model) => _ModelItem( + model: model, + isSelected: model == selectedModel, + onTap: () => onSelectModel?.call(model), + ), + ), + if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[ + const VSpace(8.0), + _ModelSectionHeader( + title: LocaleKeys.chat_switchModel_cloudModel.tr(), + ), + const VSpace(4.0), + ], + ...cloudModels.map( + (model) => _ModelItem( + model: model, + isSelected: model == selectedModel, + onTap: () => onSelectModel?.call(model), + ), + ), + ], + ), + ); + } +} + +class _ModelSectionHeader extends StatelessWidget { + const _ModelSectionHeader({ + required this.title, + }); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 2), + child: FlowyText( + title, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w500, + ), + ); + } +} + +class _ModelItem extends StatelessWidget { + const _ModelItem({ + required this.model, + required this.isSelected, + required this.onTap, + }); + + final AIModelPB model; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 32), + child: FlowyButton( + onTap: onTap, + margin: EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + text: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + model.i18n, + figmaLineHeight: 20, + overflow: TextOverflow.ellipsis, + ), + if (model.desc.isNotEmpty) + FlowyText( + model.desc, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ], + ), + rightIcon: isSelected + ? FlowySvg( + FlowySvgs.check_s, + size: const Size.square(20), + color: Theme.of(context).colorScheme.primary, + ) + : null, + ), + ); + } +} + +class _CurrentModelButton extends StatelessWidget { + const _CurrentModelButton({ + required this.model, + required this.onTap, + }); + + final AIModelPB? model; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_switchModel_label.tr(), + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: DesktopAIPromptSizes.actionBarButtonSize, + child: AnimatedSize( + duration: const Duration(milliseconds: 50), + curve: Curves.easeInOut, + alignment: AlignmentDirectional.centerStart, + child: FlowyHover( + style: const HoverStyle( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: Padding( + padding: const EdgeInsetsDirectional.all(4.0), + child: Row( + children: [ + Padding( + // TODO: remove this after change icon to 20px + padding: EdgeInsets.all(2), + child: FlowySvg( + FlowySvgs.ai_sparks_s, + color: Theme.of(context).hintColor, + size: Size.square(16), + ), + ), + if (model != null && !model!.isDefault) + Padding( + padding: EdgeInsetsDirectional.only(end: 2.0), + child: FlowyText( + model!.i18n, + fontSize: 12, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(8), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart new file mode 100644 index 0000000000..1f1b2ddf4c --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart @@ -0,0 +1,259 @@ +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_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: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.selectedSourcesNotifier, + required this.onUpdateSelectedSources, + }); + + final ValueNotifier> selectedSourcesNotifier; + final void Function(List) onUpdateSelectedSources; + + @override + State createState() => + _PromptInputMobileSelectSourcesButtonState(); +} + +class _PromptInputMobileSelectSourcesButtonState + extends State { + late final cubit = ChatSettingsCubit(); + + @override + void initState() { + super.initState(); + widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged); + WidgetsBinding.instance.addPostFrameCallback((_) { + onSelectedSourcesChanged(); + }); + } + + @override + void dispose() { + widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged); + 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: BlocBuilder( + builder: (context, state) { + return 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 { + context + .read() + .refreshSources(state.spaces, state.currentSpace); + 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); + } + }, + ); + }, + ), + ); + }, + ); + } + + void onSelectedSourcesChanged() { + cubit + ..updateSelectedSources(widget.selectedSourcesNotifier.value) + ..updateSelectedStatus(); + } +} + +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/ai/widgets/prompt_input/select_sources_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart new file mode 100644 index 0000000000..51357e6a0b --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart @@ -0,0 +1,588 @@ +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_select_sources_cubit.dart'; +import 'package:appflowy/plugins/document/application/document_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/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 'mention_page_menu.dart'; + +class PromptInputDesktopSelectSourcesButton extends StatefulWidget { + const PromptInputDesktopSelectSourcesButton({ + super.key, + required this.selectedSourcesNotifier, + required this.onUpdateSelectedSources, + }); + + final ValueNotifier> selectedSourcesNotifier; + 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(); + widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged); + WidgetsBinding.instance.addPostFrameCallback((_) { + onSelectedSourcesChanged(); + }); + } + + @override + void dispose() { + widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged); + 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: BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(320, 380)), + offset: const Offset(0.0, -10.0), + direction: PopoverDirection.topWithCenterAligned, + margin: EdgeInsets.zero, + controller: popoverController, + onOpen: () { + context + .read() + .refreshSources(state.spaces, state.currentSpace); + }, + onClose: () { + widget.onUpdateSelectedSources(cubit.selectedSourceIds); + context + .read() + .refreshSources(state.spaces, state.currentSpace); + }, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: const _PopoverContent(), + ); + }, + child: _IndicatorButton( + selectedSourcesNotifier: widget.selectedSourcesNotifier, + onTap: () => popoverController.show(), + ), + ); + }, + ), + ); + } + + void onSelectedSourcesChanged() { + cubit + ..updateSelectedSources(widget.selectedSourcesNotifier.value) + ..updateSelectedStatus(); + } +} + +class _IndicatorButton extends StatelessWidget { + const _IndicatorButton({ + required this.selectedSourcesNotifier, + required this.onTap, + }); + + final ValueNotifier> selectedSourcesNotifier; + 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).hintColor, + ), + const HSpace(2.0), + ValueListenableBuilder( + valueListenable: selectedSourcesNotifier, + builder: (context, selectedSourceIds, _) { + final documentId = + context.read()?.documentId; + final label = documentId != null && + selectedSourceIds.length == 1 && + selectedSourceIds[0] == documentId + ? LocaleKeys.chat_currentPage.tr() + : selectedSourceIds.length.toString(); + return FlowyText( + label, + fontSize: 12, + 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(8), + ), + ], + ), + ), + ), + ), + ); + } +} + +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, + this.onAdd, + required this.height, + this.showSaveButton = false, + 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 void Function(ChatSource chatSource)? onAdd; + + final bool showSaveButton; + + 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, + showSaveButton: widget.showSaveButton, + onSelected: widget.onSelected, + onAdd: widget.onAdd, + ), + ); + + 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, + showSaveButton: widget.showSaveButton, + onAdd: widget.onAdd, + ), + ), + ], + ); + }, + ); + } +} + +class ChatSourceTreeItemInner extends StatelessWidget { + const ChatSourceTreeItemInner({ + super.key, + required this.chatSource, + required this.level, + required this.isDescendentOfSpace, + required this.isSelectedSection, + required this.showCheckbox, + required this.showSaveButton, + this.onSelected, + this.onAdd, + }); + + final ChatSource chatSource; + final int level; + final bool isDescendentOfSpace; + final bool isSelectedSection; + final bool showCheckbox; + final bool showSaveButton; + final void Function(ChatSource)? onSelected; + final void Function(ChatSource)? onAdd; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + if (!isSelectedSection) { + onSelected?.call(chatSource); + } + }, + child: FlowyHover( + cursor: isSelectedSection ? SystemMouseCursors.basic : null, + style: HoverStyle( + hoverColor: isSelectedSection + ? Colors.transparent + : AFThemeExtension.of(context).lightGreyHover, + ), + builder: (context, onHover) { + final isSaveButtonVisible = + showSaveButton && !chatSource.view.isSpace; + final isAddButtonVisible = onAdd != null; + return 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, + ), + ), + if (onHover && (isSaveButtonVisible || isAddButtonVisible)) ...[ + const HSpace(4.0), + if (isSaveButtonVisible) + FlowyIconButton( + tooltipText: LocaleKeys.chat_addToPageButton.tr(), + width: 24, + icon: FlowySvg( + FlowySvgs.ai_add_to_page_s, + size: const Size.square(16), + color: Theme.of(context).hintColor, + ), + onPressed: () => onSelected?.call(chatSource), + ), + if (isSaveButtonVisible && isAddButtonVisible) + const HSpace(4.0), + if (isAddButtonVisible) + FlowyIconButton( + tooltipText: LocaleKeys.chat_addToNewPage.tr(), + width: 24, + icon: FlowySvg( + FlowySvgs.add_less_padding_s, + size: const Size.square(16), + color: Theme.of(context).hintColor, + ), + onPressed: () => onAdd?.call(chatSource), + ), + const HSpace(4.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/ai/widgets/prompt_input/send_button.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/send_button.dart new file mode 100644 index 0000000000..cca6e65f63 --- /dev/null +++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/send_button.dart @@ -0,0 +1,89 @@ +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:universal_platform/universal_platform.dart'; + +import 'layout_define.dart'; + +enum SendButtonState { enabled, streaming, disabled } + +class PromptInputSendButton extends StatelessWidget { + const PromptInputSendButton({ + super.key, + required this.state, + required this.onSendPressed, + required this.onStopStreaming, + }); + + final SendButtonState state; + final VoidCallback onSendPressed; + final VoidCallback onStopStreaming; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + width: _buttonSize, + richTooltipText: switch (state) { + SendButtonState.streaming => TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.chat_stopTooltip.tr()} ', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: 'ESC', + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ), + _ => null, + }, + 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, + ); + } + + double get _buttonSize { + return UniversalPlatform.isMobile + ? MobileAIPromptSizes.sendButtonSize + : DesktopAIPromptSizes.actionBarSendButtonSize; + } + + double get _iconSize { + return UniversalPlatform.isMobile + ? MobileAIPromptSizes.sendButtonSize + : DesktopAIPromptSizes.actionBarSendButtonIconSize; + } +} diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index 748254d07d..aefd5e5d36 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -18,6 +18,13 @@ class KVKeys { /// {'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 = @@ -28,6 +35,7 @@ class KVKeys { 'kDocumentAppearanceCursorColor'; static const String kDocumentAppearanceSelectionColor = 'kDocumentAppearanceSelectionColor'; + static const String kDocumentAppearanceWidth = 'kDocumentAppearanceWidth'; /// The key for saving the expanded views /// @@ -41,6 +49,7 @@ class KVKeys { /// {'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. @@ -49,8 +58,8 @@ class KVKeys { static const String kCloudType = 'kCloudType'; static const String kAppflowyCloudBaseURL = 'kAppFlowyCloudBaseURL'; - static const String kSupabaseURL = 'kSupabaseURL'; - static const String kSupabaseAnonKey = 'kSupabaseAnonKey'; + static const String kAppFlowyBaseShareDomain = 'kAppFlowyBaseShareDomain'; + static const String kAppFlowyEnableSyncTrace = 'kAppFlowyEnableSyncTrace'; /// The key for saving the text scale factor. /// @@ -65,9 +74,50 @@ class KVKeys { /// {'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'; + + /// The key for saving compact mode ids for node or databse view + /// + /// The value is a json list of id + static const String compactModeIds = 'compactModeIds'; } diff --git a/frontend/appflowy_flutter/lib/core/frameless_window.dart b/frontend/appflowy_flutter/lib/core/frameless_window.dart index 82101de607..48f0434833 100644 --- a/frontend/appflowy_flutter/lib/core/frameless_window.dart +++ b/frontend/appflowy_flutter/lib/core/frameless_window.dart @@ -1,6 +1,7 @@ -import 'package:flutter/services.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:flutter/material.dart'; -import 'dart:io' show Platform; +import 'package:flutter/services.dart'; +import 'package:universal_platform/universal_platform.dart'; class CocoaWindowChannel { CocoaWindowChannel._(); @@ -26,7 +27,10 @@ class CocoaWindowChannel { } class MoveWindowDetector extends StatefulWidget { - const MoveWindowDetector({super.key, this.child}); + const MoveWindowDetector({ + super.key, + this.child, + }); final Widget? child; @@ -40,15 +44,21 @@ class MoveWindowDetectorState extends State { @override Widget build(BuildContext context) { - if (!Platform.isMacOS) { + // 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 { - await CocoaWindowChannel.instance.zoom(); - }, + onDoubleTap: () async => CocoaWindowChannel.instance.zoom(), onPanStart: (DragStartDetails details) { winX = details.globalPosition.dx; winY = details.globalPosition.dy; diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart index e8c9be51d5..0502e79604 100644 --- a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -1,15 +1,25 @@ +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:universal_platform/universal_platform.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; typedef OnFailureCallback = void Function(Uri uri); -Future afLaunchUrl( +/// 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, @@ -17,21 +27,40 @@ Future afLaunchUrl( String? webOnlyWindowName, bool addingHttpSchemeWhenFailed = false, }) async { - // try to launch the uri directly - bool result; - try { - result = await launcher.launchUrl( + 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, - mode: mode, - webOnlyWindowName: webOnlyWindowName, + context: context, + onFailure: onFailure, ); - } on PlatformException catch (e) { - Log.error('Failed to open uri: $e'); - return false; + } + + // on Linux, add http scheme to the url if it is not present + if (UniversalPlatform.isLinux && !isURL(url, {'require_protocol': true})) { + uri = Uri.parse('https://$url'); + } + + // try to launch the uri directly + bool result = await launcher.canLaunchUrl(uri); + if (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 - final url = uri.toString(); + if (addingHttpSchemeWhenFailed && !result && !isURL(url, {'require_protocol': true})) { @@ -53,9 +82,14 @@ Future afLaunchUrl( 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 { @@ -66,12 +100,55 @@ Future afLaunchUrlString( } // try to launch the uri directly - return afLaunchUrl( + 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( + 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, diff --git a/frontend/appflowy_flutter/lib/core/network_monitor.dart b/frontend/appflowy_flutter/lib/core/network_monitor.dart index 8959db42b9..3d01204921 100644 --- a/frontend/appflowy_flutter/lib/core/network_monitor.dart +++ b/frontend/appflowy_flutter/lib/core/network_monitor.dart @@ -1,9 +1,9 @@ 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:appflowy_backend/log.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:flutter/services.dart'; class NetworkListener { diff --git a/frontend/appflowy_flutter/lib/env/backend_env.dart b/frontend/appflowy_flutter/lib/env/backend_env.dart index fa0bf575a3..eb8a61d037 100644 --- a/frontend/appflowy_flutter/lib/env/backend_env.dart +++ b/frontend/appflowy_flutter/lib/env/backend_env.dart @@ -1,6 +1,8 @@ // ignore_for_file: non_constant_identifier_names +import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:json_annotation/json_annotation.dart'; + part 'backend_env.g.dart'; @JsonSerializable() @@ -13,7 +15,6 @@ class AppFlowyConfiguration { required this.device_id, required this.platform, required this.authenticator_type, - required this.supabase_config, required this.appflowy_cloud_config, required this.envs, }); @@ -28,47 +29,20 @@ class AppFlowyConfiguration { final String device_id; final String platform; final int authenticator_type; - final SupabaseConfiguration supabase_config; final AppFlowyCloudConfiguration appflowy_cloud_config; final Map envs; Map toJson() => _$AppFlowyConfigurationToJson(this); } -@JsonSerializable() -class SupabaseConfiguration { - SupabaseConfiguration({ - required this.url, - required this.anon_key, - }); - - factory SupabaseConfiguration.fromJson(Map json) => - _$SupabaseConfigurationFromJson(json); - - /// Indicates whether the sync feature is enabled. - final String url; - final String anon_key; - - Map toJson() => _$SupabaseConfigurationToJson(this); - - static SupabaseConfiguration defaultConfig() { - return SupabaseConfiguration( - url: '', - anon_key: '', - ); - } - - bool get isValid { - return url.isNotEmpty && anon_key.isNotEmpty; - } -} - @JsonSerializable() class AppFlowyCloudConfiguration { AppFlowyCloudConfiguration({ required this.base_url, required this.ws_base_url, required this.gotrue_url, + required this.enable_sync_trace, + required this.base_web_domain, }); factory AppFlowyCloudConfiguration.fromJson(Map json) => @@ -77,6 +51,14 @@ class AppFlowyCloudConfiguration { final String base_url; final String ws_base_url; final String gotrue_url; + final bool enable_sync_trace; + + /// The base domain is used in + /// + /// - Share URL + /// - Publish URL + /// - Copy Link To Block + final String base_web_domain; Map toJson() => _$AppFlowyCloudConfigurationToJson(this); @@ -85,6 +67,8 @@ class AppFlowyCloudConfiguration { base_url: '', ws_base_url: '', gotrue_url: '', + enable_sync_trace: false, + base_web_domain: ShareConstants.defaultBaseWebDomain, ); } diff --git a/frontend/appflowy_flutter/lib/env/cloud_env.dart b/frontend/appflowy_flutter/lib/env/cloud_env.dart index 9e8ea0d4f9..15f3ada42e 100644 --- a/frontend/appflowy_flutter/lib/env/cloud_env.dart +++ b/frontend/appflowy_flutter/lib/env/cloud_env.dart @@ -2,6 +2,7 @@ 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/plugins/shared/share/constants.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; @@ -14,16 +15,17 @@ import 'package:appflowy_backend/log.dart'; /// [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.supabase` is stored as "1". /// - `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.supabase: - await getIt().set(KVKeys.kCloudType, 1.toString()); - break; case AuthenticatorType.appflowyCloud: await getIt().set(KVKeys.kCloudType, 2.toString()); break; @@ -63,8 +65,6 @@ Future getAuthenticatorType() async { switch (value ?? "0") { case "0": return AuthenticatorType.local; - case "1": - return AuthenticatorType.supabase; case "2": return AuthenticatorType.appflowyCloud; case "3": @@ -89,14 +89,10 @@ Future getAuthenticatorType() async { /// 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 or Supabase configuration is valid. +/// AppFlowy Cloud configuration is valid. /// Returns `false` otherwise. bool get isAuthEnabled { final env = getIt(); - if (env.authenticatorType == AuthenticatorType.supabase) { - return env.supabaseConfig.isValid; - } - if (env.authenticatorType.isAppFlowyCloudEnabled) { return env.appflowyCloudConfig.isValid; } @@ -104,17 +100,8 @@ bool get isAuthEnabled { return false; } -/// Checks if Supabase is enabled. -/// -/// This getter evaluates if Supabase should be enabled based on the -/// current integration mode and cloud type setting. -/// -/// Returns: -/// A boolean value indicating whether Supabase is enabled. It returns `true` -/// if the application is in release or develop mode and the current cloud type -/// is `CloudType.supabase`. Otherwise, it returns `false`. -bool get isSupabaseEnabled { - return currentCloudType().isSupabaseEnabled; +bool get isLocalAuthEnabled { + return currentCloudType().isLocal; } /// Determines if AppFlowy Cloud is enabled. @@ -124,7 +111,6 @@ bool get isAppFlowyCloudEnabled { enum AuthenticatorType { local, - supabase, appflowyCloud, appflowyCloudSelfHost, // The 'appflowyCloudDevelop' type is used for develop purposes only. @@ -137,14 +123,10 @@ enum AuthenticatorType { this == AuthenticatorType.appflowyCloudDevelop || this == AuthenticatorType.appflowyCloud; - bool get isSupabaseEnabled => this == AuthenticatorType.supabase; - int get value { switch (this) { case AuthenticatorType.local: return 0; - case AuthenticatorType.supabase: - return 1; case AuthenticatorType.appflowyCloud: return 2; case AuthenticatorType.appflowyCloudSelfHost: @@ -158,8 +140,6 @@ enum AuthenticatorType { switch (value) { case 0: return AuthenticatorType.local; - case 1: - return AuthenticatorType.supabase; case 2: return AuthenticatorType.appflowyCloud; case 3: @@ -180,6 +160,13 @@ Future _setAppFlowyCloudUrl(String? url) async { await getIt().set(KVKeys.kAppflowyCloudBaseURL, url ?? ''); } +Future useBaseWebDomain(String? url) async { + await getIt().set( + KVKeys.kAppFlowyBaseShareDomain, + url ?? ShareConstants.defaultBaseWebDomain, + ); +} + Future useSelfHostedAppFlowyCloudWithURL(String url) async { await _setAuthenticatorType(AuthenticatorType.appflowyCloudSelfHost); await _setAppFlowyCloudUrl(url); @@ -197,25 +184,15 @@ Future useLocalServer() async { await _setAuthenticatorType(AuthenticatorType.local); } -Future useSupabaseCloud({ - required String url, - required String anonKey, -}) async { - await _setAuthenticatorType(AuthenticatorType.supabase); - await setSupabaseServer(url, anonKey); -} - -/// Use getIt() to get the shared environment. +// Use getIt() to get the shared environment. class AppFlowyCloudSharedEnv { AppFlowyCloudSharedEnv({ required AuthenticatorType authenticatorType, required this.appflowyCloudConfig, - required this.supabaseConfig, }) : _authenticatorType = authenticatorType; final AuthenticatorType _authenticatorType; final AppFlowyCloudConfiguration appflowyCloudConfig; - final SupabaseConfiguration supabaseConfig; AuthenticatorType get authenticatorType => _authenticatorType; @@ -229,10 +206,6 @@ class AppFlowyCloudSharedEnv { ? await getAppFlowyCloudConfig(authenticatorType) : AppFlowyCloudConfiguration.defaultConfig(); - final supabaseCloudConfig = authenticatorType.isSupabaseEnabled - ? await getSupabaseCloudConfig() - : SupabaseConfiguration.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 @@ -244,7 +217,6 @@ class AppFlowyCloudSharedEnv { return AppFlowyCloudSharedEnv( authenticatorType: authenticatorType, appflowyCloudConfig: appflowyCloudConfig, - supabaseConfig: supabaseCloudConfig, ); } else { // Using the cloud settings from the .env file. @@ -252,12 +224,13 @@ class AppFlowyCloudSharedEnv { base_url: Env.afCloudUrl, ws_base_url: await _getAppFlowyCloudWSUrl(Env.afCloudUrl), gotrue_url: await _getAppFlowyCloudGotrueUrl(Env.afCloudUrl), + enable_sync_trace: false, + base_web_domain: Env.baseWebDomain, ); return AppFlowyCloudSharedEnv( authenticatorType: AuthenticatorType.fromValue(Env.authenticatorType), appflowyCloudConfig: appflowyCloudConfig, - supabaseConfig: SupabaseConfiguration.defaultConfig(), ); } } @@ -265,8 +238,7 @@ class AppFlowyCloudSharedEnv { @override String toString() { return 'authenticator: $_authenticatorType\n' - 'appflowy: ${appflowyCloudConfig.toJson()}\n' - 'supabase: ${supabaseConfig.toJson()})\n'; + 'appflowy: ${appflowyCloudConfig.toJson()}\n'; } } @@ -274,21 +246,29 @@ Future configurationFromUri( Uri baseUri, String baseUrl, AuthenticatorType authenticatorType, + String baseShareDomain, ) 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 you modify following code, please update the corresponding documentation in the appflowy billing. 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, + base_web_domain: ShareConstants.testBaseWebDomain, ); } else { return AppFlowyCloudConfiguration( base_url: baseUrl, ws_base_url: await _getAppFlowyCloudWSUrl(baseUrl), gotrue_url: await _getAppFlowyCloudGotrueUrl(baseUrl), + enable_sync_trace: await getSyncLogEnabled(), + base_web_domain: authenticatorType == AuthenticatorType.appflowyCloud + ? ShareConstants.defaultBaseWebDomain + : baseShareDomain, ); } } @@ -297,10 +277,16 @@ Future getAppFlowyCloudConfig( AuthenticatorType authenticatorType, ) async { final baseURL = await getAppFlowyCloudUrl(); + final baseShareDomain = await getAppFlowyShareDomain(); try { final uri = Uri.parse(baseURL); - return await configurationFromUri(uri, baseURL, authenticatorType); + return await configurationFromUri( + uri, + baseURL, + authenticatorType, + baseShareDomain, + ); } catch (e) { Log.error("Failed to parse AppFlowy Cloud URL: $e"); return AppFlowyCloudConfiguration.defaultConfig(); @@ -313,6 +299,30 @@ Future getAppFlowyCloudUrl() async { return result ?? kAppflowyCloudUrl; } +Future getAppFlowyShareDomain() async { + final result = + await getIt().get(KVKeys.kAppFlowyBaseShareDomain); + return result ?? ShareConstants.defaultBaseWebDomain; +} + +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); @@ -332,44 +342,3 @@ Future _getAppFlowyCloudWSUrl(String baseURL) async { Future _getAppFlowyCloudGotrueUrl(String baseURL) async { return "$baseURL/gotrue"; } - -Future setSupabaseServer( - String? url, - String? anonKey, -) async { - assert( - (url != null && anonKey != null) || (url == null && anonKey == null), - "Either both Supabase URL and anon key must be set, or both should be unset", - ); - - if (url == null) { - await getIt().remove(KVKeys.kSupabaseURL); - } else { - await getIt().set(KVKeys.kSupabaseURL, url); - } - - if (anonKey == null) { - await getIt().remove(KVKeys.kSupabaseAnonKey); - } else { - await getIt().set(KVKeys.kSupabaseAnonKey, anonKey); - } -} - -Future getSupabaseCloudConfig() async { - final url = await _getSupabaseUrl(); - final anonKey = await _getSupabaseAnonKey(); - return SupabaseConfiguration( - url: url, - anon_key: anonKey, - ); -} - -Future _getSupabaseUrl() async { - final result = await getIt().get(KVKeys.kSupabaseURL); - return result ?? ''; -} - -Future _getSupabaseAnonKey() async { - final result = await getIt().get(KVKeys.kSupabaseAnonKey); - return result ?? ''; -} diff --git a/frontend/appflowy_flutter/lib/env/env.dart b/frontend/appflowy_flutter/lib/env/env.dart index a0786e4d5b..18434f9aa6 100644 --- a/frontend/appflowy_flutter/lib/env/env.dart +++ b/frontend/appflowy_flutter/lib/env/env.dart @@ -1,5 +1,6 @@ // lib/env/env.dart import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:envied/envied.dart'; part 'env.g.dart'; @@ -29,4 +30,25 @@ abstract class Env { 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; + + @EnviedField( + obfuscate: false, + varName: 'BASE_WEB_DOMAIN', + defaultValue: ShareConstants.defaultBaseWebDomain, + ) + static const String baseWebDomain = _Env.baseWebDomain; } 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 0000000000..157be012b1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart @@ -0,0 +1,1065 @@ +// 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; + +typedef CompareFunction = bool Function(T? left, T? right); + +// 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, + this.selectOptionCompare, + 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; + + /// Defines the compare function for the menu items. + /// + /// Defaults to null. If this is null, the menu items will be sorted by the label. + final CompareFunction? selectOptionCompare; + + @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) { + if (widget.selectOptionCompare != null) { + return widget.selectOptionCompare!( + entry.value, + widget.initialSelection, + ); + } else { + return 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.withValues(alpha: 0.12). + effectiveStyle = entry.enabled && i == focusedIndex + ? effectiveStyle.copyWith( + backgroundColor: WidgetStatePropertyAll( + focusedBackgroundColor.withValues(alpha: 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 index d55f2f81c5..9117acfd1b 100644 --- a/frontend/appflowy_flutter/lib/main.dart +++ b/frontend/appflowy_flutter/lib/main.dart @@ -1,9 +1,11 @@ -import 'package:flutter/material.dart'; +import 'package:scaled_app/scaled_app.dart'; import 'startup/startup.dart'; Future main() async { - WidgetsFlutterBinding.ensureInitialized(); + 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 0000000000..50426b5761 --- /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 index 600dace29f..aa02495a49 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart @@ -1,28 +1,52 @@ +import 'dart:async'; import 'dart:convert'; -import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; -import 'package:flutter/material.dart'; - +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]) async { - await push( - Uri( - path: view.routeName, - queryParameters: view.queryParameters(arguments), - ).toString(), - ).then((value) { - getIt().latestOpenView = view; - getIt().updateRecentViews([view.id], true); - }); + Future pushView( + ViewPB view, { + Map? arguments, + bool addInRecent = true, + bool showMoreButton = true, + String? fixedTitle, + String? blockId, + List? tabs, + }) 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; + } + } + if (tabs != null) { + queryParameters[MobileDocumentScreen.viewSelectTabs] = tabs.join('-'); + } + + final uri = Uri( + path: view.routeName, + queryParameters: queryParameters, + ).toString(); + await push(uri); } } @@ -37,6 +61,9 @@ extension on ViewPB { return MobileCalendarScreen.routeName; case ViewLayoutPB.Board: return MobileBoardScreen.routeName; + case ViewLayoutPB.Chat: + return MobileChatScreen.routeName; + default: throw UnimplementedError('routeName for $this is not implemented'); } @@ -65,6 +92,11 @@ extension on ViewPB { 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 0000000000..25fee58a64 --- /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 index 52552fce3b..650fbf1d85 100644 --- 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 @@ -6,7 +6,6 @@ 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_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -23,11 +22,16 @@ class DocumentPageStyleBloc await event.when( initial: () async { try { - final layoutObject = - await ViewBackendService.getView(view.id).fold( - (s) => jsonDecode(s.extra), - (f) => {}, - ); + 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, @@ -146,7 +150,7 @@ class DocumentPageStyleBloc ) { double padding = switch (fontLayout) { PageStyleFontLayout.small => 1.0, - PageStyleFontLayout.normal => 2.0, + PageStyleFontLayout.normal => 1.0, PageStyleFontLayout.large => 4.0, }; switch (lineHeightLayout) { @@ -162,6 +166,16 @@ class DocumentPageStyleBloc 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(); @@ -428,4 +442,15 @@ class PageStyleCover { 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 index 410bc68c4e..2d89b3b388 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart @@ -1,7 +1,5 @@ import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.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/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; @@ -9,6 +7,8 @@ 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 { @@ -41,7 +41,7 @@ class RecentViewBloc extends Bloc { add( RecentViewEvent.updateNameOrIcon( view.name, - view.icon.value, + view.icon.toEmojiIconData(), ), ); @@ -57,12 +57,24 @@ class RecentViewBloc extends Bloc { } }, ); + + // 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.value, + icon: view.icon.toEmojiIconData(), coverTypeV2: cover.type, coverValue: cover.value, ), @@ -72,7 +84,7 @@ class RecentViewBloc extends Bloc { emit( state.copyWith( name: view.name, - icon: view.icon.value, + icon: view.icon.toEmojiIconData(), coverTypeV1: coverTypeV1, coverValue: coverValue, ), @@ -101,7 +113,6 @@ class RecentViewBloc extends Bloc { ); } - final _service = DocumentService(); final ViewPB view; final DocumentListener _documentListener; final ViewListener _viewListener; @@ -112,16 +123,6 @@ class RecentViewBloc extends Bloc { // for the version under 0.5.5 Future<(CoverType, String?)> getCoverV1() async { - final result = await _service.getDocument(documentId: view.id); - final document = result.fold((s) => s.toDocument(), (f) => null); - if (document != null) { - final coverType = CoverType.fromString( - document.root.attributes[DocumentHeaderBlockKeys.coverType], - ); - final coverValue = document - .root.attributes[DocumentHeaderBlockKeys.coverDetails] as String?; - return (coverType, coverValue); - } return (CoverType.none, null); } @@ -136,14 +137,17 @@ class RecentViewBloc extends Bloc { @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 + 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, - String icon, + EmojiIconData icon, ) = UpdateNameOrIcon; } @@ -151,12 +155,12 @@ class RecentViewEvent with _$RecentViewEvent { class RecentViewState with _$RecentViewState { const factory RecentViewState({ required String name, - required String icon, + required EmojiIconData icon, @Default(CoverType.none) CoverType coverTypeV1, PageStyleCoverImageType? coverTypeV2, @Default(null) String? coverValue, }) = _RecentViewState; factory RecentViewState.initial() => - const RecentViewState(name: '', icon: ''); + 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 index 7edec07cc1..0527316860 100644 --- 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 @@ -12,21 +12,20 @@ class UserProfileBloc extends Bloc { UserProfileBloc() : super(const _Initial()) { on((event, emit) async { await event.when( - started: () async => _initalize(emit), + started: () async => _initialize(emit), ); }); } - Future _initalize(Emitter emit) async { + Future _initialize(Emitter emit) async { emit(const UserProfileState.loading()); - - final workspaceOrFailure = + final latestOrFailure = await FolderEventGetCurrentWorkspaceSetting().send(); final userOrFailure = await getIt().getUser(); - final workspaceSetting = workspaceOrFailure.fold( - (workspaceSettingPB) => workspaceSettingPB, + final latest = latestOrFailure.fold( + (latestPB) => latestPB, (error) => null, ); @@ -35,13 +34,13 @@ class UserProfileBloc extends Bloc { (error) => null, ); - if (workspaceSetting == null || userProfile == null) { + if (latest == null || userProfile == null) { return emit(const UserProfileState.workspaceFailure()); } emit( UserProfileState.success( - workspaceSettings: workspaceSetting, + workspaceSettings: latest, userProfile: userProfile, ), ); @@ -59,7 +58,7 @@ class UserProfileState with _$UserProfileState { const factory UserProfileState.loading() = _Loading; const factory UserProfileState.workspaceFailure() = _WorkspaceFailure; const factory UserProfileState.success({ - required WorkspaceSettingPB workspaceSettings, + required WorkspaceLatestPB 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 0000000000..d0e973ae64 --- /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 index b6a50d9661..396ecd6bb8 100644 --- 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 @@ -10,7 +10,7 @@ enum FlowyAppBarLeadingType { Widget getWidget(VoidCallback? onTap) { switch (this) { case FlowyAppBarLeadingType.back: - return AppBarBackButton(onTap: onTap); + return AppBarImmersiveBackButton(onTap: onTap); case FlowyAppBarLeadingType.close: return AppBarCloseButton(onTap: onTap); case FlowyAppBarLeadingType.cancel: @@ -37,6 +37,7 @@ class FlowyAppBar extends AppBar { Widget? title, String? titleText, FlowyAppBarLeadingType leadingType = FlowyAppBarLeadingType.back, + double? leadingWidth, Widget? leading, super.centerTitle, VoidCallback? onTapLeading, @@ -52,7 +53,7 @@ class FlowyAppBar extends AppBar { titleSpacing: 0, elevation: 0, leading: leading ?? leadingType.getWidget(onTapLeading), - leadingWidth: leadingType.width, + leadingWidth: leadingWidth ?? leadingType.width, toolbarHeight: 44.0, bottom: showDivider ? const PreferredSize( 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 index b59c1e68cc..72142d446b 100644 --- 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 @@ -26,6 +26,31 @@ class AppBarBackButton extends StatelessWidget { } } +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, 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 index 35b3ece7c5..318b06394a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart @@ -1,31 +1,34 @@ 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/bottom_sheet/bottom_sheet.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/base/emoji/emoji_text.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_notification.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart'; -import 'package:appflowy/plugins/shared/sync_indicator.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/shared/icon_emoji_picker/tab.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/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/workspace/application/view/view_lock_status_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.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/flowy_infra_ui.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 MobileViewPage extends StatefulWidget { const MobileViewPage({ @@ -34,6 +37,10 @@ class MobileViewPage extends StatefulWidget { required this.viewLayout, this.title, this.arguments, + this.fixedTitle, + this.showMoreButton = true, + this.blockId, + this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); /// view id @@ -41,100 +48,57 @@ class MobileViewPage extends StatefulWidget { final ViewLayoutPB viewLayout; final String? title; final Map? arguments; + final bool showMoreButton; + final String? blockId; + final List tabs; + + // only used in row page + final String? fixedTitle; @override State createState() => _MobileViewPageState(); } class _MobileViewPageState extends State { - late final Future> future; - // 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(0.0); - - // only enable immersive mode for document layout - final ValueNotifier _isImmersiveMode = ValueNotifier(false); - ViewListener? viewListener; + final ValueNotifier _appBarOpacity = ValueNotifier(1.0); @override void initState() { super.initState(); - future = ViewBackendService.getView(widget.id); + + getIt().add(const ReminderEvent.started()); } @override void dispose() { _appBarOpacity.dispose(); - _isImmersiveMode.dispose(); - viewListener?.stop(); + + // 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) { - final child = FutureBuilder( - future: future, - builder: (context, state) { - Widget body; - ViewPB? viewPB; - final actions = []; - if (state.connectionState != ConnectionState.done) { - body = const Center( - child: CircularProgressIndicator(), - ); - } else if (!state.hasData) { - body = FlowyMobileStateContainer.error( - emoji: '😔', - title: LocaleKeys.error_weAreSorry.tr(), - description: LocaleKeys.error_loadingViewError.tr(), - errorMsg: state.error.toString(), - ); - } else { - body = state.data!.fold((view) { - viewPB = view; - _updateImmersiveMode(view); - viewListener?.stop(); - viewListener = ViewListener(viewId: view.id) - ..start( - onViewUpdated: _updateImmersiveMode, - ); + 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); - actions.addAll([ - if (FeatureFlag.syncDocument.isOn) ...[ - DocumentCollaborators( - width: 60, - height: 44, - fontSize: 14, - padding: const EdgeInsets.symmetric(vertical: 8), - view: view, - ), - const HSpace(16.0), - view.layout == ViewLayoutPB.Document - ? DocumentSyncIndicator(view: view) - : DatabaseSyncIndicator(view: view), - const HSpace(8.0), - ], - _buildAppBarLayoutButton(view), - _buildAppBarMoreButton(view), - ]); - final plugin = view.plugin(arguments: widget.arguments ?? const {}) - ..init(); - return plugin.widgetBuilder.buildWidget(shrinkWrap: false); - }, (error) { - return FlowyMobileStateContainer.error( - emoji: '😔', - title: LocaleKeys.error_weAreSorry.tr(), - description: LocaleKeys.error_loadingViewError.tr(), - errorMsg: error.toString(), - ); - }); - } + if (view == null) { + return SizedBox.shrink(); + } - if (viewPB != null) { return MultiBlocProvider( providers: [ BlocProvider( @@ -143,105 +107,313 @@ class _MobileViewPageState extends State { ), BlocProvider( create: (_) => - ViewBloc(view: viewPB!)..add(const ViewEvent.initial()), + ViewBloc(view: view)..add(const ViewEvent.initial()), ), BlocProvider.value( - value: getIt() - ..add(const ReminderEvent.started()), + value: getIt(), ), - if (viewPB!.layout == ViewLayoutPB.Document) + BlocProvider( + create: (_) => + ShareBloc(view: view)..add(const ShareEvent.initial()), + ), + if (state.userProfilePB != null) BlocProvider( - create: (_) => DocumentPageStyleBloc(view: viewPB!) - ..add( - const DocumentPageStyleEvent.initial(), - ), + create: (_) => + UserWorkspaceBloc(userProfile: state.userProfilePB!) + ..add(const UserWorkspaceEvent.initial()), + ), + if (view.layout.isDocumentView) + BlocProvider( + create: (_) => DocumentPageStyleBloc(view: view) + ..add(const DocumentPageStyleEvent.initial()), + ), + if (view.layout.isDocumentView || view.layout.isDatabaseView) + BlocProvider( + create: (_) => ViewLockStatusBloc(view: view) + ..add(const ViewLockStatusEvent.initial()), ), ], child: Builder( builder: (context) { final view = context.watch().state.view; - return _buildApp(view, actions, body); + return _buildApp(context, view, body); }, ), ); - } else { - return _buildApp(null, [], body); - } - }, + }, + ), ); - - return child; } - Widget _buildApp(ViewPB? view, List actions, Widget child) { - // only enable immersive mode for document layout - final isImmersive = view?.layout == ViewLayoutPB.Document; - final icon = view?.icon.value; - final title = Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null && icon.isNotEmpty) - EmojiText( - emoji: '$icon ', - fontSize: 22.0, - ), - Expanded( - child: FlowyText.medium( - view?.name ?? widget.title ?? '', - fontSize: 15.0, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - - if (isImmersive) { - return Scaffold( - extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: Size( - double.infinity, - AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight, - ), - child: 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), - leading: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 2.0, vertical: 4.0), - child: AppBarButton( - padding: EdgeInsets.zero, - onTap: (context) => context.pop(), - child: _buildImmersiveAppBarIcon( - FlowySvgs.m_app_bar_back_s, - 30.0, - iconPadding: 6.0, - ), - ), - ), - actions: actions, + 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, ), - ), - ), - body: Builder( - builder: (context) { - _rebuildScrollNotificationObserver(context); - return child; - }, - ), + title: title, + appBarOpacity: _appBarOpacity, + actions: actions, + view: view, + ) + : 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(), ); } - return Scaffold( - appBar: FlowyAppBar( - title: title, - actions: actions, - ), - body: child, + 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, + MobileDocumentScreen.viewSelectTabs: widget.tabs, + }, + ); + }, + (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 isLocked = + context.read()?.state.isLocked ?? false; + 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 && !isLocked) { + actions.addAll([ + MobileViewPageLayoutButton( + view: view, + isImmersiveMode: isImmersiveMode, + appBarOpacity: _appBarOpacity, + tabs: widget.tabs, + ), + ]); + } + + 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 ValueListenableBuilder( + valueListenable: _appBarOpacity, + builder: (_, value, child) { + if (value < 0.99) { + return Padding( + padding: const EdgeInsets.only(left: 6.0), + child: _buildLockStatus(context, view), + ); + } + + final name = + widget.fixedTitle ?? view?.nameOrDefault ?? widget.title ?? ''; + + return Opacity( + opacity: value, + child: Row( + children: [ + if (icon != null && icon.value.isNotEmpty) ...[ + RawEmojiIconWidget( + emoji: icon.toEmojiIconData(), + emojiSize: 15, + ), + const HSpace(4), + ], + Flexible( + child: FlowyText.medium( + name, + fontSize: 15.0, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 18.0, + ), + ), + const HSpace(4.0), + _buildLockStatusIcon(context, view), + ], + ), + ); + }, + ); + } + + Widget _buildLockStatus(BuildContext context, ViewPB? view) { + if (view == null || view.layout == ViewLayoutPB.Chat) { + return const SizedBox.shrink(); + } + + return BlocConsumer( + listenWhen: (previous, current) => + previous.isLoadingLockStatus == current.isLoadingLockStatus && + current.isLoadingLockStatus == false, + listener: (context, state) { + if (state.isLocked) { + showToastNotification( + message: LocaleKeys.lockPage_pageLockedToast.tr(), + ); + + EditorNotification.exitEditing().post(); + } + }, + builder: (context, state) { + if (state.isLocked) { + return LockedPageStatus(); + } else if (!state.isLocked && state.lockCounter > 0) { + return ReLockedPageStatus(); + } + return const SizedBox.shrink(); + }, + ); + } + + Widget _buildLockStatusIcon(BuildContext context, ViewPB? view) { + if (view == null || view.layout == ViewLayoutPB.Chat) { + return const SizedBox.shrink(); + } + + return BlocConsumer( + listenWhen: (previous, current) => + previous.isLoadingLockStatus == current.isLoadingLockStatus && + current.isLoadingLockStatus == false, + listener: (context, state) { + if (state.isLocked) { + showToastNotification( + message: LocaleKeys.lockPage_pageLockedToast.tr(), + ); + } + }, + builder: (context, state) { + if (state.isLocked) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + context.read().add( + const ViewLockStatusEvent.unlock(), + ); + }, + child: Padding( + padding: const EdgeInsets.only( + top: 4.0, + right: 8, + bottom: 4.0, + ), + child: FlowySvg( + FlowySvgs.lock_page_fill_s, + blendMode: null, + ), + ), + ); + } else if (!state.isLocked && state.lockCounter > 0) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + context.read().add( + const ViewLockStatusEvent.lock(), + ); + }, + child: Padding( + padding: const EdgeInsets.only( + top: 4.0, + right: 8, + bottom: 4.0, + ), + child: FlowySvg( + FlowySvgs.unlock_page_s, + color: Color(0xFF8F959E), + blendMode: null, + ), + ), + ); + } + return const SizedBox.shrink(); + }, ); } @@ -251,163 +423,6 @@ class _MobileViewPageState extends State { _scrollNotificationObserver?.addListener(_onScrollNotification); } - Widget _buildAppBarLayoutButton(ViewPB view) { - // 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: Theme.of(context).colorScheme.background, - builder: (_) => BlocProvider.value( - value: context.read(), - child: PageStyleBottomSheet( - view: context.read().state.view, - ), - ), - ); - }, - child: _buildImmersiveAppBarIcon(FlowySvgs.m_layout_s, 30.0), - ); - } - - Widget _buildAppBarMoreButton(ViewPB view) { - return AppBarButton( - padding: const EdgeInsets.only(left: 8, right: 16), - onTap: (context) { - EditorNotification.exitEditing().post(); - - showMobileBottomSheet( - context, - showDragHandle: true, - showDivider: false, - backgroundColor: Theme.of(context).colorScheme.background, - builder: (_) => _buildAppBarMoreBottomSheet(context), - ); - }, - child: _buildImmersiveAppBarIcon(FlowySvgs.m_app_bar_more_s, 30.0), - ); - } - - Widget _buildImmersiveAppBarIcon( - FlowySvgData icon, - double dimension, { - double iconPadding = 5.0, - }) { - assert( - dimension > 0.0 && dimension <= kToolbarHeight, - 'dimension must be greater than 0, and less than or equal to kToolbarHeight', - ); - return UnconstrainedBox( - child: SizedBox.square( - dimension: dimension, - child: ValueListenableBuilder( - valueListenable: _isImmersiveMode, - builder: (context, isImmersiveMode, child) { - return 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; - }, - ); - }, - ), - ), - ); - } - - Widget _buildAppBarMoreBottomSheet(BuildContext context) { - final view = context.read().state.view; - return ViewPageBottomSheet( - view: view, - onAction: (action) { - switch (action) { - case MobileViewBottomSheetBodyAction.duplicate: - context.pop(); - context.read().add(const ViewEvent.duplicate()); - // show toast - break; - case MobileViewBottomSheetBodyAction.share: - // unimplemented - context.pop(); - break; - case MobileViewBottomSheetBodyAction.delete: - // pop to home page - context - ..pop() - ..pop(); - context.read().add(const ViewEvent.delete()); - break; - case MobileViewBottomSheetBodyAction.addToFavorites: - case MobileViewBottomSheetBodyAction.removeFromFavorites: - context.pop(); - context.read().add(FavoriteEvent.toggle(view)); - 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.rename: - // no need to implement, rename is handled by the onRename callback. - throw UnimplementedError(); - } - }, - onRename: (name) { - if (name != view.name) { - context.read().add(ViewEvent.rename(name)); - } - context.pop(); - }, - ); - } - // immersive mode related // auto show or hide the app bar based on the scroll position void _onScrollNotification(ScrollNotification notification) { @@ -418,7 +433,10 @@ class _MobileViewPageState extends State { if (notification is ScrollUpdateNotification && defaultScrollNotificationPredicate(notification)) { final ScrollMetrics metrics = notification.metrics; - final height = MediaQuery.of(context).padding.top; + 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 || @@ -428,16 +446,4 @@ class _MobileViewPageState extends State { } } } - - void _updateImmersiveMode(ViewPB view) { - final cover = view.cover; - if (cover == null || cover.type == PageStyleCoverImageType.none) { - _isImmersiveMode.value = false; - } else if (view.layout != ViewLayoutPB.Document) { - // only support immersive mode for document layout - _isImmersiveMode.value = false; - } else { - _isImmersiveMode.value = true; - } - } } 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 index 04149c8238..e0c3140ea9 100644 --- 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 @@ -1,5 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart' hide WidgetBuilder; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class TypeOptionMenuItemValue { @@ -9,12 +9,14 @@ class TypeOptionMenuItemValue { 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; } @@ -22,7 +24,7 @@ class TypeOptionMenu extends StatelessWidget { const TypeOptionMenu({ super.key, required this.values, - this.width = 94, + this.width = 98, this.iconWidth = 72, this.scaleFactor = 1.0, this.maxAxisSpacing = 18, @@ -39,17 +41,18 @@ class TypeOptionMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return _GridView( + return TypeOptionGridView( crossAxisCount: crossAxisCount, mainAxisSpacing: maxAxisSpacing * scaleFactor, itemWidth: width * scaleFactor, children: values .map( - (value) => _TypeOptionMenuItem( + (value) => TypeOptionMenuItem( value: value, width: width, iconWidth: iconWidth, scaleFactor: scaleFactor, + iconPadding: value.iconPadding, ), ) .toList(), @@ -57,18 +60,21 @@ class TypeOptionMenu extends StatelessWidget { } } -class _TypeOptionMenuItem extends StatelessWidget { - const _TypeOptionMenuItem({ +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; @@ -88,7 +94,8 @@ class _TypeOptionMenuItem extends StatelessWidget { borderRadius: BorderRadius.circular(24 * scaleFactor), ), ), - padding: EdgeInsets.all(21 * scaleFactor), + padding: EdgeInsets.all(21 * scaleFactor) + + (iconPadding ?? EdgeInsets.zero), child: FlowySvg( value.icon, ), @@ -102,6 +109,7 @@ class _TypeOptionMenuItem extends StatelessWidget { value.text, fontSize: 14.0, maxLines: 2, + lineHeight: 1.0, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), @@ -112,8 +120,9 @@ class _TypeOptionMenuItem extends StatelessWidget { } } -class _GridView extends StatelessWidget { - const _GridView({ +class TypeOptionGridView extends StatelessWidget { + const TypeOptionGridView({ + super.key, required this.children, required this.crossAxisCount, required this.mainAxisSpacing, 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 0000000000..a91fbf577b --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart @@ -0,0 +1,254 @@ +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/shared/icon_emoji_picker/tab.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.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, + required this.view, + }); + + final ValueListenable appBarOpacity; + final Widget title; + final List actions; + final ViewPB? view; + @override + final Size preferredSize; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: appBarOpacity, + builder: (_, opacity, __) => FlowyAppBar( + backgroundColor: + AppBarTheme.of(context).backgroundColor?.withValues(alpha: opacity), + showDivider: false, + title: _buildTitle(context, opacity: opacity), + leadingWidth: 44, + leading: Padding( + padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 12.0), + child: _buildAppBarBackButton(context), + ), + actions: actions, + ), + ); + } + + Widget _buildTitle( + BuildContext context, { + required double opacity, + }) { + return title; + } + + 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()), + BlocProvider( + create: (context) => ViewLockStatusBloc(view: view) + ..add( + ViewLockStatusEvent.initial(), + ), + ), + ], + 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, + required this.tabs, + }); + + final ViewPB view; + final List tabs; + 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, + tabs: tabs, + ), + ), + ); + }, + 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.withValues(alpha: 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 0000000000..be134e0a92 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -0,0 +1,351 @@ +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/application/view/view_lock_status_bloc.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, {arguments}) async => + _onAction(context, action, arguments), + onRename: (name) { + _onRename(context, name); + context.pop(); + }, + ), + ), + ); + } + + Future _onAction( + BuildContext context, + MobileViewBottomSheetBodyAction action, + Map? arguments, + ) async { + switch (action) { + case MobileViewBottomSheetBodyAction.duplicate: + _duplicate(context); + break; + case MobileViewBottomSheetBodyAction.delete: + context.read().add(const ViewEvent.delete()); + Navigator.of(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.lockPage: + final isLocked = + arguments?[MobileViewBottomSheetBodyActionArguments.isLockedKey] ?? + false; + await _lockPage(context, isLocked: isLocked); + // context.pop(); + break; + case MobileViewBottomSheetBodyAction.rename: + // no need to implement, rename is handled by the onRename callback. + throw UnimplementedError(); + } + } + + Future _lockPage( + BuildContext context, { + required bool isLocked, + }) async { + if (isLocked) { + context.read().add(const ViewLockStatusEvent.lock()); + } else { + context + .read() + .add(const ViewLockStatusEvent.unlock()); + } + } + + 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( + message: LocaleKeys.button_duplicateSuccessfully.tr(), + ); + } + + void _addFavorite(BuildContext context) { + _toggleFavorite(context); + + showToastNotification( + message: LocaleKeys.button_favoriteSuccessfully.tr(), + ); + } + + void _removeFavorite(BuildContext context) { + _toggleFavorite(context); + + showToastNotification( + 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( + message: LocaleKeys.message_copy_success.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( + message: LocaleKeys.shareAction_copyLinkSuccess.tr(), + ); + } else { + showToastNotification( + 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( + message: LocaleKeys.publish_publishSuccessfully.tr(), + ), + (error) => showToastNotification( + message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', + type: ToastificationType.error, + ), + ); + } else if (state.unpublishResult != null) { + state.unpublishResult!.fold( + (value) => showToastNotification( + message: LocaleKeys.publish_unpublishSuccessfully.tr(), + ), + (error) => showToastNotification( + message: LocaleKeys.publish_unpublishFailed.tr(), + description: error.msg, + type: ToastificationType.error, + ), + ); + } else if (state.updatePathNameResult != null) { + state.updatePathNameResult!.onSuccess( + (value) { + showToastNotification( + message: + LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ); + + context.pop(); + }, + ); + } + } +} 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 index 3e594b47f9..6b54b1fda3 100644 --- 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 @@ -1,4 +1,5 @@ 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'; @@ -19,7 +20,7 @@ class BottomSheetActionWidget extends StatelessWidget { @override Widget build(BuildContext context) { final iconColor = - this.iconColor ?? Theme.of(context).colorScheme.onBackground; + this.iconColor ?? AFThemeExtension.of(context).onBackground; if (svg == null) { return OutlinedButton( 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 index 37a5cb0221..3316b7049b 100644 --- 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 @@ -21,40 +21,59 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { children: [ FlowyOptionTile.text( text: LocaleKeys.document_menuName.tr(), + height: 52.0, leftIcon: const FlowySvg( - FlowySvgs.document_s, + 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.grid_s, + 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.board_s, + 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.date_s, + 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 index 629dfd2675..b8d0699969 100644 --- 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 @@ -33,6 +33,7 @@ class BlockActionBottomSheet extends StatelessWidget { FlowySvgs.arrow_up_s, size: Size.square(20), ), + showTopBorder: false, onTap: () => onAction(BlockActionBottomSheetType.insertAbove), ), FlowyOptionTile.text( @@ -48,7 +49,13 @@ class BlockActionBottomSheet extends StatelessWidget { FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.button_duplicate.tr(), - leftIcon: const FlowySvg(FlowySvgs.m_field_copy_s), + leftIcon: const Padding( + padding: EdgeInsets.all(2), + child: FlowySvg( + FlowySvgs.copy_s, + size: Size.square(16), + ), + ), onTap: () => onAction(BlockActionBottomSheetType.duplicate), ), @@ -58,7 +65,8 @@ class BlockActionBottomSheet extends StatelessWidget { showTopBorder: false, text: LocaleKeys.button_delete.tr(), leftIcon: FlowySvg( - FlowySvgs.m_delete_s, + FlowySvgs.trash_s, + size: const Size.square(18), color: Theme.of(context).colorScheme.error, ), textColor: Theme.of(context).colorScheme.error, 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 index 4d411b7957..8adc2bebec 100644 --- 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 @@ -98,7 +98,7 @@ class BottomSheetBackButton extends StatelessWidget { width: 18, height: 18, child: FlowySvg( - FlowySvgs.m_app_bar_back_s, + FlowySvgs.m_bottom_sheet_back_s, ), ), ), 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 0000000000..11292e3194 --- /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 index d4f49cb9a9..8ac4d9b20e 100644 --- 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 @@ -52,6 +52,7 @@ class _MobileBottomSheetRenameWidgetState height: 42.0, child: FlowyTextField( controller: controller, + textStyle: Theme.of(context).textTheme.bodyMedium, keyboardType: TextInputType.text, onSubmitted: (text) => widget.onRename(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 index c1e2560e48..86021ea938 100644 --- 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 @@ -1,9 +1,17 @@ +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, @@ -14,11 +22,13 @@ 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() => @@ -27,12 +37,14 @@ class MobileViewItemBottomSheet extends StatefulWidget { class _MobileViewItemBottomSheetState extends State { MobileBottomSheetType type = MobileBottomSheetType.view; + final fToast = FToast(); @override void initState() { super.initState(); type = widget.defaultType; + fToast.init(AppGlobals.context); } @override @@ -40,6 +52,7 @@ class _MobileViewItemBottomSheetState extends State { switch (type) { case MobileBottomSheetType.view: return MobileViewItemBottomSheetBody( + actions: widget.actions, isFavorite: widget.view.isFavorite, onAction: (action) { switch (action) { @@ -51,6 +64,9 @@ class _MobileViewItemBottomSheetState extends State { case MobileViewItemBottomSheetBodyAction.duplicate: Navigator.pop(context); context.read().add(const ViewEvent.duplicate()); + showToastNotification( + message: LocaleKeys.button_duplicateSuccessfully.tr(), + ); break; case MobileViewItemBottomSheetBodyAction.share: // unimplemented @@ -59,7 +75,6 @@ class _MobileViewItemBottomSheetState extends State { case MobileViewItemBottomSheetBodyAction.delete: Navigator.pop(context); context.read().add(const ViewEvent.delete()); - break; case MobileViewItemBottomSheetBodyAction.addToFavorites: case MobileViewItemBottomSheetBodyAction.removeFromFavorites: @@ -67,6 +82,16 @@ class _MobileViewItemBottomSheetState extends State { context .read() .add(FavoriteEvent.toggle(widget.view)); + showToastNotification( + message: !widget.view.isFavorite + ? LocaleKeys.button_favoriteSuccessfully.tr() + : LocaleKeys.button_unfavoriteSuccessfully.tr(), + ); + break; + case MobileViewItemBottomSheetBodyAction.removeFromRecent: + _removeFromRecent(context); + break; + case MobileViewItemBottomSheetBodyAction.divider: break; } }, @@ -83,4 +108,45 @@ class _MobileViewItemBottomSheetState extends State { ); } } + + 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( + 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 index 624ae33b9f..0ca60fe40b 100644 --- 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 @@ -1,8 +1,10 @@ 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/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; enum MobileViewItemBottomSheetBodyAction { rename, @@ -11,6 +13,8 @@ enum MobileViewItemBottomSheetBodyAction { delete, addToFavorites, removeFromFavorites, + divider, + removeFromRecent, } class MobileViewItemBottomSheetBody extends StatelessWidget { @@ -18,63 +22,138 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { 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: [ - MobileQuickActionButton( - text: LocaleKeys.button_rename.tr(), - icon: FlowySvgs.m_rename_s, - onTap: () => onAction( - MobileViewItemBottomSheetBodyAction.rename, - ), - ), - _divider(), - MobileQuickActionButton( - text: isFavorite - ? LocaleKeys.button_removeFromFavorites.tr() - : LocaleKeys.button_addToFavorites.tr(), - icon: isFavorite - ? FlowySvgs.m_favorite_selected_lg - : FlowySvgs.m_favorite_unselected_lg, - iconColor: isFavorite ? Colors.yellow : null, - onTap: () => onAction( - isFavorite - ? MobileViewItemBottomSheetBodyAction.removeFromFavorites - : MobileViewItemBottomSheetBodyAction.addToFavorites, - ), - ), - _divider(), - MobileQuickActionButton( - text: LocaleKeys.button_duplicate.tr(), - icon: FlowySvgs.m_duplicate_s, - onTap: () => onAction( - MobileViewItemBottomSheetBodyAction.duplicate, - ), - ), - _divider(), - MobileQuickActionButton( - text: LocaleKeys.button_delete.tr(), - textColor: Theme.of(context).colorScheme.error, - icon: FlowySvgs.m_delete_s, - iconColor: Theme.of(context).colorScheme.error, - onTap: () => onAction( - MobileViewItemBottomSheetBodyAction.delete, - ), - ), - _divider(), - ], + children: + actions.map((action) => _buildActionButton(context, action)).toList(), ); } - Widget _divider() => const Divider( - height: 8.5, - thickness: 0.5, - ); + Widget _buildActionButton( + BuildContext context, + MobileViewItemBottomSheetBodyAction action, + ) { + final isLocked = + context.read()?.state.isLocked ?? false; + 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), + ), + enable: !isLocked, + 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, + ), + enable: !isLocked, + 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 index de9d51311c..47ab37505e 100644 --- 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 @@ -1,26 +1,54 @@ 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/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_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/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; enum MobileViewBottomSheetBodyAction { undo, redo, - share, rename, duplicate, delete, addToFavorites, removeFromFavorites, - helpCenter; + helpCenter, + publish, + unpublish, + copyPublishLink, + visitSite, + copyShareLink, + updatePathName, + lockPage; + + static const disableInLockedView = [ + undo, + redo, + rename, + delete, + ]; +} + +class MobileViewBottomSheetBodyActionArguments { + static const isLockedKey = 'is_locked'; } typedef MobileViewBottomSheetBodyActionCallback = void Function( MobileViewBottomSheetBodyAction action, -); + // for the [MobileViewBottomSheetBodyAction.lockPage] action, + // it will pass the [isLocked] value to the callback. + { + Map? arguments, +}); class ViewPageBottomSheet extends StatefulWidget { const ViewPageBottomSheet({ @@ -47,7 +75,7 @@ class _ViewPageBottomSheetState extends State { case MobileBottomSheetType.view: return MobileViewBottomSheetBody( view: widget.view, - onAction: (action) { + onAction: (action, {arguments}) { switch (action) { case MobileViewBottomSheetBodyAction.rename: setState(() { @@ -55,7 +83,7 @@ class _ViewPageBottomSheetState extends State { }); break; default: - widget.onAction(action); + widget.onAction(action, arguments: arguments); } }, ); @@ -84,12 +112,16 @@ class MobileViewBottomSheetBody extends StatelessWidget { @override Widget build(BuildContext context) { final isFavorite = view.isFavorite; + final isLocked = + context.watch()?.state.isLocked ?? false; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ MobileQuickActionButton( text: LocaleKeys.button_rename.tr(), - icon: FlowySvgs.m_rename_s, + icon: FlowySvgs.view_item_rename_s, + iconSize: const Size.square(18), + enable: !isLocked, onTap: () => onAction( MobileViewBottomSheetBodyAction.rename, ), @@ -99,10 +131,8 @@ class MobileViewBottomSheetBody extends StatelessWidget { text: isFavorite ? LocaleKeys.button_removeFromFavorites.tr() : LocaleKeys.button_addToFavorites.tr(), - icon: isFavorite - ? FlowySvgs.m_favorite_selected_lg - : FlowySvgs.m_favorite_unselected_lg, - iconColor: isFavorite ? Colors.yellow : null, + icon: isFavorite ? FlowySvgs.unfavorite_s : FlowySvgs.favorite_s, + iconSize: const Size.square(18), onTap: () => onAction( isFavorite ? MobileViewBottomSheetBodyAction.removeFromFavorites @@ -110,19 +140,56 @@ class MobileViewBottomSheetBody extends StatelessWidget { ), ), _divider(), + if (view.layout.isDatabaseView || view.layout.isDocumentView) ...[ + MobileQuickActionButton( + text: LocaleKeys.disclosureAction_lockPage.tr(), + icon: FlowySvgs.lock_page_s, + iconSize: const Size.square(18), + rightIconBuilder: (context) => _LockPageRightIconBuilder( + onAction: onAction, + ), + onTap: () { + final isLocked = + context.read()?.state.isLocked ?? false; + onAction( + MobileViewBottomSheetBodyAction.lockPage, + arguments: { + MobileViewBottomSheetBodyActionArguments.isLockedKey: + !isLocked, + }, + ); + }, + ), + _divider(), + ], MobileQuickActionButton( text: LocaleKeys.button_duplicate.tr(), - icon: FlowySvgs.m_duplicate_s, + 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.m_delete_s, + icon: FlowySvgs.trash_s, iconColor: Theme.of(context).colorScheme.error, + iconSize: const Size.square(18), + enable: !isLocked, onTap: () => onAction( MobileViewBottomSheetBodyAction.delete, ), @@ -132,8 +199,91 @@ class MobileViewBottomSheetBody extends StatelessWidget { ); } - Widget _divider() => const Divider( - height: 8.5, - thickness: 0.5, - ); + List _buildPublishActions(BuildContext context) { + final userProfile = context.read().state.userProfilePB; + // the publish feature is only available for AppFlowy Cloud + if (userProfile == null || + userProfile.workspaceAuthType != AuthTypePB.Server) { + 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 MobileQuickActionDivider(); +} + +class _LockPageRightIconBuilder extends StatelessWidget { + const _LockPageRightIconBuilder({ + required this.onAction, + }); + + final MobileViewBottomSheetBodyActionCallback onAction; + + @override + Widget build(BuildContext context) { + final isLocked = + context.watch()?.state.isLocked ?? false; + return SizedBox( + width: 46, + height: 30, + child: FittedBox( + fit: BoxFit.fill, + child: CupertinoSwitch( + value: isLocked, + activeTrackColor: Theme.of(context).colorScheme.primary, + onChanged: (value) { + onAction( + MobileViewBottomSheetBodyAction.lockPage, + arguments: { + MobileViewBottomSheetBodyActionArguments.isLockedKey: value, + }, + ); + }, + ), + ), + ); + } } 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 index f27c5b3b6f..d4b4292443 100644 --- 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 @@ -1,8 +1,18 @@ 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/application/view/view_lock_status_bloc.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'; @@ -11,11 +21,14 @@ enum MobilePaneActionType { delete, addToFavorites, removeFromFavorites, - more; + more, + add; MobileSlideActionButton actionButton( - BuildContext context, - ) { + BuildContext context, { + MobilePageCardType? cardType, + FolderSpaceType? spaceType, + }) { switch (this) { case MobilePaneActionType.delete: return MobileSlideActionButton( @@ -27,45 +40,107 @@ enum MobilePaneActionType { ); case MobilePaneActionType.removeFromFavorites: return MobileSlideActionButton( - backgroundColor: Colors.orange, - svg: FlowySvgs.favorite_s, - onPressed: (context) => context - .read() - .add(FavoriteEvent.toggle(context.read().view)), + backgroundColor: const Color(0xFFFA217F), + svg: FlowySvgs.favorite_section_remove_from_favorite_s, + size: 24.0, + onPressed: (context) { + showToastNotification( + message: LocaleKeys.button_unfavoriteSuccessfully.tr(), + ); + + context + .read() + .add(FavoriteEvent.toggle(context.read().view)); + }, ); case MobilePaneActionType.addToFavorites: return MobileSlideActionButton( - backgroundColor: Colors.orange, - svg: FlowySvgs.m_favorite_unselected_lg, - size: 34.0, - onPressed: (context) => context - .read() - .add(FavoriteEvent.toggle(context.read().view)), + backgroundColor: const Color(0xFF00C8FF), + svg: FlowySvgs.favorite_s, + size: 24.0, + onPressed: (context) { + showToastNotification( + message: LocaleKeys.button_favoriteSuccessfully.tr(), + ); + + context + .read() + .add(FavoriteEvent.toggle(context.read().view)); + }, ); - case MobilePaneActionType.more: + case MobilePaneActionType.add: return MobileSlideActionButton( - backgroundColor: Colors.grey, - svg: FlowySvgs.three_dots_vertical_s, + 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, - backgroundColor: Theme.of(context).colorScheme.background, 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), + BlocProvider( + create: (_) => + ViewLockStatusBloc(view: viewBloc.state.view) + ..add(const ViewLockStatusEvent.initial()), + ), ], child: BlocBuilder( builder: (context, state) { return MobileViewItemBottomSheet( view: viewBloc.state.view, + actions: _buildActions(state.view, cardType: cardType), ); }, ), @@ -76,19 +151,68 @@ enum MobilePaneActionType { ); } } + + 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, -) { + List actions, { + bool needSpace = true, + MobilePageCardType? cardType, + FolderSpaceType? spaceType, + required double spaceRatio, +}) { return ActionPane( motion: const ScrollMotion(), - extentRatio: actions.length / 5, - children: actions - .map( - (action) => action.actionButton(context), - ) - .toList(), + 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 index 718ac5c4e6..a0fa5dc6aa 100644 --- 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 @@ -1,6 +1,6 @@ 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' hide WidgetBuilder; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; extension BottomSheetPaddingExtension on BuildContext { @@ -47,13 +47,17 @@ Future showMobileBottomSheet( 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, + bool enablePadding = true, }) async { assert( showHeader || @@ -70,6 +74,7 @@ Future showMobileBottomSheet( backgroundColor ??= Theme.of(context).brightness == Brightness.light ? const Color(0xFFF7F8FB) : const Color(0xFF23262B); + barrierColor ??= Colors.black.withValues(alpha: 0.3); return showModalBottomSheet( context: context, @@ -109,6 +114,7 @@ Future showMobileBottomSheet( showRemoveButton: showRemoveButton, title: title, onRemove: onRemove, + onDone: onDone, ), ); @@ -140,6 +146,7 @@ Future showMobileBottomSheet( ) ?? Expanded( child: Scrollbar( + controller: scrollController, child: SingleChildScrollView( controller: scrollController, child: child, @@ -150,17 +157,34 @@ Future showMobileBottomSheet( ); }, ); + } 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, - ), - ); + if (enablePadding) { + // add content padding and extra bottom padding + children.add( + Padding( + padding: + padding + EdgeInsets.only(bottom: context.bottomSheetPadding()), + child: child, + ), + ); + } else { + children.add(child); + } // ----- content area ----- if (children.length == 1) { @@ -191,14 +215,23 @@ class BottomSheetHeader extends StatelessWidget { 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 String title; final bool showDoneButton; + final VoidCallback? onRemove; + final VoidCallback? onBack; + final VoidCallback? onClose; + + final void Function(BuildContext context)? onDone; @override Widget build(BuildContext context) { @@ -209,14 +242,18 @@ class BottomSheetHeader extends StatelessWidget { child: Stack( children: [ if (showBackButton) - const Align( + Align( alignment: Alignment.centerLeft, - child: BottomSheetBackButton(), + child: BottomSheetBackButton( + onTap: onBack, + ), ), if (showCloseButton) - const Align( + Align( alignment: Alignment.centerLeft, - child: BottomSheetCloseButton(), + child: BottomSheetCloseButton( + onTap: onClose, + ), ), if (showRemoveButton) Align( @@ -226,17 +263,27 @@ class BottomSheetHeader extends StatelessWidget { ), ), Align( - child: FlowyText( - title, - fontSize: 16.0, - fontWeight: FontWeight.w500, + 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: () => Navigator.pop(context), + 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 index 0ff2a6634a..b29817251a 100644 --- 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 @@ -329,7 +329,7 @@ class CupertinoSheetBottomRouteTransition extends StatelessWidget { (Theme.of(context).brightness == Brightness.dark ? Colors.grey : Colors.black) - .withOpacity(secondaryAnimation.value * 0.1), + .withValues(alpha: 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 0000000000..31fcbdcdfd --- /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 index 89ae2411e1..642a7ebeae 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/board.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/board.dart @@ -1,4 +1,4 @@ export 'mobile_board_screen.dart'; -export 'mobile_board_content.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_content.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_content.dart deleted file mode 100644 index c01f3ca048..0000000000 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_content.dart +++ /dev/null @@ -1,219 +0,0 @@ -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/board/application/board_bloc.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.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/workspace/application/settings/appearance/appearance_cubit.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'; - -class MobileBoardContent extends StatefulWidget { - const MobileBoardContent({ - super.key, - }); - - @override - State createState() => _MobileBoardContentState(); -} - -class _MobileBoardContentState extends State { - late final ScrollController scrollController; - late final AppFlowyBoardScrollController scrollManager; - - @override - void initState() { - super.initState(); - // mobile may not need this - // scroll to bottom when add a new card - scrollManager = AppFlowyBoardScrollController(); - 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 BlocListener( - listenWhen: (previous, current) => - previous.recentAddedRowMeta != current.recentAddedRowMeta, - listener: (context, state) { - context.push( - MobileRowDetailPage.routeName, - extra: { - MobileRowDetailPage.argRowId: state.recentAddedRowMeta!.id, - MobileRowDetailPage.argDatabaseController: - context.read().databaseController, - }, - ); - }, - child: BlocBuilder( - builder: (context, state) { - final showCreateGroupButton = - context.read().groupingFieldType.canCreateNewGroup; - final showHiddenGroups = state.hiddenGroups.isNotEmpty; - return AppFlowyBoard( - boardScrollController: scrollManager, - 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.createBottomRow(columnData.id)), - ), - ); - } - - 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 rowCache = boardBloc.getRowCache(); - - /// Return placeholder widget if the rowCache is null. - if (rowCache == null) return SizedBox.shrink(key: ObjectKey(groupItem)); - final viewId = boardBloc.viewId; - - final cellBuilder = - CardCellBuilder(databaseController: boardBloc.databaseController); - final isEditing = boardBloc.state.isEditingRow && - boardBloc.state.editingRow?.row.id == groupItem.row.id; - - final groupItemId = groupItem.row.id + groupData.group.groupId; - - return Container( - key: ValueKey(groupItemId), - margin: cardMargin, - decoration: _makeBoxDecoration(context), - child: RowCard( - fieldController: boardBloc.fieldController, - rowMeta: rowMeta, - viewId: viewId, - rowCache: rowCache, - groupingFieldId: groupItem.fieldInfo.id, - isEditing: isEditing, - cellBuilder: cellBuilder, - openCard: (context) { - context.push( - MobileRowDetailPage.routeName, - extra: { - MobileRowDetailPage.argRowId: rowMeta.id, - MobileRowDetailPage.argDatabaseController: - context.read().databaseController, - }, - ); - }, - onStartEditing: () => boardBloc - .add(BoardEvent.startEditingRow(groupData.group, groupItem.row)), - onEndEditing: () => - boardBloc.add(BoardEvent.endEditingRow(groupItem.row.id)), - styleConfiguration: RowCardStyleConfiguration( - cellStyleMap: mobileBoardCardCellStyleMap(context), - showAccessory: false, - ), - ), - ); - } - - BoxDecoration _makeBoxDecoration(BuildContext context) { - final themeMode = context.read().state.themeMode; - return BoxDecoration( - color: Theme.of(context).colorScheme.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_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart new file mode 100644 index 0000000000..29841dd22a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart @@ -0,0 +1,317 @@ +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/workspace/application/view/view_lock_status_bloc.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/material.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 isLocked = + context.watch()?.state.isLocked ?? false; + 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 && !isLocked + ? const MobileBoardTrailing() + : const HSpace(16), + headerBuilder: (_, groupData) { + final isLocked = + context.read()?.state.isLocked ?? + false; + return IgnorePointer( + ignoring: isLocked, + 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 isLocked = + context.read()?.state.isLocked ?? false; + final style = Theme.of(context); + + return SizedBox( + height: 42, + width: double.infinity, + child: IgnorePointer( + ignoring: isLocked, + 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; + final isLocked = + context.read()?.state.isLocked ?? false; + + return Container( + key: ValueKey(groupItemId), + margin: cardMargin, + decoration: _makeBoxDecoration(context), + child: BlocProvider.value( + value: boardBloc, + child: IgnorePointer( + ignoring: isLocked, + 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 + .withValues(alpha: 0.5), + ), + ) + : null, + boxShadow: themeMode == ThemeMode.light + ? [ + BoxShadow( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, + ); + } +} 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 index 5180febd8b..8d1e91b708 100644 --- 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 @@ -1,16 +1,13 @@ -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/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.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'; @@ -73,8 +70,12 @@ class _GroupCardHeaderState extends State { ); } - if (state.isEditingHeader && - state.editingHeaderId == widget.groupData.id) { + final isEditing = state.maybeMap( + ready: (value) => value.editingHeaderId == widget.groupData.id, + orElse: () => false, + ); + + if (isEditing) { title = TextField( controller: _controller, autofocus: true, @@ -111,12 +112,8 @@ class _GroupCardHeaderState extends State { context, showDragHandle: true, backgroundColor: Theme.of(context).colorScheme.surface, - builder: (_) => SeparatedColumn( + builder: (_) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, - separatorBuilder: () => const Divider( - height: 8.5, - thickness: 0.5, - ), children: [ MobileQuickActionButton( text: LocaleKeys.board_column_renameColumn.tr(), @@ -130,12 +127,13 @@ class _GroupCardHeaderState extends State { context.pop(); }, ), + const MobileQuickActionDivider(), MobileQuickActionButton( text: LocaleKeys.board_column_hideColumn.tr(), icon: FlowySvgs.hide_s, onTap: () { context.read().add( - BoardEvent.toggleGroupVisibility( + BoardEvent.setGroupVisibility( widget.groupData.customData.group as GroupPB, false, @@ -154,9 +152,16 @@ class _GroupCardHeaderState extends State { color: Theme.of(context).colorScheme.onSurface, ), splashRadius: 5, - onPressed: () => context.read().add( - BoardEvent.createHeaderRow(widget.groupData.id), - ), + onPressed: () { + context.read().add( + BoardEvent.createRow( + widget.groupData.id, + OrderObjectPositionTypePB.Start, + null, + null, + ), + ); + }, ), ], ), 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 index 76deaa6f0a..184bd901c1 100644 --- 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 @@ -60,7 +60,7 @@ class _MobileBoardTrailingState extends State { child: IconButton( icon: Icon( Icons.close, - color: style.colorScheme.onBackground, + color: style.colorScheme.onSurface, ), onPressed: () => setState(() => _textController.clear()), @@ -86,7 +86,7 @@ class _MobileBoardTrailingState extends State { child: Text( LocaleKeys.button_cancel.tr(), style: style.textTheme.titleSmall?.copyWith( - color: style.colorScheme.onBackground, + color: style.colorScheme.onSurface, ), ), onPressed: () => setState(() => isEditing = false), @@ -96,7 +96,7 @@ class _MobileBoardTrailingState extends State { LocaleKeys.button_add.tr(), style: style.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, - color: style.colorScheme.onBackground, + color: style.colorScheme.onSurface, ), ), onPressed: () { @@ -117,14 +117,14 @@ class _MobileBoardTrailingState extends State { ) : ElevatedButton.icon( style: ElevatedButton.styleFrom( - foregroundColor: style.colorScheme.onBackground, + foregroundColor: style.colorScheme.onSurface, backgroundColor: style.colorScheme.secondary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ).copyWith( overlayColor: - MaterialStateProperty.all(Theme.of(context).hoverColor), + WidgetStateProperty.all(Theme.of(context).hoverColor), ), icon: const Icon(Icons.add), label: Text( 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 index 4f873b73b8..f80525786e 100644 --- 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 @@ -1,16 +1,19 @@ +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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -23,7 +26,10 @@ class MobileHiddenGroupsColumn extends StatelessWidget { Widget build(BuildContext context) { final databaseController = context.read().databaseController; return BlocSelector( - selector: (state) => state.layoutSettings, + selector: (state) => state.maybeMap( + orElse: () => null, + ready: (value) => value.layoutSettings, + ), builder: (context, layoutSettings) { if (layoutSettings == null) { return const SizedBox.shrink(); @@ -105,29 +111,36 @@ class MobileHiddenGroupList extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (_, state) => 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)); - }, - ), + 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)); + }, + ); + }, + ); + }, ); } } @@ -148,92 +161,77 @@ class MobileHiddenGroup extends StatelessWidget { final primaryField = databaseController.fieldController.fieldInfos .firstWhereOrNull((element) => element.isPrimary)!; - return BlocBuilder( - builder: (context, state) { - final group = state.hiddenGroups.firstWhereOrNull( - (g) => g.groupId == this.group.groupId, - ); - if (group == null) { - return const SizedBox.shrink(); - } - - 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: Theme.of(context).colorScheme.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, - }, + 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( - context.read().generateGroupNameFromGroup(group), - 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.toggleGroupVisibility( - group, - true, - ), - ), - ), - ), - ], - ), - children: cells, ); }, + ).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, ); } 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 index 5805dc957f..5896c51b9b 100644 --- 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 @@ -3,6 +3,7 @@ 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'; @@ -18,8 +19,13 @@ import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dar 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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -52,7 +58,9 @@ class _MobileRowDetailPageState extends State { late final PageController _pageController; String get viewId => widget.databaseController.viewId; + RowCache get rowCache => widget.databaseController.rowCache; + FieldController get fieldController => widget.databaseController.fieldController; @@ -131,7 +139,7 @@ class _MobileRowDetailPageState extends State { void _showCardActions(BuildContext context) { showMobileBottomSheet( context, - backgroundColor: Theme.of(context).colorScheme.background, + backgroundColor: AFThemeExtension.of(context).background, showDragHandle: true, builder: (_) => Column( mainAxisSize: MainAxisSize.min, @@ -139,18 +147,69 @@ class _MobileRowDetailPageState extends State { MobileQuickActionButton( onTap: () => _performAction(viewId, _bloc.state.currentRowId, false), - icon: FlowySvgs.copy_s, + icon: FlowySvgs.duplicate_s, text: LocaleKeys.button_duplicate.tr(), ), - const Divider(height: 8.5, thickness: 0.5), + const MobileQuickActionDivider(), + 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 MobileQuickActionDivider(), MobileQuickActionButton( onTap: () => _performAction(viewId, _bloc.state.currentRowId, true), text: LocaleKeys.button_delete.tr(), textColor: Theme.of(context).colorScheme.error, - icon: FlowySvgs.m_delete_m, + icon: FlowySvgs.trash_s, iconColor: Theme.of(context).colorScheme.error, ), - const Divider(height: 8.5, thickness: 0.5), ], ), ); @@ -162,7 +221,7 @@ class _MobileRowDetailPageState extends State { } deleteRow - ? RowBackendService.deleteRow(viewId, rowId) + ? RowBackendService.deleteRows(viewId, [rowId]) : RowBackendService.duplicateRow(viewId, rowId); context @@ -175,6 +234,38 @@ class _MobileRowDetailPageState extends State { 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 { @@ -290,9 +381,12 @@ class MobileRowDetailPageContentState 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() { @@ -303,6 +397,8 @@ class MobileRowDetailPageContentState viewId: viewId, rowCache: rowCache, ); + rowController.initialize(); + cellBuilder = EditableCellBuilder( databaseController: widget.databaseController, ); @@ -316,68 +412,129 @@ class MobileRowDetailPageContentState rowController: rowController, ), child: BlocBuilder( - builder: (context, rowDetailState) { - return Column( - children: [ - BlocProvider( - create: (context) => RowBannerBloc( - viewId: viewId, - fieldController: fieldController, - rowMeta: rowController.rowMeta, - )..add(const RowBannerEvent.initial()), - child: BlocBuilder( - 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(), - ), + 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, ), - ); - }, + ], + ), ), - ), - 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, - ), + child: SizedBox( + height: 200, + width: double.infinity, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, ), - Padding( - padding: const EdgeInsets.fromLTRB(6, 6, 16, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (rowDetailState.numHiddenFields != 0) ...[ - const ToggleHiddenFieldsVisibilityButton(), - ], - MobileRowDetailCreateFieldButton( - viewId: viewId, - fieldController: fieldController, - ), - ], - ), + 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, + ), + ], + ), + ), + ], + ), + ), + ], + ), ), ); } @@ -388,6 +545,7 @@ class _TitleSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -400,7 +558,9 @@ class _TitleSkin extends IEditableTextCellSkin { fontSize: 23, fontWeight: FontWeight.w500, ), - onChanged: (text) => bloc.add(TextCellEvent.updateText(text)), + onEditingComplete: () { + bloc.add(TextCellEvent.updateText(textEditingController.text)); + }, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(vertical: 9), border: InputBorder.none, 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 index e62ddeb872..1d3d3efcf5 100644 --- 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 @@ -2,6 +2,7 @@ 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'; @@ -19,21 +20,24 @@ class MobileRowDetailCreateFieldButton extends StatelessWidget { @override Widget build(BuildContext context) { return ConstrainedBox( - constraints: const BoxConstraints(minWidth: double.infinity), + constraints: BoxConstraints( + minWidth: double.infinity, + maxHeight: GridSize.headerHeight, + ), child: TextButton.icon( style: Theme.of(context).textButtonTheme.style?.copyWith( - shape: MaterialStateProperty.all( + shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), ), - overlayColor: MaterialStateProperty.all( + overlayColor: WidgetStateProperty.all( Theme.of(context).hoverColor, ), alignment: AlignmentDirectional.centerStart, splashFactory: NoSplash.splashFactory, - padding: const MaterialStatePropertyAll( - EdgeInsets.symmetric(vertical: 14, horizontal: 6), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 6, vertical: 2), ), ), label: FlowyText.medium( 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 index 0498427547..42bb241ab8 100644 --- 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 @@ -1,10 +1,9 @@ -import 'package:appflowy/generated/flowy_svgs.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/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:appflowy/util/field_type_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -76,10 +75,8 @@ class _PropertyCellState extends State<_PropertyCell> { children: [ Row( children: [ - FlowySvg( - fieldInfo.fieldType.svgData, - color: Theme.of(context).hintColor, - size: const Size.square(16), + FieldIcon( + fieldInfo: fieldInfo, ), const HSpace(6), Expanded( @@ -87,6 +84,7 @@ class _PropertyCellState extends State<_PropertyCell> { fieldInfo.name, overflow: TextOverflow.ellipsis, fontSize: 14, + figmaLineHeight: 16.0, color: Theme.of(context).hintColor, ), ), 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 index fc869a54c7..e2614296af 100644 --- 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 @@ -11,13 +11,17 @@ class OptionTextField extends StatelessWidget { const OptionTextField({ super.key, required this.controller, - required this.type, + this.autoFocus = false, + required this.isPrimary, + required this.fieldType, required this.onTextChanged, required this.onFieldTypeChanged, }); final TextEditingController controller; - final FieldType type; + final bool autoFocus; + final bool isPrimary; + final FieldType fieldType; final void Function(String value) onTextChanged; final void Function(FieldType value) onFieldTypeChanged; @@ -25,10 +29,14 @@ class OptionTextField extends StatelessWidget { 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(), @@ -43,12 +51,12 @@ class OptionTextField extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: Theme.of(context).brightness == Brightness.light - ? type.mobileIconBackgroundColor - : type.mobileIconBackgroundColorDark, + ? fieldType.mobileIconBackgroundColor + : fieldType.mobileIconBackgroundColorDark, ), child: Center( child: FlowySvg( - type.svgData, + 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 0000000000..b0f21188cd --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart @@ -0,0 +1,145 @@ +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/shared/icon_emoji_picker/tab.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(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, + tabs: [PickerTabType.emoji.name], + ); + } + } + + // 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/mobile_card_content.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart index 057d3937cb..aa9d23308a 100644 --- 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 @@ -1,9 +1,11 @@ -import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +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:flutter/material.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; class MobileCardContent extends StatelessWidget { const MobileCardContent({ @@ -12,29 +14,38 @@ class MobileCardContent extends StatelessWidget { required this.cellBuilder, required this.cells, required this.styleConfiguration, + required this.userProfile, }); final RowMetaPB rowMeta; final CardCellBuilder cellBuilder; - final List cells; + final List cells; final RowCardStyleConfiguration styleConfiguration; + final UserProfilePB? userProfile; @override Widget build(BuildContext context) { - return Padding( - padding: styleConfiguration.cardPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - children: cells.map( - (cellContext) { - return cellBuilder.build( - cellContext: cellContext, - styleMap: mobileBoardCardCellStyleMap(context), - hasNotes: !rowMeta.isDocumentEmpty, - ); - }, - ).toList(), - ), + 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 index 5d5774156b..8262cf6408 100644 --- 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 @@ -5,9 +5,8 @@ import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_editor 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_appflowy_date_picker.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:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -71,71 +70,52 @@ class _MobileDateCellEditScreenState extends State { ); } - Widget _buildDatePicker() => MultiBlocProvider( - providers: [ - BlocProvider( - create: (_) => DateCellEditorBloc( - reminderBloc: getIt(), - cellController: widget.controller, - ), - ), - ], - child: BlocBuilder( - builder: (context, state) { - return MobileAppFlowyDatePicker( - selectedDay: state.dateTime, - dateStr: state.dateStr, - endDateStr: state.endDateStr, - timeStr: state.timeStr, - endTimeStr: state.endTimeStr, - startDay: state.startDay, - endDay: state.endDay, - enableRanges: true, - isRange: state.isRange, - includeTime: state.includeTime, - use24hFormat: state.dateTypeOptionPB.timeFormat == - TimeFormatPB.TwentyFourHour, - timeFormat: state.dateTypeOptionPB.timeFormat, - selectedReminderOption: state.reminderOption, - onStartTimeChanged: (String? time) { - if (time != null) { - context - .read() - .add(DateCellEditorEvent.setTime(time)); - } - }, - onEndTimeChanged: (String? time) { - if (time != null) { - context - .read() - .add(DateCellEditorEvent.setEndTime(time)); - } - }, - onDaySelected: (selectedDay, focusedDay) => context - .read() - .add(DateCellEditorEvent.selectDay(selectedDay)), - onRangeSelected: (start, end, focused) => context - .read() - .add(DateCellEditorEvent.selectDateRange(start, end)), - onRangeChanged: (value) => context - .read() - .add(DateCellEditorEvent.setIsRange(value)), - onIncludeTimeChanged: (value) => context - .read() - .add(DateCellEditorEvent.setIncludeTime(value)), - onClearDate: () => context - .read() - .add(const DateCellEditorEvent.clearDate()), - onReminderSelected: (option) => - context.read().add( - DateCellEditorEvent.setReminderOption( - option: option, - selectedDay: - state.dateTime == null ? DateTime.now() : null, - ), - ), - ); - }, - ), - ); + 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 index 0f7c758664..5abb2dc031 100644 --- 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 @@ -37,6 +37,7 @@ class _MobileNewPropertyScreenState extends State { final type = widget.fieldType ?? FieldType.RichText; optionValues = FieldOptionValues( type: type, + icon: "", name: type.i18n, ); } 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 index d794817339..75b52de414 100644 --- 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 @@ -1,8 +1,8 @@ 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/domain/field_backend_service.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'; @@ -48,8 +48,8 @@ class _MobileEditPropertyScreenState extends State { final fieldId = widget.field.id; return PopScope( - onPopInvoked: (didPop) { - if (didPop) { + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { 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 index a4ef722ea7..11a6b239e9 100644 --- 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 @@ -1,3 +1,5 @@ +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'; @@ -7,7 +9,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da 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'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:go_router/go_router.dart'; import 'mobile_create_field_screen.dart'; @@ -19,14 +21,16 @@ import 'mobile_quick_field_editor.dart'; const mobileSupportedFieldTypes = [ FieldType.RichText, FieldType.Number, - FieldType.URL, FieldType.SingleSelect, FieldType.MultiSelect, FieldType.DateTime, - FieldType.LastEditedTime, - FieldType.CreatedTime, + FieldType.Media, + FieldType.URL, FieldType.Checkbox, FieldType.Checklist, + FieldType.LastEditedTime, + FieldType.CreatedTime, + // FieldType.Time, ]; Future showFieldTypeGridBottomSheet( @@ -40,7 +44,7 @@ Future showFieldTypeGridBottomSheet( showCloseButton: true, elevation: 20, title: title, - backgroundColor: Theme.of(context).colorScheme.background, + backgroundColor: AFThemeExtension.of(context).background, enableDraggableScrollable: true, builder: (context) { final typeOptionMenuItemValue = mobileSupportedFieldTypes 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 index f488608d87..04144241a0 100644 --- 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 @@ -1,10 +1,9 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; 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/util/field_type_extension.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'; @@ -132,9 +131,9 @@ class _FieldButton extends StatelessWidget { return FlowyOptionTile.checkbox( text: field.name, isSelected: isSelected, - leftIcon: FlowySvg( - field.fieldType.svgData, - size: const Size.square(20), + 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 index 26c9d462a6..f3d71a7f0e 100644 --- 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 @@ -9,10 +9,10 @@ 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/domain/field_service.dart'; import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart'; -import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.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'; @@ -35,6 +35,7 @@ class FieldOptionValues { FieldOptionValues({ required this.type, required this.name, + required this.icon, this.dateFormat, this.timeFormat, this.includeTime, @@ -48,6 +49,7 @@ class FieldOptionValues { return FieldOptionValues( type: fieldType, name: field.name, + icon: field.icon, numberFormat: fieldType == FieldType.Number ? NumberTypeOptionPB.fromBuffer(buffer).format : null, @@ -83,6 +85,7 @@ class FieldOptionValues { FieldType type; String name; + String icon; // FieldType.DateTime // FieldType.LastEditedTime @@ -119,6 +122,7 @@ class FieldOptionValues { case FieldType.RichText: case FieldType.URL: case FieldType.Checkbox: + case FieldType.Time: return null; case FieldType.Number: return NumberTypeOptionPB( @@ -146,6 +150,8 @@ class FieldOptionValues { timeFormat: timeFormat, includeTime: includeTime, ).writeToBuffer(); + case FieldType.Media: + return MediaTypeOptionPB().writeToBuffer(); default: throw UnimplementedError(); } @@ -218,7 +224,9 @@ class _MobileFieldEditorState extends State { const _Divider(), OptionTextField( controller: controller, - type: values.type, + autoFocus: widget.mode == FieldOptionMode.add, + fieldType: values.type, + isPrimary: widget.isPrimary, onTextChanged: (value) { isFieldNameChanged = true; _updateOptionValues(name: value); @@ -855,6 +863,8 @@ class _SelectOptionListState extends State<_SelectOptionList> { 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( 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 index f3b76447f4..f2b90e9c0d 100644 --- 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 @@ -1,3 +1,5 @@ +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'; @@ -5,13 +7,12 @@ import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_ 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/domain/field_backend_service.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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -61,7 +62,8 @@ class _QuickEditFieldState extends State { create: (_) => FieldEditorBloc( viewId: widget.viewId, fieldController: widget.fieldController, - field: widget.fieldInfo.field, + fieldInfo: widget.fieldInfo, + isNew: false, ), child: BlocConsumer( listenWhen: (previous, current) => @@ -74,7 +76,8 @@ class _QuickEditFieldState extends State { const VSpace(16), OptionTextField( controller: controller, - type: state.field.fieldType, + isPrimary: state.field.isPrimary, + fieldType: state.field.fieldType, onTextChanged: (text) { context .read() @@ -99,7 +102,7 @@ class _QuickEditFieldState extends State { context.pop(); }, ), - if (!widget.fieldInfo.isPrimary) + if (!widget.fieldInfo.isPrimary) ...[ FlowyOptionTile.text( showTopBorder: false, text: fieldVisibility.isVisibleState() @@ -115,7 +118,6 @@ class _QuickEditFieldState extends State { } }, ), - if (!widget.fieldInfo.isPrimary) FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.grid_field_insertLeft.tr(), @@ -132,6 +134,7 @@ class _QuickEditFieldState extends State { ); }, ), + ], FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.grid_field_insertRight.tr(), 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 index b5ec0f9d80..9543a4593b 100644 --- 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 @@ -8,12 +8,11 @@ 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/util/field_type_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:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -79,14 +78,12 @@ class _MobileDatabaseFieldListBody extends StatelessWidget { viewId: viewId, fieldController: databaseController.fieldController, fieldInfo: field, - index: index, showTopBorder: false, ), ) .toList(); return ReorderableListView.builder( - padding: EdgeInsets.zero, proxyDecorator: (_, index, anim) { final field = fields[index]; return AnimatedBuilder( @@ -103,7 +100,6 @@ class _MobileDatabaseFieldListBody extends StatelessWidget { viewId: viewId, fieldController: databaseController.fieldController, fieldInfo: field, - index: index, showTopBorder: true, ), ), @@ -121,43 +117,32 @@ class _MobileDatabaseFieldListBody extends StatelessWidget { }, header: firstCell, footer: canCreate - ? Column( - mainAxisSize: MainAxisSize.min, - children: [ - _divider(), - _NewDatabaseFieldTile(viewId: viewId), - VSpace( - context.bottomSheetPadding( - ignoreViewPadding: false, - ), - ), - ], + ? Padding( + padding: const EdgeInsets.only(top: 20), + child: _NewDatabaseFieldTile(viewId: viewId), ) - : VSpace( - context.bottomSheetPadding(ignoreViewPadding: false), - ), + : null, itemCount: cells.length, itemBuilder: (context, index) => cells[index], + padding: EdgeInsets.only( + bottom: context.bottomSheetPadding(ignoreViewPadding: false), + ), ); }, ), ); } - - Widget _divider() => const VSpace(20); } class DatabaseFieldListTile extends StatelessWidget { const DatabaseFieldListTile({ super.key, - this.index, required this.fieldInfo, required this.viewId, required this.fieldController, required this.showTopBorder, }); - final int? index; final FieldInfo fieldInfo; final String viewId; final FieldController fieldController; @@ -168,19 +153,20 @@ class DatabaseFieldListTile extends StatelessWidget { if (fieldInfo.field.isPrimary) { return FlowyOptionTile.text( text: fieldInfo.name, - leftIcon: FlowySvg( - fieldInfo.fieldType.svgData, - size: const Size.square(20), + 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: FlowySvg( - fieldInfo.fieldType.svgData, - size: const Size.square(20), + leftIcon: FieldIcon( + fieldInfo: fieldInfo, + dimension: 20, ), showTopBorder: showTopBorder, onTap: () => showEditFieldScreen(context, viewId, fieldInfo), 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 0000000000..aea68ac27a --- /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 0000000000..a62ef846ae --- /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 0000000000..d3d0b9bcdd --- /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 index 909018d1b1..009468a8f1 100644 --- 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 @@ -2,9 +2,13 @@ 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/sort/sort_info.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'; @@ -139,7 +143,7 @@ class _Overview extends StatelessWidget { return Column( children: [ Expanded( - child: state.sortInfos.isEmpty + child: state.sorts.isEmpty ? Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -167,10 +171,10 @@ class _Overview extends StatelessWidget { onReorder: (oldIndex, newIndex) => context .read() .add(SortEditorEvent.reorderSort(oldIndex, newIndex)), - itemCount: state.sortInfos.length, + itemCount: state.sorts.length, itemBuilder: (context, index) => _SortItem( key: ValueKey("sort_item_$index"), - sort: state.sortInfos[index], + sort: state.sorts[index], ), ), ), @@ -232,7 +236,7 @@ class _Overview extends StatelessWidget { class _SortItem extends StatelessWidget { const _SortItem({super.key, required this.sort}); - final SortInfo sort; + final DatabaseSort sort; @override Widget build(BuildContext context) { @@ -288,9 +292,18 @@ class _SortItem extends StatelessWidget { child: Row( children: [ Expanded( - child: FlowyText( - sort.fieldInfo.name, - overflow: TextOverflow.ellipsis, + 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), @@ -327,7 +340,7 @@ class _SortItem extends StatelessWidget { children: [ Expanded( child: FlowyText( - sort.sortPB.condition.name, + sort.condition.name, ), ), const HSpace(6.0), @@ -349,11 +362,11 @@ class _SortItem extends StatelessWidget { ), Positioned( right: 8, - top: 9, + top: 6, child: InkWell( onTap: () => context .read() - .add(SortEditorEvent.deleteSort(sort)), + .add(SortEditorEvent.deleteSort(sort.sortId)), // steal from the container LongClickReorderWidget thing onLongPress: () {}, borderRadius: BorderRadius.circular(10), @@ -385,14 +398,14 @@ class _SortDetail extends StatelessWidget { return isCreatingNewSort ? const _SortDetailContent() - : BlocSelector( - selector: (state) => state.sortInfos.firstWhere( - (sortInfo) => - sortInfo.sortId == + : BlocSelector( + selector: (state) => state.sorts.firstWhere( + (sort) => + sort.sortId == context.read().state.editingSortId, ), - builder: (context, sortInfo) { - return _SortDetailContent(sortInfo: sortInfo); + builder: (context, sort) { + return _SortDetailContent(sort: sort); }, ); } @@ -400,12 +413,12 @@ class _SortDetail extends StatelessWidget { class _SortDetailContent extends StatelessWidget { const _SortDetailContent({ - this.sortInfo, + this.sort, }); - final SortInfo? sortInfo; + final DatabaseSort? sort; - bool get isCreatingNewSort => sortInfo == null; + bool get isCreatingNewSort => sort == null; @override Widget build(BuildContext context) { @@ -419,7 +432,7 @@ class _SortDetailContent extends StatelessWidget { length: 2, initialIndex: isCreatingNewSort ? 0 - : sortInfo!.sortPB.condition == SortConditionPB.Ascending + : sort!.condition == SortConditionPB.Ascending ? 0 : 1, child: Container( @@ -438,7 +451,7 @@ class _SortDetailContent extends StatelessWidget { color: Theme.of(context).colorScheme.surface, ), splashFactory: NoSplash.splashFactory, - overlayColor: const MaterialStatePropertyAll( + overlayColor: const WidgetStatePropertyAll( Colors.transparent, ), onTap: (index) { @@ -489,7 +502,7 @@ class _SortDetailContent extends StatelessWidget { child: BlocBuilder( builder: (context, state) { final fields = state.allFields - .where((field) => field.canCreateSort || field.hasSort) + .where((field) => field.fieldType.canCreateSort) .toList(); return ListView.builder( itemCount: fields.length, @@ -501,14 +514,19 @@ class _SortDetailContent extends StatelessWidget { .state .newSortFieldId == fieldInfo.id - : sortInfo!.fieldId == fieldInfo.id; + : sort!.fieldId == fieldInfo.id; - final enabled = fieldInfo.canCreateSort || - isCreatingNewSort && !fieldInfo.hasSort || - !isCreatingNewSort && sortInfo!.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, @@ -541,7 +559,7 @@ class _SortDetailContent extends StatelessWidget { } else { context.read().add( SortEditorEvent.editSort( - sortId: sortInfo!.sortId, + sortId: sort!.sortId, condition: newCondition, ), ); @@ -554,7 +572,7 @@ class _SortDetailContent extends StatelessWidget { } else { context.read().add( SortEditorEvent.editSort( - sortId: sortInfo!.sortId, + sortId: sort!.sortId, fieldId: newFieldId, ), ); 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 index 9ef8ddefb1..f7ad313412 100644 --- 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 @@ -5,12 +5,15 @@ 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/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_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'; @@ -162,9 +165,20 @@ class MobileDatabaseViewListButton extends StatelessWidget { } Widget _buildViewIconButton(BuildContext context, ViewPB view) { + final iconData = view.icon.toEmojiIconData(); + Widget icon; + if (iconData.isEmpty || iconData.type != FlowyIconType.icon) { + icon = view.defaultIcon(); + } else { + icon = RawEmojiIconWidget( + emoji: iconData, + emojiSize: 14.0, + enableColor: false, + ); + } return SizedBox.square( dimension: 20.0, - child: view.defaultIcon(), + child: icon, ); } @@ -183,7 +197,7 @@ class MobileDatabaseViewListButton extends StatelessWidget { showMobileBottomSheet( context, showDragHandle: true, - backgroundColor: Theme.of(context).colorScheme.background, + backgroundColor: AFThemeExtension.of(context).background, builder: (_) { return BlocProvider( create: (_) => 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 index eff4dc5e0b..a133739a9d 100644 --- 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 @@ -1,11 +1,16 @@ 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/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/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_service.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:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -48,7 +53,46 @@ class MobileDatabaseViewQuickActions extends StatelessWidget { context.pop(); } }), - _divider(), + const MobileQuickActionDivider(), + _actionButton( + context, + _Action.changeIcon, + () { + 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 Expanded( + child: FlowyIconEmojiPicker( + tabs: const [PickerTabType.icon], + enableBackgroundColorSelection: false, + onSelectedEmoji: (r) { + ViewBackendService.updateViewIcon( + view: view, + viewIcon: r.data, + ); + Navigator.pop(context); + }, + ), + ); + }, + builder: (_) => const SizedBox.shrink(), + ).then((_) { + if (context.mounted) { + Navigator.pop(context); + } + }); + }, + !isInline, + ), + const MobileQuickActionDivider(), _actionButton( context, _Action.duplicate, @@ -58,7 +102,7 @@ class MobileDatabaseViewQuickActions extends StatelessWidget { }, !isInline, ), - _divider(), + const MobileQuickActionDivider(), _actionButton( context, _Action.delete, @@ -68,7 +112,6 @@ class MobileDatabaseViewQuickActions extends StatelessWidget { }, !isInline, ), - _divider(), ], ); } @@ -88,28 +131,29 @@ class MobileDatabaseViewQuickActions extends StatelessWidget { enable: enable, ); } - - Widget _divider() => const Divider(height: 8.5, thickness: 0.5); } enum _Action { edit, - duplicate, - delete; + changeIcon, + delete, + duplicate; String get label { return switch (this) { edit => LocaleKeys.grid_settings_editView.tr(), duplicate => LocaleKeys.button_duplicate.tr(), delete => LocaleKeys.button_delete.tr(), + changeIcon => LocaleKeys.disclosureAction_changeIcon.tr(), }; } FlowySvgData get icon { return switch (this) { - edit => FlowySvgs.edit_s, - duplicate => FlowySvgs.copy_s, - delete => FlowySvgs.delete_s, + edit => FlowySvgs.view_item_rename_s, + duplicate => FlowySvgs.duplicate_s, + delete => FlowySvgs.trash_s, + changeIcon => FlowySvgs.change_icon_s, }; } 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 index a2771ece26..f5812541c8 100644 --- 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 @@ -133,7 +133,7 @@ enum DatabaseViewSettings { filter => FlowySvgs.filter_s, sort => FlowySvgs.sort_ascending_s, board => FlowySvgs.board_s, - calendar => FlowySvgs.date_s, + calendar => FlowySvgs.calendar_s, duplicate => FlowySvgs.copy_s, delete => FlowySvgs.delete_s, }; @@ -176,6 +176,7 @@ class DatabaseViewSettingTile extends StatelessWidget { return Row( children: [ FlowyText( + lineHeight: 1.0, databaseLayoutFromViewLayout(view.layout).layoutName, color: Theme.of(context).hintColor, ), @@ -234,7 +235,6 @@ class DatabaseViewSettingTile extends StatelessWidget { showHeader: true, showBackButton: true, title: LocaleKeys.grid_settings_properties.tr(), - showDivider: true, builder: (_) { return BlocProvider.value( value: context.read(), 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 index 14c4e022ae..373558a480 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart @@ -1,4 +1,5 @@ import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; @@ -7,15 +8,27 @@ class MobileDocumentScreen extends StatelessWidget { super.key, required this.id, this.title, + this.showMoreButton = true, + this.fixedTitle, + this.blockId, + this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); /// view id final String id; final String? title; + final bool showMoreButton; + final String? fixedTitle; + final String? blockId; + final List tabs; 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'; + static const viewSelectTabs = 'select_tabs'; @override Widget build(BuildContext context) { @@ -23,6 +36,10 @@ class MobileDocumentScreen extends StatelessWidget { id: id, title: title, viewLayout: ViewLayoutPB.Document, + showMoreButton: showMoreButton, + fixedTitle: fixedTitle, + blockId: blockId, + tabs: tabs, ); } } 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 index f9fcba5754..ca012891f6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - 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'; @@ -10,6 +8,7 @@ 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'; @@ -17,14 +16,15 @@ class MobileFavoritePageFolder extends StatelessWidget { const MobileFavoritePageFolder({ super.key, required this.userProfile, - required this.workspaceId, }); final UserProfilePB userProfile; - final String workspaceId; @override Widget build(BuildContext context) { + final workspaceId = + context.read().state.currentWorkspace?.workspaceId ?? + ''; return MultiBlocProvider( providers: [ BlocProvider( @@ -67,7 +67,8 @@ class MobileFavoritePageFolder extends StatelessWidget { MobileFavoriteFolder( showHeader: false, forceExpanded: true, - views: favoriteState.views, + 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 index 7afc740b45..0e7a7cb4c6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart @@ -31,9 +31,9 @@ class MobileFavoriteScreen extends StatelessWidget { return const Center(child: CircularProgressIndicator.adaptive()); } - final workspaceSetting = snapshots.data?[0].fold( - (workspaceSettingPB) { - return workspaceSettingPB as WorkspaceSettingPB?; + final latest = snapshots.data?[0].fold( + (latest) { + return latest as WorkspaceLatestPB?; }, (error) => null, ); @@ -46,7 +46,7 @@ class MobileFavoriteScreen extends StatelessWidget { // 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) { + if (latest == null || userProfile == null) { return const WorkspaceFailedScreen(); } @@ -64,8 +64,6 @@ class MobileFavoriteScreen extends StatelessWidget { builder: (context, state) { return MobileFavoritePage( userProfile: userProfile, - workspaceId: state.currentWorkspace?.workspaceId ?? - workspaceSetting.workspaceId, ); }, ), @@ -81,11 +79,9 @@ class MobileFavoritePage extends StatelessWidget { const MobileFavoritePage({ super.key, required this.userProfile, - required this.workspaceId, }); final UserProfilePB userProfile; - final String workspaceId; @override Widget build(BuildContext context) { @@ -108,7 +104,6 @@ class MobileFavoritePage extends StatelessWidget { Expanded( child: MobileFavoritePageFolder( userProfile: userProfile, - workspaceId: workspaceId, ), ), ], 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 0000000000..6282421109 --- /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 index c56d369676..1efee460eb 100644 --- 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 @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - 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'; @@ -7,6 +5,7 @@ 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 { @@ -28,7 +27,7 @@ class MobileFavoriteFolder extends StatelessWidget { } return BlocProvider( - create: (context) => FolderBloc(type: FolderCategoryType.favorite) + create: (context) => FolderBloc(type: FolderSpaceType.favorite) ..add( const FolderEvent.initial(), ), @@ -55,21 +54,26 @@ class MobileFavoriteFolder extends StatelessWidget { ...views.map( (view) => MobileViewItem( key: ValueKey( - '${FolderCategoryType.favorite.name} ${view.id}', + '${FolderSpaceType.favorite.name} ${view.id}', ), - categoryType: FolderCategoryType.favorite, + 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, - ]), + 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/home_space/home_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart new file mode 100644 index 0000000000..5651379522 --- /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 index 7631383faa..0013650df9 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart @@ -1,9 +1,9 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder.dart'; -import 'package:appflowy/workspace/application/favorite/favorite_bloc.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'; @@ -27,73 +27,89 @@ class MobileFolders extends StatelessWidget { @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (_) => SidebarSectionsBloc() - ..add( + 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, - workspaceId, - ), - ), - ), - BlocProvider( - create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), - ), - ], - child: BlocListener( - listener: (context, state) { - context.read().add( - SidebarSectionsEvent.initial( - user, - state.currentWorkspace?.workspaceId ?? workspaceId, - ), - ); - }, - child: BlocConsumer( - listenWhen: (p, c) => - p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, - listener: (context, state) { - final lastCreatedRootView = state.lastCreatedRootView; - if (lastCreatedRootView != null) { - context.pushView(lastCreatedRootView); - } - }, - builder: (context, state) { - final isCollaborativeWorkspace = - context.read().state.isCollabWorkspaceOn; - return SlidableAutoCloseBehavior( - child: Column( - children: [ - ...isCollaborativeWorkspace - ? [ - MobileSectionFolder( - title: LocaleKeys.sideBar_workspace.tr(), - categoryType: FolderCategoryType.public, - views: state.section.publicViews, - ), - const VSpace(8.0), - MobileSectionFolder( - title: LocaleKeys.sideBar_private.tr(), - categoryType: FolderCategoryType.private, - views: state.section.privateViews, - ), - ] - : [ - MobileSectionFolder( - title: LocaleKeys.sideBar_personal.tr(), - categoryType: FolderCategoryType.public, - views: state.section.publicViews, - ), - ], - const VSpace(8.0), - ], + 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 index 69759fc508..fdea8322c3 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart @@ -1,24 +1,31 @@ -import 'dart:io'; - -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/home/home.dart'; -import 'package:appflowy/mobile/presentation/home/mobile_folders.dart'; import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart'; -import 'package:appflowy/mobile/presentation/home/recent_folder/mobile_home_recent_views.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/shared/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: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:provider/provider.dart'; +import 'package:sentry/sentry.dart'; class MobileHomeScreen extends StatelessWidget { const MobileHomeScreen({super.key}); @@ -37,9 +44,9 @@ class MobileHomeScreen extends StatelessWidget { return const Center(child: CircularProgressIndicator.adaptive()); } - final workspaceSetting = snapshots.data?[0].fold( - (workspaceSettingPB) { - return workspaceSettingPB as WorkspaceSettingPB?; + final workspaceLatest = snapshots.data?[0].fold( + (workspaceLatestPB) { + return workspaceLatestPB as WorkspaceLatestPB?; }, (error) => null, ); @@ -52,17 +59,26 @@ class MobileHomeScreen extends StatelessWidget { // 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) { + if (workspaceLatest == 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, + workspaceLatest: workspaceLatest, ), ), ), @@ -72,107 +88,248 @@ class MobileHomeScreen extends StatelessWidget { } } -class MobileHomePage extends StatelessWidget { +final PropertyValueNotifier mCurrentWorkspace = + PropertyValueNotifier(null); + +class MobileHomePage extends StatefulWidget { const MobileHomePage({ super.key, required this.userProfile, - required this.workspaceSetting, + required this.workspaceLatest, }); final UserProfilePB userProfile; - final WorkspaceSettingPB workspaceSetting; + final WorkspaceLatestPB workspaceLatest; + + @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 BlocProvider( - create: (_) => UserWorkspaceBloc(userProfile: userProfile) - ..add( - const UserWorkspaceEvent.initial(), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => UserWorkspaceBloc(userProfile: widget.userProfile) + ..add(const UserWorkspaceEvent.initial()), ), - child: BlocBuilder( - buildWhen: (previous, current) => - previous.currentWorkspace?.workspaceId != - current.currentWorkspace?.workspaceId, - builder: (context, state) { - if (state.currentWorkspace == null) { - return const SizedBox.shrink(); - } - 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: Scrollbar( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Recent files - const MobileRecentFolder(), - - // Folders - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: MobileFolders( - user: userProfile, - workspaceId: - state.currentWorkspace?.workspaceId ?? - workspaceSetting.workspaceId, - showFavorite: false, - ), - ), - const SizedBox(height: 8), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 24), - child: _TrashButton(), - ), - ], - ), - ), - ), - ), - ), - ], - ); - }, - ), + 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 _TrashButton extends StatelessWidget { - const _TrashButton(); +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 FlowyButton( - expand: true, - margin: const EdgeInsets.symmetric(vertical: 8), - leftIcon: FlowySvg( - FlowySvgs.m_delete_m, - color: Theme.of(context).colorScheme.onSurface, - ), - leftIconSize: const Size.square(24), - text: FlowyText.medium( - LocaleKeys.trash_text.tr(), - fontSize: 18.0, - ), - onTap: () => context.push(MobileHomeTrashPage.routeName), + 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(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 index 86a2c0dc51..113f12e543 100644 --- 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 @@ -1,12 +1,11 @@ -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/animated_gesture.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; -import 'package:appflowy/mobile/presentation/home/mobile_home_setting_page.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/plugins/base/icon/icon_picker.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/startup/startup.dart'; import 'package:appflowy/util/built_in_svgs.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; @@ -15,9 +14,12 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sid 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, @@ -36,7 +38,7 @@ class MobileHomePageHeader extends StatelessWidget { final isCollaborativeWorkspace = context.read().state.isCollabWorkspaceOn; return ConstrainedBox( - constraints: const BoxConstraints(minHeight: 52), + constraints: const BoxConstraints(minHeight: 56), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -45,12 +47,10 @@ class MobileHomePageHeader extends StatelessWidget { ? _MobileWorkspace(userProfile: userProfile) : _MobileUser(userProfile: userProfile), ), - IconButton( - onPressed: () => context.push( - MobileHomeSettingPage.routeName, - ), - icon: const FlowySvg(FlowySvgs.m_setting_m), + HomePageSettingsPopupMenu( + userProfile: userProfile, ), + const HSpace(8.0), ], ), ); @@ -111,8 +111,10 @@ class _MobileWorkspace extends StatelessWidget { if (currentWorkspace == null) { return const SizedBox.shrink(); } - return GestureDetector( - onTap: () { + return AnimatedGestureDetector( + scaleFactor: 0.99, + alignment: Alignment.centerLeft, + onTapUp: () { context.read().add( const UserWorkspaceEvent.fetchWorkspaces(), ); @@ -120,40 +122,31 @@ class _MobileWorkspace extends StatelessWidget { }, child: Row( children: [ - const HSpace(2.0), - SizedBox.square( - dimension: 34.0, - child: WorkspaceIcon( - workspace: currentWorkspace, - iconSize: 26, - enableEdit: false, - ), + 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, + ), + ), ), - const HSpace(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - FlowyText.medium( - currentWorkspace.name, - fontSize: 16.0, - overflow: TextOverflow.ellipsis, - ), - const HSpace(4.0), - const FlowySvg(FlowySvgs.list_dropdown_s), - ], - ), - FlowyText.medium( - userProfile.email.isNotEmpty - ? userProfile.email - : userProfile.name, - overflow: TextOverflow.ellipsis, - fontSize: 12, - color: Theme.of(context).colorScheme.onSurface, - ), - ], + currentWorkspace.icon.isNotEmpty + ? const HSpace(2) + : const HSpace(8), + Flexible( + child: FlowyText.semibold( + currentWorkspace.name, + fontSize: 20.0, + overflow: TextOverflow.ellipsis, ), ), ], @@ -171,8 +164,13 @@ class _MobileWorkspace extends StatelessWidget { showDivider: false, showHeader: true, showDragHandle: true, + showCloseButton: true, + useRootNavigator: true, + enableScrollable: true, + bottomSheetPadding: context.bottomSheetPadding(), title: LocaleKeys.workspace_menuTitle.tr(), - builder: (_) { + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (sheetContext) { return BlocProvider.value( value: context.read(), child: BlocBuilder( @@ -187,7 +185,7 @@ class _MobileWorkspace extends StatelessWidget { currentWorkspace: currentWorkspace, workspaces: workspaces, onWorkspaceSelected: (workspace) { - context.pop(); + Navigator.of(sheetContext).pop(); if (workspace == currentWorkspace) { return; @@ -196,6 +194,7 @@ class _MobileWorkspace extends StatelessWidget { context.read().add( UserWorkspaceEvent.openWorkspace( workspace.workspaceId, + workspace.workspaceAuthType, ), ); }, @@ -231,12 +230,13 @@ class _UserIcon extends StatelessWidget { fontSize: 26, ), onTap: () async { - final icon = await context.push( + final icon = await context.push( Uri( path: MobileEmojiPickerScreen.routeName, queryParameters: { MobileEmojiPickerScreen.pageTitle: LocaleKeys.titleBar_userIcon.tr(), + MobileEmojiPickerScreen.selectTabs: [PickerTabType.emoji.name], }, ).toString(), ); 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 index 964f9e5aa5..a01df20549 100644 --- 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 @@ -3,15 +3,19 @@ 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/ai/ai_settings_group.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/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'; class MobileHomeSettingPage extends StatefulWidget { const MobileHomeSettingPage({ @@ -67,31 +71,42 @@ class _MobileHomeSettingPageState extends State { } 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, + return BlocProvider( + create: (context) => UserWorkspaceBloc(userProfile: userProfile) + ..add(const UserWorkspaceEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final currentWorkspaceId = state.currentWorkspace?.workspaceId ?? ''; + 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(), + if (isAuthEnabled) + AiSettingsGroup( + key: ValueKey(currentWorkspaceId), + userProfile: userProfile, + workspaceId: currentWorkspaceId, + ), + const SupportSettingGroup(), + const AboutSettingGroup(), + UserSessionSettingGroup( + userProfile: userProfile, + showThirdPartyLogin: false, + ), + const VSpace(20), + ], + ), ), - // TODO: Enable and implement along with Push Notifications - // const NotificationsSettingGroup(), - const AppearanceSettingGroup(), - const LanguageSettingGroup(), - if (Env.enableCustomCloud) const CloudSettingGroup(), - const SupportSettingGroup(), - const AboutSettingGroup(), - UserSessionSettingGroup( - 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 index a9c2f1b933..73da7594a7 100644 --- 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 @@ -64,11 +64,7 @@ class MobileHomeTrashPage extends StatelessWidget { ], ), body: state.objects.isEmpty - ? FlowyMobileStateContainer.info( - emoji: '🗑️', - title: LocaleKeys.trash_mobile_empty.tr(), - description: LocaleKeys.trash_mobile_emptyDescription.tr(), - ) + ? const _EmptyTrashBin() : _DeletedFilesListView(state), ); }, @@ -82,6 +78,41 @@ enum _TrashActionType { 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({ @@ -178,10 +209,10 @@ class _DeletedFilesListView extends StatelessWidget { title: Text( deletedFile.name, style: theme.textTheme.labelMedium - ?.copyWith(color: theme.colorScheme.onBackground), + ?.copyWith(color: theme.colorScheme.onSurface), ), horizontalTitleGap: 0, - tileColor: theme.colorScheme.onSurface.withOpacity(0.1), + tileColor: theme.colorScheme.onSurface.withValues(alpha: 0.1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), 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 index 4dc9f28155..661a422e0c 100644 --- 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 @@ -1,5 +1,3 @@ -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'; @@ -9,7 +7,9 @@ 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'; @@ -38,7 +38,7 @@ class _MobileRecentFolderState extends State { builder: (context, state) { final ids = {}; - List recentViews = state.views.reversed.toList(); + List recentViews = state.views.map((e) => e.item).toList(); recentViews.retainWhere((element) => ids.add(element.id)); // only keep the first 20 items. @@ -91,7 +91,7 @@ class _RecentViews extends StatelessWidget { context, showDivider: false, showDragHandle: true, - backgroundColor: Theme.of(context).colorScheme.background, + backgroundColor: AFThemeExtension.of(context).background, builder: (_) { return Column( children: [ 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 index 18a0338fbb..966b1ac61a 100644 --- 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 @@ -3,7 +3,7 @@ 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/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'; @@ -110,10 +110,7 @@ class MobileRecentView extends StatelessWidget { return Padding( padding: const EdgeInsets.only(left: 8.0), child: state.icon.isNotEmpty - ? EmojiText( - emoji: state.icon, - fontSize: 30.0, - ) + ? RawEmojiIconWidget(emoji: state.icon, emojiSize: 30) : SizedBox.square( dimension: 32.0, child: view.defaultIcon(), @@ -137,7 +134,8 @@ class _RecentCover extends StatelessWidget { 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), + color: + Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.2), ); final value = this.value; if (value == null) { 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 0000000000..c0baa641d9 --- /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 index c9ea1453c9..0079ed319a 100644 --- 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 @@ -1,16 +1,16 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; 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/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/shared/icon_emoji_picker/tab.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:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileSectionFolder extends StatelessWidget { @@ -18,17 +18,17 @@ class MobileSectionFolder extends StatelessWidget { super.key, required this.title, required this.views, - required this.categoryType, + required this.spaceType, }); final String title; final List views; - final FolderCategoryType categoryType; + final FolderSpaceType spaceType; @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => FolderBloc(type: categoryType) + create: (context) => FolderBloc(type: spaceType) ..add( const FolderEvent.initial(), ), @@ -36,53 +36,25 @@ class MobileSectionFolder extends StatelessWidget { builder: (context, state) { return Column( children: [ - MobileSectionFolderHeader( - title: title, - isExpanded: context.read().state.isExpanded, - onPressed: () => context - .read() - .add(const FolderEvent.expandOrUnExpand()), - onAdded: () { - context.read().add( - SidebarSectionsEvent.createRootViewInSection( - name: - LocaleKeys.menuAppHeader_defaultNewPageName.tr(), - index: 0, - viewSection: categoryType.toViewSectionPB, - ), - ); - context.read().add( - const FolderEvent.expandOrUnExpand(isExpanded: true), - ); - }, - ), - const VSpace(8.0), - const Divider( - height: 1, + 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) - ...views.map( - (view) => MobileViewItem( - key: ValueKey( - '${FolderCategoryType.private.name} ${view.id}', - ), - categoryType: categoryType, - isFirstChild: view.id == views.first.id, - view: view, - level: 0, - leftPadding: 16, - isFeedback: false, - onSelected: context.pushView, - endActionPane: (context) { - final view = context.read().state.view; - return buildEndActionPane(context, [ - MobilePaneActionType.delete, - view.isFavorite - ? MobilePaneActionType.removeFromFavorites - : MobilePaneActionType.addToFavorites, - MobilePaneActionType.more, - ]); - }, + Padding( + padding: const EdgeInsets.only( + left: HomeSpaceViewSizes.leftPadding, + ), + child: _Pages( + views: views, + spaceType: spaceType, ), ), ], @@ -91,4 +63,70 @@ class MobileSectionFolder extends StatelessWidget { ), ); } + + 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: (v) => context.pushView( + v, + tabs: [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ].map((e) => e.name).toList(), + ), + 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 index 3ba15df25d..b1d2bf6909 100644 --- 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 @@ -1,4 +1,5 @@ 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'; @@ -29,24 +30,24 @@ class _MobileSectionFolderHeaderState extends State { @override Widget build(BuildContext context) { - const iconSize = 32.0; return Row( children: [ + const HSpace(HomeSpaceViewSizes.mHorizontalPadding), Expanded( child: FlowyButton( - text: FlowyText.semibold( + text: FlowyText.medium( widget.title, - fontSize: 20.0, + fontSize: 16.0, ), - margin: const EdgeInsets.symmetric(vertical: 8), + 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 Icon( - Icons.keyboard_arrow_down_rounded, - color: Colors.grey, + child: const FlowySvg( + FlowySvgs.m_spaces_expand_s, ), ), onTap: () { @@ -57,17 +58,19 @@ class _MobileSectionFolderHeaderState extends State { }, ), ), - FlowyIconButton( - key: mobileCreateNewPageButtonKey, - hoverColor: Theme.of(context).colorScheme.secondaryContainer, - iconPadding: const EdgeInsets.all(2), - height: iconSize, - width: iconSize, - icon: const FlowySvg( - FlowySvgs.add_s, - size: Size.square(iconSize), + 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, + ), ), - onPressed: widget.onAdded, ), ], ); 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 0000000000..659473a6b1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.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/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, + helpAndDocumentation, +} + +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.workspaceAuthType == AuthTypePB.Server) ...[ + 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.helpAndDocumentation, + svg: FlowySvgs.help_and_documentation_s, + text: LocaleKeys.settings_popupMenuItem_helpAndDocumentation.tr(), + ), + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _MobileSettingsPopupMenuItem.help, + svg: FlowySvgs.message_support_s, + text: LocaleKeys.settings_popupMenuItem_getSupport.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; + case _MobileSettingsPopupMenuItem.helpAndDocumentation: + _openHelpAndDocumentationPage(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); + } + + void _openHelpAndDocumentationPage(BuildContext context) { + afLaunchUrlString('https://appflowy.com/guide'); + } +} + +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 0000000000..2de40600f2 --- /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 0000000000..87ce41d5b6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart @@ -0,0 +1,419 @@ +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/shared/icon_emoji_picker/tab.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, + tabs: [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ].map((e) => e.name).toList(), + ), + 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 || + !isURL(iconUrl)) { + 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: [ + if (icon.isNotEmpty) ...[ + WidgetSpan( + child: SizedBox( + width: 20, + child: RawEmojiIconWidget( + emoji: icon, + emojiSize: 18.0, + ), + ), + ), + const WidgetSpan(child: HSpace(8.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(), + '', + 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.withValues(alpha: 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 0000000000..da7d13a174 --- /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 0000000000..f24108c945 --- /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 0000000000..3bb62a92c8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart @@ -0,0 +1,185 @@ +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/icon_emoji_picker/tab.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: 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: (v) => context.pushView( + v, + tabs: [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ].map((e) => e.name).toList(), + ), + 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 0000000000..0cd80ff1bb --- /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 0000000000..485e07a28c --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart @@ -0,0 +1,496 @@ +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( + 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( + 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( + 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( + 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 0000000000..2eaf609af0 --- /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 0000000000..85ad35a549 --- /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 0000000000..ab3295a630 --- /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 0000000000..1a3eb121f3 --- /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 0000000000..f8c9a0d3b1 --- /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 0000000000..cc4176e0ef --- /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.withValues(alpha: 0.05), + ), + ], + ); + } + + BoxDecoration _buildWrapperDecoration(BuildContext context) { + final outlineColor = Theme.of(context).colorScheme.outline; + final borderColor = Theme.of(context).isLightMode + ? outlineColor.withValues(alpha: 0.7) + : outlineColor.withValues(alpha: 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 0000000000..56f5f3e6ab --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart @@ -0,0 +1,221 @@ +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/shared/icon_emoji_picker/tab.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/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, + tabs: [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ].map((e) => e.name).toList(), + ); + } + }, + ), + BlocListener( + listenWhen: (p, c) => + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, + listener: (context, state) { + final lastCreatedPage = state.lastCreatedRootView; + if (lastCreatedPage != null) { + context.pushView( + lastCreatedPage, + tabs: [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ].map((e) => e.name).toList(), + ); + } + }, + ), + ], + 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.workspaceAuthType == AuthTypePB.Server) + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 16, + left: 20, + right: 20, + child: const FloatingAIEntry(), + ), + ], + ); + case MobileSpaceTabType.favorites: + return MobileFavoriteSpace(userProfile: widget.userProfile); + } + }).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: layout, + openAfterCreate: true, + ), + ); + } else if (layout == ViewLayoutPB.Document) { + // only support create document in section + context.read().add( + SidebarSectionsEvent.createRootViewInSection( + name: '', + 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 0000000000..e3c1439dd4 --- /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 0000000000..741cbd6fe9 --- /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 index 69d34d26b3..d306f48964 100644 --- 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 @@ -1,14 +1,23 @@ 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({ @@ -26,7 +35,13 @@ class MobileWorkspaceMenu extends StatelessWidget { @override Widget build(BuildContext context) { - final List children = []; + // 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( @@ -34,16 +49,132 @@ class MobileWorkspaceMenu extends StatelessWidget { key: ValueKey(workspace.workspaceId), userProfile: userProfile, workspace: workspace, - showTopBorder: i == 0, + 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, + AuthTypePB.Server, + ), + ); + }, + ); + }, + ); + } + + 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).withValues(alpha: 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 { @@ -71,51 +202,288 @@ class _WorkspaceMenuItem extends StatelessWidget { )..add(const WorkspaceMemberEvent.initial()), child: BlocBuilder( builder: (context, state) { - final members = state.members; return FlowyOptionTile.text( - content: Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FlowyText( - workspace.name, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - FlowyText( - state.isLoading - ? '' - : LocaleKeys.settings_appearance_members_membersCount - .plural( - members.length, - ), - fontSize: 10.0, - color: Theme.of(context).hintColor, - ), - ], - ), - ), - ), height: 60, showTopBorder: showTopBorder, - leftIcon: WorkspaceIcon( - enableEdit: false, - iconSize: 26, + showBottomBorder: false, + leftIcon: _WorkspaceMenuItemIcon(workspace: workspace), + trailing: _WorkspaceMenuItemTrailing( workspace: workspace, + currentWorkspace: currentWorkspace, ), - trailing: workspace.workspaceId == currentWorkspace.workspaceId - ? const FlowySvg( - FlowySvgs.m_blue_check_s, - blendMode: null, - ) - : null, 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 0000000000..bb6f6207f6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_more_options.dart @@ -0,0 +1,106 @@ +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, + ), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_handler.dart b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_handler.dart new file mode 100644 index 0000000000..74d5e56bce --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_handler.dart @@ -0,0 +1,253 @@ +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_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 'mobile_inline_actions_menu_group.dart'; + +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 MobileInlineActionsHandler extends StatefulWidget { + const MobileInlineActionsHandler({ + super.key, + required this.results, + required this.editorState, + required this.menuService, + required this.onDismiss, + required this.style, + required this.service, + this.startCharAmount = 1, + this.startOffset = 0, + this.cancelBySpaceHandler, + }); + + final List results; + final EditorState editorState; + final InlineActionsMenuService menuService; + final VoidCallback onDismiss; + final InlineActionsMenuStyle style; + final int startCharAmount; + final InlineActionsService service; + final bool Function()? cancelBySpaceHandler; + final int startOffset; + + @override + State createState() => + _MobileInlineActionsHandlerState(); +} + +class _MobileInlineActionsHandlerState + extends State { + final _focusNode = + FocusNode(debugLabel: 'mobile_inline_actions_menu_handler'); + + 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 editorState.updateSelectionWithReason(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 = editorState.selection?.endIndex ?? 0; + keepEditorFocusNotifier.increase(); + editorState.selectionNotifier.addListener(onSelectionChanged); + } + + @override + void dispose() { + editorState.selectionNotifier.removeListener(onSelectionChanged); + _focusNode.dispose(); + keepEditorFocusNotifier.decrease(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final width = editorState.renderBox!.size.width - 24 * 2; + return Focus( + focusNode: _focusNode, + child: Container( + constraints: BoxConstraints( + maxHeight: 192, + minWidth: width, + maxWidth: width, + ), + margin: EdgeInsets.symmetric(horizontal: 24.0), + decoration: BoxDecoration( + color: widget.style.backgroundColor, + borderRadius: BorderRadius.circular(6.0), + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + ], + ), + child: noResults + ? SizedBox( + width: 150, + child: FlowyText.regular( + LocaleKeys.inlineActions_noResults.tr(), + ), + ) + : SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Material( + color: Colors.transparent, + child: Padding( + padding: EdgeInsets.all(6.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: results + .where((g) => g.results.isNotEmpty) + .mapIndexed( + (index, group) => MobileInlineActionsGroup( + result: group, + editorState: editorState, + menuService: 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, + onPreSelect: (int value) { + setState(() { + _selectedGroup = index; + _selectedIndex = value; + }); + }, + ), + ) + .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]; + + EditorState get editorState => widget.editorState; + + InlineActionsMenuService get menuService => widget.menuService; + + void onSelectionChanged() { + final selection = editorState.selection; + if (selection == null) { + menuService.dismiss(); + return; + } + if (!selection.isCollapsed) { + menuService.dismiss(); + return; + } + final startOffset = widget.startOffset; + final endOffset = selection.end.offset; + if (endOffset < startOffset) { + menuService.dismiss(); + return; + } + final node = editorState.getNodeAtPath(selection.start.path); + final text = node?.delta?.toPlainText() ?? ''; + final search = text.substring(startOffset, endOffset); + this.search = search; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart new file mode 100644 index 0000000000..6166671391 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +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:flutter/material.dart'; + +import 'mobile_inline_actions_handler.dart'; + +class MobileInlineActionsMenu extends InlineActionsMenuService { + MobileInlineActionsMenu({ + required this.context, + required this.editorState, + required this.initialResults, + required this.style, + required this.service, + this.startCharAmount = 1, + this.cancelBySpaceHandler, + }); + + final BuildContext context; + final EditorState editorState; + final List initialResults; + final bool Function()? cancelBySpaceHandler; + final InlineActionsService service; + + @override + final InlineActionsMenuStyle style; + + final int startCharAmount; + + OverlayEntry? _menuEntry; + + @override + void dismiss() { + if (_menuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + } + + _menuEntry?.remove(); + _menuEntry = null; + } + + @override + Future show() { + final completer = Completer(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _show(); + completer.complete(); + }); + return completer.future; + } + + void _show() { + final selectionRects = editorState.selectionRects(); + if (selectionRects.isEmpty) { + return; + } + + const double menuHeight = 192.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, + ); + } + + final (left, top, right, bottom) = _getPosition(alignment, offset); + + _menuEntry = OverlayEntry( + builder: (context) => SizedBox( + width: editorSize.width, + height: editorSize.height, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + Positioned( + top: top, + bottom: bottom, + left: left, + right: right, + child: MobileInlineActionsHandler( + service: service, + results: initialResults, + editorState: editorState, + menuService: this, + onDismiss: dismiss, + style: style, + startCharAmount: startCharAmount, + cancelBySpaceHandler: cancelBySpaceHandler, + startOffset: editorState.selection?.start.offset ?? 0, + ), + ), + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + } + + (double? left, double? top, double? right, double? bottom) _getPosition( + Alignment alignment, + Offset offset, + ) { + double? left, top, right, bottom; + switch (alignment) { + case Alignment.topLeft: + left = 0; + top = offset.dy; + break; + case Alignment.bottomLeft: + left = 0; + 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); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart new file mode 100644 index 0000000000..f340319254 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart @@ -0,0 +1,152 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.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 MobileInlineActionsGroup extends StatelessWidget { + const MobileInlineActionsGroup({ + super.key, + required this.result, + required this.editorState, + required this.menuService, + required this.style, + required this.onSelected, + required this.startOffset, + required this.endOffset, + required this.onPreSelect, + 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 ValueChanged onPreSelect; + final int startOffset; + final int endOffset; + + final bool isLastGroup; + final bool isGroupSelected; + final int selectedIndex; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (result.title != null) ...[ + SizedBox( + height: 36, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Align( + alignment: Alignment.centerLeft, + child: FlowyText.medium( + result.title!, + color: style.groupTextColor, + fontSize: 12, + ), + ), + ), + ), + ], + ...result.results.mapIndexed( + (index, item) => GestureDetector( + onTapDown: (e) { + onPreSelect.call(index); + }, + child: MobileInlineActionsWidget( + item: item, + editorState: editorState, + menuService: menuService, + isSelected: isGroupSelected && index == selectedIndex, + style: style, + onSelected: onSelected, + startOffset: startOffset, + endOffset: endOffset, + ), + ), + ), + ], + ); + } +} + +class MobileInlineActionsWidget extends StatelessWidget { + const MobileInlineActionsWidget({ + 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 + Widget build(BuildContext context) { + final hasIcon = item.iconBuilder != null; + return Container( + height: 36, + decoration: BoxDecoration( + color: isSelected ? style.menuItemSelectedColor : null, + borderRadius: BorderRadius.circular(6.0), + ), + child: FlowyButton( + expand: true, + isSelected: isSelected, + text: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Align( + alignment: Alignment.centerLeft, + child: Row( + children: [ + if (hasIcon) ...[ + item.iconBuilder!.call(isSelected), + SizedBox(width: 12), + ], + Flexible( + child: FlowyText.regular( + item.label, + figmaLineHeight: 18, + overflow: TextOverflow.ellipsis, + fontSize: 16, + color: style.menuItemSelectedTextColor, + ), + ), + ], + ), + ), + ), + onTap: () => _onPressed(context), + ), + ); + } + + void _onPressed(BuildContext context) { + onSelected(); + item.onSelected?.call( + context, + editorState, + menuService, + (startOffset, endOffset), + ); + } +} 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 index 101a546294..3c6adb8627 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart @@ -1,10 +1,77 @@ +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 StatelessWidget { +class MobileBottomNavigationBar extends StatefulWidget { /// Constructs an [MobileBottomNavigationBar]. const MobileBottomNavigationBar({ required this.navigationShell, @@ -14,62 +81,184 @@ class MobileBottomNavigationBar extends StatelessWidget { /// 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) { - final style = Theme.of(context); + _bottomNavigationBar = switch (bottomNavigationBarType.value) { + BottomNavigationBarActionType.home => + _buildHomePageNavigationBar(context), + BottomNavigationBarActionType.notificationMultiSelect => + _buildNotificationNavigationBar(context), + }; return Scaffold( - body: navigationShell, - bottomNavigationBar: BottomNavigationBar( - showSelectedLabels: false, - showUnselectedLabels: false, - enableFeedback: true, - type: BottomNavigationBarType.fixed, - items: [ - BottomNavigationBarItem( - // There is no text shown on the bottom navigation bar, but Exception will be thrown if label is null here. - label: 'home', - icon: const FlowySvg(FlowySvgs.m_home_unselected_lg), - activeIcon: FlowySvg( - FlowySvgs.m_home_selected_lg, - color: style.colorScheme.primary, - ), - ), - const BottomNavigationBarItem( - label: 'favorite', - icon: FlowySvg(FlowySvgs.m_favorite_unselected_lg), - activeIcon: FlowySvg( - FlowySvgs.m_favorite_selected_lg, - blendMode: null, - ), - ), - // Enable this when search is ready. - // BottomNavigationBarItem( - // label: 'search', - // icon: const FlowySvg(FlowySvgs.m_search_lg), - // activeIcon: FlowySvg( - // FlowySvgs.m_search_lg, - // color: style.colorScheme.primary, - // ), - // ), - BottomNavigationBarItem( - label: 'notification', - icon: const FlowySvg(FlowySvgs.m_notification_unselected_lg), - activeIcon: FlowySvg( - FlowySvgs.m_notification_selected_lg, - color: style.colorScheme.primary, - ), - ), - ], - currentIndex: navigationShell.currentIndex, - onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex), + 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. @@ -83,3 +272,110 @@ class MobileBottomNavigationBar extends StatelessWidget { ); } } + +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( + 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( + 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.withValues(alpha: 0.95) + : const Color(0xFF23262B).withValues(alpha: 0.95); + } + + Color get borderColor { + return Theme.of(this).isLightMode + ? const Color(0x141F2329) + : const Color(0xFF23262B).withValues(alpha: 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 0000000000..cf7ce35e80 --- /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 index 64e3e8824d..33c2eb3905 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart @@ -28,8 +28,8 @@ class MobileNotificationsScreen extends StatefulWidget { class _MobileNotificationsScreenState extends State with SingleTickerProviderStateMixin { - final ReminderBloc _reminderBloc = getIt(); - late final TabController _controller = TabController(length: 2, vsync: this); + final ReminderBloc reminderBloc = getIt(); + late final TabController controller = TabController(length: 2, vsync: this); @override Widget build(BuildContext context) { @@ -39,7 +39,7 @@ class _MobileNotificationsScreenState extends State create: (context) => UserProfileBloc()..add(const UserProfileEvent.started()), ), - BlocProvider.value(value: _reminderBloc), + BlocProvider.value(value: reminderBloc), BlocProvider( create: (_) => NotificationFilterBloc(), ), @@ -50,12 +50,12 @@ class _MobileNotificationsScreenState extends State orElse: () => const Center(child: CircularProgressIndicator.adaptive()), workspaceFailure: () => const WorkspaceFailedScreen(), - success: (workspaceSetting, userProfile) => + success: (workspaceLatest, userProfile) => _NotificationScreenContent( - workspaceSetting: workspaceSetting, + workspaceLatest: workspaceLatest, userProfile: userProfile, - controller: _controller, - reminderBloc: _reminderBloc, + controller: controller, + reminderBloc: reminderBloc, ), ); }, @@ -66,13 +66,13 @@ class _MobileNotificationsScreenState extends State class _NotificationScreenContent extends StatelessWidget { const _NotificationScreenContent({ - required this.workspaceSetting, + required this.workspaceLatest, required this.userProfile, required this.controller, required this.reminderBloc, }); - final WorkspaceSettingPB workspaceSetting; + final WorkspaceLatestPB workspaceLatest; final UserProfilePB userProfile; final TabController controller; final ReminderBloc reminderBloc; @@ -84,7 +84,7 @@ class _NotificationScreenContent extends StatelessWidget { ..add( SidebarSectionsEvent.initial( userProfile, - workspaceSetting.workspaceId, + workspaceLatest.workspaceId, ), ), child: BlocBuilder( @@ -124,7 +124,6 @@ class _NotificationScreenContent extends StatelessWidget { reminderBloc: reminderBloc, views: sectionState.section.publicViews, onAction: _onAction, - onDelete: _onDelete, onReadChanged: _onReadChanged, actionBar: InboxActionBar( hasUnreads: state.hasUnreads, @@ -161,9 +160,6 @@ class _NotificationScreenContent extends StatelessWidget { ), ); - void _onDelete(ReminderPB reminder) => - reminderBloc.add(ReminderEvent.remove(reminderId: reminder.id)); - 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 0000000000..54a0b4e782 --- /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 0000000000..e11e91ada5 --- /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).withValues(alpha: 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 0000000000..e5598cc6e5 --- /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 0000000000..9f1311d1e7 --- /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 index 471b456bc0..59bfe61822 100644 --- 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 @@ -21,6 +21,12 @@ class _MobileNotificationTabBarState extends State { widget.controller.addListener(_updateState); } + @override + void dispose() { + widget.controller.removeListener(_updateState); + super.dispose(); + } + void _updateState() => setState(() {}); @override 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 0000000000..ea57d5d391 --- /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 0000000000..e5fe288755 --- /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 0000000000..e694f9932d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart @@ -0,0 +1,167 @@ +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( + message: LocaleKeys + .settings_notifications_markAsReadNotifications_allSuccess + .tr(), + ); + + context.read().add(const ReminderEvent.markAllRead()); + } + + void _onArchiveAll(BuildContext context) { + showToastNotification( + message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess + .tr(), + ); + + context.read().add(const ReminderEvent.archiveAll()); + } + + void _onUnarchiveAll(BuildContext context) { + if (!kDebugMode) { + return; + } + + showToastNotification( + 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 0000000000..70cc8c5214 --- /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 0000000000..d1216eed98 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart @@ -0,0 +1,208 @@ +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( + 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( + 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( + 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( + 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 0000000000..45e801e07c --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart @@ -0,0 +1,136 @@ +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( + 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 0000000000..35fb6ea067 --- /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 0000000000..92cd83a74e --- /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 index 5aebb53100..b3d021613e 100644 --- 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 @@ -9,6 +9,7 @@ class MobileSlideActionButton extends StatelessWidget { required this.svg, this.size = 32.0, this.backgroundColor = Colors.transparent, + this.borderRadius = BorderRadius.zero, required this.onPressed, }); @@ -16,15 +17,18 @@ class MobileSlideActionButton extends StatelessWidget { 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), 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 index 34fd517613..6e2611a684 100644 --- 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 @@ -1,31 +1,29 @@ -import 'package:flutter/material.dart'; +import 'dart:io'; -import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/generated/flowy_svgs.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/page_item/mobile_view_item_add_button.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_text.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: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'; typedef ViewItemOnSelected = void Function(ViewPB); typedef ActionPaneBuilder = ActionPane Function(BuildContext context); -const _itemHeight = 48.0; - class MobileViewItem extends StatelessWidget { const MobileViewItem({ super.key, required this.view, this.parentView, - required this.categoryType, + required this.spaceType, required this.level, this.leftPadding = 10, required this.onSelected, @@ -39,7 +37,7 @@ class MobileViewItem extends StatelessWidget { final ViewPB view; final ViewPB? parentView; - final FolderCategoryType categoryType; + final FolderSpaceType spaceType; // indicate the level of the view item // used to calculate the left padding @@ -80,7 +78,7 @@ class MobileViewItem extends StatelessWidget { view: state.view, parentView: parentView, childViews: state.view.childViews, - categoryType: categoryType, + spaceType: spaceType, level: level, leftPadding: leftPadding, showActions: true, @@ -104,7 +102,7 @@ class InnerMobileViewItem extends StatelessWidget { required this.view, required this.parentView, required this.childViews, - required this.categoryType, + required this.spaceType, this.isDraggable = true, this.isExpanded = true, required this.level, @@ -120,11 +118,12 @@ class InnerMobileViewItem extends StatelessWidget { final ViewPB view; final ViewPB? parentView; final List childViews; - final FolderCategoryType categoryType; + 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; @@ -144,7 +143,7 @@ class InnerMobileViewItem extends StatelessWidget { parentView: parentView, level: level, showActions: showActions, - categoryType: categoryType, + spaceType: spaceType, onSelected: onSelected, isExpanded: isExpanded, isDraggable: isDraggable, @@ -159,9 +158,9 @@ class InnerMobileViewItem extends StatelessWidget { if (childViews.isNotEmpty) { final children = childViews.map((childView) { return MobileViewItem( - key: ValueKey('${categoryType.name} ${childView.id}'), + key: ValueKey('${spaceType.name} ${childView.id}'), parentView: view, - categoryType: categoryType, + spaceType: spaceType, isFirstChild: childView.id == childViews.first.id, view: childView, level: level + 1, @@ -178,48 +177,10 @@ class InnerMobileViewItem extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ child, - const Divider( - height: 1, - ), ...children, ], ); - } else { - child = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - child, - const Divider( - height: 1, - ), - Container( - height: _itemHeight, - alignment: Alignment.centerLeft, - child: Padding( - padding: EdgeInsets.only(left: (level + 2) * leftPadding), - child: FlowyText.medium( - LocaleKeys.noPagesInside.tr(), - color: Colors.grey, - ), - ), - ), - const Divider( - height: 1, - ), - ], - ); } - } else { - child = Column( - mainAxisSize: MainAxisSize.min, - children: [ - child, - const Divider( - height: 1, - ), - ], - ); } // wrap the child with DraggableItem if isDraggable is true @@ -227,7 +188,6 @@ class InnerMobileViewItem extends StatelessWidget { child = DraggableViewItem( isFirstChild: isFirstChild, view: view, - // FIXME: use better color centerHighlightColor: Colors.blue.shade200, topHighlightColor: Colors.blue.shade200, bottomHighlightColor: Colors.blue.shade200, @@ -235,7 +195,7 @@ class InnerMobileViewItem extends StatelessWidget { return MobileViewItem( view: view, parentView: parentView, - categoryType: categoryType, + spaceType: spaceType, level: level, onSelected: onSelected, isDraggable: false, @@ -262,7 +222,7 @@ class SingleMobileInnerViewItem extends StatefulWidget { required this.level, required this.leftPadding, this.isDraggable = true, - required this.categoryType, + required this.spaceType, required this.showActions, required this.onSelected, required this.isFeedback, @@ -273,6 +233,7 @@ class SingleMobileInnerViewItem extends StatefulWidget { final ViewPB view; final ViewPB? parentView; final bool isExpanded; + // identify if the view item is rendered as feedback widget inside DraggableItem final bool isFeedback; @@ -282,7 +243,7 @@ class SingleMobileInnerViewItem extends StatefulWidget { final bool isDraggable; final bool showActions; final ViewItemOnSelected onSelected; - final FolderCategoryType categoryType; + final FolderSpaceType spaceType; final ActionPaneBuilder? startActionPane; final ActionPaneBuilder? endActionPane; @@ -297,35 +258,25 @@ class _SingleMobileInnerViewItemState extends State { final children = [ // expand icon _buildLeftIcon(), - const HSpace(4), // icon _buildViewIcon(), const HSpace(8), // title Expanded( - child: FlowyText.medium( - widget.view.name, - fontSize: 18.0, + child: FlowyText.regular( + widget.view.nameOrDefault, + fontSize: 16.0, + figmaLineHeight: 20.0, overflow: TextOverflow.ellipsis, ), ), ]; - // hover action - - // ··· more action button - // children.add(_buildViewMoreActionButton(context)); - // only support add button for document layout - if (!widget.isFeedback && widget.view.layout == ViewLayoutPB.Document) { - // + button - children.add(_buildViewAddButton(context)); - } - Widget child = InkWell( borderRadius: BorderRadius.circular(4.0), onTap: () => widget.onSelected(widget.view), child: SizedBox( - height: _itemHeight, + height: HomeSpaceViewSizes.mViewHeight, child: Padding( padding: EdgeInsets.only(left: widget.level * widget.leftPadding), child: Row( @@ -349,33 +300,39 @@ class _SingleMobileInnerViewItemState extends State { } Widget _buildViewIcon() { - final icon = widget.view.icon.value.isNotEmpty - ? EmojiText( - emoji: widget.view.icon.value, - fontSize: 24.0, + final iconData = widget.view.icon.toEmojiIconData(); + final icon = iconData.isNotEmpty + ? EmojiIconWidget( + emoji: widget.view.icon.toEmojiIconData(), + emojiSize: Platform.isAndroid ? 16.0 : 18.0, ) - : SizedBox.square( - dimension: 26.0, - child: widget.view.defaultIcon(), + : Opacity( + opacity: 0.7, + child: widget.view.defaultIcon(size: const Size.square(18)), ); - return icon; + 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() { - if (isReferencedDatabaseView(widget.view, widget.parentView)) { - return const _DotIconWidget(); + const rightPadding = 6.0; + if (context.read().state.view.childViews.isEmpty) { + return HSpace(widget.leftPadding + rightPadding); } return GestureDetector( - child: AnimatedRotation( - duration: const Duration(milliseconds: 250), - turns: widget.isExpanded ? 0 : -0.25, - child: const Icon( - Icons.keyboard_arrow_down_rounded, - size: 28, + 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: () { @@ -385,60 +342,6 @@ class _SingleMobileInnerViewItemState extends State { }, ); } - - // + button - Widget _buildViewAddButton(BuildContext context) { - return MobileViewAddButton( - onPressed: () { - final title = widget.view.name; - showMobileBottomSheet( - context, - showHeader: true, - title: title, - showDragHandle: true, - showCloseButton: true, - useRootNavigator: true, - builder: (sheetContext) { - return AddNewPageWidgetBottomSheet( - view: widget.view, - onAction: (layout) { - Navigator.of(sheetContext).pop(); - context.read().add( - ViewEvent.createView( - LocaleKeys.menuAppHeader_defaultNewPageName.tr(), - layout, - section: - widget.categoryType != FolderCategoryType.favorite - ? widget.categoryType.toViewSectionPB - : null, - ), - ); - }, - ); - }, - ); - }, - ); - } -} - -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. 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 index eb2f8ea9f8..77bb57773f 100644 --- 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 @@ -1,9 +1,8 @@ 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'; -const _iconSize = 32.0; - class MobileViewAddButton extends StatelessWidget { const MobileViewAddButton({ super.key, @@ -15,12 +14,31 @@ class MobileViewAddButton extends StatelessWidget { @override Widget build(BuildContext context) { return FlowyIconButton( - iconPadding: const EdgeInsets.all(2), - width: _iconSize, - height: _iconSize, + width: HomeSpaceViewSizes.mViewButtonDimension, + height: HomeSpaceViewSizes.mViewButtonDimension, icon: const FlowySvg( - FlowySvgs.add_s, - size: Size.square(_iconSize), + 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/selection_menu/mobile_selection_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart new file mode 100644 index 0000000000..f69360575a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart @@ -0,0 +1,296 @@ +import 'dart:async'; + +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_selection_menu_item_widget.dart'; +import 'mobile_selection_menu_widget.dart'; + +class MobileSelectionMenu extends SelectionMenuService { + MobileSelectionMenu({ + required this.context, + required this.editorState, + required this.selectionMenuItems, + this.deleteSlashByDefault = false, + this.deleteKeywordsByDefault = false, + this.style = MobileSelectionMenuStyle.light, + this.itemCountFilter = 0, + this.startOffset = 0, + this.singleColumn = false, + }); + + final BuildContext context; + final EditorState editorState; + final List selectionMenuItems; + final bool deleteSlashByDefault; + final bool deleteKeywordsByDefault; + final bool singleColumn; + + @override + final MobileSelectionMenuStyle style; + + OverlayEntry? _selectionMenuEntry; + Offset _offset = Offset.zero; + Alignment _alignment = Alignment.topLeft; + final int itemCountFilter; + final int startOffset; + ValueNotifier<_Position> _positionNotifier = ValueNotifier(_Position.zero); + + @override + void dismiss() { + if (_selectionMenuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + editorState + .removeScrollViewScrolledListener(_checkPositionAfterScrolling); + _positionNotifier.dispose(); + } + + _selectionMenuEntry?.remove(); + _selectionMenuEntry = null; + } + + @override + Future show() async { + final completer = Completer(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _show(); + editorState.addScrollViewScrolledListener(_checkPositionAfterScrolling); + completer.complete(); + }); + return completer.future; + } + + void _show() { + final position = _getCurrentPosition(); + if (position == null) return; + + final editorHeight = editorState.renderBox!.size.height; + final editorWidth = editorState.renderBox!.size.width; + + _positionNotifier = ValueNotifier(position); + final showAtTop = position.top != null; + _selectionMenuEntry = OverlayEntry( + builder: (context) { + return SizedBox( + width: editorWidth, + height: editorHeight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + ValueListenableBuilder( + valueListenable: _positionNotifier, + builder: (context, value, _) { + return Positioned( + top: value.top, + bottom: value.bottom, + left: value.left, + right: value.right, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: MobileSelectionMenuWidget( + selectionMenuStyle: style, + singleColumn: singleColumn, + showAtTop: showAtTop, + items: selectionMenuItems + ..forEach((element) { + if (element is MobileSelectionMenuItem) { + element.deleteSlash = false; + element.deleteKeywords = + deleteKeywordsByDefault; + for (final e in element.children) { + e.deleteSlash = deleteSlashByDefault; + e.deleteKeywords = deleteKeywordsByDefault; + e.onSelected = () { + dismiss(); + }; + } + } else { + element.deleteSlash = deleteSlashByDefault; + element.deleteKeywords = + deleteKeywordsByDefault; + element.onSelected = () { + dismiss(); + }; + } + }), + maxItemInRow: 5, + editorState: editorState, + itemCountFilter: itemCountFilter, + startOffset: startOffset, + menuService: this, + onExit: () { + dismiss(); + }, + deleteSlashByDefault: deleteSlashByDefault, + ), + ), + ); + }, + ), + ], + ), + ), + ); + }, + ); + + Overlay.of(context, rootOverlay: true).insert(_selectionMenuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + } + + /// the workaround for: editor auto scrolling that will cause wrong position + /// of slash menu + void _checkPositionAfterScrolling() { + final position = _getCurrentPosition(); + if (position == null) return; + if (position == _positionNotifier.value) { + Future.delayed(const Duration(milliseconds: 100)).then((_) { + final position = _getCurrentPosition(); + if (position == null) return; + if (position != _positionNotifier.value) { + _positionNotifier.value = position; + } + }); + } else { + _positionNotifier.value = position; + } + } + + _Position? _getCurrentPosition() { + final selectionRects = editorState.selectionRects(); + if (selectionRects.isEmpty) { + return null; + } + final screenSize = MediaQuery.of(context).size; + calculateSelectionMenuOffset(selectionRects.first, screenSize); + final (left, top, right, bottom) = getPosition(); + return _Position(left, top, right, bottom); + } + + @override + Alignment get alignment { + return _alignment; + } + + @override + Offset get offset { + return _offset; + } + + @override + (double? left, double? top, double? right, double? bottom) getPosition() { + 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); + } + + void calculateSelectionMenuOffset(Rect rect, Size screenSize) { + // 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 = 192.0, menuWidth = 240.0; + final editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorHeight = editorState.renderBox!.size.height; + final screenHeight = screenSize.height; + final editorWidth = editorState.renderBox!.size.width; + final rectHeight = rect.height; + + // show below default + _alignment = Alignment.bottomRight; + final bottomRight = rect.topLeft; + final offset = bottomRight; + final limitX = editorWidth + editorOffset.dx - menuWidth, + limitY = screenHeight - + editorHeight + + editorOffset.dy - + menuHeight - + rectHeight; + _offset = Offset( + editorWidth - offset.dx - menuWidth, + screenHeight - offset.dy - menuHeight - rectHeight, + ); + + if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) { + /// show above + if (offset.dy > menuHeight) { + _offset = Offset( + _offset.dx, + offset.dy - menuHeight, + ); + _alignment = Alignment.topRight; + } else { + _offset = Offset( + _offset.dx, + limitY, + ); + } + } + + if (offset.dx + menuWidth >= editorOffset.dx + editorWidth) { + /// show left + if (offset.dx > menuWidth) { + _alignment = _alignment == Alignment.bottomRight + ? Alignment.bottomLeft + : Alignment.topLeft; + _offset = Offset( + offset.dx - menuWidth, + _offset.dy, + ); + } else { + _offset = Offset( + limitX, + _offset.dy, + ); + } + } + } +} + +class _Position { + const _Position(this.left, this.top, this.right, this.bottom); + + final double? left; + final double? top; + final double? right; + final double? bottom; + + static const _Position zero = _Position(0, 0, 0, 0); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _Position && + runtimeType == other.runtimeType && + left == other.left && + top == other.top && + right == other.right && + bottom == other.bottom; + + @override + int get hashCode => + left.hashCode ^ top.hashCode ^ right.hashCode ^ bottom.hashCode; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item.dart new file mode 100644 index 0000000000..22e202816e --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item.dart @@ -0,0 +1,18 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +class MobileSelectionMenuItem extends SelectionMenuItem { + MobileSelectionMenuItem({ + required super.getName, + required super.icon, + super.keywords = const [], + required super.handler, + this.children = const [], + super.nameBuilder, + super.deleteKeywords, + super.deleteSlash, + }); + + final List children; + + bool get isNotEmpty => children.isNotEmpty; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart new file mode 100644 index 0000000000..bdee8f1857 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart @@ -0,0 +1,138 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_selection_menu_item.dart'; + +class MobileSelectionMenuItemWidget extends StatelessWidget { + const MobileSelectionMenuItemWidget({ + super.key, + required this.editorState, + required this.menuService, + required this.item, + required this.isSelected, + required this.selectionMenuStyle, + required this.onTap, + }); + + final EditorState editorState; + final SelectionMenuService menuService; + final SelectionMenuItem item; + final bool isSelected; + final MobileSelectionMenuStyle selectionMenuStyle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final style = selectionMenuStyle; + final showRightArrow = item is MobileSelectionMenuItem && + (item as MobileSelectionMenuItem).isNotEmpty; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: TextButton.icon( + icon: item.icon( + editorState, + false, + selectionMenuStyle, + ), + style: ButtonStyle( + alignment: Alignment.centerLeft, + overlayColor: WidgetStateProperty.all(Colors.transparent), + backgroundColor: isSelected + ? WidgetStateProperty.all( + style.selectionMenuItemSelectedColor, + ) + : WidgetStateProperty.all(Colors.transparent), + ), + label: Row( + children: [ + item.nameBuilder?.call(item.name, style, false) ?? + Text( + item.name, + textAlign: TextAlign.left, + style: TextStyle( + color: style.selectionMenuItemTextColor, + fontSize: 16.0, + ), + ), + if (showRightArrow) ...[ + Spacer(), + Icon( + Icons.keyboard_arrow_right_rounded, + color: style.selectionMenuItemRightIconColor, + ), + ], + ], + ), + onPressed: () { + onTap.call(); + item.handler( + editorState, + menuService, + context, + ); + }, + ), + ); + } +} + +class MobileSelectionMenuStyle extends SelectionMenuStyle { + const MobileSelectionMenuStyle({ + required super.selectionMenuBackgroundColor, + required super.selectionMenuItemTextColor, + required super.selectionMenuItemIconColor, + required super.selectionMenuItemSelectedTextColor, + required super.selectionMenuItemSelectedIconColor, + required super.selectionMenuItemSelectedColor, + required super.selectionMenuUnselectedLabelColor, + required super.selectionMenuDividerColor, + required super.selectionMenuLinkBorderColor, + required super.selectionMenuInvalidLinkColor, + required super.selectionMenuButtonColor, + required super.selectionMenuButtonTextColor, + required super.selectionMenuButtonIconColor, + required super.selectionMenuButtonBorderColor, + required super.selectionMenuTabIndicatorColor, + required this.selectionMenuItemRightIconColor, + }); + + final Color selectionMenuItemRightIconColor; + + static const MobileSelectionMenuStyle light = MobileSelectionMenuStyle( + selectionMenuBackgroundColor: Color(0xFFFFFFFF), + selectionMenuItemTextColor: Color(0xFF1F2225), + selectionMenuItemIconColor: Color(0xFF333333), + selectionMenuItemSelectedColor: Color(0xFFF2F5F7), + selectionMenuItemRightIconColor: Color(0xB31E2022), + selectionMenuItemSelectedTextColor: Color.fromARGB(255, 56, 91, 247), + selectionMenuItemSelectedIconColor: Color.fromARGB(255, 56, 91, 247), + selectionMenuUnselectedLabelColor: Color(0xFF333333), + selectionMenuDividerColor: Color(0xFF00BCF0), + selectionMenuLinkBorderColor: Color(0xFF00BCF0), + selectionMenuInvalidLinkColor: Color(0xFFE53935), + selectionMenuButtonColor: Color(0xFF00BCF0), + selectionMenuButtonTextColor: Color(0xFF333333), + selectionMenuButtonIconColor: Color(0xFF333333), + selectionMenuButtonBorderColor: Color(0xFF00BCF0), + selectionMenuTabIndicatorColor: Color(0xFF00BCF0), + ); + + static const MobileSelectionMenuStyle dark = MobileSelectionMenuStyle( + selectionMenuBackgroundColor: Color(0xFF424242), + selectionMenuItemTextColor: Color(0xFFFFFFFF), + selectionMenuItemIconColor: Color(0xFFFFFFFF), + selectionMenuItemSelectedColor: Color(0xFF666666), + selectionMenuItemRightIconColor: Color(0xB3FFFFFF), + selectionMenuItemSelectedTextColor: Color(0xFF131720), + selectionMenuItemSelectedIconColor: Color(0xFF131720), + selectionMenuUnselectedLabelColor: Color(0xFFBBC3CD), + selectionMenuDividerColor: Color(0xFF3A3F44), + selectionMenuLinkBorderColor: Color(0xFF3A3F44), + selectionMenuInvalidLinkColor: Color(0xFFE53935), + selectionMenuButtonColor: Color(0xFF00BCF0), + selectionMenuButtonTextColor: Color(0xFFFFFFFF), + selectionMenuButtonIconColor: Color(0xFFFFFFFF), + selectionMenuButtonBorderColor: Color(0xFF00BCF0), + selectionMenuTabIndicatorColor: Color(0xFF00BCF0), + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart new file mode 100644 index 0000000000..d96dd224e1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart @@ -0,0 +1,392 @@ +import 'dart:math'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_selection_menu_item.dart'; +import 'mobile_selection_menu_item_widget.dart'; +import 'slash_keyboard_service_interceptor.dart'; + +class MobileSelectionMenuWidget extends StatefulWidget { + const MobileSelectionMenuWidget({ + super.key, + required this.items, + required this.itemCountFilter, + required this.maxItemInRow, + required this.menuService, + required this.editorState, + required this.onExit, + required this.selectionMenuStyle, + required this.deleteSlashByDefault, + required this.singleColumn, + required this.startOffset, + required this.showAtTop, + this.nameBuilder, + }); + + final List items; + final int itemCountFilter; + final int maxItemInRow; + + final SelectionMenuService menuService; + final EditorState editorState; + + final VoidCallback onExit; + + final MobileSelectionMenuStyle selectionMenuStyle; + + final bool deleteSlashByDefault; + final bool singleColumn; + final bool showAtTop; + final int startOffset; + + final SelectionMenuItemNameBuilder? nameBuilder; + + @override + State createState() => + _MobileSelectionMenuWidgetState(); +} + +class _MobileSelectionMenuWidgetState extends State { + final _focusNode = FocusNode(debugLabel: 'popup_list_widget'); + + List _showingItems = []; + + int _searchCounter = 0; + + EditorState get editorState => widget.editorState; + + SelectionMenuService get menuService => widget.menuService; + + String _keyword = ''; + + String get keyword => _keyword; + + int selectedIndex = 0; + + late AppFlowyKeyboardServiceInterceptor keyboardInterceptor; + + List get filterItems { + final List items = []; + for (final item in widget.items) { + if (item is MobileSelectionMenuItem) { + for (final childItem in item.children) { + items.add(childItem); + } + } else { + items.add(item); + } + } + return items; + } + + 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 = newKeyword.isEmpty + ? widget.items + : filterItems + .where( + (item) => item.allKeywords.any((keyword) { + final value = keyword.contains(newKeyword.toLowerCase()); + if (value) { + maxKeywordLength = max(maxKeywordLength, keyword.length); + } + return value; + }), + ) + .toList(growable: false); + + AppFlowyEditorLog.ui.debug('$items'); + + if (keyword.length >= maxKeywordLength + 2 && + !(widget.deleteSlashByDefault && _searchCounter < 2)) { + return widget.onExit(); + } + + _showingItems = items; + refreshSelectedIndex(); + + if (_showingItems.isEmpty) { + _searchCounter++; + } else { + _searchCounter = 0; + } + } + + @override + void initState() { + super.initState(); + _showingItems = buildInitialItems(); + + keepEditorFocusNotifier.increase(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + + keyboardInterceptor = SlashKeyboardServiceInterceptor( + onDelete: () async { + if (!mounted) return false; + final hasItemsChanged = !isInitialItems(); + if (keyword.isEmpty && hasItemsChanged) { + _showingItems = buildInitialItems(); + refreshSelectedIndex(); + return true; + } + return false; + }, + onEnter: () { + if (!mounted) return; + if (_showingItems.isEmpty) return; + final item = _showingItems[selectedIndex]; + if (item is MobileSelectionMenuItem) { + selectedIndex = 0; + item.onSelected?.call(); + } else { + item.handler( + editorState, + menuService, + context, + ); + } + }, + ); + editorState.service.keyboardService + ?.registerInterceptor(keyboardInterceptor); + editorState.selectionNotifier.addListener(onSelectionChanged); + } + + @override + void dispose() { + editorState.service.keyboardService + ?.unregisterInterceptor(keyboardInterceptor); + editorState.selectionNotifier.removeListener(onSelectionChanged); + _focusNode.dispose(); + keepEditorFocusNotifier.decrease(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 192, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showAtTop) Spacer(), + Focus( + focusNode: _focusNode, + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.selectionMenuStyle.selectionMenuBackgroundColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + ], + borderRadius: BorderRadius.circular(6.0), + ), + child: _showingItems.isEmpty + ? _buildNoResultsWidget(context) + : _buildResultsWidget( + context, + _showingItems, + widget.itemCountFilter, + ), + ), + ), + if (!widget.showAtTop) Spacer(), + ], + ), + ); + } + + void onSelectionChanged() { + final selection = editorState.selection; + if (selection == null) { + widget.onExit(); + return; + } + if (!selection.isCollapsed) { + widget.onExit(); + return; + } + final startOffset = widget.startOffset; + final endOffset = selection.end.offset; + if (endOffset < startOffset) { + widget.onExit(); + return; + } + final node = editorState.getNodeAtPath(selection.start.path); + final text = node?.delta?.toPlainText() ?? ''; + final search = text.substring(startOffset, endOffset); + keyword = search; + } + + Widget _buildResultsWidget( + BuildContext buildContext, + List items, + int itemCountFilter, + ) { + if (widget.singleColumn) { + final List itemWidgets = []; + for (var i = 0; i < items.length; i++) { + final item = items[i]; + itemWidgets.add( + GestureDetector( + onTapDown: (e) { + setState(() { + selectedIndex = i; + }); + }, + child: MobileSelectionMenuItemWidget( + item: item, + isSelected: i == selectedIndex, + editorState: editorState, + menuService: menuService, + selectionMenuStyle: widget.selectionMenuStyle, + onTap: () { + if (item is MobileSelectionMenuItem) refreshSelectedIndex(); + }, + ), + ), + ); + } + return ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 192, + minWidth: 240, + maxWidth: 240, + ), + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + children: itemWidgets, + ), + ); + } else { + final List columns = []; + List itemWidgets = []; + // apply item count filter + if (itemCountFilter > 0) { + items = items.take(itemCountFilter).toList(); + } + + for (var i = 0; i < items.length; i++) { + final item = items[i]; + if (i != 0 && i % (widget.maxItemInRow) == 0) { + columns.add( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: itemWidgets, + ), + ); + itemWidgets = []; + } + itemWidgets.add( + MobileSelectionMenuItemWidget( + item: item, + isSelected: false, + editorState: editorState, + menuService: menuService, + selectionMenuStyle: widget.selectionMenuStyle, + onTap: () { + if (item is MobileSelectionMenuItem) refreshSelectedIndex(); + }, + ), + ); + } + if (itemWidgets.isNotEmpty) { + columns.add( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: itemWidgets, + ), + ); + itemWidgets = []; + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: columns, + ); + } + } + + void refreshSelectedIndex() { + if (!mounted) return; + setState(() { + selectedIndex = 0; + }); + } + + Widget _buildNoResultsWidget(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + ], + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 240, + height: 48, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Material( + color: Colors.transparent, + child: Center( + child: Text( + LocaleKeys.inlineActions_noResults.tr(), + style: TextStyle(fontSize: 18.0, color: Color(0x801F2225)), + textAlign: TextAlign.center, + ), + ), + ), + ), + ), + ); + } + + List buildInitialItems() { + final List items = []; + for (final item in widget.items) { + if (item is MobileSelectionMenuItem) { + item.onSelected = () { + if (mounted) { + setState(() { + _showingItems = item.children + .map((e) => e..onSelected = widget.onExit) + .toList(); + }); + } + }; + } + items.add(item); + } + return items; + } + + bool isInitialItems() { + if (_showingItems.length != widget.items.length) return false; + int i = 0; + for (final item in _showingItems) { + final widgetItem = widget.items[i]; + if (widgetItem.name != item.name) return false; + i++; + } + return true; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/slash_keyboard_service_interceptor.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/slash_keyboard_service_interceptor.dart new file mode 100644 index 0000000000..b7d0fd6e83 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/slash_keyboard_service_interceptor.dart @@ -0,0 +1,42 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/keyboard_interceptor/keyboard_interceptor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class SlashKeyboardServiceInterceptor extends EditorKeyboardInterceptor { + SlashKeyboardServiceInterceptor({ + required this.onDelete, + required this.onEnter, + }); + + final AsyncValueGetter onDelete; + final VoidCallback onEnter; + + @override + Future interceptDelete( + TextEditingDeltaDeletion deletion, + EditorState editorState, + ) async { + final intercept = await onDelete.call(); + if (intercept) { + return true; + } else { + return super.interceptDelete(deletion, editorState); + } + } + + @override + Future interceptInsert( + TextEditingDeltaInsertion insertion, + EditorState editorState, + List characterShortcutEvents, + ) async { + final text = insertion.textInserted; + if (text.contains('\n')) { + onEnter.call(); + return true; + } + return super + .interceptInsert(insertion, editorState, characterShortcutEvents); + } +} 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 index 337ce2549d..2d5a3176cd 100644 --- 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 @@ -25,14 +25,14 @@ class AboutSettingGroup extends StatelessWidget { trailing: const Icon( Icons.chevron_right, ), - onTap: () => afLaunchUrlString('https://appflowy.io/privacy/app'), + onTap: () => afLaunchUrlString('https://appflowy.com/privacy'), ), MobileSettingItem( name: LocaleKeys.settings_mobile_termsAndConditions.tr(), trailing: const Icon( Icons.chevron_right, ), - onTap: () => afLaunchUrlString('https://appflowy.io/terms/app'), + onTap: () => afLaunchUrlString('https://appflowy.com/terms'), ), if (kDebugMode) MobileSettingItem( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart new file mode 100644 index 0000000000..b43ada6e42 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart @@ -0,0 +1,104 @@ +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/setting/widgets/mobile_setting_group_widget.dart'; +import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/workspace/application/settings/ai/settings_ai_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/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class AiSettingsGroup extends StatelessWidget { + const AiSettingsGroup({ + super.key, + required this.userProfile, + required this.workspaceId, + }); + + final UserProfilePB userProfile; + final String workspaceId; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return BlocProvider( + create: (context) => SettingsAIBloc( + userProfile, + workspaceId, + )..add(const SettingsAIEvent.started()), + child: BlocBuilder( + builder: (context, state) { + return MobileSettingGroup( + groupTitle: LocaleKeys.settings_aiPage_title.tr(), + settingItemList: [ + MobileSettingItem( + name: LocaleKeys.settings_aiPage_keys_llmModelType.tr(), + trailing: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FlowyText( + state.availableModels?.selectedModel.name ?? "", + color: theme.colorScheme.onSurface, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.chevron_right), + ], + ), + ), + onTap: () => _onLLMModelTypeTap(context, state), + ), + // enable AI search if needed + // MobileSettingItem( + // name: LocaleKeys.settings_aiPage_keys_enableAISearchTitle.tr(), + // trailing: const Icon( + // Icons.chevron_right, + // ), + // onTap: () => context.push(AppFlowyCloudPage.routeName), + // ), + ], + ); + }, + ), + ); + } + + void _onLLMModelTypeTap(BuildContext context, SettingsAIState state) { + final availableModels = state.availableModels; + showMobileBottomSheet( + context, + showHeader: true, + showDragHandle: true, + showDivider: false, + title: LocaleKeys.settings_aiPage_keys_llmModelType.tr(), + builder: (_) { + return Column( + children: (availableModels?.models ?? []) + .asMap() + .entries + .map( + (entry) => FlowyOptionTile.checkbox( + text: entry.value.name, + showTopBorder: entry.key == 0, + isSelected: + availableModels?.selectedModel.name == entry.value.name, + onTap: () { + context + .read() + .add(SettingsAIEvent.selectModel(entry.value)); + context.pop(); + }, + ), + ) + .toList(), + ); + }, + ); + } +} 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 index abb31817e5..47e356e6c1 100644 --- 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 @@ -19,7 +19,7 @@ class AppearanceSettingGroup extends StatelessWidget { settingItemList: const [ ThemeSetting(), FontSetting(), - TextScaleSetting(), + DisplaySizeSetting(), 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 index 39a0fdae4c..5b8035f004 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart @@ -1,6 +1,7 @@ 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/document/application/document_appearance_cubit.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'; @@ -17,15 +18,15 @@ class RTLSetting extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final layoutDirection = - context.watch().state.layoutDirection; + final textDirection = + context.watch().state.textDirection; return MobileSettingItem( name: LocaleKeys.settings_appearance_textDirection_label.tr(), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ FlowyText( - _textDirectionLabelText(layoutDirection), + _textDirectionLabelText(textDirection), color: theme.colorScheme.onSurface, ), const Icon(Icons.chevron_right), @@ -37,33 +38,35 @@ class RTLSetting extends StatelessWidget { showHeader: true, showDragHandle: true, showDivider: false, - showCloseButton: 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); - }, + isSelected: textDirection == AppFlowyTextDirection.ltr, + onTap: () => applyTextDirectionAndPop( + context, + AppFlowyTextDirection.ltr, + ), ), FlowyOptionTile.checkbox( showTopBorder: false, text: LocaleKeys.settings_appearance_textDirection_rtl.tr(), - isSelected: layoutDirection == LayoutDirection.rtlLayout, - onTap: () { - context - .read() - .setLayoutDirection(LayoutDirection.rtlLayout); - Navigator.pop(context); - }, + isSelected: textDirection == AppFlowyTextDirection.rtl, + onTap: () => applyTextDirectionAndPop( + context, + AppFlowyTextDirection.rtl, + ), + ), + FlowyOptionTile.checkbox( + showTopBorder: false, + text: LocaleKeys.settings_appearance_textDirection_auto.tr(), + isSelected: textDirection == AppFlowyTextDirection.auto, + onTap: () => applyTextDirectionAndPop( + context, + AppFlowyTextDirection.auto, + ), ), ], ); @@ -73,13 +76,25 @@ class RTLSetting extends StatelessWidget { ); } - String _textDirectionLabelText(LayoutDirection? textDirection) { + String _textDirectionLabelText(AppFlowyTextDirection textDirection) { switch (textDirection) { - case LayoutDirection.rtlLayout: + case AppFlowyTextDirection.auto: + return LocaleKeys.settings_appearance_textDirection_auto.tr(); + case AppFlowyTextDirection.rtl: return LocaleKeys.settings_appearance_textDirection_rtl.tr(); - case LayoutDirection.ltrLayout: - default: + case AppFlowyTextDirection.ltr: return LocaleKeys.settings_appearance_textDirection_ltr.tr(); } } + + void applyTextDirectionAndPop( + BuildContext context, + AppFlowyTextDirection textDirection, + ) { + context.read().setTextDirection(textDirection); + context + .read() + .syncDefaultTextDirection(textDirection.name); + Navigator.pop(context); + } } 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 index e3526c3df0..7c89185e79 100644 --- 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 @@ -1,39 +1,55 @@ -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/startup/startup.dart'; +import 'package:appflowy/startup/tasks/app_window_size_manager.dart'; +import 'package:appflowy/workspace/presentation/home/hotkeys.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 'package:flutter/material.dart'; +import 'package:scaled_app/scaled_app.dart'; import '../setting.dart'; const int _divisions = 4; +const double _minMobileScaleFactor = 0.8; +const double _maxMobileScaleFactor = 1.2; -class TextScaleSetting extends StatelessWidget { - const TextScaleSetting({ +class DisplaySizeSetting extends StatefulWidget { + const DisplaySizeSetting({ super.key, }); + @override + State createState() => _DisplaySizeSettingState(); +} + +class _DisplaySizeSettingState extends State { + double scaleFactor = 1.0; + final windowSizeManager = WindowSizeManager(); + + @override + void initState() { + super.initState(); + windowSizeManager.getScaleFactor().then((v) { + if (v != scaleFactor && mounted) { + setState(() { + scaleFactor = v; + }); + } + }); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); - final textScaleFactor = - context.watch().state.textScaleFactor; return MobileSettingItem( - name: LocaleKeys.settings_appearance_fontScaleFactor.tr(), + name: LocaleKeys.settings_appearance_displaySize.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), + scaleFactor.toStringAsFixed(2), color: theme.colorScheme.onSurface, ), const Icon(Icons.chevron_right), @@ -45,18 +61,15 @@ class TextScaleSetting extends StatelessWidget { showHeader: true, showDragHandle: true, showDivider: false, - showCloseButton: false, - title: LocaleKeys.settings_appearance_fontScaleFactor.tr(), + title: LocaleKeys.settings_appearance_displaySize.tr(), builder: (context) { return FontSizeStepper( - value: textScaleFactor, - minimumValue: 0.8, - maximumValue: 1.0, + value: scaleFactor, + minimumValue: _minMobileScaleFactor, + maximumValue: _maxMobileScaleFactor, divisions: _divisions, - onChanged: (newTextScaleFactor) { - context - .read() - .setTextScaleFactor(newTextScaleFactor); + onChanged: (newScaleFactor) async { + await _setScale(newScaleFactor); }, ); }, @@ -64,4 +77,22 @@ class TextScaleSetting extends StatelessWidget { }, ); } + + Future _setScale(double value) 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) + // ignore: invalid_use_of_visible_for_testing_member + appflowyScaleFactor = value; + } else { + ScaledWidgetsFlutterBinding.instance.scaleFactor = (_) => value; + } + if (mounted) { + setState(() { + scaleFactor = value; + }); + } + await windowSizeManager.setScaleFactor(value); + } } 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 index 1291804af6..8893eab105 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/theme_setting.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/theme_setting.dart @@ -38,7 +38,6 @@ class ThemeSetting extends StatelessWidget { showHeader: true, showDragHandle: true, showDivider: false, - showCloseButton: false, title: LocaleKeys.settings_appearance_themeMode_label.tr(), builder: (context) { final themeMode = 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 index 3ce8e57b36..02d620e559 100644 --- 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 @@ -1,6 +1,7 @@ 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/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -16,13 +17,11 @@ class AppFlowyCloudPage extends StatelessWidget { appBar: FlowyAppBar( titleText: LocaleKeys.settings_menu_cloudSettings.tr(), ), - body: Padding( - padding: const EdgeInsets.all(20.0), - child: SettingCloud( - restartAppFlowy: () async { - await runAppFlowy(); - }, - ), + body: SettingCloud( + restartAppFlowy: () async { + await getIt().signOut(); + await runAppFlowy(); + }, ), ); } 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 index 64dd62729c..390f0824de 100644 --- 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 @@ -29,9 +29,7 @@ class FontPickerScreen extends StatelessWidget { } class LanguagePickerPage extends StatefulWidget { - const LanguagePickerPage({ - super.key, - }); + const LanguagePickerPage({super.key}); @override State createState() => _LanguagePickerPageState(); @@ -43,7 +41,6 @@ class _LanguagePickerPageState extends State { @override void initState() { super.initState(); - availableFonts = _availableFonts; } @@ -90,7 +87,6 @@ class _FontSelectorState extends State { @override void initState() { super.initState(); - availableFonts = _availableFonts; } 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 index 050bf4b594..1076b9dba6 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart @@ -29,6 +29,7 @@ class FontSetting extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ FlowyText( + lineHeight: 1.0, name, color: theme.colorScheme.onSurface, ), 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 index 6f4e65f2b4..6473485514 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/language_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/language_setting_group.dart @@ -38,6 +38,7 @@ class _LanguageSettingGroupState extends State { mainAxisSize: MainAxisSize.min, children: [ FlowyText( + lineHeight: 1.0, languageFromLocale(locale), color: theme.colorScheme.onSurface, ), 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 index cfdf3defb0..28ebdb750e 100644 --- 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 @@ -1,5 +1,3 @@ -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'; @@ -7,10 +5,10 @@ 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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../widgets/widgets.dart'; - import 'personal_info.dart'; class PersonalInfoSettingGroup extends StatelessWidget { @@ -32,7 +30,7 @@ class PersonalInfoSettingGroup extends StatelessWidget { selector: (state) => state.userProfile.name, builder: (context, userName) { return MobileSettingGroup( - groupTitle: LocaleKeys.settings_mobile_personalInfo.tr(), + groupTitle: LocaleKeys.settings_accountPage_title.tr(), settingItemList: [ MobileSettingItem( name: userName, @@ -60,7 +58,7 @@ class PersonalInfoSettingGroup extends StatelessWidget { userName: userName, onSubmitted: (value) => context .read() - .add(SettingsUserEvent.updateUserName(value)), + .add(SettingsUserEvent.updateUserName(name: 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 index ebc58290b9..dd19c2489d 100644 --- 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 @@ -6,13 +6,20 @@ import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +enum SelfHostUrlBottomSheetType { + shareDomain, + cloudURL, +} + class SelfHostUrlBottomSheet extends StatefulWidget { const SelfHostUrlBottomSheet({ super.key, required this.url, + required this.type, }); final String url; + final SelfHostUrlBottomSheetType type; @override State createState() => _SelfHostUrlBottomSheetState(); @@ -46,8 +53,10 @@ class _SelfHostUrlBottomSheetState extends State { controller: _textFieldController, keyboardType: TextInputType.text, validator: (value) { - if (value == null || value.isEmpty) { - return LocaleKeys.settings_mobile_usernameEmptyError.tr(); + if (value == null || + value.isEmpty || + validateUrl(value).isFailure) { + return LocaleKeys.settings_menu_invalidCloudURLScheme.tr(); } return null; }, @@ -74,7 +83,12 @@ class _SelfHostUrlBottomSheetState extends State { if (value.isNotEmpty) { validateUrl(value).fold( (url) async { - await useSelfHostedAppFlowyCloudWithURL(url); + switch (widget.type) { + case SelfHostUrlBottomSheetType.shareDomain: + await useBaseWebDomain(url); + case SelfHostUrlBottomSheetType.cloudURL: + 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 index 095214d6ef..da2e0c773e 100644 --- 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 @@ -3,6 +3,7 @@ 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:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'setting.dart'; @@ -17,44 +18,106 @@ class SelfHostSettingGroup extends StatefulWidget { } class _SelfHostSettingGroupState extends State { - final future = getAppFlowyCloudUrl(); + final future = Future.wait([ + getAppFlowyCloudUrl(), + getAppFlowyShareDomain(), + ]); @override Widget build(BuildContext context) { return FutureBuilder( future: future, builder: (context, snapshot) { - if (!snapshot.hasData) { + final data = snapshot.data; + if (!snapshot.hasData || data == null || data.length != 2) { return const SizedBox.shrink(); } - final url = snapshot.data ?? ''; + final url = data[0]; + final shareDomain = data[1]; return MobileSettingGroup( - groupTitle: LocaleKeys.settings_menu_cloudAppFlowySelfHost.tr(), + groupTitle: LocaleKeys.settings_menu_cloudAppFlowy.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, - ); - }, - ); - }, - ), + _buildSelfHostField(url), + _buildShareDomainField(shareDomain), ], ); }, ); } + + Widget _buildSelfHostField(String url) { + return MobileSettingItem( + title: Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyText( + LocaleKeys.settings_menu_cloudURL.tr(), + fontSize: 12.0, + color: Theme.of(context).hintColor, + ), + ), + subtitle: FlowyText( + url, + ), + trailing: const Icon( + Icons.chevron_right, + ), + 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, + type: SelfHostUrlBottomSheetType.cloudURL, + ); + }, + ); + }, + ); + } + + Widget _buildShareDomainField(String shareDomain) { + return MobileSettingItem( + title: Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyText( + LocaleKeys.settings_menu_webURL.tr(), + fontSize: 12.0, + color: Theme.of(context).hintColor, + ), + ), + subtitle: FlowyText( + shareDomain, + ), + trailing: const Icon( + Icons.chevron_right, + ), + 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: shareDomain, + type: SelfHostUrlBottomSheetType.shareDomain, + ); + }, + ); + }, + ); + } } 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 index 5222a05b8f..e5e4efef77 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart @@ -7,7 +7,8 @@ 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/home/toast.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'; @@ -74,10 +75,13 @@ class SupportSettingGroup extends StatelessWidget { actionButtonTitle: LocaleKeys.button_yes.tr(), onActionButtonPressed: () async { await getIt().clearAllCache(); + // check the workspace and space health + await WorkspaceDataManager.checkViewHealth( + dryRun: false, + ); if (context.mounted) { - showSnackBarMessage( - context, - LocaleKeys.settings_files_clearCacheSuccess.tr(), + showToastNotification( + message: LocaleKeys.settings_files_clearCacheSuccess.tr(), ); } }, 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 index 8f8fd99ecb..5ca5525099 100644 --- 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 @@ -1,10 +1,14 @@ +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'; @@ -13,48 +17,209 @@ 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: [ - if (showThirdPartyLogin) ...[ - BlocProvider( - create: (context) => getIt(), - child: BlocConsumer( - listener: (context, state) { - state.successOrFail?.fold( - (result) => runAppFlowy(), - (e) => Log.error(e), - ); - }, - builder: (context, state) { - return const ThirdPartySignInButtons(); - }, + // 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.workspaceAuthType == AuthTypePB.Server) ...[ + 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 Column( + children: [ + const ContinueWithEmailAndPassword(), + const VSpace(12.0), + const ThirdPartySignInButtons( + expanded: true, + ), + const VSpace(16.0), + ], + ); + }, + ), + ); + } + + 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(8), - ], - MobileSignInOrLogoutButton( - labelText: LocaleKeys.settings_menu_logout.tr(), - onPressed: () async { - await showFlowyMobileConfirmDialog( + const VSpace(18.0), + _buildCheckbox(), + const VSpace(18.0), + MobileLogoutButton( + text: LocaleKeys.button_deleteAccount.tr(), + textColor: Theme.of(context).colorScheme.error, + onPressed: () => deleteMyAccount( context, - content: FlowyText( - LocaleKeys.settings_menu_logoutPrompt.tr(), - ), - actionButtonTitle: LocaleKeys.button_yes.tr(), - actionButtonColor: Theme.of(context).colorScheme.error, - onActionButtonPressed: () async { - await getIt().signOut(); - await runAppFlowy(); - }, - ); - }, + 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_item_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart index 6dc45c1c40..82c86065ae 100644 --- 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 @@ -4,41 +4,29 @@ import 'package:flutter/material.dart'; class MobileSettingItem extends StatelessWidget { const MobileSettingItem({ super.key, - required this.name, + this.name, this.padding = const EdgeInsets.only(bottom: 4), this.trailing, this.leadingIcon, + this.title, this.subtitle, this.onTap, }); - final String name; + final String? name; final EdgeInsets padding; final Widget? trailing; final Widget? leadingIcon; final Widget? subtitle; final VoidCallback? onTap; + final Widget? title; @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, - ), - ), - ], - ), + title: title ?? _buildDefaultTitle(name), subtitle: subtitle, trailing: trailing, onTap: onTap, @@ -47,4 +35,22 @@ class MobileSettingItem extends StatelessWidget { ), ); } + + Widget _buildDefaultTitle(String? name) { + return Row( + children: [ + if (leadingIcon != null) ...[ + leadingIcon!, + const HSpace(8), + ], + Expanded( + child: FlowyText.medium( + name ?? '', + fontSize: 14.0, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } } 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 0000000000..62aa114ef3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart @@ -0,0 +1,353 @@ +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( + 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( + type: ToastificationType.error, + bottomPadding: keyboardHeight, + message: message, + ); + }, + ); + } else if (actionType == WorkspaceMemberActionType.invite) { + result.fold( + (s) { + showToastNotification( + 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( + type: ToastificationType.error, + message: message, + bottomPadding: keyboardHeight, + ); + }, + ); + } else if (actionType == WorkspaceMemberActionType.remove) { + result.fold( + (s) { + showToastNotification( + message: LocaleKeys + .settings_appearance_members_removeFromWorkspaceSuccess + .tr(), + bottomPadding: keyboardHeight, + ); + }, + (f) { + showToastNotification( + type: ToastificationType.error, + message: LocaleKeys + .settings_appearance_members_removeFromWorkspaceFailed + .tr(), + bottomPadding: keyboardHeight, + ); + }, + ); + } + } + + void _inviteMember(BuildContext context) { + final email = emailController.text; + if (!isEmail(email)) { + showToastNotification( + type: ToastificationType.error, + message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(), + ); + return; + } + 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 0000000000..501fd18ef7 --- /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 0000000000..9c2161a4d1 --- /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_quick_action_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart index 17b61849da..191deb1e9f 100644 --- 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 @@ -1,7 +1,6 @@ -import 'package:flutter/material.dart'; - 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({ @@ -11,7 +10,9 @@ class MobileQuickActionButton extends StatelessWidget { required this.text, this.textColor, this.iconColor, + this.iconSize, this.enable = true, + this.rightIconBuilder, }); final VoidCallback onTap; @@ -19,36 +20,39 @@ class MobileQuickActionButton extends StatelessWidget { final String text; final Color? textColor; final Color? iconColor; + final Size? iconSize; final bool enable; + final WidgetBuilder? rightIconBuilder; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), + final iconSize = this.iconSize ?? const Size.square(18); + return Opacity( + opacity: enable ? 1.0 : 0.5, child: InkWell( onTap: enable ? onTap : null, - borderRadius: BorderRadius.circular(12), overlayColor: - enable ? null : const MaterialStatePropertyAll(Colors.transparent), + enable ? null : const WidgetStatePropertyAll(Colors.transparent), splashColor: Colors.transparent, child: Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 12), + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ FlowySvg( icon, - size: const Size.square(20), - color: enable ? iconColor : Theme.of(context).disabledColor, + size: iconSize, + color: iconColor, ), - const HSpace(12), + HSpace(30 - iconSize.width), Expanded( - child: FlowyText( + child: FlowyText.regular( text, - fontSize: 15, - color: enable ? textColor : Theme.of(context).disabledColor, + fontSize: 16, + color: textColor, ), ), + if (rightIconBuilder != null) rightIconBuilder!(context), ], ), ), @@ -56,3 +60,12 @@ class MobileQuickActionButton extends StatelessWidget { ); } } + +class MobileQuickActionDivider extends StatelessWidget { + const MobileQuickActionDivider({super.key}); + + @override + Widget build(BuildContext context) { + return const Divider(height: 0.5, thickness: 0.5); + } +} 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 index 4f76003e23..f38c724a22 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart @@ -37,6 +37,7 @@ class FlowyOptionTile extends StatelessWidget { this.backgroundColor, this.fontFamily, this.height, + this.enable = true, }); factory FlowyOptionTile.text({ @@ -49,6 +50,7 @@ class FlowyOptionTile extends StatelessWidget { Widget? trailing, VoidCallback? onTap, double? height, + bool enable = true, }) { return FlowyOptionTile._( type: FlowyOptionTileType.text, @@ -61,6 +63,7 @@ class FlowyOptionTile extends StatelessWidget { leading: leftIcon, trailing: trailing, height: height, + enable: enable, ); } @@ -77,6 +80,7 @@ class FlowyOptionTile extends StatelessWidget { Widget? trailing, String? textFieldHintText, bool autofocus = false, + bool enable = true, }) { return FlowyOptionTile._( type: FlowyOptionTileType.textField, @@ -90,6 +94,7 @@ class FlowyOptionTile extends StatelessWidget { onTextChanged: onTextChanged, onTextSubmitted: onTextSubmitted, autofocus: autofocus, + enable: enable, ); } @@ -105,6 +110,7 @@ class FlowyOptionTile extends StatelessWidget { bool showBottomBorder = true, String? fontFamily, Color? backgroundColor, + bool enable = true, }) { return FlowyOptionTile._( key: key, @@ -119,6 +125,7 @@ class FlowyOptionTile extends StatelessWidget { showTopBorder: showTopBorder, showBottomBorder: showBottomBorder, leading: leftIcon, + enable: enable, trailing: isSelected ? const FlowySvg( FlowySvgs.m_blue_check_s, @@ -136,6 +143,7 @@ class FlowyOptionTile extends StatelessWidget { bool showTopBorder = true, bool showBottomBorder = true, Widget? leftIcon, + bool enable = true, }) { return FlowyOptionTile._( type: FlowyOptionTileType.toggle, @@ -146,6 +154,7 @@ class FlowyOptionTile extends StatelessWidget { showBottomBorder: showBottomBorder, leading: leftIcon, trailing: _Toggle(value: isSelected, onChanged: onValueChanged), + enable: enable, ); } @@ -181,11 +190,13 @@ class FlowyOptionTile extends StatelessWidget { final double? height; + final bool enable; + @override Widget build(BuildContext context) { final leadingWidget = _buildLeading(); - final child = FlowyOptionDecorateBox( + Widget child = FlowyOptionDecorateBox( color: backgroundColor, showTopBorder: showTopBorder, showBottomBorder: showBottomBorder, @@ -209,12 +220,21 @@ class FlowyOptionTile extends StatelessWidget { if (type == FlowyOptionTileType.checkbox || type == FlowyOptionTileType.toggle || type == FlowyOptionTileType.text) { - return GestureDetector( + child = GestureDetector( onTap: onTap, child: child, ); } + if (!enable) { + child = Opacity( + opacity: 0.5, + child: IgnorePointer( + child: child, + ), + ); + } + return child; } @@ -299,7 +319,7 @@ class _Toggle extends StatelessWidget { fit: BoxFit.fill, child: CupertinoSwitch( value: value, - activeColor: Theme.of(context).colorScheme.primary, + activeTrackColor: 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 0000000000..2058e03e16 --- /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 index 5a481eaa68..96c18f5d91 100644 --- 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 @@ -1,6 +1,8 @@ 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 { @@ -85,3 +87,49 @@ Future showFlowyMobileConfirmDialog( }, ); } + +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.withValues(alpha: 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/plugins/ai_chat/application/ai_model_switch_listener.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart new file mode 100644 index 0000000000..2cfc349bf8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart'; +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'; + +typedef OnUpdateSelectedModel = void Function(AIModelPB model); + +class AIModelSwitchListener { + AIModelSwitchListener({required this.objectId}) { + _parser = ChatNotificationParser(id: objectId, callback: _callback); + _subscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + final String objectId; + StreamSubscription? _subscription; + ChatNotificationParser? _parser; + + void start({ + OnUpdateSelectedModel? onUpdateSelectedModel, + }) { + this.onUpdateSelectedModel = onUpdateSelectedModel; + } + + OnUpdateSelectedModel? onUpdateSelectedModel; + + void _callback( + ChatNotification ty, + FlowyResult result, + ) { + result.map((r) { + switch (ty) { + case ChatNotification.DidUpdateSelectedModel: + onUpdateSelectedModel?.call(AIModelPB.fromBuffer(r)); + break; + default: + break; + } + }); + } + + Future stop() async { + await _subscription?.cancel(); + _subscription = null; + } +} 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 0000000000..47c1668a2c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart @@ -0,0 +1,212 @@ +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), + ), + ) { + _registerEventHandlers(); + _initializeStreamListener(); + _checkInitialStreamState(); + } + + final String chatId; + final Int64? questionId; + + void _registerEventHandlers() { + on<_UpdateText>((event, emit) { + emit( + state.copyWith( + text: event.text, + messageState: const MessageState.ready(), + ), + ); + }); + + on<_ReceiveError>((event, emit) { + emit(state.copyWith(messageState: MessageState.onError(event.error))); + }); + + on<_Retry>((event, emit) async { + if (questionId == null) { + Log.error("Question id is not valid: $questionId"); + return; + } + emit(state.copyWith(messageState: const MessageState.loading())); + final payload = ChatMessageIdPB( + chatId: chatId, + messageId: questionId, + ); + final result = await AIEventGetAnswerForQuestion(payload).send(); + if (!isClosed) { + result.fold( + (answer) => add(ChatAIMessageEvent.retryResult(answer.content)), + (err) { + Log.error("Failed to get answer: $err"); + add(ChatAIMessageEvent.receiveError(err.toString())); + }, + ); + } + }); + + on<_RetryResult>((event, emit) { + emit( + state.copyWith( + text: event.text, + messageState: const MessageState.ready(), + ), + ); + }); + + on<_OnAIResponseLimit>((event, emit) { + emit( + state.copyWith( + messageState: const MessageState.onAIResponseLimit(), + ), + ); + }); + + on<_OnAIImageResponseLimit>((event, emit) { + emit( + state.copyWith( + messageState: const MessageState.onAIImageResponseLimit(), + ), + ); + }); + + on<_OnAIMaxRquired>((event, emit) { + emit( + state.copyWith( + messageState: MessageState.onAIMaxRequired(event.message), + ), + ); + }); + + on<_OnLocalAIInitializing>((event, emit) { + emit( + state.copyWith( + messageState: const MessageState.onInitializingLocalAI(), + ), + ); + }); + + on<_ReceiveMetadata>((event, emit) { + Log.debug("AI Steps: ${event.metadata.progress?.step}"); + emit( + state.copyWith( + sources: event.metadata.sources, + progress: event.metadata.progress, + ), + ); + }); + } + + void _initializeStreamListener() { + if (state.stream != null) { + state.stream!.listen( + onData: (text) => _safeAdd(ChatAIMessageEvent.updateText(text)), + onError: (error) => + _safeAdd(ChatAIMessageEvent.receiveError(error.toString())), + onAIResponseLimit: () => + _safeAdd(const ChatAIMessageEvent.onAIResponseLimit()), + onAIImageResponseLimit: () => + _safeAdd(const ChatAIMessageEvent.onAIImageResponseLimit()), + onMetadata: (metadata) => + _safeAdd(ChatAIMessageEvent.receiveMetadata(metadata)), + onAIMaxRequired: (message) { + Log.info(message); + _safeAdd(ChatAIMessageEvent.onAIMaxRequired(message)); + }, + onLocalAIInitializing: () => + _safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()), + ); + } + } + + void _checkInitialStreamState() { + if (state.stream != null) { + if (state.stream!.aiLimitReached) { + add(const ChatAIMessageEvent.onAIResponseLimit()); + } else if (state.stream!.error != null) { + add(ChatAIMessageEvent.receiveError(state.stream!.error!)); + } + } + } + + void _safeAdd(ChatAIMessageEvent event) { + if (!isClosed) { + add(event); + } + } +} + +@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.onAIImageResponseLimit() = + _OnAIImageResponseLimit; + const factory ChatAIMessageEvent.onAIMaxRequired(String message) = + _OnAIMaxRquired; + const factory ChatAIMessageEvent.onLocalAIInitializing() = + _OnLocalAIInitializing; + 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.onAIImageResponseLimit() = _AIImageResponseLimit; + const factory MessageState.onAIMaxRequired(String message) = _AIMaxRequired; + const factory MessageState.onInitializingLocalAI() = _LocalAIInitializing; + 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 0000000000..602b46f97a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -0,0 +1,690 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:appflowy/ai/ai.dart'; +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/widgets.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), + selectedSourcesNotifier = ValueNotifier([]), + super(ChatState.initial()) { + _startListening(); + _dispatch(); + _loadMessages(); + _loadSetting(); + } + + final String chatId; + final String userId; + final ChatMessageListener listener; + final ValueNotifier> selectedSourcesNotifier; + 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; + bool isFetchingRelatedQuestions = false; + bool shouldFetchRelatedQuestions = false; + + @override + Future close() async { + await answerStream?.dispose(); + await listener.stop(); + final request = ViewIdPB(value: chatId); + unawaited(FolderEventCloseView(request).send()); + selectedSourcesNotifier.dispose(); + 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, + PredefinedFormat? format, + Map? metadata, + ) { + _clearErrorMessages(emit); + _clearRelatedQuestions(); + _startStreamingMessage(message, format, metadata); + lastSentMessage = null; + + isFetchingRelatedQuestions = false; + shouldFetchRelatedQuestions = + format == null || format.imageFormat.hasText; + + 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, format, model) { + _clearRelatedQuestions(); + _regenerateAnswer(id, format, model); + lastSentMessage = null; + + isFetchingRelatedQuestions = false; + shouldFetchRelatedQuestions = false; + + emit( + state.copyWith( + promptResponseState: PromptResponseState.sendingQuestion, + ), + ); + }, + didReceiveChatSettings: (settings) { + selectedSourcesNotifier.value = settings.ragIds; + }, + updateSelectedSources: (selectedSourcesIds) async { + selectedSourcesNotifier.value = [...selectedSourcesIds]; + + final payload = UpdateChatSettingsPB( + chatId: ChatId(value: chatId), + ragIds: selectedSourcesIds, + ); + await AIEventUpdateChatSettings(payload) + .send() + .onFailure(Log.error); + }, + deleteMessage: (mesesage) async { + await chatController.remove(mesesage); + }, + ); + }, + ); + } + + void _startListening() { + listener.start( + chatMessageCallback: (pb) { + if (isClosed) { + return; + } + + // 3 mean message response from AI + if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) { + temporaryMessageIDMap.putIfAbsent( + pb.messageId.toString(), + () => answerStreamMessageId, + ); + answerStreamMessageId = ''; + } + + // 1 mean message response from User + if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) { + temporaryMessageIDMap.putIfAbsent( + 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 || + !shouldFetchRelatedQuestions) { + return; + } + + final payload = ChatMessageIdPB( + chatId: chatId, + messageId: lastSentMessage!.messageId, + ); + + isFetchingRelatedQuestions = true; + await AIEventGetRelatedQuestion(payload).send().fold( + (list) { + // while fetching related questions, the user might enter a new + // question or regenerate a previous response. In such cases, don't + // display the relatedQuestions + if (!isClosed && isFetchingRelatedQuestions) { + add( + ChatEvent.didReceiveRelatedQuestions( + list.items.map((e) => e.content).toList(), + ), + ); + isFetchingRelatedQuestions = false; + } + }, + (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, + PredefinedFormat? format, + 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), + ); + if (format != null) { + payload.format = format.toPB(); + } + + // stream the question to the server + await AIEventStreamMessage(payload).send().fold( + (question) { + if (!isClosed) { + final streamAnswer = _createAnswerStreamMessage( + stream: answerStream!, + questionMessageId: question.messageId, + fakeQuestionMessageId: questionStreamMessage.id, + ); + + 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, + PredefinedFormat? format, + AIModelPB? model, + ) 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), + ); + if (format != null) { + payload.format = format.toPB(); + } + if (model != null) { + payload.model = model; + } + + await AIEventRegenerateResponse(payload).send().fold( + (success) { + if (!isClosed) { + final streamAnswer = _createAnswerStreamMessage( + stream: answerStream!, + questionMessageId: answerMessageId - 1, + ).copyWith(id: answerMessageIdString); + + add(ChatEvent.receiveMessage(streamAnswer)); + add(const ChatEvent.finishSending()); + } + }, + (err) => Log.error("Failed to send message: ${err.msg}"), + ); + } + + Message _createAnswerStreamMessage({ + required AnswerStream stream, + required Int64 questionMessageId, + String? fakeQuestionMessageId, + }) { + answerStreamMessageId = fakeQuestionMessageId == null + ? (questionMessageId + 1).toString() + : "${fakeQuestionMessageId}_ans"; + + 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 _clearErrorMessages(Emitter emit) { + final errorMessages = chatController.messages + .where( + (message) => + onetimeMessageTypeFromMeta(message.metadata) == + OnetimeShotType.error, + ) + .toList(); + + for (final message in errorMessages) { + chatController.remove(message); + } + emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages)); + } + + 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, + PredefinedFormat? format, + Map? metadata, + }) = _SendMessage; + const factory ChatEvent.finishSending() = _FinishSendMessage; + const factory ChatEvent.failedSending() = _FailSendMessage; + + // regenerate + const factory ChatEvent.regenerateAnswer( + String id, + PredefinedFormat? format, + AIModelPB? model, + ) = _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; + + const factory ChatEvent.deleteMessage(Message message) = _DeleteMessage; +} + +@freezed +class ChatState with _$ChatState { + const factory ChatState({ + required LoadChatMessageStatus loadingState, + required PromptResponseState promptResponseState, + required bool clearErrorMessages, + }) = _ChatState; + + factory ChatState.initial() => const ChatState( + loadingState: LoadChatMessageStatus.loading, + promptResponseState: PromptResponseState.ready, + clearErrorMessages: false, + ); +} + +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 0000000000..630feb379b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_edit_document_service.dart @@ -0,0 +1,107 @@ +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 { + const 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, + tableWidth: 250.0, + ); + 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 addMessagesToPage( + String documentId, + List messages, + ) async { + // Convert messages to markdown and trim the last empty newline. + final completeMessage = messages.map((m) => m.text).join('\n').trimRight(); + if (completeMessage.isEmpty) { + 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( + completeMessage, + tableWidth: 250.0, + ); + 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, + paragraphNode(), + ]; + final insertPath = rootIsEmpty || + listEquals(lastNodeOrNull.path, const [0]) && isLastLineEmpty + ? 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 0000000000..41e2a6946d --- /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/protobuf.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 0000000000..63acb8be6d --- /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 0000000000..31d58eb000 --- /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 0000000000..8718255cd9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart @@ -0,0 +1,74 @@ +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 { + await 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) async { + 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), + ); + await UserEventGetMemberInfo(payload).send().then((result) { + result.fold( + (member) { + if (!isClosed) { + 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 0000000000..4667806286 --- /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 0000000000..5bd8a35e5b --- /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 0000000000..c22559f21b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart @@ -0,0 +1,243 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; + +import 'package:appflowy/ai/service/ai_entities.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart'; + +/// A stream that receives answer events from an isolate or external process. +/// It caches events that might occur before a listener is attached. +class AnswerStream { + AnswerStream() { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + _handleEvent, + onDone: _onDoneCallback, + onError: _handleError, + ); + } + + final RawReceivePort _port = RawReceivePort(); + final StreamController _controller = StreamController.broadcast(); + late StreamSubscription _subscription; + + bool _hasStarted = false; + bool _aiLimitReached = false; + bool _aiImageLimitReached = false; + String? _error; + String _text = ""; + + // Callbacks + void Function(String text)? _onData; + void Function()? _onStart; + void Function()? _onEnd; + void Function(String error)? _onError; + void Function()? _onLocalAIInitializing; + void Function()? _onAIResponseLimit; + void Function()? _onAIImageResponseLimit; + void Function(String message)? _onAIMaxRequired; + void Function(MetadataCollection metadata)? _onMetadata; + + // Caches for events that occur before listen() is called. + final List _pendingAIMaxRequiredEvents = []; + bool _pendingLocalAINotReady = false; + + int get nativePort => _port.sendPort.nativePort; + bool get hasStarted => _hasStarted; + bool get aiLimitReached => _aiLimitReached; + bool get aiImageLimitReached => _aiImageLimitReached; + String? get error => _error; + String get text => _text; + + /// Releases the resources used by the AnswerStream. + Future dispose() async { + await _controller.close(); + await _subscription.cancel(); + _port.close(); + } + + /// Handles incoming events from the underlying stream. + void _handleEvent(String event) { + if (event.startsWith(AIStreamEventPrefix.data)) { + _hasStarted = true; + final newText = event.substring(AIStreamEventPrefix.data.length); + _text += newText; + _onData?.call(_text); + } else if (event.startsWith(AIStreamEventPrefix.error)) { + _error = event.substring(AIStreamEventPrefix.error.length); + _onError?.call(_error!); + } else if (event.startsWith(AIStreamEventPrefix.metadata)) { + final s = event.substring(AIStreamEventPrefix.metadata.length); + _onMetadata?.call(parseMetadata(s)); + } else if (event == AIStreamEventPrefix.aiResponseLimit) { + _aiLimitReached = true; + _onAIResponseLimit?.call(); + } else if (event == AIStreamEventPrefix.aiImageResponseLimit) { + _aiImageLimitReached = true; + _onAIImageResponseLimit?.call(); + } else if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) { + final msg = event.substring(AIStreamEventPrefix.aiMaxRequired.length); + if (_onAIMaxRequired != null) { + _onAIMaxRequired!(msg); + } else { + _pendingAIMaxRequiredEvents.add(msg); + } + } else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) { + if (_onLocalAIInitializing != null) { + _onLocalAIInitializing!(); + } else { + _pendingLocalAINotReady = true; + } + } + } + + void _onDoneCallback() { + _onEnd?.call(); + } + + void _handleError(dynamic error) { + _error = error.toString(); + _onError?.call(_error!); + } + + /// Registers listeners for various events. + /// + /// If certain events have already occurred (e.g. AI_MAX_REQUIRED or LOCAL_AI_NOT_READY), + /// they will be flushed immediately. + void listen({ + void Function(String text)? onData, + void Function()? onStart, + void Function()? onEnd, + void Function(String error)? onError, + void Function()? onAIResponseLimit, + void Function()? onAIImageResponseLimit, + void Function(String message)? onAIMaxRequired, + void Function(MetadataCollection metadata)? onMetadata, + void Function()? onLocalAIInitializing, + }) { + _onData = onData; + _onStart = onStart; + _onEnd = onEnd; + _onError = onError; + _onAIResponseLimit = onAIResponseLimit; + _onAIImageResponseLimit = onAIImageResponseLimit; + _onAIMaxRequired = onAIMaxRequired; + _onMetadata = onMetadata; + _onLocalAIInitializing = onLocalAIInitializing; + + // Flush pending AI_MAX_REQUIRED events. + if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) { + for (final msg in _pendingAIMaxRequiredEvents) { + _onAIMaxRequired!(msg); + } + _pendingAIMaxRequiredEvents.clear(); + } + + // Flush pending LOCAL_AI_NOT_READY event. + if (_pendingLocalAINotReady && _onLocalAIInitializing != null) { + _onLocalAIInitializing!(); + _pendingLocalAINotReady = false; + } + + _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 0000000000..7dc1b550c3 --- /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_message_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_message_bloc.dart new file mode 100644 index 0000000000..9977d1df72 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_message_bloc.dart @@ -0,0 +1,113 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/util.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_select_message_bloc.freezed.dart'; + +class ChatSelectMessageBloc + extends Bloc { + ChatSelectMessageBloc({required this.viewNotifier}) + : super(ChatSelectMessageState.initial()) { + _dispatch(); + } + + final ViewPluginNotifier viewNotifier; + + void _dispatch() { + on( + (event, emit) { + event.when( + enableStartSelectingMessages: () { + emit(state.copyWith(enabled: true)); + }, + toggleSelectingMessages: () { + if (state.isSelectingMessages) { + emit( + state.copyWith( + isSelectingMessages: false, + selectedMessages: [], + ), + ); + } else { + emit(state.copyWith(isSelectingMessages: true)); + } + }, + toggleSelectMessage: (Message message) { + if (state.selectedMessages.contains(message)) { + emit( + state.copyWith( + selectedMessages: state.selectedMessages + .where((m) => m != message) + .toList(), + ), + ); + } else { + emit( + state.copyWith( + selectedMessages: [...state.selectedMessages, message], + ), + ); + } + }, + selectAllMessages: (List messages) { + final filtered = messages.where(isAIMessage).toList(); + emit(state.copyWith(selectedMessages: filtered)); + }, + unselectAllMessages: () { + emit(state.copyWith(selectedMessages: const [])); + }, + reset: () { + emit( + state.copyWith( + isSelectingMessages: false, + selectedMessages: [], + ), + ); + }, + ); + }, + ); + } + + bool isMessageSelected(String messageId) => + state.selectedMessages.any((m) => m.id == messageId); + + bool isAIMessage(Message message) { + return message.author.id == aiResponseUserId || + message.author.id == systemUserId || + message.author.id.startsWith("streamId:"); + } +} + +@freezed +class ChatSelectMessageEvent with _$ChatSelectMessageEvent { + const factory ChatSelectMessageEvent.enableStartSelectingMessages() = + _EnableStartSelectingMessages; + const factory ChatSelectMessageEvent.toggleSelectingMessages() = + _ToggleSelectingMessages; + const factory ChatSelectMessageEvent.toggleSelectMessage(Message message) = + _ToggleSelectMessage; + const factory ChatSelectMessageEvent.selectAllMessages( + List messages, + ) = _SelectAllMessages; + const factory ChatSelectMessageEvent.unselectAllMessages() = + _UnselectAllMessages; + const factory ChatSelectMessageEvent.reset() = _Reset; +} + +@freezed +class ChatSelectMessageState with _$ChatSelectMessageState { + const factory ChatSelectMessageState({ + required bool isSelectingMessages, + required List selectedMessages, + required bool enabled, + }) = _ChatSelectMessageState; + + factory ChatSelectMessageState.initial() => const ChatSelectMessageState( + enabled: false, + isSelectingMessages: false, + selectedMessages: [], + ); +} 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 0000000000..76cbd69cdf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_sources_cubit.dart @@ -0,0 +1,420 @@ +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:collection/collection.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({ + this.hideDisabled = false, + }) : super(ChatSettingsState.initial()); + + final bool hideDisabled; + + final List selectedSourceIds = []; + final List sources = []; + final List selectedSources = []; + String filter = ''; + + void updateSelectedSources(List newSelectedSourceIds) { + selectedSourceIds.clear(); + selectedSourceIds.addAll(newSelectedSourceIds); + } + + void refreshSources(List spaceViews, ViewPB? currentSpace) async { + filter = ""; + + final newSources = await Future.wait( + spaceViews.map((view) => _recursiveBuild(view, null)), + ); + for (final source in newSources) { + _restrictSelectionIfNecessary(source.children); + } + if (currentSpace != null) { + newSources + .firstWhereOrNull((e) => e.view.id == currentSpace.id) + ?.toggleIsExpanded(); + } + + final selected = newSources + .map((source) => _buildSelectedSources(source)) + .flattened + .toList(); + + emit( + state.copyWith( + selectedSources: selected, + visibleSources: newSources, + ), + ); + + sources + ..forEach((e) => e.dispose()) + ..clear() + ..addAll(newSources.map((e) => e.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; + } + if (childView.layout != ViewLayoutPB.Document && hideDisabled) { + 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 (sources.isEmpty) { + emit(ChatSettingsState.initial()); + } else { + final selected = + selectedSources.map(_buildSearchResults).nonNulls.toList(); + final visible = + sources.map(_buildSearchResults).nonNulls.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 (sources.isEmpty) { + return; + } + for (final source in sources) { + _recursiveUpdateSelectedStatus(source); + } + _restrictSelectionIfNecessary(sources); + for (final visibleSource in state.visibleSources) { + visibleSource.dispose(); + } + final visible = sources.map(_buildSearchResults).nonNulls.toList(); + + emit( + state.copyWith( + visibleSources: visible, + ), + ); + } + + 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 { + for (final source in sources) { + final child = source.findChildBySourceId(chatSource.view.id); + if (child != null) { + child.toggleIsExpanded(); + break; + } + } + } + } + + @override + Future close() { + for (final child in sources) { + child.dispose(); + } + for (final child in selectedSources) { + child.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 0000000000..bcd3713550 --- /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 0000000000..76aba27dc0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart @@ -0,0 +1,205 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.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/home/menu/view/view_action_type.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/more_view_actions/widgets/common_view_action.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: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 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; + late final _chatMessageSelectorBloc = + ChatSelectMessageBloc(viewNotifier: notifier); + + @override + final ViewPluginNotifier notifier; + + @override + PluginWidgetBuilder get widgetBuilder => AIChatPagePluginWidgetBuilder( + viewInfoBloc: _viewInfoBloc, + chatMessageSelectorBloc: _chatMessageSelectorBloc, + 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(); + _chatMessageSelectorBloc.close(); + notifier.dispose(); + } +} + +class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder + with NavigationItem { + AIChatPagePluginWidgetBuilder({ + required this.viewInfoBloc, + required this.chatMessageSelectorBloc, + required this.notifier, + }); + + final ViewInfoBloc viewInfoBloc; + final ChatSelectMessageBloc chatMessageSelectorBloc; + 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 MultiBlocProvider( + providers: [ + BlocProvider.value(value: chatMessageSelectorBloc), + BlocProvider.value(value: viewInfoBloc), + ], + 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; + + @override + Widget? get rightBarItem => MultiBlocProvider( + providers: [ + BlocProvider.value(value: viewInfoBloc), + BlocProvider.value(value: chatMessageSelectorBloc), + ], + child: BlocBuilder( + builder: (context, state) { + if (state.isSelectingMessages) { + return const SizedBox.shrink(); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ViewFavoriteButton( + key: ValueKey('favorite_button_${notifier.view.id}'), + view: notifier.view, + ), + const HSpace(4), + MoreViewActions( + key: ValueKey(notifier.view.id), + view: notifier.view, + customActions: [ + CustomViewAction( + view: notifier.view, + disabled: !state.enabled, + leftIcon: FlowySvgs.ai_add_to_page_s, + label: LocaleKeys.moreAction_saveAsNewPage.tr(), + tooltipMessage: state.enabled + ? null + : LocaleKeys.moreAction_saveAsNewPageDisabled.tr(), + onTap: () { + chatMessageSelectorBloc.add( + const ChatSelectMessageEvent + .toggleSelectingMessages(), + ); + }, + ), + ViewAction( + type: ViewMoreActionType.divider, + view: notifier.view, + ), + ], + ), + ], + ); + }, + ), + ); +} 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 0000000000..90085354db --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -0,0 +1,491 @@ +import 'dart:io'; + +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.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:flutter/material.dart'; +import 'package:flutter/services.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/chat_bloc.dart'; +import 'application/chat_entity.dart'; +import 'application/chat_member_bloc.dart'; +import 'application/chat_select_message_bloc.dart'; +import 'application/chat_message_stream.dart'; +import 'presentation/animated_chat_list.dart'; +import 'presentation/chat_input/mobile_chat_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 != AuthTypePB.Server) { + // 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( + objectId: view.id, + predefinedFormat: PredefinedFormat( + imageFormat: ImageFormat.text, + textFormat: TextFormat.bulletList, + ), + ), + ), + 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: FocusScope( + onKeyEvent: (focusNode, event) { + if (event is! KeyUpEvent) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.escape || + event.logicalKey == LogicalKeyboardKey.keyC && + HardwareKeyboard.instance.isControlPressed) { + final chatBloc = context.read(); + if (chatBloc.state.promptResponseState != + PromptResponseState.ready) { + chatBloc.add(ChatEvent.stopStream()); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + }, + 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 BlocBuilder( + builder: (context, state) { + return switch (state.loadingState) { + LoadChatMessageStatus.ready => Column( + children: [ + ChatMessageSelectorBanner( + view: view, + allMessages: context.read().chatController.messages, + ), + Expanded( + child: Align( + alignment: Alignment.topCenter, + child: _wrapConstraints( + SelectionArea( + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(scrollbars: false), + 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, + ), + ), + ), + ), + ), + ), + ), + _wrapConstraints( + _Input(view: view), + ), + ], + ), + _ => const Center(child: CircularProgressIndicator.adaptive()), + }; + }, + ); + } + + Widget _wrapConstraints(Widget child) { + return Container( + constraints: const BoxConstraints(maxWidth: 784), + margin: UniversalPlatform.isDesktop + ? const EdgeInsets.symmetric(horizontal: 60.0) + : null, + child: child, + ); + } + + 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) { + final bloc = context.read(); + final showPredefinedFormats = bloc.state.showPredefinedFormats; + final predefinedFormat = bloc.state.predefinedFormat; + + context.read().add( + ChatEvent.sendMessage( + message: question, + format: showPredefinedFormats ? predefinedFormat : null, + ), + ); + }, + ); + } + + if (message.author.id == userProfile.id.toString() || + isOtherUserMessage(message)) { + return ChatUserMessageWidget( + user: message.author, + message: message, + ); + } + + final stream = message.metadata?["$AnswerStream"]; + final questionId = message.metadata?[messageQuestionIdKey]; + final refSourceJsonString = + message.metadata?[messageRefSourceJsonStringKey] as String?; + + return BlocSelector( + selector: (state) => state.isSelectingMessages, + builder: (context, isSelectingMessages) { + 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, + isSelectingMessages: isSelectingMessages, + onSelectedMetadata: (metadata) => + _onSelectMetadata(context, metadata), + onRegenerate: () => context + .read() + .add(ChatEvent.regenerateAnswer(message.id, null, null)), + onChangeFormat: (format) => context + .read() + .add(ChatEvent.regenerateAnswer(message.id, format, null)), + onChangeModel: (model) => context + .read() + .add(ChatEvent.regenerateAnswer(message.id, null, model)), + onStopStream: () => context.read().add( + const ChatEvent.stopStream(), + ), + ); + }, + ); + }, + ); + } + + 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) { + final aiPromptInputBloc = context.read(); + final showPredefinedFormats = + aiPromptInputBloc.state.showPredefinedFormats; + final predefinedFormat = aiPromptInputBloc.state.predefinedFormat; + bloc.add( + ChatEvent.sendMessage( + message: question, + format: showPredefinedFormats ? predefinedFormat : null, + ), + ); + }, + ); + } + + context + .read() + .add(ChatSelectMessageEvent.enableStartSelectingMessages()); + + return BlocSelector( + selector: (state) => state.isSelectingMessages, + builder: (context, isSelectingMessages) { + return ChatAnimatedListReversed( + scrollController: scrollController, + itemBuilder: itemBuilder, + bottomPadding: isSelectingMessages + ? 48.0 + DesktopAIChatSizes.messageActionBarIconSize + : 8.0, + onLoadPreviousMessages: () { + bloc.add(const ChatEvent.loadPreviousMessages()); + }, + ); + }, + ); + } + + void _onSelectMetadata( + BuildContext context, + ChatMessageRefSource metadata, + ) async { + // When the source of metatdata is appflowy, which means it is a appflowy page + if (metadata.source == "appflowy") { + final sidebarView = + await ViewBackendService.getView(metadata.id).toNullable(); + if (context.mounted) { + openPageFromMessage(context, sidebarView); + } + return; + } + + if (metadata.source == "web") { + 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"); + } + } + return; + } + } +} + +class _Input extends StatefulWidget { + const _Input({ + required this.view, + }); + + final ViewPB view; + + @override + State<_Input> createState() => _InputState(); +} + +class _InputState extends State<_Input> { + final textController = TextEditingController(); + + @override + void dispose() { + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.isSelectingMessages, + builder: (context, isSelectingMessages) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) { + return SizeTransition( + sizeFactor: animation, + axisAlignment: -1, + child: child, + ); + }, + child: isSelectingMessages + ? const SizedBox.shrink() + : Padding( + padding: AIChatUILayout.safeAreaInsets(context), + child: BlocSelector( + selector: (state) { + return state.promptResponseState == + PromptResponseState.ready; + }, + builder: (context, canSendMessage) { + final chatBloc = context.read(); + + return UniversalPlatform.isDesktop + ? DesktopPromptInput( + isStreaming: !canSendMessage, + textController: textController, + onStopStreaming: () { + chatBloc.add(const ChatEvent.stopStream()); + }, + onSubmitted: (text, format, metadata) { + chatBloc.add( + ChatEvent.sendMessage( + message: text, + format: format, + metadata: metadata, + ), + ); + }, + selectedSourcesNotifier: + chatBloc.selectedSourcesNotifier, + onUpdateSelectedSources: (ids) { + chatBloc.add( + ChatEvent.updateSelectedSources( + selectedSourcesIds: ids, + ), + ); + }, + ) + : MobileChatInput( + isStreaming: !canSendMessage, + onStopStreaming: () { + chatBloc.add(const ChatEvent.stopStream()); + }, + onSubmitted: (text, format, metadata) { + chatBloc.add( + ChatEvent.sendMessage( + message: text, + format: format, + metadata: metadata, + ), + ); + }, + selectedSourcesNotifier: + chatBloc.selectedSourcesNotifier, + onUpdateSelectedSources: (ids) { + chatBloc.add( + ChatEvent.updateSelectedSources( + selectedSourcesIds: ids, + ), + ); + }, + ); + }, + ), + ), + ); + }, + ); + } +} 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 0000000000..9b7aadf4a4 --- /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:flutter_chat_ui/src/scroll_to_bottom.dart'; +import 'package:flutter_chat_ui/src/utils/message_list_diff.dart'; +import 'package:provider/provider.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(event.index!, 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 (position == 0 && + _userHasScrolled && + widget.scrollController.offset > + widget.scrollController.position.minScrollExtent) { + _userHasScrolled = false; + } + + _listKey.currentState!.insertItem( + 0, + 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 0000000000..59b7fbd39b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart @@ -0,0 +1,135 @@ +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: DesktopAIChatSizes.avatarSize, + height: DesktopAIChatSizes.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.app_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: DesktopAIChatSizes.avatarSize, + height: DesktopAIChatSizes.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: DesktopAIChatSizes.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: DesktopAIChatSizes.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 0000000000..b79e3c52c3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart @@ -0,0 +1,151 @@ +// 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.withValues(alpha: 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.withValues(alpha: 0.6), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart new file mode 100644 index 0000000000..76d1af7134 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart @@ -0,0 +1,369 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; +import 'package:appflowy/util/theme_extension.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'; + +class MobileChatInput extends StatefulWidget { + const MobileChatInput({ + super.key, + required this.isStreaming, + required this.onStopStreaming, + required this.onSubmitted, + required this.selectedSourcesNotifier, + required this.onUpdateSelectedSources, + }); + + final bool isStreaming; + final void Function() onStopStreaming; + final ValueNotifier> selectedSourcesNotifier; + final void Function(String, PredefinedFormat?, Map) + onSubmitted; + final void Function(List) onUpdateSelectedSources; + + @override + State createState() => _MobileChatInputState(); +} + +class _MobileChatInputState extends State { + final inputControlCubit = ChatInputControlCubit(); + final focusNode = FocusNode(); + final textController = TextEditingController(); + + late SendButtonState sendButtonState; + + @override + void initState() { + super.initState(); + + textController.addListener(handleTextControllerChanged); + // 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: + const BorderRadius.vertical(top: Radius.circular(8.0)), + ), + child: BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MobileAIPromptSizes + .attachedFilesBarPadding.vertical + + MobileAIPromptSizes.attachedFilesPreviewHeight, + ), + child: PromptInputFile( + onDeleted: (file) => context + .read() + .add(AIPromptInputEvent.removeFile(file)), + ), + ), + if (state.showPredefinedFormats) + TextFieldTapRegion( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: ChangeFormatBar( + predefinedFormat: state.predefinedFormat, + spacing: 8.0, + onSelectPredefinedFormat: (format) => + context.read().add( + AIPromptInputEvent.updatePredefinedFormat( + format, + ), + ), + ), + ), + ) + else + const VSpace(8.0), + inputTextField(context), + TextFieldTapRegion( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + const HSpace(8.0), + leadingButtons( + context, + state.showPredefinedFormats, + ), + const Spacer(), + 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(); + + final bloc = context.read(); + final showPredefinedFormats = bloc.state.showPredefinedFormats; + final predefinedFormat = bloc.state.predefinedFormat; + + widget.onSubmitted( + trimmedText, + showPredefinedFormats ? predefinedFormat : null, + metadata, + ); + } + + void handleTextControllerChanged() { + 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, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: MobileAIPromptSizes.textFieldContentPadding, + hintText: switch (state.aiType) { + AiType.cloud => LocaleKeys.chat_inputMessageHint.tr(), + AiType.local => LocaleKeys.chat_inputLocalAIMessageHint.tr() + }, + hintStyle: inputHintTextStyle(context), + isCollapsed: true, + isDense: true, + ), + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + minLines: 1, + maxLines: null, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith(height: 20 / 14), + specialTextSpanBuilder: PromptInputTextSpanBuilder( + inputControlCubit: inputControlCubit, + specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + onTapOutside: (_) => focusNode.unfocus(), + ); + }, + ); + } + + TextStyle? inputHintTextStyle(BuildContext context) { + return Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).isLightMode + ? const Color(0xFFBDC2C8) + : const Color(0xFF3C3E51), + ); + } + + Widget leadingButtons(BuildContext context, bool showPredefinedFormats) { + return _LeadingActions( + // onMention: () { + // textController.text += '@'; + // if (!focusNode.hasFocus) { + // focusNode.requestFocus(); + // } + // WidgetsBinding.instance.addPostFrameCallback((_) { + // mentionPage(context); + // }); + // }, + showPredefinedFormats: showPredefinedFormats, + onTogglePredefinedFormatSection: () { + context + .read() + .add(AIPromptInputEvent.toggleShowPredefinedFormat()); + }, + selectedSourcesNotifier: widget.selectedSourcesNotifier, + onUpdateSelectedSources: widget.onUpdateSelectedSources, + ); + } + + Widget sendButton() { + return PromptInputSendButton( + state: sendButtonState, + onSendPressed: handleSendPressed, + onStopStreaming: widget.onStopStreaming, + ); + } +} + +class _LeadingActions extends StatelessWidget { + const _LeadingActions({ + required this.showPredefinedFormats, + required this.onTogglePredefinedFormatSection, + required this.selectedSourcesNotifier, + required this.onUpdateSelectedSources, + }); + + final bool showPredefinedFormats; + final void Function() onTogglePredefinedFormatSection; + final ValueNotifier> selectedSourcesNotifier; + final void Function(List) onUpdateSelectedSources; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surface, + child: SeparatedRow( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const HSpace(4.0), + children: [ + PromptInputMobileSelectSourcesButton( + selectedSourcesNotifier: selectedSourcesNotifier, + onUpdateSelectedSources: onUpdateSelectedSources, + ), + PromptInputMobileToggleFormatButton( + showFormatBar: showPredefinedFormats, + onTap: onTogglePredefinedFormatSection, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_message_selector_banner.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_message_selector_banner.dart new file mode 100644 index 0000000000..790a3fac3c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_message_selector_banner.dart @@ -0,0 +1,314 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/startup/startup.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_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: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 'message/ai_message_action_bar.dart'; +import 'message/message_util.dart'; + +class ChatMessageSelectorBanner extends StatelessWidget { + const ChatMessageSelectorBanner({ + super.key, + required this.view, + this.allMessages = const [], + }); + + final ViewPB view; + final List allMessages; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (!state.isSelectingMessages) { + return const SizedBox.shrink(); + } + + final selectedAmount = state.selectedMessages.length; + final totalAmount = allMessages.length; + final allSelected = selectedAmount == totalAmount; + + return Container( + height: 48, + color: const Color(0xFF00BCF0), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + GestureDetector( + onTap: () { + if (selectedAmount > 0) { + _unselectAllMessages(context); + } else { + _selectAllMessages(context); + } + }, + child: FlowySvg( + allSelected + ? FlowySvgs.checkbox_ai_selected_s + : selectedAmount > 0 + ? FlowySvgs.checkbox_ai_minus_s + : FlowySvgs.checkbox_ai_empty_s, + blendMode: BlendMode.dstIn, + size: const Size.square(18), + ), + ), + const HSpace(8), + Expanded( + child: FlowyText.semibold( + allSelected + ? LocaleKeys.chat_selectBanner_allSelected.tr() + : selectedAmount > 0 + ? LocaleKeys.chat_selectBanner_nSelected + .tr(args: [selectedAmount.toString()]) + : LocaleKeys.chat_selectBanner_selectMessages.tr(), + figmaLineHeight: 16, + color: Colors.white, + ), + ), + SaveToPageButton( + view: view, + ), + const HSpace(8), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => context.read().add( + const ChatSelectMessageEvent.toggleSelectingMessages(), + ), + child: const FlowySvg( + FlowySvgs.close_m, + color: Colors.white, + size: Size.square(24), + ), + ), + ), + ], + ), + ); + }, + ); + } + + void _selectAllMessages(BuildContext context) => context + .read() + .add(ChatSelectMessageEvent.selectAllMessages(allMessages)); + + void _unselectAllMessages(BuildContext context) => context + .read() + .add(const ChatSelectMessageEvent.unselectAllMessages()); +} + +class SaveToPageButton extends StatefulWidget { + const SaveToPageButton({ + super.key, + required this.view, + }); + + final ViewPB view; + + @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(hideDisabled: true), + ), + ], + child: BlocSelector( + selector: (state) => state.currentSpace, + builder: (context, spaceView) { + return AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + offset: const Offset(0, 18), + direction: PopoverDirection.bottomWithRightAligned, + constraints: const BoxConstraints.tightFor(width: 300, height: 400), + child: buildButton(context, spaceView), + popupBuilder: (_) => buildPopover(context), + ); + }, + ), + ); + } + + Widget buildButton(BuildContext context, ViewPB? spaceView) { + return BlocBuilder( + builder: (context, state) { + final selectedAmount = state.selectedMessages.length; + + return Opacity( + opacity: selectedAmount == 0 ? 0.5 : 1, + child: FlowyTextButton( + LocaleKeys.chat_selectBanner_saveButton.tr(), + onPressed: selectedAmount == 0 + ? null + : () async { + final documentId = getOpenedDocumentId(); + if (documentId != null) { + await onAddToExistingPage(context, documentId); + await forceReload(documentId); + await Future.delayed(const Duration(milliseconds: 500)); + await updateSelection(documentId); + } else { + if (spaceView != null) { + context + .read() + .refreshSources([spaceView], spaceView); + } + popoverController.show(); + } + }, + fontColor: Colors.white, + borderColor: Colors.white, + fillColor: Colors.transparent, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 6.0, + ), + ), + ); + }, + ); + } + + Widget buildPopover(BuildContext context) { + return BlocProvider.value( + value: context.read(), + child: SaveToPagePopoverContent( + onAddToNewPage: (parentViewId) async { + await addMessageToNewPage(context, parentViewId); + popoverController.close(); + }, + onAddToExistingPage: (documentId) async { + final view = await onAddToExistingPage(context, documentId); + + if (context.mounted) { + openPageFromMessage(context, view); + } + await Future.delayed(const Duration(milliseconds: 500)); + await updateSelection(documentId); + popoverController.close(); + }, + ), + ); + } + + Future onAddToExistingPage( + BuildContext context, + String documentId, + ) async { + final bloc = context.read(); + + final selectedMessages = [ + ...bloc.state.selectedMessages.whereType(), + ]..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + await ChatEditDocumentService.addMessagesToPage( + documentId, + selectedMessages, + ); + await Future.delayed(const Duration(milliseconds: 500)); + final view = await ViewBackendService.getView(documentId).toNullable(); + if (context.mounted) { + showSaveMessageSuccessToast(context, view); + } + + bloc.add(const ChatSelectMessageEvent.reset()); + + return view; + } + + Future addMessageToNewPage( + BuildContext context, + String parentViewId, + ) async { + final bloc = context.read(); + + final selectedMessages = [ + ...bloc.state.selectedMessages.whereType(), + ]..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + final newView = await ChatEditDocumentService.saveMessagesToNewPage( + widget.view.nameOrDefault, + parentViewId, + selectedMessages, + ); + + if (context.mounted) { + showSaveMessageSuccessToast(context, newView); + openPageFromMessage(context, newView); + } + bloc.add(const ChatSelectMessageEvent.reset()); + } + + Future forceReload(String documentId) async { + final bloc = DocumentBloc.findOpen(documentId); + if (bloc == null) { + return; + } + await bloc.forceReloadDocumentState(); + } + + Future updateSelection(String documentId) async { + final bloc = DocumentBloc.findOpen(documentId); + if (bloc == null) { + return; + } + await bloc.forceReloadDocumentState(); + final editorState = bloc.state.editorState; + final lastNodePath = editorState?.getLastSelectable()?.$1.path; + if (editorState == null || lastNodePath == null) { + return; + } + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: lastNodePath)), + ), + ); + } + + String? getOpenedDocumentId() { + final pageManager = getIt().state.currentPageManager; + if (!pageManager.showSecondaryPluginNotifier.value) { + return null; + } + return pageManager.secondaryNotifier.plugin.id; + } +} 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 0000000000..2c09e77050 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.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/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 void Function(String) onQuestionSelected; + final List relatedQuestions; + + @override + Widget build(BuildContext context) { + return SelectionContainer.disabled( + child: 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, + maxLines: 2, + 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 0000000000..30dc918f70 --- /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.app_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.withValues(alpha: 0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 4, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withValues(alpha: 0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 8, + spreadRadius: 2, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withValues(alpha: 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 0000000000..611ff5d922 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class AIChatUILayout { + const 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 DesktopAIChatSizes { + const DesktopAIChatSizes._(); + + static const avatarSize = 32.0; + static const avatarAndChatBubbleSpacing = 12.0; + + static const messageActionBarIconSize = 28.0; + static const messageHoverActionBarPadding = EdgeInsets.all(2.0); + static const messageHoverActionBarRadius = + BorderRadius.all(Radius.circular(8.0)); + static const messageHoverActionBarIconRadius = + BorderRadius.all(Radius.circular(6.0)); + static const messageActionBarIconRadius = + BorderRadius.all(Radius.circular(8.0)); + + static const inputActionBarMargin = + EdgeInsetsDirectional.fromSTEB(8, 0, 8, 4); + static const inputActionBarButtonSpacing = 4.0; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_format_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_format_bottom_sheet.dart new file mode 100644 index 0000000000..5fa3b8f8a7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_format_bottom_sheet.dart @@ -0,0 +1,196 @@ +import 'package:appflowy/ai/ai.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/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/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'; + +Future showChangeFormatBottomSheet( + BuildContext context, +) { + return showMobileBottomSheet( + context, + showDragHandle: true, + builder: (context) => const _ChangeFormatBottomSheetContent(), + ); +} + +class _ChangeFormatBottomSheetContent extends StatefulWidget { + const _ChangeFormatBottomSheetContent(); + + @override + State<_ChangeFormatBottomSheetContent> createState() => + _ChangeFormatBottomSheetContentState(); +} + +class _ChangeFormatBottomSheetContentState + extends State<_ChangeFormatBottomSheetContent> { + PredefinedFormat? predefinedFormat; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _Header( + onCancel: () => Navigator.of(context).pop(), + onDone: () => Navigator.of(context).pop(predefinedFormat), + ), + const VSpace(4.0), + _Body( + predefinedFormat: predefinedFormat, + onSelectPredefinedFormat: (format) { + setState(() => predefinedFormat = format); + }, + ), + const VSpace(16.0), + ], + ); + } +} + +class _Header extends StatelessWidget { + const _Header({ + required this.onCancel, + required this.onDone, + }); + + final VoidCallback onCancel; + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44.0, + child: Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: AppBarBackButton( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + onTap: onCancel, + ), + ), + Align( + child: Container( + constraints: const BoxConstraints(maxWidth: 250), + child: FlowyText( + LocaleKeys.chat_changeFormat_actionButton.tr(), + fontSize: 17.0, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: AppBarDoneButton( + onTap: onDone, + ), + ), + ], + ), + ); + } +} + +class _Body extends StatelessWidget { + const _Body({ + required this.predefinedFormat, + required this.onSelectPredefinedFormat, + }); + + final PredefinedFormat? predefinedFormat; + final void Function(PredefinedFormat) onSelectPredefinedFormat; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFormatButton(ImageFormat.text, true), + _buildFormatButton(ImageFormat.textAndImage), + _buildFormatButton(ImageFormat.image), + const VSpace(32.0), + Opacity( + opacity: predefinedFormat?.imageFormat.hasText ?? true ? 1 : 0, + child: Column( + children: [ + _buildTextFormatButton(TextFormat.paragraph, true), + _buildTextFormatButton(TextFormat.bulletList), + _buildTextFormatButton(TextFormat.numberedList), + _buildTextFormatButton(TextFormat.table), + ], + ), + ), + ], + ); + } + + Widget _buildFormatButton( + ImageFormat format, [ + bool isFirst = false, + ]) { + return FlowyOptionTile.checkbox( + text: format.i18n, + isSelected: format == predefinedFormat?.imageFormat, + showTopBorder: isFirst, + leftIcon: FlowySvg( + format.icon, + size: format == ImageFormat.textAndImage + ? const Size(21.0 / 16.0 * 20, 20) + : const Size.square(20), + ), + onTap: () { + if (predefinedFormat != null && + format == predefinedFormat!.imageFormat) { + return; + } + if (format.hasText) { + final textFormat = + predefinedFormat?.textFormat ?? TextFormat.paragraph; + onSelectPredefinedFormat( + PredefinedFormat(imageFormat: format, textFormat: textFormat), + ); + } else { + onSelectPredefinedFormat( + PredefinedFormat(imageFormat: format, textFormat: null), + ); + } + }, + ); + } + + Widget _buildTextFormatButton( + TextFormat format, [ + bool isFirst = false, + ]) { + return FlowyOptionTile.checkbox( + text: format.i18n, + isSelected: format == predefinedFormat?.textFormat, + showTopBorder: isFirst, + leftIcon: FlowySvg( + format.icon, + size: const Size.square(20), + ), + onTap: () { + if (predefinedFormat != null && + format == predefinedFormat!.textFormat) { + return; + } + onSelectPredefinedFormat( + PredefinedFormat( + imageFormat: predefinedFormat?.imageFormat ?? ImageFormat.text, + textFormat: format, + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart new file mode 100644 index 0000000000..aa0d840574 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart @@ -0,0 +1,145 @@ +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_backend/protobuf/flowy-ai/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'; + +Future showChangeModelBottomSheet( + BuildContext context, + List models, +) { + return showMobileBottomSheet( + context, + showDragHandle: true, + builder: (context) => _ChangeModelBottomSheetContent(models: models), + ); +} + +class _ChangeModelBottomSheetContent extends StatefulWidget { + const _ChangeModelBottomSheetContent({ + required this.models, + }); + + final List models; + + @override + State<_ChangeModelBottomSheetContent> createState() => + _ChangeModelBottomSheetContentState(); +} + +class _ChangeModelBottomSheetContentState + extends State<_ChangeModelBottomSheetContent> { + AIModelPB? model; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _Header( + onCancel: () => Navigator.of(context).pop(), + onDone: () => Navigator.of(context).pop(model), + ), + const VSpace(4.0), + _Body( + models: widget.models, + selectedModel: model, + onSelectModel: (format) { + setState(() => model = format); + }, + ), + const VSpace(16.0), + ], + ); + } +} + +class _Header extends StatelessWidget { + const _Header({ + required this.onCancel, + required this.onDone, + }); + + final VoidCallback onCancel; + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44.0, + child: Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: AppBarBackButton( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + onTap: onCancel, + ), + ), + Align( + child: Container( + constraints: const BoxConstraints(maxWidth: 250), + child: FlowyText( + LocaleKeys.chat_switchModel_label.tr(), + fontSize: 17.0, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: AppBarDoneButton( + onTap: onDone, + ), + ), + ], + ), + ); + } +} + +class _Body extends StatelessWidget { + const _Body({ + required this.models, + required this.selectedModel, + required this.onSelectModel, + }); + + final List models; + final AIModelPB? selectedModel; + final void Function(AIModelPB) onSelectModel; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: models + .mapIndexed( + (index, model) => _buildModelButton(model, index == 0), + ) + .toList(), + ); + } + + Widget _buildModelButton( + AIModelPB model, [ + bool isFirst = false, + ]) { + return FlowyOptionTile.checkbox( + text: model.name, + isSelected: model == selectedModel, + showTopBorder: isFirst, + onTap: () { + onSelectModel(model); + }, + ); + } +} 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 0000000000..1e7d428263 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart @@ -0,0 +1,163 @@ +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.trim()); + scrollController = EditorScrollController( + editorState: editorState, + shrinkWrap: true, + ); + } + + @override + void didUpdateWidget(covariant _AppFlowyEditorMarkdown oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.markdown != widget.markdown) { + final editorState = _parseMarkdown( + widget.markdown.trim(), + previousDocument: this.editorState.document, + ); + this.editorState.dispose(); + this.editorState = editorState; + 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, + alwaysDistributeSimpleTableColumnWidths: UniversalPlatform.isDesktop, + ); + return IntrinsicHeight( + child: AppFlowyEditor( + shrinkWrap: true, + // the editor is not editable in the chat + editable: false, + disableKeyboardService: UniversalPlatform.isMobile, + disableSelectionService: 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, { + Document? previousDocument, + }) { + // merge the nodes from the previous document with the new document to keep the same node ids + final document = customMarkdownToDocument(markdown); + final documentIterator = NodeIterator( + document: document, + startNode: document.root, + ); + if (previousDocument != null) { + final previousDocumentIterator = NodeIterator( + document: previousDocument, + startNode: previousDocument.root, + ); + while ( + documentIterator.moveNext() && previousDocumentIterator.moveNext()) { + final currentNode = documentIterator.current; + final previousNode = previousDocumentIterator.current; + if (currentNode.path.equals(previousNode.path)) { + currentNode.id = previousNode.id; + } + } + } + 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 0000000000..08fd82188d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart @@ -0,0 +1,779 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/ai/ai.dart'; +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/shared/patterns/common_patterns.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-ai/protobuf.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: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 '../layout_define.dart'; +import 'message_util.dart'; + +class AIMessageActionBar extends StatefulWidget { + const AIMessageActionBar({ + super.key, + required this.message, + required this.showDecoration, + this.onRegenerate, + this.onChangeFormat, + this.onChangeModel, + this.onOverrideVisibility, + }); + + final Message message; + final bool showDecoration; + final void Function()? onRegenerate; + final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; + final void Function(bool)? onOverrideVisibility; + + @override + State createState() => _AIMessageActionBarState(); +} + +class _AIMessageActionBarState extends State { + final popoverMutex = PopoverMutex(); + + @override + Widget build(BuildContext context) { + final isLightMode = Theme.of(context).isLightMode; + + final child = SeparatedRow( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const HSpace(8.0), + children: _buildChildren(), + ); + + return widget.showDecoration + ? Container( + padding: DesktopAIChatSizes.messageHoverActionBarPadding, + decoration: BoxDecoration( + borderRadius: DesktopAIChatSizes.messageHoverActionBarRadius, + border: Border.all( + color: isLightMode + ? const Color(0x1F1F2329) + : Theme.of(context).dividerColor, + strokeAlign: BorderSide.strokeAlignOutside, + ), + 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.withValues(alpha: 0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 4, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withValues(alpha: 0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 8, + spreadRadius: 2, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withValues(alpha: 0.02), + ), + ], + ), + child: child, + ) + : child; + } + + List _buildChildren() { + return [ + CopyButton( + isInHoverBar: widget.showDecoration, + textMessage: widget.message as TextMessage, + ), + RegenerateButton( + isInHoverBar: widget.showDecoration, + onTap: () => widget.onRegenerate?.call(), + ), + ChangeFormatButton( + isInHoverBar: widget.showDecoration, + onRegenerate: widget.onChangeFormat, + popoverMutex: popoverMutex, + onOverrideVisibility: widget.onOverrideVisibility, + ), + ChangeModelButton( + isInHoverBar: widget.showDecoration, + onRegenerate: widget.onChangeModel, + popoverMutex: popoverMutex, + onOverrideVisibility: widget.onOverrideVisibility, + ), + SaveToPageButton( + textMessage: widget.message as TextMessage, + isInHoverBar: widget.showDecoration, + popoverMutex: popoverMutex, + onOverrideVisibility: widget.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: DesktopAIChatSizes.messageActionBarIconSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: isInHoverBar + ? DesktopAIChatSizes.messageHoverActionBarIconRadius + : DesktopAIChatSizes.messageActionBarIconRadius, + icon: FlowySvg( + FlowySvgs.copy_s, + color: Theme.of(context).hintColor, + size: const Size.square(16), + ), + onPressed: () async { + final messageText = textMessage.text.trim(); + final document = customMarkdownToDocument( + messageText, + tableWidth: 250.0, + ); + await getIt().setData( + ClipboardServiceData( + plainText: _stripMarkdownIfNecessary(messageText), + inAppJson: jsonEncode(document.toJson()), + ), + ); + if (context.mounted) { + showToastNotification( + message: LocaleKeys.message_copy_success.tr(), + ); + } + }, + ), + ); + } + + String _stripMarkdownIfNecessary(String plainText) { + // match and capture inner url as group + final matches = singleLineMarkdownImageRegex.allMatches(plainText); + + if (matches.length != 1) { + return plainText; + } + + return matches.first[1] ?? plainText; + } +} + +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: DesktopAIChatSizes.messageActionBarIconSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: isInHoverBar + ? DesktopAIChatSizes.messageHoverActionBarIconRadius + : DesktopAIChatSizes.messageActionBarIconRadius, + icon: FlowySvg( + FlowySvgs.ai_try_again_s, + color: Theme.of(context).hintColor, + size: const Size.square(16), + ), + onPressed: onTap, + ), + ); + } +} + +class ChangeFormatButton extends StatefulWidget { + const ChangeFormatButton({ + super.key, + required this.isInHoverBar, + this.popoverMutex, + this.onRegenerate, + this.onOverrideVisibility, + }); + + final bool isInHoverBar; + final PopoverMutex? popoverMutex; + final void Function(PredefinedFormat)? onRegenerate; + final void Function(bool)? onOverrideVisibility; + + @override + State createState() => _ChangeFormatButtonState(); +} + +class _ChangeFormatButtonState extends State { + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + mutex: widget.popoverMutex, + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + offset: Offset(0, widget.isInHoverBar ? 8 : 4), + direction: PopoverDirection.bottomWithLeftAligned, + constraints: const BoxConstraints(), + onClose: () => widget.onOverrideVisibility?.call(false), + child: buildButton(context), + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: _ChangeFormatPopoverContent( + onRegenerate: widget.onRegenerate, + ), + ), + ); + } + + Widget buildButton(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_changeFormat_actionButton.tr(), + child: FlowyIconButton( + width: 32.0, + height: DesktopAIChatSizes.messageActionBarIconSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: widget.isInHoverBar + ? DesktopAIChatSizes.messageHoverActionBarIconRadius + : DesktopAIChatSizes.messageActionBarIconRadius, + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.ai_retry_font_s, + color: Theme.of(context).hintColor, + size: const Size.square(16), + ), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(8), + ), + ], + ), + onPressed: () { + widget.onOverrideVisibility?.call(true); + popoverController.show(); + }, + ), + ); + } +} + +class _ChangeFormatPopoverContent extends StatefulWidget { + const _ChangeFormatPopoverContent({ + this.onRegenerate, + }); + + final void Function(PredefinedFormat)? onRegenerate; + + @override + State<_ChangeFormatPopoverContent> createState() => + _ChangeFormatPopoverContentState(); +} + +class _ChangeFormatPopoverContentState + extends State<_ChangeFormatPopoverContent> { + PredefinedFormat? predefinedFormat; + + @override + Widget build(BuildContext context) { + final isLightMode = Theme.of(context).isLightMode; + return Container( + padding: const EdgeInsets.all(2.0), + decoration: BoxDecoration( + borderRadius: DesktopAIChatSizes.messageHoverActionBarRadius, + border: Border.all( + color: isLightMode + ? const Color(0x1F1F2329) + : Theme.of(context).dividerColor, + strokeAlign: BorderSide.strokeAlignOutside, + ), + 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.withValues(alpha: 0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 4, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withValues(alpha: 0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 8, + spreadRadius: 2, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withValues(alpha: 0.02), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BlocBuilder( + builder: (context, state) { + return ChangeFormatBar( + spacing: 2.0, + showImageFormats: state.aiType.isCloud, + predefinedFormat: predefinedFormat, + onSelectPredefinedFormat: (format) { + setState(() => predefinedFormat = format); + }, + ); + }, + ), + const HSpace(4.0), + FlowyTooltip( + message: LocaleKeys.chat_changeFormat_confirmButton.tr(), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (predefinedFormat != null) { + widget.onRegenerate?.call(predefinedFormat!); + } + }, + child: SizedBox.square( + dimension: DesktopAIPromptSizes.predefinedFormatButtonHeight, + child: Center( + child: FlowySvg( + FlowySvgs.ai_retry_filled_s, + color: Theme.of(context).colorScheme.primary, + size: const Size.square(20), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class ChangeModelButton extends StatefulWidget { + const ChangeModelButton({ + super.key, + required this.isInHoverBar, + this.popoverMutex, + this.onRegenerate, + this.onOverrideVisibility, + }); + + final bool isInHoverBar; + final PopoverMutex? popoverMutex; + final void Function(AIModelPB)? onRegenerate; + final void Function(bool)? onOverrideVisibility; + + @override + State createState() => _ChangeModelButtonState(); +} + +class _ChangeModelButtonState extends State { + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + mutex: widget.popoverMutex, + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + offset: Offset(8, 0), + direction: PopoverDirection.rightWithBottomAligned, + constraints: BoxConstraints(maxWidth: 250, maxHeight: 600), + onClose: () => widget.onOverrideVisibility?.call(false), + child: buildButton(context), + popupBuilder: (_) { + final bloc = context.read(); + final (models, _) = bloc.aiModelStateNotifier.getAvailableModels(); + return SelectModelPopoverContent( + models: models, + selectedModel: null, + onSelectModel: widget.onRegenerate, + ); + }, + ); + } + + Widget buildButton(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_switchModel_label.tr(), + child: FlowyIconButton( + width: 32.0, + height: DesktopAIChatSizes.messageActionBarIconSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: widget.isInHoverBar + ? DesktopAIChatSizes.messageHoverActionBarIconRadius + : DesktopAIChatSizes.messageActionBarIconRadius, + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.ai_sparks_s, + color: Theme.of(context).hintColor, + size: const Size.square(16), + ), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(8), + ), + ], + ), + onPressed: () { + widget.onOverrideVisibility?.call(true); + popoverController.show(); + }, + ), + ); + } +} + +class SaveToPageButton extends StatefulWidget { + const SaveToPageButton({ + super.key, + required this.textMessage, + required this.isInHoverBar, + this.popoverMutex, + this.onOverrideVisibility, + }); + + final TextMessage textMessage; + final bool isInHoverBar; + final PopoverMutex? popoverMutex; + 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(hideDisabled: true), + ), + ], + child: BlocSelector( + selector: (state) => state.currentSpace, + builder: (context, spaceView) { + return AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + mutex: widget.popoverMutex, + offset: const Offset(8, 0), + direction: PopoverDirection.rightWithBottomAligned, + constraints: const BoxConstraints.tightFor(width: 300, height: 400), + onClose: () { + if (spaceView != null) { + context + .read() + .refreshSources([spaceView], 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: DesktopAIChatSizes.messageActionBarIconSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: widget.isInHoverBar + ? DesktopAIChatSizes.messageHoverActionBarIconRadius + : DesktopAIChatSizes.messageActionBarIconRadius, + 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(context, documentId); + await forceReload(documentId); + await Future.delayed(const Duration(milliseconds: 500)); + await updateSelection(documentId); + } else { + widget.onOverrideVisibility?.call(true); + if (spaceView != null) { + context + .read() + .refreshSources([spaceView], spaceView); + } + popoverController.show(); + } + }, + ), + ); + } + + Widget buildPopover(BuildContext context) { + return BlocProvider.value( + value: context.read(), + child: SaveToPagePopoverContent( + onAddToNewPage: (parentViewId) { + addMessageToNewPage(context, parentViewId); + popoverController.close(); + }, + onAddToExistingPage: (documentId) async { + popoverController.close(); + final view = await onAddToExistingPage(context, documentId); + + if (context.mounted) { + openPageFromMessage(context, view); + } + await Future.delayed(const Duration(milliseconds: 500)); + await updateSelection(documentId); + }, + ), + ); + } + + Future onAddToExistingPage( + BuildContext context, + String documentId, + ) async { + await ChatEditDocumentService.addMessagesToPage( + documentId, + [widget.textMessage], + ); + await Future.delayed(const Duration(milliseconds: 500)); + final view = await ViewBackendService.getView(documentId).toNullable(); + if (context.mounted) { + showSaveMessageSuccessToast(context, view); + } + return view; + } + + void addMessageToNewPage(BuildContext context, String parentViewId) async { + final chatView = await ViewBackendService.getView( + context.read().chatId, + ).toNullable(); + if (chatView != null) { + final newView = await ChatEditDocumentService.saveMessagesToNewPage( + chatView.nameOrDefault, + parentViewId, + [widget.textMessage], + ); + + if (context.mounted) { + showSaveMessageSuccessToast(context, newView); + openPageFromMessage(context, newView); + } + } + } + + Future forceReload(String documentId) async { + final bloc = DocumentBloc.findOpen(documentId); + if (bloc == null) { + return; + } + await bloc.forceReloadDocumentState(); + } + + Future updateSelection(String documentId) async { + final bloc = DocumentBloc.findOpen(documentId); + if (bloc == null) { + return; + } + await bloc.forceReloadDocumentState(); + final editorState = bloc.state.editorState; + final lastNodePath = editorState?.getLastSelectable()?.$1.path; + if (editorState == null || lastNodePath == null) { + return; + } + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: lastNodePath)), + ), + ); + } + + String? getOpenedDocumentId() { + final pageManager = getIt().state.currentPageManager; + if (!pageManager.showSecondaryPluginNotifier.value) { + return null; + } + return pageManager.secondaryNotifier.plugin.id; + } +} + +class SaveToPagePopoverContent extends StatelessWidget { + const SaveToPagePopoverContent({ + super.key, + required this.onAddToNewPage, + required this.onAddToExistingPage, + }); + + final void Function(String) 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(), + ), + ), + ], + ); + }, + ); + } + + 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, + showSaveButton: true, + onSelected: (source) { + if (source.view.isSpace) { + onAddToNewPage(source.view.id); + } else { + onAddToExistingPage(source.view.id); + } + }, + onAdd: (source) { + onAddToNewPage(source.view.id); + }, + height: 30.0, + ), + ); + } +} 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 0000000000..2786799520 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart @@ -0,0 +1,550 @@ +import 'dart:convert'; + +import 'package:appflowy/ai/ai.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/flowy_mobile_quick_action_button.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.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:appflowy_backend/protobuf/flowy-ai/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: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_change_model_bottom_sheet.dart'; +import 'ai_message_action_bar.dart'; +import 'ai_change_format_bottom_sheet.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.isSelectingMessages = false, + this.onRegenerate, + this.onChangeFormat, + this.onChangeModel, + }); + + final Message message; + final Widget child; + final bool showActions; + final bool isLastMessage; + final bool isSelectingMessages; + final void Function()? onRegenerate; + final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; + + @override + Widget build(BuildContext context) { + final messageWidget = _WrapIsSelectingMessage( + isSelectingMessages: isSelectingMessages, + message: message, + child: child, + ); + + return !isSelectingMessages && showActions + ? UniversalPlatform.isMobile + ? _wrapPopMenu(messageWidget) + : isLastMessage + ? _wrapBottomActions(messageWidget) + : _wrapHover(messageWidget) + : messageWidget; + } + + Widget _wrapBottomActions(Widget child) { + return ChatAIBottomInlineActions( + message: message, + onRegenerate: onRegenerate, + onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, + child: child, + ); + } + + Widget _wrapHover(Widget child) { + return ChatAIMessageHover( + message: message, + onRegenerate: onRegenerate, + onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, + child: child, + ); + } + + Widget _wrapPopMenu(Widget child) { + return ChatAIMessagePopup( + message: message, + onRegenerate: onRegenerate, + onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, + child: child, + ); + } +} + +class ChatAIBottomInlineActions extends StatelessWidget { + const ChatAIBottomInlineActions({ + super.key, + required this.child, + required this.message, + this.onRegenerate, + this.onChangeFormat, + this.onChangeModel, + }); + + final Widget child; + final Message message; + final void Function()? onRegenerate; + final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + child, + const VSpace(16.0), + Padding( + padding: const EdgeInsetsDirectional.only( + start: DesktopAIChatSizes.avatarSize + + DesktopAIChatSizes.avatarAndChatBubbleSpacing, + ), + child: AIMessageActionBar( + message: message, + showDecoration: false, + onRegenerate: onRegenerate, + onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, + ), + ), + const VSpace(32.0), + ], + ); + } +} + +class ChatAIMessageHover extends StatefulWidget { + const ChatAIMessageHover({ + super.key, + required this.child, + required this.message, + this.onRegenerate, + this.onChangeFormat, + this.onChangeModel, + }); + + final Widget child; + final Message message; + final void Function()? onRegenerate; + final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; + + @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( + DesktopAIChatSizes.avatarSize + + DesktopAIChatSizes.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: SizedBox( + width: 784, + height: DesktopAIChatSizes.messageActionBarIconSize + + DesktopAIChatSizes.messageHoverActionBarPadding.vertical, + child: hoverBubble || hoverActionBar || overrideVisibility + ? Align( + alignment: AlignmentDirectional.centerStart, + child: AIMessageActionBar( + message: widget.message, + showDecoration: true, + onRegenerate: widget.onRegenerate, + onChangeFormat: widget.onChangeFormat, + onChangeModel: widget.onChangeModel, + 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 + + DesktopAIChatSizes.messageActionBarIconSize + + DesktopAIChatSizes.messageHoverActionBarPadding.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, + this.onChangeFormat, + this.onChangeModel, + }); + + final Widget child; + final Message message; + final void Function()? onRegenerate; + final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; + + @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: [ + _copyButton(context, bottomSheetContext), + _divider(), + _regenerateButton(context), + _divider(), + _changeFormatButton(context), + _divider(), + _changeModelButton(context), + _divider(), + _saveToPageButton(context), + ], + ); + }, + ); + }, + child: child, + ); + } + + Widget _divider() => const MobileQuickActionDivider(); + + 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( + message: LocaleKeys.message_copy_success.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_try_again_s, + iconSize: const Size.square(20), + text: LocaleKeys.chat_regenerate.tr(), + ); + } + + Widget _changeFormatButton(BuildContext context) { + return MobileQuickActionButton( + onTap: () async { + final result = await showChangeFormatBottomSheet(context); + if (result != null) { + onChangeFormat?.call(result); + if (context.mounted) { + Navigator.of(context).pop(); + } + } + }, + icon: FlowySvgs.ai_retry_font_s, + iconSize: const Size.square(20), + text: LocaleKeys.chat_changeFormat_actionButton.tr(), + ); + } + + Widget _changeModelButton(BuildContext context) { + return MobileQuickActionButton( + onTap: () async { + final bloc = context.read(); + final (models, _) = bloc.aiModelStateNotifier.getAvailableModels(); + final result = await showChangeModelBottomSheet(context, models); + if (result != null) { + onChangeModel?.call(result); + if (context.mounted) { + Navigator.of(context).pop(); + } + } + }, + icon: FlowySvgs.ai_sparks_s, + iconSize: const Size.square(20), + text: LocaleKeys.chat_switchModel_label.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.addMessagesToPage( + 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(), + ); + } +} + +class _WrapIsSelectingMessage extends StatelessWidget { + const _WrapIsSelectingMessage({ + required this.message, + required this.child, + this.isSelectingMessages = false, + }); + + final Message message; + final Widget child; + final bool isSelectingMessages; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final isSelected = + context.read().isMessageSelected(message.id); + return GestureDetector( + onTap: () { + if (isSelectingMessages) { + context + .read() + .add(ChatSelectMessageEvent.toggleSelectMessage(message)); + } + }, + behavior: isSelectingMessages ? HitTestBehavior.opaque : null, + child: DecoratedBox( + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.tertiaryContainer + : null, + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isSelectingMessages) + ChatSelectMessageIndicator(isSelected: isSelected) + else + SelectionContainer.disabled( + child: const ChatAIAvatar(), + ), + const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing), + Expanded( + child: IgnorePointer( + ignoring: isSelectingMessages, + child: child, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class ChatSelectMessageIndicator extends StatelessWidget { + const ChatSelectMessageIndicator({ + super.key, + required this.isSelected, + }); + + final bool isSelected; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: SizedBox.square( + dimension: 30.0, + child: Center( + child: FlowySvg( + isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + size: const Size.square(20), + ), + ), + ), + ); + } +} 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 0000000000..cc97610e8d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart @@ -0,0 +1,159 @@ +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: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_backend/protobuf/flowy-folder/protobuf.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:flutter_bloc/flutter_bloc.dart'; +import 'package:string_validator/string_validator.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) { + if (isURL(m.id)) { + return _MetadataButton( + name: m.id, + onTap: () => widget.onSelectedMetadata?.call(m), + ); + } else if (isUUID(m.id)) { + return FutureBuilder( + future: ViewBackendService.getView(m.id) + .then((f) => f.toNullable()), + builder: (context, snapshot) { + final data = snapshot.data; + if (!snapshot.hasData || + snapshot.connectionState != ConnectionState.done || + data == null) { + return _MetadataButton( + name: m.name, + onTap: () => widget.onSelectedMetadata?.call(m), + ); + } + return BlocProvider( + create: (_) => ViewBloc(view: data), + child: BlocBuilder( + builder: (context, state) { + return _MetadataButton( + name: state.view.nameOrDefault, + onTap: () => widget.onSelectedMetadata?.call(m), + ); + }, + ), + ); + }, + ); + } else { + return _MetadataButton( + name: m.name, + onTap: () => widget.onSelectedMetadata?.call(m), + ); + } + }, + ).toList(), + ), + ], + ], + ), + ); + } +} + +class _MetadataButton extends StatelessWidget { + const _MetadataButton({ + this.name = "", + this.onTap, + }); + + final String name; + final void Function()? onTap; + + @override + Widget build(BuildContext context) { + 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( + name, + fontSize: 12, + overflow: TextOverflow.ellipsis, + ), + leftIcon: FlowySvg( + FlowySvgs.icon_document_s, + size: const Size.square(16), + color: Theme.of(context).hintColor, + ), + onTap: onTap, + ), + ); + } +} 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 0000000000..380767105f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart @@ -0,0 +1,173 @@ +import 'package:appflowy/ai/ai.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_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:appflowy_backend/protobuf/flowy-ai/protobuf.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 '../layout_define.dart'; +import 'ai_markdown_text.dart'; +import 'ai_message_bubble.dart'; +import 'ai_metadata.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, + required this.onStopStream, + this.onSelectedMetadata, + this.onRegenerate, + this.onChangeFormat, + this.onChangeModel, + this.isLastMessage = false, + this.isStreaming = false, + this.isSelectingMessages = 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 void Function() onStopStream; + final void Function(PredefinedFormat)? onChangeFormat; + final void Function(AIModelPB)? onChangeModel; + final bool isStreaming; + final bool isLastMessage; + final bool isSelectingMessages; + + @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 BlocListener( + listenWhen: (previous, current) => + previous.clearErrorMessages != current.clearErrorMessages, + listener: (context, chatState) { + if (state.stream?.error?.isEmpty != false) { + return; + } + context.read().add(ChatEvent.deleteMessage(message)); + }, + child: Padding( + padding: AIChatUILayout.messageMargin, + child: state.messageState.when( + loading: () => ChatAIMessageBubble( + message: message, + showActions: false, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: AILoadingIndicator(text: loadingText), + ), + ), + ready: () { + return state.text.isEmpty + ? ChatAIMessageBubble( + message: message, + showActions: false, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: AILoadingIndicator(text: loadingText), + ), + ) + : ChatAIMessageBubble( + message: message, + isLastMessage: isLastMessage, + showActions: stream == null && + state.text.isNotEmpty && + !isStreaming, + isSelectingMessages: isSelectingMessages, + onRegenerate: onRegenerate, + onChangeFormat: onChangeFormat, + onChangeModel: onChangeModel, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AIMarkdownText( + markdown: state.text, + ), + if (state.sources.isNotEmpty) + SelectionContainer.disabled( + child: AIMessageMetadata( + sources: state.sources, + onSelectedMetadata: onSelectedMetadata, + ), + ), + if (state.sources.isNotEmpty && !isLastMessage) + const VSpace(8.0), + ], + ), + ); + }, + onError: (error) { + return ChatErrorMessageWidget( + errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(), + ); + }, + onAIResponseLimit: () { + return ChatErrorMessageWidget( + errorMessage: + LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(), + ); + }, + onAIImageResponseLimit: () { + return ChatErrorMessageWidget( + errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(), + ); + }, + onAIMaxRequired: (message) { + return ChatErrorMessageWidget( + errorMessage: message, + ); + }, + onInitializingLocalAI: () { + onStopStream(); + + return ChatErrorMessageWidget( + errorMessage: LocaleKeys + .settings_aiPage_keys_localAIInitializing + .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 0000000000..6056ffffa6 --- /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.toast_error_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/message_util.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart new file mode 100644 index 0000000000..652fe3791b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +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/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'; +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) { + showToastNotification( + message: LocaleKeys.chat_openPagePreviewFailedToast.tr(), + type: ToastificationType.error, + ); + return; + } + if (UniversalPlatform.isDesktop) { + getIt().add( + TabsEvent.openSecondaryPlugin( + plugin: view.plugin(), + ), + ); + } else { + context.pushView(view); + } +} + +void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) { + if (view == null) { + return; + } + showToastNotification( + richMessage: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.chat_addToNewPageSuccessToast.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: const Color(0xFFFFFFFF), + ), + ), + const TextSpan( + text: ' ', + ), + TextSpan( + text: view.nameOrDefault, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: const Color(0xFFFFFFFF), + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); +} 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 0000000000..8bd115ad0f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart @@ -0,0 +1,156 @@ +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, + this.files = const [], + }); + + final Message message; + final Widget child; + 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( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _buildBubble(context), + const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing), + _buildAvatar(), + ], + ), + ], + ), + ); + } + + Widget _buildAvatar() { + return BlocBuilder( + builder: (context, state) { + final member = state.members[message.author.id]; + return SelectionContainer.disabled( + child: 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 0000000000..c73100b59d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart @@ -0,0 +1,86 @@ +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, + }); + + final User user; + final TextMessage message; + + @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, + 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, + 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 0000000000..d66a6665b3 --- /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.withValues(alpha: 0.06), + ), + BoxShadow( + offset: const Offset(0, 4), + blurRadius: 8, + color: isLightMode + ? const Color(0x141F2329) + : Theme.of(context).shadowColor.withValues(alpha: 0.08), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 4, + color: isLightMode + ? const Color(0x1F1F2329) + : Theme.of(context).shadowColor.withValues(alpha: 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/emoji/emoji_picker.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart index 1297bccc37..27b288090a 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart @@ -1,33 +1,49 @@ -import 'dart:io'; +import 'dart:math'; import 'package:appflowy/plugins/base/emoji/emoji_picker_header.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_search_bar.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.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'; -import 'package:google_fonts/google_fonts.dart'; // use a global value to store the selected emoji to prevent reloading every time. EmojiData? kCachedEmojiData; +const _kRecentEmojiCategoryId = 'Recent'; + +class EmojiPickerResult { + EmojiPickerResult({ + required this.emojiId, + required this.emoji, + this.isRandom = false, + }); + + final String emojiId; + final String emoji; + final bool isRandom; +} class FlowyEmojiPicker extends StatefulWidget { const FlowyEmojiPicker({ super.key, required this.onEmojiSelected, this.emojiPerLine = 9, + this.ensureFocus = false, }); - final EmojiSelectedCallback onEmojiSelected; + final ValueChanged onEmojiSelected; final int emojiPerLine; + final bool ensureFocus; @override State createState() => _FlowyEmojiPickerState(); } class _FlowyEmojiPickerState extends State { - EmojiData? emojiData; - List? fallbackFontFamily; + late EmojiData emojiData; + bool loaded = false; @override void initState() { @@ -35,29 +51,20 @@ class _FlowyEmojiPickerState extends State { // load the emoji data from cache if it's available if (kCachedEmojiData != null) { - emojiData = kCachedEmojiData; + loadEmojis(kCachedEmojiData!); } else { EmojiData.builtIn().then( (value) { kCachedEmojiData = value; - setState(() { - emojiData = value; - }); + loadEmojis(value); }, ); } - - if (Platform.isAndroid || Platform.isLinux) { - final notoColorEmoji = GoogleFonts.notoColorEmoji().fontFamily; - if (notoColorEmoji != null) { - fallbackFontFamily = [notoColorEmoji]; - } - } } @override Widget build(BuildContext context) { - if (emojiData == null) { + if (!loaded) { return const Center( child: SizedBox.square( dimension: 24.0, @@ -69,41 +76,79 @@ class _FlowyEmojiPickerState extends State { } return EmojiPicker( - emojiData: emojiData!, + emojiData: emojiData, configuration: EmojiPickerConfiguration( showTabs: false, defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none, - perLine: widget.emojiPerLine, ), - onEmojiSelected: widget.onEmojiSelected, - headerBuilder: (context, category) { - return FlowyEmojiHeader( - category: category, + onEmojiSelected: (id, emoji) { + widget.onEmojiSelected.call( + EmojiPickerResult(emojiId: id, emoji: emoji), ); + RecentIcons.putEmoji(id); }, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + headerBuilder: (_, category) => FlowyEmojiHeader(category: category), itemBuilder: (context, emojiId, emoji, callback) { - return FlowyIconButton( - iconPadding: const EdgeInsets.all(2.0), - icon: FlowyText( - emoji, - fontSize: 28.0, - fallbackFontFamily: fallbackFontFamily, + 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), ), - onPressed: () => callback(emojiId, emoji), ); }, searchBarBuilder: (context, keyword, skinTone) { - return FlowyEmojiSearchBar( - emojiData: emojiData!, - onKeywordChanged: (value) { - keyword.value = value; - }, - onSkinToneChanged: (value) { - skinTone.value = value; - }, - onRandomEmojiSelected: widget.onEmojiSelected, + 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: (id, emoji) { + widget.onEmojiSelected.call( + EmojiPickerResult(emojiId: id, emoji: emoji, isRandom: true), + ); + RecentIcons.putEmoji(id); + }, + ), ); }, ); } + + void loadEmojis(EmojiData data) { + RecentIcons.getEmojiIds().then((v) { + if (v.isEmpty) { + emojiData = data; + if (mounted) 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); + if (mounted) 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 index 9619f00d30..9b41dd8bce 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_header.dart @@ -1,7 +1,8 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; +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({ @@ -13,12 +14,17 @@ class FlowyEmojiHeader extends StatelessWidget { @override Widget build(BuildContext context) { - if (PlatformExtension.isDesktopOrWeb) { + if (UniversalPlatform.isDesktop) { return Container( height: 22, - padding: const EdgeInsets.symmetric(horizontal: 8.0), color: Theme.of(context).cardColor, - child: FlowyText.regular(category.id), + child: Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyText.regular( + category.id.capitalize(), + color: Theme.of(context).hintColor, + ), + ), ); } else { return Column( 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 index 85e2197cb7..47f257d174 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_screen.dart +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_screen.dart @@ -1,23 +1,48 @@ -import 'package:appflowy/plugins/base/icon/icon_picker.dart'; -import 'package:appflowy/plugins/base/icon/icon_picker_page.dart'; +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'; -class MobileEmojiPickerScreen extends StatelessWidget { - const MobileEmojiPickerScreen({super.key, this.title}); +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.selectedType, + this.documentId, + this.tabs = const [PickerTabType.emoji, PickerTabType.icon], + }); + + final PickerTabType? selectedType; final String? title; + final String? documentId; + final List tabs; static const routeName = '/emoji_picker'; static const pageTitle = 'title'; + static const iconSelectedType = 'iconSelected_type'; + static const selectTabs = 'tabs'; + static const uploadDocumentId = 'document_id'; @override Widget build(BuildContext context) { - return IconPickerPage( - title: title, - onSelected: (result) { - context.pop(result); - }, + return Scaffold( + appBar: FlowyAppBar( + titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(), + ), + body: SafeArea( + child: FlowyIconEmojiPicker( + tabs: tabs, + documentId: documentId, + initialType: selectedType, + onSelectedEmoji: (r) { + context.pop(r.data); + }, + ), + ), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_search_bar.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_search_bar.dart deleted file mode 100644 index 1b01e6aee8..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_search_bar.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.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/flowy_tooltip.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; - -typedef EmojiKeywordChangedCallback = void Function(String keyword); -typedef EmojiSkinToneChanged = void Function(EmojiSkinTone skinTone); - -class FlowyEmojiSearchBar extends StatefulWidget { - const FlowyEmojiSearchBar({ - super.key, - required this.emojiData, - required this.onKeywordChanged, - required this.onSkinToneChanged, - required this.onRandomEmojiSelected, - }); - - 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: 8.0, - horizontal: PlatformExtension.isDesktopOrWeb ? 0.0 : 8.0, - ), - child: Row( - children: [ - Expanded( - child: _SearchTextField( - onKeywordChanged: widget.onKeywordChanged, - ), - ), - const HSpace(6.0), - _RandomEmojiButton( - emojiData: widget.emojiData, - onRandomEmojiSelected: widget.onRandomEmojiSelected, - ), - const HSpace(6.0), - FlowyEmojiSkinToneSelector( - onEmojiSkinToneChanged: widget.onSkinToneChanged, - ), - const HSpace(6.0), - ], - ), - ); - } -} - -class _RandomEmojiButton extends StatelessWidget { - const _RandomEmojiButton({ - required this.emojiData, - required this.onRandomEmojiSelected, - }); - - final EmojiData emojiData; - final EmojiSelectedCallback onRandomEmojiSelected; - - @override - Widget build(BuildContext context) { - return FlowyTooltip( - message: LocaleKeys.emoji_random.tr(), - child: FlowyButton( - useIntrinsicWidth: true, - text: const Icon( - Icons.shuffle_rounded, - ), - onTap: () { - final random = emojiData.random; - onRandomEmojiSelected( - random.$1, - random.$2, - ); - }, - ), - ); - } -} - -class _SearchTextField extends StatefulWidget { - const _SearchTextField({ - required this.onKeywordChanged, - }); - - final EmojiKeywordChangedCallback 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 ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 32.0, - ), - child: FlowyTextField( - focusNode: focusNode, - hintText: LocaleKeys.emoji_search.tr(), - controller: controller, - onChanged: widget.onKeywordChanged, - prefixIcon: const Padding( - padding: EdgeInsets.only( - left: 8.0, - right: 4.0, - ), - child: FlowySvg( - FlowySvgs.search_s, - ), - ), - prefixIconConstraints: const BoxConstraints( - maxHeight: 18.0, - ), - suffixIcon: Padding( - padding: const EdgeInsets.all(4.0), - child: FlowyButton( - text: const FlowySvg( - FlowySvgs.close_lg, - ), - margin: EdgeInsets.zero, - useIntrinsicWidth: true, - onTap: () { - if (controller.text.isNotEmpty) { - controller.clear(); - widget.onKeywordChanged(''); - } else { - focusNode.unfocus(); - } - }, - ), - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_text.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_text.dart index e9fed800d4..9df541f4a2 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_text.dart +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_text.dart @@ -29,13 +29,15 @@ class EmojiText extends StatelessWidget { emoji, fontSize: fontSize, textAlign: textAlign, + strutStyle: const StrutStyle(forceStrutHeight: true), fallbackFontFamily: _cachedFallbackFontFamily, lineHeight: lineHeight, + isEmoji: true, ); } void _loadFallbackFontFamily() { - if (Platform.isLinux || Platform.isAndroid) { + if (Platform.isLinux) { final notoColorEmoji = GoogleFonts.notoColorEmoji().fontFamily; if (notoColorEmoji != null) { _cachedFallbackFontFamily = [notoColorEmoji]; diff --git a/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker.dart b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker.dart deleted file mode 100644 index a77b4b2f27..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker.dart +++ /dev/null @@ -1,143 +0,0 @@ -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/base/emoji/emoji_picker.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/icon.pbenum.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'; - -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; - } - } -} - -enum FlowyIconType { - emoji, - icon, - custom; -} - -class EmojiPickerResult { - factory EmojiPickerResult.none() => - const EmojiPickerResult(FlowyIconType.icon, ''); - - factory EmojiPickerResult.emoji(String emoji) => - EmojiPickerResult(FlowyIconType.emoji, emoji); - - const EmojiPickerResult( - this.type, - this.emoji, - ); - - final FlowyIconType type; - final String emoji; -} - -class FlowyIconPicker extends StatelessWidget { - const FlowyIconPicker({ - super.key, - required this.onSelected, - }); - - final void Function(EmojiPickerResult result) onSelected; - - @override - Widget build(BuildContext context) { - // ONLY supports emoji picker for now - return DefaultTabController( - length: 1, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - _buildTabs(context), - const Spacer(), - _RemoveIconButton( - onTap: () => onSelected(EmojiPickerResult.none()), - ), - ], - ), - const Divider(height: 2), - Expanded( - child: TabBarView( - children: [ - FlowyEmojiPicker( - emojiPerLine: _getEmojiPerLine(context), - onEmojiSelected: (_, emoji) => - onSelected(EmojiPickerResult.emoji(emoji)), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildTabs(BuildContext context) { - return Align( - alignment: Alignment.centerLeft, - child: TabBar( - indicatorSize: TabBarIndicatorSize.label, - isScrollable: true, - overlayColor: MaterialStatePropertyAll( - Theme.of(context).colorScheme.secondary, - ), - padding: EdgeInsets.zero, - tabs: [ - FlowyHover( - style: const HoverStyle(borderRadius: BorderRadius.zero), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0, - ), - child: FlowyText(LocaleKeys.emoji_emojiTab.tr()), - ), - ), - ], - ), - ); - } - - int _getEmojiPerLine(BuildContext context) { - if (PlatformExtension.isDesktopOrWeb) { - return 9; - } - final width = MediaQuery.of(context).size.width; - return width ~/ 46.0; // the size of the emoji - } -} - -class _RemoveIconButton extends StatelessWidget { - const _RemoveIconButton({required this.onTap}); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 28, - child: FlowyButton( - onTap: onTap, - useIntrinsicWidth: true, - text: FlowyText.small( - LocaleKeys.document_plugins_cover_removeIcon.tr(), - ), - leftIcon: const FlowySvg(FlowySvgs.delete_s), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart deleted file mode 100644 index 15cc8c59e0..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker_page.dart +++ /dev/null @@ -1,29 +0,0 @@ -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/base/icon/icon_picker.dart'; -import 'package:easy_localization/easy_localization.dart'; - -class IconPickerPage extends StatelessWidget { - const IconPickerPage({ - super.key, - this.title, - required this.onSelected, - }); - - final void Function(EmojiPickerResult) onSelected; - final String? title; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: FlowyAppBar( - titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(), - ), - body: SafeArea( - child: FlowyIconPicker(onSelected: onSelected), - ), - ); - } -} 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 0000000000..630219e060 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_widget.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; +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.iconsData}); + + final IconsData iconsData; + final double size; + + @override + Widget build(BuildContext context) { + final colorValue = int.tryParse(iconsData.color ?? ''); + Color? color; + if (colorValue != null) { + color = Color(colorValue); + } + final svgString = iconsData.svgString; + if (svgString == null) { + return EmojiText( + emoji: '❓', + fontSize: size, + textAlign: TextAlign.center, + ); + } + return FlowySvg.string( + svgString, + 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 index 9e4a6ba373..b25bb5af06 100644 --- a/frontend/appflowy_flutter/lib/plugins/blank/blank.dart +++ b/frontend/appflowy_flutter/lib/plugins/blank/blank.dart @@ -2,6 +2,7 @@ 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'; @@ -20,6 +21,9 @@ class BlankPluginBuilder extends PluginBuilder { @override PluginType get pluginType => PluginType.blank; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class BlankPluginConfig implements PluginConfig { @@ -40,14 +44,21 @@ class BlankPagePlugin extends Plugin { 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) => leftBarItem; + Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem; @override - Widget buildWidget({PluginContext? context, required bool shrinkWrap}) => + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + Map? data, + }) => const BlankPage(); @override 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 index 7aad1108b1..34c922aaf9 100644 --- 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 @@ -33,6 +33,8 @@ class ChecklistCellBloc extends Bloc { final ChecklistCellBackendService _checklistCellService; void Function()? _onCellChangedFn; + int? nextPhantomIndex; + @override Future close() async { if (_onCellChangedFn != null) { @@ -52,17 +54,23 @@ class ChecklistCellBloc extends Bloc { const ChecklistCellState( tasks: [], percent: 0, - newTask: false, + 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); @@ -70,16 +78,28 @@ class ChecklistCellBloc extends Bloc { selectTask: (id) async { await _checklistCellService.select(optionId: id); }, - createNewTask: (name) async { - final result = await _checklistCellService.create(name: name); - result.fold( - (l) => emit(state.copyWith(newTask: true)), - (err) => Log.error(err), - ); + 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, + ), + ); + }, ); }, ); @@ -95,6 +115,31 @@ class ChecklistCellBloc extends Bloc { ); } + 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); @@ -105,6 +150,32 @@ class ChecklistCellBloc extends Bloc { 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 @@ -117,9 +188,17 @@ class ChecklistCellEvent with _$ChecklistCellEvent { String name, ) = _UpdateTaskName; const factory ChecklistCellEvent.selectTask(String taskId) = _SelectTask; - const factory ChecklistCellEvent.createNewTask(String description) = - _CreateNewTask; + 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 @@ -127,7 +206,8 @@ class ChecklistCellState with _$ChecklistCellState { const factory ChecklistCellState({ required List tasks, required double percent, - required bool newTask, + required bool showIncompleteOnly, + required int? phantomIndex, }) = _ChecklistCellState; factory ChecklistCellState.initial(ChecklistCellController cellController) { @@ -136,7 +216,8 @@ class ChecklistCellState with _$ChecklistCellState { return ChecklistCellState( tasks: _makeChecklistSelectOptions(cellData), percent: cellData?.percentage ?? 0, - newTask: false, + showIncompleteOnly: false, + phantomIndex: null, ); } } 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 index c5573a9061..6f1d57fb50 100644 --- 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 @@ -1,4 +1,5 @@ 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'; @@ -6,6 +7,8 @@ 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 { @@ -16,7 +19,7 @@ class DateCellBloc extends Bloc { } final DateCellController cellController; - void Function()? _onCellChangedFn; + VoidCallback? _onCellChangedFn; @override Future close() async { @@ -35,15 +38,19 @@ class DateCellBloc extends Bloc { (event, emit) async { event.when( didReceiveCellUpdate: (DateCellDataPB? cellData) { + final dateCellData = DateCellData.fromPB(cellData); emit( state.copyWith( - data: cellData, - dateStr: _dateStrFromCellData(cellData), + cellData: dateCellData, ), ); }, didUpdateField: (fieldInfo) { - emit(state.copyWith(fieldInfo: fieldInfo)); + emit( + state.copyWith( + fieldInfo: fieldInfo, + ), + ); }, ); }, @@ -79,41 +86,16 @@ class DateCellEvent with _$DateCellEvent { @freezed class DateCellState with _$DateCellState { const factory DateCellState({ - required DateCellDataPB? data, - required String dateStr, required FieldInfo fieldInfo, + required DateCellData cellData, }) = _DateCellState; - factory DateCellState.initial(DateCellController context) { - final cellData = context.getCellData(); + factory DateCellState.initial(DateCellController cellController) { + final cellData = DateCellData.fromPB(cellController.getCellData()); return DateCellState( - fieldInfo: context.fieldInfo, - data: cellData, - dateStr: _dateStrFromCellData(cellData), + fieldInfo: cellController.fieldInfo, + cellData: cellData, ); } } - -String _dateStrFromCellData(DateCellDataPB? cellData) { - if (cellData == null || !cellData.hasTimestamp()) { - return ""; - } - - String dateStr = ""; - if (cellData.isRange) { - if (cellData.includeTime) { - dateStr = - "${cellData.date} ${cellData.time} → ${cellData.endDate} ${cellData.endTime}"; - } else { - dateStr = "${cellData.date} → ${cellData.endDate}"; - } - } else { - if (cellData.includeTime) { - dateStr = "${cellData.date} ${cellData.time}"; - } else { - dateStr = cellData.date; - } - } - return dateStr.trim(); -} 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 index 879e759f35..8f0c37fb0d 100644 --- 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 @@ -2,6 +2,7 @@ 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'; @@ -11,18 +12,17 @@ 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/code.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:flowy_infra/time/duration.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'; +import 'package:protobuf/protobuf.dart' hide FieldInfo; part 'date_cell_editor_bloc.freezed.dart'; @@ -45,6 +45,7 @@ class DateCellEditorBloc final DateCellBackendService _dateCellBackendService; final DateCellController cellController; final ReminderBloc _reminderBloc; + void Function()? _onCellChangedFn; void _dispatch() { @@ -52,303 +53,216 @@ class DateCellEditorBloc (event, emit) async { await event.when( didReceiveCellUpdate: (DateCellDataPB? cellData) { - final dateCellData = _dateDataFromCellData(cellData); - final endDay = - dateCellData.isRange == state.isRange && dateCellData.isRange - ? dateCellData.endDateTime - : null; - ReminderOption option = state.reminderOption; + final dateCellData = DateCellData.fromPB(cellData); - if (dateCellData.dateTime != null && - (state.reminderId?.isEmpty ?? true) && - (dateCellData.reminderId?.isNotEmpty ?? false) && - state.reminderOption != ReminderOption.none) { - final date = state.reminderOption.withoutTime - ? dateCellData.dateTime!.withoutTime - : dateCellData.dateTime!; + ReminderOption reminderOption = ReminderOption.none; - // Add Reminder - _reminderBloc.add( - ReminderEvent.addById( - reminderId: dateCellData.reminderId!, - objectId: cellController.viewId, - meta: { - ReminderMetaKeys.includeTime: true.toString(), - ReminderMetaKeys.rowId: cellController.rowId, - }, - scheduledAt: Int64( - state.reminderOption - .fromDate(date) - .millisecondsSinceEpoch ~/ - 1000, - ), - ), - ); - } - - if ((dateCellData.reminderId?.isNotEmpty ?? false) && + if (dateCellData.reminderId.isNotEmpty && dateCellData.dateTime != null) { - if (option.requiresNoTime && dateCellData.includeTime) { - option = ReminderOption.atTimeOfEvent; - } else if (!option.withoutTime && !dateCellData.includeTime) { - option = ReminderOption.onDayOfEvent; + final reminder = _reminderBloc.state.reminders + .firstWhereOrNull((r) => r.id == dateCellData.reminderId); + if (reminder != null) { + reminderOption = ReminderOption.fromDateDifference( + dateCellData.dateTime!, + reminder.scheduledAt.toDateTime(), + ); } - - final date = option.withoutTime - ? dateCellData.dateTime!.withoutTime - : dateCellData.dateTime!; - - final scheduledAt = option.fromDate(date); - - // Update Reminder - _reminderBloc.add( - ReminderEvent.update( - ReminderUpdate( - id: dateCellData.reminderId!, - scheduledAt: scheduledAt, - includeTime: true, - ), - ), - ); } emit( state.copyWith( dateTime: dateCellData.dateTime, - timeStr: dateCellData.timeStr, endDateTime: dateCellData.endDateTime, - endTimeStr: dateCellData.endTimeStr, includeTime: dateCellData.includeTime, isRange: dateCellData.isRange, - startDay: dateCellData.isRange ? dateCellData.dateTime : null, - endDay: endDay, - dateStr: dateCellData.dateStr, - endDateStr: dateCellData.endDateStr, reminderId: dateCellData.reminderId, - reminderOption: option, + reminderOption: reminderOption, ), ); }, - didReceiveTimeFormatError: ( - String? parseTimeError, - String? parseEndTimeError, - ) { - emit( - state.copyWith( - parseTimeError: parseTimeError, - parseEndTimeError: parseEndTimeError, - ), - ); + didUpdateField: (field) { + final typeOption = DateTypeOptionDataParser() + .fromBuffer(field.field.typeOptionData); + emit(state.copyWith(dateTypeOptionPB: typeOption)); }, - selectDay: (date) async { + updateDateTime: (date) async { + if (state.isRange) { + return; + } + await _updateDateData(date: date); + }, + updateDateRange: (DateTime start, DateTime end) async { if (!state.isRange) { - await _updateDateData(date: date); + return; } + await _updateDateData(date: start, endDate: end); }, - setIncludeTime: (includeTime) async => - _updateDateData(includeTime: includeTime), - setIsRange: (isRange) async => _updateDateData(isRange: isRange), - setTime: (timeStr) async { - emit(state.copyWith(timeStr: timeStr)); - await _updateDateData(timeStr: timeStr); + setIncludeTime: (includeTime, dateTime, endDateTime) async { + await _updateIncludeTime(includeTime, dateTime, endDateTime); }, - selectDateRange: (DateTime? start, DateTime? end) async { - if (end == null && state.startDay != null && state.endDay == null) { - final (newStart, newEnd) = state.startDay!.isBefore(start!) - ? (state.startDay!, start) - : (start, state.startDay!); - - emit(state.copyWith(startDay: null, endDay: null)); - - await _updateDateData(date: newStart.date, endDate: newEnd.date); - } else if (end == null) { - emit(state.copyWith(startDay: start, endDay: null)); - } else { - await _updateDateData(date: start!.date, endDate: end.date); - } + setIsRange: (isRange, dateTime, endDateTime) async { + await _updateIsRange(isRange, dateTime, endDateTime); }, - setStartDay: (DateTime startDay) async { - if (state.endDay == null) { - emit(state.copyWith(startDay: startDay)); - } else if (startDay.isAfter(state.endDay!)) { - emit(state.copyWith(startDay: startDay, endDay: null)); - } else { - emit(state.copyWith(startDay: startDay)); - await _updateDateData( - date: startDay.date, - endDate: state.endDay!.date, - ); - } + setDateFormat: (DateFormatPB dateFormat) async { + await _updateTypeOption(emit, dateFormat: dateFormat); }, - setEndDay: (DateTime endDay) { - if (state.startDay == null) { - emit(state.copyWith(endDay: endDay)); - } else if (endDay.isBefore(state.startDay!)) { - emit(state.copyWith(startDay: null, endDay: endDay)); - } else { - emit(state.copyWith(endDay: endDay)); - _updateDateData(date: state.startDay!.date, endDate: endDay.date); - } + setTimeFormat: (TimeFormatPB timeFormat) async { + await _updateTypeOption(emit, timeFormat: timeFormat); }, - setEndTime: (String? endTime) async { - emit(state.copyWith(endTimeStr: endTime)); - await _updateDateData(endTimeStr: endTime); - }, - 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 != null) { + if (state.reminderId.isNotEmpty) { _reminderBloc - .add(ReminderEvent.remove(reminderId: state.reminderId!)); + .add(ReminderEvent.remove(reminderId: state.reminderId)); } await _clearDate(); }, - setReminderOption: ( - ReminderOption option, - DateTime? selectedDay, - ) async { - if (state.reminderId?.isEmpty ?? - true && - (state.dateTime != null || selectedDay != null) && - option != ReminderOption.none) { - // New Reminder - final reminderId = nanoid(); - await _updateDateData(reminderId: reminderId, date: selectedDay); - - emit( - state.copyWith(reminderOption: option, dateTime: selectedDay), - ); - } else if (option == ReminderOption.none && - (state.reminderId?.isNotEmpty ?? false)) { - // Remove reminder - _reminderBloc - .add(ReminderEvent.remove(reminderId: state.reminderId!)); - await _updateDateData(reminderId: ""); - emit(state.copyWith(reminderOption: option)); - } else if (state.dateTime != null && - (state.reminderId?.isNotEmpty ?? false)) { - final scheduledAt = option.fromDate(state.dateTime!); - - // Update reminder - _reminderBloc.add( - ReminderEvent.update( - ReminderUpdate( - id: state.reminderId!, - scheduledAt: scheduledAt, - includeTime: true, - ), - ), - ); - } + setReminderOption: (ReminderOption option) async { + await _setReminderOption(option); }, - // Empty String signifies no reminder - removeReminder: () async => _updateDateData(reminderId: ""), ); }, ); } - Future _updateDateData({ + Future> _updateDateData({ DateTime? date, - String? timeStr, DateTime? endDate, - String? endTimeStr, - bool? includeTime, - bool? isRange, - String? reminderId, + bool updateReminderIfNecessary = true, }) async { - // make sure that not both date and time are updated at the same time - assert( - !(date != null && timeStr != null) || - !(endDate != null && endTimeStr != null), - ); - - // if not updating the time, use the old time in the state - final String? newTime = timeStr ?? state.timeStr; - final DateTime? newDate = timeStr != null && timeStr.isNotEmpty - ? state.dateTime ?? DateTime.now() - : _utcToLocalAndAddCurrentTime(date); - - // if not updating the time, use the old time in the state - final String? newEndTime = endTimeStr ?? state.endTimeStr; - final DateTime? newEndDate = endTimeStr != null && endTimeStr.isNotEmpty - ? state.endDateTime ?? DateTime.now() - : _utcToLocalAndAddCurrentTime(endDate); - final result = await _dateCellBackendService.update( - date: newDate, - time: newTime, - endDate: newEndDate, - endTime: newEndTime, - includeTime: includeTime ?? state.includeTime, - isRange: isRange ?? state.isRange, - reminderId: reminderId ?? state.reminderId, + date: date, + endDate: endDate, ); + if (updateReminderIfNecessary) { + result.onSuccess((_) => _updateReminderIfNecessary(date)); + } + return result; + } - result.fold( - (_) { - if (!isClosed && - (state.parseEndTimeError != null || state.parseTimeError != null)) { - add(const DateCellEditorEvent.didReceiveTimeFormatError(null, null)); - } - }, - (err) { - switch (err.code) { - case ErrorCode.InvalidDateTimeFormat: - if (isClosed) { - return; - } + Future _updateIsRange( + bool isRange, + DateTime? dateTime, + DateTime? endDateTime, + ) { + return _dateCellBackendService + .update( + date: dateTime, + endDate: endDateTime, + isRange: isRange, + ) + .fold((s) => _updateReminderIfNecessary(dateTime), Log.error); + } - // to determine which textfield should show error - final (startError, endError) = newDate != null - ? (timeFormatPrompt(err), null) - : (null, timeFormatPrompt(err)); - - add( - DateCellEditorEvent.didReceiveTimeFormatError( - startError, - endError, - ), - ); - break; - default: - Log.error(err); - } - }, - ); + 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.fold( - (_) { - if (!isClosed) { - add(const DateCellEditorEvent.didReceiveTimeFormatError(null, null)); - } - }, - (err) => Log.error(err), - ); + result.onFailure(Log.error); } - DateTime? _utcToLocalAndAddCurrentTime(DateTime? date) { - if (date == null) { - return null; + 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, + ), + ), + ); + } } - final now = DateTime.now(); - // the incoming date is Utc. This trick converts it into Local - // and add the current time. The time may be overwritten by - // explicitly provided time string in the backend though - return DateTime( - date.year, - date.month, - date.day, - now.hour, - now.minute, - now.second, + } + + 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, + ), + ), ); } @@ -367,6 +281,7 @@ class DateCellEditorBloc if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, ); } return super.close(); @@ -379,10 +294,17 @@ class DateCellEditorBloc add(DateCellEditorEvent.didReceiveCellUpdate(cell)); } }, + onFieldChanged: _onFieldChangedListener, ); } - Future? _updateTypeOption( + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(DateCellEditorEvent.didUpdateField(fieldInfo)); + } + } + + Future _updateTypeOption( Emitter emit, { DateFormatPB? dateFormat, TimeFormatPB? timeFormat, @@ -404,61 +326,43 @@ class DateCellEditorBloc typeOptionData: newDateTypeOption.writeToBuffer(), ); - result.fold( - (_) => emit( - state.copyWith( - dateTypeOptionPB: newDateTypeOption, - timeHintText: _timeHintText(newDateTypeOption), - ), - ), - (err) => Log.error(err), - ); + 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.didReceiveTimeFormatError( - String? parseTimeError, - String? parseEndTimeError, - ) = _DidReceiveTimeFormatError; + const factory DateCellEditorEvent.updateDateTime(DateTime day) = + _UpdateDateTime; - // date cell data is modified - const factory DateCellEditorEvent.selectDay(DateTime day) = _SelectDay; + const factory DateCellEditorEvent.updateDateRange( + DateTime start, + DateTime end, + ) = _UpdateDateRange; - const factory DateCellEditorEvent.selectDateRange( - DateTime? start, - DateTime? end, - ) = _SelectDateRange; + const factory DateCellEditorEvent.setIncludeTime( + bool includeTime, + DateTime? dateTime, + DateTime? endDateTime, + ) = _IncludeTime; - const factory DateCellEditorEvent.setStartDay( - DateTime startDay, - ) = _SetStartDay; + const factory DateCellEditorEvent.setIsRange( + bool isRange, + DateTime? dateTime, + DateTime? endDateTime, + ) = _SetIsRange; - const factory DateCellEditorEvent.setEndDay( - DateTime endDay, - ) = _SetEndDay; - - const factory DateCellEditorEvent.setTime(String time) = _SetTime; - - const factory DateCellEditorEvent.setEndTime(String endTime) = _SetEndTime; - - const factory DateCellEditorEvent.setIncludeTime(bool includeTime) = - _IncludeTime; - - const factory DateCellEditorEvent.setIsRange(bool isRange) = _SetIsRange; - - const factory DateCellEditorEvent.setReminderOption({ - required ReminderOption option, - @Default(null) DateTime? selectedDay, - }) = _SetReminderOption; - - const factory DateCellEditorEvent.removeReminder() = _RemoveReminder; + const factory DateCellEditorEvent.setReminderOption(ReminderOption option) = + _SetReminderOption; // date field type options are modified const factory DateCellEditorEvent.setTimeFormat(TimeFormatPB timeFormat) = @@ -476,25 +380,12 @@ class DateCellEditorState with _$DateCellEditorState { // the date field's type option required DateTypeOptionPB dateTypeOptionPB, - // used when selecting a date range - required DateTime? startDay, - required DateTime? endDay, - // cell data from the backend required DateTime? dateTime, required DateTime? endDateTime, - required String? timeStr, - required String? endTimeStr, required bool includeTime, required bool isRange, - required String? dateStr, - required String? endDateStr, - required String? reminderId, - - // error and hint text - required String? parseTimeError, - required String? parseEndTimeError, - required String timeHintText, + required String reminderId, @Default(ReminderOption.none) ReminderOption reminderOption, }) = _DateCellEditorState; @@ -504,11 +395,11 @@ class DateCellEditorState with _$DateCellEditorState { ) { final typeOption = controller.getTypeOption(DateTypeOptionDataParser()); final cellData = controller.getCellData(); - final dateCellData = _dateDataFromCellData(cellData); + final dateCellData = DateCellData.fromPB(cellData); ReminderOption reminderOption = ReminderOption.none; - if ((dateCellData.reminderId?.isNotEmpty ?? false) && - dateCellData.dateTime != null) { + + if (dateCellData.reminderId.isNotEmpty && dateCellData.dateTime != null) { final reminder = reminderBloc.state.reminders .firstWhereOrNull((r) => r.id == dateCellData.reminderId); if (reminder != null) { @@ -524,114 +415,60 @@ class DateCellEditorState with _$DateCellEditorState { return DateCellEditorState( dateTypeOptionPB: typeOption, - startDay: dateCellData.isRange ? dateCellData.dateTime : null, - endDay: dateCellData.isRange ? dateCellData.endDateTime : null, dateTime: dateCellData.dateTime, endDateTime: dateCellData.endDateTime, - timeStr: dateCellData.timeStr, - endTimeStr: dateCellData.endTimeStr, - dateStr: dateCellData.dateStr, - endDateStr: dateCellData.endDateStr, includeTime: dateCellData.includeTime, isRange: dateCellData.isRange, - parseTimeError: null, - parseEndTimeError: null, - timeHintText: _timeHintText(typeOption), reminderId: dateCellData.reminderId, reminderOption: reminderOption, ); } } -String _timeHintText(DateTypeOptionPB typeOption) { - switch (typeOption.timeFormat) { - case TimeFormatPB.TwelveHour: - return LocaleKeys.document_date_timeHintTextInTwelveHour.tr(); - case TimeFormatPB.TwentyFourHour: - return LocaleKeys.document_date_timeHintTextInTwentyFourHour.tr(); - default: - return ""; - } -} - -_DateCellData _dateDataFromCellData( - 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 _DateCellData( - dateTime: null, - endDateTime: null, - timeStr: null, - endTimeStr: null, - includeTime: false, - isRange: false, - dateStr: null, - endDateStr: null, - reminderId: null, - ); - } - - DateTime? dateTime; - String? timeStr; - DateTime? endDateTime; - String? endTimeStr; - - String? endDateStr; - if (cellData.hasTimestamp()) { - final timestamp = cellData.timestamp * 1000; - dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp.toInt()); - timeStr = cellData.time; - if (cellData.hasEndTimestamp()) { - final endTimestamp = cellData.endTimestamp * 1000; - endDateTime = DateTime.fromMillisecondsSinceEpoch(endTimestamp.toInt()); - endTimeStr = cellData.endTime; - } - } - - final bool includeTime = cellData.includeTime; - final bool isRange = cellData.isRange; - - if (cellData.isRange) { - endDateStr = cellData.endDate; - } - - final String dateStr = cellData.date; - - return _DateCellData( - dateTime: dateTime, - endDateTime: endDateTime, - timeStr: timeStr, - endTimeStr: endTimeStr, - includeTime: includeTime, - isRange: isRange, - dateStr: dateStr, - endDateStr: endDateStr, - reminderId: cellData.reminderId, - ); -} - -class _DateCellData { - _DateCellData({ +/// Helper class to parse ProtoBuf payloads into DateCellEditorState +class DateCellData { + const DateCellData({ required this.dateTime, required this.endDateTime, - required this.timeStr, - required this.endTimeStr, required this.includeTime, required this.isRange, - required this.dateStr, - required this.endDateStr, 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 String? timeStr; - final String? endTimeStr; final bool includeTime; final bool isRange; - final String? dateStr; - final String? endDateStr; - final String? reminderId; + 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 0000000000..7a3075e0f2 --- /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 index df159b817b..73b2d2977b 100644 --- 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 @@ -47,15 +47,6 @@ class NumberCellBloc extends Bloc { 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(), - ), - ); } }, ); 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 index 28ff1b2f78..ec789b03a0 100644 --- 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 @@ -48,7 +48,7 @@ class RelationCellBloc extends Bloc { emit(state.copyWith(rows: const [])); return; } - final payload = RepeatedRowIdPB( + final payload = GetRelatedRowDataPB( databaseId: state.relatedDatabaseMeta!.databaseId, rowIds: cellData.rowIds, ); @@ -143,12 +143,11 @@ class RelationCellBloc extends Bloc { (f) => null, ); if (databaseMeta != null) { - final result = - await ViewBackendService.getView(databaseMeta.inlineViewId); + final result = await ViewBackendService.getView(databaseMeta.viewId); return result.fold( (s) => DatabaseMeta( databaseId: databaseId, - inlineViewId: databaseMeta.inlineViewId, + viewId: databaseMeta.viewId, databaseName: s.name, ), (f) => null, 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 index c0bfa48a85..c6e4e6484b 100644 --- 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 @@ -1,5 +1,7 @@ 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'; @@ -9,7 +11,6 @@ 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/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -109,16 +110,16 @@ class SelectOptionCellEditorBloc selectOption: (optionId) async { await _selectOptionService.select(optionIds: [optionId]); }, - unSelectOption: (optionId) async { - await _selectOptionService.unSelect(optionIds: [optionId]); + unselectOption: (optionId) async { + await _selectOptionService.unselect(optionIds: [optionId]); }, - unSelectLastOption: () async { + unselectLastOption: () async { if (state.selectedOptions.isEmpty) { return; } final lastSelectedOptionId = state.selectedOptions.last.id; await _selectOptionService - .unSelect(optionIds: [lastSelectedOptionId]); + .unselect(optionIds: [lastSelectedOptionId]); }, submitTextField: () { _submitTextFieldValue(emit); @@ -240,6 +241,11 @@ class SelectOptionCellEditorBloc } else if (!state.selectedOptions .any((option) => option.id == focusedOptionId)) { _selectOptionService.select(optionIds: [focusedOptionId]); + emit( + state.copyWith( + clearFilter: true, + ), + ); } } @@ -353,10 +359,10 @@ class SelectOptionCellEditorEvent with _$SelectOptionCellEditorEvent { 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.unselectOption(String optionId) = + _UnselectOption; + const factory SelectOptionCellEditorEvent.unselectLastOption() = + _UnselectLastOption; const factory SelectOptionCellEditorEvent.updateOption( SelectOptionPB option, ) = _UpdateOption; 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 index 22baf26599..7960b34d7c 100644 --- 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 @@ -2,6 +2,7 @@ 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'; @@ -33,7 +34,7 @@ class TextCellBloc extends Bloc { on( (event, emit) { event.when( - didReceiveCellUpdate: (String content) { + didReceiveCellUpdate: (content) { emit(state.copyWith(content: content)); }, didUpdateField: (fieldInfo) { @@ -42,11 +43,10 @@ class TextCellBloc extends Bloc { emit(state.copyWith(wrap: wrap)); } }, - didUpdateEmoji: (String emoji) { - emit(state.copyWith(emoji: emoji)); - }, updateText: (String text) { - if (state.content != 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); } }, @@ -62,17 +62,10 @@ class TextCellBloc extends Bloc { _onCellChangedFn = cellController.addListener( onCellChanged: (cellContent) { if (!isClosed) { - add(TextCellEvent.didReceiveCellUpdate(cellContent ?? "")); + add(TextCellEvent.didReceiveCellUpdate(cellContent)); } }, onFieldChanged: _onFieldChangedListener, - onRowMetaChanged: cellController.fieldInfo.isPrimary - ? () { - if (!isClosed) { - add(TextCellEvent.didUpdateEmoji(cellController.icon ?? "")); - } - } - : null, ); } @@ -85,34 +78,39 @@ class TextCellBloc extends Bloc { @freezed class TextCellEvent with _$TextCellEvent { - const factory TextCellEvent.didReceiveCellUpdate(String cellContent) = + 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; - const factory TextCellEvent.didUpdateEmoji(String emoji) = _UpdateEmoji; } @freezed class TextCellState with _$TextCellState { const factory TextCellState({ - required String content, - required String emoji, + 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 cellData = cellController.getCellData(); final wrap = cellController.fieldInfo.wrapCellContent ?? true; - final emoji = - cellController.fieldInfo.isPrimary ? cellController.icon ?? "" : ""; + 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 0000000000..62ff95850f --- /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/translate_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/translate_cell_bloc.dart new file mode 100644 index 0000000000..f31a4a1c91 --- /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 0000000000..4778df2c2a --- /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/cell_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart index 858acadc5a..d9009b2ba0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart @@ -5,7 +5,6 @@ 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/domain/row_meta_listener.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'; @@ -61,10 +60,8 @@ class CellController { final CellDataPersistence _cellDataPersistence; CellListener? _cellListener; - RowMetaListener? _rowMetaListener; CellDataNotifier? _cellDataNotifier; - VoidCallback? _onRowMetaChanged; Timer? _loadDataOperation; Timer? _saveDataOperation; @@ -75,8 +72,9 @@ class CellController { FieldInfo get fieldInfo => _fieldController.getField(_cellContext.fieldId)!; FieldType get fieldType => _fieldController.getField(_cellContext.fieldId)!.fieldType; - RowMetaPB? get rowMeta => _rowCache.getRow(rowId)?.rowMeta; - String? get icon => rowMeta?.icon; + 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 @@ -107,23 +105,12 @@ class CellController { fieldId, onFieldChanged: _onFieldChangedListener, ); - - // 3. If the field is primary listen to row meta changes. - if (fieldInfo.field.isPrimary) { - _rowMetaListener = RowMetaListener(_cellContext.rowId); - _rowMetaListener?.start( - callback: (newRowMeta) { - _onRowMetaChanged?.call(); - }, - ); - } } /// Add a new listener VoidCallback? addListener({ required void Function(T?) onCellChanged, void Function(FieldInfo fieldInfo)? onFieldChanged, - VoidCallback? onRowMetaChanged, }) { /// an adaptor for the onCellChanged listener void onCellChangedFn() => onCellChanged(_cellDataNotifier?.value); @@ -136,8 +123,6 @@ class CellController { ); } - _onRowMetaChanged = onRowMetaChanged; - // Return the function pointer that can be used when calling removeListener. return onCellChangedFn; } @@ -233,9 +218,6 @@ class CellController { } Future dispose() async { - await _rowMetaListener?.stop(); - _rowMetaListener = null; - await _cellListener?.stop(); _cellListener = null; @@ -249,7 +231,6 @@ class CellController { _saveDataOperation?.cancel(); _cellDataNotifier?.dispose(); _cellDataNotifier = null; - _onRowMetaChanged = null; } } @@ -260,15 +241,11 @@ class CellDataNotifier extends ChangeNotifier { bool Function(T? oldValue, T? newValue)? listenWhen; set value(T newValue) { - if (listenWhen?.call(_value, newValue) ?? false) { - _value = newValue; - notifyListeners(); - } else { - if (_value != newValue) { - _value = newValue; - notifyListeners(); - } + 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 index e4866a1517..afe05e8b70 100644 --- 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 @@ -16,6 +16,9 @@ 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, @@ -120,7 +123,6 @@ CellController makeCellController( ), cellDataPersistence: TextCellDataPersistence(), ); - case FieldType.Relation: return RelationCellController( viewId: viewId, @@ -145,6 +147,42 @@ CellController makeCellController( ), 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 index 4edae575ce..cfab4668ae 100644 --- 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 @@ -181,3 +181,34 @@ class RelationCellDataParser implements CellDataParser { } } } + +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/database_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart index b1bc6c43a5..5317539128 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart @@ -40,7 +40,9 @@ class GroupCallbacks { } class DatabaseLayoutSettingCallbacks { - DatabaseLayoutSettingCallbacks({required this.onLayoutSettingsChanged}); + DatabaseLayoutSettingCallbacks({ + required this.onLayoutSettingsChanged, + }); final void Function(DatabaseLayoutSettingPB) onLayoutSettingsChanged; } @@ -96,9 +98,11 @@ class DatabaseController { final List _databaseCallbacks = []; final List _groupCallbacks = []; final List _layoutCallbacks = []; + final Set> _compactModeCallbacks = {}; // Getters RowCache get rowCache => _viewCache.rowCache; + String get viewId => view.id; // Listener @@ -106,17 +110,26 @@ class DatabaseController { final DatabaseLayoutSettingListener _layoutListener; final ValueNotifier _isLoading = ValueNotifier(true); + final ValueNotifier _compactMode = ValueNotifier(true); - void setIsLoading(bool isLoading) { - _isLoading.value = isLoading; - } + void setIsLoading(bool isLoading) => _isLoading.value = isLoading; ValueNotifier get isLoading => _isLoading; + void setCompactMode(bool compactMode) { + _compactMode.value = compactMode; + for (final callback in Set.of(_compactModeCallbacks)) { + callback.call(compactMode); + } + } + + ValueNotifier get compactModeNotifier => _compactMode; + void addListener({ DatabaseCallbacks? onDatabaseChanged, DatabaseLayoutSettingCallbacks? onLayoutSettingsChanged, GroupCallbacks? onGroupChanged, + ValueChanged? onCompactModeChanged, }) { if (onLayoutSettingsChanged != null) { _layoutCallbacks.add(onLayoutSettingsChanged); @@ -129,6 +142,33 @@ class DatabaseController { if (onGroupChanged != null) { _groupCallbacks.add(onGroupChanged); } + + if (onCompactModeChanged != null) { + _compactModeCallbacks.add(onCompactModeChanged); + } + } + + void removeListener({ + DatabaseCallbacks? onDatabaseChanged, + DatabaseLayoutSettingCallbacks? onLayoutSettingsChanged, + GroupCallbacks? onGroupChanged, + ValueChanged? onCompactModeChanged, + }) { + if (onDatabaseChanged != null) { + _databaseCallbacks.remove(onDatabaseChanged); + } + + if (onLayoutSettingsChanged != null) { + _layoutCallbacks.remove(onLayoutSettingsChanged); + } + + if (onGroupChanged != null) { + _groupCallbacks.remove(onGroupChanged); + } + + if (onCompactModeChanged != null) { + _compactModeCallbacks.remove(onCompactModeChanged); + } } Future> open() async { @@ -223,6 +263,8 @@ class DatabaseController { _databaseCallbacks.clear(); _groupCallbacks.clear(); _layoutCallbacks.clear(); + _compactModeCallbacks.clear(); + _isLoading.dispose(); } Future _loadGroups() async { @@ -356,4 +398,10 @@ class DatabaseController { }, ); } + + void initCompactMode(bool enableCompactMode) { + if (_compactMode.value != enableCompactMode) { + _compactMode.value = enableCompactMode; + } + } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/defines.dart b/frontend/appflowy_flutter/lib/plugins/database/application/defines.dart index 48785fc87f..65deae7e58 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/defines.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/defines.dart @@ -1,25 +1,24 @@ import 'dart:collection'; -// TODO(RS): remove dependency on presentation code -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; +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 OnFiltersChanged = void Function(List); +typedef OnSortsChanged = void Function(List); typedef OnDatabaseChanged = void Function(DatabasePB); -typedef OnRowsCreated = void Function(List rowIds); +typedef OnRowsCreated = void Function(List rows); typedef OnRowsUpdated = void Function( List rowIds, ChangedReason reason, @@ -30,6 +29,9 @@ typedef OnNumOfRowsChanged = void Function( UnmodifiableMapView rowById, ChangedReason reason, ); +typedef OnRowsVisibilityChanged = void Function( + List<(RowId, bool)> rowVisibilityChanges, +); @freezed class LoadingState with _$LoadingState { 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 index 7fa5c81d3d..93fd69bcfc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart @@ -1,7 +1,5 @@ import 'dart:collection'; -import 'package:flutter/foundation.dart'; - 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'; @@ -12,17 +10,17 @@ 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/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_info.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 = []; @@ -41,9 +39,9 @@ class _GridFieldNotifier extends ChangeNotifier { } class _GridFilterNotifier extends ChangeNotifier { - List _filters = []; + List _filters = []; - set filters(List filters) { + set filters(List filters) { _filters = filters; notifyListeners(); } @@ -52,13 +50,13 @@ class _GridFilterNotifier extends ChangeNotifier { notifyListeners(); } - List get filters => _filters; + List get filters => _filters; } class _GridSortNotifier extends ChangeNotifier { - List _sorts = []; + List _sorts = []; - set sorts(List sorts) { + set sorts(List sorts) { _sorts = sorts; notifyListeners(); } @@ -67,15 +65,14 @@ class _GridSortNotifier extends ChangeNotifier { notifyListeners(); } - List get sorts => _sorts; + 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); - +typedef OnReceiveFilters = void Function(List); +typedef OnReceiveSorts = void Function(List); class FieldController { FieldController({required this.viewId}) @@ -135,30 +132,32 @@ class FieldController { // Getters List get fieldInfos => [..._fieldNotifier.fieldInfos]; - List get filterInfos => [..._filterNotifier?.filters ?? []]; - List get sortInfos => [..._sortNotifier?.sorts ?? []]; + 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); } - FilterInfo? getFilterByFilterId(String filterId) { + DatabaseFilter? getFilterByFilterId(String filterId) { return _filterNotifier?.filters .firstWhereOrNull((element) => element.filterId == filterId); } - FilterInfo? getFilterByFieldId(String fieldId) { + DatabaseFilter? getFilterByFieldId(String fieldId) { return _filterNotifier?.filters .firstWhereOrNull((element) => element.fieldId == fieldId); } - SortInfo? getSortBySortId(String sortId) { + DatabaseSort? getSortBySortId(String sortId) { return _sortNotifier?.sorts .firstWhereOrNull((element) => element.sortId == sortId); } - SortInfo? getSortByFieldId(String fieldId) { + DatabaseSort? getSortByFieldId(String fieldId) { return _sortNotifier?.sorts .firstWhereOrNull((element) => element.fieldId == fieldId); } @@ -173,22 +172,10 @@ class FieldController { result.fold( (FilterChangesetNotificationPB changeset) { - final List filters = []; - for (final filter in changeset.filters.items) { - final fieldInfo = _findFieldInfo( - fieldInfos: fieldInfos, - fieldId: filter.data.fieldId, - fieldType: filter.data.fieldType, - ); - - if (fieldInfo != null) { - final filterInfo = FilterInfo(viewId, filter, fieldInfo); - filters.add(filterInfo); - } - } - - _filterNotifier?.filters = filters; - _updateFieldInfos(); + _filterNotifier?.filters = + _filterListFromPBs(changeset.filters.items); + _fieldNotifier.fieldInfos = + _updateFieldInfos(_fieldNotifier.fieldInfos); }, (err) => Log.error(err), ); @@ -199,76 +186,55 @@ class FieldController { /// Listen for sort changes in the backend. void _listenOnSortChanged() { void deleteSortFromChangeset( - List newSortInfos, + List newDatabaseSorts, SortChangesetNotificationPB changeset, ) { final deleteSortIds = changeset.deleteSorts.map((e) => e.id).toList(); if (deleteSortIds.isNotEmpty) { - newSortInfos.retainWhere( + newDatabaseSorts.retainWhere( (element) => !deleteSortIds.contains(element.sortId), ); } } void insertSortFromChangeset( - List newSortInfos, + List newDatabaseSorts, SortChangesetNotificationPB changeset, ) { for (final newSortPB in changeset.insertSorts) { - final sortIndex = newSortInfos + final sortIndex = newDatabaseSorts .indexWhere((element) => element.sortId == newSortPB.sort.id); if (sortIndex == -1) { - final fieldInfo = _findFieldInfo( - fieldInfos: fieldInfos, - fieldId: newSortPB.sort.fieldId, - fieldType: null, + newDatabaseSorts.insert( + newSortPB.index, + DatabaseSort.fromPB(newSortPB.sort), ); - - if (fieldInfo != null) { - newSortInfos.insert( - newSortPB.index, - SortInfo(sortPB: newSortPB.sort, fieldInfo: fieldInfo), - ); - } } } } void updateSortFromChangeset( - List newSortInfos, + List newDatabaseSorts, SortChangesetNotificationPB changeset, ) { for (final updatedSort in changeset.updateSorts) { - final sortIndex = newSortInfos.indexWhere( + final newDatabaseSort = DatabaseSort.fromPB(updatedSort); + + final sortIndex = newDatabaseSorts.indexWhere( (element) => element.sortId == updatedSort.id, ); - // Remove the old filter + if (sortIndex != -1) { - newSortInfos.removeAt(sortIndex); - } - - final fieldInfo = _findFieldInfo( - fieldInfos: fieldInfos, - fieldId: updatedSort.fieldId, - fieldType: null, - ); - - if (fieldInfo != null) { - final newSortInfo = SortInfo( - sortPB: updatedSort, - fieldInfo: fieldInfo, - ); - if (sortIndex != -1) { - newSortInfos.insert(sortIndex, newSortInfo); - } else { - newSortInfos.add(newSortInfo); - } + newDatabaseSorts.removeAt(sortIndex); + newDatabaseSorts.insert(sortIndex, newDatabaseSort); + } else { + newDatabaseSorts.add(newDatabaseSort); } } } void updateFieldInfos( - List newSortInfos, + List newDatabaseSorts, SortChangesetNotificationPB changeset, ) { final changedFieldIds = HashSet.from([ @@ -287,7 +253,7 @@ class FieldController { continue; } newFieldInfos[index] = newFieldInfos[index].copyWith( - hasSort: newSortInfos.any((sort) => sort.fieldId == fieldId), + hasSort: newDatabaseSorts.any((sort) => sort.fieldId == fieldId), ); } @@ -301,13 +267,13 @@ class FieldController { } result.fold( (SortChangesetNotificationPB changeset) { - final List newSortInfos = sortInfos; - deleteSortFromChangeset(newSortInfos, changeset); - insertSortFromChangeset(newSortInfos, changeset); - updateSortFromChangeset(newSortInfos, changeset); + final List newDatabaseSorts = sorts; + deleteSortFromChangeset(newDatabaseSorts, changeset); + insertSortFromChangeset(newDatabaseSorts, changeset); + updateSortFromChangeset(newDatabaseSorts, changeset); - updateFieldInfos(newSortInfos, changeset); - _sortNotifier?.sorts = newSortInfos; + updateFieldInfos(newDatabaseSorts, changeset); + _sortNotifier?.sorts = newDatabaseSorts; }, (err) => Log.error(err), ); @@ -431,7 +397,7 @@ class FieldController { (updatedFields, fieldInfos) = await updateFields(changeset.updatedFields, fieldInfos); - _fieldNotifier.fieldInfos = fieldInfos; + _fieldNotifier.fieldInfos = _updateFieldInfos(fieldInfos); for (final listener in _updatedFieldCallbacks.values) { listener(updatedFields); } @@ -444,20 +410,29 @@ class FieldController { /// Listen for field setting changes in the backend. void _listenOnFieldSettingsChanged() { - FieldInfo updateFieldSettings(FieldSettingsPB updatedFieldSettings) { - final List newFields = fieldInfos; - FieldInfo updatedField = newFields[0]; + FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) { + final newFields = [...fieldInfos]; + + if (newFields.isEmpty) { + 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; + _fieldSettings + ..removeWhere( + (field) => field.fieldId == updatedFieldSettings.fieldId, + ) + ..add(updatedFieldSettings); + return newFields[index]; } - _fieldNotifier.fieldInfos = newFields; - return updatedField; + return null; } _fieldSettingsListener.start( @@ -468,6 +443,10 @@ class FieldController { result.fold( (fieldSettings) { final updatedFieldInfo = updateFieldSettings(fieldSettings); + if (updatedFieldInfo == null) { + return; + } + for (final listener in _updatedFieldCallbacks.values) { listener([updatedFieldInfo]); } @@ -485,32 +464,29 @@ class FieldController { _groupConfigurationByFieldId[configuration.fieldId] = configuration; } - _filterNotifier?.filters = _filterInfoListFromPBs(setting.filters.items); + _filterNotifier?.filters = _filterListFromPBs(setting.filters.items); - _sortNotifier?.sorts = _sortInfoListFromPBs(setting.sorts.items); + _sortNotifier?.sorts = _sortListFromPBs(setting.sorts.items); _fieldSettings.clear(); _fieldSettings.addAll(setting.fieldSettings.items); - _updateFieldInfos(); + _fieldNotifier.fieldInfos = _updateFieldInfos(_fieldNotifier.fieldInfos); } /// Attach sort, filter, group information and field settings to `FieldInfo` - void _updateFieldInfos() { - final List newFieldInfos = []; - for (final field in _fieldNotifier.fieldInfos) { - newFieldInfos.add( - 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, - ), - ); - } - - _fieldNotifier.fieldInfos = newFieldInfos; + 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 @@ -533,7 +509,8 @@ class FieldController { _loadAllFieldSettings(), _loadSettings(), ]); - _updateFieldInfos(); + _fieldNotifier.fieldInfos = + _updateFieldInfos(_fieldNotifier.fieldInfos); return FlowyResult.success(null); }, @@ -547,7 +524,7 @@ class FieldController { return _filterBackendSvc.getAllFilters().then((result) { return result.fold( (filterPBs) { - _filterNotifier?.filters = _filterInfoListFromPBs(filterPBs); + _filterNotifier?.filters = _filterListFromPBs(filterPBs); return FlowyResult.success(null); }, (err) => FlowyResult.failure(err), @@ -560,7 +537,7 @@ class FieldController { return _sortBackendSvc.getAllSorts().then((result) { return result.fold( (sortPBs) { - _sortNotifier?.sorts = _sortInfoListFromPBs(sortPBs); + _sortNotifier?.sorts = _sortListFromPBs(sortPBs); return FlowyResult.success(null); }, (err) => FlowyResult.failure(err), @@ -599,39 +576,13 @@ class FieldController { } /// Attach corresponding `FieldInfo`s to the `FilterPB`s - List _filterInfoListFromPBs(List filterPBs) { - FilterInfo? getFilterInfo(FilterPB filterPB) { - final fieldInfo = _findFieldInfo( - fieldInfos: fieldInfos, - fieldId: filterPB.data.fieldId, - fieldType: filterPB.data.fieldType, - ); - return fieldInfo != null ? FilterInfo(viewId, filterPB, fieldInfo) : null; - } - - return filterPBs - .map((filterPB) => getFilterInfo(filterPB)) - .whereType() - .toList(); + List _filterListFromPBs(List filterPBs) { + return filterPBs.map(DatabaseFilter.fromPB).toList(); } /// Attach corresponding `FieldInfo`s to the `SortPB`s - List _sortInfoListFromPBs(List sortPBs) { - SortInfo? getSortInfo(SortPB sortPB) { - final fieldInfo = _findFieldInfo( - fieldInfos: fieldInfos, - fieldId: sortPB.fieldId, - fieldType: null, - ); - return fieldInfo != null - ? SortInfo(sortPB: sortPB, fieldInfo: fieldInfo) - : null; - } - - return sortPBs - .map((sortPB) => getSortInfo(sortPB)) - .whereType() - .toList(); + List _sortListFromPBs(List sortPBs) { + return sortPBs.map(DatabaseSort.fromPB).toList(); } void addListener({ @@ -669,7 +620,7 @@ class FieldController { if (listenWhen != null && listenWhen() == false) { return; } - onFilters(filterInfos); + onFilters(filters); } _filterCallbacks[onFilters] = callback; @@ -681,7 +632,7 @@ class FieldController { if (listenWhen != null && listenWhen() == false) { return; } - onSorts(sortInfos); + onSorts(sorts); } _sortCallbacks[onSorts] = callback; @@ -820,15 +771,3 @@ class RowCacheDependenciesImpl extends RowFieldsDelegate with RowLifeCycle { } } } - -FieldInfo? _findFieldInfo({ - required List fieldInfos, - required String fieldId, - required FieldType? fieldType, -}) { - return fieldInfos.firstWhereOrNull( - (element) => - element.id == fieldId && - (fieldType == null || element.fieldType == fieldType), - ); -} 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 index b33f9f44ee..1c056b1461 100644 --- 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 @@ -2,6 +2,7 @@ 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'; @@ -17,30 +18,33 @@ part 'field_editor_bloc.freezed.dart'; class FieldEditorBloc extends Bloc { FieldEditorBloc({ required this.viewId, + required this.fieldInfo, required this.fieldController, this.onFieldInserted, - required FieldPB field, - }) : fieldId = field.id, - fieldService = FieldBackendService( + required this.isNew, + }) : _fieldService = FieldBackendService( viewId: viewId, - fieldId: field.id, + fieldId: fieldInfo.id, ), fieldSettingsService = FieldSettingsBackendService(viewId: viewId), - super(FieldEditorState(field: FieldInfo.initial(field))) { + super(FieldEditorState(field: fieldInfo)) { _dispatch(); _startListening(); _init(); } final String viewId; - final String fieldId; + final FieldInfo fieldInfo; + final bool isNew; final FieldController fieldController; - final FieldBackendService fieldService; + 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( @@ -58,10 +62,23 @@ class FieldEditorBloc extends Bloc { emit(state.copyWith(field: fieldInfo)); }, switchFieldType: (fieldType) async { - await fieldService.updateType(fieldType: fieldType); + 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); + 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 { @@ -73,14 +90,14 @@ class FieldEditorBloc extends Bloc { _logIfError(result); }, insertLeft: () async { - final result = await fieldService.createBefore(); + 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(); + final result = await _fieldService.createAfter(); result.fold( (newField) => onFieldInserted?.call(newField.id), (err) => Log.error("Failed creating field $err"), @@ -94,7 +111,7 @@ class FieldEditorBloc extends Bloc { ? FieldVisibility.AlwaysShown : FieldVisibility.AlwaysHidden; final result = await fieldSettingsService.updateFieldSettings( - fieldId: state.field.id, + fieldId: fieldId, fieldVisibility: newVisibility, ); _logIfError(result); @@ -152,6 +169,7 @@ class FieldEditorEvent with _$FieldEditorEvent { 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() = @@ -164,5 +182,6 @@ class FieldEditorEvent with _$FieldEditorEvent { 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 index 1022b1f839..46fc8659ca 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart @@ -29,6 +29,8 @@ class FieldInfo with _$FieldInfo { String get name => field.name; + String get icon => field.icon; + bool get isPrimary => field.isPrimary; double? get width => fieldSettings?.width.toDouble(); @@ -36,57 +38,4 @@ class FieldInfo with _$FieldInfo { FieldVisibility? get visibility => fieldSettings?.visibility; bool? get wrapCellContent => fieldSettings?.wrapCellContent; - - bool get canBeGroup { - switch (field.fieldType) { - case FieldType.URL: - case FieldType.Checkbox: - case FieldType.MultiSelect: - case FieldType.SingleSelect: - case FieldType.DateTime: - return true; - default: - return false; - } - } - - bool get canCreateFilter { - if (isGroupField) { - return false; - } - - switch (field.fieldType) { - case FieldType.Number: - case FieldType.Checkbox: - case FieldType.MultiSelect: - case FieldType.RichText: - case FieldType.SingleSelect: - case FieldType.Checklist: - case FieldType.URL: - return true; - default: - return false; - } - } - - bool get canCreateSort { - if (hasSort) { - return false; - } - - switch (field.fieldType) { - case FieldType.RichText: - case FieldType.Checkbox: - case FieldType.Number: - case FieldType.DateTime: - case FieldType.SingleSelect: - case FieldType.MultiSelect: - case FieldType.LastEditedTime: - case FieldType.CreatedTime: - case FieldType.Checklist: - return true; - default: - return false; - } - } } 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 0000000000..46867e1f97 --- /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 0000000000..a5aeaa3e28 --- /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/relation_type_option_cubit.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart index df8e0d46fb..4ddde80b79 100644 --- 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 @@ -1,6 +1,7 @@ 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'; @@ -12,17 +13,15 @@ class RelationDatabaseListCubit extends Cubit { } void _loadDatabaseMetas() async { - final getDatabaseResult = await DatabaseEventGetDatabases().send(); - final metaPBs = getDatabaseResult.fold>( - (s) => s.items, - (f) => [], - ); + final metaPBs = await DatabaseEventGetDatabases() + .send() + .fold>((s) => s.items, (f) => []); final futures = metaPBs.map((meta) { - return ViewBackendService.getView(meta.inlineViewId).then( + return ViewBackendService.getView(meta.viewId).then( (result) => result.fold( (s) => DatabaseMeta( databaseId: meta.databaseId, - inlineViewId: meta.inlineViewId, + viewId: meta.viewId, databaseName: s.name, ), (f) => null, @@ -44,10 +43,10 @@ class DatabaseMeta with _$DatabaseMeta { /// id of the database required String databaseId, - /// id of the inline view - required String inlineViewId, + /// id of the view + required String viewId, - /// name of the database, currently identical to the name of the inline view + /// name of the database required String databaseName, }) = _DatabaseMeta; } 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 0000000000..43e990f6d4 --- /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 index f93a7a3d02..c76e6d095c 100644 --- 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 @@ -4,8 +4,6 @@ abstract class TypeOptionParser { T fromBuffer(List buffer); } - - class NumberTypeOptionDataParser extends TypeOptionParser { @override NumberTypeOptionPB fromBuffer(List buffer) { @@ -51,3 +49,18 @@ class RelationTypeOptionDataParser 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 index a5dd0d9ca1..0f884a1e9a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart @@ -31,6 +31,7 @@ class DatabaseLayoutBloc @freezed class DatabaseLayoutEvent with _$DatabaseLayoutEvent { const factory DatabaseLayoutEvent.initial() = _Initial; + const factory DatabaseLayoutEvent.updateLayout(DatabaseLayoutPB layout) = _UpdateLayout; } 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 index 06e1e2b70f..f735618dd8 100644 --- 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 @@ -1,11 +1,14 @@ 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'; @@ -20,12 +23,15 @@ class RelatedRowDetailPageBloc _init(databaseId, initialRowId); } + UserProfilePB? _userProfile; + UserProfilePB? get userProfile => _userProfile; + @override Future close() { state.whenOrNull( - ready: (databaseController, rowController) { - rowController.dispose(); - databaseController.dispose(); + ready: (databaseController, rowController) async { + await rowController.dispose(); + await databaseController.dispose(); }, ); return super.close(); @@ -33,11 +39,19 @@ class RelatedRowDetailPageBloc void _dispatch() { on((event, emit) async { - event.when( - didInitialize: (databaseController, rowController) { - state.maybeWhen( - ready: (_, oldRowController) { - oldRowController.dispose(); + 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, @@ -59,27 +73,24 @@ class RelatedRowDetailPageBloc }); } - /// 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) { + final viewId = await DatabaseEventGetDefaultDatabaseViewId( + DatabaseIdPB(value: databaseId), + ).send().fold( + (pb) => pb.value, + (error) => null, + ); + + if (viewId == null) { return; } - final inlineView = - await ViewBackendService.getView(databaseMeta.inlineViewId) - .fold((viewPB) => viewPB, (f) => null); - if (inlineView == null) { + + final databaseView = await ViewBackendService.getView(viewId) + .fold((viewPB) => viewPB, (f) => null); + if (databaseView == null) { return; } - final databaseController = DatabaseController(view: inlineView); + final databaseController = DatabaseController(view: databaseView); await databaseController.open().fold( (s) => databaseController.setIsLoading(false), (f) => null, @@ -90,9 +101,10 @@ class RelatedRowDetailPageBloc } final rowController = RowController( rowMeta: rowInfo.rowMeta, - viewId: inlineView.id, + viewId: databaseView.id, rowCache: databaseController.rowCache, ); + add( RelatedRowDetailPageEvent.didInitialize( databaseController, 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 index 1f05352dc5..7714b7727f 100644 --- 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 @@ -1,8 +1,13 @@ +import 'package:flutter/foundation.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/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'; @@ -26,6 +31,11 @@ class RowBannerBloc extends Bloc { 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(); @@ -36,15 +46,21 @@ class RowBannerBloc extends Bloc { on( (event, emit) { event.when( - initial: () { - _loadPrimaryField(); + 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: (String coverURL) => _updateMeta(coverURL: coverURL), + setCover: (RowCoverPB cover) => _updateMeta(cover: cover), setIcon: (String iconURL) => _updateMeta(iconURL: iconURL), + removeCover: () => _removeCover(), didReceiveFieldUpdate: (updatedField) { emit( state.copyWith( @@ -91,14 +107,19 @@ class RowBannerBloc extends Bloc { } /// Update the meta of the row and the view - Future _updateMeta({String? iconURL, String? coverURL}) async { + Future _updateMeta({String? iconURL, RowCoverPB? cover}) async { final result = await _rowBackendSvc.updateMeta( iconURL: iconURL, - coverURL: coverURL, + 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 @@ -109,11 +130,14 @@ class RowBannerEvent with _$RowBannerEvent { const factory RowBannerEvent.didReceiveFieldUpdate(FieldPB field) = _DidReceiveFieldUpdate; const factory RowBannerEvent.setIcon(String iconURL) = _SetIcon; - const factory RowBannerEvent.setCover(String coverURL) = _SetCover; + const factory RowBannerEvent.setCover(RowCoverPB cover) = _SetCover; + const factory RowBannerEvent.removeCover() = _RemoveCover; } @freezed -class RowBannerState with _$RowBannerState { +class RowBannerState extends Equatable with _$RowBannerState { + const RowBannerState._(); + const factory RowBannerState({ required FieldPB? primaryField, required RowMetaPB rowMeta, @@ -125,6 +149,14 @@ class RowBannerState with _$RowBannerState { rowMeta: rowMetaPB, loadingState: const LoadingState.loading(), ); + + @override + List get props => [ + rowMeta.cover.data, + rowMeta.icon, + primaryField, + loadingState, + ]; } @freezed 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 index 331dd159da..be5ba29dfc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart @@ -1,5 +1,6 @@ import 'dart:collection'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; @@ -44,7 +45,8 @@ class RowCache { for (final fieldInfo in fieldInfos) { _cellMemCache.removeCellWithFieldId(fieldInfo.id); } - _changedNotifier.receive(const ChangedReason.fieldDidChange()); + + _changedNotifier?.receive(const ChangedReason.fieldDidChange()); }); } @@ -53,7 +55,9 @@ class RowCache { final CellMemCache _cellMemCache; final RowLifeCycle _rowLifeCycle; final RowFieldsDelegate _fieldDelegate; - final RowChangesetNotifier _changedNotifier; + RowChangesetNotifier? _changedNotifier; + bool _isInitialRows = false; + final List _pendingVisibilityChanges = []; /// Returns a unmodifiable list of RowInfo UnmodifiableListView get rowInfos { @@ -67,7 +71,8 @@ class RowCache { } CellMemCache get cellCache => _cellMemCache; - ChangedReason get changeReason => _changedNotifier.reason; + ChangedReason get changeReason => + _changedNotifier?.reason ?? const InitialListState(); RowInfo? getRow(RowId rowId) { return _rowList.get(rowId); @@ -78,12 +83,29 @@ class RowCache { final rowInfo = buildGridRow(row); _rowList.add(rowInfo); } - _changedNotifier.receive(const ChangedReason.setInitialRows()); + _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?.dispose(); + _changedNotifier = null; _cellMemCache.dispose(); } @@ -94,13 +116,20 @@ class RowCache { } void applyRowsVisibility(RowsVisibilityChangePB changeset) { - _hideRows(changeset.invisibleRows); - _showRows(changeset.visibleRows); + 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()); + _changedNotifier?.receive(const ChangedReason.reorderRows()); } void reorderSingleRow(ReorderSingleRowPB reorderRow) { @@ -111,7 +140,7 @@ class RowCache { reorderRow.oldIndex, reorderRow.newIndex, ); - _changedNotifier.receive( + _changedNotifier?.receive( ChangedReason.reorderSingleRow( reorderRow, rowInfo, @@ -124,19 +153,25 @@ class RowCache { for (final rowId in deletedRowIds) { final deletedRow = _rowList.remove(rowId); if (deletedRow != null) { - _changedNotifier.receive(ChangedReason.delete(deletedRow)); + _changedNotifier?.receive(ChangedReason.delete(deletedRow)); } } } void _insertRows(List insertRows) { + final InsertedIndexs insertedIndices = []; for (final insertedRow in insertRows) { - final insertedIndex = - _rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta)); - if (insertedIndex != null) { - _changedNotifier.receive(ChangedReason.insert(insertedIndex)); + 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) { @@ -155,11 +190,13 @@ class RowCache { } } - final updatedIndexs = - _rowList.updateRows(updatedList, (rowId) => buildGridRow(rowId)); + final updatedIndexs = _rowList.updateRows( + rowMetas: updatedList, + builder: (rowId) => buildGridRow(rowId), + ); if (updatedIndexs.isNotEmpty) { - _changedNotifier.receive(ChangedReason.update(updatedIndexs)); + _changedNotifier?.receive(ChangedReason.update(updatedIndexs)); } } @@ -167,7 +204,7 @@ class RowCache { for (final rowId in invisibleRows) { final deletedRow = _rowList.remove(rowId); if (deletedRow != null) { - _changedNotifier.receive(ChangedReason.delete(deletedRow)); + _changedNotifier?.receive(ChangedReason.delete(deletedRow)); } } } @@ -177,14 +214,16 @@ class RowCache { final insertedIndex = _rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta)); if (insertedIndex != null) { - _changedNotifier.receive(ChangedReason.insert(insertedIndex)); + _changedNotifier?.receive(ChangedReason.insert([insertedIndex])); } } } void onRowsChanged(void Function(ChangedReason) onRowChanged) { - _changedNotifier.addListener(() { - onRowChanged(_changedNotifier.reason); + _changedNotifier?.addListener(() { + if (_changedNotifier != null) { + onRowChanged(_changedNotifier!.reason); + } }); } @@ -197,17 +236,19 @@ class RowCache { final rowInfo = _rowList.get(rowId); if (rowInfo != null) { final cellDataMap = _makeCells(rowInfo.rowMeta); - onRowChanged(cellDataMap, _changedNotifier.reason); + if (_changedNotifier != null) { + onRowChanged(cellDataMap, _changedNotifier!.reason); + } } } } - _changedNotifier.addListener(listenerHandler); + _changedNotifier?.addListener(listenerHandler); return listenerHandler; } void removeRowListener(VoidCallback callback) { - _changedNotifier.removeListener(callback); + _changedNotifier?.removeListener(callback); } List loadCells(RowMetaPB rowMeta) { @@ -215,7 +256,8 @@ class RowCache { if (rowInfo == null) { _loadRow(rowMeta.id); } - return _makeCells(rowMeta); + final cells = _makeCells(rowMeta); + return cells; } Future _loadRow(RowId rowId) async { @@ -225,9 +267,7 @@ class RowCache { final rowInfo = _rowList.get(rowMetaPB.id); final rowIndex = _rowList.indexOfRow(rowMetaPB.id); if (rowInfo != null && rowIndex != null) { - final updatedRowInfo = rowInfo.copyWith(rowMeta: rowMetaPB); - _rowList.remove(rowMetaPB.id); - _rowList.insert(rowIndex, updatedRowInfo); + rowInfo.rowMetaNotifier.value = rowMetaPB; final UpdatedIndexMap updatedIndexs = UpdatedIndexMap(); updatedIndexs[rowMetaPB.id] = UpdatedIndex( @@ -235,7 +275,7 @@ class RowCache { rowId: rowMetaPB.id, ); - _changedNotifier.receive(ChangedReason.update(updatedIndexs)); + _changedNotifier?.receive(ChangedReason.update(updatedIndexs)); } }, (err) => Log.error(err), @@ -255,9 +295,7 @@ class RowCache { RowInfo buildGridRow(RowMetaPB rowMetaPB) { return RowInfo( - viewId: viewId, fields: _fieldDelegate.fieldInfos, - rowId: rowMetaPB.id, rowMeta: rowMetaPB, ); } @@ -278,19 +316,48 @@ class RowChangesetNotifier extends ChangeNotifier { initial: (_) {}, reorderRows: (_) => notifyListeners(), reorderSingleRow: (_) => notifyListeners(), + updateRowsVisibility: (_) => notifyListeners(), setInitialRows: (_) => notifyListeners(), + didFetchRow: (_) => notifyListeners(), ); } } -@unfreezed -class RowInfo with _$RowInfo { - factory RowInfo({ - required String rowId, - required String viewId, - required UnmodifiableListView fields, +class RowInfo extends Equatable { + RowInfo({ + required this.fields, required RowMetaPB rowMeta, - }) = _RowInfo; + }) : 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; @@ -301,16 +368,20 @@ typedef UpdatedIndexMap = LinkedHashMap; @freezed class ChangedReason with _$ChangedReason { - const factory ChangedReason.insert(InsertedIndex item) = _Insert; + 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; } 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 index b34beba275..0d2bf4985d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart @@ -1,4 +1,8 @@ -import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; +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'; @@ -9,35 +13,92 @@ typedef OnRowChanged = void Function(List, ChangedReason); class RowController { RowController({ - required this.rowMeta, + required RowMetaPB rowMeta, required this.viewId, required RowCache rowCache, this.groupId, - }) : _rowCache = rowCache; + }) : _rowMeta = rowMeta, + _rowCache = rowCache, + _rowBackendSvc = RowBackendService(viewId: viewId), + _rowListener = RowListener(rowMeta.id); - final RowMetaPB rowMeta; + 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; - String get rowId => rowMeta.id; + List loadCells() => _rowCache.loadCells(rowMeta); - List loadData() => _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; + } - void addListener({OnRowChanged? onRowChanged}) { + 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: onRowChanged, + 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; } - void dispose() { + 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 index b31a3022e9..00b0745448 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_list.dart @@ -1,4 +1,5 @@ import 'dart:collection'; +import 'dart:math'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; @@ -117,20 +118,23 @@ class RowList { return deletedIndex; } - UpdatedIndexMap updateRows( - List rowMetas, - RowInfo Function(RowMetaPB) builder, - ) { + 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(index, rowInfo); + insert(insertIndex, rowInfo); updatedIndexs[rowMeta.id] = UpdatedIndex( - index: index, + index: insertIndex, rowId: rowMeta.id, ); } @@ -162,4 +166,11 @@ class RowList { 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 index 1b0d73de8f..151a32d961 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart @@ -37,6 +37,14 @@ class RowBackendService { 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, @@ -57,7 +65,7 @@ class RowBackendService { required String viewId, required String rowId, }) { - final payload = RowIdPB() + final payload = DatabaseViewRowIdPB() ..viewId = viewId ..rowId = rowId; @@ -65,7 +73,7 @@ class RowBackendService { } Future> getRowMeta(RowId rowId) { - final payload = RowIdPB.create() + final payload = DatabaseViewRowIdPB.create() ..viewId = viewId ..rowId = rowId; @@ -75,7 +83,7 @@ class RowBackendService { Future> updateMeta({ required String rowId, String? iconURL, - String? coverURL, + RowCoverPB? cover, bool? isDocumentEmpty, }) { final payload = UpdateRowMetaChangesetPB.create() @@ -85,8 +93,8 @@ class RowBackendService { if (iconURL != null) { payload.iconUrl = iconURL; } - if (coverURL != null) { - payload.coverUrl = coverURL; + if (cover != null) { + payload.cover = cover; } if (isDocumentEmpty != null) { @@ -96,22 +104,30 @@ class RowBackendService { return DatabaseEventUpdateRowMeta(payload).send(); } - static Future> deleteRow( - String viewId, - RowId rowId, - ) { - final payload = RowIdPB.create() + Future> removeCover(String rowId) async { + final payload = RemoveCoverPayloadPB.create() ..viewId = viewId ..rowId = rowId; - return DatabaseEventDeleteRow(payload).send(); + 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 = RowIdPB( + final payload = DatabaseViewRowIdPB( viewId: viewId, rowId: rowId, ); 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 index cef3ad6d7f..c62e30b742 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/setting/group_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/setting/group_bloc.dart @@ -6,6 +6,7 @@ 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'; @@ -22,6 +23,7 @@ class DatabaseGroupBloc extends Bloc { viewId, databaseController.fieldController.fieldInfos, databaseController.databaseLayoutSetting!.board, + databaseController.fieldController.groupSettings, ), ) { _dispatch(); @@ -47,15 +49,24 @@ class DatabaseGroupBloc extends Bloc { on( (event, emit) async { await event.when( - initial: () { - _startListening(); - }, + initial: () async => _startListening(), didReceiveFieldUpdate: (fieldInfos) { - emit(state.copyWith(fieldInfos: fieldInfos)); + emit( + state.copyWith( + fieldInfos: fieldInfos, + groupSettings: + _databaseController.fieldController.groupSettings, + ), + ); }, - setGroupByField: (String fieldId, FieldType fieldType) async { + 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)); }, @@ -96,8 +107,9 @@ class DatabaseGroupEvent with _$DatabaseGroupEvent { const factory DatabaseGroupEvent.initial() = _Initial; const factory DatabaseGroupEvent.setGroupByField( String fieldId, - FieldType fieldType, - ) = _DatabaseGroupEvent; + FieldType fieldType, [ + @Default([]) List settingContent, + ]) = _DatabaseGroupEvent; const factory DatabaseGroupEvent.didReceiveFieldUpdate( List fields, ) = _DidReceiveFieldUpdate; @@ -112,16 +124,19 @@ class DatabaseGroupState with _$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/sync/database_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart index ae0b9173c7..351dea2cd8 100644 --- 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 @@ -30,9 +30,9 @@ class DatabaseSyncBloc extends Bloc { .then((value) => value.fold((s) => s, (f) => null)); emit( state.copyWith( - shouldShowIndicator: userProfile?.authenticator == - AuthenticatorPB.AppFlowyCloud && - databaseId != null, + shouldShowIndicator: + userProfile?.workspaceAuthType == AuthTypePB.Server && + databaseId != null, ), ); if (databaseId != 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 index 943f8ec20c..e55bbb96a4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart @@ -1,15 +1,16 @@ 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/plugins/document/presentation/compact_mode_event.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:appflowy_editor/appflowy_editor.dart' hide Log; 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'; @@ -17,8 +18,17 @@ part 'tab_bar_bloc.freezed.dart'; class DatabaseTabBarBloc extends Bloc { - DatabaseTabBarBloc({required ViewPB view}) - : super(DatabaseTabBarState.initial(view)) { + DatabaseTabBarBloc({ + required ViewPB view, + required String compactModeId, + required bool enableCompactMode, + }) : super( + DatabaseTabBarState.initial( + view, + compactModeId, + enableCompactMode, + ), + ) { on( (event, emit) async { await event.when( @@ -52,7 +62,7 @@ class DatabaseTabBarBloc _createLinkedView(layout.layoutType, name ?? layout.layoutName); }, deleteView: (String viewId) async { - final result = await ViewBackendService.delete(viewId: viewId); + final result = await ViewBackendService.deleteView(viewId: viewId); result.fold( (l) {}, (r) => Log.error(r), @@ -154,10 +164,13 @@ class DatabaseTabBarBloc ) { final tabBarControllerByViewId = {...state.tabBarControllerByViewId}; for (final view in newViews) { - final controller = DatabaseTabBarController(view: view); - controller.onViewUpdated = (newView) { - add(DatabaseTabBarEvent.viewDidUpdate(newView)); - }; + final controller = DatabaseTabBarController( + view: view, + compactModeId: state.compactModeId, + enableCompactMode: state.enableCompactMode, + )..onViewUpdated = (newView) { + add(DatabaseTabBarEvent.viewDidUpdate(newView)); + }; tabBarControllerByViewId[view.id] = controller; } @@ -205,20 +218,27 @@ class DatabaseTabBarBloc @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; } @@ -227,19 +247,29 @@ class DatabaseTabBarState with _$DatabaseTabBarState { const factory DatabaseTabBarState({ required ViewPB parentView, required int selectedIndex, + required String compactModeId, + required bool enableCompactMode, required List tabBars, required Map tabBarControllerByViewId, }) = _DatabaseTabBarState; - factory DatabaseTabBarState.initial(ViewPB view) { + factory DatabaseTabBarState.initial( + ViewPB view, + String compactModeId, + bool enableCompactMode, + ) { final tabBar = DatabaseTabBar(view: view); return DatabaseTabBarState( parentView: view, selectedIndex: 0, + compactModeId: compactModeId, + enableCompactMode: enableCompactMode, tabBars: [tabBar], tabBarControllerByViewId: { view.id: DatabaseTabBarController( view: view, + compactModeId: compactModeId, + enableCompactMode: enableCompactMode, ), }, ); @@ -249,7 +279,7 @@ class DatabaseTabBarState with _$DatabaseTabBarState { class DatabaseTabBar extends Equatable { DatabaseTabBar({ required this.view, - }) : _builder = PlatformExtension.isMobile + }) : _builder = UniversalPlatform.isMobile ? view.mobileTabBarItem() : view.tabBarItem(); @@ -257,7 +287,9 @@ class DatabaseTabBar extends Equatable { final DatabaseTabBarItemBuilder _builder; String get viewId => view.id; + DatabaseTabBarItemBuilder get builder => _builder; + ViewLayoutPB get layout => view.layout; @override @@ -274,8 +306,18 @@ typedef OnViewChildViewChanged = void Function( ); class DatabaseTabBarController { - DatabaseTabBarController({required this.view}) - : controller = DatabaseController(view: view), + DatabaseTabBarController({ + required this.view, + required String compactModeId, + required bool enableCompactMode, + }) : controller = DatabaseController(view: view) + ..initCompactMode(enableCompactMode) + ..addListener( + onCompactModeChanged: (v) async { + compactModeEventBus + .fire(CompactModeEvent(id: compactModeId, enable: v)); + }, + ), viewListener = ViewListener(viewId: view.id) { viewListener.start( onViewChildViewsUpdated: (update) => onViewChildViewChanged?.call(update), @@ -293,7 +335,6 @@ class DatabaseTabBarController { OnViewChildViewChanged? onViewChildViewChanged; Future dispose() async { - await viewListener.stop(); - await controller.dispose(); + 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 index 77670fb0bb..754b2d1c23 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart @@ -69,11 +69,7 @@ class DatabaseViewCache { if (changeset.insertedRows.isNotEmpty) { for (final callback in _callbacks) { - callback.onRowsCreated?.call( - changeset.insertedRows - .map((insertedRow) => insertedRow.rowMeta.id) - .toList(), - ); + callback.onRowsCreated?.call(changeset.insertedRows); } } }, 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 index 6a41e2f173..3f97304296 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart @@ -7,83 +7,88 @@ 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'; -import 'package:flowy_infra/notifier.dart'; -typedef RowsVisibilityNotifierValue - = FlowyResult; - -typedef NumberOfRowsNotifierValue = FlowyResult; -typedef ReorderAllRowsNotifierValue = FlowyResult, FlowyError>; -typedef SingleRowNotifierValue = FlowyResult; +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; - - PublishNotifier? _rowsNotifier = PublishNotifier(); - PublishNotifier? _reorderAllRows = - PublishNotifier(); - PublishNotifier? _reorderSingleRow = - PublishNotifier(); - PublishNotifier? _rowsVisibility = - PublishNotifier(); - DatabaseNotificationListener? _listener; void start({ - required void Function(NumberOfRowsNotifierValue) onRowsChanged, - required void Function(ReorderAllRowsNotifierValue) onReorderAllRows, - required void Function(SingleRowNotifierValue) onReorderSingleRow, - required void Function(RowsVisibilityNotifierValue) onRowsVisibilityChanged, + required NumberOfRowsCallback onRowsChanged, + required ReorderAllRowsCallback onReorderAllRows, + required SingleRowCallback onReorderSingleRow, + required RowsVisibilityCallback onRowsVisibilityChanged, }) { - if (_listener != null) { - _listener?.stop(); - } + // Stop any existing listener + _listener?.stop(); + // Initialize the notification listener _listener = DatabaseNotificationListener( objectId: viewId, - handler: _handler, + handler: (ty, result) => _handler( + ty, + result, + onRowsChanged, + onReorderAllRows, + onReorderSingleRow, + onRowsVisibilityChanged, + ), ); - - _rowsNotifier?.addPublishListener(onRowsChanged); - _rowsVisibility?.addPublishListener(onRowsVisibilityChanged); - _reorderAllRows?.addPublishListener(onReorderAllRows); - _reorderSingleRow?.addPublishListener(onReorderSingleRow); } void _handler( DatabaseNotification ty, FlowyResult result, + NumberOfRowsCallback onRowsChanged, + ReorderAllRowsCallback onReorderAllRows, + SingleRowCallback onReorderSingleRow, + RowsVisibilityCallback onRowsVisibilityChanged, ) { switch (ty) { case DatabaseNotification.DidUpdateViewRowsVisibility: result.fold( - (payload) => _rowsVisibility?.value = - FlowyResult.success(RowsVisibilityChangePB.fromBuffer(payload)), - (error) => _rowsVisibility?.value = FlowyResult.failure(error), + (payload) => onRowsVisibilityChanged( + FlowyResult.success(RowsVisibilityChangePB.fromBuffer(payload)), + ), + (error) => onRowsVisibilityChanged(FlowyResult.failure(error)), ); break; case DatabaseNotification.DidUpdateRow: result.fold( - (payload) => _rowsNotifier?.value = - FlowyResult.success(RowsChangePB.fromBuffer(payload)), - (error) => _rowsNotifier?.value = FlowyResult.failure(error), + (payload) => onRowsChanged( + FlowyResult.success(RowsChangePB.fromBuffer(payload)), + ), + (error) => onRowsChanged(FlowyResult.failure(error)), ); break; case DatabaseNotification.DidReorderRows: result.fold( - (payload) => _reorderAllRows?.value = FlowyResult.success( - ReorderAllRowsPB.fromBuffer(payload).rowOrders, + (payload) => onReorderAllRows( + FlowyResult.success(ReorderAllRowsPB.fromBuffer(payload).rowOrders), ), - (error) => _reorderAllRows?.value = FlowyResult.failure(error), + (error) => onReorderAllRows(FlowyResult.failure(error)), ); break; case DatabaseNotification.DidReorderSingleRow: result.fold( - (payload) => _reorderSingleRow?.value = - FlowyResult.success(ReorderSingleRowPB.fromBuffer(payload)), - (error) => _reorderSingleRow?.value = FlowyResult.failure(error), + (payload) => onReorderSingleRow( + FlowyResult.success(ReorderSingleRowPB.fromBuffer(payload)), + ), + (error) => onReorderSingleRow(FlowyResult.failure(error)), ); break; default: @@ -93,16 +98,6 @@ class DatabaseViewListener { Future stop() async { await _listener?.stop(); - _rowsVisibility?.dispose(); - _rowsVisibility = null; - - _rowsNotifier?.dispose(); - _rowsNotifier = null; - - _reorderAllRows?.dispose(); - _reorderAllRows = null; - - _reorderSingleRow?.dispose(); - _reorderSingleRow = null; + _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 0000000000..12a1603430 --- /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 index 7b816bbafc..a2c6c89578 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart @@ -3,20 +3,23 @@ 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/domain/group_service.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-folder/view.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:intl/intl.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'; @@ -27,232 +30,286 @@ part 'board_bloc.freezed.dart'; class BoardBloc extends Bloc { BoardBloc({ - required ViewPB view, required this.databaseController, - }) : super(BoardState.initial(view.id)) { + this.didCreateRow, + AppFlowyBoardController? boardController, + }) : super(const BoardState.loading()) { groupBackendSvc = GroupBackendService(viewId); - boardController = 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, - ); - } - }, - ); - + _initBoardController(boardController); _dispatch(); } final DatabaseController databaseController; + late final AppFlowyBoardController boardController; final LinkedHashMap groupControllers = LinkedHashMap(); final List groupList = []; - late final AppFlowyBoardController boardController; + 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 _openGrid(emit); + await _openDatabase(emit); + + final result = await UserEventGetUserProfile().send(); + result.fold( + (profile) => _userProfile = profile, + (err) => Log.error('Failed to fetch user profile: ${err.msg}'), + ); }, - createHeaderRow: (groupId) async { - final rowId = groupControllers[groupId]?.firstRow()?.id; - final position = rowId == null - ? OrderObjectPositionTypePB.Start - : OrderObjectPositionTypePB.Before; + 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: rowId, + targetRowId: targetRowId, + withCells: cellBuilder, ); - result.fold( - (rowMeta) => emit(state.copyWith(recentAddedRowMeta: rowMeta)), - (err) => Log.error(err), - ); - }, - createBottomRow: (groupId) async { - final rowId = groupControllers[groupId]?.lastRow()?.id; - final position = rowId == null - ? OrderObjectPositionTypePB.End - : OrderObjectPositionTypePB.After; - final result = await RowBackendService.createRow( - viewId: databaseController.viewId, - groupId: groupId, - position: position, - targetRowId: rowId, - ); + final startEditing = position != OrderObjectPositionTypePB.End; + final action = UniversalPlatform.isMobile + ? DidCreateRowAction.openAsPage + : startEditing + ? DidCreateRowAction.startEditing + : DidCreateRowAction.none; result.fold( - (rowMeta) => emit(state.copyWith(recentAddedRowMeta: rowMeta)), + (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.fold((_) {}, (err) => Log.error(err)); + result.onFailure(Log.error); }, deleteGroup: (groupId) async { final result = await groupBackendSvc.deleteGroup(groupId: groupId); - result.fold((_) {}, (err) => Log.error(err)); + result.onFailure(Log.error); }, - didCreateRow: (group, row, int? index) { - emit( - state.copyWith( - isEditingRow: true, - editingRow: BoardEditingRow( - group: group, - row: row, - index: index, - ), - ), + renameGroup: (groupId, name) async { + final result = await groupBackendSvc.updateGroup( + groupId: groupId, + name: name, ); - _groupItemStartEditing(group, row, true); + result.onFailure(Log.error); }, - didReceiveGridUpdate: (DatabasePB grid) { - emit(state.copyWith(grid: grid)); - }, - didReceiveError: (FlowyError error) { - emit(state.copyWith(noneOrError: error)); + didReceiveError: (error) { + emit(BoardState.error(error: error)); }, didReceiveGroups: (List groups) { - final hiddenGroups = _filterHiddenGroups(hideUngrouped, groups); - emit( - state.copyWith( - hiddenGroups: hiddenGroups, - groupIds: groups.map((group) => group.groupId).toList(), - ), + state.maybeMap( + ready: (state) { + emit( + state.copyWith( + hiddenGroups: _filterHiddenGroups(hideUngrouped, groups), + groupIds: groups.map((group) => group.groupId).toList(), + ), + ); + }, + orElse: () {}, ); }, didUpdateLayoutSettings: (layoutSettings) { - final hiddenGroups = _filterHiddenGroups(hideUngrouped, groupList); - emit( - state.copyWith( - layoutSettings: layoutSettings, - hiddenGroups: hiddenGroups, - ), + state.maybeMap( + ready: (state) { + emit( + state.copyWith( + layoutSettings: layoutSettings, + hiddenGroups: _filterHiddenGroups(hideUngrouped, groupList), + ), + ); + }, + orElse: () {}, ); }, - toggleGroupVisibility: (GroupPB group, bool isVisible) async { - await _toggleGroupVisibility(group, isVisible); + setGroupVisibility: (GroupPB group, bool isVisible) async { + await _setGroupVisibility(group, isVisible); }, toggleHiddenSectionVisibility: (isVisible) async { - final newLayoutSettings = state.layoutSettings!; - newLayoutSettings.freeze(); + await state.maybeMap( + ready: (state) async { + final newLayoutSettings = state.layoutSettings!; + newLayoutSettings.freeze(); - final newLayoutSetting = newLayoutSettings.rebuild( - (message) => message.collapseHiddenGroups = isVisible, - ); + final newLayoutSetting = newLayoutSettings.rebuild( + (message) => message.collapseHiddenGroups = isVisible, + ); - await databaseController.updateLayoutSetting( - boardLayoutSetting: newLayoutSetting, + await databaseController.updateLayoutSetting( + boardLayoutSetting: newLayoutSetting, + ); + }, + orElse: () {}, ); }, reorderGroup: (fromGroupId, toGroupId) async { _reorderGroup(fromGroupId, toGroupId, emit); }, - startEditingRow: (group, row) { - emit( - state.copyWith( - isEditingRow: true, - editingRow: BoardEditingRow( - group: group, - row: row, - index: null, - ), - ), - ); - _groupItemStartEditing(group, row, true); - }, - endEditingRow: (rowId) { - if (state.editingRow != null && state.isEditingRow) { - assert(state.editingRow!.row.id == rowId); - _groupItemStartEditing( - state.editingRow!.group, - state.editingRow!.row, - false, - ); - - emit(state.copyWith(isEditingRow: false, editingRow: null)); - } - }, startEditingHeader: (String groupId) { - emit( - state.copyWith(isEditingHeader: true, editingHeaderId: groupId), + state.maybeMap( + ready: (state) => emit(state.copyWith(editingHeaderId: groupId)), + orElse: () {}, ); }, endEditingHeader: (String groupId, String? groupName) async { - await groupBackendSvc.updateGroup( - fieldId: groupControllers.values.first.group.fieldId, - groupId: groupId, - name: groupName, + 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: () {}, ); - emit(state.copyWith(isEditingHeader: false)); + }, + 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); }, ); }, ); } - void _groupItemStartEditing(GroupPB group, RowMetaPB row, bool isEdit) { - final fieldInfo = fieldController.getField(group.fieldId); - if (fieldInfo == null) { - return Log.warn("fieldInfo should not be null"); - } - - boardController.enableGroupDragging(!isEdit); - } - - Future _toggleGroupVisibility(GroupPB group, bool isVisible) async { + Future _setGroupVisibility(GroupPB group, bool isVisible) async { if (group.isDefault) { - final newLayoutSettings = state.layoutSettings!; - newLayoutSettings.freeze(); + await state.maybeMap( + ready: (state) async { + final newLayoutSettings = state.layoutSettings!; + newLayoutSettings.freeze(); - final newLayoutSetting = newLayoutSettings.rebuild( - (message) => message.hideUngroupedColumn = !isVisible, + final newLayoutSetting = newLayoutSettings.rebuild( + (message) => message.hideUngroupedColumn = !isVisible, + ); + + await databaseController.updateLayoutSetting( + boardLayoutSetting: newLayoutSetting, + ); + }, + orElse: () {}, ); - - return databaseController.updateLayoutSetting( - boardLayoutSetting: newLayoutSetting, + } else { + await groupBackendSvc.updateGroup( + groupId: group.groupId, + visible: isVisible, ); } - - await groupBackendSvc.updateGroup( - fieldId: groupControllers.values.first.group.fieldId, - groupId: group.groupId, - visible: isVisible, - ); } void _reorderGroup( @@ -277,6 +334,18 @@ class BoardBloc extends Bloc { 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(); } @@ -284,11 +353,13 @@ class BoardBloc extends Bloc { databaseController.databaseLayoutSetting?.board.hideUngroupedColumn ?? false; - FieldType get groupingFieldType { - final fieldInfo = - databaseController.fieldController.getField(groupList.first.fieldId)!; - - return fieldInfo.fieldType; + FieldType? get groupingFieldType { + if (groupList.isEmpty) { + return null; + } + return databaseController.fieldController + .getField(groupList.first.fieldId) + ?.fieldType; } void initializeGroups(List groups) { @@ -321,17 +392,10 @@ class BoardBloc extends Bloc { } } - RowCache? getRowCache() => databaseController.rowCache; + RowCache get rowCache => databaseController.rowCache; void _startListening() { - final onDatabaseChanged = DatabaseCallbacks( - onDatabaseChanged: (database) { - if (!isClosed) { - add(BoardEvent.didReceiveGridUpdate(database)); - } - }, - ); - final onLayoutSettingsChanged = DatabaseLayoutSettingCallbacks( + _layoutSettingsCallback = DatabaseLayoutSettingCallbacks( onLayoutSettingsChanged: (layoutSettings) { if (isClosed) { return; @@ -354,7 +418,7 @@ class BoardBloc extends Bloc { add(BoardEvent.didUpdateLayoutSettings(layoutSettings.board)); }, ); - final onGroupChanged = GroupCallbacks( + _groupCallbacks = GroupCallbacks( onGroupByField: (groups) { if (isClosed) { return; @@ -406,7 +470,9 @@ class BoardBloc extends Bloc { boardController.getGroupController(group.groupId); if (columnController != null) { // remove the group or update its name - columnController.updateGroupName(generateGroupNameFromGroup(group)); + columnController.updateGroupName( + group.generateGroupName(databaseController), + ); if (!group.isVisible) { boardController.removeGroup(group.groupId); } @@ -414,7 +480,7 @@ class BoardBloc extends Bloc { final newGroup = _initializeGroupData(group); final visibleGroups = [...groupList]..retainWhere( (g) => - g.isVisible || + (g.isVisible && !g.isDefault) || g.isDefault && !hideUngrouped || g.groupId == group.groupId, ); @@ -431,11 +497,20 @@ class BoardBloc extends Bloc { 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: onDatabaseChanged, - onLayoutSettingsChanged: onLayoutSettingsChanged, - onGroupChanged: onGroupChanged, + onDatabaseChanged: _databaseCallbacks, + onLayoutSettingsChanged: _layoutSettingsCallback, + onGroupChanged: _groupCallbacks, ); } @@ -451,31 +526,20 @@ class BoardBloc extends Bloc { return [...items]; } - 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)), - ), + Future _openDatabase(Emitter emit) { + return databaseController.open().fold( + (datbasePB) => databaseController.setIsLoading(false), + (err) => emit(BoardState.error(error: err)), ); - }, - (err) => emit( - state.copyWith( - loadingState: LoadingState.finish(FlowyResult.failure(err)), - ), - ), - ); } GroupController _initializeGroupController(GroupPB group) { + group.freeze(); + final delegate = GroupControllerDelegateImpl( controller: boardController, fieldController: fieldController, - onNewColumnItem: (groupId, row, index) => - add(BoardEvent.didCreateRow(group, row, index)), + onNewColumnItem: (groupId, row, index) {}, ); final controller = GroupController( @@ -500,7 +564,7 @@ class BoardBloc extends Bloc { AppFlowyGroupData _initializeGroupData(GroupPB group) { return AppFlowyGroupData( id: group.groupId, - name: generateGroupNameFromGroup(group), + name: group.generateGroupName(databaseController), items: _buildGroupItems(group), customData: GroupData( group: group, @@ -508,142 +572,89 @@ class BoardBloc extends Bloc { ), ); } - - String generateGroupNameFromGroup(GroupPB group) { - final field = fieldController.getField(group.fieldId); - if (field == null) { - return ""; - } - - // if the group is the default group, then - if (group.isDefault) { - return "No ${field.name}"; - } - - switch (field.fieldType) { - case FieldType.SingleSelect: - final options = - SingleSelectTypeOptionPB.fromBuffer(field.field.typeOptionData) - .options; - final option = - options.firstWhereOrNull((option) => option.id == group.groupId); - return option == null ? "" : option.name; - case FieldType.MultiSelect: - final options = - MultiSelectTypeOptionPB.fromBuffer(field.field.typeOptionData) - .options; - final option = - options.firstWhereOrNull((option) => option.id == group.groupId); - return option == null ? "" : option.name; - case FieldType.Checkbox: - return group.groupId; - case FieldType.URL: - return group.groupId; - case FieldType.DateTime: - // Assume DateCondition::Relative as there isn't an option for this - // right now. - final dateFormat = DateFormat("y/MM/dd"); - try { - final targetDateTime = dateFormat.parseLoose(group.groupId); - final targetDateTimeDay = DateTime( - targetDateTime.year, - targetDateTime.month, - targetDateTime.day, - ); - final now = DateTime.now(); - final nowDay = DateTime( - now.year, - now.month, - now.day, - ); - final diff = targetDateTimeDay.difference(nowDay).inDays; - return switch (diff) { - 0 => "Today", - -1 => "Yesterday", - 1 => "Tomorrow", - -7 => "Last 7 days", - 2 => "Next 7 days", - -30 => "Last 30 days", - 8 => "Next 30 days", - _ => DateFormat("MMM y").format(targetDateTimeDay) - }; - } on FormatException { - return ""; - } - default: - return ""; - } - } } @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.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.didCreateRow( - GroupPB group, - RowMetaPB row, - int? index, - ) = _DidCreateRow; - const factory BoardEvent.startEditingRow( - GroupPB group, - RowMetaPB row, - ) = _StartEditRow; - const factory BoardEvent.endEditingRow(RowId rowId) = _EndEditRow; - const factory BoardEvent.toggleGroupVisibility( + const factory BoardEvent.setGroupVisibility( GroupPB group, bool isVisible, - ) = _ToggleGroupVisibility; + ) = _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.didReceiveGridUpdate( - DatabasePB grid, - ) = _DidReceiveGridUpdate; 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 factory BoardState({ + const BoardState._(); + + const factory BoardState.loading() = _BoardLoadingState; + + const factory BoardState.error({ + required FlowyError error, + }) = _BoardErrorState; + + const factory BoardState.ready({ required String viewId, - required DatabasePB? grid, required List groupIds, - required bool isEditingHeader, - required bool isEditingRow, required LoadingState loadingState, required FlowyError? noneOrError, required BoardLayoutSettingPB? layoutSettings, - String? editingHeaderId, - BoardEditingRow? editingRow, - RowMetaPB? recentAddedRowMeta, required List hiddenGroups, - }) = _BoardState; + String? editingHeaderId, + }) = _BoardReadyState; - factory BoardState.initial(String viewId) => BoardState( - grid: null, + 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: [], - isEditingHeader: false, - isEditingRow: false, 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) { @@ -656,10 +667,7 @@ class GroupItem extends AppFlowyGroupItem { GroupItem({ required this.row, required this.fieldInfo, - bool draggable = true, - }) { - super.draggable = draggable; - } + }); final RowMetaPB row; final FieldInfo fieldInfo; @@ -668,6 +676,23 @@ class GroupItem extends AppFlowyGroupItem { 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, @@ -731,7 +756,7 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate { return Log.warn("fieldInfo should not be null"); } - final item = GroupItem(row: row, fieldInfo: fieldInfo, draggable: false); + final item = GroupItem(row: row, fieldInfo: fieldInfo); if (index != null) { controller.insertGroupItem(group.groupId, index, item); @@ -743,20 +768,8 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate { } } -class BoardEditingRow { - BoardEditingRow({ - required this.group, - required this.row, - required this.index, - }); - - GroupPB group; - RowMetaPB row; - int? index; -} - class GroupData { - GroupData({ + const GroupData({ required this.group, required this.fieldInfo, }); @@ -779,3 +792,21 @@ class CheckboxGroup { // 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 index 4fa7a15672..4e0ad9bada 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/application/group_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/group_controller.dart @@ -50,6 +50,10 @@ class GroupController { } 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) { @@ -80,11 +84,14 @@ class GroupController { } } - group.freeze(); 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), @@ -93,8 +100,8 @@ class GroupController { ); } - Future dispose() { - return _listener.stop(); + Future dispose() async { + await _listener.stop(); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/board.dart b/frontend/appflowy_flutter/lib/plugins/database/board/board.dart index fb267220be..f8fb18a561 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/board.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/board.dart @@ -19,13 +19,13 @@ class BoardPluginBuilder implements PluginBuilder { String get menuName => LocaleKeys.board_menuName.tr(); @override - FlowySvgData get icon => FlowySvgs.board_s; + FlowySvgData get icon => FlowySvgs.icon_board_s; @override PluginType get pluginType => PluginType.board; @override - ViewLayoutPB? get layoutType => ViewLayoutPB.Board; + ViewLayoutPB get layoutType => ViewLayoutPB.Board; } class BoardPluginConfig implements PluginConfig { 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 0000000000..5c69a66e62 --- /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 index 352345e2d2..70d00bcd25 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart @@ -1,39 +1,42 @@ -import 'dart:collection'; +import 'dart:io'; 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_content.dart'; -import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.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_cache.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/grid/presentation/widgets/header/field_type_extension.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_backend/protobuf/flowy-database2/row_entities.pb.dart'; +import 'package:appflowy/shared/conditional_listenable_builder.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.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: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:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart' hide Card; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; -import '../../../../workspace/application/view/view_bloc.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(); @@ -46,7 +49,18 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { bool shrinkWrap, String? initialRowId, ) => - BoardPage(view: view, databaseController: controller); + 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) => @@ -79,12 +93,14 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { ValueKey(controller.viewId); } -class BoardPage extends StatelessWidget { - BoardPage({ +class DesktopBoardPage extends StatefulWidget { + const DesktopBoardPage({ + super.key, required this.view, required this.databaseController, this.onEditStateChanged, - }) : super(key: ValueKey(view.id)); + this.shrinkWrap = false, + }); final ViewPB view; @@ -93,54 +109,154 @@ class BoardPage extends StatelessWidget { /// 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 BlocProvider( - create: (context) => BoardBloc( - view: view, - databaseController: databaseController, - )..add(const BoardEvent.initial()), + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: _boardBloc), + BlocProvider.value(value: _boardActionsCubit), + ], child: BlocBuilder( - buildWhen: (p, c) => p.loadingState != c.loadingState, - builder: (context, state) => state.loadingState.when( - loading: () => const Center( + builder: (context, state) => state.maybeMap( + loading: (_) => const Center( child: CircularProgressIndicator.adaptive(), ), - idle: () => const SizedBox.shrink(), - finish: (result) => result.fold( - (_) => PlatformExtension.isMobile - ? const MobileBoardContent() - : DesktopBoardContent(onEditStateChanged: onEditStateChanged), - (err) => PlatformExtension.isMobile - ? FlowyMobileStateContainer.error( - emoji: '🛸', - title: LocaleKeys.board_mobile_failedToLoad.tr(), - errorMsg: err.toString(), - ) - : FlowyErrorPage.message( - err.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), - ), + error: (err) => Center(child: AppFlowyErrorPage(error: err.error)), + orElse: () => _BoardContent( + shrinkWrap: widget.shrinkWrap, + onEditStateChanged: widget.onEditStateChanged, + focusScope: _focusScope, + boardController: _boardController, + view: widget.view, ), ), ), ); } + + 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 DesktopBoardContent extends StatefulWidget { - const DesktopBoardContent({ - super.key, +class _BoardContent extends StatefulWidget { + const _BoardContent({ + required this.boardController, + required this.focusScope, + required this.view, this.onEditStateChanged, + this.shrinkWrap = false, }); + final AppFlowyBoardController boardController; + final BoardFocusScope focusScope; final VoidCallback? onEditStateChanged; + final bool shrinkWrap; + final ViewPB view; @override - State createState() => _DesktopBoardContentState(); + State<_BoardContent> createState() => _BoardContentState(); } -class _DesktopBoardContentState extends State { +class _BoardContentState extends State<_BoardContent> { final ScrollController scrollController = ScrollController(); final AppFlowyBoardScrollController scrollManager = AppFlowyBoardScrollController(); @@ -148,16 +264,19 @@ class _DesktopBoardContentState extends State { final config = const AppFlowyBoardConfig( groupMargin: EdgeInsets.symmetric(horizontal: 4), groupBodyPadding: EdgeInsets.symmetric(horizontal: 4), - groupFooterPadding: EdgeInsets.fromLTRB(4, 14, 4, 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: context.read().databaseController, + databaseController: databaseController, ); + DatabaseController get databaseController => + context.read().databaseController; + @override void dispose() { scrollController.dispose(); @@ -166,187 +285,501 @@ class _DesktopBoardContentState extends State { @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - widget.onEditStateChanged?.call(); - }, - child: BlocBuilder( - builder: (context, state) { - final showCreateGroupButton = - context.read().groupingFieldType.canCreateNewGroup; - return Padding( + final horizontalPadding = + context.read()?.horizontalPadding ?? + 0.0; + 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: showCreateGroupButton - ? BoardTrailing(scrollController: scrollController) - : const HSpace(40), - headerBuilder: (_, groupData) => BlocProvider.value( - value: context.read(), - child: BoardColumnHeader( - groupData: groupData, - margin: config.groupHeaderPadding, - ), - ), - footerBuilder: _buildFooter, - cardBuilder: (_, column, columnItem) => _buildCard( - context, - column, - columnItem, - ), + child: ValueListenableBuilder( + valueListenable: databaseController.compactModeNotifier, + builder: (context, compactMode, _) { + return AppFlowyBoard( + boardScrollController: scrollManager, + scrollController: scrollController, + shrinkWrap: widget.shrinkWrap, + controller: context.read().boardController, + groupConstraints: + BoxConstraints.tightFor(width: compactMode ? 196 : 256), + config: config, + leading: HiddenGroupsColumn( + shrinkWrap: widget.shrinkWrap, + margin: config.groupHeaderPadding + + EdgeInsets.only( + left: widget.shrinkWrap ? horizontalPadding : 0.0, + ), + ), + 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: (cardContext, column, columnItem) => + MultiBlocProvider( + key: ValueKey("board_card_${column.id}_${columnItem.id}"), + providers: [ + BlocProvider.value( + value: cardContext.read(), + ), + BlocProvider.value( + value: cardContext.read(), + ), + BlocProvider( + create: (_) => ViewLockStatusBloc(view: widget.view) + ..add(ViewLockStatusEvent.initial()), + ), + ], + child: BlocBuilder( + builder: (lockStatusContext, state) { + return IgnorePointer( + ignoring: state.isLocked, + child: _BoardCard( + afGroupData: column, + groupItem: columnItem as GroupItem, + boardConfig: config, + notifier: widget.focusScope, + cellBuilder: cellBuilder, + compactMode: compactMode, + onOpenCard: (rowMeta) => _openCard( + context: context, + databaseController: lockStatusContext + .read() + .databaseController, + rowMeta: rowMeta, + ), + ), + ); + }, + ), + ), + ); + }, ), - ); + ), + ), + ), + ); + } +} + +@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 _buildFooter(BuildContext context, AppFlowyGroupData columnData) { - return Padding( - padding: config.groupFooterPadding, + 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: FlowyHover( - child: AppFlowyGroupFooter( - height: 36, - icon: FlowySvg( + child: SizedBox( + height: 36, + child: FlowyButton( + leftIcon: FlowySvg( FlowySvgs.add_s, color: Theme.of(context).hintColor, ), - title: FlowyText.medium( + text: FlowyText( LocaleKeys.board_column_createNewCard.tr(), color: Theme.of(context).hintColor, ), - onAddButtonClick: () => context - .read() - .add(BoardEvent.createBottomRow(columnData.id)), + onTap: () { + context + .read() + .startCreateBottomRow(widget.columnData.id); + }, ), ), ), ); } +} - Widget _buildCard( - BuildContext context, - AppFlowyGroupData afGroupData, - AppFlowyGroupItem afGroupItem, - ) { +class _BoardCard extends StatefulWidget { + const _BoardCard({ + required this.afGroupData, + required this.groupItem, + required this.boardConfig, + required this.cellBuilder, + required this.notifier, + required this.compactMode, + required this.onOpenCard, + }); + + final AppFlowyGroupData afGroupData; + final GroupItem groupItem; + final AppFlowyBoardConfig boardConfig; + final CardCellBuilder cellBuilder; + final BoardFocusScope notifier; + final bool compactMode; + final void Function(RowMetaPB) onOpenCard; + + @override + State<_BoardCard> createState() => _BoardCardState(); +} + +class _BoardCardState extends State<_BoardCard> { + bool _isEditing = false; + + @override + Widget build(BuildContext context) { final boardBloc = context.read(); - final groupItem = afGroupItem as GroupItem; - final groupData = afGroupData.customData as GroupData; - final rowCache = boardBloc.getRowCache(); - final rowInfo = rowCache?.getRow(groupItem.row.id); - - /// Return placeholder widget if the rowCache or rowInfo is null. - if (rowCache == null) { - return SizedBox.shrink(key: ObjectKey(groupItem)); - } - + final groupData = widget.afGroupData.customData as GroupData; + final rowCache = boardBloc.rowCache; final databaseController = boardBloc.databaseController; - final viewId = boardBloc.viewId; + final rowMeta = + rowCache.getRow(widget.groupItem.id)?.rowMeta ?? widget.groupItem.row; - final isEditing = boardBloc.state.isEditingRow && - boardBloc.state.editingRow?.row.id == groupItem.row.id; + const nada = DoNothingAndStopPropagationIntent(); - final groupItemId = "${groupData.group.groupId}${groupItem.row.id}"; - final rowMeta = rowInfo?.rowMeta ?? groupItem.row; + 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 Container( - key: ValueKey(groupItemId), - margin: config.cardMargin, - decoration: _makeBoxDecoration(context), - child: RowCard( - fieldController: databaseController.fieldController, - rowMeta: rowMeta, - viewId: viewId, - rowCache: rowCache, - groupingFieldId: groupItem.fieldInfo.id, - isEditing: isEditing, - cellBuilder: cellBuilder, - openCard: (context) => _openCard( - context: context, - databaseController: databaseController, - groupId: groupData.group.groupId, - rowMeta: context.read().state.rowMeta, - ), - styleConfiguration: RowCardStyleConfiguration( - cellStyleMap: desktopBoardCardCellStyleMap(context), - hoverStyle: HoverStyle( - hoverColor: Theme.of(context).brightness == Brightness.light - ? const Color(0x0F1F2329) - : const Color(0x0FEFF4FB), - foregroundColorOnHover: Theme.of(context).colorScheme.onBackground, + return previousContainsFocus != currentContainsFocus; + }, + builder: (context, focusedItems, child) { + final cardMargin = widget.boardConfig.cardMargin; + final margin = widget.compactMode + ? cardMargin - EdgeInsets.symmetric(horizontal: 2) + : cardMargin; + return Container( + margin: margin, + 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) => widget.onOpenCard( + 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, ), ), - onStartEditing: () => - boardBloc.add(BoardEvent.startEditingRow(groupData.group, rowMeta)), - onEndEditing: () => boardBloc.add(BoardEvent.endEditingRow(rowMeta.id)), ), ); } - BoxDecoration _makeBoxDecoration(BuildContext context) { + 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: Theme.of(context).brightness == Brightness.light - ? const Color(0xFF1F2329).withOpacity(0.12) - : const Color(0xFF59647A), + color: widget.notifier + .isFocused(GroupedRowId(rowId: rowId, groupId: groupId)) + ? Theme.of(context).colorScheme.primary + : Theme.of(context).brightness == Brightness.light + ? const Color(0xFF1F2329).withValues(alpha: 0.12) + : const Color(0xFF59647A), ), ), boxShadow: [ BoxShadow( blurRadius: 4, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), BoxShadow( blurRadius: 4, spreadRadius: -2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), ], ); } - - void _openCard({ - required BuildContext context, - required DatabaseController databaseController, - required String groupId, - required RowMetaPB rowMeta, - }) { - final rowInfo = RowInfo( - viewId: databaseController.viewId, - fields: - UnmodifiableListView(databaseController.fieldController.fieldInfos), - rowMeta: rowMeta, - rowId: rowMeta.id, - ); - - final rowController = RowController( - rowMeta: rowInfo.rowMeta, - viewId: rowInfo.viewId, - rowCache: databaseController.rowCache, - groupId: groupId, - ); - - FlowyOverlay.show( - context: context, - builder: (_) => BlocProvider.value( - value: context.read(), - child: RowDetailPage( - databaseController: databaseController, - rowController: rowController, - ), - ), - ); - } } class BoardTrailing extends StatefulWidget { @@ -421,7 +854,7 @@ class _BoardTrailingState extends State { suffixIcon: Padding( padding: const EdgeInsets.only(left: 4, bottom: 8.0), child: FlowyIconButton( - icon: const FlowySvg(FlowySvgs.close_filled_m), + icon: const FlowySvg(FlowySvgs.close_filled_s), hoverColor: Colors.transparent, onPressed: () => _textController.clear(), ), @@ -458,3 +891,27 @@ class _BoardTrailingState extends State { } } } + +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: (_) => BlocProvider.value( + value: context.read(), + child: 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 index aa2883ff73..e57364b2d8 100644 --- 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 @@ -1,11 +1,14 @@ import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/grid/application/filter/filter_menu_bloc.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/grid/presentation/widgets/toolbar/view_database_button.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.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 BoardSettingBar extends StatelessWidget { const BoardSettingBar({ @@ -19,35 +22,39 @@ class BoardSettingBar extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => DatabaseFilterMenuBloc( + return BlocProvider( + create: (context) => FilterEditorBloc( viewId: databaseController.viewId, fieldController: databaseController.fieldController, - )..add(const DatabaseFilterMenuEvent.initial()), - child: BlocListener( - listenWhen: (p, c) => p.isVisible != c.isVisible, - listener: (context, state) => toggleExtension.toggle(), - 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: [ - const FilterButton(), + ), + child: ValueListenableBuilder( + valueListenable: databaseController.isLoading, + builder: (context, value, child) { + if (value) { + return const SizedBox.shrink(); + } + final isReference = + Provider.of(context)?.isReference ?? false; + return SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilterButton( + toggleExtension: toggleExtension, + ), + if (isReference) ...[ const HSpace(2), - SettingButton( - databaseController: databaseController, - ), + ViewDatabaseButton(view: databaseController.view), ], - ), - ); - }, - ), + 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 0000000000..8ac06bf2df --- /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 index 06eb9be584..abd28ac022 100644 --- 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 @@ -1,27 +1,30 @@ 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/plugins/database/grid/presentation/widgets/header/field_type_extension.dart'; +import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_board/appflowy_board.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.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 '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; @@ -30,171 +33,61 @@ class BoardColumnHeader extends StatefulWidget { } class _BoardColumnHeaderState extends State { - final FocusNode _focusNode = FocusNode(); + final ValueNotifier isEditing = ValueNotifier(false); - late final TextEditingController _controller = - TextEditingController.fromValue( - TextEditingValue( - selection: TextSelection.collapsed( - offset: widget.groupData.headerData.groupName.length, - ), - text: widget.groupData.headerData.groupName, - ), - ); - - @override - void initState() { - super.initState(); - _focusNode.addListener(() { - if (!_focusNode.hasFocus) { - _saveEdit(); - } - }); - } + GroupData get customData => widget.groupData.customData; @override void dispose() { - _focusNode.dispose(); - _controller.dispose(); + isEditing.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final boardCustomData = widget.groupData.customData as GroupData; - - return BlocBuilder( - builder: (context, state) { - if (state.isEditingHeader) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); - } - - Widget title = Expanded( - child: FlowyText.medium( - widget.groupData.headerData.groupName, - overflow: TextOverflow.ellipsis, - ), - ); - - if (!boardCustomData.group.isDefault && - boardCustomData.fieldType.canEditHeader) { - title = Flexible( - fit: FlexFit.tight, - child: FlowyTooltip( - message: LocaleKeys.board_column_renameGroupTooltip.tr(), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => context - .read() - .add(BoardEvent.startEditingHeader(widget.groupData.id)), - child: FlowyText.medium( - widget.groupData.headerData.groupName, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - ); - } - - if (state.isEditingHeader && - state.editingHeaderId == widget.groupData.id) { - title = _buildTextField(context); - } - - return Padding( - padding: widget.margin, - child: SizedBox( - height: 50, - child: Row( - children: [ - _buildHeaderIcon(boardCustomData), - title, - const HSpace(6), - _groupOptionsButton(context), - const HSpace(4), - 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.createHeaderRow(widget.groupData.id)), - ), - ), - ], - ), - ), - ); - }, - ); - } - - Widget _buildTextField(BuildContext context) { - return Expanded( - child: KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: (event) { - if ([LogicalKeyboardKey.enter, LogicalKeyboardKey.escape] - .contains(event.logicalKey)) { - _saveEdit(); - } - }, - child: TextField( - controller: _controller, - focusNode: _focusNode, - onEditingComplete: _saveEdit, - style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 14), - 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, - ), + 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, ); } +} - void _saveEdit() => context - .read() - .add(BoardEvent.endEditingHeader(widget.groupData.id, _controller.text)); +class GroupOptionsButton extends StatelessWidget { + const GroupOptionsButton({ + super.key, + required this.groupData, + this.isEditing, + }); - 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(), - }; + final AppFlowyGroupData groupData; + final ValueNotifier? isEditing; - Widget _groupOptionsButton(BuildContext context) { + @override + Widget build(BuildContext context) { return AppFlowyPopover( clickHandler: PopoverClickHandler.gestureDetector, margin: const EdgeInsets.all(8), @@ -206,14 +99,14 @@ class _BoardColumnHeaderState extends State { iconColorOnHover: Theme.of(context).colorScheme.onSurface, ), popupBuilder: (popoverContext) { - final customGroupData = widget.groupData.customData as GroupData; + final customGroupData = groupData.customData as GroupData; final isDefault = customGroupData.group.isDefault; - final menuItems = GroupOptions.values.toList(); + final menuItems = GroupOption.values.toList(); if (!customGroupData.fieldType.canEditHeader || isDefault) { - menuItems.remove(GroupOptions.rename); + menuItems.remove(GroupOption.rename); } if (!customGroupData.fieldType.canDeleteGroup || isDefault) { - menuItems.remove(GroupOptions.delete); + menuItems.remove(GroupOption.delete); } return SeparatedColumn( mainAxisSize: MainAxisSize.min, @@ -224,12 +117,13 @@ class _BoardColumnHeaderState extends State { height: GridSize.popoverItemHeight, child: FlowyButton( leftIcon: FlowySvg(action.icon), - text: FlowyText.medium( + text: FlowyText( action.text, + lineHeight: 1.0, overflow: TextOverflow.ellipsis, ), onTap: () { - action.call(context, customGroupData.group); + run(context, action, customGroupData.group); PopoverContainer.of(popoverContext).close(); }, ), @@ -240,37 +134,107 @@ class _BoardColumnHeaderState extends State { }, ); } -} -enum GroupOptions { - rename, - hide, - delete; - - void call(BuildContext context, GroupPB group) { - switch (this) { - case rename: + void run(BuildContext context, GroupOption option, GroupPB group) { + switch (option) { + case GroupOption.rename: + isEditing?.value = true; + break; + case GroupOption.hide: context .read() - .add(BoardEvent.startEditingHeader(group.groupId)); + .add(BoardEvent.setGroupVisibility(group, false)); break; - case hide: - context - .read() - .add(BoardEvent.toggleGroupVisibility(group, false)); - break; - case delete: - NavigatorAlertDialog( - title: LocaleKeys.board_column_deleteColumnConfirmation.tr(), - confirm: () { + 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)); }, - ).show(context); + ); 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, 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 0000000000..e6ecca43bc --- /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 0000000000..ed329904b9 --- /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 index 1e820af120..1a0d1a3163 100644 --- 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 @@ -7,36 +7,46 @@ 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/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.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/flowy_infra_ui.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 HiddenGroupsColumn extends StatelessWidget { - const HiddenGroupsColumn({super.key, required this.margin}); + const HiddenGroupsColumn({ + super.key, + required this.margin, + required this.shrinkWrap, + }); final EdgeInsets margin; + final bool shrinkWrap; @override Widget build(BuildContext context) { final databaseController = context.read().databaseController; return BlocSelector( - selector: (state) => state.layoutSettings, + 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, @@ -51,39 +61,32 @@ class HiddenGroupsColumn extends StatelessWidget { ), ), ) - : SizedBox( + : Container( width: 274, + padding: EdgeInsets.only( + left: leftPadding, + right: margin.right + 4, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 50, - child: Padding( - padding: EdgeInsets.only( - left: 80 + margin.left, - right: margin.right + 4, - ), - child: Row( - children: [ - Expanded( - child: FlowyText.medium( - LocaleKeys - .board_hiddenGroupSection_sectionTitle - .tr(), - overflow: TextOverflow.ellipsis, - color: Theme.of(context).hintColor, - ), + 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, + ), + _collapseExpandIcon(context, isCollapsed), + ], ), ), + _hiddenGroupList(databaseController), ], ), ), @@ -92,6 +95,14 @@ class HiddenGroupsColumn extends StatelessWidget { ); } + Widget _hiddenGroupList(DatabaseController databaseController) { + final hiddenGroupList = HiddenGroupList( + shrinkWrap: shrinkWrap, + databaseController: databaseController, + ); + return shrinkWrap ? hiddenGroupList : Expanded(child: hiddenGroupList); + } + Widget _collapseExpandIcon(BuildContext context, bool isCollapsed) { return FlowyTooltip( message: isCollapsed @@ -119,50 +130,58 @@ class HiddenGroupList extends StatelessWidget { const HiddenGroupList({ super.key, required this.databaseController, + required this.shrinkWrap, }); final DatabaseController databaseController; + final bool shrinkWrap; @override Widget build(BuildContext context) { return BlocBuilder( - builder: (_, 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(), + 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(), + ), + ], ), - ], + ), + shrinkWrap: shrinkWrap, + 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)); + }, ), - ), - 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)); - }, - ), + ); + }, ); } } @@ -191,31 +210,27 @@ class _HiddenGroupCardState extends State { final databaseController = widget.bloc.databaseController; final primaryField = databaseController.fieldController.fieldInfos .firstWhereOrNull((element) => element.isPrimary)!; - - return Padding( - padding: const EdgeInsets.only(left: 26), - child: 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, - ), + 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, ), ); } @@ -248,57 +263,72 @@ class HiddenGroupButtonContent extends StatelessWidget { value: bloc, child: BlocBuilder( builder: (context, state) { - final group = state.hiddenGroups.firstWhereOrNull( - (g) => g.groupId == groupId, - ); - if (group == null) { - return const SizedBox.shrink(); - } + 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, + return SizedBox( + height: 32, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 3, ), - const HSpace(4), - FlowyText.medium( - bloc.generateGroupNameFromGroup(group), - overflow: TextOverflow.ellipsis, - ), - const HSpace(6), - Expanded( - child: FlowyText.medium( - group.rows.length.toString(), - overflow: TextOverflow.ellipsis, - color: Theme.of(context).hintColor, - ), - ), - if (isHovering) ...[ - FlowyIconButton( - width: 20, - icon: FlowySvg( - FlowySvgs.show_m, - color: Theme.of(context).hintColor, + child: Row( + children: [ + HiddenGroupCardActions( + isVisible: isHovering, + index: index, ), - onPressed: () => context.read().add( - BoardEvent.toggleGroupVisibility( - group, - true, + 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, + ), + ), + ), + ], + ], + ), + ), + ); + }, ); }, ), @@ -344,11 +374,11 @@ class HiddenGroupCardActions extends StatelessWidget { class HiddenGroupPopupItemList extends StatelessWidget { const HiddenGroupPopupItemList({ + super.key, required this.groupId, required this.viewId, required this.primaryFieldId, required this.rowCache, - super.key, }); final String groupId; @@ -360,68 +390,75 @@ class HiddenGroupPopupItemList extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final group = state.hiddenGroups.firstWhereOrNull( - (g) => g.groupId == groupId, - ); - if (group == null) { - return const SizedBox.shrink(); - } - final cells = [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: FlowyText.medium( - context.read().generateGroupNameFromGroup(group), - fontSize: 10, - color: Theme.of(context).hintColor, - overflow: TextOverflow.ellipsis, - ), - ), - ...group.rows.map( - (item) { - final rowController = RowController( - rowMeta: item, - viewId: viewId, - rowCache: rowCache, - ); - - final databaseController = - context.read().databaseController; - - return HiddenGroupPopupItem( - cellContext: rowCache.loadCells(item).firstWhere( - (cellContext) => cellContext.fieldId == primaryFieldId, - ), - rowController: rowController, - rowMeta: item, - cellBuilder: CardCellBuilder( - databaseController: databaseController, + 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, ), - onPressed: () { - FlowyOverlay.show( - context: context, - builder: (_) { - return BlocProvider.value( - value: context.read(), - child: RowDetailPage( - databaseController: databaseController, - rowController: rowController, + ), + ...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: (_) => BlocProvider.value( + value: context.read(), + child: RowDetailPage( + databaseController: databaseController, + rowController: rowController, + userProfile: context.read().userProfile, + ), ), ); + PopoverContainer.of(context).close(); }, ); - PopoverContainer.of(context).close(); }, - ); - }, - ), - ]; + ), + ]; - return ListView.separated( - itemBuilder: (context, index) => cells[index], - itemCount: cells.length, - separatorBuilder: (context, index) => - VSpace(GridSize.typeOptionSeparatorHeight), - shrinkWrap: true, + return ListView.separated( + itemBuilder: (context, index) => cells[index], + itemCount: cells.length, + separatorBuilder: (context, index) => + VSpace(GridSize.typeOptionSeparatorHeight), + shrinkWrap: true, + ); + }, ); }, ); @@ -453,7 +490,7 @@ class HiddenGroupPopupItem extends StatelessWidget { text: cellBuilder.build( cellContext: cellContext, styleMap: {FieldType.RichText: _titleCellStyle(context)}, - hasNotes: !rowMeta.isDocumentEmpty, + hasNotes: false, ), onTap: onPressed, ), 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 0000000000..c69269a1b7 --- /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/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart index ecfb4bfff8..45157b1a47 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart @@ -7,6 +7,7 @@ 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'; @@ -33,11 +34,34 @@ class CalendarBloc extends Bloc { 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(); @@ -72,7 +96,7 @@ class CalendarBloc extends Bloc { ); }, deleteEvent: (String viewId, String rowId) async { - final result = await RowBackendService.deleteRow(viewId, rowId); + final result = await RowBackendService.deleteRows(viewId, [rowId]); result.fold( (_) => null, (e) => Log.error('Failed to delete event: $e', e), @@ -112,6 +136,7 @@ class CalendarBloc extends Bloc { deleteEventIds: deletedRowIds, ), ); + emit(state.copyWith(deleteEventIds: const [])); }, didReceiveEvent: (CalendarEventData event) { emit( @@ -120,6 +145,11 @@ class CalendarBloc extends Bloc { newEvent: event, ), ); + emit(state.copyWith(newEvent: null)); + }, + openRowDetail: (row) { + emit(state.copyWith(openRow: row)); + emit(state.copyWith(openRow: null)); }, ); }, @@ -220,16 +250,14 @@ class CalendarBloc extends Bloc { } Future?> _loadEvent(RowId rowId) async { - final payload = RowIdPB(viewId: viewId, rowId: rowId); - return DatabaseEventGetCalendarEvent(payload).send().then((result) { - return result.fold( - (eventPB) => _calendarEventDataFromEventPB(eventPB), - (r) { - Log.error(r); - return null; - }, - ); - }); + final payload = DatabaseViewRowIdPB(viewId: viewId, rowId: rowId); + return DatabaseEventGetCalendarEvent(payload).send().fold( + (eventPB) => _calendarEventDataFromEventPB(eventPB), + (r) { + Log.error(r); + return null; + }, + ); } void _loadAllEvents() async { @@ -286,7 +314,7 @@ class CalendarBloc extends Bloc { } void _startListening() { - final onDatabaseChanged = DatabaseCallbacks( + _databaseCallbacks = DatabaseCallbacks( onDatabaseChanged: (database) { if (isClosed) return; }, @@ -298,14 +326,18 @@ class CalendarBloc extends Bloc { for (final fieldInfo in fieldInfos) fieldInfo.field.id: fieldInfo, }; }, - onRowsCreated: (rowIds) async { + onRowsCreated: (rows) async { if (isClosed) { return; } - for (final id in rowIds) { - final event = await _loadEvent(id); - if (event != null && !isClosed) { - add(CalendarEvent.didReceiveEvent(event)); + 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)); + } } } }, @@ -331,15 +363,39 @@ class CalendarBloc extends Bloc { } } }, + 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: () {}, + ); + }, ); - final onLayoutSettingsChanged = DatabaseLayoutSettingCallbacks( + _layoutSettingCallbacks = DatabaseLayoutSettingCallbacks( onLayoutSettingsChanged: _didReceiveLayoutSetting, ); databaseController.addListener( - onDatabaseChanged: onDatabaseChanged, - onLayoutSettingsChanged: onLayoutSettingsChanged, + onDatabaseChanged: _databaseCallbacks, + onLayoutSettingsChanged: _layoutSettingCallbacks, ); } @@ -362,6 +418,10 @@ class CalendarBloc extends Bloc { 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; @@ -427,6 +487,8 @@ class CalendarEvent with _$CalendarEvent { const factory CalendarEvent.deleteEvent(String viewId, String rowId) = _DeleteEvent; + + const factory CalendarEvent.openRowDetail(RowMetaPB row) = _OpenRowDetail; } @freezed @@ -441,6 +503,7 @@ class CalendarState with _$CalendarState { CalendarEventData? updateEvent, required List deleteEventIds, required CalendarLayoutSettingPB? settings, + required RowMetaPB? openRow, required LoadingState loadingState, required FlowyError? noneOrError, }) = _CalendarState; @@ -451,6 +514,7 @@ class CalendarState with _$CalendarState { initialEvents: [], deleteEventIds: [], settings: null, + openRow: null, noneOrError: null, loadingState: LoadingState.loading(), ); 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 index a0a5ec0efc..48e159475c 100644 --- 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 @@ -29,12 +29,14 @@ class CalendarEventEditorBloc (event, emit) async { await event.when( initial: () { + rowController.initialize(); + _startListening(); final primaryFieldId = fieldController.fieldInfos .firstWhere((fieldInfo) => fieldInfo.isPrimary) .id; final cells = rowController - .loadData() + .loadCells() .where( (cellContext) => _filterCellContext(cellContext, primaryFieldId), @@ -46,9 +48,9 @@ class CalendarEventEditorBloc emit(state.copyWith(cells: cells)); }, delete: () async { - final result = await RowBackendService.deleteRow( + final result = await RowBackendService.deleteRows( rowController.viewId, - rowController.rowId, + [rowController.rowId], ); result.fold((l) => null, (err) => Log.error(err)); }, @@ -88,7 +90,7 @@ class CalendarEventEditorBloc @override Future close() async { - rowController.dispose(); + await rowController.dispose(); return super.close(); } } 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 index 208906f00b..1e67ad9e9d 100644 --- 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 @@ -29,6 +29,15 @@ class UnscheduleEventsBloc 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 { @@ -42,7 +51,7 @@ class UnscheduleEventsBloc state.copyWith( allEvents: events, unscheduleEvents: - events.where((element) => !element.isScheduled).toList(), + events.where((element) => !element.hasTimestamp()).toList(), ), ); }, @@ -55,7 +64,7 @@ class UnscheduleEventsBloc state.copyWith( allEvents: events, unscheduleEvents: - events.where((element) => !element.isScheduled).toList(), + events.where((element) => !element.hasTimestamp()).toList(), ), ); }, @@ -65,7 +74,7 @@ class UnscheduleEventsBloc state.copyWith( allEvents: events, unscheduleEvents: - events.where((element) => !element.isScheduled).toList(), + events.where((element) => !element.hasTimestamp()).toList(), ), ); }, @@ -77,7 +86,7 @@ class UnscheduleEventsBloc Future _loadEvent( RowId rowId, ) async { - final payload = RowIdPB(viewId: viewId, rowId: rowId); + final payload = DatabaseViewRowIdPB(viewId: viewId, rowId: rowId); return DatabaseEventGetCalendarEvent(payload).send().then( (result) => result.fold( (eventPB) => eventPB, @@ -103,13 +112,13 @@ class UnscheduleEventsBloc } void _startListening() { - final onDatabaseChanged = DatabaseCallbacks( - onRowsCreated: (rowIds) async { + _databaseCallbacks = DatabaseCallbacks( + onRowsCreated: (rows) async { if (isClosed) { return; } - for (final id in rowIds) { - final event = await _loadEvent(id); + for (final row in rows) { + final event = await _loadEvent(row.rowMeta.id); if (event != null && !isClosed) { add(UnscheduleEventsEvent.didReceiveEvent(event)); } @@ -135,7 +144,7 @@ class UnscheduleEventsBloc }, ); - databaseController.addListener(onDatabaseChanged: onDatabaseChanged); + databaseController.addListener(onDatabaseChanged: _databaseCallbacks); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart index 099714c304..54667d5f69 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart @@ -2,8 +2,8 @@ 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:easy_localization/easy_localization.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; class CalendarPluginBuilder extends PluginBuilder { @override @@ -19,13 +19,13 @@ class CalendarPluginBuilder extends PluginBuilder { String get menuName => LocaleKeys.calendar_menuName.tr(); @override - FlowySvgData get icon => FlowySvgs.date_s; + FlowySvgData get icon => FlowySvgs.icon_calendar_s; @override PluginType get pluginType => PluginType.calendar; @override - ViewLayoutPB? get layoutType => ViewLayoutPB.Calendar; + ViewLayoutPB get layoutType => ViewLayoutPB.Calendar; } class CalendarPluginConfig implements PluginConfig { 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 index 5d1a29141e..1d2838210d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_day.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_day.dart @@ -2,7 +2,6 @@ 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:appflowy_editor/appflowy_editor.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; @@ -12,6 +11,7 @@ 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'; @@ -65,14 +65,14 @@ class CalendarDayCard extends StatelessWidget { const VSpace(6.0), // List of cards or empty space - if (events.isNotEmpty && !PlatformExtension.isMobile) ...[ + if (events.isNotEmpty && !UniversalPlatform.isMobile) ...[ _EventList( events: events, viewId: viewId, rowCache: rowCache, constraints: constraints, ), - ] else if (events.isNotEmpty && PlatformExtension.isMobile) ...[ + ] else if (events.isNotEmpty && UniversalPlatform.isMobile) ...[ const _EventIndicator(), ], ], @@ -82,7 +82,7 @@ class CalendarDayCard extends StatelessWidget { children: [ GestureDetector( onDoubleTap: () => onCreateEvent(date), - onTap: PlatformExtension.isMobile + onTap: UniversalPlatform.isMobile ? () => _mobileOnTap(context) : null, child: Container( @@ -106,7 +106,7 @@ class CalendarDayCard extends StatelessWidget { padding: const EdgeInsets.only(top: 5.0), child: child, ), - if (candidate.isEmpty && !PlatformExtension.isMobile) + if (candidate.isEmpty && !UniversalPlatform.isMobile) NewEventButton( onCreate: () => onCreateEvent(date), ), @@ -238,10 +238,11 @@ class NewEventButton extends StatelessWidget { child: FlowyIconButton( onPressed: onCreate, icon: const FlowySvg(FlowySvgs.add_s), - fillColor: Theme.of(context).colorScheme.background, + 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( @@ -251,20 +252,20 @@ class NewEventButton extends StatelessWidget { width: 0.5, ), ), - borderRadius: Corners.s5Border, + borderRadius: Corners.s6Border, boxShadow: [ BoxShadow( spreadRadius: -2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 2, ), BoxShadow( - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 4, ), BoxShadow( spreadRadius: 2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 8, ), ], @@ -289,8 +290,8 @@ class _DayBadge extends StatelessWidget { @override Widget build(BuildContext context) { - Color dayTextColor = Theme.of(context).colorScheme.onBackground; - Color monthTextColor = Theme.of(context).colorScheme.onBackground; + 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(); @@ -303,16 +304,16 @@ class _DayBadge extends StatelessWidget { dayTextColor = Theme.of(context).colorScheme.onPrimary; } - final double size = PlatformExtension.isMobile ? 20 : 18; + final double size = UniversalPlatform.isMobile ? 20 : 18; return SizedBox( height: size, child: Row( - mainAxisAlignment: PlatformExtension.isMobile + mainAxisAlignment: UniversalPlatform.isMobile ? MainAxisAlignment.center : MainAxisAlignment.end, children: [ - if (date.day == 1 && !PlatformExtension.isMobile) + if (date.day == 1 && !UniversalPlatform.isMobile) FlowyText.medium( monthString, fontSize: 11, @@ -326,9 +327,9 @@ class _DayBadge extends StatelessWidget { width: isToday ? size : null, height: isToday ? size : null, child: Center( - child: FlowyText.medium( + child: FlowyText( dayString, - fontSize: PlatformExtension.isMobile ? 12 : 11, + fontSize: UniversalPlatform.isMobile ? 12 : 11, color: dayTextColor, ), ), 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 index c0d283eeb8..5ef2e2c327 100644 --- 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 @@ -1,21 +1,23 @@ 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/application/row/row_controller.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/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_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/material.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 { @@ -80,8 +82,9 @@ class _EventCardState extends State { rowCache: rowCache, isEditing: false, cellBuilder: cellBuilder, - openCard: (context) { - if (PlatformExtension.isMobile) { + isCompact: true, + onTap: (context) { + if (UniversalPlatform.isMobile) { context.push( MobileRowDetailPage.routeName, extra: { @@ -102,11 +105,12 @@ class _EventCardState extends State { hoverColor: Theme.of(context).brightness == Brightness.light ? const Color(0x0F1F2329) : const Color(0x0FEFF4FB), - foregroundColorOnHover: Theme.of(context).colorScheme.onBackground, + foregroundColorOnHover: AFThemeExtension.of(context).onBackground, ), ), onStartEditing: () {}, onEndEditing: () {}, + userProfile: context.read().userProfile, ); final decoration = BoxDecoration( @@ -123,16 +127,16 @@ class _EventCardState extends State { boxShadow: [ BoxShadow( spreadRadius: -2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 2, ), BoxShadow( - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 4, ), BoxShadow( spreadRadius: 2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 8, ), ], @@ -164,15 +168,36 @@ class _EventCardState extends State { 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: (_) => BlocProvider.value( + value: context.read(), + child: RowDetailPage( + databaseController: widget.databaseController, + rowController: rowController, + userProfile: context.read().userProfile, + ), + ), + ); + }, ), ); }, - child: Material( - color: Colors.transparent, - child: Container( - padding: widget.padding, - decoration: decoration, - child: card, + child: Padding( + padding: widget.padding, + child: Material( + color: Colors.transparent, + child: DecoratedBox( + decoration: decoration, + child: 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 index 10f078c805..dcbe626dd8 100644 --- 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 @@ -1,6 +1,3 @@ -import 'package:appflowy/workspace/application/view/view_bloc.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/bloc/text_cell_bloc.dart'; @@ -10,18 +7,17 @@ import 'package:appflowy/plugins/database/application/field/field_controller.dar 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/plugins/database/widgets/row/row_detail.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_popover/appflowy_popover.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/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CalendarEventEditor extends StatelessWidget { @@ -30,6 +26,7 @@ class CalendarEventEditor extends StatelessWidget { required RowMetaPB rowMeta, required this.layoutSettings, required this.databaseController, + required this.onExpand, }) : rowController = RowController( rowMeta: rowMeta, viewId: databaseController.viewId, @@ -42,6 +39,7 @@ class CalendarEventEditor extends StatelessWidget { final DatabaseController databaseController; final RowController rowController; final EditableCellBuilder cellBuilder; + final VoidCallback onExpand; @override Widget build(BuildContext context) { @@ -57,6 +55,7 @@ class CalendarEventEditor extends StatelessWidget { EventEditorControls( rowController: rowController, databaseController: databaseController, + onExpand: onExpand, ), Flexible( child: EventPropertyList( @@ -76,10 +75,12 @@ class EventEditorControls extends StatelessWidget { 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) { @@ -92,48 +93,58 @@ class EventEditorControls extends StatelessWidget { message: LocaleKeys.calendar_duplicateEvent.tr(), child: FlowyIconButton( width: 20, - icon: const FlowySvg( + icon: FlowySvg( FlowySvgs.m_duplicate_s, - size: Size.square(17), + size: const Size.square(16), + color: Theme.of(context).iconTheme.color, ), - iconColorOnHover: Theme.of(context).colorScheme.onSecondary, - onPressed: () => context.read().add( - CalendarEvent.duplicateEvent( - rowController.viewId, - rowController.rowId, - ), - ), + onPressed: () { + context.read().add( + CalendarEvent.duplicateEvent( + rowController.viewId, + rowController.rowId, + ), + ); + PopoverContainer.of(context).close(); + }, ), ), 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, - ), - ), + icon: FlowySvg( + FlowySvgs.delete_s, + size: const Size.square(16), + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + showConfirmDeletionDialog( + context: context, + name: LocaleKeys.grid_row_label.tr(), + description: LocaleKeys.grid_row_deleteRowPrompt.tr(), + onConfirm: () { + context.read().add( + CalendarEvent.deleteEvent( + rowController.viewId, + rowController.rowId, + ), + ); + PopoverContainer.of(context).close(); + }, + ); + }, ), const HSpace(8.0), FlowyIconButton( width: 20, - icon: const FlowySvg(FlowySvgs.full_view_s), - iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + icon: FlowySvg( + FlowySvgs.full_view_s, + size: const Size.square(16), + color: Theme.of(context).iconTheme.color, + ), onPressed: () { PopoverContainer.of(context).close(); - FlowyOverlay.show( - context: context, - builder: (_) => BlocProvider.value( - value: context.read(), - child: RowDetailPage( - databaseController: databaseController, - rowController: rowController, - ), - ), - ); + onExpand.call(); }, ), ], @@ -248,10 +259,9 @@ class _PropertyCellState extends State { padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6), child: Row( children: [ - FlowySvg( - fieldInfo.fieldType.svgData, - color: Theme.of(context).hintColor, - size: const Size.square(14), + FieldIcon( + fieldInfo: fieldInfo, + dimension: 14, ), const HSpace(4.0), Expanded( @@ -279,6 +289,7 @@ class _TitleTextCellSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -288,10 +299,8 @@ class _TitleTextCellSkin extends IEditableTextCellSkin { textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14), focusNode: focusNode, hintText: LocaleKeys.calendar_defaultNewCalendarTitle.tr(), - onChanged: (text) { - if (textEditingController.value.composing.isCollapsed) { - bloc.add(TextCellEvent.updateText(text)); - } + 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 index c38c7647ba..1876332d01 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart @@ -1,5 +1,3 @@ -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'; @@ -8,30 +6,33 @@ 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/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.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/workspace/application/view/view_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_popover/appflowy_popover.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:flowy_infra_ui/widget/flowy_tooltip.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'; 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, @@ -53,6 +54,7 @@ class CalendarPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { return CalendarSettingBar( key: _makeValueKey(controller), databaseController: controller, + toggleExtension: _toggleExtension, ); } @@ -61,7 +63,18 @@ class CalendarPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { BuildContext context, DatabaseController controller, ) { - return SizedBox.fromSize(); + return DatabaseViewSettingExtension( + key: _makeValueKey(controller), + viewId: controller.viewId, + databaseController: controller, + toggleExtension: _toggleExtension, + ); + } + + @override + void dispose() { + _toggleExtension.dispose(); + super.dispose(); } ValueKey _makeValueKey(DatabaseController controller) { @@ -92,12 +105,11 @@ class _CalendarPageState extends State { @override void initState() { + super.initState(); _calendarState = GlobalKey(); _calendarBloc = CalendarBloc( databaseController: widget.databaseController, )..add(const CalendarEvent.initial()); - - super.initState(); } @override @@ -110,8 +122,18 @@ class _CalendarPageState extends State { Widget build(BuildContext context) { return CalendarControllerProvider( controller: _eventController, - child: BlocProvider.value( - value: _calendarBloc, + child: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: _calendarBloc, + ), + BlocProvider( + create: (context) => ViewLockStatusBloc(view: widget.view) + ..add( + ViewLockStatusEvent.initial(), + ), + ), + ], child: MultiBlocListener( listeners: [ BlocListener( @@ -154,6 +176,18 @@ class _CalendarPageState extends State { } }, ), + 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) { @@ -187,11 +221,17 @@ class _CalendarPageState extends State { 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: PlatformExtension.isMobile - ? CalendarSize.contentInsetsMobile - : CalendarSize.contentInsets + - const EdgeInsets.symmetric(horizontal: 40), + padding: padding, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), @@ -199,12 +239,26 @@ class _CalendarPageState extends State { key: _calendarState, controller: _eventController, width: constraints.maxWidth, - cellAspectRatio: PlatformExtension.isMobile ? 0.9 : 0.6, + cellAspectRatio: UniversalPlatform.isMobile ? 0.9 : 0.6, startDay: _weekdayFromInt(firstDayOfWeek), showBorder: false, headerBuilder: _headerNavigatorBuilder, weekDayBuilder: _headerWeekDayBuilder, - cellBuilder: _calendarDayBuilder, + cellBuilder: ( + date, + calenderEvents, + isToday, + isInMonth, + position, + ) => + _calendarDayBuilder( + context, + date, + calenderEvents, + isToday, + isInMonth, + position, + ), useAvailableVerticalSpace: widget.shrinkWrap, ), ), @@ -219,7 +273,7 @@ class _CalendarPageState extends State { child: Row( children: [ GestureDetector( - onTap: PlatformExtension.isMobile + onTap: UniversalPlatform.isMobile ? () => showMobileBottomSheet( context, title: LocaleKeys.calendar_quickJumpYear.tr(), @@ -246,7 +300,7 @@ class _CalendarPageState extends State { DateFormat('MMMM y', context.locale.toLanguageTag()) .format(currentMonth), ), - if (PlatformExtension.isMobile) ...[ + if (UniversalPlatform.isMobile) ...[ const HSpace(6), const FlowySvg(FlowySvgs.arrow_down_s), ], @@ -296,7 +350,7 @@ class _CalendarPageState extends State { final symbols = DateFormat.EEEE(context.locale.toLanguageTag()).dateSymbols; String weekDayString = symbols.WEEKDAYS[(day + 1) % 7]; - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { weekDayString = weekDayString.substring(0, 3); } @@ -313,6 +367,7 @@ class _CalendarPageState extends State { } Widget _calendarDayBuilder( + BuildContext context, DateTime date, List> calenderEvents, isToday, @@ -324,17 +379,22 @@ class _CalendarPageState extends State { // 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)); + final isLocked = + context.watch()?.state.isLocked ?? false; - 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, + return IgnorePointer( + ignoring: isLocked, + child: 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, + ), ); } @@ -347,10 +407,10 @@ class _CalendarPageState extends State { void showEventDetails({ required BuildContext context, required DatabaseController databaseController, - required CalendarEventPB event, + required RowMetaPB rowMeta, }) { final rowController = RowController( - rowMeta: event.rowMeta, + rowMeta: rowMeta, viewId: databaseController.viewId, rowCache: databaseController.rowCache, ); @@ -359,10 +419,11 @@ void showEventDetails({ context: context, builder: (BuildContext overlayContext) { return BlocProvider.value( - value: context.read(), + value: context.read(), child: RowDetailPage( rowController: rowController, databaseController: databaseController, + userProfile: context.read().userProfile, ), ); }, @@ -380,13 +441,7 @@ class UnscheduledEventsButton extends StatefulWidget { } class _UnscheduledEventsButtonState extends State { - late final PopoverController _popoverController; - - @override - void initState() { - super.initState(); - _popoverController = PopoverController(); - } + final PopoverController _popoverController = PopoverController(); @override Widget build(BuildContext context) { @@ -410,11 +465,10 @@ class _UnscheduledEventsButtonState extends State { ), side: BorderSide(color: Theme.of(context).dividerColor), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - visualDensity: VisualDensity.compact, ), onPressed: () { if (state.unscheduleEvents.isNotEmpty) { - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { _showUnscheduledEventsMobile(state.unscheduleEvents); } else { _popoverController.show(); @@ -432,15 +486,20 @@ class _UnscheduledEventsButtonState extends State { ), ), ), - popupBuilder: (_) { - return BlocProvider.value( - value: context.read(), - child: UnscheduleEventsList( - databaseController: widget.databaseController, - unscheduleEvents: state.unscheduleEvents, + popupBuilder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), ), - ); - }, + BlocProvider.value( + value: context.read(), + ), + ], + child: UnscheduleEventsList( + databaseController: widget.databaseController, + unscheduleEvents: state.unscheduleEvents, + ), + ), ); }, ), @@ -453,7 +512,7 @@ class _UnscheduledEventsButtonState extends State { builder: (_) { return Column( children: [ - FlowyText.medium( + FlowyText( LocaleKeys.calendar_settings_unscheduledEventsTitle.tr(), ), UnscheduleEventsList( @@ -479,10 +538,10 @@ class UnscheduleEventsList extends StatelessWidget { @override Widget build(BuildContext context) { final cells = [ - if (!PlatformExtension.isMobile) + if (!UniversalPlatform.isMobile) Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: FlowyText.medium( + child: FlowyText( LocaleKeys.calendar_settings_clickToAdd.tr(), fontSize: 10, color: Theme.of(context).hintColor, @@ -493,7 +552,7 @@ class UnscheduleEventsList extends StatelessWidget { (event) => UnscheduledEventCell( event: event, onPressed: () { - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { context.push( MobileRowDetailPage.routeName, extra: { @@ -505,7 +564,7 @@ class UnscheduleEventsList extends StatelessWidget { } else { showEventDetails( context: context, - event: event, + rowMeta: event.rowMeta, databaseController: databaseController, ); PopoverContainer.of(context).close(); @@ -523,7 +582,7 @@ class UnscheduleEventsList extends StatelessWidget { shrinkWrap: true, ); - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { return Flexible(child: child); } @@ -543,7 +602,7 @@ class UnscheduledEventCell extends StatelessWidget { @override Widget build(BuildContext context) { - return PlatformExtension.isMobile + return UniversalPlatform.isMobile ? MobileUnscheduledEventTile(event: event, onPressed: onPressed) : DesktopUnscheduledEventTile(event: event, onPressed: onPressed); } @@ -565,7 +624,7 @@ class DesktopUnscheduledEventTile extends StatelessWidget { height: 26, child: FlowyButton( margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - text: FlowyText.medium( + text: FlowyText( event.title.isEmpty ? LocaleKeys.calendar_defaultNewCalendarTitle.tr() : event.title, 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 index 3ec5e7c34f..8a66191950 100644 --- 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 @@ -1,3 +1,5 @@ +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'; @@ -5,12 +7,9 @@ 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/workspace/presentation/widgets/toggle/toggle_style.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:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// Widget that displays a list of settings that alters the appearance of the @@ -30,6 +29,12 @@ class CalendarLayoutSetting extends StatefulWidget { class _CalendarLayoutSettingState extends State { final PopoverMutex popoverMutex = PopoverMutex(); + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return BlocProvider( @@ -167,12 +172,15 @@ class LayoutDateField extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(fieldInfo.name), + text: FlowyText( + fieldInfo.name, + lineHeight: 1.0, + ), onTap: () { onUpdated(fieldInfo.id); popoverMutex.close(); }, - leftIcon: const FlowySvg(FlowySvgs.grid_s), + leftIcon: const FlowySvg(FlowySvgs.date_s), rightIcon: fieldInfo.id == fieldId ? const FlowySvg(FlowySvgs.check_s) : null, @@ -199,7 +207,8 @@ class LayoutDateField extends StatelessWidget { height: GridSize.popoverItemHeight, child: FlowyButton( margin: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0), - text: FlowyText.medium( + text: FlowyText( + lineHeight: 1.0, LocaleKeys.calendar_settings_layoutDateField.tr(), ), ), @@ -300,7 +309,8 @@ class FirstDayOfWeek extends StatelessWidget { height: GridSize.popoverItemHeight, child: FlowyButton( margin: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0), - text: FlowyText.medium( + text: FlowyText( + lineHeight: 1.0, LocaleKeys.calendar_settings_firstDayOfWeek.tr(), ), ), @@ -320,12 +330,11 @@ Widget _toggleItem({ padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0), child: Row( children: [ - FlowyText.medium(text), + FlowyText(text), const Spacer(), Toggle( value: value, - onChanged: (value) => onToggle(!value), - style: ToggleStyle.big, + onChanged: (value) => onToggle(value), padding: EdgeInsets.zero, ), ], @@ -362,7 +371,10 @@ class StartFromButton extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(title), + 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 index cc496873ef..6bfe7b99a8 100644 --- 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 @@ -1,26 +1,60 @@ 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/grid/presentation/widgets/toolbar/view_database_button.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.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 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 SizedBox( - height: 20, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - SettingButton( - databaseController: databaseController, - ), - ], + 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(); + } + final isReference = + Provider.of(context)?.isReference ?? false; + return SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilterButton( + toggleExtension: toggleExtension, + ), + if (isReference) ...[ + const HSpace(2), + ViewDatabaseButton(view: databaseController.view), + ], + const HSpace(2), + SettingButton( + databaseController: databaseController, + ), + ], + ), + ); + }, ), ); } 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 index 5b89d05643..3dcba2ca37 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/checklist_cell_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/checklist_cell_service.dart @@ -1,6 +1,5 @@ import 'package:appflowy_backend/dispatch/dispatch.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: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'; @@ -18,12 +17,16 @@ class ChecklistCellBackendService { Future> create({ required String name, + int? index, }) { - final payload = ChecklistCellDataChangesetPB.create() - ..viewId = viewId - ..fieldId = fieldId - ..rowId = rowId - ..insertOptions.add(name); + final insert = ChecklistCellInsertPB()..name = name; + if (index != null) { + insert.index = index; + } + + final payload = ChecklistCellDataChangesetPB() + ..cellId = _makdeCellId() + ..insertTask.add(insert); return DatabaseEventUpdateChecklistCell(payload).send(); } @@ -31,11 +34,9 @@ class ChecklistCellBackendService { Future> delete({ required List optionIds, }) { - final payload = ChecklistCellDataChangesetPB.create() - ..viewId = viewId - ..fieldId = fieldId - ..rowId = rowId - ..deleteOptionIds.addAll(optionIds); + final payload = ChecklistCellDataChangesetPB() + ..cellId = _makdeCellId() + ..deleteTasks.addAll(optionIds); return DatabaseEventUpdateChecklistCell(payload).send(); } @@ -43,11 +44,9 @@ class ChecklistCellBackendService { Future> select({ required String optionId, }) { - final payload = ChecklistCellDataChangesetPB.create() - ..viewId = viewId - ..fieldId = fieldId - ..rowId = rowId - ..selectedOptionIds.add(optionId); + final payload = ChecklistCellDataChangesetPB() + ..cellId = _makdeCellId() + ..completedTasks.add(optionId); return DatabaseEventUpdateChecklistCell(payload).send(); } @@ -60,12 +59,28 @@ class ChecklistCellBackendService { final newOption = option.rebuild((option) { option.name = name; }); - final payload = ChecklistCellDataChangesetPB.create() - ..viewId = viewId - ..fieldId = fieldId - ..rowId = rowId - ..updateOptions.add(newOption); + 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/date_cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/date_cell_service.dart index 9a9f75e75f..4afd41ad9c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/date_cell_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/date_cell_service.dart @@ -10,7 +10,7 @@ final class DateCellBackendService { required String viewId, required String fieldId, required String rowId, - }) : cellId = CellIdPB.create() + }) : cellId = CellIdPB() ..viewId = viewId ..fieldId = fieldId ..rowId = rowId; @@ -18,32 +18,27 @@ final class DateCellBackendService { final CellIdPB cellId; Future> update({ - required bool includeTime, - required bool isRange, + bool? includeTime, + bool? isRange, DateTime? date, - String? time, DateTime? endDate, - String? endTime, String? reminderId, }) { - final payload = DateCellChangesetPB.create() - ..cellId = cellId - ..includeTime = includeTime - ..isRange = isRange; + 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.date = Int64(dateTimestamp); - } - if (time != null) { - payload.time = time; + payload.timestamp = Int64(dateTimestamp); } if (endDate != null) { final dateTimestamp = endDate.millisecondsSinceEpoch ~/ 1000; - payload.endDate = Int64(dateTimestamp); - } - if (endTime != null) { - payload.endTime = endTime; + payload.endTimestamp = Int64(dateTimestamp); } if (reminderId != null) { payload.reminderId = reminderId; @@ -53,7 +48,7 @@ final class DateCellBackendService { } Future> clear() { - final payload = DateCellChangesetPB.create() + final payload = DateCellChangesetPB() ..cellId = cellId ..clearFlag = true; diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart index 9bc873f7f1..66c941891d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart @@ -20,6 +20,7 @@ class FieldBackendService { required String viewId, FieldType fieldType = FieldType.RichText, String? fieldName, + String? icon, Uint8List? typeOptionData, OrderObjectPositionPB? position, }) { @@ -88,6 +89,7 @@ class FieldBackendService { /// Update a field's properties Future> updateField({ String? name, + String? icon, bool? frozen, }) { final payload = FieldChangesetPB.create() @@ -98,6 +100,10 @@ class FieldBackendService { payload.name = name; } + if (icon != null) { + payload.icon = icon; + } + if (frozen != null) { payload.frozen = frozen; } @@ -110,12 +116,18 @@ class FieldBackendService { 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(); } @@ -177,11 +189,13 @@ class FieldBackendService { Future> updateType({ required FieldType fieldType, + String? fieldName, }) => updateFieldType( viewId: viewId, fieldId: fieldId, fieldType: fieldType, + fieldName: fieldName, ); Future> delete() => diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart index 64854a8faf..4e191bf019 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart @@ -92,20 +92,14 @@ class FilterBackendService { Future> insertDateFilter({ required String fieldId, + required FieldType fieldType, String? filterId, required DateFilterConditionPB condition, - required FieldType fieldType, int? start, int? end, int? timestamp, }) { - assert( - fieldType == FieldType.DateTime || - fieldType == FieldType.LastEditedTime || - fieldType == FieldType.CreatedTime, - ); - - final filter = DateFilterPB(); + final filter = DateFilterPB()..condition = condition; if (timestamp != null) { filter.timestamp = $fixnum.Int64(timestamp); @@ -120,13 +114,13 @@ class FilterBackendService { return filterId == null ? insertFilter( fieldId: fieldId, - fieldType: FieldType.DateTime, + fieldType: fieldType, data: filter.writeToBuffer(), ) : updateFilter( filterId: filterId, fieldId: fieldId, - fieldType: FieldType.DateTime, + fieldType: fieldType, data: filter.writeToBuffer(), ); } @@ -202,6 +196,30 @@ class FilterBackendService { ); } + 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, @@ -257,13 +275,34 @@ class FilterBackendService { ); } - Future> deleteFilter({ + 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() - ..fieldId = fieldId - ..filterId = filterId; + final deleteFilterPayload = DeleteFilterPB()..filterId = filterId; final payload = DatabaseSettingChangesetPB() ..viewId = viewId diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart index 69703d7748..f7a9be4a9c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart @@ -1,5 +1,5 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.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'; @@ -10,22 +10,22 @@ class GroupBackendService { Future> groupByField({ required String fieldId, + required List settingContent, }) { final payload = GroupByFieldPayloadPB.create() ..viewId = viewId - ..fieldId = fieldId; + ..fieldId = fieldId + ..settingContent = settingContent; return DatabaseEventSetGroupByField(payload).send(); } Future> updateGroup({ required String groupId, - required String fieldId, String? name, bool? visible, }) { final payload = UpdateGroupPB.create() - ..fieldId = fieldId ..viewId = viewId ..groupId = groupId; 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 index 93db3c703d..2e0c24718e 100644 --- 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 @@ -70,7 +70,7 @@ class SelectOptionCellBackendService { return DatabaseEventUpdateSelectOptionCell(payload).send(); } - Future> unSelect({ + Future> unselect({ required Iterable optionIds, }) { final payload = SelectOptionCellChangesetPB() diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/sort_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/sort_service.dart index 12255afb7f..bdd8ea9716 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/sort_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/sort_service.dart @@ -85,7 +85,6 @@ class SortBackendService { } Future> deleteSort({ - required String fieldId, required String sortId, }) { final deleteSortPayload = DeleteSortPayloadPB.create() 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 index e41fa61b2f..a2b80a29df 100644 --- 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 @@ -39,11 +39,13 @@ class CalculationsBloc extends Bloc { _startListening(); await _getAllCalculations(); - add( - CalculationsEvent.didReceiveFieldUpdate( - _fieldController.fieldInfos, - ), - ); + if (!isClosed) { + add( + CalculationsEvent.didReceiveFieldUpdate( + _fieldController.fieldInfos, + ), + ); + } }, didReceiveFieldUpdate: (fields) async { emit( @@ -131,6 +133,10 @@ class CalculationsBloc extends Bloc { Future _getAllCalculations() async { final calculationsOrFailure = await _calculationsService.getCalculations(); + if (isClosed) { + return; + } + final RepeatedCalculationsPB? calculations = calculationsOrFailure.fold((s) => s, (e) => null); if (calculations != null) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checkbox_filter_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checkbox_filter_editor_bloc.dart deleted file mode 100644 index 17449bda44..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checkbox_filter_editor_bloc.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/plugins/database/domain/filter_listener.dart'; -import 'package:appflowy/plugins/database/domain/filter_service.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'checkbox_filter_editor_bloc.freezed.dart'; - -class CheckboxFilterEditorBloc - extends Bloc { - CheckboxFilterEditorBloc({required this.filterInfo}) - : _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId), - _listener = FilterListener( - viewId: filterInfo.viewId, - filterId: filterInfo.filter.id, - ), - super(CheckboxFilterEditorState.initial(filterInfo)) { - _dispatch(); - } - - final FilterInfo filterInfo; - final FilterBackendService _filterBackendSvc; - final FilterListener _listener; - - void _dispatch() { - on( - (event, emit) async { - await event.when( - initial: () async { - _startListening(); - }, - updateCondition: (CheckboxFilterConditionPB condition) { - _filterBackendSvc.insertCheckboxFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: condition, - ); - }, - delete: () { - _filterBackendSvc.deleteFilter( - fieldId: filterInfo.fieldInfo.id, - filterId: filterInfo.filter.id, - ); - }, - didReceiveFilter: (FilterPB filter) { - final filterInfo = state.filterInfo.copyWith(filter: filter); - final checkboxFilter = filterInfo.checkboxFilter()!; - emit( - state.copyWith( - filterInfo: filterInfo, - filter: checkboxFilter, - ), - ); - }, - ); - }, - ); - } - - void _startListening() { - _listener.start( - onUpdated: (filter) { - if (!isClosed) { - add(CheckboxFilterEditorEvent.didReceiveFilter(filter)); - } - }, - ); - } - - @override - Future close() async { - await _listener.stop(); - return super.close(); - } -} - -@freezed -class CheckboxFilterEditorEvent with _$CheckboxFilterEditorEvent { - const factory CheckboxFilterEditorEvent.initial() = _Initial; - const factory CheckboxFilterEditorEvent.didReceiveFilter(FilterPB filter) = - _DidReceiveFilter; - const factory CheckboxFilterEditorEvent.updateCondition( - CheckboxFilterConditionPB condition, - ) = _UpdateCondition; - const factory CheckboxFilterEditorEvent.delete() = _Delete; -} - -@freezed -class CheckboxFilterEditorState with _$CheckboxFilterEditorState { - const factory CheckboxFilterEditorState({ - required FilterInfo filterInfo, - required CheckboxFilterPB filter, - }) = _GridFilterState; - - factory CheckboxFilterEditorState.initial(FilterInfo filterInfo) { - return CheckboxFilterEditorState( - filterInfo: filterInfo, - filter: filterInfo.checkboxFilter()!, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checklist_filter_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checklist_filter_bloc.dart deleted file mode 100644 index 1decdd8215..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checklist_filter_bloc.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/plugins/database/domain/filter_listener.dart'; -import 'package:appflowy/plugins/database/domain/filter_service.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'checklist_filter_bloc.freezed.dart'; - -class ChecklistFilterEditorBloc - extends Bloc { - ChecklistFilterEditorBloc({required this.filterInfo}) - : _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId), - _listener = FilterListener( - viewId: filterInfo.viewId, - filterId: filterInfo.filter.id, - ), - super(ChecklistFilterEditorState.initial(filterInfo)) { - _dispatch(); - } - - final FilterInfo filterInfo; - final FilterBackendService _filterBackendSvc; - final FilterListener _listener; - - void _dispatch() { - on( - (event, emit) async { - await event.when( - initial: () async { - _startListening(); - }, - updateCondition: (ChecklistFilterConditionPB condition) { - _filterBackendSvc.insertChecklistFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: condition, - ); - }, - delete: () { - _filterBackendSvc.deleteFilter( - fieldId: filterInfo.fieldInfo.id, - filterId: filterInfo.filter.id, - ); - }, - didReceiveFilter: (FilterPB filter) { - final filterInfo = state.filterInfo.copyWith(filter: filter); - final checklistFilter = filterInfo.checklistFilter()!; - emit( - state.copyWith( - filterInfo: filterInfo, - filter: checklistFilter, - ), - ); - }, - ); - }, - ); - } - - void _startListening() { - _listener.start( - onUpdated: (filter) { - if (!isClosed) { - add(ChecklistFilterEditorEvent.didReceiveFilter(filter)); - } - }, - ); - } - - @override - Future close() async { - await _listener.stop(); - return super.close(); - } -} - -@freezed -class ChecklistFilterEditorEvent with _$ChecklistFilterEditorEvent { - const factory ChecklistFilterEditorEvent.initial() = _Initial; - const factory ChecklistFilterEditorEvent.didReceiveFilter(FilterPB filter) = - _DidReceiveFilter; - const factory ChecklistFilterEditorEvent.updateCondition( - ChecklistFilterConditionPB condition, - ) = _UpdateCondition; - const factory ChecklistFilterEditorEvent.delete() = _Delete; -} - -@freezed -class ChecklistFilterEditorState with _$ChecklistFilterEditorState { - const factory ChecklistFilterEditorState({ - required FilterInfo filterInfo, - required ChecklistFilterPB filter, - required String filterDesc, - }) = _GridFilterState; - - factory ChecklistFilterEditorState.initial(FilterInfo filterInfo) { - return ChecklistFilterEditorState( - filterInfo: filterInfo, - filter: filterInfo.checklistFilter()!, - filterDesc: '', - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart deleted file mode 100644 index d8ea5906a8..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart +++ /dev/null @@ -1,198 +0,0 @@ -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/filter_service.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_filter.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/number_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_filter.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'filter_create_bloc.freezed.dart'; - -class GridCreateFilterBloc - extends Bloc { - GridCreateFilterBloc({required this.viewId, required this.fieldController}) - : _filterBackendSvc = FilterBackendService(viewId: viewId), - super(GridCreateFilterState.initial(fieldController.fieldInfos)) { - _dispatch(); - } - - final String viewId; - final FilterBackendService _filterBackendSvc; - final FieldController fieldController; - void Function(List)? _onFieldFn; - - void _dispatch() { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - }, - didReceiveFields: (List fields) { - emit( - state.copyWith( - allFields: fields, - creatableFields: _filterFields(fields, state.filterText), - ), - ); - }, - didReceiveFilterText: (String text) { - emit( - state.copyWith( - filterText: text, - creatableFields: _filterFields(state.allFields, text), - ), - ); - }, - createDefaultFilter: (FieldInfo field) { - emit(state.copyWith(didCreateFilter: true)); - _createDefaultFilter(field); - }, - ); - }, - ); - } - - List _filterFields( - List fields, - String filterText, - ) { - final List allFields = List.from(fields); - final keyword = filterText.toLowerCase(); - allFields.retainWhere((field) { - if (!field.canCreateFilter) { - return false; - } - - if (filterText.isNotEmpty) { - return field.name.toLowerCase().contains(keyword); - } - - return true; - }); - - return allFields; - } - - void _startListening() { - _onFieldFn = (fields) { - fields.retainWhere((field) => field.canCreateFilter); - add(GridCreateFilterEvent.didReceiveFields(fields)); - }; - fieldController.addListener(onReceiveFields: _onFieldFn); - } - - Future> _createDefaultFilter( - FieldInfo field, - ) async { - final fieldId = field.id; - switch (field.fieldType) { - case FieldType.Checkbox: - return _filterBackendSvc.insertCheckboxFilter( - fieldId: fieldId, - condition: CheckboxFilterConditionPB.IsChecked, - ); - case FieldType.DateTime: - case FieldType.LastEditedTime: - case FieldType.CreatedTime: - final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - return _filterBackendSvc.insertDateFilter( - fieldId: fieldId, - condition: DateFilterConditionPB.DateIs, - timestamp: timestamp, - fieldType: field.fieldType, - ); - case FieldType.MultiSelect: - return _filterBackendSvc.insertSelectOptionFilter( - fieldId: fieldId, - condition: SelectOptionFilterConditionPB.OptionContains, - fieldType: FieldType.MultiSelect, - ); - case FieldType.Checklist: - return _filterBackendSvc.insertChecklistFilter( - fieldId: fieldId, - condition: ChecklistFilterConditionPB.IsIncomplete, - ); - case FieldType.Number: - return _filterBackendSvc.insertNumberFilter( - fieldId: fieldId, - condition: NumberFilterConditionPB.Equal, - ); - case FieldType.RichText: - return _filterBackendSvc.insertTextFilter( - fieldId: fieldId, - condition: TextFilterConditionPB.TextContains, - content: '', - ); - case FieldType.SingleSelect: - return _filterBackendSvc.insertSelectOptionFilter( - fieldId: fieldId, - condition: SelectOptionFilterConditionPB.OptionIs, - fieldType: FieldType.SingleSelect, - ); - case FieldType.URL: - return _filterBackendSvc.insertURLFilter( - fieldId: fieldId, - condition: TextFilterConditionPB.TextContains, - ); - default: - throw UnimplementedError(); - } - } - - @override - Future close() async { - if (_onFieldFn != null) { - fieldController.removeListener(onFieldsListener: _onFieldFn); - _onFieldFn = null; - } - return super.close(); - } -} - -@freezed -class GridCreateFilterEvent with _$GridCreateFilterEvent { - const factory GridCreateFilterEvent.initial() = _Initial; - const factory GridCreateFilterEvent.didReceiveFields(List fields) = - _DidReceiveFields; - - const factory GridCreateFilterEvent.createDefaultFilter(FieldInfo field) = - _CreateDefaultFilter; - - const factory GridCreateFilterEvent.didReceiveFilterText(String text) = - _DidReceiveFilterText; -} - -@freezed -class GridCreateFilterState with _$GridCreateFilterState { - const factory GridCreateFilterState({ - required String filterText, - required List creatableFields, - required List allFields, - required bool didCreateFilter, - }) = _GridFilterState; - - factory GridCreateFilterState.initial(List fields) { - return GridCreateFilterState( - filterText: "", - creatableFields: getCreatableFilter(fields), - allFields: fields, - didCreateFilter: false, - ); - } -} - -List getCreatableFilter(List fieldInfos) { - final List creatableFields = List.from(fieldInfos); - creatableFields.retainWhere((element) => element.canCreateFilter); - return creatableFields; -} 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 0000000000..6a386ff130 --- /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/filter_menu_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_menu_bloc.dart deleted file mode 100644 index cc26e42b83..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_menu_bloc.dart +++ /dev/null @@ -1,129 +0,0 @@ -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/grid/presentation/widgets/filter/filter_info.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'filter_menu_bloc.freezed.dart'; - -class DatabaseFilterMenuBloc - extends Bloc { - DatabaseFilterMenuBloc({required this.viewId, required this.fieldController}) - : super( - DatabaseFilterMenuState.initial( - viewId, - fieldController.filterInfos, - fieldController.fieldInfos, - ), - ) { - _dispatch(); - } - - final String viewId; - final FieldController fieldController; - void Function(List)? _onFilterFn; - void Function(List)? _onFieldFn; - - void _dispatch() { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - }, - didReceiveFilters: (filters) { - emit(state.copyWith(filters: filters)); - }, - toggleMenu: () { - final isVisible = !state.isVisible; - emit(state.copyWith(isVisible: isVisible)); - }, - didReceiveFields: (List fields) { - emit( - state.copyWith( - fields: fields, - creatableFields: getCreatableFilter(fields), - ), - ); - }, - ); - }, - ); - } - - void _startListening() { - _onFilterFn = (filters) { - add(DatabaseFilterMenuEvent.didReceiveFilters(filters)); - }; - - _onFieldFn = (fields) { - add(DatabaseFilterMenuEvent.didReceiveFields(fields)); - }; - - fieldController.addListener( - onFilters: (filters) { - _onFilterFn?.call(filters); - }, - onReceiveFields: (fields) { - _onFieldFn?.call(fields); - }, - ); - } - - @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(); - } -} - -@freezed -class DatabaseFilterMenuEvent with _$DatabaseFilterMenuEvent { - const factory DatabaseFilterMenuEvent.initial() = _Initial; - const factory DatabaseFilterMenuEvent.didReceiveFilters( - List filters, - ) = _DidReceiveFilters; - const factory DatabaseFilterMenuEvent.didReceiveFields( - List fields, - ) = _DidReceiveFields; - const factory DatabaseFilterMenuEvent.toggleMenu() = _SetMenuVisibility; -} - -@freezed -class DatabaseFilterMenuState with _$DatabaseFilterMenuState { - const factory DatabaseFilterMenuState({ - required String viewId, - required List filters, - required List fields, - required List creatableFields, - required bool isVisible, - }) = _DatabaseFilterMenuState; - - factory DatabaseFilterMenuState.initial( - String viewId, - List filterInfos, - List fields, - ) => - DatabaseFilterMenuState( - viewId: viewId, - filters: filterInfos, - fields: fields, - creatableFields: getCreatableFilter(fields), - isVisible: false, - ); -} - -List getCreatableFilter(List fieldInfos) { - final List creatableFields = List.from(fieldInfos); - creatableFields.retainWhere((element) => element.canCreateFilter); - return creatableFields; -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart deleted file mode 100644 index d68dd17537..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/plugins/database/domain/filter_listener.dart'; -import 'package:appflowy/plugins/database/domain/filter_service.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_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 'number_filter_editor_bloc.freezed.dart'; - -class NumberFilterEditorBloc - extends Bloc { - NumberFilterEditorBloc({required this.filterInfo}) - : _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId), - _listener = FilterListener( - viewId: filterInfo.viewId, - filterId: filterInfo.filter.id, - ), - super(NumberFilterEditorState.initial(filterInfo)) { - _dispatch(); - _startListening(); - } - - final FilterInfo filterInfo; - final FilterBackendService _filterBackendSvc; - final FilterListener _listener; - - void _dispatch() { - on( - (event, emit) async { - event.when( - didReceiveFilter: (filter) { - final filterInfo = state.filterInfo.copyWith(filter: filter); - emit( - state.copyWith( - filterInfo: filterInfo, - filter: filterInfo.numberFilter()!, - ), - ); - }, - updateCondition: (NumberFilterConditionPB condition) { - _filterBackendSvc.insertNumberFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: condition, - content: state.filter.content, - ); - }, - updateContent: (content) { - _filterBackendSvc.insertNumberFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: state.filter.condition, - content: content, - ); - }, - delete: () { - _filterBackendSvc.deleteFilter( - fieldId: filterInfo.fieldInfo.id, - filterId: filterInfo.filter.id, - ); - }, - ); - }, - ); - } - - void _startListening() { - _listener.start( - onUpdated: (filter) { - if (!isClosed) { - add(NumberFilterEditorEvent.didReceiveFilter(filter)); - } - }, - ); - } - - @override - Future close() async { - await _listener.stop(); - return super.close(); - } -} - -@freezed -class NumberFilterEditorEvent with _$NumberFilterEditorEvent { - const factory NumberFilterEditorEvent.didReceiveFilter(FilterPB filter) = - _DidReceiveFilter; - const factory NumberFilterEditorEvent.updateCondition( - NumberFilterConditionPB condition, - ) = _UpdateCondition; - const factory NumberFilterEditorEvent.updateContent(String content) = - _UpdateContent; - const factory NumberFilterEditorEvent.delete() = _Delete; -} - -@freezed -class NumberFilterEditorState with _$NumberFilterEditorState { - const factory NumberFilterEditorState({ - required FilterInfo filterInfo, - required NumberFilterPB filter, - }) = _NumberFilterEditorState; - - factory NumberFilterEditorState.initial(FilterInfo filterInfo) { - return NumberFilterEditorState( - filterInfo: filterInfo, - filter: filterInfo.numberFilter()!, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_bloc.dart deleted file mode 100644 index 3f44cb6d36..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_bloc.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/plugins/database/domain/filter_listener.dart'; -import 'package:appflowy/plugins/database/domain/filter_service.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option_loader.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_filter.pbserver.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'select_option_filter_bloc.freezed.dart'; - -class SelectOptionFilterEditorBloc - extends Bloc { - SelectOptionFilterEditorBloc({ - required this.filterInfo, - required this.delegate, - }) : _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId), - _listener = FilterListener( - viewId: filterInfo.viewId, - filterId: filterInfo.filter.id, - ), - super(SelectOptionFilterEditorState.initial(filterInfo)) { - _dispatch(); - } - - final FilterInfo filterInfo; - final FilterBackendService _filterBackendSvc; - final FilterListener _listener; - final SelectOptionFilterDelegate delegate; - - void _dispatch() { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - _loadOptions(); - }, - updateCondition: (SelectOptionFilterConditionPB condition) { - _filterBackendSvc.insertSelectOptionFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: condition, - optionIds: state.filter.optionIds, - fieldType: state.filterInfo.fieldInfo.fieldType, - ); - }, - updateContent: (List optionIds) { - _filterBackendSvc.insertSelectOptionFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: state.filter.condition, - optionIds: optionIds, - fieldType: state.filterInfo.fieldInfo.fieldType, - ); - }, - delete: () { - _filterBackendSvc.deleteFilter( - fieldId: filterInfo.fieldInfo.id, - filterId: filterInfo.filter.id, - ); - }, - didReceiveFilter: (FilterPB filter) { - final filterInfo = state.filterInfo.copyWith(filter: filter); - final selectOptionFilter = filterInfo.selectOptionFilter()!; - emit( - state.copyWith( - filterInfo: filterInfo, - filter: selectOptionFilter, - ), - ); - }, - updateFilterDescription: (String desc) { - emit(state.copyWith(filterDesc: desc)); - }, - ); - }, - ); - } - - void _startListening() { - _listener.start( - onUpdated: (filter) { - if (!isClosed) { - add(SelectOptionFilterEditorEvent.didReceiveFilter(filter)); - } - }, - ); - } - - void _loadOptions() { - if (!isClosed) { - final options = delegate.loadOptions(); - String filterDesc = ''; - for (final option in options) { - if (state.filter.optionIds.contains(option.id)) { - filterDesc += "${option.name} "; - } - } - add(SelectOptionFilterEditorEvent.updateFilterDescription(filterDesc)); - } - } - - @override - Future close() async { - await _listener.stop(); - return super.close(); - } -} - -@freezed -class SelectOptionFilterEditorEvent with _$SelectOptionFilterEditorEvent { - const factory SelectOptionFilterEditorEvent.initial() = _Initial; - const factory SelectOptionFilterEditorEvent.didReceiveFilter( - FilterPB filter, - ) = _DidReceiveFilter; - const factory SelectOptionFilterEditorEvent.updateCondition( - SelectOptionFilterConditionPB condition, - ) = _UpdateCondition; - const factory SelectOptionFilterEditorEvent.updateContent( - List optionIds, - ) = _UpdateContent; - const factory SelectOptionFilterEditorEvent.updateFilterDescription( - String desc, - ) = _UpdateDesc; - const factory SelectOptionFilterEditorEvent.delete() = _Delete; -} - -@freezed -class SelectOptionFilterEditorState with _$SelectOptionFilterEditorState { - const factory SelectOptionFilterEditorState({ - required FilterInfo filterInfo, - required SelectOptionFilterPB filter, - required String filterDesc, - }) = _GridFilterState; - - factory SelectOptionFilterEditorState.initial(FilterInfo filterInfo) { - return SelectOptionFilterEditorState( - filterInfo: filterInfo, - filter: filterInfo.selectOptionFilter()!, - filterDesc: '', - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_list_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_list_bloc.dart deleted file mode 100644 index 84e1284822..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_list_bloc.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option_loader.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 'select_option_filter_list_bloc.freezed.dart'; - -class SelectOptionFilterListBloc - extends Bloc { - SelectOptionFilterListBloc({ - required this.delegate, - required List selectedOptionIds, - }) : super(SelectOptionFilterListState.initial(selectedOptionIds)) { - _dispatch(); - } - - final SelectOptionFilterDelegate delegate; - - void _dispatch() { - on( - (event, emit) async { - await event.when( - initial: () async { - _startListening(); - _loadOptions(); - }, - selectOption: (option, condition) { - final selectedOptionIds = delegate.selectOption( - state.selectedOptionIds, - option.id, - condition, - ); - - _updateSelectOptions( - selectedOptionIds: selectedOptionIds, - emit: emit, - ); - }, - unSelectOption: (option) { - final selectedOptionIds = Set.from(state.selectedOptionIds); - selectedOptionIds.remove(option.id); - - _updateSelectOptions( - selectedOptionIds: selectedOptionIds, - emit: emit, - ); - }, - didReceiveOptions: (newOptions) { - final List options = List.from(newOptions); - options.retainWhere( - (element) => element.name.contains(state.predicate), - ); - - final visibleOptions = options.map((option) { - return VisibleSelectOption( - option, - state.selectedOptionIds.contains(option.id), - ); - }).toList(); - - emit( - state.copyWith( - options: options, - visibleOptions: visibleOptions, - ), - ); - }, - filterOption: (optionName) { - _updateSelectOptions(predicate: optionName, emit: emit); - }, - ); - }, - ); - } - - void _updateSelectOptions({ - String? predicate, - Set? selectedOptionIds, - required Emitter emit, - }) { - final List visibleOptions = _makeVisibleOptions( - predicate ?? state.predicate, - selectedOptionIds ?? state.selectedOptionIds, - ); - - emit( - state.copyWith( - predicate: predicate ?? state.predicate, - visibleOptions: visibleOptions, - selectedOptionIds: selectedOptionIds ?? state.selectedOptionIds, - ), - ); - } - - List _makeVisibleOptions( - String predicate, - Set selectedOptionIds, - ) { - final List options = List.from(state.options); - options.retainWhere((element) => element.name.contains(predicate)); - - return options.map((option) { - return VisibleSelectOption(option, selectedOptionIds.contains(option.id)); - }).toList(); - } - - void _loadOptions() { - if (!isClosed) { - final options = delegate.loadOptions(); - add(SelectOptionFilterListEvent.didReceiveOptions(options)); - } - } - - void _startListening() {} -} - -@freezed -class SelectOptionFilterListEvent with _$SelectOptionFilterListEvent { - const factory SelectOptionFilterListEvent.initial() = _Initial; - const factory SelectOptionFilterListEvent.selectOption( - SelectOptionPB option, - SelectOptionFilterConditionPB condition, - ) = _SelectOption; - const factory SelectOptionFilterListEvent.unSelectOption( - SelectOptionPB option, - ) = _UnSelectOption; - const factory SelectOptionFilterListEvent.didReceiveOptions( - List options, - ) = _DidReceiveOptions; - const factory SelectOptionFilterListEvent.filterOption(String optionName) = - _SelectOptionFilter; -} - -@freezed -class SelectOptionFilterListState with _$SelectOptionFilterListState { - const factory SelectOptionFilterListState({ - required List options, - required List visibleOptions, - required Set selectedOptionIds, - required String predicate, - }) = _SelectOptionFilterListState; - - factory SelectOptionFilterListState.initial(List selectedOptionIds) { - return SelectOptionFilterListState( - options: [], - predicate: '', - visibleOptions: [], - selectedOptionIds: selectedOptionIds.toSet(), - ); - } -} - -class VisibleSelectOption { - VisibleSelectOption(this.optionPB, this.isSelected); - - final SelectOptionPB optionPB; - final bool isSelected; -} 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 0000000000..0e7c59bbb6 --- /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/filter/text_filter_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/text_filter_editor_bloc.dart deleted file mode 100644 index e4fa67c4a8..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/text_filter_editor_bloc.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/plugins/database/domain/filter_listener.dart'; -import 'package:appflowy/plugins/database/domain/filter_service.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_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 'text_filter_editor_bloc.freezed.dart'; - -class TextFilterEditorBloc - extends Bloc { - TextFilterEditorBloc({required this.filterInfo, required this.fieldType}) - : _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId), - _listener = FilterListener( - viewId: filterInfo.viewId, - filterId: filterInfo.filter.id, - ), - super(TextFilterEditorState.initial(filterInfo)) { - _dispatch(); - } - - final FilterInfo filterInfo; - final FieldType fieldType; - final FilterBackendService _filterBackendSvc; - final FilterListener _listener; - - void _dispatch() { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - }, - updateCondition: (TextFilterConditionPB condition) { - fieldType == FieldType.RichText - ? _filterBackendSvc.insertTextFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: condition, - content: state.filter.content, - ) - : _filterBackendSvc.insertURLFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: condition, - content: state.filter.content, - ); - }, - updateContent: (String content) { - fieldType == FieldType.RichText - ? _filterBackendSvc.insertTextFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: state.filter.condition, - content: content, - ) - : _filterBackendSvc.insertURLFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: state.filter.condition, - content: content, - ); - }, - delete: () { - _filterBackendSvc.deleteFilter( - fieldId: filterInfo.fieldInfo.id, - filterId: filterInfo.filter.id, - ); - }, - didReceiveFilter: (FilterPB filter) { - final filterInfo = state.filterInfo.copyWith(filter: filter); - final textFilter = filterInfo.textFilter()!; - emit( - state.copyWith( - filterInfo: filterInfo, - filter: textFilter, - ), - ); - }, - ); - }, - ); - } - - void _startListening() { - _listener.start( - onUpdated: (filter) { - if (!isClosed) { - add(TextFilterEditorEvent.didReceiveFilter(filter)); - } - }, - ); - } - - @override - Future close() async { - await _listener.stop(); - return super.close(); - } -} - -@freezed -class TextFilterEditorEvent with _$TextFilterEditorEvent { - const factory TextFilterEditorEvent.initial() = _Initial; - const factory TextFilterEditorEvent.didReceiveFilter(FilterPB filter) = - _DidReceiveFilter; - const factory TextFilterEditorEvent.updateCondition( - TextFilterConditionPB condition, - ) = _UpdateCondition; - const factory TextFilterEditorEvent.updateContent(String content) = - _UpdateContent; - const factory TextFilterEditorEvent.delete() = _Delete; -} - -@freezed -class TextFilterEditorState with _$TextFilterEditorState { - const factory TextFilterEditorState({ - required FilterInfo filterInfo, - required TextFilterPB filter, - }) = _GridFilterState; - - factory TextFilterEditorState.initial(FilterInfo filterInfo) { - return TextFilterEditorState( - filterInfo: filterInfo, - filter: filterInfo.textFilter()!, - ); - } -} 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 index 01178d9b3f..9b59b997b1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart @@ -2,13 +2,15 @@ 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/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_info.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'; @@ -18,30 +20,73 @@ import '../../application/database_controller.dart'; part 'grid_bloc.freezed.dart'; class GridBloc extends Bloc { - GridBloc({required ViewPB view, required this.databaseController}) - : super(GridState.initial(view.id)) { + 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 result = await RowBackendService.createRow(viewId: viewId); + 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), @@ -51,7 +96,7 @@ class GridBloc extends Bloc { emit(state.copyWith(createdRow: null, openRowDetail: false)); }, deleteRow: (rowInfo) async { - await RowBackendService.deleteRow(rowInfo.viewId, rowInfo.rowId); + await RowBackendService.deleteRows(viewId, [rowInfo.rowId]); }, moveRow: (int from, int to) { final List rows = [...state.rowInfos]; @@ -64,15 +109,8 @@ class GridBloc extends Bloc { databaseController.moveRow(fromRowId: fromRow, toRowId: toRow); }, - didReceiveGridUpdate: (grid) { - emit(state.copyWith(grid: grid)); - }, didReceiveFieldUpdate: (fields) { - emit( - state.copyWith( - fields: fields, - ), - ); + emit(state.copyWith(fields: fields)); }, didLoadRows: (newRowInfos, reason) { emit( @@ -83,39 +121,38 @@ class GridBloc extends Bloc { ), ); }, - didReceveFilters: (List filters) { - emit( - state.copyWith(filters: filters), - ); + didReceveFilters: (filters) { + emit(state.copyWith(filters: filters)); }, - didReceveSorts: (List sorts) { - emit( - state.copyWith( - reorderable: sorts.isEmpty, - sorts: sorts, - ), - ); + didReceveSorts: (sorts) { + emit(state.copyWith(reorderable: sorts.isEmpty, sorts: sorts)); + }, + loadMoreRows: () { + emit(state.copyWith(visibleRows: state.visibleRows + 25)); }, ); }, ); } - RowCache getRowCache(RowId rowId) => databaseController.rowCache; + RowCache get rowCache => databaseController.rowCache; void _startListening() { - final onDatabaseChanged = DatabaseCallbacks( - onDatabaseChanged: (database) { - if (!isClosed) { - add(GridEvent.didReceiveGridUpdate(database)); - } - }, + _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), @@ -138,7 +175,7 @@ class GridBloc extends Bloc { } }, ); - databaseController.addListener(onDatabaseChanged: onDatabaseChanged); + databaseController.addListener(onDatabaseChanged: _databaseCallbacks); } Future _openGrid(Emitter emit) async { @@ -164,6 +201,7 @@ class GridBloc extends Bloc { @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; @@ -175,22 +213,17 @@ class GridEvent with _$GridEvent { const factory GridEvent.didReceiveFieldUpdate( List fields, ) = _DidReceiveFieldUpdate; - - const factory GridEvent.didReceiveGridUpdate( - DatabasePB grid, - ) = _DidReceiveGridUpdate; - - const factory GridEvent.didReceveFilters(List filters) = + const factory GridEvent.didReceveFilters(List filters) = _DidReceiveFilters; - const factory GridEvent.didReceveSorts(List sorts) = + const factory GridEvent.didReceveSorts(List sorts) = _DidReceiveSorts; + const factory GridEvent.loadMoreRows() = _LoadMoreRows; } @freezed class GridState with _$GridState { const factory GridState({ required String viewId, - required DatabasePB? grid, required List fields, required List rowInfos, required int rowCount, @@ -198,9 +231,10 @@ class GridState with _$GridState { required LoadingState loadingState, required bool reorderable, required ChangedReason reason, - required List sorts, - required List filters, + required List sorts, + required List filters, required bool openRowDetail, + @Default(0) int visibleRows, }) = _GridState; factory GridState.initial(String viewId) => GridState( @@ -208,7 +242,6 @@ class GridState with _$GridState { rowInfos: [], rowCount: 0, createdRow: null, - grid: null, viewId: viewId, reorderable: true, loadingState: const LoadingState.loading(), @@ -216,5 +249,19 @@ class GridState with _$GridState { 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 index b9fa5b7e92..a869b636d2 100644 --- 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 @@ -20,6 +20,12 @@ class GridHeaderBloc extends Bloc { final String viewId; final FieldController fieldController; + @override + Future close() async { + fieldController.removeListener(onFieldsListener: _onReceiveFields); + await super.close(); + } + void _dispatch() { on( (event, emit) async { @@ -82,11 +88,13 @@ class GridHeaderBloc extends Bloc { void _startListening() { fieldController.addListener( - onReceiveFields: (fields) => - add(GridHeaderEvent.didReceiveFieldUpdate(fields)), + onReceiveFields: _onReceiveFields, listenWhen: () => !isClosed, ); } + + void _onReceiveFields(List fields) => + add(GridHeaderEvent.didReceiveFieldUpdate(fields)); } @freezed 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 index 60c7ad8e65..ec42cd5cac 100644 --- 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 @@ -1,5 +1,10 @@ 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'; @@ -9,17 +14,32 @@ 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) { + initial: (rowId) async { _startListening(); + emit( state.copyWith( isLoading: false, @@ -27,6 +47,12 @@ class MobileRowDetailBloc 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)); @@ -34,13 +60,23 @@ class MobileRowDetailBloc 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() { - final onDatabaseChanged = DatabaseCallbacks( + _databaseCallbacks = DatabaseCallbacks( onNumOfRowsChanged: (rowInfos, _, reason) { if (!isClosed) { add(MobileRowDetailEvent.didLoadRows(rowInfos)); @@ -56,7 +92,7 @@ class MobileRowDetailBloc } }, ); - databaseController.addListener(onDatabaseChanged: onDatabaseChanged); + databaseController.addListener(onDatabaseChanged: _databaseCallbacks); } } @@ -66,6 +102,7 @@ class MobileRowDetailEvent with _$MobileRowDetailEvent { const factory MobileRowDetailEvent.didLoadRows(List rows) = _DidLoadRows; const factory MobileRowDetailEvent.changeRowId(String rowId) = _ChangeRowId; + const factory MobileRowDetailEvent.addCover(RowCoverPB cover) = _AddCover; } @freezed 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 index a0c0467b95..402ae6e596 100644 --- 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 @@ -26,6 +26,7 @@ class RowBloc extends Bloc { _dispatch(); _startListening(); _init(); + rowController.initialize(); } final FieldController fieldController; @@ -36,7 +37,7 @@ class RowBloc extends Bloc { @override Future close() async { - _rowController.dispose(); + await _rowController.dispose(); return super.close(); } @@ -69,20 +70,19 @@ class RowBloc extends Bloc { ); } - void _startListening() { - _rowController.addListener( - onRowChanged: (cells, reason) { - if (!isClosed) { - add(RowEvent.didReceiveCells(cells, 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.loadData(), + _rowController.loadCells(), const ChangedReason.setInitialRows(), ), ); 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 index 0d655a840b..e7d4658df8 100644 --- 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 @@ -1,12 +1,17 @@ +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/application/row/row_controller.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'; @@ -16,20 +21,26 @@ class RowDetailBloc extends Bloc { RowDetailBloc({ required this.fieldController, required this.rowController, - }) : super(RowDetailState.initial()) { + }) : _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 { - rowController.dispose(); + await rowController.dispose(); + await _metaListener.stop(); return super.close(); } @@ -80,12 +91,35 @@ class RowDetailBloc extends Bloc { ), ); }, + 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) { @@ -125,7 +159,7 @@ class RowDetailBloc extends Bloc { } void _init() { - allCells.addAll(rowController.loadData()); + allCells.addAll(rowController.loadCells()); int numHiddenFields = 0; final visibleCells = []; for (final cell in allCells) { @@ -217,6 +251,23 @@ class RowDetailEvent with _$RowDetailEvent { /// 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 @@ -226,12 +277,18 @@ class RowDetailState with _$RowDetailState { required List visibleCells, required bool showHiddenFields, required int numHiddenFields, + required String editingFieldId, + required String newFieldId, + required RowMetaPB rowMeta, }) = _RowDetailState; - factory RowDetailState.initial() => const 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 index 93b55bfb3d..743d854b23 100644 --- 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 @@ -81,7 +81,7 @@ class RowDocumentBloc extends Bloc { // new document for the given document id of the row. final documentView = await _createRowDocumentView(rowMeta.documentId); - if (documentView != null) { + if (documentView != null && !isClosed) { add(RowDocumentEvent.didReceiveRowDocument(documentView)); } } else { 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 0000000000..bbf010d279 --- /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 index 5f2bd8cf89..37b0e37747 100644 --- 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 @@ -2,8 +2,9 @@ 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/plugins/database/grid/presentation/widgets/sort/sort_info.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'; @@ -19,7 +20,7 @@ class SortEditorBloc extends Bloc { }) : _sortBackendSvc = SortBackendService(viewId: viewId), super( SortEditorState.initial( - fieldController.sortInfos, + fieldController.sorts, fieldController.fieldInfos, ), ) { @@ -32,7 +33,7 @@ class SortEditorBloc extends Bloc { final FieldController fieldController; void Function(List)? _onFieldFn; - void Function(List)? _onSortsFn; + void Function(List)? _onSortsFn; void _dispatch() { on( @@ -42,13 +43,10 @@ class SortEditorBloc extends Bloc { emit( state.copyWith( allFields: fields, - creatableFields: getCreatableSorts(fields), + creatableFields: _getCreatableSorts(fields), ), ); }, - updateCreateSortFilter: (text) { - emit(state.copyWith(filter: text)); - }, createSort: ( String fieldId, SortConditionPB? condition, @@ -64,16 +62,16 @@ class SortEditorBloc extends Bloc { String? fieldId, SortConditionPB? condition, ) async { - final sortInfo = state.sortInfos + final sort = state.sorts .firstWhereOrNull((element) => element.sortId == sortId); - if (sortInfo == null) { + if (sort == null) { return; } final result = await _sortBackendSvc.updateSort( sortId: sortId, - fieldId: fieldId ?? sortInfo.fieldId, - condition: condition ?? sortInfo.sortPB.condition, + fieldId: fieldId ?? sort.fieldId, + condition: condition ?? sort.condition, ); result.fold((l) => {}, (err) => Log.error(err)); }, @@ -81,13 +79,12 @@ class SortEditorBloc extends Bloc { final result = await _sortBackendSvc.deleteAllSorts(); result.fold((l) => {}, (err) => Log.error(err)); }, - didReceiveSorts: (List sortInfos) { - emit(state.copyWith(sortInfos: sortInfos)); + didReceiveSorts: (sorts) { + emit(state.copyWith(sorts: sorts)); }, - deleteSort: (SortInfo sortInfo) async { + deleteSort: (sortId) async { final result = await _sortBackendSvc.deleteSort( - fieldId: sortInfo.fieldInfo.id, - sortId: sortInfo.sortId, + sortId: sortId, ); result.fold((l) => null, (err) => Log.error(err)); }, @@ -96,12 +93,12 @@ class SortEditorBloc extends Bloc { toIndex--; } - final fromId = state.sortInfos[fromIndex].sortId; - final toId = state.sortInfos[toIndex].sortId; + final fromId = state.sorts[fromIndex].sortId; + final toId = state.sorts[toIndex].sortId; - final newSorts = [...state.sortInfos]; + final newSorts = [...state.sorts]; newSorts.insert(toIndex, newSorts.removeAt(fromIndex)); - emit(state.copyWith(sortInfos: newSorts)); + emit(state.copyWith(sorts: newSorts)); final result = await _sortBackendSvc.reorderSort( fromSortId: fromId, toSortId: toId, @@ -144,10 +141,8 @@ class SortEditorBloc extends Bloc { class SortEditorEvent with _$SortEditorEvent { const factory SortEditorEvent.didReceiveFields(List fieldInfos) = _DidReceiveFields; - const factory SortEditorEvent.didReceiveSorts(List sortInfos) = + const factory SortEditorEvent.didReceiveSorts(List sorts) = _DidReceiveSorts; - const factory SortEditorEvent.updateCreateSortFilter(String text) = - _UpdateCreateSortFilter; const factory SortEditorEvent.createSort({ required String fieldId, SortConditionPB? condition, @@ -159,34 +154,34 @@ class SortEditorEvent with _$SortEditorEvent { }) = _EditSort; const factory SortEditorEvent.reorderSort(int oldIndex, int newIndex) = _ReorderSort; - const factory SortEditorEvent.deleteSort(SortInfo sortInfo) = _DeleteSort; + const factory SortEditorEvent.deleteSort(String sortId) = _DeleteSort; const factory SortEditorEvent.deleteAllSorts() = _DeleteAllSorts; } @freezed class SortEditorState with _$SortEditorState { const factory SortEditorState({ - required List sortInfos, - required List creatableFields, + required List sorts, required List allFields, - required String filter, + required List creatableFields, }) = _SortEditorState; factory SortEditorState.initial( - List sortInfos, + List sorts, List fields, ) { return SortEditorState( - creatableFields: getCreatableSorts(fields), + sorts: sorts, allFields: fields, - sortInfos: sortInfos, - filter: "", + creatableFields: _getCreatableSorts(fields), ); } } -List getCreatableSorts(List fieldInfos) { +List _getCreatableSorts(List fieldInfos) { final List creatableFields = List.from(fieldInfos); - creatableFields.retainWhere((element) => element.canCreateSort); + 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 index 3a10835489..b43c425a9d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/grid.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/grid.dart @@ -2,8 +2,8 @@ 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:easy_localization/easy_localization.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; class GridPluginBuilder implements PluginBuilder { @override @@ -19,13 +19,13 @@ class GridPluginBuilder implements PluginBuilder { String get menuName => LocaleKeys.grid_menuName.tr(); @override - FlowySvgData get icon => FlowySvgs.grid_s; + FlowySvgData get icon => FlowySvgs.icon_grid_s; @override PluginType get pluginType => PluginType.grid; @override - ViewLayoutPB? get layoutType => ViewLayoutPB.Grid; + ViewLayoutPB get layoutType => ViewLayoutPB.Grid; } class GridPluginConfig implements PluginConfig { 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 index 67e238ed51..4c9fd7bd61 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart @@ -1,30 +1,35 @@ -import 'package:appflowy/workspace/application/view/view_bloc.dart'; -import 'package:flutter/material.dart'; +import 'dart:async'; 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/grid/presentation/widgets/toolbar/grid_setting_bar.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.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/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_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: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/scrolling/styled_scrollview.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter/material.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'; @@ -60,6 +65,7 @@ class DesktopGridTabBarBuilderImpl extends DatabaseTabBarItemBuilder { view: view, databaseController: controller, initialRowId: initialRowId, + shrinkWrap: shrinkWrap, ); } @@ -103,12 +109,14 @@ class GridPage extends StatefulWidget { 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(); @@ -117,13 +125,30 @@ class GridPage extends StatefulWidget { 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: (context) => GridBloc( - view: widget.view, - databaseController: widget.databaseController, - )..add(const GridEvent.initial()), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => gridBloc, + ), + BlocProvider( + create: (context) => ViewLockStatusBloc(view: widget.view) + ..add(ViewLockStatusEvent.initial()), + ), + ], child: BlocListener( listener: (context, state) { final action = state.action; @@ -138,46 +163,32 @@ class _GridPageState extends State { } }, child: BlocConsumer( - listener: (context, state) => state.loadingState.whenOrNull( - // If initial row id is defined, open row details overlay - finish: (_) { - if (widget.initialRowId != null && !_didOpenInitialRow) { - _didOpenInitialRow = true; - - _openRow(context, widget.initialRowId!); - } - - return; - }, - ), + listener: listener, builder: (context, state) => state.loadingState.map( - loading: (_) => - const Center(child: CircularProgressIndicator.adaptive()), + 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) => FlowyErrorPage.message( - err.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), - ), + (err) => Center(child: AppFlowyErrorPage(error: err)), ), - idle: (_) => const SizedBox.shrink(), ), ), ), ); } - void _openRow( - BuildContext context, - String rowId, - ) { + void _openRow(BuildContext context, String rowId) { WidgetsBinding.instance.addPostFrameCallback((_) { final gridBloc = context.read(); - final rowCache = gridBloc.getRowCache(rowId); + final rowCache = gridBloc.rowCache; final rowMeta = rowCache.getRow(rowId)?.rowMeta; if (rowMeta == null) { return; @@ -192,24 +203,69 @@ class _GridPageState extends State { FlowyOverlay.show( context: context, builder: (_) => BlocProvider.value( - value: context.read(), + 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(); @@ -237,13 +293,17 @@ class _GridPageContentState extends State { Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ _GridHeader( headerScrollController: headerScrollController, + editable: !context.read().state.isLocked, + shrinkWrap: widget.shrinkWrap, ), _GridRows( viewId: widget.view.id, scrollController: _scrollController, + shrinkWrap: widget.shrinkWrap, ), ], ); @@ -251,20 +311,33 @@ class _GridPageContentState extends State { } class _GridHeader extends StatelessWidget { - const _GridHeader({required this.headerScrollController}); + const _GridHeader({ + required this.headerScrollController, + required this.editable, + required this.shrinkWrap, + }); final ScrollController headerScrollController; + final bool editable; + final bool shrinkWrap; @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return GridHeaderSliverAdaptor( - viewId: state.viewId, - anchorScrollController: headerScrollController, - ); - }, + Widget child = BlocBuilder( + builder: (_, state) => GridHeaderSliverAdaptor( + viewId: state.viewId, + anchorScrollController: headerScrollController, + shrinkWrap: shrinkWrap, + ), ); + + if (!editable) { + child = IgnorePointer( + child: child, + ); + } + + return child; } } @@ -272,133 +345,244 @@ 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(); - _evaluateFloatingCalculations(); + 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((_) { - setState(() { - // maxScrollExtent is 0.0 if scrolling is not possible - showFloatingCalculations = widget - .scrollController.verticalController.position.maxScrollExtent > - 0; - }); + 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) { - return BlocBuilder( - buildWhen: (previous, current) => previous.fields != current.fields, - builder: (context, state) { - return Flexible( - child: _WrapScrollView( - scrollController: widget.scrollController, - contentWidth: GridLayout.headerWidth(state.fields), - child: BlocConsumer( - listenWhen: (previous, current) => - previous.rowCount != current.rowCount, - listener: (context, state) => _evaluateFloatingCalculations(), - builder: (context, state) { - return ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - scrollbars: false, - ), - child: _renderList(context, state), - ); - }, + 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 * + 3, + context.read().state.fields, + ), ), + child: _shrinkWrapRenderList(context), ), - ); - }, - ); - } - - Widget _renderList( - BuildContext context, - GridState state, - ) { - final children = state.rowInfos.mapIndexed((index, rowInfo) { - return _renderRow( - context, - rowInfo.rowId, - isDraggable: state.reorderable, - index: index, - ); - }).toList() - ..add(const GridRowBottomBar(key: Key('grid_footer'))); - - if (showFloatingCalculations) { - children.add( - const SizedBox( - key: Key('calculations_bottom_padding'), - height: 36, ), ); } else { - children.add( - GridCalculationsRow( - key: const Key('grid_calculations'), - viewId: widget.viewId, + 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), + ), ), ); } - children.add(const SizedBox(key: Key('footer_padding'), height: 10)); + if (widget.shrinkWrap) { + return child; + } - return Stack( + return Flexible(child: child); + } + + Widget _shrinkWrapRenderList(BuildContext context) { + final state = context.read().state; + final horizontalPadding = + context.read()?.horizontalPadding ?? + 0.0; + return ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), children: [ - Positioned.fill( - child: ReorderableListView.builder( - /// This is a workaround related to - /// https://github.com/flutter/flutter/issues/25652 - cacheExtent: 5000, - scrollController: widget.scrollController.verticalController, - physics: const ClampingScrollPhysics(), - buildDefaultDragHandles: false, - proxyDecorator: (child, index, animation) => Material( - color: Colors.white.withOpacity(.1), - child: Opacity(opacity: .5, child: child), - ), - onReorder: (fromIndex, newIndex) { - final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; - if (fromIndex != toIndex) { - context - .read() - .add(GridEvent.moveRow(fromIndex, toIndex)); - } - }, - itemCount: children.length, - itemBuilder: (context, index) => children[index], + widget.shrinkWrap + ? _reorderableListView(state) + : Expanded(child: _reorderableListView(state)), + if (showFloatingCalculations && !widget.shrinkWrap) ...[ + _PositionedCalculationsRow( + viewId: widget.viewId, + isAtBottom: isAtBottom, ), - ), - if (showFloatingCalculations) ...[ - _PositionedCalculationsRow(viewId: widget.viewId), ], ], ); } + 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.withValues(alpha: .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) { + final child = Column( + key: const Key('grid_footer'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: footer, + ); + + if (context.read().state.isLocked) { + return IgnorePointer( + key: const Key('grid_footer'), + child: child, + ); + } + + return child; + } + + return _renderRow( + context, + state.rowInfos[index].rowId, + index: index, + ); + }, + ); + } + Widget _renderRow( BuildContext context, RowId rowId, { - int? index, - required bool isDraggable, + required int index, Animation? animation, }) { final databaseController = context.read().databaseController; @@ -410,33 +594,43 @@ class _GridRowsState extends State<_GridRows> { Log.warn('RowMeta is null for rowId: $rowId'); return const SizedBox.shrink(); } - final rowController = RowController( - viewId: viewId, - rowMeta: rowMeta, - rowCache: rowCache, - ); final child = GridRow( - key: ValueKey(rowMeta.id), + key: ValueKey("grid_row_$rowId"), + shrinkWrap: widget.shrinkWrap, fieldController: databaseController.fieldController, rowId: rowId, viewId: viewId, index: index, - isDraggable: isDraggable, - rowController: rowController, + editable: !context.watch().state.isLocked, + rowController: RowController( + viewId: viewId, + rowMeta: rowMeta, + rowCache: rowCache, + ), cellBuilder: EditableCellBuilder(databaseController: databaseController), - openDetailPage: (rowDetailContext) { - FlowyOverlay.show( - context: rowDetailContext, - builder: (_) => BlocProvider.value( - value: context.read(), + 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, + rowController: RowController( + viewId: viewId, + rowMeta: rowMeta, + rowCache: rowCache, + ), databaseController: databaseController, + userProfile: context.read().userProfile, ), - ), - ); - }, + ); + }, + ), ); if (animation != null) { @@ -445,6 +639,12 @@ class _GridRowsState extends State<_GridRows> { return child; } + + void moveRow(int from, int to) { + if (from != to) { + context.read().add(GridEvent.moveRow(from, to)); + } + } } class _WrapScrollView extends StatelessWidget { @@ -486,10 +686,16 @@ class _WrapScrollView extends StatelessWidget { 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(); @@ -499,27 +705,28 @@ class _PositionedCalculationsRowState extends State<_PositionedCalculationsRow> { @override Widget build(BuildContext context) { - return Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - margin: EdgeInsets.only(left: GridSize.horizontalHeaderPadding + 40), - padding: const EdgeInsets.only(bottom: 10), - decoration: BoxDecoration( - color: Theme.of(context).canvasColor, - border: Border( - top: BorderSide(color: Theme.of(context).dividerColor), - ), - ), - child: SizedBox( - height: 36, - width: double.infinity, - child: GridCalculationsRow( - key: const Key('floating_grid_calculations'), - viewId: widget.viewId, - includeDefaultInsets: false, - ), + 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/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart index 3c5c9a912c..c7402a17f9 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart @@ -1,9 +1,10 @@ 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(List fields) { + static double headerWidth(double padding, List fields) { if (fields.isEmpty) return 0; final fieldsWidth = fields @@ -15,9 +16,6 @@ class GridLayout { .map((fieldInfo) => fieldInfo.width!.toDouble()) .reduce((value, element) => value + element); - return fieldsWidth + - GridSize.horizontalHeaderPadding + - 40 + - GridSize.trailHeaderPadding; + 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 index 5e96e35f1f..78a8c97dae 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart @@ -1,40 +1,50 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; 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 => 40 * scale; - static double get footerHeight => 40 * scale; + + static double get headerHeight => 36 * scale; + + static double get buttonHeight => 38 * scale; + + static double get footerHeight => 36 * scale; + static double get horizontalHeaderPadding => - PlatformExtension.isDesktop ? 40 * scale : 16 * scale; - static double get trailHeaderPadding => 140 * scale; + UniversalPlatform.isDesktop ? 40 * scale : 16 * scale; + static double get cellHPadding => 10 * scale; - static double get cellVPadding => 10 * scale; + + static double get cellVPadding => 8 * 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 compactCellContentInsets => + cellContentInsets - EdgeInsets.symmetric(vertical: 2); static EdgeInsets get typeOptionContentInsets => const EdgeInsets.all(4); static EdgeInsets get toolbarSettingButtonInsets => - const EdgeInsets.symmetric(horizontal: 8, vertical: 2); + const EdgeInsets.symmetric(horizontal: 6, vertical: 2); static EdgeInsets get footerContentInsets => EdgeInsets.fromLTRB( GridSize.horizontalHeaderPadding, 0, - PlatformExtension.isMobile ? GridSize.horizontalHeaderPadding : 0, - PlatformExtension.isMobile ? 100 : 0, + UniversalPlatform.isMobile ? GridSize.horizontalHeaderPadding : 0, + UniversalPlatform.isMobile ? 100 : 0, ); static EdgeInsets get contentInsets => EdgeInsets.symmetric( 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 index 80952f30f2..17e4c0ed1d 100644 --- 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 @@ -1,5 +1,3 @@ -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/mobile_card_detail_screen.dart'; @@ -7,10 +5,11 @@ 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/workspace/application/view/view_lock_status_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'; @@ -18,7 +17,7 @@ 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/widget/error_page.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'; @@ -43,6 +42,7 @@ class MobileGridTabBarBuilderImpl extends DatabaseTabBarItemBuilder { view: view, databaseController: controller, initialRowId: initialRowId, + shrinkWrap: shrinkWrap, ); } @@ -69,12 +69,14 @@ class MobileGridPage extends StatefulWidget { 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() => _MobileGridPageState(); @@ -105,10 +107,14 @@ class _MobileGridPageState extends State { finish: (result) { _openRow(context, widget.initialRowId, true); return result.successOrFail.fold( - (_) => GridShortcuts(child: GridPageContent(view: widget.view)), - (err) => FlowyErrorPage.message( - err.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + (_) => GridPageContent( + view: widget.view, + shrinkWrap: widget.shrinkWrap, + ), + (err) => Center( + child: AppFlowyErrorPage( + error: err, + ), ), ); }, @@ -145,9 +151,11 @@ class GridPageContent extends StatefulWidget { const GridPageContent({ super.key, required this.view, + this.shrinkWrap = false, }); final ViewPB view; + final bool shrinkWrap; @override State createState() => _GridPageContentState(); @@ -175,6 +183,8 @@ class _GridPageContentState extends State { @override Widget build(BuildContext context) { + final isLocked = + context.read()?.state.isLocked ?? false; return BlocListener( listenWhen: (previous, current) => previous.createdRow != current.createdRow, @@ -196,6 +206,7 @@ class _GridPageContentState extends State { children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ _GridHeader( contentScrollController: contentScrollController, @@ -207,11 +218,12 @@ class _GridPageContentState extends State { ), ], ), - Positioned( - bottom: 16, - right: 16, - child: getGridFabs(context), - ), + if (!widget.shrinkWrap && !isLocked) + Positioned( + bottom: 16, + right: 16, + child: getGridFabs(context), + ), ], ), ); @@ -256,7 +268,7 @@ class _GridRows extends StatelessWidget { buildWhen: (previous, current) => previous.fields != current.fields, builder: (context, state) { final double contentWidth = getMobileGridContentWidth(state.fields); - return Expanded( + return Flexible( child: _WrapScrollView( scrollController: scrollController, contentWidth: contentWidth, @@ -305,6 +317,7 @@ class _GridRows extends StatelessWidget { return ReorderableListView.builder( scrollController: scrollController.verticalController, buildDefaultDragHandles: false, + shrinkWrap: true, proxyDecorator: (child, index, animation) => Material( color: Colors.transparent, child: child, @@ -346,7 +359,7 @@ class _GridRows extends StatelessWidget { final databaseController = context.read().databaseController; - final child = MobileGridRow( + Widget child = MobileGridRow( key: ValueKey(rowMeta.id), rowId: rowId, isDraggable: isDraggable, @@ -363,12 +376,20 @@ class _GridRows extends StatelessWidget { ); if (animation != null) { - return SizeTransition( + child = SizeTransition( sizeFactor: animation, child: child, ); } + final isLocked = + context.read()?.state.isLocked ?? false; + if (isLocked) { + child = IgnorePointer( + child: child, + ); + } + return child; } } 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 index c8199ce135..5ea364bc72 100644 --- 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 @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - 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'; @@ -12,10 +10,9 @@ 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:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CalculateCell extends StatefulWidget { @@ -141,11 +138,14 @@ class _CalculateCellState extends State { TextSpan( text: widget.calculation!.calculationType.shortLabel .toUpperCase(), + style: context.tooltipTextStyle(), ), const TextSpan(text: ' '), TextSpan( text: calculateValue, - style: const TextStyle(fontWeight: FontWeight.w500), + style: context + .tooltipTextStyle() + ?.copyWith(fontWeight: FontWeight.w500), ), ], ), @@ -166,6 +166,7 @@ class _CalculateCellState extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ FlowyText( + lineHeight: 1.0, widget.calculation!.calculationType.shortLabel .toUpperCase(), color: Theme.of(context).hintColor, @@ -174,6 +175,7 @@ class _CalculateCellState extends State { if (widget.calculation!.value.isNotEmpty) ...[ const HSpace(8), FlowyText( + lineHeight: 1.0, calculateValue, color: AFThemeExtension.of(context).textColor, overflow: TextOverflow.ellipsis, 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 index eb1a76fe18..b1b696d790 100644 --- 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 @@ -22,7 +22,11 @@ class CalculationTypeItem extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(type.label, overflow: TextOverflow.ellipsis), + 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 index 7899d5f56d..5524633a46 100644 --- 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 @@ -1,9 +1,8 @@ -import 'package:flutter/material.dart'; - 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/layout/sizes.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 { @@ -27,9 +26,12 @@ class GridCalculationsRow extends StatelessWidget { )..add(const CalculationsEvent.started()), child: BlocBuilder( builder: (context, state) { + final padding = + context.read().horizontalPadding; return Padding( - padding: - includeDefaultInsets ? GridSize.contentInsets : EdgeInsets.zero, + padding: includeDefaultInsets + ? EdgeInsets.symmetric(horizontal: padding) + : EdgeInsets.zero, child: Row( children: [ ...state.fields.map( 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 index 369d27133e..982ee992b6 100644 --- 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 @@ -20,7 +20,7 @@ class RemoveCalculationButton extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium( + text: FlowyText( LocaleKeys.grid_calculationTypeLabel_none.tr(), overflow: TextOverflow.ellipsis, ), 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 index 7341f6046d..bda8634cdb 100644 --- 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 @@ -1,79 +1,60 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/grid/application/filter/checkbox_filter_editor_bloc.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_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; - -import 'package:flowy_infra_ui/flowy_infra_ui.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 '../filter_info.dart'; + import 'choicechip.dart'; -class CheckboxFilterChoicechip extends StatefulWidget { - const CheckboxFilterChoicechip({required this.filterInfo, super.key}); +class CheckboxFilterChoicechip extends StatelessWidget { + const CheckboxFilterChoicechip({ + super.key, + required this.filterId, + }); - final FilterInfo filterInfo; - - @override - State createState() => - _CheckboxFilterChoicechipState(); -} - -class _CheckboxFilterChoicechipState extends State { - late CheckboxFilterEditorBloc bloc; - - @override - void initState() { - bloc = CheckboxFilterEditorBloc(filterInfo: widget.filterInfo) - ..add(const CheckboxFilterEditorEvent.initial()); - super.initState(); - } - - @override - void dispose() { - bloc.close(); - super.dispose(); - } + final String filterId; @override Widget build(BuildContext context) { - return BlocProvider.value( - value: bloc, - child: BlocBuilder( - builder: (blocContext, state) { - return AppFlowyPopover( - controller: PopoverController(), - constraints: BoxConstraints.loose(const Size(200, 76)), - direction: PopoverDirection.bottomWithCenterAligned, - popupBuilder: (BuildContext context) { - return CheckboxFilterEditor(bloc: bloc); - }, - child: ChoiceChipButton( - filterInfo: widget.filterInfo, - filterDesc: _makeFilterDesc(state), - ), + 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, ); }, ), ); } - - String _makeFilterDesc(CheckboxFilterEditorState state) { - final prefix = LocaleKeys.grid_checkboxFilter_choicechipPrefix_is.tr(); - return "$prefix ${state.filter.condition.filterName}"; - } } class CheckboxFilterEditor extends StatefulWidget { - const CheckboxFilterEditor({required this.bloc, super.key}); + const CheckboxFilterEditor({ + super.key, + required this.filterId, + }); - final CheckboxFilterEditorBloc bloc; + final String filterId; @override State createState() => _CheckboxFilterEditorState(); @@ -83,62 +64,57 @@ class _CheckboxFilterEditorState extends State { final popoverMutex = PopoverMutex(); @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: widget.bloc, - child: BlocBuilder( - builder: (context, state) { - final List children = [ - _buildFilterPanel(context, state), - ]; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - child: IntrinsicHeight(child: Column(children: children)), - ); - }, - ), - ); + void dispose() { + popoverMutex.dispose(); + super.dispose(); } - Widget _buildFilterPanel( - BuildContext context, - CheckboxFilterEditorState state, - ) { - return SizedBox( - height: 20, - child: Row( - children: [ - Expanded( - child: FlowyText( - state.filterInfo.fieldInfo.field.name, - overflow: TextOverflow.ellipsis, + @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; + } + }, + ), + ], ), ), - const HSpace(4), - CheckboxFilterConditionList( - filterInfo: state.filterInfo, - popoverMutex: popoverMutex, - onCondition: (condition) { - context - .read() - .add(CheckboxFilterEditorEvent.updateCondition(condition)); - }, - ), - DisclosureButton( - popoverMutex: popoverMutex, - onAction: (action) { - switch (action) { - case FilterDisclosureAction.delete: - context - .read() - .add(const CheckboxFilterEditorEvent.delete()); - break; - } - }, - ), - ], - ), + ); + }, ); } } @@ -146,18 +122,17 @@ class _CheckboxFilterEditorState extends State { class CheckboxFilterConditionList extends StatelessWidget { const CheckboxFilterConditionList({ super.key, - required this.filterInfo, + required this.filter, required this.popoverMutex, required this.onCondition, }); - final FilterInfo filterInfo; + final CheckboxFilter filter; final PopoverMutex popoverMutex; - final Function(CheckboxFilterConditionPB) onCondition; + final void Function(CheckboxFilterConditionPB) onCondition; @override Widget build(BuildContext context) { - final checkboxFilter = filterInfo.checkboxFilter()!; return PopoverActionList( asBarrier: true, mutex: popoverMutex, @@ -166,17 +141,17 @@ class CheckboxFilterConditionList extends StatelessWidget { .map( (action) => ConditionWrapper( action, - checkboxFilter.condition == action, + filter.condition == action, ), ) .toList(), buildChild: (controller) { return ConditionButton( - conditionName: checkboxFilter.condition.filterName, + conditionName: filter.conditionName, onTap: () => controller.show(), ); }, - onSelected: (action, controller) async { + onSelected: (action, controller) { onCondition(action.inner); controller.close(); }, 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 0000000000..9dd302ecf7 --- /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/checklist/checklist.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/checklist/checklist.dart deleted file mode 100644 index c16df32306..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/checklist/checklist.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/grid/application/filter/checklist_filter_bloc.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_ui/flowy_infra_ui.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pbenum.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../condition_button.dart'; -import '../../disclosure_button.dart'; -import '../../filter_info.dart'; -import '../choicechip.dart'; - -class ChecklistFilterChoicechip extends StatefulWidget { - const ChecklistFilterChoicechip({required this.filterInfo, super.key}); - - final FilterInfo filterInfo; - - @override - State createState() => - _ChecklistFilterChoicechipState(); -} - -class _ChecklistFilterChoicechipState extends State { - late ChecklistFilterEditorBloc bloc; - late PopoverMutex popoverMutex; - - @override - void initState() { - popoverMutex = PopoverMutex(); - bloc = ChecklistFilterEditorBloc(filterInfo: widget.filterInfo); - bloc.add(const ChecklistFilterEditorEvent.initial()); - super.initState(); - } - - @override - void dispose() { - bloc.close(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: bloc, - child: BlocBuilder( - builder: (blocContext, state) { - return AppFlowyPopover( - controller: PopoverController(), - constraints: BoxConstraints.loose(const Size(200, 160)), - direction: PopoverDirection.bottomWithCenterAligned, - popupBuilder: (BuildContext context) { - return ChecklistFilterEditor( - bloc: bloc, - popoverMutex: popoverMutex, - ); - }, - child: ChoiceChipButton( - filterInfo: widget.filterInfo, - filterDesc: state.filterDesc, - ), - ); - }, - ), - ); - } -} - -class ChecklistFilterEditor extends StatefulWidget { - const ChecklistFilterEditor({ - super.key, - required this.bloc, - required this.popoverMutex, - }); - - final ChecklistFilterEditorBloc bloc; - final PopoverMutex popoverMutex; - - @override - ChecklistState createState() => ChecklistState(); -} - -class ChecklistState extends State { - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: widget.bloc, - child: BlocBuilder( - builder: (context, state) { - return SizedBox( - height: 20, - child: Row( - children: [ - Expanded( - child: FlowyText( - state.filterInfo.fieldInfo.field.name, - overflow: TextOverflow.ellipsis, - ), - ), - const HSpace(4), - ChecklistFilterConditionList( - filterInfo: state.filterInfo, - ), - DisclosureButton( - popoverMutex: widget.popoverMutex, - onAction: (action) { - switch (action) { - case FilterDisclosureAction.delete: - context - .read() - .add(const ChecklistFilterEditorEvent.delete()); - break; - } - }, - ), - ], - ), - ); - }, - ), - ); - } -} - -class ChecklistFilterConditionList extends StatelessWidget { - const ChecklistFilterConditionList({ - super.key, - required this.filterInfo, - }); - - final FilterInfo filterInfo; - - @override - Widget build(BuildContext context) { - final checklistFilter = filterInfo.checklistFilter()!; - return PopoverActionList( - asBarrier: true, - direction: PopoverDirection.bottomWithCenterAligned, - actions: ChecklistFilterConditionPB.values - .map((action) => ConditionWrapper(action)) - .toList(), - buildChild: (controller) { - return ConditionButton( - conditionName: checklistFilter.condition.filterName, - onTap: () => controller.show(), - ); - }, - onSelected: (action, controller) async { - context - .read() - .add(ChecklistFilterEditorEvent.updateCondition(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 index 2e080e8e68..99804ad69b 100644 --- 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 @@ -1,54 +1,58 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/util/field_type_extension.dart'; -import 'package:flowy_infra/theme_extension.dart'; +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 'dart:math' as math; - -import '../filter_info.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class ChoiceChipButton extends StatelessWidget { const ChoiceChipButton({ super.key, - required this.filterInfo, + required this.fieldInfo, this.filterDesc = '', this.onTap, }); - final FilterInfo filterInfo; + final FieldInfo fieldInfo; final String filterDesc; final VoidCallback? onTap; @override Widget build(BuildContext context) { - final borderSide = BorderSide( - color: AFThemeExtension.of(context).toggleOffFill, - ); - - final decoration = BoxDecoration( - color: Colors.transparent, - border: Border.fromBorderSide(borderSide), - borderRadius: const BorderRadius.all(Radius.circular(14)), - ); + final buttonText = + filterDesc.isEmpty ? fieldInfo.name : "${fieldInfo.name}: $filterDesc"; return SizedBox( height: 28, child: FlowyButton( - decoration: decoration, + 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( - filterInfo.fieldInfo.field.name, + 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: FlowySvg( - filterInfo.fieldInfo.fieldType.svgData, - color: Theme.of(context).iconTheme.color, + leftIcon: FieldIcon( + fieldInfo: fieldInfo, ), - rightIcon: _ChoicechipFilterDesc(filterDesc: filterDesc), + rightIcon: const _ChoicechipDownArrow(), hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: onTap, ), @@ -56,28 +60,54 @@ class ChoiceChipButton extends StatelessWidget { } } -class _ChoicechipFilterDesc extends StatelessWidget { - const _ChoicechipFilterDesc({this.filterDesc = ''}); - - final String filterDesc; +class _ChoicechipDownArrow extends StatelessWidget { + const _ChoicechipDownArrow(); @override Widget build(BuildContext context) { - final arrow = Transform.rotate( + return Transform.rotate( angle: -math.pi / 2, child: FlowySvg( FlowySvgs.arrow_left_s, color: AFThemeExtension.of(context).textColor, ), ); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Row( - children: [ - if (filterDesc.isNotEmpty) FlowyText(': $filterDesc'), - arrow, - ], - ), + } +} + +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 index 3c97aaddb2..a2d61cc5f4 100644 --- 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 @@ -1,15 +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 '../filter_info.dart'; import 'choicechip.dart'; class DateFilterChoicechip extends StatelessWidget { - const DateFilterChoicechip({required this.filterInfo, super.key}); + const DateFilterChoicechip({ + super.key, + required this.filterId, + }); - final FilterInfo filterInfo; + final String filterId; @override Widget build(BuildContext context) { - return ChoiceChipButton(filterInfo: filterInfo); + 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 index 0947239273..cc38b4eaba 100644 --- 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 @@ -1,9 +1,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/grid/application/filter/number_filter_editor_bloc.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:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -11,42 +12,34 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../condition_button.dart'; import '../disclosure_button.dart'; -import '../filter_info.dart'; + import 'choicechip.dart'; -class NumberFilterChoiceChip extends StatefulWidget { +class NumberFilterChoiceChip extends StatelessWidget { const NumberFilterChoiceChip({ super.key, - required this.filterInfo, + required this.filterId, }); - final FilterInfo filterInfo; + final String filterId; - @override - State createState() => _NumberFilterChoiceChipState(); -} - -class _NumberFilterChoiceChipState extends State { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => NumberFilterEditorBloc( - filterInfo: widget.filterInfo, - ), - child: BlocBuilder( - builder: (context, state) { - return AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(200, 100)), - direction: PopoverDirection.bottomWithCenterAligned, - popupBuilder: (_) { - return BlocProvider.value( - value: context.read(), - child: const NumberFilterEditor(), - ); - }, - child: ChoiceChipButton( - filterInfo: state.filterInfo, - ), + 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), ); }, ), @@ -55,7 +48,12 @@ class _NumberFilterChoiceChipState extends State { } class NumberFilterEditor extends StatefulWidget { - const NumberFilterEditor({super.key}); + const NumberFilterEditor({ + super.key, + required this.filterId, + }); + + final String filterId; @override State createState() => _NumberFilterEditorState(); @@ -64,17 +62,23 @@ class NumberFilterEditor extends StatefulWidget { class _NumberFilterEditorState extends State { final popoverMutex = PopoverMutex(); + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { + return SingleFilterBlocSelector( + filterId: widget.filterId, + builder: (context, filter, field) { final List children = [ - _buildFilterPanel(context, state), - if (state.filter.condition != NumberFilterConditionPB.NumberIsEmpty && - state.filter.condition != - NumberFilterConditionPB.NumberIsNotEmpty) ...[ + _buildFilterPanel(filter, field), + if (filter.condition != NumberFilterConditionPB.NumberIsEmpty && + filter.condition != NumberFilterConditionPB.NumberIsNotEmpty) ...[ const VSpace(4), - _buildFilterNumberField(context, state), + _buildFilterNumberField(filter), ], ]; @@ -87,8 +91,8 @@ class _NumberFilterEditorState extends State { } Widget _buildFilterPanel( - BuildContext context, - NumberFilterEditorState state, + NumberFilter filter, + FieldInfo field, ) { return SizedBox( height: 20, @@ -96,19 +100,20 @@ class _NumberFilterEditorState extends State { children: [ Expanded( child: FlowyText( - state.filterInfo.fieldInfo.name, + field.name, overflow: TextOverflow.ellipsis, ), ), const HSpace(4), Expanded( - child: NumberFilterConditionPBList( - filterInfo: state.filterInfo, + child: NumberFilterConditionList( + filter: filter, popoverMutex: popoverMutex, onCondition: (condition) { + final newFilter = filter.copyWith(condition: condition); context - .read() - .add(NumberFilterEditorEvent.updateCondition(condition)); + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); }, ), ), @@ -118,9 +123,11 @@ class _NumberFilterEditorState extends State { onAction: (action) { switch (action) { case FilterDisclosureAction.delete: - context - .read() - .add(const NumberFilterEditorEvent.delete()); + context.read().add( + FilterEditorEvent.deleteFilter( + filter.filterId, + ), + ); break; } }, @@ -131,38 +138,37 @@ class _NumberFilterEditorState extends State { } Widget _buildFilterNumberField( - BuildContext context, - NumberFilterEditorState state, + NumberFilter filter, ) { return FlowyTextField( - text: state.filter.content, + 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(NumberFilterEditorEvent.updateContent(text)); + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); }, ); } } -class NumberFilterConditionPBList extends StatelessWidget { - const NumberFilterConditionPBList({ +class NumberFilterConditionList extends StatelessWidget { + const NumberFilterConditionList({ super.key, - required this.filterInfo, + required this.filter, required this.popoverMutex, required this.onCondition, }); - final FilterInfo filterInfo; + final NumberFilter filter; final PopoverMutex popoverMutex; - final Function(NumberFilterConditionPB) onCondition; + final void Function(NumberFilterConditionPB) onCondition; @override Widget build(BuildContext context) { - final numberFilter = filterInfo.numberFilter()!; return PopoverActionList( asBarrier: true, mutex: popoverMutex, @@ -171,13 +177,13 @@ class NumberFilterConditionPBList extends StatelessWidget { .map( (action) => ConditionWrapper( action, - numberFilter.condition == action, + filter.condition == action, ), ) .toList(), buildChild: (controller) { return ConditionButton( - conditionName: numberFilter.condition.filterName, + conditionName: filter.condition.filterName, onTap: () => controller.show(), ); }, @@ -204,6 +210,22 @@ class ConditionWrapper extends ActionCell { } 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(), 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 index d33dba6293..a8c8f69016 100644 --- 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 @@ -1,7 +1,8 @@ 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/plugins/database/grid/presentation/widgets/filter/filter_info.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'; @@ -11,33 +12,37 @@ import 'package:flutter/widgets.dart'; class SelectOptionFilterConditionList extends StatelessWidget { const SelectOptionFilterConditionList({ super.key, - required this.filterInfo, + required this.filter, + required this.fieldType, required this.popoverMutex, required this.onCondition, }); - final FilterInfo filterInfo; + final SelectOptionFilter filter; + final FieldType fieldType; final PopoverMutex popoverMutex; - final Function(SelectOptionFilterConditionPB) onCondition; + final void Function(SelectOptionFilterConditionPB) onCondition; @override Widget build(BuildContext context) { - final selectOptionFilter = filterInfo.selectOptionFilter()!; + final conditions = (fieldType == FieldType.SingleSelect + ? SingleSelectOptionFilterCondition().conditions + : MultiSelectOptionFilterCondition().conditions); return PopoverActionList( asBarrier: true, mutex: popoverMutex, direction: PopoverDirection.bottomWithCenterAligned, - actions: _conditionsForFieldType(filterInfo.fieldInfo.fieldType) + actions: conditions .map( (action) => ConditionWrapper( - action, - selectOptionFilter.condition == action, + action.$1, + filter.condition == action.$1, ), ) .toList(), buildChild: (controller) { return ConditionButton( - conditionName: selectOptionFilter.condition.i18n, + conditionName: filter.condition.i18n, onTap: () => controller.show(), ); }, @@ -47,29 +52,6 @@ class SelectOptionFilterConditionList extends StatelessWidget { }, ); } - - List _conditionsForFieldType( - FieldType fieldType, - ) { - // SelectOptionFilterConditionPB.values is not in order - return switch (fieldType) { - FieldType.SingleSelect => [ - SelectOptionFilterConditionPB.OptionIs, - SelectOptionFilterConditionPB.OptionIsNot, - SelectOptionFilterConditionPB.OptionIsEmpty, - SelectOptionFilterConditionPB.OptionIsNotEmpty, - ], - FieldType.MultiSelect => [ - SelectOptionFilterConditionPB.OptionContains, - SelectOptionFilterConditionPB.OptionDoesNotContain, - SelectOptionFilterConditionPB.OptionIs, - SelectOptionFilterConditionPB.OptionIsNot, - SelectOptionFilterConditionPB.OptionIsEmpty, - SelectOptionFilterConditionPB.OptionIsNotEmpty, - ], - _ => [], - }; - } } class ConditionWrapper extends ActionCell { 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 index b3c1482453..3e9db2df1b 100644 --- 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 @@ -1,115 +1,111 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/database/grid/application/filter/select_option_filter_bloc.dart'; -import 'package:appflowy/plugins/database/grid/application/filter/select_option_filter_list_bloc.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/grid/presentation/widgets/filter/filter_info.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.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: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'; -import 'select_option_loader.dart'; - class SelectOptionFilterList extends StatelessWidget { const SelectOptionFilterList({ super.key, - required this.filterInfo, - required this.selectedOptionIds, - required this.onSelectedOptions, + required this.filter, + required this.field, + required this.options, + required this.onTap, }); - final FilterInfo filterInfo; - final List selectedOptionIds; - final Function(List) onSelectedOptions; + final SelectOptionFilter filter; + final FieldInfo field; + final List options; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) { - return SelectOptionFilterListBloc( - selectedOptionIds: selectedOptionIds, - delegate: filterInfo.fieldInfo.fieldType == FieldType.SingleSelect - ? SingleSelectOptionFilterDelegateImpl(filterInfo: filterInfo) - : MultiSelectOptionFilterDelegateImpl(filterInfo: filterInfo), - )..add(const SelectOptionFilterListEvent.initial()); + 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), + ); }, - child: - BlocConsumer( - listenWhen: (previous, current) => - previous.selectedOptionIds != current.selectedOptionIds, - listener: (context, state) { - onSelectedOptions(state.selectedOptionIds.toList()); - }, - builder: (context, state) { - return ListView.separated( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: state.visibleOptions.length, - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - itemBuilder: (BuildContext context, int index) { - final option = state.visibleOptions[index]; - return SelectOptionFilterCell( - option: option.optionPB, - isSelected: 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 StatefulWidget { +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 - State createState() => _SelectOptionFilterCellState(); -} - -class _SelectOptionFilterCellState extends State { @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, - child: SelectOptionTagCell( - option: widget.option, - onSelected: () { - if (widget.isSelected) { - context - .read() - .add(SelectOptionFilterListEvent.unSelectOption(widget.option)); - } else { - context.read().add( - SelectOptionFilterListEvent.selectOption( - widget.option, - context - .read() - .state - .filter - .condition, - ), - ); - } - }, - children: [ - if (widget.isSelected) - const Padding( - padding: EdgeInsets.only(right: 6), - child: FlowySvg(FlowySvgs.check_s), - ), - ], + 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 index 24d1955a3f..50984bbf3d 100644 --- 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 @@ -1,75 +1,41 @@ -import 'package:appflowy/plugins/database/grid/application/filter/select_option_filter_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_filter.pb.dart'; -import 'package:appflowy_popover/appflowy_popover.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:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../disclosure_button.dart'; -import '../../filter_info.dart'; import '../choicechip.dart'; + import 'condition_list.dart'; import 'option_list.dart'; -import 'select_option_loader.dart'; -class SelectOptionFilterChoicechip extends StatefulWidget { - const SelectOptionFilterChoicechip({required this.filterInfo, super.key}); +class SelectOptionFilterChoicechip extends StatelessWidget { + const SelectOptionFilterChoicechip({ + super.key, + required this.filterId, + }); - final FilterInfo filterInfo; - - @override - State createState() => - _SelectOptionFilterChoicechipState(); -} - -class _SelectOptionFilterChoicechipState - extends State { - late SelectOptionFilterEditorBloc bloc; - - @override - void initState() { - if (widget.filterInfo.fieldInfo.fieldType == FieldType.SingleSelect) { - bloc = SelectOptionFilterEditorBloc( - filterInfo: widget.filterInfo, - delegate: - SingleSelectOptionFilterDelegateImpl(filterInfo: widget.filterInfo), - ); - } else { - bloc = SelectOptionFilterEditorBloc( - filterInfo: widget.filterInfo, - delegate: - MultiSelectOptionFilterDelegateImpl(filterInfo: widget.filterInfo), - ); - } - bloc.add(const SelectOptionFilterEditorEvent.initial()); - super.initState(); - } - - @override - void dispose() { - bloc.close(); - super.dispose(); - } + final String filterId; @override Widget build(BuildContext context) { - return BlocProvider.value( - value: bloc, - child: BlocBuilder( - builder: (blocContext, state) { - return AppFlowyPopover( - controller: PopoverController(), - constraints: BoxConstraints.loose(const Size(240, 160)), - direction: PopoverDirection.bottomWithCenterAligned, - popupBuilder: (BuildContext context) { - return SelectOptionFilterEditor(bloc: bloc); - }, - child: ChoiceChipButton( - filterInfo: widget.filterInfo, - filterDesc: state.filterDesc, - ), + 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), ); }, ), @@ -78,9 +44,12 @@ class _SelectOptionFilterChoicechipState } class SelectOptionFilterEditor extends StatefulWidget { - const SelectOptionFilterEditor({required this.bloc, super.key}); + const SelectOptionFilterEditor({ + super.key, + required this.filterId, + }); - final SelectOptionFilterEditorBloc bloc; + final String filterId; @override State createState() => @@ -91,54 +60,50 @@ class _SelectOptionFilterEditorState extends State { final popoverMutex = PopoverMutex(); @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: widget.bloc, - child: BlocBuilder( - builder: (context, state) { - final List slivers = [ - SliverToBoxAdapter(child: _buildFilterPanel(context, state)), - ]; + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } - if (state.filter.condition != - SelectOptionFilterConditionPB.OptionIsEmpty && - state.filter.condition != - SelectOptionFilterConditionPB.OptionIsNotEmpty) { - slivers.add(const SliverToBoxAdapter(child: VSpace(4))); - slivers.add( + @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( - filterInfo: state.filterInfo, - selectedOptionIds: state.filter.optionIds, - onSelectedOptions: (optionIds) { - context.read().add( - SelectOptionFilterEditorEvent.updateContent( - optionIds, - ), - ); - }, + 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(), - ), - ); - }, - ), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: CustomScrollView( + shrinkWrap: true, + slivers: slivers, + physics: StyledScrollPhysics(), + ), + ); + }, ); } Widget _buildFilterPanel( - BuildContext context, - SelectOptionFilterEditorState state, + SelectOptionFilter filter, + FieldInfo field, ) { return SizedBox( height: 20, @@ -146,18 +111,20 @@ class _SelectOptionFilterEditorState extends State { children: [ Expanded( child: FlowyText( - state.filterInfo.fieldInfo.field.name, + field.field.name, overflow: TextOverflow.ellipsis, ), ), const HSpace(4), SelectOptionFilterConditionList( - filterInfo: state.filterInfo, + filter: filter, + fieldType: field.fieldType, popoverMutex: popoverMutex, onCondition: (condition) { - context.read().add( - SelectOptionFilterEditorEvent.updateCondition(condition), - ); + final newFilter = filter.copyWith(condition: condition); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); }, ), DisclosureButton( @@ -166,8 +133,8 @@ class _SelectOptionFilterEditorState extends State { switch (action) { case FilterDisclosureAction.delete: context - .read() - .add(const SelectOptionFilterEditorEvent.delete()); + .read() + .add(FilterEditorEvent.deleteFilter(filter.filterId)); break; } }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option_loader.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option_loader.dart deleted file mode 100644 index 7a7aa25cad..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option_loader.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; - -abstract class SelectOptionFilterDelegate { - List loadOptions(); - - Set selectOption( - Set currentOptionIds, - String optionId, - SelectOptionFilterConditionPB condition, - ); -} - -class SingleSelectOptionFilterDelegateImpl - implements SelectOptionFilterDelegate { - SingleSelectOptionFilterDelegateImpl({required this.filterInfo}); - - final FilterInfo filterInfo; - - @override - List loadOptions() { - final parser = SingleSelectTypeOptionDataParser(); - return parser.fromBuffer(filterInfo.fieldInfo.field.typeOptionData).options; - } - - @override - Set selectOption( - Set currentOptionIds, - String optionId, - SelectOptionFilterConditionPB condition, - ) { - final selectOptionIds = Set.from(currentOptionIds); - - switch (condition) { - case SelectOptionFilterConditionPB.OptionIs: - if (selectOptionIds.isNotEmpty) { - selectOptionIds.clear(); - } - selectOptionIds.add(optionId); - break; - case SelectOptionFilterConditionPB.OptionIsNot: - selectOptionIds.add(optionId); - break; - case SelectOptionFilterConditionPB.OptionIsEmpty || - SelectOptionFilterConditionPB.OptionIsNotEmpty: - selectOptionIds.clear(); - break; - default: - throw UnimplementedError(); - } - - return selectOptionIds; - } -} - -class MultiSelectOptionFilterDelegateImpl - implements SelectOptionFilterDelegate { - MultiSelectOptionFilterDelegateImpl({required this.filterInfo}); - - final FilterInfo filterInfo; - - @override - List loadOptions() { - final parser = MultiSelectTypeOptionDataParser(); - return parser.fromBuffer(filterInfo.fieldInfo.field.typeOptionData).options; - } - - @override - Set selectOption( - Set currentOptionIds, - String optionId, - SelectOptionFilterConditionPB condition, - ) => - Set.from(currentOptionIds)..add(optionId); -} 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 index 66f17e0971..548f21efbe 100644 --- 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 @@ -1,9 +1,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/grid/application/filter/text_filter_editor_bloc.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:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -11,59 +12,48 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../condition_button.dart'; import '../disclosure_button.dart'; -import '../filter_info.dart'; + import 'choicechip.dart'; class TextFilterChoicechip extends StatelessWidget { - const TextFilterChoicechip({required this.filterInfo, super.key}); + const TextFilterChoicechip({ + super.key, + required this.filterId, + }); - final FilterInfo filterInfo; + final String filterId; @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => TextFilterEditorBloc( - filterInfo: filterInfo, - fieldType: FieldType.RichText, - )..add(const TextFilterEditorEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - return AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(200, 76)), - direction: PopoverDirection.bottomWithCenterAligned, - popupBuilder: (popoverContext) { - return BlocProvider.value( - value: context.read(), - child: const TextFilterEditor(), - ); - }, - child: ChoiceChipButton( - filterInfo: filterInfo, - filterDesc: _makeFilterDesc(state), - ), + 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), ); }, ), ); } - - String _makeFilterDesc(TextFilterEditorState state) { - String filterDesc = state.filter.condition.choicechipPrefix; - if (state.filter.condition == TextFilterConditionPB.TextIsEmpty || - state.filter.condition == TextFilterConditionPB.TextIsNotEmpty) { - return filterDesc; - } - - if (state.filter.content.isNotEmpty) { - filterDesc += " ${state.filter.content}"; - } - - return filterDesc; - } } class TextFilterEditor extends StatefulWidget { - const TextFilterEditor({super.key}); + const TextFilterEditor({ + super.key, + required this.filterId, + }); + + final String filterId; @override State createState() => _TextFilterEditorState(); @@ -72,18 +62,25 @@ class TextFilterEditor extends StatefulWidget { class _TextFilterEditorState extends State { final popoverMutex = PopoverMutex(); + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { + return SingleFilterBlocSelector( + filterId: widget.filterId, + builder: (context, filter, field) { final List children = [ - _buildFilterPanel(context, state), + _buildFilterPanel(filter, field), ]; - if (state.filter.condition != TextFilterConditionPB.TextIsEmpty && - state.filter.condition != TextFilterConditionPB.TextIsNotEmpty) { + if (filter.condition != TextFilterConditionPB.TextIsEmpty && + filter.condition != TextFilterConditionPB.TextIsNotEmpty) { children.add(const VSpace(4)); - children.add(_buildFilterTextField(context, state)); + children.add(_buildFilterTextField(filter, field)); } return Padding( @@ -94,26 +91,27 @@ class _TextFilterEditorState extends State { ); } - Widget _buildFilterPanel(BuildContext context, TextFilterEditorState state) { + Widget _buildFilterPanel(TextFilter filter, FieldInfo field) { return SizedBox( height: 20, child: Row( children: [ Expanded( child: FlowyText( - state.filterInfo.fieldInfo.name, + field.name, overflow: TextOverflow.ellipsis, ), ), const HSpace(4), Expanded( - child: TextFilterConditionPBList( - filterInfo: state.filterInfo, + child: TextFilterConditionList( + filter: filter, popoverMutex: popoverMutex, onCondition: (condition) { + final newFilter = filter.copyWith(condition: condition); context - .read() - .add(TextFilterEditorEvent.updateCondition(condition)); + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); }, ), ), @@ -124,8 +122,8 @@ class _TextFilterEditorState extends State { switch (action) { case FilterDisclosureAction.delete: context - .read() - .add(const TextFilterEditorEvent.delete()); + .read() + .add(FilterEditorEvent.deleteFilter(filter.filterId)); break; } }, @@ -135,39 +133,36 @@ class _TextFilterEditorState extends State { ); } - Widget _buildFilterTextField( - BuildContext context, - TextFilterEditorState state, - ) { + Widget _buildFilterTextField(TextFilter filter, FieldInfo field) { return FlowyTextField( - text: state.filter.content, + 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(TextFilterEditorEvent.updateContent(text)); + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); }, ); } } -class TextFilterConditionPBList extends StatelessWidget { - const TextFilterConditionPBList({ +class TextFilterConditionList extends StatelessWidget { + const TextFilterConditionList({ super.key, - required this.filterInfo, + required this.filter, required this.popoverMutex, required this.onCondition, }); - final FilterInfo filterInfo; + final TextFilter filter; final PopoverMutex popoverMutex; - final Function(TextFilterConditionPB) onCondition; + final void Function(TextFilterConditionPB) onCondition; @override Widget build(BuildContext context) { - final textFilter = filterInfo.textFilter()!; return PopoverActionList( asBarrier: true, mutex: popoverMutex, @@ -176,13 +171,13 @@ class TextFilterConditionPBList extends StatelessWidget { .map( (action) => ConditionWrapper( action, - textFilter.condition == action, + filter.condition == action, ), ) .toList(), buildChild: (controller) { return ConditionButton( - conditionName: textFilter.condition.filterName, + conditionName: filter.condition.filterName, onTap: () => controller.show(), ); }, 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 0000000000..dcd33f66c3 --- /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 index 53d2b0ace8..7e453b9ab7 100644 --- 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 @@ -1,58 +1,40 @@ -import 'package:appflowy/plugins/database/grid/application/filter/text_filter_editor_bloc.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/filter/choicechip/text.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.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 '../filter_info.dart'; import 'choicechip.dart'; -class URLFilterChoiceChip extends StatelessWidget { - const URLFilterChoiceChip({required this.filterInfo, super.key}); +class URLFilterChoicechip extends StatelessWidget { + const URLFilterChoicechip({ + super.key, + required this.filterId, + }); - final FilterInfo filterInfo; + final String filterId; @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => TextFilterEditorBloc( - filterInfo: filterInfo, - fieldType: FieldType.URL, - ), - child: BlocBuilder( - builder: (context, state) { - return AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(200, 76)), - direction: PopoverDirection.bottomWithCenterAligned, - popupBuilder: (popoverContext) { - return BlocProvider.value( - value: context.read(), - child: const TextFilterEditor(), - ); - }, - child: ChoiceChipButton( - filterInfo: filterInfo, - filterDesc: _makeFilterDesc(state), - ), + 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), ); }, ), ); } - - String _makeFilterDesc(TextFilterEditorState state) { - String filterDesc = state.filter.condition.choicechipPrefix; - if (state.filter.condition == TextFilterConditionPB.TextIsEmpty || - state.filter.condition == TextFilterConditionPB.TextIsNotEmpty) { - return filterDesc; - } - - if (state.filter.content.isNotEmpty) { - filterDesc += " ${state.filter.content}"; - } - - return filterDesc; - } } 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 index 736fdee63a..2be7810546 100644 --- 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 @@ -1,5 +1,6 @@ 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'; @@ -31,13 +32,14 @@ class ConditionButton extends StatelessWidget { 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: const BorderRadius.all(Radius.circular(2)), + 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 index b91fb44389..9cf2cd8322 100644 --- 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 @@ -1,8 +1,9 @@ -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/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/util/field_type_extension.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'; @@ -13,57 +14,43 @@ import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../application/field/field_controller.dart'; -import '../../../application/filter/filter_create_bloc.dart'; - -class GridCreateFilterList extends StatefulWidget { - const GridCreateFilterList({ +class CreateDatabaseViewFilterList extends StatelessWidget { + const CreateDatabaseViewFilterList({ super.key, - required this.viewId, - required this.fieldController, - required this.onClosed, - this.onCreateFilter, + this.onTap, }); - final String viewId; - final FieldController fieldController; - final VoidCallback onClosed; - final VoidCallback? onCreateFilter; - - @override - State createState() => _GridCreateFilterListState(); -} - -class _GridCreateFilterListState extends State { - late GridCreateFilterBloc editBloc; - - @override - void initState() { - editBloc = GridCreateFilterBloc( - viewId: widget.viewId, - fieldController: widget.fieldController, - )..add(const GridCreateFilterEvent.initial()); - super.initState(); - } + final VoidCallback? onTap; @override Widget build(BuildContext context) { - return BlocProvider.value( - value: editBloc, - child: BlocListener( + 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) { - if (state.didCreateFilter) { - widget.onClosed(); - } + context + .read>() + .add(SimpleTextFilterEvent.receiveNewValues(state.fields)); }, - child: BlocBuilder( + child: BlocBuilder, + SimpleTextFilterState>( builder: (context, state) { - final cells = state.creatableFields.map((fieldInfo) { + final cells = state.values.map((fieldInfo) { return SizedBox( height: GridSize.popoverItemHeight, - child: GridFilterPropertyCell( + child: FilterableFieldButton( fieldInfo: fieldInfo, - onTap: (fieldInfo) => createFilter(fieldInfo), + onTap: () { + context + .read() + .add(FilterEditorEvent.createFilter(fieldInfo)); + onTap?.call(); + }, ), ); }).toList(); @@ -77,12 +64,9 @@ class _GridCreateFilterListState extends State { child: ListView.separated( shrinkWrap: true, itemCount: cells.length, - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - separatorBuilder: (BuildContext context, int index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, + itemBuilder: (_, int index) => cells[index], + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), ), ), ]; @@ -96,17 +80,6 @@ class _GridCreateFilterListState extends State { ), ); } - - @override - void dispose() { - editBloc.close(); - super.dispose(); - } - - void createFilter(FieldInfo field) { - editBloc.add(GridCreateFilterEvent.createDefaultFilter(field)); - widget.onCreateFilter?.call(); - } } class _FilterTextFieldDelegate extends SliverPersistentHeaderDelegate { @@ -128,8 +101,8 @@ class _FilterTextFieldDelegate extends SliverPersistentHeaderDelegate { hintText: LocaleKeys.grid_settings_filterBy.tr(), onChanged: (text) { context - .read() - .add(GridCreateFilterEvent.didReceiveFilterText(text)); + .read>() + .add(SimpleTextFilterEvent.updateFilter(text)); }, ), ); @@ -147,28 +120,28 @@ class _FilterTextFieldDelegate extends SliverPersistentHeaderDelegate { } } -class GridFilterPropertyCell extends StatelessWidget { - const GridFilterPropertyCell({ +class FilterableFieldButton extends StatelessWidget { + const FilterableFieldButton({ super.key, required this.fieldInfo, required this.onTap, }); final FieldInfo fieldInfo; - final Function(FieldInfo) onTap; + final VoidCallback onTap; @override Widget build(BuildContext context) { return FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, - text: FlowyText.medium( + text: FlowyText( + lineHeight: 1.0, fieldInfo.field.name, color: AFThemeExtension.of(context).textColor, ), - onTap: () => onTap(fieldInfo), - leftIcon: FlowySvg( - fieldInfo.fieldType.svgData, - color: Theme.of(context).iconTheme.color, + onTap: onTap, + leftIcon: FieldIcon( + fieldInfo: fieldInfo, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart deleted file mode 100644 index 19c201d026..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; - -class FilterInfo { - FilterInfo(this.viewId, this.filter, this.fieldInfo); - - final String viewId; - final FilterPB filter; - final FieldInfo fieldInfo; - - FilterInfo copyWith({FilterPB? filter, FieldInfo? fieldInfo}) { - return FilterInfo( - viewId, - filter ?? this.filter, - fieldInfo ?? this.fieldInfo, - ); - } - - String get filterId => filter.id; - - String get fieldId => filter.data.fieldId; - - DateFilterPB? dateFilter() { - final fieldType = filter.data.fieldType; - return fieldType == FieldType.DateTime || - fieldType == FieldType.CreatedTime || - fieldType == FieldType.LastEditedTime - ? DateFilterPB.fromBuffer(filter.data.data) - : null; - } - - TextFilterPB? textFilter() { - return filter.data.fieldType == FieldType.RichText || - filter.data.fieldType == FieldType.URL - ? TextFilterPB.fromBuffer(filter.data.data) - : null; - } - - CheckboxFilterPB? checkboxFilter() { - return filter.data.fieldType == FieldType.Checkbox - ? CheckboxFilterPB.fromBuffer(filter.data.data) - : null; - } - - SelectOptionFilterPB? selectOptionFilter() { - return filter.data.fieldType == FieldType.SingleSelect || - filter.data.fieldType == FieldType.MultiSelect - ? SelectOptionFilterPB.fromBuffer(filter.data.data) - : null; - } - - ChecklistFilterPB? checklistFilter() { - return filter.data.fieldType == FieldType.Checklist - ? ChecklistFilterPB.fromBuffer(filter.data.data) - : null; - } - - NumberFilterPB? numberFilter() { - return filter.data.fieldType == FieldType.Number - ? NumberFilterPB.fromBuffer(filter.data.data) - : null; - } -} 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 index 80deb98695..c91b47e2b7 100644 --- 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 @@ -1,12 +1,11 @@ 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_menu_bloc.dart'; -import 'package:appflowy_popover/appflowy_popover.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'; @@ -23,43 +22,47 @@ class FilterMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => DatabaseFilterMenuBloc( + return BlocProvider( + create: (context) => FilterEditorBloc( viewId: fieldController.viewId, fieldController: fieldController, - )..add( - const DatabaseFilterMenuEvent.initial(), - ), - child: BlocBuilder( + ), + 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( - (filterInfo) => FilterMenuItem( - key: ValueKey(filterInfo.filter.id), - filterInfo: filterInfo, + (filter) => FilterMenuItem( + key: ValueKey(filter.filterId), + filterId: filter.filterId, + fieldType: state.fields + .firstWhere( + (element) => element.id == filter.fieldId, + ) + .fieldType, ), ) .toList(), ); - if (state.creatableFields.isNotEmpty) { - children.add(AddFilterButton(viewId: state.viewId)); + if (state.fields.isNotEmpty) { + children.add( + AddFilterButton( + viewId: state.viewId, + ), + ); } - return Expanded( - child: Row( - children: [ - Expanded( - child: Wrap( - spacing: 6, - runSpacing: 4, - children: children, - ), - ), - ], - ), + return Wrap( + spacing: 6, + runSpacing: 4, + children: children, ); }, ), @@ -77,22 +80,16 @@ class AddFilterButton extends StatefulWidget { } class _AddFilterButtonState extends State { - late PopoverController popoverController; - - @override - void initState() { - popoverController = PopoverController(); - super.initState(); - } + final PopoverController popoverController = PopoverController(); @override Widget build(BuildContext context) { return wrapPopover( - context, SizedBox( height: 28, child: FlowyButton( text: FlowyText( + lineHeight: 1.0, LocaleKeys.grid_settings_addFilter.tr(), color: AFThemeExtension.of(context).textColor, ), @@ -108,18 +105,18 @@ class _AddFilterButtonState extends State { ); } - Widget wrapPopover(BuildContext buildContext, Widget child) { + Widget wrapPopover(Widget child) { return AppFlowyPopover( controller: popoverController, constraints: BoxConstraints.loose(const Size(200, 300)), triggerActions: PopoverTriggerFlags.none, child: child, - popupBuilder: (BuildContext context) { - final bloc = buildContext.read(); - return GridCreateFilterList( - viewId: widget.viewId, - fieldController: bloc.fieldController, - onClosed: () => popoverController.close(), + 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 index 3ca86d3969..d7e45840e6 100644 --- 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 @@ -1,34 +1,42 @@ -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/material.dart'; import 'choicechip/checkbox.dart'; -import 'choicechip/checklist/checklist.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'; -import 'filter_info.dart'; class FilterMenuItem extends StatelessWidget { - const FilterMenuItem({required this.filterInfo, super.key}); + const FilterMenuItem({ + super.key, + required this.fieldType, + required this.filterId, + }); - final FilterInfo filterInfo; + final FieldType fieldType; + final String filterId; @override Widget build(BuildContext context) { - return switch (filterInfo.fieldInfo.fieldType) { - FieldType.Checkbox => CheckboxFilterChoicechip(filterInfo: filterInfo), - FieldType.DateTime => DateFilterChoicechip(filterInfo: filterInfo), + 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(filterInfo: filterInfo), - FieldType.Number => NumberFilterChoiceChip(filterInfo: filterInfo), - FieldType.RichText => TextFilterChoicechip(filterInfo: filterInfo), - FieldType.SingleSelect => - SelectOptionFilterChoicechip(filterInfo: filterInfo), - FieldType.URL => URLFilterChoiceChip(filterInfo: filterInfo), - FieldType.Checklist => ChecklistFilterChoicechip(filterInfo: filterInfo), - _ => const SizedBox(), + 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 index fa78dd629e..43a0301a10 100755 --- 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 @@ -1,13 +1,13 @@ -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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class GridAddRowButton extends StatelessWidget { @@ -15,22 +15,27 @@ class GridAddRowButton extends StatelessWidget { @override Widget build(BuildContext context) { + final color = Theme.of(context).brightness == Brightness.light + ? const Color(0xFF171717).withValues(alpha: 0.4) + : const Color(0xFFFFFFFF).withValues(alpha: 0.4); return FlowyButton( radius: BorderRadius.zero, decoration: BoxDecoration( border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), + bottom: BorderSide(color: AFThemeExtension.of(context).borderColor), ), ), text: FlowyText( + lineHeight: 1.0, LocaleKeys.grid_row_newRow.tr(), - color: Theme.of(context).hintColor, + color: color, ), + margin: const EdgeInsets.symmetric(horizontal: 12), hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: () => context.read().add(const GridEvent.createRow()), leftIcon: FlowySvg( - FlowySvgs.add_s, - color: Theme.of(context).hintColor, + FlowySvgs.add_less_padding_s, + color: color, ), ); } @@ -41,10 +46,54 @@ class GridRowBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { + final padding = + context.read().horizontalPadding; return Container( - padding: GridSize.footerContentInsets + const EdgeInsets.only(left: 40), + 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).withValues(alpha: 0.4) + : const Color(0xFFFFFFFF).withValues(alpha: 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 index 1a63e3c678..915bf70a61 100755 --- 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 @@ -3,9 +3,10 @@ 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/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:appflowy_popover/appflowy_popover.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'; @@ -41,13 +42,12 @@ class GridFieldCell extends StatefulWidget { } class _GridFieldCellState extends State { + final PopoverController popoverController = PopoverController(); late final FieldCellBloc _bloc; - late PopoverController popoverController; @override void initState() { super.initState(); - popoverController = PopoverController(); _bloc = FieldCellBloc(viewId: widget.viewId, fieldInfo: widget.fieldInfo); if (widget.isEditing) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { @@ -86,16 +86,21 @@ class _GridFieldCellState extends State { return FieldEditor( viewId: widget.viewId, fieldController: widget.fieldController, - field: widget.fieldInfo.field, + fieldInfo: widget.fieldInfo, + isNewField: widget.isNew, initialPage: widget.isNew ? FieldEditorPage.details : FieldEditorPage.general, onFieldInserted: widget.onFieldInsertedOnEitherSide, ); }, - child: FieldCellButton( - field: widget.fieldInfo.field, - onTap: widget.onTap, + child: SizedBox( + height: GridSize.headerHeight, + child: FieldCellButton( + field: widget.fieldInfo.field, + onTap: widget.onTap, + margin: const EdgeInsetsDirectional.fromSTEB(12, 9, 10, 9), + ), ), ); @@ -103,7 +108,7 @@ class _GridFieldCellState extends State { top: 0, bottom: 0, right: 0, - child: _DragToExpandLine(), + child: DragToExpandLine(), ); return _GridHeaderCellContainer( @@ -136,9 +141,8 @@ class _GridHeaderCellContainer extends StatelessWidget { @override Widget build(BuildContext context) { - final borderSide = BorderSide( - color: Theme.of(context).dividerColor, - ); + final borderSide = + BorderSide(color: AFThemeExtension.of(context).borderColor); final decoration = BoxDecoration( border: Border( right: borderSide, @@ -154,8 +158,11 @@ class _GridHeaderCellContainer extends StatelessWidget { } } -class _DragToExpandLine extends StatelessWidget { - const _DragToExpandLine(); +@visibleForTesting +class DragToExpandLine extends StatelessWidget { + const DragToExpandLine({ + super.key, + }); @override Widget build(BuildContext context) { @@ -206,20 +213,27 @@ class FieldCellButton extends StatelessWidget { final VoidCallback onTap; final int? maxLines; final BorderRadius? radius; - final EdgeInsets? margin; + final EdgeInsetsGeometry? margin; @override Widget build(BuildContext context) { return FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: onTap, - leftIcon: FlowySvg( - field.fieldType.svgData, - color: Theme.of(context).iconTheme.color, + 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.medium( + text: FlowyText( field.name, + lineHeight: 1.0, maxLines: maxLines, overflow: TextOverflow.ellipsis, color: AFThemeExtension.of(context).textColor, @@ -228,3 +242,39 @@ class FieldCellButton extends StatelessWidget { ); } } + +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.withValues(alpha: 0.6), + size: Size.square(dimension), + ) + : SizedBox.square( + dimension: dimension, + child: Center( + child: FlowySvg.string( + svgContent, + color: color.withValues(alpha: 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 index 3267c74ad7..c59327113b 100644 --- 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 @@ -1,24 +1,8 @@ import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -extension FieldTypeListExtension on FieldType { - 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, +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 index 79ad37c706..e30c238f96 100644 --- 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 @@ -4,15 +4,15 @@ import 'package:appflowy/plugins/database/application/field/field_controller.dar 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:appflowy_editor/appflowy_editor.dart' hide Log; 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:reorderables/reorderables.dart'; +import 'package:universal_platform/universal_platform.dart'; import '../../layout/sizes.dart'; import 'desktop_field_cell.dart'; @@ -21,11 +21,13 @@ class GridHeaderSliverAdaptor extends StatefulWidget { const GridHeaderSliverAdaptor({ super.key, required this.viewId, + required this.shrinkWrap, required this.anchorScrollController, }); final String viewId; final ScrollController anchorScrollController; + final bool shrinkWrap; @override State createState() => @@ -37,6 +39,9 @@ class _GridHeaderSliverAdaptorState extends State { Widget build(BuildContext context) { final fieldController = context.read().databaseController.fieldController; + final horizontalPadding = + context.read()?.horizontalPadding ?? + 0.0; return BlocProvider( create: (context) { return GridHeaderBloc( @@ -47,9 +52,14 @@ class _GridHeaderSliverAdaptorState extends State { child: SingleChildScrollView( scrollDirection: Axis.horizontal, controller: widget.anchorScrollController, - child: _GridHeader( - viewId: widget.viewId, - fieldController: fieldController, + child: Padding( + padding: widget.shrinkWrap + ? EdgeInsets.symmetric(horizontal: horizontalPadding) + : EdgeInsets.zero, + child: _GridHeader( + viewId: widget.viewId, + fieldController: fieldController, + ), ), ), ); @@ -111,7 +121,7 @@ class _GridHeaderState extends State<_GridHeader> { ), draggingWidgetOpacity: 0, header: _cellLeading(), - needsLongPressDraggable: PlatformExtension.isMobile, + needsLongPressDraggable: UniversalPlatform.isMobile, footer: _CellTrailing(viewId: widget.viewId), onReorder: (int oldIndex, int newIndex) { context @@ -139,7 +149,9 @@ class _GridHeaderState extends State<_GridHeader> { } Widget _cellLeading() { - return SizedBox(width: GridSize.horizontalHeaderPadding + 40); + return SizedBox( + width: context.read().horizontalPadding, + ); } } @@ -151,11 +163,11 @@ class _CellTrailing extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - width: GridSize.trailHeaderPadding, - margin: EdgeInsets.only(right: GridSize.scrollBarSize + Insets.m), + width: GridSize.newPropertyButtonWidth, + height: GridSize.headerHeight, decoration: BoxDecoration( border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), + bottom: BorderSide(color: AFThemeExtension.of(context).borderColor), ), ), child: CreateFieldButton( @@ -168,7 +180,7 @@ class _CellTrailing extends StatelessWidget { } } -class CreateFieldButton extends StatefulWidget { +class CreateFieldButton extends StatelessWidget { const CreateFieldButton({ super.key, required this.viewId, @@ -178,33 +190,29 @@ class CreateFieldButton extends StatefulWidget { final String viewId; final void Function(String fieldId) onFieldCreated; - @override - State createState() => _CreateFieldButtonState(); -} - -class _CreateFieldButtonState extends State { @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: widget.viewId, + viewId: viewId, ); result.fold( - (field) => widget.onFieldCreated(field.id), + (field) => onFieldCreated(field.id), (err) => Log.error("Failed to create field type option: $err"), ); }, leftIcon: const FlowySvg( - FlowySvgs.add_s, - size: Size.square(18), + 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 index d4c2289136..20cc030ff4 100755 --- 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 @@ -1,8 +1,7 @@ -import 'package:appflowy/generated/flowy_svgs.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/util/field_type_extension.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'; @@ -43,9 +42,9 @@ class MobileFieldButton extends StatelessWidget { radius: radius, margin: margin, leftIconSize: const Size.square(18), - leftIcon: FlowySvg( - fieldInfo.fieldType.svgData, - size: const Size.square(18), + leftIcon: FieldIcon( + fieldInfo: fieldInfo, + dimension: 18, ), text: FlowyText( fieldInfo.name, 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 index 35c4127dfd..369bdeb523 100644 --- 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 @@ -5,6 +5,7 @@ import 'package:appflowy/plugins/database/application/field/field_controller.dar 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:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -39,6 +40,8 @@ class _MobileGridHeaderState extends State { Widget build(BuildContext context) { final fieldController = context.read().databaseController.fieldController; + final isLocked = + context.read()?.state.isLocked ?? false; return BlocProvider( create: (context) { return GridHeaderBloc( @@ -76,12 +79,15 @@ class _MobileGridHeaderState extends State { ); }, ), - SizedBox( - height: _kGridHeaderHeight, - child: _GridHeader( - viewId: widget.viewId, - fieldController: fieldController, - scrollController: widget.reorderableController, + IgnorePointer( + ignoring: isLocked, + child: SizedBox( + height: _kGridHeaderHeight, + child: _GridHeader( + viewId: widget.viewId, + fieldController: fieldController, + scrollController: widget.reorderableController, + ), ), ), ], @@ -178,7 +184,7 @@ class _GridHeaderState extends State<_GridHeader> { } } -class CreateFieldButton extends StatefulWidget { +class CreateFieldButton extends StatelessWidget { const CreateFieldButton({ super.key, required this.viewId, @@ -188,15 +194,13 @@ class CreateFieldButton extends StatefulWidget { final String viewId; final void Function(String fieldId) onFieldCreated; - @override - State createState() => _CreateFieldButtonState(); -} - -class _CreateFieldButtonState extends State { @override Widget build(BuildContext context) { return Container( - width: 200, + constraints: BoxConstraints( + maxWidth: GridSize.mobileNewPropertyButtonWidth, + minHeight: GridSize.headerHeight, + ), decoration: _getDecoration(context), child: FlowyButton( margin: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), @@ -208,7 +212,7 @@ class _CreateFieldButtonState extends State { color: Theme.of(context).hintColor, ), hoverColor: AFThemeExtension.of(context).greyHover, - onTap: () => mobileCreateFieldWorkflow(context, widget.viewId), + onTap: () => mobileCreateFieldWorkflow(context, viewId), leftIconSize: const Size.square(18), leftIcon: FlowySvg( FlowySvgs.add_s, 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 index 29af8e4355..7c18bb927f 100644 --- 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 @@ -43,7 +43,7 @@ Widget getGridFabs(BuildContext context) { .read() .add(const GridEvent.createRow(openRowDetail: true)); }, - overlayColor: const MaterialStatePropertyAll(Color(0xFF009FD1)), + overlayColor: const WidgetStatePropertyAll(Color(0xFF009FD1)), boxShadow: const BoxShadow( offset: Offset(0, 8), color: Color(0x6612BFEF), @@ -75,7 +75,7 @@ class MobileGridFab extends StatelessWidget { final VoidCallback onTap; final FlowySvgData icon; final Size iconSize; - final MaterialStateProperty? overlayColor; + final WidgetStateProperty? overlayColor; @override Widget build(BuildContext context) { 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 index 0766579627..d212c50746 100644 --- 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 @@ -1,12 +1,15 @@ 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:appflowy_popover/appflowy_popover.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({ @@ -50,7 +53,11 @@ class RowActionMenu extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(action.text, overflow: TextOverflow.ellipsis), + text: FlowyText( + action.text, + overflow: TextOverflow.ellipsis, + lineHeight: 1.0, + ), onTap: () { action.performAction(context, viewId, rowId); PopoverContainer.of(context).close(); @@ -71,7 +78,7 @@ enum RowAction { return switch (this) { insertAbove => FlowySvgs.arrow_s, insertBelow => FlowySvgs.add_s, - duplicate => FlowySvgs.copy_s, + duplicate => FlowySvgs.duplicate_s, delete => FlowySvgs.delete_s, }; } @@ -92,17 +99,45 @@ enum RowAction { final position = this == insertAbove ? OrderObjectPositionTypePB.Before : OrderObjectPositionTypePB.After; - RowBackendService.createRow( - viewId: viewId, - position: position, - targetRowId: rowId, - ); + 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: - RowBackendService.deleteRow(viewId, rowId); + 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 index b6817fc848..209c439ad1 100755 --- 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 @@ -81,8 +81,8 @@ class _MobileGridRowState extends State { @override Future dispose() async { - _rowController.dispose(); super.dispose(); + await _rowController.dispose(); } } 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 index 351062933a..2306767f46 100755 --- 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 @@ -1,27 +1,28 @@ -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/domain/sort_service.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.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:appflowy_popover/appflowy_popover.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_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 StatefulWidget { +class GridRow extends StatelessWidget { const GridRow({ super.key, required this.fieldController, @@ -30,8 +31,9 @@ class GridRow extends StatefulWidget { required this.rowController, required this.cellBuilder, required this.openDetailPage, - this.index, - this.isDraggable = false, + required this.index, + this.shrinkWrap = false, + required this.editable, }); final FieldController fieldController; @@ -40,52 +42,57 @@ class GridRow extends StatefulWidget { final RowController rowController; final EditableCellBuilder cellBuilder; final void Function(BuildContext context) openDetailPage; - final int? index; - final bool isDraggable; + final int index; + final bool shrinkWrap; + final bool editable; - @override - State createState() => _GridRowState(); -} - -class _GridRowState extends State { @override Widget build(BuildContext context) { - return BlocProvider( + Widget rowContent = RowContent( + fieldController: fieldController, + cellBuilder: cellBuilder, + onExpand: () => openDetailPage(context), + ); + + if (!shrinkWrap) { + rowContent = Expanded(child: rowContent); + } + + rowContent = BlocProvider( create: (_) => RowBloc( - fieldController: widget.fieldController, - rowId: widget.rowId, - rowController: widget.rowController, - viewId: widget.viewId, + fieldController: fieldController, + rowId: rowId, + rowController: rowController, + viewId: viewId, ), child: _RowEnterRegion( child: Row( children: [ - _RowLeading( - index: widget.index, - isDraggable: widget.isDraggable, - ), - Expanded( - child: RowContent( - fieldController: widget.fieldController, - cellBuilder: widget.cellBuilder, - onExpand: () => widget.openDetailPage(context), - ), - ), + _RowLeading(viewId: viewId, index: index), + rowContent, ], ), ), ); + + if (!editable) { + rowContent = IgnorePointer( + child: rowContent, + ); + } + + return rowContent; } } class _RowLeading extends StatefulWidget { const _RowLeading({ - this.index, - this.isDraggable = false, + required this.viewId, + required this.index, }); - final int? index; - final bool isDraggable; + final String viewId; + final int index; @override State<_RowLeading> createState() => _RowLeadingState(); @@ -99,20 +106,25 @@ class _RowLeadingState extends State<_RowLeading> { return AppFlowyPopover( controller: popoverController, triggerActions: PopoverTriggerFlags.none, - constraints: BoxConstraints.loose(const Size(176, 200)), + constraints: BoxConstraints.loose(const Size(200, 200)), direction: PopoverDirection.rightWithCenterAligned, margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), popupBuilder: (_) { final bloc = context.read(); - return RowActionMenu( - viewId: bloc.viewId, - rowId: bloc.rowId, + return BlocProvider.value( + value: context.read(), + child: RowActionMenu( + viewId: bloc.viewId, + rowId: bloc.rowId, + ), ); }, child: Consumer( builder: (context, state, _) { return SizedBox( - width: GridSize.horizontalHeaderPadding + 40, + width: context + .read() + .horizontalPadding, child: state.onEnter ? _activeWidget() : null, ); }, @@ -124,28 +136,25 @@ class _RowLeadingState extends State<_RowLeading> { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - const InsertRowButton(), - if (isDraggable) - ReorderableDragStartListener( - index: widget.index!, - child: RowMenuButton( - isDragEnabled: isDraggable, - openMenu: popoverController.show, - ), - ) - else - RowMenuButton( + InsertRowButton(viewId: widget.viewId), + ReorderableDragStartListener( + index: widget.index, + child: RowMenuButton( openMenu: popoverController.show, ), + ), ], ); } - - bool get isDraggable => widget.index != null && widget.isDraggable; } class InsertRowButton extends StatelessWidget { - const InsertRowButton({super.key}); + const InsertRowButton({ + super.key, + required this.viewId, + }); + + final String viewId; @override Widget build(BuildContext context) { @@ -154,7 +163,28 @@ class InsertRowButton extends StatelessWidget { hoverColor: AFThemeExtension.of(context).lightGreyHover, width: 20, height: 30, - onPressed: () => context.read().add(const RowEvent.createRow()), + 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, @@ -168,11 +198,9 @@ class RowMenuButton extends StatefulWidget { const RowMenuButton({ super.key, required this.openMenu, - this.isDragEnabled = false, }); final VoidCallback openMenu; - final bool isDragEnabled; @override State createState() => _RowMenuButtonState(); @@ -182,16 +210,18 @@ class _RowMenuButtonState extends State { @override Widget build(BuildContext context) { return FlowyIconButton( - tooltipText: - widget.isDragEnabled ? null : LocaleKeys.tooltip_openMenu.tr(), - richTooltipText: widget.isDragEnabled - ? TextSpan( - children: [ - TextSpan(text: '${LocaleKeys.tooltip_dragRow.tr()}\n'), - TextSpan(text: LocaleKeys.tooltip_openMenu.tr()), - ], - ) - : null, + 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, @@ -278,14 +308,20 @@ class RowContent extends StatelessWidget { Widget _finalCellDecoration(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.basic, - child: Container( - width: GridSize.trailHeaderPadding, - constraints: const BoxConstraints(minHeight: 46), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), - ), - ), + child: ValueListenableBuilder( + valueListenable: cellBuilder.databaseController.compactModeNotifier, + builder: (context, compactMode, _) { + return Container( + width: GridSize.newPropertyButtonWidth, + constraints: BoxConstraints(minHeight: compactMode ? 32 : 36), + decoration: BoxDecoration( + border: Border( + bottom: + BorderSide(color: AFThemeExtension.of(context).borderColor), + ), + ), + ); + }, ), ); } 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 index 69e46a04ff..5218a60ee5 100644 --- 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 @@ -1,9 +1,9 @@ -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/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/util/field_type_extension.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'; @@ -24,44 +24,58 @@ class CreateDatabaseViewSortList extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final filter = state.filter.toLowerCase(); - final cells = state.creatableFields - .where((field) => field.field.name.toLowerCase().contains(filter)) - .map((fieldInfo) { - return GridSortPropertyCell( - fieldInfo: fieldInfo, - onTap: () { - context - .read() - .add(SortEditorEvent.createSort(fieldId: fieldInfo.id)); - onTap.call(); - }, - ); - }).toList(); + 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( + 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, - itemCount: cells.length, - itemBuilder: (_, index) => cells[index], - separatorBuilder: (_, __) => - VSpace(GridSize.typeOptionSeparatorHeight), - ), - ), - ]; - return CustomScrollView( - shrinkWrap: true, - slivers: slivers, - physics: StyledScrollPhysics(), - ); - }, + slivers: slivers, + physics: StyledScrollPhysics(), + ); + }, + ), + ), ); } } @@ -85,8 +99,8 @@ class _SortTextFieldDelegate extends SliverPersistentHeaderDelegate { hintText: LocaleKeys.grid_settings_sortBy.tr(), onChanged: (text) { context - .read() - .add(SortEditorEvent.updateCreateSortFilter(text)); + .read>() + .add(SimpleTextFilterEvent.updateFilter(text)); }, ), ); @@ -118,14 +132,14 @@ class GridSortPropertyCell extends StatelessWidget { height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, - text: FlowyText.medium( + text: FlowyText( fieldInfo.name, + lineHeight: 1.0, color: AFThemeExtension.of(context).textColor, ), onTap: onTap, - leftIcon: FlowySvg( - fieldInfo.fieldType.svgData, - color: Theme.of(context).iconTheme.color, + 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 index 0d1a0fe1d0..9bf8f36c85 100644 --- 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 @@ -13,7 +13,7 @@ class OrderPanel extends StatelessWidget { @override Widget build(BuildContext context) { final List children = SortConditionPB.values.map((condition) { - return OrderPannelItem( + return OrderPanelItem( condition: condition, onCondition: onCondition, ); @@ -32,8 +32,8 @@ class OrderPanel extends StatelessWidget { } } -class OrderPannelItem extends StatelessWidget { - const OrderPannelItem({ +class OrderPanelItem extends StatelessWidget { + const OrderPanelItem({ super.key, required this.condition, required this.onCondition, @@ -47,7 +47,7 @@ class OrderPannelItem extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(condition.title), + 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 index a00bc1002f..4d509b3862 100644 --- 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 @@ -34,6 +34,7 @@ class SortChoiceButton extends StatelessWidget { useIntrinsicWidth: true, text: FlowyText( text, + lineHeight: 1.0, color: AFThemeExtension.of(context).textColor, overflow: TextOverflow.ellipsis, ), 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 index 671a5c2084..2f7f68e2f6 100644 --- 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 @@ -1,22 +1,23 @@ 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:appflowy_popover/appflowy_popover.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 'create_sort_list.dart'; import 'order_panel.dart'; import 'sort_choice_button.dart'; -import 'sort_info.dart'; class SortEditor extends StatefulWidget { const SortEditor({super.key}); @@ -28,20 +29,25 @@ class SortEditor extends StatefulWidget { class _SortEditorState extends State { final popoverMutex = PopoverMutex(); + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final sortInfos = state.sortInfos; return ReorderableListView.builder( onReorder: (oldIndex, newIndex) => context .read() .add(SortEditorEvent.reorderSort(oldIndex, newIndex)), - itemCount: state.sortInfos.length, + itemCount: state.sorts.length, itemBuilder: (context, index) => DatabaseSortItem( - key: ValueKey(sortInfos[index].sortId), + key: ValueKey(state.sorts[index].sortId), index: index, - sortInfo: sortInfos[index], + sort: state.sorts[index], popoverMutex: popoverMutex, ), proxyDecorator: (child, index, animation) => Material( @@ -90,12 +96,12 @@ class DatabaseSortItem extends StatelessWidget { super.key, required this.index, required this.popoverMutex, - required this.sortInfo, + required this.sort, }); final int index; final PopoverMutex popoverMutex; - final SortInfo sortInfo; + final DatabaseSort sort; @override Widget build(BuildContext context) { @@ -125,9 +131,16 @@ class DatabaseSortItem extends StatelessWidget { fit: FlexFit.tight, child: SizedBox( height: 26, - child: SortChoiceButton( - text: sortInfo.fieldInfo.name, - editable: false, + child: BlocSelector( + selector: (state) => state.allFields.firstWhereOrNull( + (field) => field.id == sort.fieldId, + ), + builder: (context, field) { + return SortChoiceButton( + text: field?.name ?? "", + editable: false, + ); + }, ), ), ), @@ -137,7 +150,7 @@ class DatabaseSortItem extends StatelessWidget { child: SizedBox( height: 26, child: SortConditionButton( - sortInfo: sortInfo, + sort: sort, popoverMutex: popoverMutex, ), ), @@ -148,7 +161,7 @@ class DatabaseSortItem extends StatelessWidget { onPressed: () { context .read() - .add(SortEditorEvent.deleteSort(sortInfo)); + .add(SortEditorEvent.deleteSort(sort.sortId)); PopoverContainer.of(context).close(); }, hoverColor: AFThemeExtension.of(context).lightGreyHover, @@ -209,15 +222,12 @@ class _DatabaseAddSortButtonState extends State { ), ); }, - onClose: () => context - .read() - .add(const SortEditorEvent.updateCreateSortFilter("")), child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).greyHover, disable: widget.disable, - text: FlowyText.medium(LocaleKeys.grid_sort_addSort.tr()), + text: FlowyText(LocaleKeys.grid_sort_addSort.tr()), onTap: () => _popoverController.show(), leftIcon: const FlowySvg(FlowySvgs.add_s), ), @@ -238,7 +248,7 @@ class DeleteAllSortsButton extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(LocaleKeys.grid_sort_deleteAllSorts.tr()), + text: FlowyText(LocaleKeys.grid_sort_deleteAllSorts.tr()), onTap: () { context .read() @@ -257,11 +267,11 @@ class SortConditionButton extends StatefulWidget { const SortConditionButton({ super.key, required this.popoverMutex, - required this.sortInfo, + required this.sort, }); final PopoverMutex popoverMutex; - final SortInfo sortInfo; + final DatabaseSort sort; @override State createState() => _SortConditionButtonState(); @@ -283,7 +293,7 @@ class _SortConditionButtonState extends State { onCondition: (condition) { context.read().add( SortEditorEvent.editSort( - sortId: widget.sortInfo.sortId, + sortId: widget.sort.sortId, condition: condition, ), ); @@ -292,7 +302,7 @@ class _SortConditionButtonState extends State { ); }, child: SortChoiceButton( - text: widget.sortInfo.sortPB.condition.title, + text: widget.sort.condition.title, rightIcon: FlowySvg( FlowySvgs.arrow_down_s, color: Theme.of(context).iconTheme.color, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_info.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_info.dart deleted file mode 100644 index eef8d700ba..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_info.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; - -class SortInfo { - SortInfo({required this.sortPB, required this.fieldInfo}); - - final SortPB sortPB; - final FieldInfo fieldInfo; - - String get sortId => sortPB.id; - - String get fieldId => sortPB.fieldId; -} 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 index 43f583bd07..fc35e76241 100644 --- 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 @@ -2,8 +2,8 @@ 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: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'; @@ -12,7 +12,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'sort_choice_button.dart'; import 'sort_editor.dart'; -import 'sort_info.dart'; class SortMenu extends StatelessWidget { const SortMenu({ @@ -31,7 +30,7 @@ class SortMenu extends StatelessWidget { ), child: BlocBuilder( builder: (context, state) { - if (state.sortInfos.isEmpty) { + if (state.sorts.isEmpty) { return const SizedBox.shrink(); } @@ -47,7 +46,7 @@ class SortMenu extends StatelessWidget { child: const SortEditor(), ); }, - child: SortChoiceChip(sortInfos: state.sortInfos), + child: SortChoiceChip(sorts: state.sorts), ); }, ), @@ -58,11 +57,11 @@ class SortMenu extends StatelessWidget { class SortChoiceChip extends StatelessWidget { const SortChoiceChip({ super.key, - required this.sortInfos, + required this.sorts, this.onTap, }); - final List sortInfos; + final List sorts; final VoidCallback? onTap; @override 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 index 21c61713e4..5c33426281 100644 --- 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 @@ -1,18 +1,22 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/grid/application/filter/filter_menu_bloc.dart'; -import 'package:appflowy_popover/appflowy_popover.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}); + const FilterButton({ + super.key, + required this.toggleExtension, + }); + + final ToggleExtensionNotifier toggleExtension; @override State createState() => _FilterButtonState(); @@ -23,38 +27,34 @@ class _FilterButtonState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { - final textColor = state.filters.isEmpty - ? AFThemeExtension.of(context).textColor - : Theme.of(context).colorScheme.primary; - return _wrapPopover( - context, - FlowyTextButton( - LocaleKeys.grid_settings_filter.tr(), - fontColor: textColor, - fontSize: FontSizes.s11, - fontWeight: FontWeight.w400, - 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 { - bloc.add(const DatabaseFilterMenuEvent.toggleMenu()); - } - }, + MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyIconButton( + tooltipText: LocaleKeys.grid_settings_filter.tr(), + width: 24, + height: 24, + iconPadding: const EdgeInsets.all(3), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + icon: const FlowySvg(FlowySvgs.database_filter_s), + onPressed: () { + final bloc = context.read(); + if (bloc.state.filters.isEmpty) { + _popoverController.show(); + } else { + widget.toggleExtension.toggle(); + } + }, + ), ), ); }, ); } - Widget _wrapPopover(BuildContext buildContext, Widget child) { + Widget _wrapPopover(Widget child) { return AppFlowyPopover( controller: _popoverController, direction: PopoverDirection.bottomWithLeftAligned, @@ -62,17 +62,17 @@ class _FilterButtonState extends State { offset: const Offset(0, 8), triggerActions: PopoverTriggerFlags.none, child: child, - popupBuilder: (BuildContext context) { - final bloc = buildContext.read(); - return GridCreateFilterList( - viewId: bloc.viewId, - fieldController: bloc.fieldController, - onClosed: () => _popoverController.close(), - onCreateFilter: () { - if (!bloc.state.isVisible) { - bloc.add(const DatabaseFilterMenuEvent.toggleMenu()); - } - }, + 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 index 312bfd7511..f325ab206f 100644 --- 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 @@ -1,14 +1,17 @@ import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/grid/application/filter/filter_menu_bloc.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:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.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 'filter_button.dart'; import 'sort_button.dart'; +import 'view_database_button.dart'; class GridSettingBar extends StatelessWidget { const GridSettingBar({ @@ -24,45 +27,45 @@ class GridSettingBar extends StatelessWidget { Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider( - create: (context) => DatabaseFilterMenuBloc( + BlocProvider( + create: (context) => FilterEditorBloc( viewId: controller.viewId, fieldController: controller.fieldController, - )..add(const DatabaseFilterMenuEvent.initial()), + ), ), - BlocProvider( + BlocProvider( create: (context) => SortEditorBloc( viewId: controller.viewId, fieldController: controller.fieldController, ), ), ], - child: BlocListener( - listenWhen: (p, c) => p.isVisible != c.isVisible, - listener: (context, state) => toggleExtension.toggle(), - child: ValueListenableBuilder( - valueListenable: controller.isLoading, - builder: (context, value, child) { - if (value) { - return const SizedBox.shrink(); - } - return SizedBox( - height: 20, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const FilterButton(), + child: ValueListenableBuilder( + valueListenable: controller.isLoading, + builder: (context, isLoading, child) { + if (isLoading) { + return const SizedBox.shrink(); + } + final isReference = + Provider.of(context)?.isReference ?? false; + return SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilterButton(toggleExtension: toggleExtension), + const HSpace(2), + SortButton(toggleExtension: toggleExtension), + if (isReference) ...[ const HSpace(2), - SortButton(toggleExtension: toggleExtension), - const HSpace(2), - SettingButton( - databaseController: controller, - ), + ViewDatabaseButton(view: controller.view), ], - ), - ); - }, - ), + const HSpace(2), + 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 index be4740cea0..6649d53594 100644 --- 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 @@ -1,14 +1,12 @@ +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/grid_page.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_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'; @@ -28,35 +26,31 @@ class _SortButtonState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final textColor = state.sortInfos.isEmpty - ? AFThemeExtension.of(context).textColor - : Theme.of(context).colorScheme.primary; - return wrapPopover( - context, - FlowyTextButton( - LocaleKeys.grid_settings_sort.tr(), - fontColor: textColor, - fontSize: FontSizes.s11, - fontWeight: FontWeight.w400, - fillColor: Colors.transparent, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - padding: GridSize.toolbarSettingButtonInsets, - radius: Corners.s4Border, - onPressed: () { - if (state.sortInfos.isEmpty) { - _popoverController.show(); - } else { - widget.toggleExtension.toggle(); - } - }, + MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyIconButton( + tooltipText: LocaleKeys.grid_settings_sort.tr(), + width: 24, + height: 24, + iconPadding: const EdgeInsets.all(3), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + icon: const FlowySvg(FlowySvgs.database_sort_s), + onPressed: () { + if (state.sorts.isEmpty) { + _popoverController.show(); + } else { + widget.toggleExtension.toggle(); + } + }, + ), ), ); }, ); } - Widget wrapPopover(BuildContext context, Widget child) { + Widget wrapPopover(Widget child) { return AppFlowyPopover( controller: _popoverController, direction: PopoverDirection.bottomWithLeftAligned, @@ -76,9 +70,6 @@ class _SortButtonState extends State { ), ); }, - onClose: () => context - .read() - .add(const SortEditorEvent.updateCreateSortFilter("")), child: child, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart new file mode 100644 index 0000000000..93493e599f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart @@ -0,0 +1,37 @@ +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/tabs/tabs_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/style_widget/icon_button.dart'; +import 'package:flutter/material.dart'; + +class ViewDatabaseButton extends StatelessWidget { + const ViewDatabaseButton({super.key, required this.view}); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyIconButton( + tooltipText: LocaleKeys.grid_rowPage_openAsFullPage.tr(), + width: 24, + height: 24, + iconPadding: const EdgeInsets.all(3), + icon: const FlowySvg(FlowySvgs.database_fullscreen_s), + onPressed: () { + getIt().add( + TabsEvent.openPlugin( + plugin: view.plugin(), + view: view, + ), + ); + }, + ), + ); + } +} 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 index 5b66c3a149..69e5d27d37 100644 --- 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 @@ -2,7 +2,6 @@ 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/layout/sizes.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'; @@ -55,27 +54,24 @@ class _DatabaseViewSettingContent extends StatelessWidget { return BlocBuilder( builder: (context, state) { - return Padding( - padding: EdgeInsets.symmetric( - horizontal: GridSize.horizontalHeaderPadding, - ), - child: DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - ), + 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), - FilterMenu(fieldController: fieldController), - ], - ), + ), + 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 index b928423310..71b9fddda5 100644 --- 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 @@ -3,7 +3,6 @@ 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:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -32,27 +31,22 @@ class _AddDatabaseViewButtonState extends State { offset: const Offset(0, 8), margin: EdgeInsets.zero, triggerActions: PopoverTriggerFlags.none, - child: SizedBox( - height: 26, - child: Row( - children: [ - VerticalDivider( - width: 1.0, - thickness: 1.0, - indent: 4.0, - endIndent: 4.0, - color: Theme.of(context).dividerColor, - ), - FlowyIconButton( - width: 26, - iconPadding: const EdgeInsets.all(5), - hoverColor: AFThemeExtension.of(context).greyHover, - onPressed: () => popoverController.show(), - radius: Corners.s4Border, - icon: const FlowySvg(FlowySvgs.add_s), - iconColorOnHover: Theme.of(context).colorScheme.onSurface, - ), - ], + 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) { @@ -108,7 +102,7 @@ class TabBarAddButtonActionCell extends StatelessWidget { height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, - text: FlowyText.medium( + text: FlowyText( '${LocaleKeys.grid_createView.tr()} ${action.layoutName}', color: AFThemeExtension.of(context).textColor, ), 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 index 54a08c1284..fa5e44a5e6 100644 --- 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 @@ -1,31 +1,34 @@ 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/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.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/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.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:appflowy_popover/appflowy_popover.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:provider/provider.dart'; import 'tab_bar_add_button.dart'; class TabBarHeader extends StatelessWidget { - const TabBarHeader({super.key}); + const TabBarHeader({ + super.key, + }); @override Widget build(BuildContext context) { - return Container( - height: 30, - padding: EdgeInsets.symmetric( - horizontal: GridSize.horizontalHeaderPadding + 40, - ), + return SizedBox( + height: 35, child: Stack( children: [ Positioned( @@ -33,7 +36,7 @@ class TabBarHeader extends StatelessWidget { left: 0, right: 0, child: Divider( - color: Theme.of(context).dividerColor, + color: AFThemeExtension.of(context).borderColor, height: 1, thickness: 1, ), @@ -41,19 +44,18 @@ class TabBarHeader extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Flexible(child: DatabaseTabBar()), - BlocBuilder( - builder: (context, state) { - return SizedBox( - width: 200, - child: Column( - children: [ - const VSpace(3), - pageSettingBarFromState(context, state), - ], - ), - ); - }, + const Expanded( + child: DatabaseTabBar(), + ), + Flexible( + child: BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.only(top: 6.0), + child: pageSettingBarFromState(context, state), + ); + }, + ), ), ], ), @@ -96,40 +98,36 @@ class _DatabaseTabBarState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final children = state.tabBars.indexed.map((indexed) { - final isSelected = state.selectedIndex == indexed.$1; - final tabBar = indexed.$2; - return DatabaseTabBarItem( - key: ValueKey(tabBar.viewId), - view: tabBar.view, - isSelected: isSelected, - onTap: (selectedView) { - context.read().add( - DatabaseTabBarEvent.selectView(selectedView.id), - ); - }, - ); - }).toList(); - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: ListView( - controller: _scrollController, - scrollDirection: Axis.horizontal, - shrinkWrap: true, - children: children, - ), - ), - AddDatabaseViewButton( - onTap: (layoutType) async { - context.read().add( - DatabaseTabBarEvent.createView(layoutType, null), - ); - }, - ), - ], + 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, + ), ); }, ); @@ -154,12 +152,15 @@ class DatabaseTabBarItem extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 160), child: Stack( children: [ - SizedBox( - height: 26, - child: TabBarItemButton( - view: view, - isSelected: isSelected, - onTap: () => onTap(view), + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: SizedBox( + height: 26, + child: TabBarItemButton( + view: view, + isSelected: isSelected, + onTap: () => onTap(view), + ), ), ), if (isSelected) @@ -179,7 +180,7 @@ class DatabaseTabBarItem extends StatelessWidget { } } -class TabBarItemButton extends StatelessWidget { +class TabBarItemButton extends StatefulWidget { const TabBarItemButton({ super.key, required this.view, @@ -191,76 +192,167 @@ class TabBarItemButton extends StatelessWidget { final bool isSelected; final VoidCallback onTap; + @override + State createState() => _TabBarItemButtonState(); +} + +class _TabBarItemButtonState extends State { + final menuController = PopoverController(); + final iconController = PopoverController(); + @override Widget build(BuildContext context) { - return PopoverActionList( + Color? color; + if (!widget.isSelected) { + color = Theme.of(context).hintColor; + } + if (Theme.of(context).brightness == Brightness.dark) { + color = null; + } + return AppFlowyPopover( + controller: menuController, + constraints: const BoxConstraints( + minWidth: 120, + maxWidth: 460, + maxHeight: 300, + ), 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, - onSecondaryTap: () { - controller.show(); - }, - leftIcon: FlowySvg( - view.iconData, - size: const Size(14, 14), - color: color, - ), - text: FlowyText( - view.name, - fontSize: FontSizes.s11, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - color: color, - fontWeight: isSelected ? null : FontWeight.w400, + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (_) { + return IntrinsicHeight( + child: IntrinsicWidth( + child: Column( + children: [ + ActionCellWidget( + action: TabBarViewAction.rename, + itemHeight: ActionListSizes.itemHeight, + onSelected: (action) { + NavigatorTextFieldDialog( + title: LocaleKeys.menuAppHeader_renameDialog.tr(), + value: widget.view.nameOrDefault, + onConfirm: (newValue, _) { + context.read().add( + DatabaseTabBarEvent.renameView( + widget.view.id, + newValue, + ), + ); + }, + ).show(context); + menuController.close(); + }, + ), + AppFlowyPopover( + controller: iconController, + direction: PopoverDirection.rightWithCenterAligned, + constraints: BoxConstraints.loose(const Size(364, 356)), + margin: const EdgeInsets.all(0), + child: ActionCellWidget( + action: TabBarViewAction.changeIcon, + itemHeight: ActionListSizes.itemHeight, + onSelected: (action) { + iconController.show(); + }, + ), + popupBuilder: (context) { + return FlowyIconEmojiPicker( + tabs: const [PickerTabType.icon], + enableBackgroundColorSelection: false, + onSelectedEmoji: (r) { + ViewBackendService.updateViewIcon( + view: widget.view, + viewIcon: r.data, + ); + if (!r.keepOpen) { + iconController.close(); + menuController.close(); + } + }, + ); + }, + ), + ActionCellWidget( + action: TabBarViewAction.delete, + itemHeight: ActionListSizes.itemHeight, + onSelected: (action) { + NavigatorAlertDialog( + title: LocaleKeys.grid_deleteView.tr(), + confirm: () { + context.read().add( + DatabaseTabBarEvent.deleteView(widget.view.id), + ); + }, + ).show(context); + menuController.close(); + }, + ), + ], ), ), ); }, - onSelected: (action, controller) { - switch (action) { - case TabBarViewAction.rename: - NavigatorTextFieldDialog( - title: LocaleKeys.menuAppHeader_renameDialog.tr(), - value: view.name, - 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(); - }, + child: IntrinsicWidth( + child: FlowyButton( + radius: Corners.s6Border, + hoverColor: AFThemeExtension.of(context).greyHover, + onTap: () { + if (widget.isSelected) menuController.show(); + widget.onTap.call(); + }, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + onSecondaryTap: () { + menuController.show(); + }, + leftIcon: _buildViewIcon(), + text: FlowyText( + widget.view.nameOrDefault, + lineHeight: 1.0, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + color: color, + fontWeight: widget.isSelected ? FontWeight.w500 : FontWeight.w400, + ), + ), + ), ); } + + Widget _buildViewIcon() { + final iconData = widget.view.icon.toEmojiIconData(); + Widget icon; + if (iconData.isEmpty || iconData.type != FlowyIconType.icon) { + icon = widget.view.defaultIcon(); + } else { + icon = RawEmojiIconWidget( + emoji: iconData, + emojiSize: 14.0, + enableColor: false, + ); + } + final isReference = + Provider.of(context)?.isReference ?? false; + final iconWidget = Opacity(opacity: 0.6, child: icon); + return isReference + ? Stack( + children: [ + iconWidget, + const Positioned( + right: 0, + bottom: 0, + child: FlowySvg( + FlowySvgs.referenced_page_s, + blendMode: BlendMode.dstIn, + ), + ), + ], + ) + : iconWidget; + } } enum TabBarViewAction implements ActionCell { rename, + changeIcon, delete; @override @@ -268,6 +360,8 @@ enum TabBarViewAction implements ActionCell { switch (this) { case TabBarViewAction.rename: return LocaleKeys.disclosureAction_rename.tr(); + case TabBarViewAction.changeIcon: + return LocaleKeys.disclosureAction_changeIcon.tr(); case TabBarViewAction.delete: return LocaleKeys.disclosureAction_delete.tr(); } @@ -277,6 +371,8 @@ enum TabBarViewAction implements ActionCell { switch (this) { case TabBarViewAction.rename: return const FlowySvg(FlowySvgs.edit_s); + case TabBarViewAction.changeIcon: + return const FlowySvg(FlowySvgs.change_icon_s); case TabBarViewAction.delete: return const FlowySvg(FlowySvgs.delete_s); } @@ -287,4 +383,9 @@ enum TabBarViewAction implements ActionCell { @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 index 55394ec33c..3a1fcac510 100644 --- 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 @@ -1,29 +1,22 @@ -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/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/setting/mobile_database_controls.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_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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../grid/presentation/grid_page.dart'; - -class MobileTabBarHeader extends StatefulWidget { +class MobileTabBarHeader extends StatelessWidget { const MobileTabBarHeader({super.key}); - @override - State createState() => _MobileTabBarHeaderState(); -} - -class _MobileTabBarHeaderState extends State { @override Widget build(BuildContext context) { return Padding( @@ -50,7 +43,16 @@ class _MobileTabBarHeaderState extends State { return MobileDatabaseControls( controller: state .tabBarControllerByViewId[currentView.viewId]!.controller, - toggleExtension: ToggleExtensionNotifier(), + features: switch (currentView.layout) { + ViewLayoutPB.Board || ViewLayoutPB.Calendar => [ + MobileDatabaseControlFeatures.filter, + ], + ViewLayoutPB.Grid => [ + MobileDatabaseControlFeatures.sort, + MobileDatabaseControlFeatures.filter, + ], + _ => [], + }, ); }, ), @@ -77,22 +79,22 @@ class _DatabaseViewSelectorButton extends StatelessWidget { return TextButton( style: ButtonStyle( - padding: const MaterialStatePropertyAll( + padding: const WidgetStatePropertyAll( EdgeInsets.fromLTRB(12, 8, 8, 8), ), - maximumSize: const MaterialStatePropertyAll(Size(200, 48)), - minimumSize: const MaterialStatePropertyAll(Size(48, 0)), - shape: const MaterialStatePropertyAll( + maximumSize: const WidgetStatePropertyAll(Size(200, 48)), + minimumSize: const WidgetStatePropertyAll(Size(48, 0)), + shape: const WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(12)), ), ), - backgroundColor: MaterialStatePropertyAll( + backgroundColor: WidgetStatePropertyAll( Theme.of(context).brightness == Brightness.light ? const Color(0x0F212729) : const Color(0x0FFFFFFF), ), - overlayColor: MaterialStatePropertyAll( + overlayColor: WidgetStatePropertyAll( Theme.of(context).colorScheme.secondary, ), ), @@ -103,8 +105,8 @@ class _DatabaseViewSelectorButton extends StatelessWidget { const HSpace(6), Flexible( child: FlowyText.medium( - tabBar.view.name, - fontSize: 13, + tabBar.view.nameOrDefault, + fontSize: 14, overflow: TextOverflow.ellipsis, ), ), @@ -119,7 +121,6 @@ class _DatabaseViewSelectorButton extends StatelessWidget { showTransitionMobileBottomSheet( context, showDivider: false, - initialStop: 1.0, builder: (_) { return MultiBlocProvider( providers: [ @@ -141,14 +142,16 @@ class _DatabaseViewSelectorButton extends StatelessWidget { } 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(), - ); + final iconData = view.icon.toEmojiIconData(); + if (iconData.isEmpty || iconData.type != FlowyIconType.icon) { + return SizedBox.square( + dimension: 16.0, + child: view.defaultIcon(), + ); + } + return RawEmojiIconWidget( + emoji: iconData, + emojiSize: 16, + ); } } 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 index d8aa978e12..7c2dc40869 100644 --- 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 @@ -1,23 +1,34 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; -import 'package:appflowy/plugins/database/widgets/share_button.dart'; -import 'package:appflowy/plugins/shared/sync_indicator.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/document/presentation/compact_mode_event.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/database/database_view_block_component.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/startup/startup.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/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.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'; @@ -59,11 +70,17 @@ class DatabaseTabBarView extends StatefulWidget { super.key, required this.view, required this.shrinkWrap, + required this.showActions, this.initialRowId, + this.actionBuilder, + this.node, }); final ViewPB view; final bool shrinkWrap; + final BlockComponentActionBuilder? actionBuilder; + final bool showActions; + final Node? node; /// Used to open a Row on plugin load /// @@ -74,105 +91,223 @@ class DatabaseTabBarView extends StatefulWidget { } class _DatabaseTabBarViewState extends State { - final PageController _pageController = PageController(); - late String? _initialRowId = widget.initialRowId; + bool enableCompactMode = false; + bool initialed = false; + StreamSubscription? compactModeSubscription; + + String get compactModeId => widget.node?.id ?? widget.view.id; + + @override + void initState() { + super.initState(); + if (widget.node != null) { + enableCompactMode = + widget.node!.attributes[DatabaseBlockKeys.enableCompactMode] ?? false; + setState(() { + initialed = true; + }); + } else { + fetchLocalCompactMode(compactModeId).then((v) { + if (mounted) { + setState(() { + enableCompactMode = v; + initialed = true; + }); + } + }); + compactModeSubscription = + compactModeEventBus.on().listen((event) { + if (event.id != widget.view.id) return; + updateLocalCompactMode(event.enable); + }); + } + } @override void dispose() { - _pageController.dispose(); super.dispose(); + compactModeSubscription?.cancel(); } @override Widget build(BuildContext context) { + if (!initialed) return Center(child: CircularProgressIndicator()); return MultiBlocProvider( providers: [ BlocProvider( - create: (context) => DatabaseTabBarBloc(view: widget.view) - ..add(const DatabaseTabBarEvent.initial()), + create: (_) => DatabaseTabBarBloc( + view: widget.view, + compactModeId: compactModeId, + enableCompactMode: enableCompactMode, + )..add(const DatabaseTabBarEvent.initial()), ), BlocProvider( - create: (context) => - ViewBloc(view: widget.view)..add(const ViewEvent.initial()), + create: (_) => ViewBloc(view: widget.view) + ..add( + const ViewEvent.initial(), + ), ), ], - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => p.selectedIndex != c.selectedIndex, - listener: (context, state) { - _initialRowId = null; - _pageController.jumpToPage(state.selectedIndex); - }, - ), - ], - child: Column( - children: [ - if (PlatformExtension.isMobile) const VSpace(12), - BlocBuilder( - builder: (context, state) { - return ValueListenableBuilder( - valueListenable: state - .tabBarControllerByViewId[state.parentView.id]! - .controller - .isLoading, - builder: (_, value, ___) { - if (value) { - return const SizedBox.shrink(); - } + child: BlocBuilder( + builder: (innerContext, state) { + final layout = state.tabBars[state.selectedIndex].layout; + final isCalendar = layout == ViewLayoutPB.Calendar; + final horizontalPadding = + context.read().horizontalPadding; + final showActionWrapper = widget.showActions && + widget.actionBuilder != null && + widget.node != null; + final Widget child = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + 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 PlatformExtension.isDesktop - ? const TabBarHeader() - : const MobileTabBarHeader(); - }, - ); - }, - ), - BlocBuilder( - builder: (context, state) => - pageSettingBarExtensionFromState(state), - ), - Expanded( - child: BlocBuilder( - builder: (context, state) => PageView( - pageSnapping: false, - physics: const NeverScrollableScrollPhysics(), - controller: _pageController, - children: pageContentFromState(state), + Widget child = UniversalPlatform.isDesktop + ? const TabBarHeader() + : const MobileTabBarHeader(); + + if (innerContext.watch().state.view.isLocked) { + child = IgnorePointer( + child: child, + ); + } + + if (showActionWrapper) { + child = BlockComponentActionWrapper( + node: widget.node!, + actionBuilder: widget.actionBuilder!, + child: Padding( + padding: EdgeInsets.only(right: horizontalPadding), + child: child, + ), + ); + } + + if (UniversalPlatform.isDesktop) { + child = Container( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + child: child, + ); + } + + return child; + }, + ), + pageSettingBarExtensionFromState(context, state), + wrapContent( + layout: layout, + child: Padding( + padding: + (isCalendar && widget.shrinkWrap || showActionWrapper) + ? EdgeInsets.only(left: 42 - horizontalPadding) + : EdgeInsets.zero, + child: pageContentFromState(context, state), ), ), - ), - ], - ), + ], + ); + + return child; + }, ), ); } - List pageContentFromState(DatabaseTabBarState state) { - return state.tabBars.map((tabBar) { - final controller = - state.tabBarControllerByViewId[tabBar.viewId]!.controller; - - return tabBar.builder.content( - context, - tabBar.view, - controller, - widget.shrinkWrap, - _initialRowId, + Future fetchLocalCompactMode(String compactModeId) async { + Set compactModeIds = {}; + try { + final localIds = await getIt().get( + KVKeys.compactModeIds, ); - }).toList(); + final List decodedList = jsonDecode(localIds ?? ''); + compactModeIds = Set.from(decodedList.map((item) => item as String)); + } catch (e) { + Log.warn('fetch local compact mode from id :$compactModeId failed', e); + } + return compactModeIds.contains(compactModeId); } - Widget pageSettingBarExtensionFromState(DatabaseTabBarState state) { + Future updateLocalCompactMode(bool enableCompactMode) async { + Set compactModeIds = {}; + try { + final localIds = await getIt().get( + KVKeys.compactModeIds, + ); + final List decodedList = jsonDecode(localIds ?? ''); + compactModeIds = Set.from(decodedList.map((item) => item as String)); + } catch (e) { + Log.warn('get compact mode ids failed', e); + } + if (enableCompactMode) { + compactModeIds.add(compactModeId); + } else { + compactModeIds.remove(compactModeId); + } + await getIt().set( + KVKeys.compactModeIds, + jsonEncode(compactModeIds.toList()), + ); + } + + Widget wrapContent({required ViewLayoutPB layout, required Widget child}) { + if (widget.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, + widget.shrinkWrap, + widget.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 tabBar.builder.settingBarExtension( - context, - controller, + return Padding( + padding: EdgeInsets.symmetric( + horizontal: + context.read().horizontalPadding, + ), + child: tabBar.builder.settingBarExtension( + context, + controller, + ), ); } } @@ -221,6 +356,21 @@ class DatabaseTabBarViewPlugin extends Plugin { } } +const kDatabasePluginWidgetBuilderHorizontalPadding = 'horizontal_padding'; +const kDatabasePluginWidgetBuilderShowActions = 'show_actions'; +const kDatabasePluginWidgetBuilderActionBuilder = 'action_builder'; +const kDatabasePluginWidgetBuilderNode = 'node'; + +class DatabasePluginWidgetBuilderSize { + const DatabasePluginWidgetBuilderSize({ + required this.horizontalPadding, + this.verticalPadding = 16.0, + }); + + final double horizontalPadding; + final double verticalPadding; +} + class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { DatabasePluginWidgetBuilder({ required this.bloc, @@ -236,25 +386,51 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { final String? initialRowId; @override - Widget get leftBarItem => ViewTitleBar(view: notifier.view); + String? get viewName => notifier.view.nameOrDefault; @override - Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view); + Widget get leftBarItem => + ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view); @override - Widget buildWidget({PluginContext? context, required bool shrinkWrap}) { + 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(notifier.view, deletedView.index); + context.onDeleted?.call(notifier.view, deletedView.index); } }); - return DatabaseTabBarView( - key: ValueKey(notifier.view.id), - view: notifier.view, - shrinkWrap: shrinkWrap, - initialRowId: initialRowId, + final horizontalPadding = + data?[kDatabasePluginWidgetBuilderHorizontalPadding] as double? ?? + GridSize.horizontalHeaderPadding + 40; + final BlockComponentActionBuilder? actionBuilder = + data?[kDatabasePluginWidgetBuilderActionBuilder]; + final bool showActions = + data?[kDatabasePluginWidgetBuilderShowActions] ?? false; + final Node? node = data?[kDatabasePluginWidgetBuilderNode]; + + return Provider( + create: (context) => DatabasePluginWidgetBuilderSize( + horizontalPadding: horizontalPadding, + ), + child: DatabaseTabBarView( + key: ValueKey(notifier.view.id), + view: notifier.view, + shrinkWrap: shrinkWrap, + initialRowId: initialRowId, + actionBuilder: actionBuilder, + showActions: showActions, + node: node, + ), ); } @@ -268,20 +444,11 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { value: bloc, child: Row( children: [ - ...FeatureFlag.syncDatabase.isOn - ? [ - DatabaseSyncIndicator( - key: ValueKey('sync_state_${view.id}'), - view: view, - ), - const HSpace(16), - ] - : [], - DatabaseShareButton(key: ValueKey(view.id), view: view), - const HSpace(4), + ShareButton(key: ValueKey(view.id), view: view), + const HSpace(10), ViewFavoriteButton(view: view), const HSpace(4), - MoreViewActions(view: view, isDocument: false), + MoreViewActions(view: view), ], ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart index 2d3f956f14..68c4b15d5c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart @@ -1,21 +1,26 @@ 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/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/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/row/action.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.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:appflowy_popover/appflowy_popover.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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; -import 'card_bloc.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'; @@ -29,12 +34,15 @@ class RowCard extends StatefulWidget { required this.isEditing, required this.rowCache, required this.cellBuilder, - required this.openCard, + 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; @@ -50,7 +58,9 @@ class RowCard extends StatefulWidget { final CardCellBuilder cellBuilder; /// Called when the user taps on the card. - final void Function(BuildContext) openCard; + final void Function(BuildContext context) onTap; + + final void Function(BuildContext context)? onShiftTap; /// Called when the user starts editing the card. final VoidCallback onStartEditing; @@ -60,6 +70,14 @@ class RowCard extends StatefulWidget { 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(); } @@ -67,36 +85,35 @@ class RowCard extends StatefulWidget { class _RowCardState extends State { final popoverController = PopoverController(); late final CardBloc _cardBloc; - late final EditableRowNotifier rowNotifier; @override void initState() { super.initState(); - rowNotifier = EditableRowNotifier(isEditing: widget.isEditing); + 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, - rowMeta: widget.rowMeta, - rowCache: widget.rowCache, + rowController: rowController, )..add(const CardEvent.initial()); + } - rowNotifier.isEditing.addListener(() { - if (!mounted) return; - _cardBloc.add(CardEvent.setIsEditing(rowNotifier.isEditing.value)); - - if (rowNotifier.isEditing.value) { - widget.onStartEditing(); - } else { - widget.onEndEditing(); - } - }); + @override + void didUpdateWidget(covariant oldWidget) { + if (widget.isEditing != _cardBloc.state.isEditing) { + _cardBloc.add(CardEvent.setIsEditing(widget.isEditing)); + } + super.didUpdateWidget(oldWidget); } @override void dispose() { - rowNotifier.dispose(); _cardBloc.close(); super.dispose(); } @@ -105,31 +122,42 @@ class _RowCardState extends State { Widget build(BuildContext context) { return BlocProvider.value( value: _cardBloc, - child: BlocBuilder( - builder: (context, state) => - PlatformExtension.isMobile ? _mobile(state) : _desktop(state), + child: BlocListener( + listenWhen: (previous, current) => + previous.isEditing != current.isEditing, + listener: (context, state) { + if (!state.isEditing) { + widget.onEndEditing(); + } + }, + child: UniversalPlatform.isMobile ? _mobile() : _desktop(), ), ); } - Widget _mobile(CardState state) { - return GestureDetector( - onTap: () => widget.openCard(context), - behavior: HitTestBehavior.opaque, - child: MobileCardContent( - rowMeta: state.rowMeta, - cellBuilder: widget.cellBuilder, - styleConfiguration: widget.styleConfiguration, - cells: state.cells, - ), + 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(CardState state) { + Widget _desktop() { final accessories = widget.styleConfiguration.showAccessory - ? [ - EditCardAccessory(rowNotifier: rowNotifier), - const MoreCardOptionsAccessory(), + ? const [ + EditCardAccessory(), + MoreCardOptionsAccessory(), ] : null; return AppFlowyPopover( @@ -137,25 +165,34 @@ class _RowCardState extends State { triggerActions: PopoverTriggerFlags.none, constraints: BoxConstraints.loose(const Size(140, 200)), direction: PopoverDirection.rightWithCenterAligned, - popupBuilder: (_) { - return RowActionMenu.board( - viewId: _cardBloc.viewId, - rowId: _cardBloc.rowId, - groupId: widget.groupId, - ); - }, - child: RowCardContainer( - buildAccessoryWhen: () => state.isEditing == false, - accessories: accessories ?? [], - openAccessory: _handleOpenAccessory, - openCard: widget.openCard, - child: _CardContent( - rowMeta: state.rowMeta, - rowNotifier: rowNotifier, - cellBuilder: widget.cellBuilder, - styleConfiguration: widget.styleConfiguration, - cells: state.cells, - ), + 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, + ); + }, + ), + ); + }, ), ); } @@ -163,6 +200,7 @@ class _RowCardState extends State { void _handleOpenAccessory(AccessoryType newAccessoryType) { switch (newAccessoryType) { case AccessoryType.edit: + widget.onStartEditing(); break; case AccessoryType.more: popoverController.show(); @@ -174,32 +212,44 @@ class _RowCardState extends State { class _CardContent extends StatelessWidget { const _CardContent({ required this.rowMeta, - required this.rowNotifier, required this.cellBuilder, required this.cells, required this.styleConfiguration, + this.userProfile, + this.isCompact = false, }); final RowMetaPB rowMeta; - final EditableRowNotifier rowNotifier; final CardCellBuilder cellBuilder; - final List cells; + final List cells; final RowCardStyleConfiguration styleConfiguration; + final UserProfilePB? userProfile; + final bool isCompact; @override Widget build(BuildContext context) { - final child = Padding( - padding: styleConfiguration.cardPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - children: _makeCells(context, rowMeta, cells), - ), + 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: () => !rowNotifier.isEditing.value, + buildWhenOnHover: () => !context.read().state.isEditing, child: child, ); } @@ -207,30 +257,187 @@ class _CardContent extends StatelessWidget { List _makeCells( BuildContext context, RowMetaPB rowMeta, - List cells, + List cells, ) { - // Remove all the cell listeners. - rowNotifier.unbind(); - - return cells.mapIndexed((int index, CellContext cellContext) { - EditableCardNotifier? cellNotifier; - - if (index == 0) { - cellNotifier = - EditableCardNotifier(isEditing: rowNotifier.isEditing.value); - rowNotifier.bindCell(cellContext, cellNotifier); - } - - return cellBuilder.build( - cellContext: cellContext, - cellNotifier: cellNotifier, - styleMap: styleConfiguration.cellStyleMap, - hasNotes: !rowMeta.isDocumentEmpty, - ); - }).toList(); + 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}); @@ -249,34 +456,11 @@ class MoreCardOptionsAccessory extends StatelessWidget with CardAccessory { AccessoryType get type => AccessoryType.more; } -class EditCardAccessory extends StatelessWidget with CardAccessory { - const EditCardAccessory({super.key, required this.rowNotifier}); - - final EditableRowNotifier rowNotifier; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(3.0), - child: FlowySvg( - FlowySvgs.edit_s, - color: Theme.of(context).hintColor, - ), - ); - } - - @override - void onTap(BuildContext context) => rowNotifier.becomeFirstResponder(); - - @override - AccessoryType get type => AccessoryType.edit; -} - class RowCardStyleConfiguration { const RowCardStyleConfiguration({ required this.cellStyleMap, this.showAccessory = true, - this.cardPadding = const EdgeInsets.all(8), + this.cardPadding = const EdgeInsets.all(4), this.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 index 3cb47d9f43..04f9bb652c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart @@ -1,13 +1,14 @@ 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/domain/row_listener.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.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'; @@ -18,42 +19,36 @@ class CardBloc extends Bloc { required this.fieldController, required this.groupFieldId, required this.viewId, - required RowMetaPB rowMeta, - required RowCache rowCache, required bool isEditing, - }) : rowId = rowMeta.id, - _rowListener = RowListener(rowMeta.id), - _rowCache = rowCache, - super( + required this.rowController, + }) : super( CardState.initial( - rowMeta, _makeCells( fieldController, groupFieldId, - rowCache.loadCells(rowMeta), + rowController, ), isEditing, + rowController.rowMeta, ), ) { + rowController.initialize(); _dispatch(); } final FieldController fieldController; - final String rowId; final String? groupFieldId; - final RowCache _rowCache; final String viewId; - final RowListener _rowListener; + final RowController rowController; VoidCallback? _rowCallback; @override Future close() async { if (_rowCallback != null) { - _rowCache.removeRowListener(_rowCallback!); _rowCallback = null; } - await _rowListener.stop(); + await rowController.dispose(); return super.close(); } @@ -73,7 +68,9 @@ class CardBloc extends Bloc { ); }, setIsEditing: (bool isEditing) { - emit(state.copyWith(isEditing: isEditing)); + if (isEditing != state.isEditing) { + emit(state.copyWith(isEditing: isEditing)); + } }, didUpdateRowMeta: (rowMeta) { emit(state.copyWith(rowMeta: rowMeta)); @@ -84,39 +81,46 @@ class CardBloc extends Bloc { } Future _startListening() async { - _rowCallback = _rowCache.addListener( - rowId: rowId, + rowController.addListener( onRowChanged: (cellMap, reason) { if (!isClosed) { - final cells = _makeCells(fieldController, groupFieldId, cellMap); + final cells = + _makeCells(fieldController, groupFieldId, rowController); add(CardEvent.didReceiveCells(cells, reason)); } }, - ); - - _rowListener.start( - onMetaChanged: (rowMeta) { + onMetaChanged: () { if (!isClosed) { - add(CardEvent.didUpdateRowMeta(rowMeta)); + add(CardEvent.didUpdateRowMeta(rowController.rowMeta)); } }, ); } } -List _makeCells( +List _makeCells( FieldController fieldController, String? groupFieldId, - List cellContexts, + RowController rowController, ) { // Only show the non-hidden cells and cells that aren't of the grouping field - cellContexts.removeWhere((cellContext) { + 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 cellContexts.toList(); + return cellContext + .map( + (cellCtx) => CellMeta( + fieldId: cellCtx.fieldId, + rowId: cellCtx.rowId, + fieldType: fieldController.getField(cellCtx.fieldId)!.fieldType, + ), + ) + .toList(); } @freezed @@ -124,30 +128,43 @@ class CardEvent with _$CardEvent { const factory CardEvent.initial() = _InitialRow; const factory CardEvent.setIsEditing(bool isEditing) = _IsEditing; const factory CardEvent.didReceiveCells( - List cells, + 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 RowMetaPB rowMeta, + required List cells, required bool isEditing, + required RowMetaPB rowMeta, ChangedReason? changeReason, }) = _RowCardState; factory CardState.initial( - RowMetaPB rowMeta, - List cells, + List cells, bool isEditing, + RowMetaPB rowMeta, ) => CardState( cells: cells, - rowMeta: rowMeta, 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 index 56045d57d6..e74f947b46 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/accessory.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/accessory.dart @@ -1,6 +1,5 @@ -import 'package:flutter/material.dart'; - import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; enum AccessoryType { edit, @@ -24,6 +23,10 @@ class CardAccessoryContainer extends StatelessWidget { @override Widget build(BuildContext context) { + if (accessories.isEmpty) { + return const SizedBox.shrink(); + } + final children = accessories.map((accessory) { return GestureDetector( behavior: HitTestBehavior.opaque, @@ -41,7 +44,7 @@ class CardAccessoryContainer extends StatelessWidget { width: 1, thickness: 1, color: Theme.of(context).brightness == Brightness.light - ? const Color(0xFF1F2329).withOpacity(0.12) + ? const Color(0xFF1F2329).withValues(alpha: 0.12) : const Color(0xff59647a), ), ); @@ -73,19 +76,19 @@ class CardAccessoryContainer extends StatelessWidget { border: Border.fromBorderSide( BorderSide( color: Theme.of(context).brightness == Brightness.light - ? const Color(0xFF1F2329).withOpacity(0.12) + ? const Color(0xFF1F2329).withValues(alpha: 0.12) : const Color(0xff59647a), ), ), boxShadow: [ BoxShadow( blurRadius: 4, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), BoxShadow( blurRadius: 4, spreadRadius: -2, - color: const Color(0xFF1F2329).withOpacity(0.02), + color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), ], ); 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 index 0cb0988bac..a91ffae42d 100644 --- 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 @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'accessory.dart'; @@ -7,14 +8,16 @@ class RowCardContainer extends StatelessWidget { const RowCardContainer({ super.key, required this.child, - required this.openCard, + required this.onTap, required this.openAccessory, required this.accessories, this.buildAccessoryWhen, + this.onShiftTap, }); final Widget child; - final void Function(BuildContext) openCard; + final void Function(BuildContext) onTap; + final void Function(BuildContext)? onShiftTap; final void Function(AccessoryType) openAccessory; final List accessories; final bool Function()? buildAccessoryWhen; @@ -25,26 +28,25 @@ class RowCardContainer extends StatelessWidget { create: (_) => _CardContainerNotifier(), child: Consumer<_CardContainerNotifier>( builder: (context, notifier, _) { - Widget container = Center(child: child); - bool shouldBuildAccessory = true; - if (buildAccessoryWhen != null) { - shouldBuildAccessory = buildAccessoryWhen!.call(); - } - - if (shouldBuildAccessory && accessories.isNotEmpty) { - container = _CardEnterRegion( - accessories: accessories, - onTapAccessory: openAccessory, - child: container, - ); - } + final shouldBuildAccessory = buildAccessoryWhen?.call() ?? true; return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => openCard(context), + onTap: () { + if (HardwareKeyboard.instance.isShiftPressed) { + onShiftTap?.call(context); + } else { + onTap(context); + } + }, child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 30), - child: container, + constraints: const BoxConstraints(minHeight: 36), + child: _CardEnterRegion( + shouldBuildAccessory: shouldBuildAccessory, + accessories: accessories, + onTapAccessory: openAccessory, + child: child, + ), ), ); }, @@ -55,11 +57,13 @@ class RowCardContainer extends StatelessWidget { 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; @@ -69,19 +73,18 @@ class _CardEnterRegion extends StatelessWidget { return Selector<_CardContainerNotifier, bool>( selector: (context, notifier) => notifier.onEnter, builder: (context, onEnter, _) { - final List children = [child]; - if (onEnter) { - children.add( + final List children = [ + child, + if (onEnter && shouldBuildAccessory) Positioned( - top: 10.0, - right: 10.0, + top: 7.0, + right: 7.0, child: CardAccessoryContainer( accessories: accessories, onTapAccessory: onTapAccessory, ), ), - ); - } + ]; return MouseRegion( cursor: SystemMouseCursors.click, 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 index a002f73f88..d17c522de6 100644 --- 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 @@ -1,18 +1,22 @@ +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/card_cell_skeleton/relation_card_cell.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:flutter/widgets.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; @@ -98,6 +102,24 @@ class CardCellBuilder { 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 index 4351e7dd2c..7b03539252 100644 --- 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 @@ -1,4 +1,3 @@ -import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:flutter/material.dart'; abstract class CardCell extends StatefulWidget { @@ -32,61 +31,6 @@ class EditableCardNotifier { } } -class EditableRowNotifier { - EditableRowNotifier({required bool isEditing}) - : isEditing = ValueNotifier(isEditing); - - final Map _cells = {}; - final ValueNotifier isEditing; - - void bindCell( - CellContext cellIdentifier, - EditableCardNotifier notifier, - ) { - assert( - _cells.values.isEmpty, - 'Only one cell can receive the notification', - ); - _cells[cellIdentifier]?.dispose(); - - notifier.isCellEditing.addListener(() { - isEditing.value = notifier.isCellEditing.value; - }); - - _cells[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() { - unbind(); - isEditing.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 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 index 3b47971fdb..c459d8cc60 100644 --- 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 @@ -1,10 +1,13 @@ +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 { @@ -44,18 +47,33 @@ class _DateCellState extends State { ); }, child: BlocBuilder( - buildWhen: (previous, current) => previous.dateStr != current.dateStr, builder: (context, state) { - if (state.dateStr.isEmpty) { + final dateStr = getDateCellStrFromCellData( + state.fieldInfo, + state.cellData, + ); + + if (dateStr.isEmpty) { return const SizedBox.shrink(); } return Container( alignment: Alignment.centerLeft, padding: widget.style.padding, - child: Text( - state.dateStr, - style: widget.style.textStyle, + 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 0000000000..969f80d17b --- /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/text_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart index b7aae4dd58..a3758029d1 100644 --- 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 @@ -1,13 +1,15 @@ +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:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_builder.dart'; @@ -54,33 +56,37 @@ class _TextCellState extends State { widget.cellContext, ).as(), ); - late final TextEditingController _textEditingController = - TextEditingController(text: cellBloc.state.content); + late final TextEditingController _textEditingController; final focusNode = SingleListenerFocusNode(); - bool focusWhenInit = false; - @override void initState() { super.initState(); - focusWhenInit = widget.editableNotifier?.isCellEditing.value ?? false; - if (focusWhenInit) { - focusNode.requestFocus(); + _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(() { - if (!focusNode.hasFocus) { - focusWhenInit = false; - widget.editableNotifier?.isCellEditing.value = false; - cellBloc.add(const TextCellEvent.enableEdit(false)); - } - }); + 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) { @@ -89,9 +95,8 @@ class _TextCellState extends State { final isEditing = widget.editableNotifier?.isCellEditing.value ?? false; if (isEditing) { - WidgetsBinding.instance.addPostFrameCallback((_) { - focusNode.requestFocus(); - }); + WidgetsBinding.instance + .addPostFrameCallback((_) => focusNode.requestFocus()); } cellBloc.add(TextCellEvent.enableEdit(isEditing)); }); @@ -99,52 +104,23 @@ class _TextCellState extends State { @override void didUpdateWidget(covariant oldWidget) { - _bindEditableNotifier(); + 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: BlocConsumer( - listenWhen: (previous, current) => - previous.content != current.content && !current.enableEdit, + child: BlocListener( + listenWhen: (previous, current) => previous.content != current.content, listener: (context, state) { - _textEditingController.text = state.content; - }, - buildWhen: (previous, current) { - if (previous.content != current.content && - _textEditingController.text == current.content) { - return false; - } - - return previous != current; - }, - builder: (context, state) { - final isTitle = cellBloc.cellController.fieldInfo.isPrimary; - if (state.content.isEmpty && - state.enableEdit == false && - focusWhenInit == false && - !isTitle) { - return const SizedBox.shrink(); - } - - final icon = isTitle ? _buildIcon(state) : null; - final child = isTitle - ? _buildTextField(state.enableEdit || focusWhenInit) - : _buildText(state.content); - - return Row( - children: [ - if (icon != null) ...[ - icon, - const HSpace(4.0), - ], - Expanded(child: child), - ], - ); + _textEditingController.text = state.content ?? ""; }, + child: isTitle ? _buildTitle() : _buildText(), ), ); } @@ -152,77 +128,149 @@ class _TextCellState extends State { @override void dispose() { _textEditingController.dispose(); + widget.editableNotifier?.isCellEditing + .removeListener(_bindEditableNotifier); focusNode.dispose(); cellBloc.close(); super.dispose(); } Widget? _buildIcon(TextCellState state) { - if (state.emoji.isNotEmpty) { - return Text( - state.emoji, - style: widget.style.titleTextStyle, + 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: FlowySvg( - FlowySvgs.notes_s, - color: Theme.of(context).hintColor, + child: Padding( + padding: const EdgeInsets.all(1.0), + child: FlowySvg( + FlowySvgs.notes_s, + color: Theme.of(context).hintColor, + ), ), ); } return null; } - Widget _buildText(String content) { - final text = - content.isEmpty ? LocaleKeys.grid_row_textPlaceholder.tr() : content; - final color = content.isEmpty ? Theme.of(context).hintColor : null; + Widget _buildText() { + return BlocBuilder( + builder: (context, state) { + final content = state.content ?? ""; - return Padding( - padding: widget.style.padding, - child: Text( - text, - style: widget.style.textStyle.copyWith(color: color), - maxLines: widget.style.maxLines, - ), + 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 _buildTextField(bool isEditing) { - final padding = - widget.style.padding.add(const EdgeInsets.symmetric(vertical: 4.0)); - return IgnorePointer( - ignoring: !isEditing, - child: TextField( - controller: _textEditingController, - focusNode: focusNode, - onChanged: (_) { - if (_textEditingController.value.composing.isCollapsed) { - cellBloc.add(TextCellEvent.updateText(_textEditingController.text)); - } - }, - onEditingComplete: () => focusNode.unfocus(), - maxLines: isEditing ? null : 2, - minLines: 1, - textInputAction: TextInputAction.done, - readOnly: !isEditing, - enableInteractiveSelection: isEditing, - style: widget.style.titleTextStyle, - decoration: InputDecoration( - contentPadding: 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, + 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 0000000000..68a95e53e2 --- /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/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 0000000000..e9c233af18 --- /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_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 index 431ac44029..23a8a2451f 100644 --- 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 @@ -1,16 +1,19 @@ -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; 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) { @@ -84,5 +87,13 @@ CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) { 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 index ebe1537cbb..6264fea958 100644 --- 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 @@ -1,16 +1,20 @@ -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; 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) { @@ -18,7 +22,6 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) { final TextStyle textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: 11, overflow: TextOverflow.ellipsis, - fontWeight: FontWeight.w400, ); return { @@ -84,5 +87,17 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) { 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 index df162abcba..93d98f013e 100644 --- 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 @@ -1,7 +1,10 @@ -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; 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'; @@ -10,6 +13,7 @@ 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'; @@ -83,5 +87,17 @@ CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) { 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 index befe49dd6d..74abcecb3a 100644 --- 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 @@ -1,7 +1,7 @@ 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/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'; @@ -12,22 +12,31 @@ class DesktopGridCheckboxCellSkin extends IEditableCheckboxCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, 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, - ), + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Container( + alignment: AlignmentDirectional.centerStart, + padding: padding, + 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 index 285b0217eb..ebc4a6f976 100644 --- 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 @@ -1,12 +1,10 @@ -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: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 '../editable_cell_skeleton/checklist.dart'; @@ -16,8 +14,8 @@ class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, - ChecklistCellState state, PopoverController popoverController, ) { return AppFlowyPopover( @@ -27,7 +25,7 @@ class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin { direction: PopoverDirection.bottomWithLeftAligned, triggerActions: PopoverTriggerFlags.none, skipTraversal: true, - popupBuilder: (BuildContext popoverContext) { + popupBuilder: (popoverContext) { WidgetsBinding.instance.addPostFrameCallback((_) { cellContainerNotifier.isFocus = true; }); @@ -39,15 +37,28 @@ class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin { ); }, onClose: () => cellContainerNotifier.isFocus = false, - child: Container( - alignment: AlignmentDirectional.centerStart, - padding: GridSize.cellContentInsets, - child: state.tasks.isEmpty - ? const SizedBox.shrink() - : ChecklistProgressBar( - tasks: state.tasks, - percent: state.percent, - ), + child: BlocBuilder( + builder: (context, state) { + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + + return Container( + alignment: AlignmentDirectional.centerStart, + padding: padding, + 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 index 537207c12c..de7f7f5a2e 100644 --- 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 @@ -1,13 +1,11 @@ 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_editor.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/date_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_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/widgets.dart'; import '../editable_cell_skeleton/date.dart'; @@ -17,6 +15,7 @@ class DesktopGridDateCellSkin extends IEditableDateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, @@ -30,11 +29,11 @@ class DesktopGridDateCellSkin extends IEditableDateCellSkin { child: Align( alignment: AlignmentDirectional.centerStart, child: state.fieldInfo.wrapCellContent ?? false - ? _buildCellContent(state) + ? _buildCellContent(state, compactModeNotifier) : SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, - child: _buildCellContent(state), + child: _buildCellContent(state, compactModeNotifier), ), ), popupBuilder: (BuildContext popoverContent) { @@ -49,29 +48,44 @@ class DesktopGridDateCellSkin extends IEditableDateCellSkin { ); } - Widget _buildCellContent(DateCellState state) { + Widget _buildCellContent( + DateCellState state, + ValueNotifier compactModeNotifier, + ) { final wrap = state.fieldInfo.wrapCellContent ?? false; - return Padding( - padding: GridSize.cellContentInsets, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: FlowyText.medium( - state.dateStr, - overflow: wrap ? null : TextOverflow.ellipsis, - maxLines: wrap ? null : 1, - ), + final dateStr = getDateCellStrFromCellData( + state.fieldInfo, + state.cellData, + ); + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Padding( + padding: padding, + 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), + ), + ], + ], ), - if (state.data?.reminderId.isNotEmpty ?? false) ...[ - 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 0000000000..b070af7cc7 --- /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.symmetric(horizontal: 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.symmetric(horizontal: 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 index 04368bc725..7a6f3e63bc 100644 --- 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 @@ -1,6 +1,6 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/number_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:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,27 +11,37 @@ class DesktopGridNumberCellSkin extends IEditableNumberCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, 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, - ), + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + + 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: padding, + 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 index 80649f6a02..dda3183b59 100644 --- 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 @@ -1,10 +1,10 @@ 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/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.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:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -17,10 +17,12 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, ) { + final userWorkspaceBloc = context.read(); return AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, @@ -28,16 +30,24 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { margin: EdgeInsets.zero, onClose: () => cellContainerNotifier.isFocus = false, popupBuilder: (context) { - return BlocProvider.value( - value: bloc, + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: userWorkspaceBloc), + BlocProvider.value(value: bloc), + ], child: const RelationCellEditor(), ); }, child: Align( alignment: AlignmentDirectional.centerStart, - child: state.wrap - ? _buildWrapRows(context, state.rows) - : _buildNoWrapRows(context, state.rows), + child: ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + return state.wrap + ? _buildWrapRows(context, state.rows, compactMode) + : _buildNoWrapRows(context, state.rows, compactMode); + }, + ), ), ); } @@ -45,16 +55,19 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { Widget _buildWrapRows( BuildContext context, List rows, + bool compactMode, ) { return Padding( - padding: GridSize.cellContentInsets, + padding: compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets, child: Wrap( runSpacing: 4, spacing: 4.0, children: rows.map( (row) { final isEmpty = row.name.isEmpty; - return FlowyText.medium( + return FlowyText( isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, color: isEmpty ? Theme.of(context).hintColor : null, decoration: TextDecoration.underline, @@ -69,6 +82,7 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { Widget _buildNoWrapRows( BuildContext context, List rows, + bool compactMode, ) { return SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), @@ -81,7 +95,7 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { children: rows.map( (row) { final isEmpty = row.name.isEmpty; - return FlowyText.medium( + return FlowyText( isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, color: isEmpty ? Theme.of(context).hintColor : null, decoration: TextDecoration.underline, 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 index 45b43efcec..b599acc4f1 100644 --- 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 @@ -1,10 +1,9 @@ -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/extension.dart'; 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:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -16,6 +15,7 @@ class DesktopGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { @@ -36,63 +36,92 @@ class DesktopGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin { return Align( alignment: AlignmentDirectional.centerStart, child: state.wrap - ? _buildWrapOptions(context, state.selectedOptions) - : _buildNoWrapOptions(context, state.selectedOptions), + ? _buildWrapOptions( + context, + state.selectedOptions, + compactModeNotifier, + ) + : _buildNoWrapOptions( + context, + state.selectedOptions, + compactModeNotifier, + ), ); }, ), ); } - 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: 1, - horizontal: 8, - ), - ), - ); - }, - ).toList(), - ), + Widget _buildWrapOptions( + BuildContext context, + List options, + ValueNotifier compactModeNotifier, + ) { + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Padding( + padding: padding, + child: Wrap( + runSpacing: 4, + children: options.map( + (option) { + return Padding( + padding: const EdgeInsets.only(right: 4), + child: SelectOptionTag( + option: option, + padding: EdgeInsets.symmetric( + vertical: compactMode ? 2 : 4, + horizontal: 8, + ), + ), + ); + }, + ).toList(), + ), + ); + }, ); } Widget _buildNoWrapOptions( BuildContext context, List options, + ValueNotifier compactModeNotifier, ) { 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(), - ), + child: ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Padding( + padding: padding, + 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 index 4ef64ced2f..1f3ded0109 100644 --- 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 @@ -11,6 +11,7 @@ class DesktopGridSummaryCellSkin extends IEditableSummaryCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -27,50 +28,69 @@ class DesktopGridSummaryCellSkin extends IEditableSummaryCellSkin { onExit: (p) => Provider.of(context, listen: false) .onEnter = false, - child: Stack( - children: [ - 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, + child: ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: compactMode + ? GridSize.headerHeight - 4 + : GridSize.headerHeight, ), - ), - Padding( - padding: EdgeInsets.symmetric( - horizontal: GridSize.cellVPadding, + 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: padding, + 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: compactMode ? 4 : 8), + ], ), - 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/desktop_grid/desktop_grid_text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_text_cell.dart index 8001590840..75c973d886 100644 --- 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 @@ -1,6 +1,7 @@ +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:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -12,53 +13,98 @@ class DesktopGridTextCellSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { - return Padding( - padding: GridSize.cellContentInsets, - child: Row( - children: [ - BlocBuilder( - buildWhen: (p, c) => p.emoji != c.emoji, - builder: (context, state) { - if (state.emoji.isEmpty) { - return const SizedBox.shrink(); - } - return Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FlowyText( - state.emoji, - fontSize: 16, - ), - const HSpace(6), - ], + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, data) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Padding( + padding: padding, + 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, + ), ), - ); - }, - ), - Expanded( - child: TextField( - controller: textEditingController, - focusNode: focusNode, - maxLines: context.watch().state.wrap ? null : 1, - style: Theme.of(context).textTheme.bodyMedium, - 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 0000000000..a948e92b03 --- /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 index d1b6131680..8a1fd92499 100644 --- 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 @@ -1,6 +1,6 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_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:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; @@ -11,29 +11,41 @@ class DesktopGridTimestampCellSkin extends IEditableTimestampCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { return Container( alignment: AlignmentDirectional.centerStart, child: state.wrap - ? _buildCellContent(state) + ? _buildCellContent(state, compactModeNotifier) : SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, - child: _buildCellContent(state), + child: _buildCellContent(state, compactModeNotifier), ), ); } - Widget _buildCellContent(TimestampCellState state) { - return Padding( - padding: GridSize.cellContentInsets, - child: FlowyText.medium( - state.dateStr, - overflow: state.wrap ? null : TextOverflow.ellipsis, - maxLines: state.wrap ? null : 1, - ), + Widget _buildCellContent( + TimestampCellState state, + ValueNotifier compactModeNotifier, + ) { + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return Padding( + padding: padding, + 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 0000000000..102b491f52 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart @@ -0,0 +1,114 @@ +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, + ValueNotifier compactModeNotifier, + TranslateCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return ChangeNotifierProvider( + create: (_) => TranslateMouseNotifier(), + builder: (context, child) { + return ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + + 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: compactMode + ? GridSize.headerHeight - 4 + : 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: padding, + 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: compactMode ? 4 : 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 index 8ccda95391..935716e686 100644 --- 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 @@ -8,6 +8,7 @@ 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'; @@ -20,6 +21,7 @@ class DesktopGridURLSkin extends IEditableURLCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -27,28 +29,36 @@ class DesktopGridURLSkin extends IEditableURLCellSkin { ) { 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, + builder: (context, wrap) => ValueListenableBuilder( + valueListenable: compactModeNotifier, + builder: (context, compactMode, _) { + final padding = compactMode + ? GridSize.compactCellContentInsets + : GridSize.cellContentInsets; + return 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: padding, + 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, ), - 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(), + onTapOutside: (_) => focusNode.unfocus(), + ); + }, ), ); } @@ -202,8 +212,14 @@ class _URLAccessoryIconContainer extends StatelessWidget { ), borderRadius: Corners.s6Border, ), - child: Center( - child: child, + 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 index bb15cd5d9f..1e56c5160e 100644 --- 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 @@ -1,6 +1,6 @@ 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:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class DesktopRowDetailCheckboxCellSkin extends IEditableCheckboxCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ) { 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 index 4a1f1f05bc..ab0533819a 100644 --- 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 @@ -1,16 +1,20 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; 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/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_popover/appflowy_popover.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: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 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; import '../editable_cell_skeleton/checklist.dart'; @@ -19,159 +23,423 @@ class DesktopRowDetailChecklistCellSkin extends IEditableChecklistCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, - ChecklistCellState state, PopoverController popoverController, ) { - return ChecklistItems( + return ChecklistRowDetailCell( context: context, cellContainerNotifier: cellContainerNotifier, bloc: bloc, - state: state, popoverController: popoverController, ); } } -class ChecklistItems extends StatefulWidget { - const ChecklistItems({ +class ChecklistRowDetailCell extends StatefulWidget { + const ChecklistRowDetailCell({ super.key, required this.context, required this.cellContainerNotifier, required this.bloc, - required this.state, required this.popoverController, }); final BuildContext context; final CellContainerNotifier cellContainerNotifier; final ChecklistCellBloc bloc; - final ChecklistCellState state; final PopoverController popoverController; @override - State createState() => _ChecklistItemsState(); + State createState() => _ChecklistRowDetailCellState(); } -class _ChecklistItemsState extends State { - bool showIncompleteOnly = false; +class _ChecklistRowDetailCellState extends State { + final phantomTextController = TextEditingController(); + + @override + void dispose() { + phantomTextController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - final tasks = [...widget.state.tasks]; - if (showIncompleteOnly) { - tasks.removeWhere((task) => task.isSelected); - } - final children = tasks - .mapIndexed( - (index, task) => Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: ChecklistItem( - key: ValueKey(task.data.id), - task: task, - autofocus: widget.state.newTask && index == tasks.length - 1, - onSubmitted: index == tasks.length - 1 - ? () => widget.bloc - .add(const ChecklistCellEvent.createNewTask("")) - : null, - ), - ), - ) - .toList(); return Align( alignment: AlignmentDirectional.centerStart, child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: ChecklistProgressBar( - tasks: widget.state.tasks, - percent: widget.state.percent, - ), - ), - const HSpace(6.0), - FlowyIconButton( - tooltipText: showIncompleteOnly - ? LocaleKeys.grid_checklist_showComplete.tr() - : LocaleKeys.grid_checklist_hideComplete.tr(), - width: 32, - iconColorOnHover: Theme.of(context).colorScheme.onSurface, - icon: FlowySvg( - showIncompleteOnly ? FlowySvgs.show_m : FlowySvgs.hide_m, - size: const Size.square(16), - ), - onPressed: () { - setState( - () => showIncompleteOnly = !showIncompleteOnly, - ); - }, - ), - ], - ), + ProgressAndHideCompleteButton( + onToggleHideComplete: () => context + .read() + .add(const ChecklistCellEvent.toggleShowIncompleteOnly()), ), const VSpace(2.0), - ...children, - ChecklistItemControl(cellNotifer: widget.cellContainerNotifier), + _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: MouseRegion( + cursor: UniversalPlatform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: IgnorePointer( + child: BlocProvider.value( + value: context.read(), + child: child, + ), + ), + ), + ), + 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}); + 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, _) => GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => context - .read() - .add(const ChecklistCellEvent.createNewTask("")), - 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, + 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, + ), ), - onPressed: () => context - .read() - .add( - const ChecklistCellEvent.createNewTask(""), - ), - child: FlowySvg( - FlowySvgs.add_s, - color: Theme.of(context).colorScheme.onPrimary, - ), - ), - const HSpace(12.0), - const Flexible(child: Center(child: Divider())), - ], - ), - ) - : const SizedBox.expand(), + 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 index 071ed0ab84..f1b5f14975 100644 --- 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 @@ -1,13 +1,11 @@ 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_editor.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/date_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_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; class DesktopRowDetailDateCellSkin extends IEditableDateCellSkin { @@ -15,14 +13,18 @@ class DesktopRowDetailDateCellSkin extends IEditableDateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, ) { - final text = state.dateStr.isEmpty - ? LocaleKeys.grid_row_textPlaceholder.tr() - : state.dateStr; - final color = state.dateStr.isEmpty ? Theme.of(context).hintColor : null; + 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, @@ -30,6 +32,7 @@ class DesktopRowDetailDateCellSkin extends IEditableDateCellSkin { 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), @@ -37,13 +40,13 @@ class DesktopRowDetailDateCellSkin extends IEditableDateCellSkin { mainAxisSize: MainAxisSize.min, children: [ Flexible( - child: FlowyText.medium( + child: FlowyText( text, color: color, overflow: TextOverflow.ellipsis, ), ), - if (state.data?.reminderId.isNotEmpty ?? false) ...[ + if (state.cellData.reminderId.isNotEmpty) ...[ const HSpace(4), FlowyTooltip( message: LocaleKeys.grid_field_reminderOnDateTooltip.tr(), 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 0000000000..6e648eb187 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart @@ -0,0 +1,749 @@ +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).withValues(alpha: .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.withValues(alpha: 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.withValues(alpha: 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 index 97f8f80569..e90fc85549 100644 --- 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 @@ -1,6 +1,6 @@ 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:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class DesktopRowDetailNumberCellSkin extends IEditableNumberCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, 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 index e481d33cd5..d760d3ac29 100644 --- 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 @@ -1,9 +1,9 @@ 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/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.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:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -16,19 +16,25 @@ class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, ) { + final userWorkspaceBloc = context.read(); 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, + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: userWorkspaceBloc), + BlocProvider.value(value: bloc), + ], child: const RelationCellEditor(), ); }, @@ -56,7 +62,7 @@ class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin { children: rows.map( (row) { final isEmpty = row.name.isEmpty; - return FlowyText.medium( + return FlowyText( isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, color: isEmpty ? Theme.of(context).hintColor : null, decoration: TextDecoration.underline, 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 index 0e8c6fdffa..ff84744c27 100644 --- 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 @@ -1,10 +1,9 @@ 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/extension.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:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -18,6 +17,7 @@ class DesktopRowDetailSelectOptionCellSkin Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { @@ -25,16 +25,14 @@ class DesktopRowDetailSelectOptionCellSkin controller: popoverController, constraints: const BoxConstraints.tightFor(width: 300), margin: EdgeInsets.zero, + asBarrier: true, + triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithLeftAligned, - popupBuilder: (BuildContext popoverContext) { - WidgetsBinding.instance.addPostFrameCallback((_) { - cellContainerNotifier.isFocus = true; - }); - return SelectOptionCellEditor( - cellController: bloc.cellController, - ); - }, onClose: () => cellContainerNotifier.isFocus = false, + onOpen: () => cellContainerNotifier.isFocus = true, + popupBuilder: (_) => SelectOptionCellEditor( + cellController: bloc.cellController, + ), child: BlocBuilder( builder: (context, state) { return Container( @@ -67,7 +65,7 @@ class DesktopRowDetailSelectOptionCellSkin return SelectOptionTag( option: option, padding: const EdgeInsets.symmetric( - vertical: 1, + vertical: 4, horizontal: 8, ), ); 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 index 424dda870c..30cd54832d 100644 --- 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 @@ -3,51 +3,65 @@ 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, + ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { - return Column( - children: [ - TextField( - 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: SummaryCellAccessory( - viewId: bloc.cellController.viewId, - fieldId: bloc.cellController.fieldId, - rowId: bloc.cellController.rowId, - ), + 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 index b1e10e4da3..9511c2f871 100644 --- 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 @@ -1,6 +1,6 @@ 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:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class DesktopRowDetailTextCellSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, 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 0000000000..ffd68933c9 --- /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 index ac0d3ea033..6fc534f313 100644 --- 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 @@ -1,5 +1,5 @@ -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/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; @@ -10,13 +10,14 @@ class DesktopRowDetailTimestampCellSkin extends IEditableTimestampCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { return Container( alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6.0), - child: FlowyText.medium( + 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 index 82b1055356..ee9d7e7300 100644 --- 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 @@ -1,10 +1,11 @@ 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/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'; @@ -13,31 +14,15 @@ class DesktopRowDetailURLSkin extends IEditableURLCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, URLCellDataNotifier cellDataNotifier, ) { - return TextField( + return LinkTextField( controller: textEditingController, focusNode: focusNode, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, - ), - 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, - ), ); } @@ -54,3 +39,76 @@ class DesktopRowDetailURLSkin extends IEditableURLCellSkin { ]; } } + +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 0000000000..a374417b3d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart @@ -0,0 +1,67 @@ +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, + ValueNotifier compactModeNotifier, + 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 index 66beb7c437..e7b5d0d79b 100755 --- 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 @@ -3,6 +3,8 @@ 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'; @@ -17,6 +19,7 @@ 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'; @@ -120,6 +123,25 @@ class EditableCellBuilder { 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(), }; } @@ -206,6 +228,19 @@ class EditableCellBuilder { 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(), }; } @@ -254,6 +289,7 @@ abstract class GridCellState extends State { @override void didUpdateWidget(covariant T oldWidget) { if (oldWidget != this) { + oldWidget.requestFocus.removeListener(onRequestFocus); widget.requestFocus.addListener(onRequestFocus); } super.didUpdateWidget(oldWidget); @@ -348,6 +384,12 @@ class SingleListenerFocusNode extends FocusNode { removeListener(_listener!); } } + + @override + void dispose() { + removeAllListener(); + super.dispose(); + } } class EditableCellSkinMap { @@ -361,6 +403,8 @@ class EditableCellSkinMap { this.textSkin, this.urlSkin, this.relationSkin, + this.timeSkin, + this.mediaSkin, }); final IEditableCheckboxCellSkin? checkboxSkin; @@ -372,6 +416,8 @@ class EditableCellSkinMap { final IEditableTextCellSkin? textSkin; final IEditableURLCellSkin? urlSkin; final IEditableRelationCellSkin? relationSkin; + final IEditableTimeCellSkin? timeSkin; + final IEditableMediaCellSkin? mediaSkin; bool has(FieldType fieldType) { return switch (fieldType) { @@ -387,6 +433,8 @@ class EditableCellSkinMap { 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 index 4b7bd2c442..ab421b8925 100644 --- 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 @@ -1,9 +1,9 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_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/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:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -27,6 +27,7 @@ abstract class IEditableCheckboxCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ); @@ -71,6 +72,7 @@ class _CheckboxCellState extends GridCellState { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, state, ); 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 index dd7bc6c2c1..fbed429642 100644 --- 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 @@ -1,9 +1,9 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/checklist_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/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/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -28,8 +28,8 @@ abstract class IEditableChecklistCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, - ChecklistCellState state, PopoverController popoverController, ); } @@ -70,16 +70,12 @@ class GridChecklistCellState extends GridCellState { Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, - child: BlocBuilder( - builder: (context, state) { - return widget.skin.build( - context, - widget.cellContainerNotifier, - cellBloc, - state, - _popover, - ); - }, + child: widget.skin.build( + context, + widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, + cellBloc, + _popover, ), ); } 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 index 4b12c780d1..e61c759f48 100644 --- 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 @@ -1,12 +1,17 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; +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/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.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/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.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'; @@ -28,6 +33,7 @@ abstract class IEditableDateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, @@ -74,6 +80,7 @@ class _DateCellState extends GridCellState { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, state, _popover, @@ -90,5 +97,45 @@ class _DateCellState extends GridCellState { } @override - String? onCopy() => cellBloc.state.dateStr; + 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 0000000000..55adb85334 --- /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 index bae9523207..4d2bfdf627 100644 --- 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 @@ -1,11 +1,11 @@ import 'dart:async'; +import 'package:appflowy/plugins/database/application/cell/bloc/number_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/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:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -29,6 +29,7 @@ abstract class IEditableNumberCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -79,13 +80,17 @@ class _NumberCellState extends GridEditableTextCell { return BlocProvider.value( value: cellBloc, child: BlocListener( - listener: (context, state) => - _textEditingController.text = state.content, + listener: (context, state) { + if (!focusNode.hasFocus) { + _textEditingController.text = state.content; + } + }, child: Builder( builder: (context) { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, 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 index 4e39900abf..67ca6275a6 100644 --- 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 @@ -1,9 +1,9 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/relation_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/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/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -28,6 +28,7 @@ abstract class IEditableRelationCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, @@ -74,6 +75,7 @@ class _RelationCellState extends GridCellState { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, state, _popover, 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 index b45018f4f5..f7e8b6f435 100644 --- 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 @@ -1,9 +1,9 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_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/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/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; @@ -31,6 +31,7 @@ abstract class IEditableSelectOptionCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ); @@ -79,6 +80,7 @@ class _SelectOptionCellState extends GridCellState { child: widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, _popover, ), 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 index ccd42bc96c..7a086b2a35 100644 --- 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 @@ -7,11 +7,12 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller_build 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/plugins/database/widgets/cell/editable_cell_builder.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'; @@ -37,6 +38,7 @@ abstract class IEditableSummaryCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -96,6 +98,7 @@ class _SummaryCellState extends GridEditableTextCell { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, @@ -149,7 +152,22 @@ class SummaryCellAccessory extends StatelessWidget { rowId: rowId, fieldId: fieldId, ), - child: BlocBuilder( + 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()], @@ -169,15 +187,15 @@ class SummaryButton extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return state.loadingState.map( - loading: (_) { + return state.loadingState.when( + loading: () { return const Center( child: CircularProgressIndicator.adaptive(), ); }, - finish: (_) { + finish: () { return FlowyTooltip( - message: LocaleKeys.tooltip_genSummary.tr(), + message: LocaleKeys.tooltip_aiGenerate.tr(), child: Container( width: 26, height: 26, 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 index 18423ccbd0..3ea622374e 100644 --- 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 @@ -1,11 +1,11 @@ import 'dart:async'; +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/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:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -29,6 +29,7 @@ abstract class IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -79,14 +80,20 @@ class _TextCellState extends GridEditableTextCell { return BlocProvider.value( value: cellBloc, child: BlocListener( + listenWhen: (previous, current) => previous.content != current.content, listener: (context, state) { - _textEditingController.text = state.content; + // 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, + widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, 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 0000000000..83c34bdf5d --- /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 index 6c00e8b4b4..2fc9d049cc 100644 --- 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 @@ -1,9 +1,9 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_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/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/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -28,6 +28,7 @@ abstract class IEditableTimestampCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ); @@ -74,6 +75,7 @@ class _TimestampCellState extends GridCellState { return widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, state, ); 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 0000000000..b273419aed --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart @@ -0,0 +1,268 @@ +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/material.dart'; +import 'package:flutter/services.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, + ValueNotifier compactModeNotifier, + 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, + widget.databaseController.compactModeNotifier, + 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 index 1535886ea1..39616dbcf8 100644 --- 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 @@ -4,13 +4,13 @@ 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/bloc/url_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/editable_cell_builder.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'; @@ -40,6 +40,7 @@ abstract class IEditableURLCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -112,13 +113,16 @@ class _GridURLCellState extends GridEditableTextCell { child: BlocListener( listenWhen: (previous, current) => previous.content != current.content, listener: (context, state) { - _textEditingController.value = - _textEditingController.value.copyWith(text: state.content); + if (!focusNode.hasFocus) { + _textEditingController.value = + _textEditingController.value.copyWith(text: state.content); + } widget._cellDataNotifier.value = state.content; }, child: widget.skin.build( context, widget.cellContainerNotifier, + widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, @@ -191,7 +195,7 @@ class MobileURLEditor extends StatelessWidget { icon: FlowySvgs.url_s, text: LocaleKeys.grid_url_launch.tr(), ), - const Divider(height: 8.5, thickness: 0.5), + const MobileQuickActionDivider(), MobileQuickActionButton( enable: context.watch().state.content.isNotEmpty, onTap: () { @@ -199,7 +203,7 @@ class MobileURLEditor extends StatelessWidget { ClipboardData(text: textEditingController.text), ); Fluttertoast.showToast( - msg: LocaleKeys.grid_url_copiedNotification.tr(), + msg: LocaleKeys.message_copy_success.tr(), gravity: ToastGravity.BOTTOM, ); context.pop(); @@ -207,7 +211,6 @@ class MobileURLEditor extends StatelessWidget { icon: FlowySvgs.copy_s, text: LocaleKeys.grid_url_copy.tr(), ), - const Divider(height: 8.5, thickness: 0.5), ], ); } 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 index 8859372c2a..e9ac19c874 100644 --- 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 @@ -1,6 +1,6 @@ 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:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/checkbox.dart'; @@ -10,6 +10,7 @@ class MobileGridCheckboxCellSkin extends IEditableCheckboxCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ) { 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 index aa55dd9e36..c56d28e1a7 100644 --- 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 @@ -1,10 +1,9 @@ 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/grid/presentation/layout/sizes.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_popover/appflowy_popover.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'; @@ -16,36 +15,40 @@ class MobileGridChecklistCellSkin extends IEditableChecklistCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, - ChecklistCellState state, PopoverController popoverController, ) { - 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(), - ); - }, - ), + 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 index 7984322328..5686e09295 100644 --- 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 @@ -1,23 +1,26 @@ -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/application/cell/bloc/date_cell_bloc.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_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; class MobileGridDateCellSkin extends IEditableDateCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, ) { + final dateStr = getDateCellStrFromCellData( + state.fieldInfo, + state.cellData, + ); return FlowyButton( radius: BorderRadius.zero, hoverColor: Colors.transparent, @@ -29,12 +32,12 @@ class MobileGridDateCellSkin extends IEditableDateCellSkin { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - if (state.data?.reminderId.isNotEmpty ?? false) ...[ + if (state.cellData.reminderId.isNotEmpty) ...[ const FlowySvg(FlowySvgs.clock_alarm_s), const HSpace(6), ], FlowyText( - state.dateStr, + dateStr, fontSize: 15, ), ], 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 index c02cf6aa8d..310c0b5692 100644 --- 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 @@ -1,5 +1,5 @@ -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/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/number.dart'; @@ -9,6 +9,7 @@ class MobileGridNumberCellSkin extends IEditableNumberCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, 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 index 0e411440ef..69e9b20104 100644 --- 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 @@ -1,7 +1,6 @@ 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:appflowy_popover/appflowy_popover.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'; @@ -12,6 +11,7 @@ class MobileGridRelationCellSkin extends IEditableRelationCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, @@ -42,7 +42,6 @@ class MobileGridRelationCellSkin extends IEditableRelationCellSkin { onTap: () { showMobileBottomSheet( context, - padding: EdgeInsets.zero, 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 index 9c01536e71..010974e49a 100644 --- 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 @@ -1,10 +1,9 @@ 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/select_option_cell_bloc.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/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -17,6 +16,7 @@ class MobileGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { 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 index 3fdb28a8fa..e48c56d74d 100644 --- 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 @@ -12,6 +12,7 @@ class MobileGridSummaryCellSkin extends IEditableSummaryCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -32,7 +33,7 @@ class MobileGridSummaryCellSkin extends IEditableSummaryCellSkin { children: [ TextField( controller: textEditingController, - enabled: false, + readOnly: true, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), 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 index bef6202934..43a4fe49d7 100644 --- 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 @@ -1,5 +1,5 @@ -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/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'; @@ -11,33 +11,35 @@ class MobileGridTextCellSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, 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( - state.emoji, - fontSize: 16, + child: FlowyText.emoji( + state.emoji?.value ?? "", + fontSize: 15, + optimizeEmojiAlign: true, ), ), ), - const HSpace(6), Expanded( child: TextField( controller: textEditingController, focusNode: focusNode, - style: - Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 15), + 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), + 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 0000000000..08ab04c7c7 --- /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 index d9e020eece..68209e7e05 100644 --- 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 @@ -1,5 +1,5 @@ -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/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -10,6 +10,7 @@ class MobileGridTimestampCellSkin extends IEditableTimestampCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { 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 0000000000..4288136734 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart @@ -0,0 +1,80 @@ +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, + ValueNotifier compactModeNotifier, + 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 index 4dffae3022..0dbe5474c7 100644 --- 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 @@ -2,6 +2,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_she 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'; @@ -12,6 +13,7 @@ class MobileGridURLCellSkin extends IEditableURLCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -51,7 +53,7 @@ class MobileGridURLCellSkin extends IEditableURLCellSkin { showMobileBottomSheet( context, showDragHandle: true, - backgroundColor: Theme.of(context).colorScheme.background, + backgroundColor: AFThemeExtension.of(context).background, builder: (context) => BlocProvider.value( value: bloc, child: MobileURLEditor( 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 index 23b5c31c75..ade82e8c5c 100644 --- 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 @@ -1,6 +1,7 @@ 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:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/checkbox.dart'; @@ -10,6 +11,7 @@ class MobileRowDetailCheckboxCellSkin extends IEditableCheckboxCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ) { @@ -31,7 +33,7 @@ class MobileRowDetailCheckboxCellSkin extends IEditableCheckboxCellSkin { alignment: AlignmentDirectional.centerStart, child: FlowySvg( state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, - color: Theme.of(context).colorScheme.onBackground, + 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 index dd981795d2..75eee9a560 100644 --- 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 @@ -1,11 +1,11 @@ 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/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_popover/appflowy_popover.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'; @@ -17,50 +17,54 @@ class MobileRowDetailChecklistCellSkin extends IEditableChecklistCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, - ChecklistCellState state, PopoverController popoverController, ) { - return InkWell( - borderRadius: const BorderRadius.all(Radius.circular(14)), - onTap: () => showMobileBottomSheet( - context, - backgroundColor: Theme.of(context).colorScheme.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), - ), + return BlocBuilder( + builder: (context, state) { + return InkWell( 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, - ), + 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 index a5c500cdbc..0256ee25cf 100644 --- 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 @@ -1,10 +1,10 @@ +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/application/cell/bloc/date_cell_bloc.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_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -14,14 +14,18 @@ class MobileRowDetailDateCellSkin extends IEditableDateCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, ) { - final text = state.dateStr.isEmpty - ? LocaleKeys.grid_row_textPlaceholder.tr() - : state.dateStr; - final color = state.dateStr.isEmpty ? Theme.of(context).hintColor : null; + 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)), @@ -46,11 +50,19 @@ class MobileRowDetailDateCellSkin extends IEditableDateCellSkin { borderRadius: const BorderRadius.all(Radius.circular(14)), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), - child: FlowyText.regular( - text, - fontSize: 16, - color: color, - maxLines: null, + 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 index 6e32fbbbdc..430044fb5c 100644 --- 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 @@ -1,6 +1,6 @@ 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:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class MobileRowDetailNumberCellSkin extends IEditableNumberCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, 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 index eebb3e1c75..c3e8b82867 100644 --- 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 @@ -1,8 +1,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.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:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -11,6 +10,7 @@ class MobileRowDetailRelationCellSkin extends IEditableRelationCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, @@ -19,7 +19,6 @@ class MobileRowDetailRelationCellSkin extends IEditableRelationCellSkin { borderRadius: const BorderRadius.all(Radius.circular(14)), onTap: () => showMobileBottomSheet( context, - padding: EdgeInsets.zero, builder: (context) { return const FlowyText("Coming soon"); }, 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 index 59941394fa..7d4eb71f9d 100644 --- 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 @@ -1,11 +1,10 @@ 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/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/mobile_select_option_editor.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.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/flowy_infra_ui.dart'; @@ -20,6 +19,7 @@ class MobileRowDetailSelectOptionCellSkin Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { 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 index cca601ce12..9974220b96 100644 --- 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 @@ -9,45 +9,59 @@ class MobileRowDetailSummaryCellSkin extends IEditableSummaryCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { - return Column( - children: [ - TextField( - 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, - ), + return Container( + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), ), - 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, - ), + 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 index 1cdde84c27..fc8f816103 100644 --- 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 @@ -1,6 +1,6 @@ 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:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class MobileRowDetailTextCellSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, 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 0000000000..159f2063a4 --- /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 index 2ca0a40b62..f3f800e994 100644 --- 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 @@ -1,6 +1,6 @@ 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:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -12,6 +12,7 @@ class MobileRowDetailTimestampCellSkin extends IEditableTimestampCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { @@ -27,7 +28,7 @@ class MobileRowDetailTimestampCellSkin extends IEditableTimestampCellSkin { borderRadius: const BorderRadius.all(Radius.circular(14)), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), - child: FlowyText.medium( + child: FlowyText( state.dateStr.isEmpty ? LocaleKeys.grid_row_textPlaceholder.tr() : state.dateStr, 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 0000000000..c2d84b3d2e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart @@ -0,0 +1,67 @@ +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, + ValueNotifier compactModeNotifier, + 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 index f97eabe830..9bb91255aa 100644 --- 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 @@ -1,11 +1,11 @@ -import 'package:flutter/material.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/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:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/url.dart'; @@ -15,6 +15,7 @@ class MobileRowDetailURLCellSkin extends IEditableURLCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, @@ -28,7 +29,7 @@ class MobileRowDetailURLCellSkin extends IEditableURLCellSkin { onTap: () => showMobileBottomSheet( context, showDragHandle: true, - backgroundColor: Theme.of(context).colorScheme.background, + backgroundColor: AFThemeExtension.of(context).background, builder: (_) { return BlocProvider.value( value: bloc, 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 index 3ab883329a..9853f9c1bd 100644 --- 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 @@ -1,23 +1,23 @@ -import 'dart:async'; 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/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 'package:universal_platform/universal_platform.dart'; import '../../application/cell/bloc/checklist_cell_bloc.dart'; - +import 'checklist_cell_textfield.dart'; import 'checklist_progress_bar.dart'; class ChecklistCellEditor extends StatefulWidget { @@ -89,7 +89,7 @@ class _ChecklistCellEditorState extends State { } } -/// Displays the a list of all the exisiting tasks and an input field to create +/// 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({ @@ -110,23 +110,43 @@ class ChecklistItemList extends StatelessWidget { final itemList = options .mapIndexed( (index, option) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + 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, - key: ValueKey(option.data.id), ), ), ) .toList(); return Flexible( - child: ListView.separated( - itemBuilder: (context, index) => itemList[index], - separatorBuilder: (context, index) => const VSpace(4), - itemCount: itemList.length, + child: ReorderableListView.builder( shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 8.0), + proxyDecorator: (child, index, _) => Material( + color: Colors.transparent, + child: MouseRegion( + cursor: UniversalPlatform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: IgnorePointer( + child: BlocProvider.value( + value: context.read(), + child: child, + ), + ), + ), + ), + 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)); + }, ), ); } @@ -140,17 +160,21 @@ class _EndEditingTaskIntent extends Intent { const _EndEditingTaskIntent(); } -/// Represents an existing task -@visibleForTesting +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; @@ -159,164 +183,177 @@ class ChecklistItem extends StatefulWidget { } class _ChecklistItemState extends State { - late final TextEditingController _textController; - final FocusNode _focusNode = FocusNode(skipTraversal: true); - final FocusNode _textFieldFocusNode = FocusNode(); + TextEditingController textController = TextEditingController(); + final textFieldFocusNode = FocusNode(); + final focusNode = FocusNode(skipTraversal: true); - bool _isHovered = false; - bool _isFocused = false; - Timer? _debounceOnChanged; + bool isHovered = false; + bool isFocused = false; + bool isComposing = false; + + final _debounceOnChanged = Debounce( + duration: const Duration(milliseconds: 300), + ); @override void initState() { super.initState(); - _textController = TextEditingController(text: widget.task.data.name); + 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?.cancel(); - _textController.dispose(); - _focusNode.dispose(); - _textFieldFocusNode.dispose(); + _debounceOnChanged.dispose(); + + textController.removeListener(_onTextChanged); + textController.dispose(); + focusNode.dispose(); + textFieldFocusNode.dispose(); super.dispose(); } - @override - void didUpdateWidget(ChecklistItem oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.task.data.name != oldWidget.task.data.name) { - final selection = _textController.selection; - _textController.text = widget.task.data.name; - _textController.selection = selection; - } - } - @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: (isHovered) { - setState(() => _isHovered = isHovered); - }, - onFocusChange: (isFocused) { - setState(() => _isFocused = isFocused); - }, - actions: { - _SelectTaskIntent: CallbackAction<_SelectTaskIntent>( - onInvoke: (_SelectTaskIntent intent) => context - .read() - .add(ChecklistCellEvent.selectTask(widget.task.data.id)), - ), - _EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>( - onInvoke: (_EndEditingTaskIntent intent) => - _textFieldFocusNode.unfocus(), - ), - }, - shortcuts: { - SingleActivator( - LogicalKeyboardKey.enter, - meta: Platform.isMacOS, - control: !Platform.isMacOS, - ): const _SelectTaskIntent(), - }, + 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: _isHovered || _isFocused || _textFieldFocusNode.hasFocus - ? AFThemeExtension.of(context).lightGreyHover - : Colors.transparent, - borderRadius: Corners.s6Border, - ), - child: Row( - children: [ - ExcludeFocus( - child: FlowyIconButton( - width: 32, - icon: FlowySvg( - widget.task.isSelected - ? FlowySvgs.check_filled_s - : FlowySvgs.uncheck_s, - blendMode: BlendMode.dst, - ), - hoverColor: Colors.transparent, - onPressed: () => context.read().add( - ChecklistCellEvent.selectTask(widget.task.data.id), - ), - ), - ), - Expanded( - child: Shortcuts( - shortcuts: const { - SingleActivator(LogicalKeyboardKey.escape): - _EndEditingTaskIntent(), - }, - child: Builder( - builder: (context) { - return TextField( - controller: _textController, - focusNode: _textFieldFocusNode, - autofocus: widget.autofocus, - style: Theme.of(context).textTheme.bodyMedium, - decoration: InputDecoration( - border: InputBorder.none, - isCollapsed: true, - contentPadding: EdgeInsets.only( - top: 8.0, - bottom: 8.0, - left: 2.0, - right: _isHovered ? 2.0 : 8.0, - ), - hintText: LocaleKeys.grid_checklist_taskHint.tr(), - ), - textInputAction: widget.onSubmitted == null - ? TextInputAction.next - : null, - onChanged: (text) { - if (_textController.value.composing.isCollapsed) { - _debounceOnChangedText(text); - } - }, - onSubmitted: (description) { - _submitUpdateTaskDescription(description); - if (widget.onSubmitted != null) { - widget.onSubmitted?.call(); - } else { - Actions.invoke(context, const NextFocusIntent()); - } - }, - ); - }, - ), - ), - ), - if (_isHovered || _isFocused || _textFieldFocusNode.hasFocus) - _DeleteTaskButton( - onPressed: () => context.read().add( - ChecklistCellEvent.deleteTask(widget.task.data.id), - ), - ), - ], - ), + decoration: BoxDecoration(color: color, borderRadius: Corners.s6Border), + child: _buildChild(isFocusedOrHovered && !textFieldFocusNode.hasFocus), ), ); } - void _debounceOnChangedText(String text) { - _debounceOnChanged?.cancel(); - _debounceOnChanged = Timer(const Duration(milliseconds: 300), () { - _submitUpdateTaskDescription(text); - }); + 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)), + ), + ], + ); } - void _submitUpdateTaskDescription(String description) { - context.read().add( - ChecklistCellEvent.updateTaskName( - widget.task.data, - description, - ), - ); + 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. @@ -332,19 +369,27 @@ class NewTaskItem extends StatefulWidget { } class _NewTaskItemState extends State { - final _textEditingController = TextEditingController(); + 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() { - _textEditingController.dispose(); + textController.removeListener(_onTextChanged); + textController.dispose(); super.dispose(); } @@ -357,109 +402,68 @@ class _NewTaskItemState extends State { children: [ const HSpace(8), Expanded( - child: TextField( - focusNode: widget.focusNode, - controller: _textEditingController, - style: Theme.of(context).textTheme.bodyMedium, - decoration: InputDecoration( - border: InputBorder.none, - isCollapsed: true, - contentPadding: const EdgeInsets.symmetric( - vertical: 6.0, - horizontal: 2.0, + 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, ), - hintText: LocaleKeys.grid_checklist_addNew.tr(), ), - onSubmitted: (taskDescription) { - if (taskDescription.isNotEmpty) { - context - .read() - .add(ChecklistCellEvent.createNewTask(taskDescription)); - _textEditingController.clear(); - } - widget.focusNode.requestFocus(); - }, - onChanged: (value) => setState(() {}), ), ), FlowyTextButton( LocaleKeys.grid_checklist_submitNewTask.tr(), fontSize: 11, - fillColor: _textEditingController.text.isEmpty - ? Theme.of(context).disabledColor - : Theme.of(context).colorScheme.primary, - hoverColor: _textEditingController.text.isEmpty - ? Theme.of(context).disabledColor - : Theme.of(context).colorScheme.primaryContainer, + 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: 3), - onPressed: _textEditingController.text.isEmpty - ? null - : () { + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + onPressed: isCreateButtonEnabled + ? () { context.read().add( - ChecklistCellEvent.createNewTask( - _textEditingController.text, - ), + ChecklistCellEvent.createNewTask(textController.text), ); widget.focusNode.requestFocus(); - _textEditingController.clear(); - }, + textController.clear(); + } + : null, ), ], ), ); } -} -class _DeleteTaskButton extends StatefulWidget { - const _DeleteTaskButton({ - required this.onPressed, - }); - - final VoidCallback onPressed; - - @override - State<_DeleteTaskButton> createState() => _DeleteTaskButtonState(); -} - -class _DeleteTaskButtonState extends State<_DeleteTaskButton> { - final _materialStatesController = MaterialStatesController(); - - @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 MaterialStatePropertyAll(Size.square(32)), - minimumSize: const MaterialStatePropertyAll(Size.square(32)), - maximumSize: const MaterialStatePropertyAll(Size.square(32)), - overlayColor: MaterialStateProperty.resolveWith((state) { - if (state.contains(MaterialState.focused)) { - return AFThemeExtension.of(context).greyHover; - } - return Colors.transparent; - }), - shape: const MaterialStatePropertyAll( - RoundedRectangleBorder(borderRadius: Corners.s6Border), - ), - ), - statesController: _materialStatesController, - child: FlowySvg( - FlowySvgs.delete_s, - color: _materialStatesController.value - .contains(MaterialState.hovered) || - _materialStatesController.value.contains(MaterialState.focused) - ? Theme.of(context).colorScheme.error - : 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 0000000000..789a4adf46 --- /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 index b51f662930..7e0b376f77 100644 --- 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 @@ -1,7 +1,7 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; 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 { @@ -32,43 +32,41 @@ class _ChecklistProgressBarState extends State { 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, + child: widget.tasks.isNotEmpty && + widget.tasks.length <= widget.segmentLimit + ? Row( + children: [ + ...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, + ), ), - 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), - ), + : LinearPercentIndicator( + lineHeight: 4.0, + percent: widget.percent, + padding: EdgeInsets.zero, + progressColor: completedTaskColor, + backgroundColor: + AFThemeExtension.of(context).progressBarBGColor, + barRadius: const Radius.circular(2), ), - ], - ), ), SizedBox( - width: PlatformExtension.isDesktop ? 36 : 45, + width: 45, child: Align( alignment: AlignmentDirectional.centerEnd, child: Text( 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 0000000000..3ee7f1ef56 --- /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/date_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/date_editor.dart deleted file mode 100644 index e0d7c3944f..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/date_editor.dart +++ /dev/null @@ -1,109 +0,0 @@ -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/appflowy_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 MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => DateCellEditorBloc( - reminderBloc: getIt(), - cellController: widget.cellController, - ), - ), - ], - child: BlocBuilder( - builder: (context, state) { - final dateCellBloc = context.read(); - return AppFlowyDatePicker( - includeTime: state.includeTime, - rebuildOnDaySelected: false, - onIncludeTimeChanged: (value) => - dateCellBloc.add(DateCellEditorEvent.setIncludeTime(!value)), - isRange: state.isRange, - startDay: state.isRange ? state.startDay : null, - endDay: state.isRange ? state.endDay : null, - onIsRangeChanged: (value) => - dateCellBloc.add(DateCellEditorEvent.setIsRange(!value)), - dateFormat: state.dateTypeOptionPB.dateFormat, - timeFormat: state.dateTypeOptionPB.timeFormat, - selectedDay: state.dateTime, - timeStr: state.timeStr, - endTimeStr: state.endTimeStr, - timeHintText: state.timeHintText, - parseEndTimeError: state.parseEndTimeError, - parseTimeError: state.parseTimeError, - popoverMutex: popoverMutex, - onReminderSelected: (option) => dateCellBloc.add( - DateCellEditorEvent.setReminderOption( - option: option, - selectedDay: state.dateTime == null ? DateTime.now() : null, - ), - ), - selectedReminderOption: state.reminderOption, - 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()), - ), - ], - ), - ], - onStartTimeSubmitted: (timeStr) => - dateCellBloc.add(DateCellEditorEvent.setTime(timeStr)), - onEndTimeSubmitted: (timeStr) => - dateCellBloc.add(DateCellEditorEvent.setEndTime(timeStr)), - onDaySelected: (selectedDay, _) => - dateCellBloc.add(DateCellEditorEvent.selectDay(selectedDay)), - onRangeSelected: (start, end, _) => dateCellBloc - .add(DateCellEditorEvent.selectDateRange(start, end)), - ); - }, - ), - ); - } -} 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 index fe9921b4d5..c29c7a23c1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/extension.dart @@ -1,11 +1,11 @@ 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: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:universal_platform/universal_platform.dart'; extension SelectOptionColorExtension on SelectOptionColorPB { Color toColor(BuildContext context) { @@ -70,6 +70,7 @@ class SelectOptionTag extends StatelessWidget { this.onRemove, this.textAlign, this.isExpanded = false, + this.borderRadius, required this.padding, }) : assert(option != null || name != null && color != null); @@ -80,6 +81,7 @@ class SelectOptionTag extends StatelessWidget { final TextStyle? textStyle; final void Function(String)? onRemove; final EdgeInsets padding; + final BorderRadius? borderRadius; final TextAlign? textAlign; final bool isExpanded; @@ -87,22 +89,20 @@ class SelectOptionTag extends StatelessWidget { Widget build(BuildContext context) { final optionName = option?.name ?? name!; final optionColor = option?.color.toColor(context) ?? color!; - final text = FlowyText.medium( + 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.all( - Radius.circular( - PlatformExtension.isDesktopOrWeb ? 6 : 11, - ), - ), + borderRadius: borderRadius ?? + BorderRadius.circular(UniversalPlatform.isDesktopOrWeb ? 6 : 11), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -122,4 +122,3 @@ class SelectOptionTag extends StatelessWidget { ); } } - 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 0000000000..eba42c1f97 --- /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 index fb8b11474c..6e86d88e5b 100644 --- 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 @@ -1,5 +1,7 @@ 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'; @@ -8,14 +10,11 @@ import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_b 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:go_router/go_router.dart'; class MobileChecklistCellEditScreen extends StatefulWidget { - const MobileChecklistCellEditScreen({ - super.key, - }); + const MobileChecklistCellEditScreen({super.key}); @override State createState() => @@ -28,41 +27,24 @@ class _MobileChecklistCellEditScreenState Widget build(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints.tightFor(height: 420), - child: BlocBuilder( - builder: (context, state) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const DragHandle(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: _buildHeader(context), - ), - const Divider(), - const Expanded(child: _TaskList()), - ], - ); - }, + 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) { - const iconWidth = 36.0; - const height = 44.0; return Stack( children: [ - Align( - alignment: Alignment.centerLeft, - child: FlowyIconButton( - icon: const FlowySvg( - FlowySvgs.close_s, - size: Size.square(iconWidth), - ), - width: iconWidth, - onPressed: () => context.pop(), - ), - ), SizedBox( height: 44.0, child: Align( @@ -72,7 +54,7 @@ class _MobileChecklistCellEditScreenState ), ), ), - ].map((e) => SizedBox(height: height, child: e)).toList(), + ], ); } } @@ -89,19 +71,42 @@ class _TaskList extends StatelessWidget { state.tasks .mapIndexed( (index, task) => _ChecklistItem( + key: ValueKey('mobile_checklist_task_${task.data.id}'), task: task, - autofocus: state.newTask && index == state.tasks.length - 1, + index: index, + autofocus: state.phantomIndex != null && + index == state.tasks.length - 1, + onAutofocus: () { + context + .read() + .add(const ChecklistCellEvent.updatePhantomIndex(null)); + }, ), ) .toList(), ); - cells.add(const _NewTaskButton()); + cells.add( + const _NewTaskButton(key: ValueKey('mobile_checklist_new_task')), + ); - return ListView.separated( + 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, - separatorBuilder: (_, __) => const VSpace(8), - itemBuilder: (_, int index) => cells[index], + itemBuilder: (_, index) => cells[index], padding: const EdgeInsets.only(bottom: 12.0), + onReorder: (from, to) { + context + .read() + .add(ChecklistCellEvent.reorderTask(from, to)); + }, ); }, ); @@ -109,26 +114,37 @@ class _TaskList extends StatelessWidget { } class _ChecklistItem extends StatefulWidget { - const _ChecklistItem({required this.task, required this.autofocus}); + 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(); + late final TextEditingController textController; + final FocusNode focusNode = FocusNode(); Timer? _debounceOnChanged; @override void initState() { super.initState(); - _textController = TextEditingController(text: widget.task.data.name); + textController = TextEditingController(text: widget.task.data.name); if (widget.autofocus) { - _focusNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + widget.onAutofocus?.call(); + }); } } @@ -136,48 +152,53 @@ class _ChecklistItemState extends State<_ChecklistItem> { void didUpdateWidget(covariant oldWidget) { super.didUpdateWidget(oldWidget); if (widget.task.data.name != oldWidget.task.data.name && - !_focusNode.hasFocus) { - _textController.text = widget.task.data.name; + !focusNode.hasFocus) { + textController.text = widget.task.data.name; } } @override void dispose() { - _textController.dispose(); - _focusNode.dispose(); + textController.dispose(); + focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 5), - height: 44, + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 4), + constraints: const BoxConstraints(minHeight: 44), child: Row( children: [ - 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, + 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, + controller: textController, + focusNode: focusNode, style: Theme.of(context).textTheme.bodyMedium, + keyboardType: TextInputType.multiline, + maxLines: null, decoration: InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, @@ -273,7 +294,7 @@ class _ChecklistItemState extends State<_ChecklistItem> { } class _NewTaskButton extends StatelessWidget { - const _NewTaskButton(); + const _NewTaskButton({super.key}); @override Widget build(BuildContext context) { @@ -282,6 +303,9 @@ class _NewTaskButton extends StatelessWidget { child: InkWell( borderRadius: BorderRadius.circular(12), onTap: () { + context + .read() + .add(const ChecklistCellEvent.updatePhantomIndex(-1)); context .read() .add(const ChecklistCellEvent.createNewTask("")); 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 0000000000..2960a6a34d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart @@ -0,0 +1,310 @@ +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/shared/loading.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'; + +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 index 3750a9294b..bd84c9074d 100644 --- 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 @@ -1,3 +1,5 @@ +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'; @@ -12,7 +14,6 @@ import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum. 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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:protobuf/protobuf.dart'; @@ -168,15 +169,15 @@ class _MobileSelectOptionEditorState extends State { .add(const SelectOptionCellEditorEvent.createOption()); searchController.clear(); }, - onCheck: (option, value) { - if (value) { + onCheck: (option, isSelected) { + if (isSelected) { context .read() - .add(SelectOptionCellEditorEvent.selectOption(option.id)); + .add(SelectOptionCellEditorEvent.unselectOption(option.id)); } else { context .read() - .add(SelectOptionCellEditorEvent.unSelectOption(option.id)); + .add(SelectOptionCellEditorEvent.selectOption(option.id)); } }, onMoreOptions: (option) { @@ -274,11 +275,13 @@ class _OptionList extends StatelessWidget { cells.addAll( state.options.map( - (option) => _SelectOption( - fieldType: fieldType, + (option) => MobileSelectOption( + indicator: fieldType == FieldType.MultiSelect + ? MobileSelectedOptionIndicator.multi + : MobileSelectedOptionIndicator.single, option: option, - checked: state.selectedOptions.contains(option), - onCheck: (value) => onCheck(option, value), + isSelected: state.selectedOptions.contains(option), + onTap: (value) => onCheck(option, value), onMoreOptions: () => onMoreOptions(option), ), ), @@ -297,20 +300,23 @@ class _OptionList extends StatelessWidget { } } -class _SelectOption extends StatelessWidget { - const _SelectOption({ - required this.fieldType, +class MobileSelectOption extends StatelessWidget { + const MobileSelectOption({ + super.key, + required this.indicator, required this.option, - required this.checked, - required this.onCheck, - required this.onMoreOptions, + required this.isSelected, + required this.onTap, + this.showMoreOptionsButton = true, + this.onMoreOptions, }); - final FieldType fieldType; + final MobileSelectedOptionIndicator indicator; final SelectOptionPB option; - final bool checked; - final void Function(bool value) onCheck; - final VoidCallback onMoreOptions; + final bool isSelected; + final void Function(bool value) onTap; + final bool showMoreOptionsButton; + final VoidCallback? onMoreOptions; @override Widget build(BuildContext context) { @@ -319,7 +325,7 @@ class _SelectOption extends StatelessWidget { child: GestureDetector( // no need to add click effect, so using gesture detector behavior: HitTestBehavior.translucent, - onTap: () => onCheck(!checked), + onTap: () => onTap(isSelected), child: Row( children: [ // checked or selected icon @@ -327,8 +333,8 @@ class _SelectOption extends StatelessWidget { height: 20, width: 20, child: _IsSelectedIndicator( - fieldType: fieldType, - isSelected: checked, + indicator: indicator, + isSelected: isSelected, ), ), // padding @@ -348,14 +354,16 @@ class _SelectOption extends StatelessWidget { ), ), ), - const HSpace(24), - // more options - FlowyIconButton( - icon: const FlowySvg( - FlowySvgs.m_field_more_s, + if (showMoreOptionsButton) ...[ + const HSpace(24), + // more options + FlowyIconButton( + icon: const FlowySvg( + FlowySvgs.m_field_more_s, + ), + onPressed: onMoreOptions, ), - onPressed: onMoreOptions, - ), + ], ], ), ), @@ -383,7 +391,7 @@ class _CreateOptionCell extends StatelessWidget { onTap: onTap, child: Row( children: [ - FlowyText.medium( + FlowyText( LocaleKeys.grid_selectOption_create.tr(), color: Theme.of(context).hintColor, ), @@ -495,13 +503,15 @@ class _MoreOptionsState extends State<_MoreOptions> { } } +enum MobileSelectedOptionIndicator { single, multi } + class _IsSelectedIndicator extends StatelessWidget { const _IsSelectedIndicator({ - required this.fieldType, + required this.indicator, required this.isSelected, }); - final FieldType fieldType; + final MobileSelectedOptionIndicator indicator; final bool isSelected; @override @@ -513,7 +523,7 @@ class _IsSelectedIndicator extends StatelessWidget { color: Theme.of(context).colorScheme.primary, ), child: Center( - child: fieldType == FieldType.MultiSelect + child: indicator == MobileSelectedOptionIndicator.multi ? FlowySvg( FlowySvgs.checkmark_tiny_s, color: Theme.of(context).colorScheme.onPrimary, 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 index 70383361d7..7f6960de9d 100644 --- 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 @@ -2,9 +2,16 @@ 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/user/user_workspace_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_popover/appflowy_popover.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'; @@ -106,8 +113,11 @@ class _RelationCellEditorContentState @override Widget build(BuildContext context) { - return BlocProvider.value( - value: bloc, + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: bloc), + BlocProvider.value(value: context.read()), + ], child: BlocBuilder( buildWhen: (previous, current) => !listEquals(previous.filteredRows, current.filteredRows), @@ -126,7 +136,7 @@ class _RelationCellEditorContentState shrinkWrap: true, slivers: [ _CellEditorTitle( - databaseName: widget.relatedDatabaseMeta.databaseName, + databaseMeta: widget.relatedDatabaseMeta, ), _SearchField( focusNode: focusNode, @@ -204,10 +214,10 @@ class _RelationCellEditorContentState class _CellEditorTitle extends StatelessWidget { const _CellEditorTitle({ - required this.databaseName, + required this.databaseMeta, }); - final String databaseName; + final DatabaseMeta databaseMeta; @override Widget build(BuildContext context) { @@ -223,15 +233,20 @@ class _CellEditorTitle extends StatelessWidget { fontSize: 11, color: Theme.of(context).hintColor, ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - child: FlowyText.regular( - databaseName, - fontSize: 11, - overflow: TextOverflow.ellipsis, + 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, + ), + ), ), ), ], @@ -239,6 +254,28 @@ class _CellEditorTitle extends StatelessWidget { ), ); } + + void _openRelatedDatbase(BuildContext context) { + FolderEventGetView(ViewIdPB(value: databaseMeta.viewId)) + .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 { @@ -283,13 +320,16 @@ class _SearchField extends StatelessWidget { FlowyOverlay.show( context: context, builder: (BuildContext overlayContext) { - return RelatedRowDetailPage( - databaseId: context - .read() - .state - .relatedDatabaseMeta! - .databaseId, - rowId: row.rowId, + return BlocProvider.value( + value: context.read(), + child: RelatedRowDetailPage( + databaseId: context + .read() + .state + .relatedDatabaseMeta! + .databaseId, + rowId: row.rowId, + ), ); }, ); @@ -358,13 +398,17 @@ class _RowListItem extends StatelessWidget { ), child: GestureDetector( onTap: () { + final userWorkspaceBloc = context.read(); if (isSelected) { FlowyOverlay.show( context: context, builder: (BuildContext overlayContext) { - return RelatedRowDetailPage( - databaseId: databaseId, - rowId: row.rowId, + return BlocProvider.value( + value: userWorkspaceBloc, + child: RelatedRowDetailPage( + databaseId: databaseId, + rowId: row.rowId, + ), ); }, ); @@ -385,7 +429,7 @@ class _RowListItem extends StatelessWidget { child: Row( children: [ Expanded( - child: FlowyText.medium( + child: FlowyText( row.name.trim().isEmpty ? LocaleKeys.grid_title_placeholder.tr() : row.name, @@ -422,7 +466,7 @@ class _UnselectRowButton extends StatefulWidget { } class _UnselectRowButtonState extends State<_UnselectRowButton> { - final _materialStatesController = MaterialStatesController(); + final _materialStatesController = WidgetStatesController(); @override void dispose() { @@ -437,26 +481,25 @@ class _UnselectRowButtonState extends State<_UnselectRowButton> { onHover: (_) => setState(() {}), onFocusChange: (_) => setState(() {}), style: ButtonStyle( - fixedSize: const MaterialStatePropertyAll(Size.square(32)), - minimumSize: const MaterialStatePropertyAll(Size.square(32)), - maximumSize: const MaterialStatePropertyAll(Size.square(32)), - overlayColor: MaterialStateProperty.resolveWith((state) { - if (state.contains(MaterialState.focused)) { + 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 MaterialStatePropertyAll( + shape: const WidgetStatePropertyAll( RoundedRectangleBorder(borderRadius: Corners.s6Border), ), ), statesController: _materialStatesController, child: Container( - color: _materialStatesController.value - .contains(MaterialState.hovered) || - _materialStatesController.value.contains(MaterialState.focused) + color: _materialStatesController.value.contains(WidgetState.hovered) || + _materialStatesController.value.contains(WidgetState.focused) ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onBackground, + : AFThemeExtension.of(context).onBackground, width: 12, height: 1, ), @@ -503,7 +546,7 @@ class _RelationCellEditorDatabasePicker extends StatelessWidget { databaseMeta.databaseId, ), ), - text: FlowyText.medium( + 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 index 3094c97887..6ac2a5b807 100644 --- 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 @@ -1,23 +1,23 @@ 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:appflowy_popover/appflowy_popover.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/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../grid/presentation/layout/sizes.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'; @@ -73,7 +73,7 @@ class _SelectOptionCellEditorState extends State { break; case LogicalKeyboardKey.backspace when event is KeyUpEvent: if (!textEditingController.text.isNotEmpty) { - bloc.add(const SelectOptionCellEditorEvent.unSelectLastOption()); + bloc.add(const SelectOptionCellEditorEvent.unselectLastOption()); return KeyEventResult.handled; } break; @@ -137,8 +137,7 @@ class _OptionList extends StatelessWidget { Widget build(BuildContext context) { return BlocConsumer( - listenWhen: (previous, current) => - previous.clearFilter != current.clearFilter, + listenWhen: (prev, curr) => prev.clearFilter != curr.clearFilter, listener: (context, state) { if (state.clearFilter) { textEditingController.clear(); @@ -151,60 +150,66 @@ class _OptionList extends StatelessWidget { !listEquals(previous.options, current.options) || previous.createSelectOptionSuggestion != current.createSelectOptionSuggestion, - builder: (context, state) { - return 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(), - ), - ], - ), + 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: const _Title(), - footer: state.createSelectOptionSuggestion == null - ? null - : _CreateOptionCell( - suggestion: state.createSelectOptionSuggestion!, + ), + 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, ), - padding: const EdgeInsets.symmetric(vertical: 8.0), - ); - }, + ); + }, + 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), + ), ); } } @@ -245,11 +250,9 @@ class _TextField extends StatelessWidget { scrollController: scrollController, textSeparators: const [','], onClick: () => popoverMutex.close(), - newText: (text) { - context - .read() - .add(SelectOptionCellEditorEvent.filterOption(text)); - }, + newText: (text) => context + .read() + .add(SelectOptionCellEditorEvent.filterOption(text)), onSubmitted: () { context .read() @@ -264,13 +267,12 @@ class _TextField extends StatelessWidget { ), ); }, - onRemove: (optionName) { - context.read().add( - SelectOptionCellEditorEvent.unSelectOption( - optionMap[optionName]!.id, + onRemove: (name) => + context.read().add( + SelectOptionCellEditorEvent.unselectOption( + optionMap[name]!.id, + ), ), - ); - }, ), ), ); @@ -286,12 +288,9 @@ class _Title extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - height: GridSize.popoverItemHeight, - child: FlowyText.regular( - LocaleKeys.grid_selectOption_panelTitle.tr(), - color: Theme.of(context).hintColor, - ), + child: FlowyText.regular( + LocaleKeys.grid_selectOption_panelTitle.tr(), + color: Theme.of(context).hintColor, ), ); } @@ -314,13 +313,7 @@ class _SelectOptionCell extends StatefulWidget { } class _SelectOptionCellState extends State<_SelectOptionCell> { - late PopoverController _popoverController; - - @override - void initState() { - _popoverController = PopoverController(); - super.initState(); - } + final _popoverController = PopoverController(); @override Widget build(BuildContext context) { @@ -332,16 +325,27 @@ class _SelectOptionCellState extends State<_SelectOptionCell> { 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, - ), - ); - }, + onEnter: (_) => context.read().add( + SelectOptionCellEditorEvent.updateFocusedOption( + widget.option.id, + ), + ), child: Container( height: 28, decoration: BoxDecoration( @@ -380,7 +384,7 @@ class _SelectOptionCellState extends State<_SelectOptionCell> { icon: FlowySvg( FlowySvgs.three_dots_s, size: const Size.square(16), - color: Theme.of(context).colorScheme.onBackground, + color: AFThemeExtension.of(context).onBackground, ), ), ], @@ -388,42 +392,16 @@ class _SelectOptionCellState extends State<_SelectOptionCell> { ), ), ), - popupBuilder: (BuildContext popoverContext) { - return SelectOptionEditor( - option: widget.option, - onDeleted: () { - context - .read() - .add(SelectOptionCellEditorEvent.deleteOption(widget.option)); - PopoverContainer.of(popoverContext).close(); - }, - onUpdated: (updatedOption) { - context - .read() - .add(SelectOptionCellEditorEvent.updateOption(updatedOption)); - }, - key: ValueKey( - widget.option.id, - ), // Use ValueKey to refresh the UI, otherwise, it will remain the old value. - ); - }, ); } void _onTap() { widget.popoverMutex.close(); - if (context - .read() - .state - .selectedOptions - .contains(widget.option)) { - context - .read() - .add(SelectOptionCellEditorEvent.unSelectOption(widget.option.id)); + final bloc = context.read(); + if (bloc.state.selectedOptions.contains(widget.option)) { + bloc.add(SelectOptionCellEditorEvent.unselectOption(widget.option.id)); } else { - context - .read() - .add(SelectOptionCellEditorEvent.selectOption(widget.option.id)); + bloc.add(SelectOptionCellEditorEvent.selectOption(widget.option.id)); } } } @@ -462,7 +440,7 @@ class SelectOptionTagCell extends StatelessWidget { child: FlowySvg( FlowySvgs.drag_element_s, size: const Size.square(14), - color: Theme.of(context).colorScheme.onBackground, + color: AFThemeExtension.of(context).onBackground, ), ), ), @@ -475,16 +453,15 @@ class SelectOptionTagCell extends StatelessWidget { onTap: onSelected, child: MouseRegion( cursor: SystemMouseCursors.click, - child: Align( + child: Container( alignment: AlignmentDirectional.centerStart, - child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: SelectOptionTag( + fontSize: 14, + option: option, padding: const EdgeInsets.symmetric( - horizontal: 6.0, - vertical: 4.0, - ), - child: SelectOptionTag( - option: option, - padding: const EdgeInsets.symmetric(horizontal: 8), + horizontal: 8, + vertical: 2, ), ), ), @@ -498,16 +475,14 @@ class SelectOptionTagCell extends StatelessWidget { } class _CreateOptionCell extends StatelessWidget { - const _CreateOptionCell({ - required this.suggestion, - }); + const _CreateOptionCell({required this.suggestion}); final CreateSelectOptionSuggestion suggestion; @override Widget build(BuildContext context) { return Container( - height: 28, + height: 32, margin: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -532,7 +507,7 @@ class _CreateOptionCell extends StatelessWidget { }, child: Row( children: [ - FlowyText.medium( + FlowyText( LocaleKeys.grid_selectOption_create.tr(), color: Theme.of(context).hintColor, ), @@ -543,10 +518,10 @@ class _CreateOptionCell extends StatelessWidget { child: SelectOptionTag( name: suggestion.name, color: suggestion.color.toColor(context), - fontSize: 11, + fontSize: 14, padding: const EdgeInsets.symmetric( horizontal: 8, - vertical: 1, + 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 index f16500f601..a03167df9d 100644 --- 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 @@ -60,6 +60,12 @@ class _SelectOptionTextFieldState extends State { _scrollToEnd(); }); } + + if (oldWidget.textController != widget.textController) { + oldWidget.textController.removeListener(_onChanged); + widget.textController.addListener(_onChanged); + } + super.didUpdateWidget(oldWidget); } @@ -132,7 +138,10 @@ class _SelectOptionTextFieldState extends State { (option) => SelectOptionTag( option: option, onRemove: (option) => widget.onRemove(option), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), ), ) .toList(); 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 index 4d79b2d075..f8118a7e51 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/database_layout_ext.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_layout_ext.dart @@ -26,7 +26,7 @@ extension DatabaseLayoutExtension on DatabaseLayoutPB { FlowySvgData get icon { return switch (this) { DatabaseLayoutPB.Board => FlowySvgs.board_s, - DatabaseLayoutPB.Calendar => FlowySvgs.date_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 index 97e13a626a..a218e1ed68 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart @@ -1,19 +1,27 @@ -import 'package:flutter/material.dart'; - +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:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class DatabaseViewWidget extends StatefulWidget { const DatabaseViewWidget({ super.key, required this.view, this.shrinkWrap = true, + required this.showActions, + required this.node, + this.actionBuilder, }); final ViewPB view; final bool shrinkWrap; + final BlockComponentActionBuilder? actionBuilder; + final bool showActions; + final Node node; @override State createState() => _DatabaseViewWidgetState(); @@ -50,10 +58,27 @@ class _DatabaseViewWidgetState extends State { @override Widget build(BuildContext context) { + double? horizontalPadding = 0.0; + final databasePluginWidgetBuilderSize = + Provider.of(context); + if (view.layout == ViewLayoutPB.Grid || view.layout == ViewLayoutPB.Board) { + horizontalPadding = 40.0; + } + if (databasePluginWidgetBuilderSize != null) { + horizontalPadding = databasePluginWidgetBuilderSize.horizontalPadding; + } + return ValueListenableBuilder( valueListenable: _layoutTypeChangeNotifier, builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget( shrinkWrap: widget.shrinkWrap, + context: PluginContext(), + data: { + kDatabasePluginWidgetBuilderHorizontalPadding: horizontalPadding, + kDatabasePluginWidgetBuilderActionBuilder: widget.actionBuilder, + kDatabasePluginWidgetBuilderShowActions: widget.showActions, + kDatabasePluginWidgetBuilderNode: widget.node, + }, ), ); } 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 index 445966afe8..88cd88ee68 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:appflowy/generated/flowy_svgs.g.dart'; @@ -8,17 +9,21 @@ 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/workspace/presentation/widgets/toggle/toggle_style.dart'; +import 'package:appflowy_backend/log.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: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'; @@ -31,35 +36,39 @@ class FieldEditor extends StatefulWidget { const FieldEditor({ super.key, required this.viewId, - required this.field, + required this.fieldInfo, required this.fieldController, + required this.isNewField, this.initialPage = FieldEditorPage.details, this.onFieldInserted, }); final String viewId; - final FieldPB field; + 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; + late final TextEditingController textController = + TextEditingController(text: widget.fieldInfo.name); @override void initState() { super.initState(); _currentPage = widget.initialPage; - textController = TextEditingController(text: widget.field.name); } @override void dispose() { + popoverMutex.dispose(); textController.dispose(); super.dispose(); } @@ -69,13 +78,14 @@ class _FieldEditorState extends State { return BlocProvider( create: (_) => FieldEditorBloc( viewId: widget.viewId, - field: widget.field, + fieldInfo: widget.fieldInfo, fieldController: widget.fieldController, onFieldInserted: widget.onFieldInserted, + isNew: widget.isNewField, ), - child: _currentPage == FieldEditorPage.details - ? _fieldDetails() - : _fieldGeneral(), + child: _currentPage == FieldEditorPage.general + ? _fieldGeneral() + : _fieldDetails(), ); } @@ -86,9 +96,10 @@ class _FieldEditorState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - FieldNameTextField( + _NameAndIcon( + popoverMutex: popoverMutex, + textController: textController, padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), - textEditingController: textController, ), VSpace(GridSize.typeOptionSeparatorHeight), _EditFieldButton( @@ -117,6 +128,21 @@ class _FieldEditorState extends State { ); } + 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, @@ -126,19 +152,6 @@ class _FieldEditorState extends State { ), ); } - - Widget _actionCell(FieldAction action) { - return BlocBuilder( - builder: (context, state) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: FieldActionCell( - viewId: widget.viewId, - fieldInfo: state.field, - action: action, - ), - ), - ); - } } class _EditFieldButton extends StatelessWidget { @@ -157,7 +170,8 @@ class _EditFieldButton extends StatelessWidget { padding: padding, child: FlowyButton( leftIcon: const FlowySvg(FlowySvgs.edit_s), - text: FlowyText.medium( + text: FlowyText( + lineHeight: 1.0, LocaleKeys.grid_field_editProperty.tr(), ), onTap: onTap, @@ -188,20 +202,30 @@ class FieldActionCell extends StatelessWidget { (action == FieldAction.duplicate || action == FieldAction.delete)) { enable = false; } - - return FlowyButton( + return FlowyIconTextButton( + resetHoverOnRebuild: false, disable: !enable, - text: FlowyText.medium( - action.title(fieldInfo), - color: enable ? null : Theme.of(context).disabledColor, - ), onHover: (_) => popoverMutex?.close(), onTap: () => action.run(context, viewId, fieldInfo), - leftIcon: action.leading( - fieldInfo, - enable ? null : Theme.of(context).disabledColor, + // 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, ), - rightIcon: action.trailing(context, fieldInfo), + 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), ); } } @@ -258,7 +282,6 @@ enum FieldAction { onChanged: (_) => context .read() .add(const FieldEditorEvent.toggleWrapCellContent()), - style: ToggleStyle.big, padding: EdgeInsets.zero, ); } @@ -318,32 +341,33 @@ enum FieldAction { ); break; case FieldAction.clearData: - NavigatorAlertDialog( - constraints: const BoxConstraints( - maxWidth: 250, - maxHeight: 260, - ), - title: LocaleKeys.grid_field_clearFieldPromptMessage.tr(), - confirm: () { + 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, ); }, - ).show(context); - PopoverContainer.of(context).close(); + ); break; case FieldAction.delete: - NavigatorAlertDialog( - title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), - confirm: () { + 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, ); }, - ).show(context); - PopoverContainer.of(context).close(); + ); break; case FieldAction.wrap: context @@ -371,13 +395,7 @@ class FieldDetailsEditor extends StatefulWidget { } class _FieldDetailsEditorState extends State { - late PopoverMutex popoverMutex; - - @override - void initState() { - popoverMutex = PopoverMutex(); - super.initState(); - } + final PopoverMutex popoverMutex = PopoverMutex(); @override void dispose() { @@ -388,10 +406,10 @@ class _FieldDetailsEditorState extends State { @override Widget build(BuildContext context) { final List children = [ - FieldNameTextField( + _NameAndIcon( popoverMutex: popoverMutex, + textController: widget.textEditingController, padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), - textEditingController: widget.textEditingController, ), const VSpace(8.0), SwitchFieldButton(popoverMutex: popoverMutex), @@ -510,69 +528,167 @@ class FieldTypeOptionEditor extends StatelessWidget { } } +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.textEditingController, + required this.textController, this.popoverMutex, - this.padding = EdgeInsets.zero, }); - final TextEditingController textEditingController; + final TextEditingController textController; final PopoverMutex? popoverMutex; - final EdgeInsets padding; @override State createState() => _FieldNameTextFieldState(); } class _FieldNameTextFieldState extends State { - FocusNode focusNode = FocusNode(); + final focusNode = FocusNode(); @override void initState() { super.initState(); - focusNode.addListener(() { - if (focusNode.hasFocus) { - widget.popoverMutex?.close(); - } - }); - - widget.popoverMutex?.listenOnPopoverChanged(() { - if (focusNode.hasFocus) { - focusNode.unfocus(); - } - }); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: widget.padding, - child: FlowyTextField( - focusNode: focusNode, - controller: widget.textEditingController, - onSubmitted: (_) => PopoverContainer.of(context).close(), - onChanged: (newName) { - context - .read() - .add(FieldEditorEvent.renameField(newName)); - }, - ), - ); + focusNode.addListener(_onFocusChanged); + widget.popoverMutex?.addPopoverListener(_onPopoverChanged); } @override void dispose() { - focusNode.removeListener(() { - if (focusNode.hasFocus) { - widget.popoverMutex?.close(); - } - }); + 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 { @@ -594,12 +710,37 @@ class _SwitchFieldButtonState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final bool isPrimary = state.field.isPrimary; + 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: isPrimary ? 0 : PopoverTriggerFlags.hover, + triggerActions: PopoverTriggerFlags.hover, mutex: widget.popoverMutex, controller: _popoverController, offset: const Offset(8, 0), @@ -616,22 +757,16 @@ class _SwitchFieldButtonState extends State { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: FlowyButton( - onTap: () { - if (!isPrimary) { - _popoverController.show(); - } - }, - text: FlowyText.medium( + onTap: () => _popoverController.show(), + text: FlowyText( state.field.fieldType.i18n, - color: isPrimary ? Theme.of(context).disabledColor : null, + lineHeight: 1.0, ), leftIcon: FlowySvg( state.field.fieldType.svgData, - color: isPrimary ? Theme.of(context).disabledColor : null, ), - rightIcon: FlowySvg( + rightIcon: const FlowySvg( FlowySvgs.more_s, - color: isPrimary ? Theme.of(context).disabledColor : null, ), ), ), 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 index bcb1867086..6661d5cd2d 100644 --- 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 @@ -1,10 +1,10 @@ +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:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; typedef SelectFieldCallback = void Function(FieldType); @@ -14,13 +14,16 @@ const List _supportedFieldTypes = [ FieldType.SingleSelect, FieldType.MultiSelect, FieldType.DateTime, + FieldType.Media, + FieldType.URL, FieldType.Checkbox, FieldType.Checklist, - FieldType.URL, FieldType.LastEditedTime, FieldType.CreatedTime, FieldType.Relation, - // FieldType.Summary, + FieldType.Summary, + FieldType.Translate, + // FieldType.Time, ]; class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { @@ -72,9 +75,7 @@ class FieldTypeCell extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium( - fieldType.i18n, - ), + 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 index 2db2c09544..624f9f1fb2 100644 --- 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 @@ -1,8 +1,11 @@ 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 'package:flutter/material.dart'; import 'checkbox.dart'; import 'checklist.dart'; @@ -13,6 +16,7 @@ import 'relation.dart'; import 'rich_text.dart'; import 'single_select.dart'; import 'summary.dart'; +import 'time.dart'; import 'timestamp.dart'; import 'url.dart'; @@ -33,6 +37,9 @@ abstract class TypeOptionEditorFactory { 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(), }; } 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 index 1ce3b73dad..ab216c7b98 100644 --- 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 @@ -1,6 +1,5 @@ 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:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:protobuf/protobuf.dart'; 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 index 4c7dc73ae2..862e46fc3b 100644 --- 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 @@ -1,12 +1,12 @@ +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/workspace/presentation/widgets/toggle/toggle_style.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'; class DateFormatButton extends StatelessWidget { const DateFormatButton({ @@ -23,7 +23,10 @@ class DateFormatButton extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(LocaleKeys.grid_field_dateFormat.tr()), + text: FlowyText( + LocaleKeys.grid_field_dateFormat.tr(), + lineHeight: 1.0, + ), onTap: onTap, onHover: onHover, rightIcon: const FlowySvg(FlowySvgs.more_s), @@ -47,7 +50,10 @@ class TimeFormatButton extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(LocaleKeys.grid_field_timeFormat.tr()), + text: FlowyText( + LocaleKeys.grid_field_timeFormat.tr(), + lineHeight: 1.0, + ), onTap: onTap, onHover: onHover, rightIcon: const FlowySvg(FlowySvgs.more_s), @@ -68,7 +74,9 @@ class DateFormatList extends StatelessWidget { @override Widget build(BuildContext context) { - final cells = DateFormatPB.values.map((format) { + final cells = DateFormatPB.values + .where((value) => value != DateFormatPB.FriendlyFull) + .map((format) { return DateFormatCell( dateFormat: format, onSelected: onSelected, @@ -114,7 +122,10 @@ class DateFormatCell extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(dateFormat.title()), + text: FlowyText( + dateFormat.title(), + lineHeight: 1.0, + ), rightIcon: checkmark, onTap: () => onSelected(dateFormat), ), @@ -135,6 +146,8 @@ extension DateFormatExtension on DateFormatPB { 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; } @@ -199,7 +212,10 @@ class TimeFormatCell extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(timeFormat.title()), + text: FlowyText( + timeFormat.title(), + lineHeight: 1.0, + ), rightIcon: checkmark, onTap: () => onSelected(timeFormat), ), @@ -224,11 +240,11 @@ class IncludeTimeButton extends StatelessWidget { const IncludeTimeButton({ super.key, required this.onChanged, - required this.value, + required this.includeTime, }); final Function(bool value) onChanged; - final bool value; + final bool includeTime; @override Widget build(BuildContext context) { @@ -243,12 +259,11 @@ class IncludeTimeButton extends StatelessWidget { color: Theme.of(context).iconTheme.color, ), const HSpace(6), - FlowyText.medium(LocaleKeys.grid_field_includeTime.tr()), + FlowyText(LocaleKeys.grid_field_includeTime.tr()), const Spacer(), Toggle( - value: value, + value: includeTime, onChanged: onChanged, - style: ToggleStyle.big, 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 0000000000..07dc2bafd0 --- /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/number.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/number.dart index 244f38326c..b8a40907c6 100644 --- 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 @@ -3,7 +3,6 @@ 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:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -31,7 +30,8 @@ class NumberTypeOptionEditorFactory implements TypeOptionEditorFactory { height: GridSize.popoverItemHeight, child: FlowyButton( rightIcon: const FlowySvg(FlowySvgs.more_s), - text: FlowyText.medium( + text: FlowyText( + lineHeight: 1.0, typeOption.format.title(), ), ), @@ -167,7 +167,10 @@ class NumberFormatCell extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(format.title()), + text: FlowyText( + format.title(), + lineHeight: 1.0, + ), onTap: () => onSelected(format), rightIcon: checkmark, ), 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 index 9ca2729cb6..2ee3222b23 100644 --- 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 @@ -4,7 +4,6 @@ import 'package:appflowy/plugins/database/application/field/type_option/relation 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:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -61,6 +60,7 @@ class RelationTypeOptionEditorFactory implements TypeOptionEditorFactory { (meta) => meta.databaseId == typeOption.databaseId, ); return FlowyText( + lineHeight: 1.0, databaseMeta == null ? LocaleKeys .grid_relation_relatedDatabasePlaceholder @@ -133,7 +133,8 @@ class _DatabaseList extends StatelessWidget { height: GridSize.popoverItemHeight, child: FlowyButton( onTap: () => onSelectDatabase(meta.databaseId), - text: FlowyText.medium( + text: FlowyText( + lineHeight: 1.0, meta.databaseName, overflow: TextOverflow.ellipsis, ), 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 index 4c56121890..5201630cc7 100644 --- 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 @@ -7,7 +7,6 @@ import 'package:appflowy/plugins/database/application/field/type_option/select_t 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:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -180,7 +179,8 @@ class _AddOptionButton extends StatelessWidget { child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium( + text: FlowyText( + lineHeight: 1.0, LocaleKeys.grid_field_addSelectOption.tr(), ), onTap: () { @@ -205,22 +205,23 @@ class CreateOptionTextField extends StatefulWidget { } class _CreateOptionTextFieldState extends State { - late final FocusNode _focusNode; + final focusNode = FocusNode(); @override void initState() { super.initState(); - _focusNode = FocusNode() - ..addListener(() { - if (_focusNode.hasFocus) { - widget.popoverMutex?.close(); - } - }); - widget.popoverMutex?.listenOnPopoverChanged(() { - if (_focusNode.hasFocus) { - _focusNode.unfocus(); - } - }); + + focusNode.addListener(_onFocusChanged); + widget.popoverMutex?.addPopoverListener(_onPopoverChanged); + } + + @override + void dispose() { + widget.popoverMutex?.removePopoverListener(_onPopoverChanged); + focusNode.removeListener(_onFocusChanged); + focusNode.dispose(); + + super.dispose(); } @override @@ -233,7 +234,7 @@ class _CreateOptionTextFieldState extends State { child: FlowyTextField( autoClearWhenDone: true, text: text, - focusNode: _focusNode, + focusNode: focusNode, onCanceled: () { context .read() @@ -251,15 +252,16 @@ class _CreateOptionTextFieldState extends State { ); } - @override - void dispose() { - _focusNode.removeListener(() { - if (_focusNode.hasFocus) { - widget.popoverMutex?.close(); - } - }); - _focusNode.dispose(); - super.dispose(); + void _onFocusChanged() { + if (focusNode.hasFocus) { + widget.popoverMutex?.close(); + } + } + + void _onPopoverChanged() { + if (focusNode.hasFocus) { + focusNode.unfocus(); + } } } 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 index 5df44f4b49..9946a6ab75 100644 --- 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 @@ -106,7 +106,8 @@ class _DeleteTag extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium( + text: FlowyText( + lineHeight: 1.0, LocaleKeys.grid_selectOption_deleteTag.tr(), ), leftIcon: const FlowySvg(FlowySvgs.delete_s), @@ -174,7 +175,7 @@ class SelectOptionColorList extends StatelessWidget { padding: GridSize.typeOptionContentInsets, child: SizedBox( height: GridSize.popoverItemHeight, - child: FlowyText.medium( + child: FlowyText( LocaleKeys.grid_selectOption_colorPanelTitle.tr(), textAlign: TextAlign.left, color: Theme.of(context).hintColor, @@ -229,7 +230,8 @@ class _SelectOptionColorCell extends StatelessWidget { height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, - text: FlowyText.medium( + text: FlowyText( + lineHeight: 1.0, color.colorName(), color: AFThemeExtension.of(context).textColor, ), 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 0000000000..01a8c519c2 --- /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 index e3f50cc857..e67929d2dc 100644 --- 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 @@ -1,7 +1,6 @@ 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:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:protobuf/protobuf.dart'; @@ -34,11 +33,11 @@ class TimestampTypeOptionEditorFactory implements TypeOptionEditorFactory { onChanged: (value) { final newTypeOption = _updateTypeOption( typeOption: typeOption, - includeTime: !value, + includeTime: value, ); onTypeOptionUpdated(newTypeOption.writeToBuffer()); }, - value: typeOption.includeTime, + includeTime: typeOption.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 0000000000..70ce6e8049 --- /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/group/database_group.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/group/database_group.dart index ff2ecef03d..f1486094bf 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/group/database_group.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/group/database_group.dart @@ -5,19 +5,17 @@ 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/workspace/presentation/widgets/toggle/toggle_style.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/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; @@ -42,34 +40,45 @@ class DatabaseGroupList extends StatelessWidget { )..add(const DatabaseGroupEvent.initial()), child: BlocBuilder( builder: (context, state) { - final showHideUngroupedToggle = state.fieldInfos.any( - (field) => - field.canBeGroup && - field.isGroupField && - field.fieldType != FieldType.Checkbox, + 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.medium( - LocaleKeys.board_showUngrouped.tr(), - ), - ), - Toggle( - value: !state.layoutSettings.hideUngroupedColumn, - onChanged: (value) => - _updateLayoutSettings(state.layoutSettings, value), - style: ToggleStyle.big, - padding: EdgeInsets.zero, - ), - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + resetHoverOnRebuild: false, + text: FlowyText( + LocaleKeys.board_showUngrouped.tr(), + lineHeight: 1.0, + ), + onTap: () { + _updateLayoutSettings( + state.layoutSettings, + !state.layoutSettings.hideUngroupedColumn, + ); + }, + rightIcon: Toggle( + value: !state.layoutSettings.hideUngroupedColumn, + onChanged: (value) => + _updateLayoutSettings(state.layoutSettings, !value), + padding: EdgeInsets.zero, + ), ), ), ), @@ -80,20 +89,48 @@ class DatabaseGroupList extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: FlowyText.medium( + child: FlowyText( LocaleKeys.board_groupBy.tr(), textAlign: TextAlign.left, color: Theme.of(context).hintColor, ), ), ), - ...state.fieldInfos.where((fieldInfo) => fieldInfo.canBeGroup).map( + ...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( @@ -128,15 +165,21 @@ class _GridGroupCell extends StatelessWidget { 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 (fieldInfo.isGroupField) { + if (checked) { rightIcon = const Padding( padding: EdgeInsets.all(2.0), child: FlowySvg(FlowySvgs.check_s), @@ -149,20 +192,28 @@ class _GridGroupCell extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 6.0), child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, - text: FlowyText.medium( - fieldInfo.name, + text: FlowyText( + name, color: AFThemeExtension.of(context).textColor, + lineHeight: 1.0, ), - leftIcon: FlowySvg( - fieldInfo.fieldType.svgData, - color: Theme.of(context).iconTheme.color, - ), + 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 0000000000..9ac6bec394 --- /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 index febd6a6749..6e13cc5ecb 100644 --- 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 @@ -1,13 +1,14 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.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/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'; @@ -124,6 +125,12 @@ class _AccessoryHoverState extends State { @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( @@ -181,6 +188,9 @@ class CellAccessoryContainer extends StatelessWidget { ); }).toList(); - return Wrap(spacing: 6, children: children); + return SeparatedRow( + separatorBuilder: () => const HSpace(6), + children: children, + ); } } 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 index 22a9ce1381..333ff0fe96 100644 --- 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 @@ -1,12 +1,13 @@ +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'; -import '../../cell/editable_cell_builder.dart'; class CellContainer extends StatelessWidget { const CellContainer({ @@ -56,7 +57,7 @@ class CellContainer extends StatelessWidget { } }, child: Container( - constraints: BoxConstraints(maxWidth: width, minHeight: 46), + constraints: BoxConstraints(maxWidth: width, minHeight: 32), decoration: _makeBoxDecoration(context, isFocus), child: container, ), @@ -75,7 +76,8 @@ class CellContainer extends StatelessWidget { return BoxDecoration(border: Border.fromBorderSide(borderSide)); } - final borderSide = BorderSide(color: Theme.of(context).dividerColor); + final borderSide = + BorderSide(color: AFThemeExtension.of(context).borderColor); return BoxDecoration( border: Border(right: borderSide, bottom: borderSide), ); 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 index cdc984e4b7..8dba996d05 100644 --- 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 @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../accessory/cell_shortcuts.dart'; import '../../cell/editable_cell_builder.dart'; import 'cell_container.dart'; @@ -24,7 +23,7 @@ class MobileCellContainer extends StatelessWidget { child: Selector( selector: (context, notifier) => notifier.isFocus, builder: (providerContext, isFocus, _) { - Widget container = Center(child: GridCellShortcuts(child: child)); + Widget container = Center(child: child); if (isPrimary) { container = IgnorePointer(child: container); 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 index 256de6bc3c..1260641fdf 100644 --- 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 @@ -1,4 +1,5 @@ import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -22,14 +23,17 @@ class RelatedRowDetailPage extends StatelessWidget { initialRowId: rowId, ), child: BlocBuilder( - builder: (context, state) { + builder: (_, state) { return state.when( loading: () => const SizedBox.shrink(), ready: (databaseController, rowController) { - return RowDetailPage( - databaseController: databaseController, - rowController: rowController, - allowOpenAsFullPage: false, + return BlocProvider.value( + value: context.read(), + child: 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 index fee92600c6..c0cf547a06 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_action.dart @@ -50,10 +50,13 @@ class RowDetailPageDeleteButton extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()), + text: FlowyText.regular( + LocaleKeys.grid_row_delete.tr(), + lineHeight: 1.0, + ), leftIcon: const FlowySvg(FlowySvgs.trash_m), onTap: () { - RowBackendService.deleteRow(viewId, rowId); + RowBackendService.deleteRows(viewId, [rowId]); FlowyOverlay.pop(context); }, ), @@ -76,7 +79,10 @@ class RowDetailPageDuplicateButton extends StatelessWidget { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.regular(LocaleKeys.grid_row_duplicate.tr()), + text: FlowyText.regular( + LocaleKeys.grid_row_duplicate.tr(), + lineHeight: 1.0, + ), leftIcon: const FlowySvg(FlowySvgs.copy_s), onTap: () { RowBackendService.duplicateRow(viewId, rowId); 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 index 358d26bece..debbb467e7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart @@ -1,23 +1,50 @@ 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/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; -import 'package:appflowy_popover/appflowy_popover.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/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.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/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/rounded_button.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:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; -const _kBannerActionHeight = 40.0; +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({ @@ -26,12 +53,14 @@ class RowBanner extends StatefulWidget { 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(); @@ -39,7 +68,9 @@ class RowBanner extends StatefulWidget { class _RowBannerState extends State { final _isHovering = ValueNotifier(false); - final popoverController = PopoverController(); + late final isLocalMode = + (widget.userProfile?.workspaceAuthType ?? AuthTypePB.Local) == + AuthTypePB.Local; @override void dispose() { @@ -55,28 +86,456 @@ class _RowBannerState extends State { fieldController: widget.databaseController.fieldController, rowMeta: widget.rowController.rowMeta, )..add(const RowBannerEvent.initial()), - child: MouseRegion( - onEnter: (event) => _isHovering.value = true, - onExit: (event) => _isHovering.value = false, - child: Padding( - padding: const EdgeInsets.fromLTRB(60, 34, 60, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: BlocBuilder( + builder: (context, state) { + final hasCover = state.rowMeta.cover.data.isNotEmpty; + final hasIcon = state.rowMeta.icon.isNotEmpty; + + return Column( children: [ - SizedBox( - height: 30, - child: _BannerAction( - isHovering: _isHovering, - popoverController: popoverController, - ), + 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(4), + const VSpace(8), _BannerTitle( cellBuilder: widget.cellBuilder, - popoverController: popoverController, 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 + .withValues(alpha: 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(); + } + }, + ), + ), + ], + ), ), ), ), @@ -84,47 +543,43 @@ class _RowBannerState extends State { } } -class _BannerAction extends StatelessWidget { - const _BannerAction({ - required this.isHovering, - required this.popoverController, +class RowIcon extends StatefulWidget { + const RowIcon({ + super.key, + required this.icon, + required this.onIconChanged, }); - final ValueNotifier isHovering; - final PopoverController popoverController; + final EmojiIconData icon; + final void Function(String?) onIconChanged; + + @override + State createState() => _RowIconState(); +} + +class _RowIconState extends State { + final controller = PopoverController(); @override Widget build(BuildContext context) { - return SizedBox( - height: _kBannerActionHeight, - child: ValueListenableBuilder( - valueListenable: isHovering, - builder: (BuildContext context, bool isHovering, Widget? child) { - if (!isHovering) { - return const SizedBox.shrink(); - } + if (widget.icon.isEmpty) { + return const SizedBox.shrink(); + } - return BlocBuilder( - builder: (context, state) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (state.rowMeta.icon.isEmpty) - AddEmojiButton( - onTap: () => popoverController.show(), - ) - else - RemoveEmojiButton( - onTap: () => context - .read() - .add(const RowBannerEvent.setIcon('')), - ), - ], - ); - }, - ); + 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), ); } } @@ -132,25 +587,17 @@ class _BannerAction extends StatelessWidget { class _BannerTitle extends StatelessWidget { const _BannerTitle({ required this.cellBuilder, - required this.popoverController, required this.rowController, }); final EditableCellBuilder cellBuilder; - final PopoverController popoverController; final RowController rowController; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final children = [ - if (state.rowMeta.icon.isNotEmpty) - EmojiButton( - emoji: state.rowMeta.icon, - showEmojiPicker: () => popoverController.show(), - ), - const HSpace(4), + final children = [ if (state.primaryField != null) Expanded( child: cellBuilder.buildCustom( @@ -163,18 +610,8 @@ class _BannerTitle extends StatelessWidget { ), ]; - return AppFlowyPopover( - controller: popoverController, - triggerActions: PopoverTriggerFlags.none, - direction: PopoverDirection.bottomWithLeftAligned, - constraints: const BoxConstraints(maxWidth: 380, maxHeight: 300), - popupBuilder: (popoverContext) => EmojiSelectionMenu( - onSubmitted: (emoji) { - popoverController.close(); - context.read().add(RowBannerEvent.setIcon(emoji)); - }, - onExit: () {}, - ), + return Padding( + padding: const EdgeInsets.only(left: 60), child: Row(children: children), ); }, @@ -182,72 +619,43 @@ class _BannerTitle extends StatelessWidget { } } -class EmojiButton extends StatelessWidget { - const EmojiButton({ - super.key, - required this.emoji, - required this.showEmojiPicker, - }); - - final String emoji; - final VoidCallback showEmojiPicker; - +class _TitleSkin extends IEditableTextCellSkin { @override - Widget build(BuildContext context) { - return SizedBox( - width: _kBannerActionHeight, - child: FlowyButton( - margin: EdgeInsets.zero, - text: FlowyText.medium( - emoji, - fontSize: 30, - textAlign: TextAlign.center, + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, + 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, ), - onTap: showEmojiPicker, - ), - ); - } -} - -class AddEmojiButton extends StatelessWidget { - const AddEmojiButton({super.key, required this.onTap}); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 26, - child: FlowyButton( - useIntrinsicWidth: true, - text: FlowyText.medium( - LocaleKeys.document_plugins_cover_addIcon.tr(), - ), - leftIcon: const FlowySvg(FlowySvgs.emoji_s), - onTap: onTap, - margin: const EdgeInsets.all(4), - ), - ); - } -} - -class RemoveEmojiButton extends StatelessWidget { - const RemoveEmojiButton({super.key, required this.onTap}); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 26, - child: FlowyButton( - useIntrinsicWidth: true, - text: FlowyText.medium( - LocaleKeys.document_plugins_cover_removeIcon.tr(), - ), - leftIcon: const FlowySvg(FlowySvgs.emoji_s), - onTap: onTap, - margin: const EdgeInsets.all(4), + onEditingComplete: () { + bloc.add(TextCellEvent.updateText(textEditingController.text)); + }, ), ); } @@ -269,44 +677,9 @@ class RowActionButton extends StatelessWidget { width: 20, height: 20, icon: const FlowySvg(FlowySvgs.details_horizontal_s), - iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + iconColorOnHover: Theme.of(context).colorScheme.onSurface, ), ), ); } } - -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, - autofocus: true, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 28), - 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, - ), - onChanged: (text) { - if (textEditingController.value.composing.isCollapsed) { - bloc.add(TextCellEvent.updateText(text)); - } - }, - ); - } -} 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 index 430f817095..8bd181b427 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_detail.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_detail.dart @@ -6,18 +6,20 @@ 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/plugins/document/presentation/editor_drop_manager.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:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.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'; @@ -27,24 +29,41 @@ class RowDetailPage extends StatefulWidget with FlowyOverlayDelegate { 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 { - final scrollController = ScrollController(); + // 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(); } @@ -52,62 +71,101 @@ class _RowDetailPageState extends State { @override Widget build(BuildContext context) { return FlowyDialog( - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => RowDetailBloc( - fieldController: widget.databaseController.fieldController, - rowController: widget.rowController, + child: ChangeNotifierProvider.value( + value: dropManagerState, + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => RowDetailBloc( + fieldController: widget.databaseController.fieldController, + rowController: widget.rowController, + ), ), - ), - BlocProvider.value(value: getIt()), - ], - child: Stack( - children: [ - ListView( - controller: scrollController, + BlocProvider.value(value: getIt()), + ], + child: BlocBuilder( + builder: (context, state) => Stack( + fit: StackFit.expand, children: [ - RowBanner( - databaseController: widget.databaseController, - rowController: widget.rowController, - cellBuilder: cellBuilder, - allowOpenAsFullPage: widget.allowOpenAsFullPage, - ), - const VSpace(16), - Padding( - padding: const EdgeInsets.only(left: 40, right: 60), - child: RowPropertyList( - cellBuilder: cellBuilder, - viewId: widget.databaseController.viewId, - fieldController: widget.databaseController.fieldController, + Positioned.fill( + child: NestedScrollView( + controller: scrollController, + headerSliverBuilder: + (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverToBoxAdapter( + child: Column( + 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), + ], + ), + ), + ]; + }, + body: RowDocument( + viewId: widget.rowController.viewId, + rowId: widget.rowController.rowId, + ), ), ), - 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)), ), ], ), - Positioned( - top: 12, - right: 12, - child: Row( - children: _actions(context), - ), - ), - ], + ), ), ), ); } - List _actions(BuildContext 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( 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 index 4d58d6fc32..436dbd085d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart @@ -1,16 +1,23 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_document_bloc.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.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/ai/widgets/ai_writer_scroll_wrapper.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/view_bloc.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:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; class RowDocument extends StatelessWidget { const RowDocument({ @@ -27,18 +34,23 @@ class RowDocument extends StatelessWidget { return BlocProvider( create: (context) => RowDocumentBloc(viewId: viewId, rowId: rowId) ..add(const RowDocumentEvent.initial()), - child: BlocBuilder( + 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) => FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + error: (error) => Center( + child: AppFlowyErrorPage( + error: error, + ), ), - finish: () => RowEditor( - viewPB: state.viewPB!, + finish: () => _RowEditor( + view: state.viewPB!, onIsEmptyChanged: (isEmpty) => context .read() .add(RowDocumentEvent.updateIsEmpty(isEmpty)), @@ -50,87 +62,101 @@ class RowDocument extends StatelessWidget { } } -class RowEditor extends StatefulWidget { - const RowEditor({ - super.key, - required this.viewPB, +class _RowEditor extends StatelessWidget { + const _RowEditor({ + required this.view, this.onIsEmptyChanged, }); - final ViewPB viewPB; + final ViewPB view; final void Function(bool)? onIsEmptyChanged; - @override - State createState() => _RowEditorState(); -} - -class _RowEditorState extends State { - late final DocumentBloc documentBloc; - - @override - void initState() { - super.initState(); - documentBloc = DocumentBloc(documentId: widget.viewPB.id) - ..add(const DocumentEvent.initial()); - } - - @override - void dispose() { - documentBloc.close(); - super.dispose(); - } - @override Widget build(BuildContext context) { return MultiBlocProvider( - providers: [BlocProvider.value(value: documentBloc)], - child: BlocListener( + providers: [ + BlocProvider( + create: (_) => DocumentBloc(documentId: view.id) + ..add(const DocumentEvent.initial()), + ), + BlocProvider( + create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), + ), + ], + child: BlocConsumer( listenWhen: (previous, current) => previous.isDocumentEmpty != current.isDocumentEmpty, - listener: (context, state) { + listener: (_, state) { if (state.isDocumentEmpty != null) { - widget.onIsEmptyChanged?.call(state.isDocumentEmpty!); + 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'); } }, - child: BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator.adaptive()); - } + 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) { - Log.error(error); - return FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), - ); - } + final editorState = state.editorState; + final error = state.error; + if (error != null || editorState == null) { + return Center( + child: AppFlowyErrorPage(error: error), + ); + } - return IntrinsicHeight( - child: Container( - constraints: const BoxConstraints(minHeight: 300), - child: BlocProvider( - create: (context) => ViewInfoBloc(view: widget.viewPB), - child: AppFlowyEditorPage( - shrinkWrap: true, - autoFocus: false, + return BlocProvider( + create: (context) => ViewInfoBloc(view: view), + child: Container( + constraints: const BoxConstraints(minHeight: 300), + child: Provider( + create: (_) { + final context = SharedEditorContext(); + context.isInDatabaseRowPage = true; + return context; + }, + dispose: (_, editorContext) => editorContext.dispose(), + child: AiWriterScrollWrapper( + viewId: view.id, + editorState: editorState, + child: EditorDropHandler( + viewId: view.id, editorState: editorState, - styleCustomizer: EditorStyleCustomizer( - context: context, - padding: const EdgeInsets.only(left: 16, right: 54), + isLocalMode: context.read().isLocalMode, + dropManagerState: context.read(), + child: EditorTransactionService( + viewId: view.id, + editorState: editorState, + child: Provider( + create: (context) => DatabasePluginWidgetBuilderSize( + horizontalPadding: 0, + ), + 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(), + ), + ), ), - showParagraphPlaceholder: (editorState, node) => - editorState.document.isEmpty, - placeholderText: (node) => - 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 index 969c4e6e0c..240d33f0f2 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart @@ -11,9 +11,6 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/header/deskt 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:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; -import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -21,9 +18,10 @@ 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 'accessory/cell_accessory.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 { @@ -128,48 +126,11 @@ class _PropertyCell extends StatefulWidget { class _PropertyCellState extends State<_PropertyCell> { final PopoverController _popoverController = PopoverController(); - final PopoverController _fieldPopoverController = PopoverController(); final ValueNotifier _isFieldHover = ValueNotifier(false); @override Widget build(BuildContext context) { - final dragThumb = MouseRegion( - cursor: Platform.isWindows - ? SystemMouseCursors.click - : SystemMouseCursors.grab, - child: SizedBox( - width: 16, - height: 30, - child: AppFlowyPopover( - controller: _fieldPopoverController, - constraints: BoxConstraints.loose(const Size(240, 600)), - margin: EdgeInsets.zero, - triggerActions: PopoverTriggerFlags.none, - direction: PopoverDirection.bottomWithLeftAligned, - popupBuilder: (popoverContext) => FieldEditor( - viewId: widget.fieldController.viewId, - field: widget.fieldController - .getField(widget.cellContext.fieldId)! - .field, - fieldController: widget.fieldController, - ), - child: ValueListenableBuilder( - valueListenable: _isFieldHover, - builder: (_, isHovering, child) => - isHovering ? child! : const SizedBox.shrink(), - child: BlockActionButton( - onTap: () => _fieldPopoverController.show(), - svg: FlowySvgs.drag_element_s, - richMessage: TextSpan( - text: LocaleKeys.grid_rowPage_fieldDragElementTooltip.tr(), - ), - ), - ), - ), - ), - ); - final cell = widget.cellBuilder.buildStyled( widget.cellContext, EditableCellStyle.desktopRowDetail, @@ -206,52 +167,12 @@ class _PropertyCellState extends State<_PropertyCell> { return ReorderableDragStartListener( index: widget.index, enabled: value, - child: dragThumb, + child: _buildDragHandle(context), ); }, ), const HSpace(4), - 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, - popupBuilder: (popoverContext) => FieldEditor( - viewId: widget.fieldController.viewId, - field: fieldInfo.field, - fieldController: widget.fieldController, - ), - 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: () => _popoverController.show(), - radius: BorderRadius.circular(6), - margin: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 6, - ), - ), - ), - ), - ); - }, - ), + _buildFieldButton(context), const HSpace(8), Expanded(child: gesture), ], @@ -259,6 +180,96 @@ class _PropertyCellState extends State<_PropertyCell> { ), ); } + + 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 { @@ -281,7 +292,7 @@ class ToggleHiddenFieldsVisibilityButton extends StatelessWidget { namedArgs: {'count': '${state.numHiddenFields}'}, ); final quarterTurns = state.showHiddenFields ? 1 : 3; - return PlatformExtension.isDesktopOrWeb + return UniversalPlatform.isDesktopOrWeb ? _desktop(context, text, quarterTurns) : _mobile(context, text, quarterTurns); }, @@ -292,7 +303,11 @@ class ToggleHiddenFieldsVisibilityButton extends StatelessWidget { return SizedBox( height: 30, child: FlowyButton( - text: FlowyText.medium(text, color: Theme.of(context).hintColor), + text: FlowyText( + text, + lineHeight: 1.0, + color: Theme.of(context).hintColor, + ), hoverColor: AFThemeExtension.of(context).lightGreyHover, leftIcon: RotatedBox( quarterTurns: quarterTurns, @@ -314,21 +329,21 @@ class ToggleHiddenFieldsVisibilityButton extends StatelessWidget { constraints: const BoxConstraints(minWidth: double.infinity), child: TextButton.icon( style: Theme.of(context).textButtonTheme.style?.copyWith( - shape: MaterialStateProperty.all( + shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), ), - overlayColor: MaterialStateProperty.all( + overlayColor: WidgetStateProperty.all( Theme.of(context).hoverColor, ), alignment: AlignmentDirectional.centerStart, splashFactory: NoSplash.splashFactory, - padding: const MaterialStatePropertyAll( + padding: const WidgetStatePropertyAll( EdgeInsets.symmetric(vertical: 14, horizontal: 6), ), ), - label: FlowyText.medium( + label: FlowyText( text, fontSize: 15, color: Theme.of(context).hintColor, @@ -348,7 +363,7 @@ class ToggleHiddenFieldsVisibilityButton extends StatelessWidget { } } -class CreateRowFieldButton extends StatefulWidget { +class CreateRowFieldButton extends StatelessWidget { const CreateRowFieldButton({ super.key, required this.viewId, @@ -358,65 +373,35 @@ class CreateRowFieldButton extends StatefulWidget { final String viewId; final FieldController fieldController; - @override - State createState() => _CreateRowFieldButtonState(); -} - -class _CreateRowFieldButtonState extends State { - late PopoverController popoverController; - FieldPB? createdField; - - @override - void initState() { - popoverController = PopoverController(); - super.initState(); - } - @override Widget build(BuildContext context) { - return AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(240, 200)), - controller: popoverController, - direction: PopoverDirection.topWithLeftAligned, - triggerActions: PopoverTriggerFlags.none, - margin: EdgeInsets.zero, - child: SizedBox( - height: 30, - child: FlowyButton( - margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), - text: FlowyText.medium( - LocaleKeys.grid_field_newProperty.tr(), - color: Theme.of(context).hintColor, - ), - hoverColor: AFThemeExtension.of(context).lightGreyHover, - onTap: () async { - final result = await FieldBackendService.createField( - viewId: widget.viewId, - ); - result.fold( - (newField) { - createdField = newField; - popoverController.show(); - }, - (r) => Log.error("Failed to create field type option: $r"), - ); - }, - leftIcon: FlowySvg( - FlowySvgs.add_m, - color: Theme.of(context).hintColor, - ), + 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, ), ), - popupBuilder: (BuildContext popoverContext) { - if (createdField == null) { - return const SizedBox.shrink(); - } - return FieldEditor( - viewId: widget.viewId, - field: createdField!, - fieldController: widget.fieldController, - ); - }, ); } } 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 index b8d1560141..b4ee4134c9 100644 --- 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 @@ -1,35 +1,33 @@ 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/layout/layout_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_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 '../../grid/presentation/layout/sizes.dart'; - -class DatabaseLayoutSelector extends StatefulWidget { +class DatabaseLayoutSelector extends StatelessWidget { const DatabaseLayoutSelector({ super.key, required this.viewId, - required this.currentLayout, + required this.databaseController, }); final String viewId; - final DatabaseLayoutPB currentLayout; + final DatabaseController databaseController; - @override - State createState() => _DatabaseLayoutSelectorState(); -} - -class _DatabaseLayoutSelectorState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DatabaseLayoutBloc( - viewId: widget.viewId, - databaseLayout: widget.currentLayout, + viewId: viewId, + databaseLayout: databaseController.databaseLayout, )..add(const DatabaseLayoutEvent.initial()), child: BlocBuilder( builder: (context, state) { @@ -44,14 +42,57 @@ class _DatabaseLayoutSelectorState extends State { ), ) .toList(); - - return ListView.separated( - shrinkWrap: true, - itemCount: cells.length, + return Padding( padding: const EdgeInsets.symmetric(vertical: 6.0), - itemBuilder: (_, int index) => cells[index], - separatorBuilder: (_, __) => - VSpace(GridSize.typeOptionSeparatorHeight), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ListView.separated( + shrinkWrap: true, + itemCount: cells.length, + padding: EdgeInsets.zero, + itemBuilder: (_, int index) => cells[index], + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + ), + Container( + height: 1, + margin: EdgeInsets.fromLTRB(8, 4, 8, 0), + color: AFThemeExtension.of(context).borderColor, + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 2), + child: SizedBox( + height: 30, + child: FlowyButton( + resetHoverOnRebuild: false, + text: FlowyText( + LocaleKeys.grid_settings_compactMode.tr(), + lineHeight: 1.0, + ), + onTap: () { + databaseController.setCompactMode( + !databaseController.compactModeNotifier.value, + ); + }, + rightIcon: ValueListenableBuilder( + valueListenable: databaseController.compactModeNotifier, + builder: (context, compactMode, child) { + return Toggle( + value: compactMode, + duration: Duration.zero, + onChanged: (value) => + databaseController.setCompactMode(value), + padding: EdgeInsets.zero, + ); + }, + ), + ), + ), + ), + ], + ), ); }, ), @@ -76,10 +117,11 @@ class DatabaseViewLayoutCell extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: SizedBox( - height: GridSize.popoverItemHeight, + height: 30, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, - text: FlowyText.medium( + text: FlowyText( + lineHeight: 1.0, databaseLayout.layoutName, color: AFThemeExtension.of(context).textColor, ), 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 index 4d01970406..c7bc286371 100644 --- 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 @@ -3,10 +3,9 @@ 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/database_layout_selector.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.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/flowy_infra_ui.dart'; @@ -23,13 +22,13 @@ extension DatabaseSettingActionExtension on DatabaseSettingAction { FlowySvgData iconData() { switch (this) { case DatabaseSettingAction.showProperties: - return FlowySvgs.properties_s; + return FlowySvgs.multiselect_s; case DatabaseSettingAction.showLayout: - return FlowySvgs.database_layout_m; + return FlowySvgs.database_layout_s; case DatabaseSettingAction.showGroup: return FlowySvgs.group_s; case DatabaseSettingAction.showCalendarLayout: - return FlowySvgs.calendar_layout_m; + return FlowySvgs.calendar_layout_s; } } @@ -54,7 +53,7 @@ extension DatabaseSettingActionExtension on DatabaseSettingAction { final popover = switch (this) { DatabaseSettingAction.showLayout => DatabaseLayoutSelector( viewId: databaseController.viewId, - currentLayout: databaseController.databaseLayout, + databaseController: databaseController, ), DatabaseSettingAction.showGroup => DatabaseGroupList( viewId: databaseController.viewId, @@ -80,14 +79,16 @@ extension DatabaseSettingActionExtension on DatabaseSettingAction { height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, - text: FlowyText.medium( + text: FlowyText( title(), + lineHeight: 1.0, color: AFThemeExtension.of(context).textColor, ), leftIcon: FlowySvg( iconData(), color: Theme.of(context).iconTheme.color, ), + rightIcon: FlowySvg(FlowySvgs.database_settings_arrow_right_s), ), ), 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 index 7422db15ca..79d5e2410e 100644 --- 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 @@ -2,11 +2,11 @@ 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_editor/appflowy_editor.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({ @@ -21,7 +21,13 @@ class DatabaseSettingsList extends StatefulWidget { } class _DatabaseSettingsListState extends State { - late final PopoverMutex popoverMutex = PopoverMutex(); + final PopoverMutex popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -55,7 +61,7 @@ List actionsForDatabaseLayout(DatabaseLayoutPB? layout) { return [ DatabaseSettingAction.showProperties, DatabaseSettingAction.showLayout, - if (!PlatformExtension.isMobile) DatabaseSettingAction.showGroup, + if (!UniversalPlatform.isMobile) DatabaseSettingAction.showGroup, ]; case DatabaseLayoutPB.Calendar: return [ 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 index d5f3ce293a..6394a2ac1a 100644 --- 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 @@ -2,36 +2,39 @@ 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_menu_bloc.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/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.toggleExtension, + required this.features, }); final DatabaseController controller; - final ToggleExtensionNotifier toggleExtension; + final List features; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider( - create: (context) => DatabaseFilterMenuBloc( + BlocProvider( + create: (context) => FilterEditorBloc( viewId: controller.viewId, fieldController: controller.fieldController, - )..add(const DatabaseFilterMenuEvent.initial()), + ), ), BlocProvider( create: (context) => SortEditorBloc( @@ -40,38 +43,44 @@ class MobileDatabaseControls extends StatelessWidget { ), ), ], - child: BlocListener( - listenWhen: (p, c) => p.isVisible != c.isVisible, - listener: (context, state) => toggleExtension.toggle(), - child: ValueListenableBuilder( - valueListenable: controller.isLoading, - builder: (context, isLoading, child) { - if (isLoading) { - return const SizedBox.shrink(); - } + child: ValueListenableBuilder( + valueListenable: controller.isLoading, + builder: (context, isLoading, child) { + if (isLoading) { + return const SizedBox.shrink(); + } - return SeparatedRow( - separatorBuilder: () => const HSpace(8.0), - children: [ + return SeparatedRow( + separatorBuilder: () => const HSpace(8.0), + children: [ + if (features.contains(MobileDatabaseControlFeatures.sort)) _DatabaseControlButton( icon: FlowySvgs.sort_ascending_s, - count: context.watch().state.sortInfos.length, + count: context.watch().state.sorts.length, onTap: () => _showEditSortPanelFromToolbar( context, controller, ), ), + if (features.contains(MobileDatabaseControlFeatures.filter)) _DatabaseControlButton( - icon: FlowySvgs.m_field_hide_s, - onTap: () => _showDatabaseFieldListFromToolbar( + 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, + ), + ), + ], + ); + }, ), ); } @@ -128,7 +137,6 @@ void _showDatabaseFieldListFromToolbar( showHeader: true, showBackButton: true, title: LocaleKeys.grid_settings_properties.tr(), - showDivider: true, builder: (_) { return BlocProvider.value( value: context.read(), @@ -150,7 +158,7 @@ void _showEditSortPanelFromToolbar( showDragHandle: true, showDivider: false, useSafeArea: false, - backgroundColor: Theme.of(context).colorScheme.background, + backgroundColor: AFThemeExtension.of(context).background, builder: (_) { return BlocProvider.value( value: context.read(), @@ -159,3 +167,22 @@ void _showEditSortPanelFromToolbar( }, ); } + +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 index 7e52303b33..36a6436b2a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_button.dart @@ -1,14 +1,11 @@ -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/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.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_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; class SettingButton extends StatefulWidget { const SettingButton({super.key, required this.databaseController}); @@ -30,16 +27,17 @@ class _SettingButtonState extends State { direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 8), triggerActions: PopoverTriggerFlags.none, - child: FlowyTextButton( - LocaleKeys.settings_title.tr(), - fontColor: AFThemeExtension.of(context).textColor, - fontSize: FontSizes.s11, - fontWeight: FontWeight.w400, - fillColor: Colors.transparent, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - padding: GridSize.toolbarSettingButtonInsets, - radius: Corners.s4Border, - onPressed: _popoverController.show, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyIconButton( + tooltipText: LocaleKeys.settings_title.tr(), + width: 24, + height: 24, + iconPadding: const EdgeInsets.all(3), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + icon: const FlowySvg(FlowySvgs.settings_s), + 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 index ece69f9848..8c7d35b2e4 100644 --- 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 @@ -5,11 +5,10 @@ import 'package:appflowy/plugins/database/application/field/field_controller.dar 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/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -43,6 +42,12 @@ class _DatabasePropertyListState extends State { )..add(const DatabasePropertyEvent.initial()); } + @override + void dispose() { + _popoverMutex.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return BlocProvider.value( @@ -142,7 +147,8 @@ class _DatabasePropertyCellState extends State { margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 6), child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, - text: FlowyText.medium( + text: FlowyText( + lineHeight: 1.0, widget.fieldInfo.name, color: AFThemeExtension.of(context).textColor, ), @@ -166,10 +172,8 @@ class _DatabasePropertyCellState extends State { ), ), const HSpace(6.0), - FlowySvg( - widget.fieldInfo.fieldType.svgData, - color: Theme.of(context).iconTheme.color, - size: const Size.square(16), + FieldIcon( + fieldInfo: widget.fieldInfo, ), ], ), @@ -196,8 +200,9 @@ class _DatabasePropertyCellState extends State { popupBuilder: (BuildContext context) { return FieldEditor( viewId: widget.viewId, - field: widget.fieldInfo.field, + 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 index d6c84e39fe..7ede902085 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/share_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/share_button.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/share_bloc.dart'; import 'package:appflowy/startup/startup.dart'; @@ -13,6 +11,7 @@ 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 { @@ -39,11 +38,7 @@ class DatabaseShareButton extends StatelessWidget { ); }, child: BlocBuilder( - builder: (context, state) => ConstrainedBox( - constraints: const BoxConstraints.expand( - height: 30, - width: 100, - ), + builder: (context, state) => IntrinsicWidth( child: DatabaseShareActionList(view: view), ), ), @@ -106,6 +101,8 @@ class DatabaseShareActionListState extends State { 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: () {}, ), 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 index 9f600e4a4e..ee52be8c26 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart @@ -3,22 +3,30 @@ import 'package:appflowy/plugins/database/application/row/related_row_detail_blo 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_notification.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/ai/widgets/ai_writer_scroll_wrapper.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_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +import '../../workspace/application/view/view_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. @@ -45,18 +53,6 @@ class DatabaseDocumentPage extends StatefulWidget { 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( @@ -71,6 +67,10 @@ class _DatabaseDocumentPageState extends State { documentId: widget.documentId, )..add(const DocumentEvent.initial()), ), + BlocProvider( + create: (_) => + ViewBloc(view: widget.view)..add(const ViewEvent.initial()), + ), ], child: BlocBuilder( builder: (context, state) { @@ -83,9 +83,10 @@ class _DatabaseDocumentPageState extends State { final error = state.error; if (error != null || editorState == null) { Log.error(error); - return FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + return Center( + child: AppFlowyErrorPage( + error: error, + ), ); } @@ -96,7 +97,11 @@ class _DatabaseDocumentPageState extends State { return BlocListener( listener: _onNotificationAction, listenWhen: (_, curr) => curr.action != null, - child: _buildEditorPage(context, state), + child: AiWriterScrollWrapper( + viewId: widget.view.id, + editorState: editorState, + child: _buildEditorPage(context, state), + ), ); }, ), @@ -104,27 +109,44 @@ class _DatabaseDocumentPageState extends State { } Widget _buildEditorPage(BuildContext context, DocumentState state) { - final appflowyEditorPage = AppFlowyEditorPage( + final appflowyEditorPage = EditorDropHandler( + viewId: widget.view.id, editorState: state.editorState!, - styleCustomizer: EditorStyleCustomizer( - context: context, - // the 44 is the width of the left action list - padding: EditorStyleCustomizer.documentPadding, + isLocalMode: context.read().isLocalMode, + child: AppFlowyEditorPage( + editorState: state.editorState!, + styleCustomizer: EditorStyleCustomizer( + context: context, + padding: EditorStyleCustomizer.documentPadding, + editorState: state.editorState!, + ), + header: _buildDatabaseDataContent(context, state.editorState!), + initialSelection: widget.initialSelection, + useViewInfoBloc: false, + placeholderText: (node) => + node.type == ParagraphBlockKeys.type && !node.isInTable + ? LocaleKeys.editor_slashPlaceHolder.tr() + : '', ), - header: _buildDatabaseDataContent(context, state.editorState!), - initialSelection: widget.initialSelection, - useViewInfoBloc: false, ); - return Column( - children: [ - // Only show the indicator in integration test mode - // if (FlowyRunner.currentMode.isIntegrationTest) - // const DocumentSyncIndicator(), - - if (state.isDeleted) _buildBanner(context), - Expanded(child: appflowyEditorPage), - ], + return Provider( + create: (_) { + final context = SharedEditorContext(); + context.isInDatabaseRowPage = true; + return context; + }, + dispose: (_, editorContext) => editorContext.dispose(), + child: EditorTransactionService( + viewId: widget.view.id, + editorState: state.editorState!, + child: Column( + children: [ + if (state.isDeleted) _buildBanner(context), + Expanded(child: appflowyEditorPage), + ], + ), + ), ); } @@ -142,29 +164,39 @@ class _DatabaseDocumentPageState extends 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: Padding( - padding: EdgeInsets.only( - top: 24, - left: EditorStyleCustomizer.documentPadding.left + 16 + 6, - right: EditorStyleCustomizer.documentPadding.right, - ), - child: Column( - children: [ - RowPropertyList( + 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), - ], - ), + ), + const TypeOptionSeparator(spacing: 24.0), + ], ), ); }, @@ -176,6 +208,7 @@ class _DatabaseDocumentPageState extends State { Widget _buildBanner(BuildContext context) { return DocumentBanner( + viewName: widget.view.name, onRestore: () => context.read().add( const DocumentEvent.restorePage(), ), @@ -185,20 +218,6 @@ class _DatabaseDocumentPageState extends State { ); } - 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, 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 index ce3668b43d..fd238271b7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart @@ -1,9 +1,10 @@ -library document_plugin; +library; 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'; @@ -44,10 +45,13 @@ class DatabaseDocumentPluginBuilder extends PluginBuilder { String get menuName => LocaleKeys.document_menuName.tr(); @override - FlowySvgData get icon => FlowySvgs.document_s; + FlowySvgData get icon => FlowySvgs.icon_document_s; @override PluginType get pluginType => PluginType.databaseDocument; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class DatabaseDocumentPlugin extends Plugin { @@ -94,11 +98,18 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder final String documentId; final Selection? initialSelection; + @override + String? get viewName => view.nameOrDefault; + @override EdgeInsets get contentPadding => EdgeInsets.zero; @override - Widget buildWidget({PluginContext? context, required bool shrinkWrap}) { + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + Map? data, + }) { return BlocBuilder( builder: (_, state) => DatabaseDocumentPage( key: ValueKey(documentId), @@ -116,7 +127,8 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder ViewTitleBarWithRow(view: view, databaseId: databaseId, rowId: rowId); @override - Widget tabBarItem(String pluginId) => const SizedBox.shrink(); + Widget tabBarItem(String pluginId, [bool shortForm = false]) => + const SizedBox.shrink(); @override Widget? get rightBarItem => const SizedBox.shrink(); 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 index 47cf99b293..7f4493a999 100644 --- 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 @@ -1,24 +1,22 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_text.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/startup/tasks/app_window_size_manager.dart'; -import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; -import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.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. @@ -48,25 +46,16 @@ class ViewTitleBarWithRow extends StatelessWidget { if (state.ancestors.isEmpty) { return const SizedBox.shrink(); } - const maxWidth = WindowSizeManager.minWindowWidth - 200; - return LayoutBuilder( - builder: (context, constraints) { - return Visibility( - visible: maxWidth < constraints.maxWidth, - // if the width is too small, only show one view title bar without the ancestors - replacement: _ViewTitle( - key: ValueKey(state.ancestors.last), - view: state.ancestors.last, - maxTitleWidth: constraints.maxWidth - 50.0, - onUpdated: () {}, - ), - child: Row( - // refresh the view title bar when the ancestors changed - key: ValueKey(state.ancestors.hashCode), - children: _buildViewTitles(state.ancestors), - ), - ); - }, + 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), + ), + ), ); }, ), @@ -77,16 +66,22 @@ class ViewTitleBarWithRow extends StatelessWidget { // if the level is too deep, only show the root view, the database view and the row return views.length > 2 ? [ - _buildViewButton(views.first), - const FlowyText.regular('/'), - const FlowyText.regular(' ... /'), + _buildViewButton(views[1]), + const FlowySvg(FlowySvgs.title_bar_divider_s), + const FlowyText.regular(' ... '), + const FlowySvg(FlowySvgs.title_bar_divider_s), _buildViewButton(views.last), - const FlowyText.regular('/'), + const FlowySvg(FlowySvgs.title_bar_divider_s), _buildRowName(), ] : [ ...views - .map((e) => [_buildViewButton(e), const FlowyText.regular('/')]) + .map( + (e) => [ + _buildViewButton(e), + const FlowySvg(FlowySvgs.title_bar_divider_s), + ], + ) .flattened, _buildRowName(), ]; @@ -95,51 +90,48 @@ class ViewTitleBarWithRow extends StatelessWidget { Widget _buildViewButton(ViewPB view) { return FlowyTooltip( message: view.name, - child: _ViewTitle( + child: ViewTitle( view: view, - behavior: _ViewTitleBehavior.uneditable, + behavior: ViewTitleBehavior.uneditable, onUpdated: () {}, ), ); } Widget _buildRowName() { - return BlocBuilder( - builder: (context, state) { - if (state.databaseController == null) { - return const SizedBox.shrink(); - } - return _RowName( - cellBuilder: EditableCellBuilder( - databaseController: state.databaseController!, - ), - primaryFieldId: state.fieldId!, - rowId: rowId, - ); - }, + return _RowName( + rowId: rowId, ); } } class _RowName extends StatelessWidget { const _RowName({ - required this.cellBuilder, - required this.primaryFieldId, required this.rowId, }); - final EditableCellBuilder cellBuilder; - final String primaryFieldId; final String rowId; @override Widget build(BuildContext context) { - return cellBuilder.buildCustom( - CellContext( - fieldId: primaryFieldId, - rowId: rowId, - ), - skinMap: EditableCellSkinMap(textSkin: _TitleSkin()), + 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()), + ); + }, ); } } @@ -149,12 +141,13 @@ class _TitleSkin extends IEditableTextCellSkin { Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, + ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return BlocSelector( - selector: (state) => state.content, + selector: (state) => state.content ?? "", builder: (context, content) { final name = content.isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() @@ -174,31 +167,34 @@ class _TitleSkin extends IEditableTextCellSkin { popupBuilder: (_) { return RenameRowPopover( textController: textEditingController, - icon: state.icon ?? "", - onUpdateIcon: (String icon) { + icon: state.icon ?? EmojiIconData.none(), + onUpdateIcon: (icon) { context .read() .add(DatabaseDocumentTitleEvent.updateIcon(icon)); }, onUpdateName: (text) => bloc.add(TextCellEvent.updateText(text)), + tabs: const [PickerTabType.emoji], ); }, child: FlowyButton( useIntrinsicWidth: true, onTap: () {}, + margin: const EdgeInsets.symmetric(horizontal: 6), text: Row( children: [ - EmojiText( - emoji: state.icon ?? "", - fontSize: 18.0, - ), - const HSpace(2.0), + 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, ), ), ], @@ -213,108 +209,6 @@ class _TitleSkin extends IEditableTextCellSkin { } } -enum _ViewTitleBehavior { - editable, - uneditable, -} - -class _ViewTitle extends StatefulWidget { - const _ViewTitle({ - super.key, - required this.view, - this.behavior = _ViewTitleBehavior.editable, - this.maxTitleWidth = 180, - required this.onUpdated, - }); - - final ViewPB view; - final _ViewTitleBehavior behavior; - final double maxTitleWidth; - final VoidCallback onUpdated; - - @override - State<_ViewTitle> createState() => _ViewTitleState(); -} - -class _ViewTitleState extends State<_ViewTitle> { - late final viewListener = ViewListener(viewId: widget.view.id); - - String name = ''; - String icon = ''; - - @override - void initState() { - super.initState(); - - name = widget.view.name.isEmpty - ? LocaleKeys.document_title_placeholder.tr() - : widget.view.name; - icon = widget.view.icon.value; - - viewListener.start( - onViewUpdated: (view) { - if (name != view.name || icon != view.icon.value) { - widget.onUpdated(); - } - setState(() { - name = view.name.isEmpty - ? LocaleKeys.document_title_placeholder.tr() - : view.name; - icon = view.icon.value; - }); - }, - ); - } - - @override - void dispose() { - viewListener.stop(); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // root view - if (widget.view.parentViewId.isEmpty) { - return Row( - children: [ - FlowyText.regular(name), - const HSpace(4.0), - ], - ); - } - - final child = Row( - children: [ - EmojiText( - emoji: icon, - fontSize: 18.0, - ), - const HSpace(2.0), - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: widget.maxTitleWidth, - ), - child: FlowyText.regular( - name, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - - return Listener( - onPointerDown: (_) => context.read().openPlugin(widget.view), - child: FlowyButton( - useIntrinsicWidth: true, - onTap: () {}, - text: child, - ), - ); - } -} - class RenameRowPopover extends StatefulWidget { const RenameRowPopover({ super.key, @@ -322,13 +216,15 @@ class RenameRowPopover extends StatefulWidget { required this.onUpdateName, required this.onUpdateIcon, required this.icon, + this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); final TextEditingController textController; - final String icon; + final EmojiIconData icon; - final void Function(String name) onUpdateName; - final void Function(String icon) onUpdateIcon; + final ValueChanged onUpdateName; + final ValueChanged onUpdateIcon; + final List tabs; @override State createState() => _RenameRowPopoverState(); @@ -354,10 +250,11 @@ class _RenameRowPopoverState extends State { direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 18), defaultIcon: const FlowySvg(FlowySvgs.document_s), - onSubmitted: (emoji, _) { - widget.onUpdateIcon(emoji); - PopoverContainer.of(context).close(); + onSubmitted: (r, _) { + widget.onUpdateIcon(r.data); + if (!r.keepOpen) PopoverContainer.of(context).close(); }, + tabs: widget.tabs, ), const HSpace(6), SizedBox( 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 index 5f8bf7ca08..2711274cb2 100644 --- 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 @@ -1,3 +1,5 @@ +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'; @@ -10,6 +12,8 @@ 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 @@ -55,7 +59,7 @@ class DatabaseDocumentTitleBloc ); }, updateIcon: (icon) { - _updateMeta(icon); + _updateMeta(icon.emoji); }, ); }); @@ -65,7 +69,11 @@ class DatabaseDocumentTitleBloc _metaListener.start( callback: (rowMeta) { if (!isClosed) { - add(DatabaseDocumentTitleEvent.didUpdateRowIcon(rowMeta.icon)); + add( + DatabaseDocumentTitleEvent.didUpdateRowIcon( + EmojiIconData.emoji(rowMeta.icon), + ), + ); } }, ); @@ -87,6 +95,8 @@ class DatabaseDocumentTitleBloc viewId: view.id, rowCache: databaseController.rowCache, ); + unawaited(rowController.initialize()); + final primaryFieldId = await FieldBackendService.getPrimaryField(viewId: view.id).fold( (primaryField) => primaryField.id, @@ -112,7 +122,11 @@ class DatabaseDocumentTitleBloc // initialize icon if (rowInfo.rowMeta.icon.isNotEmpty) { - add(DatabaseDocumentTitleEvent.didUpdateRowIcon(rowInfo.rowMeta.icon)); + add( + DatabaseDocumentTitleEvent.didUpdateRowIcon( + EmojiIconData.emoji(rowInfo.rowMeta.icon), + ), + ); } } @@ -132,16 +146,19 @@ 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( - String icon, + EmojiIconData icon, ) = _DidUpdateRowIcon; + const factory DatabaseDocumentTitleEvent.updateIcon( - String icon, + EmojiIconData icon, ) = _UpdateIcon; } @@ -152,7 +169,7 @@ class DatabaseDocumentTitleState with _$DatabaseDocumentTitleState { required DatabaseController? databaseController, required RowController? rowController, required String? fieldId, - required String? icon, + required EmojiIconData? icon, }) = _DatabaseDocumentTitleState; factory DatabaseDocumentTitleState.initial() => 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 index 1a39c519a3..c65d818351 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart @@ -1,17 +1,20 @@ 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:bloc/bloc.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, @@ -23,6 +26,7 @@ class DocumentAppearance { 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`. @@ -39,6 +43,7 @@ class DocumentAppearance { bool cursorColorIsNull = false, bool selectionColorIsNull = false, bool textDirectionIsNull = false, + double? width, }) { return DocumentAppearance( fontSize: fontSize ?? this.fontSize, @@ -50,6 +55,7 @@ class DocumentAppearance { defaultTextDirection: textDirectionIsNull ? null : defaultTextDirection ?? this.defaultTextDirection, + width: width ?? this.width, ); } } @@ -57,10 +63,13 @@ class DocumentAppearance { class DocumentAppearanceCubit extends Cubit { DocumentAppearanceCubit() : super( - const DocumentAppearance( + DocumentAppearance( fontSize: 16.0, fontFamily: defaultFontFamily, codeFontFamily: builtInCodeFontFamily, + width: UniversalPlatform.isMobile + ? double.infinity + : EditorStyleCustomizer.maxDocumentWidth, ), ); @@ -82,6 +91,7 @@ class DocumentAppearanceCubit extends Cubit { 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'); @@ -100,6 +110,7 @@ class DocumentAppearanceCubit extends Cubit { cursorColorIsNull: cursorColor == null, selectionColorIsNull: selectionColor == null, textDirectionIsNull: defaultTextDirection == null, + width: width, ), ); } @@ -186,4 +197,21 @@ class DocumentAppearanceCubit extends Cubit { ); } } + + 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_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart index 038405c957..264ec4bb11 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -6,12 +6,13 @@ import 'package:appflowy/plugins/document/application/document_awareness_metadat 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_rules.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.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/app_widget.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'; @@ -19,20 +20,14 @@ 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/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.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, - LogLevel, - TransactionTime, - Selection, - Position, - paragraphNode; + show AppFlowyEditorLogLevel, EditorState, TransactionTime; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -40,12 +35,23 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'document_bloc.freezed.dart'; +/// Enable this flag to enable the internal log for +/// - document diff +/// - document integrity check +/// - document sync state +/// - document awareness states +bool enableDocumentInternalLog = false; + +final Map _documentBlocMap = {}; + class DocumentBloc extends Bloc { DocumentBloc({ required this.documentId, this.databaseViewId, this.rowId, - }) : _documentListener = DocumentListener(id: documentId), + bool saveToBlocMap = true, + }) : _saveToBlocMap = saveToBlocMap, + _documentListener = DocumentListener(id: documentId), _syncStateListener = DocumentSyncStateListener(id: documentId), super(DocumentState.initial()) { _viewListener = databaseViewId == null && rowId == null @@ -54,12 +60,17 @@ class DocumentBloc extends Bloc { 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; @@ -74,34 +85,57 @@ class DocumentBloc extends Bloc { documentService: _documentService, ); + late final DocumentRules _documentRules; + StreamSubscription? _transactionSubscription; - final _updateSelectionDebounce = Debounce(); - final _syncThrottle = Throttler(duration: const Duration(milliseconds: 500)); + 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 _shouldSync = false; bool get isLocalMode { final userProfilePB = state.userProfilePB; - final type = userProfilePB?.authenticator ?? AuthenticatorPB.Local; - return type == AuthenticatorPB.Local; + final type = userProfilePB?.workspaceAuthType ?? AuthTypePB.Local; + return type == AuthTypePB.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(); - return super.close(); } Future _onDocumentEvent( @@ -110,15 +144,12 @@ class DocumentBloc extends Bloc { ) async { await event.when( initial: () async { - _resetSyncTimer(); + if (_saveToBlocMap) { + _documentBlocMap[documentId] = this; + } final result = await _fetchDocumentState(); _onViewChanged(); _onDocumentChanged(); - result.onSuccess((s) { - if (s != null) { - _migrateCover(s); - } - }); final newState = await result.fold( (s) async { final userProfilePB = @@ -156,7 +187,7 @@ class DocumentBloc extends Bloc { }, restorePage: () async { if (databaseViewId == null && rowId == null) { - final result = await _trashService.putback(documentId); + final result = await TrashService.putback(documentId); final isDeleted = result.fold((l) => false, (r) => true); emit(state.copyWith(isDeleted: isDeleted)); } @@ -205,19 +236,6 @@ class DocumentBloc extends Bloc { ); } - void _resetSyncTimer() { - _syncTimer?.cancel(); - _syncTimer = null; - _syncTimer = Timer.periodic(const Duration(seconds: 10), (_) { - if (!_shouldSync) { - return; - } - Log.debug('auto sync document'); - // unawaited(_documentCollabAdapter.forceReload()); - _shouldSync = false; - }); - } - /// Fetch document Future> _fetchDocumentState() async { final result = await _documentService.openDocument(documentId: documentId); @@ -228,6 +246,10 @@ class DocumentBloc extends Bloc { } 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'); @@ -237,30 +259,47 @@ class DocumentBloc extends Bloc { final editorState = EditorState(document: document); _documentCollabAdapter = DocumentCollabAdapter(editorState, documentId); + _documentRules = DocumentRules(editorState: editorState); // subscribe to the document change from the editor _transactionSubscription = editorState.transactionStream.listen( - (event) async { - final time = event.$1; - final transaction = event.$2; + (value) async { + final time = value.$1; + final transaction = value.$2; + final options = value.$3; if (time != TransactionTime.before) { return; } + if (options.inMemoryUpdate) { + if (enableDocumentInternalLog) { + Log.trace('skip transaction for in-memory update'); + } + return; + } + + if (enableDocumentInternalLog) { + Log.trace( + '[TransactionAdapter] 1. transaction before apply: ${transaction.hashCode}', + ); + } + // apply transaction to backend await _transactionAdapter.apply(transaction, editorState); // check if the document is empty. - await _applyRules(); + await _documentRules.applyRules(value: value); + + if (enableDocumentInternalLog) { + Log.trace( + '[TransactionAdapter] 4. transaction after apply: ${transaction.hashCode}', + ); + } if (!isClosed) { // ignore: invalid_use_of_visible_for_testing_member emit(state.copyWith(isDocumentEmpty: editorState.document.isEmpty)); } - - // reset the sync timer - _shouldSync = true; - _resetSyncTimer(); }, ); @@ -269,61 +308,23 @@ class DocumentBloc extends Bloc { // output the log from the editor when debug mode if (kDebugMode) { editorState.logConfiguration - ..level = LogLevel.all + ..level = AppFlowyEditorLogLevel.all ..handler = (log) { - // Log.debug(log); + if (enableDocumentInternalLog) { + // Log.info(log); + } }; } return editorState; } - Future _applyRules() async { - await Future.wait([ - _ensureAtLeastOneParagraphExists(), - _ensureLastNodeIsEditable(), - ]); - } - - Future _ensureLastNodeIsEditable() async { - final editorState = state.editorState; - if (editorState == null) { - return; - } - final document = editorState.document; - final lastNode = document.root.children.lastOrNull; - if (lastNode == null || lastNode.delta == null) { - final transaction = editorState.transaction; - transaction.insertNode([document.root.children.length], paragraphNode()); - transaction.afterSelection = transaction.beforeSelection; - await editorState.apply(transaction); - } - } - - 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)); - - _resetSyncTimer(); } Future _onAwarenessStatesUpdate( @@ -347,13 +348,18 @@ class DocumentBloc extends Bloc { } void _throttleSyncDoc(DocEventPB docEvent) { - _shouldSync = true; + if (enableDocumentInternalLog) { + Log.info('[DocumentBloc] throttle sync doc: ${docEvent.toProto3Json()}'); + } _syncThrottle.call(() { _onDocumentStateUpdate(docEvent); }); } Future _onSelectionUpdate() async { + if (isClosing) { + return; + } final user = state.userProfilePB; final deviceId = ApplicationInfo.deviceId; if (!FeatureFlag.syncDocument.isOn || user == null) { @@ -371,7 +377,7 @@ class DocumentBloc extends Bloc { final basicColor = ColorGenerator(id.toString()).toColor(); final metadata = DocumentAwarenessMetadata( cursorColor: basicColor.toHexString(), - selectionColor: basicColor.withOpacity(0.6).toHexString(), + selectionColor: basicColor.withValues(alpha: 0.6).toHexString(), userName: user.name, userAvatar: user.iconUrl, ); @@ -394,7 +400,7 @@ class DocumentBloc extends Bloc { final basicColor = ColorGenerator(id.toString()).toColor(); final metadata = DocumentAwarenessMetadata( cursorColor: basicColor.toHexString(), - selectionColor: basicColor.withOpacity(0.6).toHexString(), + selectionColor: basicColor.withValues(alpha: 0.6).toHexString(), userName: user.name, userAvatar: user.iconUrl, ); @@ -404,12 +410,43 @@ class DocumentBloc extends Bloc { ); } - // from version 0.5.5, the cover is stored in the view.ext - Future _migrateCover(EditorState editorState) async { - final view = await ViewBackendService.getView(documentId); - view.onSuccess((s) { - return EditorMigration.migrateCoverIfNeeded(s, editorState); - }); + Future forceReloadDocumentState() { + return _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'); + + final context = AppGlobals.rootNavKey.currentContext; + if (context != null && context.mounted) { + showToastNotification( + message: 'document integrity check failed', + type: ToastificationType.error, + ); + } + } } } 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 index 43292817d1..f550093b54 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_collab_adapter.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_collab_adapter.dart @@ -2,6 +2,7 @@ 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/document_diff.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'; @@ -9,17 +10,21 @@ 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' hide Log; +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); + DocumentCollabAdapter( + this.editorState, + this.docId, + ); final EditorState editorState; final String docId; + final DocumentDiff diff = const DocumentDiff(); final _service = DocumentService(); @@ -75,13 +80,14 @@ class DocumentCollabAdapter { return; } - final ops = diffNodes(editorState.document.root, document.root); + final ops = diff.diffDocument(editorState.document, document); if (ops.isEmpty) { return; } - // Use for debugging, DO NOT REMOVE - // prettyPrintJson(ops.map((op) => op.toJson()).toList()); + if (enableDocumentInternalLog) { + prettyPrintJson(ops.map((op) => op.toJson()).toList()); + } final transaction = editorState.transaction; for (final op in ops) { @@ -89,18 +95,19 @@ class DocumentCollabAdapter { } 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; - // }()); + if (enableDocumentInternalLog) { + 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 { @@ -183,7 +190,7 @@ class DocumentCollabAdapter { for (final state in values) { // the following code is only for version 1 if (state.version != 1 || state.metadata.isEmpty) { - return; + continue; } final uid = state.user.uid.toString(); final did = state.user.deviceId; @@ -244,9 +251,8 @@ class DocumentCollabAdapter { ); remoteSelections.add(remoteSelection); } - if (remoteSelections.isNotEmpty) { - editorState.remoteSelections.value = remoteSelections; - } + + editorState.remoteSelections.value = remoteSelections; } } 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 index 7e5e4eb528..a0678372cf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart @@ -32,13 +32,17 @@ class DocumentCollaboratorsBloc emit( state.copyWith( shouldShowIndicator: - userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud, + userProfile?.workspaceAuthType == AuthTypePB.Server, ), ); final deviceId = ApplicationInfo.deviceId; if (userProfile != null) { _listener.start( onDocAwarenessUpdate: (states) { + if (isClosed) { + return; + } + add( DocumentCollaboratorsEvent.update( userProfile, @@ -81,7 +85,11 @@ class DocumentCollaboratorsBloc 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)); + // filter the duplicate users + ..retainWhere((e) => ids.add(e.user.uid.toString() + e.user.deviceId)) + // only keep version 1 and metadata is not empty + ..retainWhere((e) => e.version == 1) + ..retainWhere((e) => e.metadata.isNotEmpty); for (final state in sorted) { if (state.version != 1) { continue; 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 index e60782605a..38bf2bcd14 100644 --- 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 @@ -1,5 +1,6 @@ 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' @@ -10,8 +11,12 @@ import 'package:appflowy_editor/appflowy_editor.dart' Delta, ParagraphBlockKeys, NodeIterator, - NodeExternalValues; -import 'package:collection/collection.dart'; + NodeExternalValues, + HeadingBlockKeys, + NumberedListBlockKeys, + BulletedListBlockKeys, + blockComponentDelta; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:nanoid/nanoid.dart'; class ExternalValues extends NodeExternalValues { @@ -98,7 +103,7 @@ extension DocumentDataPBFromTo on DocumentDataPB { final children = []; if (childrenIds != null && childrenIds.isNotEmpty) { - children.addAll(childrenIds.map((e) => buildNode(e)).whereNotNull()); + children.addAll(childrenIds.map((e) => buildNode(e)).nonNulls); } final node = block?.toNode( @@ -144,21 +149,25 @@ extension BlockToNode on BlockPB { final deltaString = meta.textMap[externalId]; if (deltaString != null) { final delta = jsonDecode(deltaString); - map['delta'] = delta; - // map.putIfAbsent( - // 'delta', - // () => delta, - // ); + map[blockComponentDelta] = delta; } } } + Attributes adapterCallback(Attributes map) => map + ..putIfAbsent( + blockComponentDelta, + () => Delta().toJson(), + ); + final adapter = { - ParagraphBlockKeys.type: (Attributes map) => map - ..putIfAbsent( - 'delta', - () => Delta().toJson(), - ), + 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; } @@ -169,6 +178,8 @@ extension NodeToBlock on Node { String? parentId, String? childrenId, Attributes? attributes, + String? externalId, + String? externalType, }) { assert(id.isNotEmpty); final block = BlockPB.create() @@ -181,10 +192,29 @@ extension NodeToBlock on Node { 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) { - return jsonEncode(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_diff.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_diff.dart new file mode 100644 index 0000000000..e174d6671e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_diff.dart @@ -0,0 +1,172 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +/// DocumentDiff compares two document states and generates operations needed +/// to transform one state into another. +class DocumentDiff { + const DocumentDiff({ + this.enableDebugLog = false, + }); + + final bool enableDebugLog; + + // Using DeepCollectionEquality for deep comparison of collections + static const _equality = DeepCollectionEquality(); + + /// Generates operations needed to transform oldDocument into newDocument. + /// Returns a list of operations (Insert, Delete, Update) that can be applied sequentially. + List diffDocument(Document oldDocument, Document newDocument) { + return diffNode(oldDocument.root, newDocument.root); + } + + /// Compares two nodes and their children recursively to generate transformation operations. + /// Returns a list of operations that will transform oldNode into newNode. + List diffNode(Node oldNode, Node newNode) { + final operations = []; + + // Compare and update node attributes if they're different. + // Using DeepCollectionEquality instead of == for deep comparison of collections + if (!_equality.equals(oldNode.attributes, newNode.attributes)) { + operations.add( + UpdateOperation(oldNode.path, newNode.attributes, oldNode.attributes), + ); + } + + final oldChildrenById = Map.fromEntries( + oldNode.children.map((child) => MapEntry(child.id, child)), + ); + final newChildrenById = Map.fromEntries( + newNode.children.map((child) => MapEntry(child.id, child)), + ); + + // Insertion or Update + for (final newChild in newNode.children) { + final oldChild = oldChildrenById[newChild.id]; + if (oldChild == null) { + // If the node doesn't exist in the old document, it's a new node. + operations.add(InsertOperation(newChild.path, [newChild])); + } else { + // If the node exists in the old document, recursively compare its children + operations.addAll(diffNode(oldChild, newChild)); + } + } + + // Deletion + for (final id in oldChildrenById.keys) { + // If the node doesn't exist in the new document, it's a deletion. + if (!newChildrenById.containsKey(id)) { + final oldChild = oldChildrenById[id]!; + operations.add(DeleteOperation(oldChild.path, [oldChild])); + } + } + + // Optimize operations by merging consecutive inserts and deletes + return _optimizeOperations(operations); + } + + /// Optimizes the list of operations by merging consecutive operations where possible. + /// This reduces the total number of operations that need to be applied. + List _optimizeOperations(List operations) { + // Optimize the insert operations first, then the delete operations + final optimizedOps = mergeDeleteOperations( + mergeInsertOperations( + operations, + ), + ); + return optimizedOps; + } + + /// Merges consecutive insert operations to reduce the number of operations. + /// Operations are merged if they target consecutive paths in the document. + List mergeInsertOperations(List operations) { + if (enableDebugLog) { + _logOperations('mergeInsertOperations[before]', operations); + } + + final copy = [...operations]; + final insertOperations = operations + .whereType() + .sorted(_descendingCompareTo) + .toList(); + + _mergeConsecutiveOperations( + insertOperations, + (prev, current) => InsertOperation( + prev.path, + [...prev.nodes, ...current.nodes], + ), + ); + + if (insertOperations.isNotEmpty) { + copy + ..removeWhere((op) => op is InsertOperation) + ..insertAll(0, insertOperations); // Insert ops must be at the start + } + + if (enableDebugLog) { + _logOperations('mergeInsertOperations[after]', copy); + } + + return copy; + } + + /// Merges consecutive delete operations to reduce the number of operations. + /// Operations are merged if they target consecutive paths in the document. + List mergeDeleteOperations(List operations) { + if (enableDebugLog) { + _logOperations('mergeDeleteOperations[before]', operations); + } + + final copy = [...operations]; + final deleteOperations = operations + .whereType() + .sorted(_descendingCompareTo) + .toList(); + + _mergeConsecutiveOperations( + deleteOperations, + (prev, current) => DeleteOperation( + prev.path, + [...prev.nodes, ...current.nodes], + ), + ); + + if (deleteOperations.isNotEmpty) { + copy + ..removeWhere((op) => op is DeleteOperation) + ..addAll(deleteOperations); // Delete ops must be at the end + } + + if (enableDebugLog) { + _logOperations('mergeDeleteOperations[after]', copy); + } + + return copy; + } + + /// Merge consecutive operations of the same type + void _mergeConsecutiveOperations( + List operations, + T Function(T prev, T current) merge, + ) { + for (var i = operations.length - 1; i > 0; i--) { + final op = operations[i]; + final previousOp = operations[i - 1]; + + if (op.path.equals(previousOp.path.next)) { + operations + ..removeAt(i) + ..[i - 1] = merge(previousOp, op); + } + } + } + + void _logOperations(String prefix, List operations) { + debugPrint('$prefix: ${operations.map((op) => op.toJson()).toList()}'); + } + + int _descendingCompareTo(Operation a, Operation b) { + return a.path > b.path ? 1 : -1; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_rules.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_rules.dart new file mode 100644 index 0000000000..f530b1ef8d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_rules.dart @@ -0,0 +1,140 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// Apply rules to the document +/// +/// 1. ensure there is at least one paragraph in the document, otherwise the user will be blocked from typing +/// 2. remove columns block if its children are empty +class DocumentRules { + DocumentRules({ + required this.editorState, + }); + + final EditorState editorState; + + Future applyRules({ + required EditorTransactionValue value, + }) async { + await Future.wait([ + _ensureAtLeastOneParagraphExists(value: value), + _removeColumnIfItIsEmpty(value: value), + ]); + } + + Future _ensureAtLeastOneParagraphExists({ + required EditorTransactionValue value, + }) async { + final document = editorState.document; + if (document.root.children.isEmpty) { + final transaction = editorState.transaction; + transaction + ..insertNode([0], paragraphNode()) + ..afterSelection = Selection.collapsed( + Position(path: [0]), + ); + await editorState.apply(transaction); + } + } + + Future _removeColumnIfItIsEmpty({ + required EditorTransactionValue value, + }) async { + final transaction = value.$2; + final options = value.$3; + + if (options.inMemoryUpdate) { + return; + } + + for (final operation in transaction.operations) { + final deleteColumnsTransaction = editorState.transaction; + if (operation is DeleteOperation) { + final path = operation.path; + final column = editorState.document.nodeAtPath(path.parent); + if (column != null && column.type == SimpleColumnBlockKeys.type) { + // check if the column is empty + final children = column.children; + if (children.isEmpty) { + // delete the column or the columns + final columns = column.parent; + + if (columns != null && + columns.type == SimpleColumnsBlockKeys.type) { + final nonEmptyColumnCount = columns.children.fold( + 0, + (p, c) => c.children.isEmpty ? p : p + 1, + ); + + // Example: + // columns + // - column 1 + // - paragraph 1-1 + // - paragraph 1-2 + // - column 2 + // - paragraph 2 + // - column 3 + // - paragraph 3 + // + // case 1: delete the paragraph 3 from column 3. + // because there is only one child in column 3, we should delete the column 3 as well. + // the result should be: + // columns + // - column 1 + // - paragraph 1-1 + // - paragraph 1-2 + // - column 2 + // - paragraph 2 + // + // case 2: delete the paragraph 3 from column 3 and delete the paragraph 2 from column 2. + // in this case, there will be only one column left, so we should delete the columns block and flatten the children. + // the result should be: + // paragraph 1-1 + // paragraph 1-2 + + // if there is only one empty column left, delete the columns block and flatten the children + if (nonEmptyColumnCount <= 1) { + // move the children in columns out of the column + final children = columns.children + .map((e) => e.children) + .expand((e) => e) + .map((e) => e.deepCopy()) + .toList(); + deleteColumnsTransaction.insertNodes(columns.path, children); + deleteColumnsTransaction.deleteNode(columns); + } else { + // otherwise, delete the column + deleteColumnsTransaction.deleteNode(column); + + final deletedColumnRatio = + column.attributes[SimpleColumnBlockKeys.ratio]; + if (deletedColumnRatio != null) { + // update the ratio of the columns + final columnsNode = column.columnsParent; + if (columnsNode != null) { + final length = columnsNode.children.length; + for (final columnNode in columnsNode.children) { + final ratio = + columnNode.attributes[SimpleColumnBlockKeys.ratio] ?? + 1.0 / length; + if (ratio != null) { + deleteColumnsTransaction.updateNode(columnNode, { + ...columnNode.attributes, + SimpleColumnBlockKeys.ratio: + ratio + deletedColumnRatio / (length - 1), + }); + } + } + } + } + } + } + } + } + } + + if (deleteColumnsTransaction.operations.isNotEmpty) { + await editorState.apply(deleteColumnsTransaction); + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart index 6a0b79c90e..f520e20d02 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart @@ -1,4 +1,6 @@ +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'; @@ -36,6 +38,41 @@ class DocumentService { 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, @@ -114,20 +151,22 @@ class DocumentService { /// Upload a file to the cloud storage. Future> uploadFile({ required String localFilePath, - bool isAsync = true, + required String documentId, }) async { final workspace = await FolderEventReadCurrentWorkspace().send(); - return workspace.fold((l) async { - final payload = UploadFileParamsPB( - workspaceId: l.id, - localFilePath: localFilePath, - isAsync: isAsync, - ); - final result = await DocumentEventUploadFile(payload).send(); - return result; - }, (r) async { - return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); - }); + 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. @@ -136,7 +175,7 @@ class DocumentService { }) async { final workspace = await FolderEventReadCurrentWorkspace().send(); return workspace.fold((l) async { - final payload = UploadedFilePB( + final payload = DownloadFilePB( url: url, ); final result = await DocumentEventDownloadFile(payload).send(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_share_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_share_bloc.dart deleted file mode 100644 index af0d5081f1..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_share_bloc.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:io'; - -import 'package:appflowy/workspace/application/export/document_exporter.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 'document_share_bloc.freezed.dart'; - -class DocumentShareBloc extends Bloc { - DocumentShareBloc({ - required this.view, - }) : super(const DocumentShareState.initial()) { - on((event, emit) async { - await event.when( - share: (type, path) async { - if (DocumentShareType.unimplemented.contains(type)) { - Log.error('DocumentShareType $type is not implemented'); - return; - } - - emit(const DocumentShareState.loading()); - - final exporter = DocumentExporter(view); - final FlowyResult result = - await exporter.export(type.exportType).then((value) { - return value.fold( - (s) { - if (path != null) { - switch (type) { - case DocumentShareType.markdown: - return FlowyResult.success(_saveMarkdownToPath(s, path)); - case DocumentShareType.html: - return FlowyResult.success(_saveHTMLToPath(s, path)); - default: - break; - } - } - return FlowyResult.failure(FlowyError()); - }, - (f) => FlowyResult.failure(f), - ); - }); - - emit(DocumentShareState.finish(result)); - }, - ); - }); - } - - final ViewPB view; - - ExportDataPB _saveMarkdownToPath(String markdown, String path) { - File(path).writeAsStringSync(markdown); - return ExportDataPB() - ..data = markdown - ..exportType = ExportType.Markdown; - } - - ExportDataPB _saveHTMLToPath(String html, String path) { - File(path).writeAsStringSync(html); - return ExportDataPB() - ..data = html - ..exportType = ExportType.HTML; - } -} - -enum DocumentShareType { - markdown, - html, - text, - link; - - static List get unimplemented => [text, link]; - - DocumentExportType get exportType { - switch (this) { - case DocumentShareType.markdown: - return DocumentExportType.markdown; - case DocumentShareType.html: - return DocumentExportType.html; - case DocumentShareType.text: - return DocumentExportType.text; - case DocumentShareType.link: - throw UnsupportedError('DocumentShareType.link is not supported'); - } - } -} - -@freezed -class DocumentShareEvent with _$DocumentShareEvent { - const factory DocumentShareEvent.share(DocumentShareType type, String? path) = - Share; -} - -@freezed -class DocumentShareState with _$DocumentShareState { - const factory DocumentShareState.initial() = _Initial; - const factory DocumentShareState.loading() = _Loading; - const factory DocumentShareState.finish( - FlowyResult successOrFail, - ) = _Finish; -} 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 index 0fae90920d..7254539809 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart @@ -31,7 +31,7 @@ class DocumentSyncBloc extends Bloc { emit( state.copyWith( shouldShowIndicator: - userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud, + userProfile?.workspaceAuthType == AuthTypePB.Server, ), ); _syncStateListener.start( 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 0000000000..8fb2e8008d --- /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 index 666dea3f00..2094462d6d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart @@ -1,26 +1,18 @@ 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/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 - EditorState, - Transaction, - Operation, - InsertOperation, - UpdateOperation, - DeleteOperation, - PathExtensions, - Node, - Path, - Delta, - composeAttributes; +import 'package:appflowy_editor/appflowy_editor.dart'; 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. @@ -34,22 +26,34 @@ class TransactionAdapter { final DocumentService documentService; final String documentId; - final bool _enableDebug = false; - Future apply(Transaction transaction, EditorState editorState) async { - final stopwatch = Stopwatch()..start(); - if (_enableDebug) { - Log.debug('transaction => ${transaction.toJson()}'); + if (enableDocumentInternalLog) { + Log.info( + '[TransactionAdapter] 2. apply transaction begin ${transaction.hashCode} in $hashCode', + ); } - final actions = transaction.operations - .map((op) => op.toBlockAction(editorState, documentId)) - .whereNotNull() - .expand((element) => element) - .toList(growable: false); // avoid lazy evaluation - final textActions = actions.where( - (e) => - e.textDeltaType != TextDeltaType.none && e.textDeltaPayloadPB != null, - ); + + 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!; @@ -60,8 +64,10 @@ class TransactionAdapter { textId: payload.textId, delta: payload.delta, ); - if (_enableDebug) { - Log.debug('create external text: ${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( @@ -69,25 +75,66 @@ class TransactionAdapter { textId: payload.textId, delta: payload.delta, ); - if (_enableDebug) { - Log.debug('update external text: ${payload.delta}'); + if (enableDocumentInternalLog) { + Log.info( + '[editor_transaction_adapter] update external text: id: ${payload.textId} delta: ${payload.delta}', + ); } } } - final blockActions = - actions.map((e) => e.blockActionPB).toList(growable: false); + + 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 (_enableDebug) { - Log.debug( - 'apply transaction cost: total $elapsed ms, converter action $actionCostTime ms, apply action ${elapsed - actionCostTime} ms', + 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)) + .nonNulls + .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 { @@ -116,28 +163,32 @@ extension on InsertOperation { Path currentPath = path; final List actions = []; for (final node in nodes) { + if (node.type == AiWriterBlockKeys.type) { + continue; + } + final parentId = node.parent?.id ?? editorState.getNodeAtPath(currentPath.parent)?.id ?? ''; - var prevId = previousNode?.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 ??= editorState.getNodeAtPath(currentPath.previous)?.id ?? ''; - } - prevId ??= ''; - assert(parentId.isNotEmpty); - if (isFirstChild) { - prevId = ''; - } else { + 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) { - final textId = nanoid(6); + textId = nanoid(6); textDeltaPayloadPB = TextDeltaPayloadPB( documentId: documentId, @@ -148,13 +199,18 @@ extension on InsertOperation { // sync the text id to the node node.externalValues = ExternalValues( externalId: textId, - externalType: 'text', + externalType: kExternalTextType, ); } // remove the delta from the data when the incremental update is stable. final payload = BlockActionPayloadPB() - ..block = node.toBlock(childrenId: nanoid(6)) + ..block = node.toBlock( + childrenId: nanoid(6), + externalId: textId, + externalType: textId != null ? kExternalTextType : null, + attributes: {...node.attributes}..remove(blockComponentDelta), + ) ..parentId = parentId ..prevId = prevId; @@ -216,18 +272,17 @@ extension on UpdateOperation { assert(parentId.isNotEmpty); // create the external text if the node contains the delta in its data. - final prevDelta = oldAttributes['delta']; - final delta = attributes['delta']; - final diff = prevDelta != null && delta != null - ? Delta.fromJson(prevDelta).diff( - Delta.fromJson(delta), - ) - : null; + final prevDelta = oldAttributes[blockComponentDelta]; + final delta = attributes[blockComponentDelta]; + + final composedAttributes = composeAttributes(oldAttributes, attributes); + final composedDelta = composedAttributes?[blockComponentDelta]; + composedAttributes?.remove(blockComponentDelta); final payload = BlockActionPayloadPB() ..block = node.toBlock( parentId: parentId, - attributes: composeAttributes(oldAttributes, attributes), + attributes: composedAttributes, ) ..parentId = parentId; final blockActionPB = BlockActionPB() @@ -238,19 +293,32 @@ extension on UpdateOperation { 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 textDeltaPayloadPB = delta == null + final textDelta = composedDelta ?? delta ?? prevDelta; + final correctedTextDelta = + textDelta != null ? _correctAttributes(textDelta) : null; + + final textDeltaPayloadPB = correctedTextDelta == null ? null : TextDeltaPayloadPB( documentId: documentId, textId: textId, - delta: jsonEncode(delta), + delta: jsonEncode(correctedTextDelta), ); node.externalValues = ExternalValues( externalId: textId, - externalType: 'text', + 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, @@ -259,14 +327,31 @@ extension on UpdateOperation { ), ); } else { - final textDeltaPayloadPB = delta == null + final diff = prevDelta != null && delta != null + ? Delta.fromJson(prevDelta).diff( + Delta.fromJson(delta), + ) + : null; + + final correctedDiff = diff != null ? _correctDelta(diff) : null; + + final textDeltaPayloadPB = correctedDiff == null ? null : TextDeltaPayloadPB( documentId: documentId, textId: textId, - delta: jsonEncode(diff), + delta: jsonEncode(correctedDiff), ); + 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, @@ -278,6 +363,58 @@ extension on UpdateOperation { return actions; } + + // if the value in Delta's attributes is false, we should set the value to null instead. + // because on Yjs, canceling format must use the null value. If we use false, the update will be rejected. + List? _correctDelta(Delta delta) { + // if the value in diff's attributes is false, we should set the value to null instead. + // because on Yjs, canceling format must use the null value. If we use false, the update will be rejected. + final correctedOps = delta.map((op) { + final attributes = op.attributes?.map( + (key, value) => MapEntry( + key, + // if the value is false, we should set the value to null instead. + value == false ? null : value, + ), + ); + + if (attributes != null) { + if (op is TextRetain) { + return TextRetain(op.length, attributes: attributes); + } else if (op is TextInsert) { + return TextInsert(op.text, attributes: attributes); + } + // ignore the other operations that do not contain attributes. + } + + return op; + }); + + return correctedOps.toList(growable: false); + } + + // Refer to [_correctDelta] for more details. + List> _correctAttributes( + List> attributes, + ) { + final correctedAttributes = attributes.map((attribute) { + return attribute.map((key, value) { + if (value is bool) { + return MapEntry(key, value == false ? null : value); + } else if (value is Map) { + return MapEntry( + key, + value.map((key, value) { + return MapEntry(key, value == false ? null : value); + }), + ); + } + return MapEntry(key, value); + }); + }).toList(growable: false); + + return correctedAttributes; + } } extension on DeleteOperation { diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/prelude.dart b/frontend/appflowy_flutter/lib/plugins/document/application/prelude.dart index 78f30608d7..a6497bf6de 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/prelude.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/prelude.dart @@ -1,3 +1,3 @@ +export '../../shared/share/share_bloc.dart'; export 'document_bloc.dart'; export 'document_service.dart'; -export 'document_share_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index 13242bbe0a..4ebc6f1b47 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -1,17 +1,17 @@ -library document_plugin; - -import 'package:flutter/material.dart'; +library; 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/document/presentation/share/share_button.dart'; -import 'package:appflowy/plugins/shared/sync_indicator.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/shared/icon_emoji_picker/tab.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'; @@ -22,6 +22,7 @@ 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 { @@ -38,13 +39,13 @@ class DocumentPluginBuilder extends PluginBuilder { String get menuName => LocaleKeys.document_menuName.tr(); @override - FlowySvgData get icon => FlowySvgs.document_s; + FlowySvgData get icon => FlowySvgs.icon_document_s; @override PluginType get pluginType => PluginType.document; @override - ViewLayoutPB? get layoutType => ViewLayoutPB.Document; + ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class DocumentPlugin extends Plugin { @@ -52,6 +53,7 @@ class DocumentPlugin extends Plugin { required ViewPB view, required PluginType pluginType, this.initialSelection, + this.initialBlockId, }) : notifier = ViewPluginNotifier(view: view) { _pluginType = pluginType; } @@ -62,13 +64,18 @@ class DocumentPlugin extends Plugin { @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 @@ -96,19 +103,26 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder 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({PluginContext? context, required bool shrinkWrap}) { + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + Map? data, + }) { notifier.isDeleted.addListener(() { final deletedView = notifier.isDeleted.value; if (deletedView != null && deletedView.hasIndex()) { @@ -116,24 +130,40 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder } }); + final fixedTitle = data?[MobileDocumentScreen.viewFixedTitle]; + final blockId = initialBlockId ?? data?[MobileDocumentScreen.viewBlockId]; + final tabs = data?[MobileDocumentScreen.viewSelectTabs] ?? + const [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ]; + return BlocProvider.value( value: bloc, child: BlocBuilder( builder: (_, state) => DocumentPage( key: ValueKey(view.id), view: view, - onDeleted: () => context?.onDeleted(view, deletedViewIndex), + onDeleted: () => context.onDeleted?.call(view, deletedViewIndex), initialSelection: initialSelection, + initialBlockId: blockId, + fixedTitle: fixedTitle, + tabs: tabs, ), ), ); } @override - Widget get leftBarItem => ViewTitleBar(view: view); + String? get viewName => notifier.view.nameOrDefault; @override - Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view); + 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 { @@ -146,23 +176,18 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder ? [ DocumentCollaborators( key: ValueKey('collaborators_${view.id}'), - width: 150, + width: 120, height: 32, view: view, ), const HSpace(16), - DocumentSyncIndicator( - key: ValueKey('sync_state_${view.id}'), - view: view, - ), - const HSpace(16), ] : [const HSpace(8)], - DocumentShareButton( + ShareButton( key: ValueKey('share_button_${view.id}'), view: view, ), - const HSpace(4), + const HSpace(10), ViewFavoriteButton( key: ValueKey('favorite_button_${view.id}'), view: view, diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 231ff37bc9..8716bb7ae2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -1,36 +1,50 @@ -import 'package:flutter/material.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/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; -import 'package:appflowy/plugins/document/presentation/editor_notification.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/ai/widgets/ai_writer_scroll_wrapper.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/shared/icon_emoji_picker/tab.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/application/view/view_lock_status_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' hide Log; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/widget/error_page.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, + required this.tabs, this.initialSelection, + this.initialBlockId, + this.fixedTitle, }); final ViewPB view; final VoidCallback onDeleted; final Selection? initialSelection; + final String? initialBlockId; + final String? fixedTitle; + final List tabs; @override State createState() => _DocumentPageState(); @@ -39,6 +53,7 @@ class DocumentPage extends StatefulWidget { class _DocumentPageState extends State with WidgetsBindingObserver { EditorState? editorState; + Selection? initialSelection; late final documentBloc = DocumentBloc(documentId: widget.view.id) ..add(const DocumentEvent.initial()); @@ -46,14 +61,13 @@ class _DocumentPageState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - EditorNotification.addListener(_onEditorNotification); } @override void dispose() { - EditorNotification.removeListener(_onEditorNotification); WidgetsBinding.instance.removeObserver(this); documentBloc.close(); + super.dispose(); } @@ -73,103 +87,178 @@ class _DocumentPageState extends State providers: [ BlocProvider.value(value: getIt()), BlocProvider.value(value: documentBloc), + BlocProvider.value( + value: ViewLockStatusBloc(view: widget.view) + ..add(ViewLockStatusEvent.initial()), + ), + BlocProvider( + create: (context) => + ViewBloc(view: widget.view)..add(const ViewEvent.initial()), + lazy: false, + ), ], - child: BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator.adaptive()); + child: BlocConsumer( + listenWhen: (prev, curr) => curr.isLocked != prev.isLocked, + listener: (context, lockStatusState) { + if (lockStatusState.isLoadingLockStatus) { + return; } + editorState?.editable = !lockStatusState.isLocked; + }, + builder: (context, lockStatusState) { + return 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 FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), - ); - } + 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(); - } + if (state.forceClose) { + widget.onDeleted(); + return const SizedBox.shrink(); + } - return BlocListener( - listenWhen: (_, curr) => curr.action != null, - listener: _onNotificationAction, - child: _buildEditorPage(context, state), + return MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) => + editorState.editable = !state.isLocked, + ), + BlocListener( + listenWhen: (_, curr) => curr.action != null, + listener: onNotificationAction, + ), + ], + child: AiWriterScrollWrapper( + viewId: widget.view.id, + editorState: editorState, + child: buildEditorPage(context, state), + ), + ); + }, ); }, ), ); } - Widget _buildEditorPage(BuildContext context, DocumentState state) { - final Widget child; + Widget buildEditorPage( + BuildContext context, + DocumentState state, + ) { + final editorState = state.editorState; + if (editorState == null) { + return const SizedBox.shrink(); + } - if (PlatformExtension.isMobile) { + 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) { - return AppFlowyEditorPage( - editorState: state.editorState!, - styleCustomizer: EditorStyleCustomizer( - context: context, - // the 44 is the width of the left action list - padding: EditorStyleCustomizer.documentPadding, - ), - header: _buildCoverAndIcon(context, state), - initialSelection: widget.initialSelection, - ); - }, + 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, + editorState: editorState, + ), + header: buildCoverAndIcon(context, state), + initialSelection: initialSelection, + ), ); } else { - child = AppFlowyEditorPage( - editorState: state.editorState!, - styleCustomizer: EditorStyleCustomizer( - context: context, - // the 44 is the width of the left action list - padding: EditorStyleCustomizer.documentPadding, + 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, + editorState: editorState, + ), + header: buildCoverAndIcon(context, state), + initialSelection: initialSelection, + placeholderText: (node) => + node.type == ParagraphBlockKeys.type && !node.isInTable + ? LocaleKeys.editor_slashPlaceHolder.tr() + : '', ), - header: _buildCoverAndIcon(context, state), - initialSelection: widget.initialSelection, ); } - return Column( - children: [ - // Only show the indicator in integration test mode - // if (FlowyRunner.currentMode.isIntegrationTest) - // const DocumentSyncIndicator(), - - if (state.isDeleted) _buildBanner(context), - Expanded(child: child), - ], + 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: [ + // the banner only shows on desktop + if (state.isDeleted && UniversalPlatform.isDesktop) + buildBanner(context), + Expanded(child: child), + ], + ), + ), ); } - Widget _buildBanner(BuildContext context) { + Widget buildBanner(BuildContext context) { return DocumentBanner( - onRestore: () => context.read().add( - const DocumentEvent.restorePage(), - ), + viewName: widget.view.nameOrDefault, + onRestore: () => + context.read().add(const DocumentEvent.restorePage()), onDelete: () => context .read() .add(const DocumentEvent.deletePermanently()), ); } - Widget _buildCoverAndIcon(BuildContext context, DocumentState state) { + Widget buildCoverAndIcon(BuildContext context, DocumentState state) { final editorState = state.editorState; final userProfilePB = state.userProfilePB; if (editorState == null || userProfilePB == null) { return const SizedBox.shrink(); } - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { return DocumentImmersiveCover( + fixedTitle: widget.fixedTitle, view: widget.view, + tabs: widget.tabs, userProfilePB: userProfilePB, ); } @@ -177,43 +266,116 @@ class _DocumentPageState extends State final page = editorState.document.root; return DocumentCoverWidget( node: page, + tabs: widget.tabs, editorState: editorState, view: widget.view, onIconChanged: (icon) async => ViewBackendService.updateViewIcon( - viewId: widget.view.id, + view: widget.view, viewIcon: icon, ), ); } - 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) { - editorState.selection = null; - } - } - - void _onNotificationAction( + void onNotificationAction( BuildContext context, ActionNavigationState state, ) { - if (state.action != null && state.action!.type == ActionType.jumpToBlock) { - final path = state.action?.arguments?[ActionArgumentKeys.nodePath]; + 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 && widget.view.id == state.action?.objectId) { - editorState.updateSelectionWithReason( - Selection.collapsed(Position(path: [path])), + 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 index 3936bf6968..856763e9b9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/banner.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/banner.dart @@ -1,18 +1,21 @@ +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'; -import 'package:appflowy/generated/locale_keys.g.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; @@ -23,7 +26,7 @@ class DocumentBanner extends StatelessWidget { constraints: const BoxConstraints(minHeight: 60), child: Container( width: double.infinity, - color: colorScheme.surfaceVariant, + color: colorScheme.surfaceContainerHighest, child: FittedBox( fit: BoxFit.scaleDown, child: Row( @@ -58,7 +61,16 @@ class DocumentBanner extends StatelessWidget { highlightColor: Theme.of(context).colorScheme.error, outlineColor: colorScheme.tertiaryContainer, borderRadius: Corners.s8Border, - onPressed: onDelete, + 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, 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 index 72913a68ed..1be5a41d81 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart @@ -1,7 +1,6 @@ -import 'package:flutter/material.dart'; - import 'package:avatar_stack/avatar_stack.dart'; import 'package:avatar_stack/positions.dart'; +import 'package:flutter/material.dart'; class CollaboratorAvatarStack extends StatelessWidget { const CollaboratorAvatarStack({ @@ -31,14 +30,14 @@ class CollaboratorAvatarStack extends StatelessWidget { Widget build(BuildContext context) { final settings = this.settings ?? RestrictedPositions( - maxCoverage: 0.3, - minCoverage: 0.2, + maxCoverage: 0.4, + minCoverage: 0.3, align: StackAlign.right, laying: StackLaying.first, ); final border = BorderSide( - color: borderColor ?? Theme.of(context).colorScheme.onPrimary, + color: borderColor ?? Theme.of(context).dividerColor, width: borderWidth ?? 2.0, ); @@ -47,7 +46,7 @@ class CollaboratorAvatarStack extends StatelessWidget { width: width, child: WidgetStack( positions: settings, - buildInfoWidget: (value) => plusWidgetBuilder(value, border), + buildInfoWidget: (value, _) => plusWidgetBuilder(value, border), stackedWidgets: avatars .map( (avatar) => CircleAvatar( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/compact_mode_event.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/compact_mode_event.dart new file mode 100644 index 0000000000..eaee989bbc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/compact_mode_event.dart @@ -0,0 +1,13 @@ +import 'package:event_bus/event_bus.dart'; + +EventBus compactModeEventBus = EventBus(); + +class CompactModeEvent { + CompactModeEvent({ + required this.id, + required this.enable, + }); + + final String id; + final bool enable; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart index 9da14f7b3a..d4a6815e32 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart @@ -1,12 +1,13 @@ +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/workspace/presentation/widgets/user_avatar.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: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'; class DocumentCollaborators extends StatelessWidget { const DocumentCollaborators({ @@ -66,21 +67,11 @@ class DocumentCollaborators extends StatelessWidget { ), ); }, - avatars: collaborators - .map( - (c) => FlowyTooltip( - message: c.userName, - child: CircleAvatar( - backgroundColor: c.cursorColor.tryToColor(), - child: FlowyText( - c.userName.characters.firstOrNull ?? ' ', - fontSize: fontSize, - color: Colors.black, - ), - ), - ), - ) - .toList(), + avatars: [ + ...collaborators.map( + (c) => _UserAvatar(fontSize: fontSize, user: c, width: width), + ), + ], ), ); }, @@ -88,3 +79,30 @@ class DocumentCollaborators extends StatelessWidget { ); } } + +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) { + return FlowyTooltip( + message: user.userName, + child: IgnorePointer( + child: UserAvatar( + iconUrl: user.userAvatar, + name: user.userName, + size: 30.0, + fontSize: fontSize ?? (UniversalPlatform.isMobile ? 14 : 12), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index 8f18cb1d58..5e7eefc24e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -1,233 +1,434 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.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_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/image/custom_image_block_component.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/appflowy_editor.dart' + hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; 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'; -Map getEditorBuilderMap({ +import 'editor_plugins/link_preview/custom_link_preview_block_component.dart'; +import 'editor_plugins/page_block/custom_page_block_component.dart'; + +/// A global configuration for the editor. +class EditorGlobalConfiguration { + /// Whether to enable the drag menu in the editor. + /// + /// Case 1, resizing the columns block in the desktop, then the drag menu will be disabled. + static ValueNotifier enableDragMenu = ValueNotifier(true); +} + +/// 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, + CalloutBlockKeys.type, + + // Simple table + SimpleTableBlockKeys.type, + SimpleTableRowBlockKeys.type, + SimpleTableCellBlockKeys.type, + + // Columns + SimpleColumnsBlockKeys.type, + SimpleColumnBlockKeys.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, - List? slashMenuItems, + SlashMenuItemsBuilder? slashMenuItemsBuilder, bool editable = true, ShowPlaceholder? showParagraphPlaceholder, String Function(Node)? placeholderText, + EdgeInsets? customHeadingPadding, + bool alwaysDistributeSimpleTableColumnWidths = false, }) { - final standardActions = [OptionAction.delete, OptionAction.duplicate]; + final configuration = _buildDefaultConfiguration(context); + final builders = _buildBlockComponentBuilderMap( + context, + configuration: configuration, + editorState: editorState, + styleCustomizer: styleCustomizer, + showParagraphPlaceholder: showParagraphPlaceholder, + placeholderText: placeholderText, + alwaysDistributeSimpleTableColumnWidths: + alwaysDistributeSimpleTableColumnWidths, + ); - final calloutBGColor = AFThemeExtension.of(context).calloutBGColor; + // 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( - // use EdgeInsets.zero to remove the default padding. - padding: (_) { - if (PlatformExtension.isMobile) { + padding: (node) { + if (UniversalPlatform.isMobile) { final pageStyle = context.read().state; final factor = pageStyle.fontLayout.factor; - final padding = pageStyle.lineHeightLayout.padding * factor; - return EdgeInsets.only(top: padding); + 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), + indentPadding: (node, textDirection) { + double padding = 26.0; + + // only add indent padding for the top level node to align the children + if (UniversalPlatform.isMobile && node.level == 1) { + padding += EditorStyleCustomizer.nodeHorizontalPadding - 4; + } + + // in the quote block, we reduce the indent padding for the first level block. + // So we have to add more padding for the second level to avoid the drag menu overlay the quote icon. + if (node.parent?.type == QuoteBlockKeys.type && + UniversalPlatform.isDesktop) { + padding += 22; + } + + return textDirection == TextDirection.ltr + ? EdgeInsets.only(left: padding) + : EdgeInsets.only(right: padding); + }, ); + return configuration; +} - final customBlockComponentBuilderMap = { - PageBlockKeys.type: PageBlockComponentBuilder(), - ParagraphBlockKeys.type: ParagraphBlockComponentBuilder( - configuration: configuration.copyWith(placeholderText: placeholderText), - showPlaceholder: showParagraphPlaceholder, - ), - TodoListBlockKeys.type: TodoListBlockComponentBuilder( - configuration: configuration.copyWith( - placeholderText: (_) => LocaleKeys.blockPlaceholders_todoList.tr(), - ), - iconBuilder: PlatformExtension.isMobile - ? (_, node, onCheck) => TodoListIcon(node: node, onCheck: onCheck) - : null, - toggleChildrenTriggers: [ - LogicalKeyboardKey.shift, - LogicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.shiftRight, - ], - ), - BulletedListBlockKeys.type: BulletedListBlockComponentBuilder( - configuration: configuration.copyWith( - placeholderText: (_) => LocaleKeys.blockPlaceholders_bulletList.tr(), - ), - iconBuilder: PlatformExtension.isMobile - ? (_, node) => BulletedListIcon(node: node) - : null, - ), - NumberedListBlockKeys.type: NumberedListBlockComponentBuilder( - configuration: configuration.copyWith( - placeholderText: (_) => LocaleKeys.blockPlaceholders_numberList.tr(), - ), - iconBuilder: PlatformExtension.isMobile - ? (_, node, textDirection) => - NumberedListIcon(node: node, textDirection: textDirection) - : null, - ), - QuoteBlockKeys.type: QuoteBlockComponentBuilder( - configuration: configuration.copyWith( - placeholderText: (_) => LocaleKeys.blockPlaceholders_quote.tr(), - ), - ), - HeadingBlockKeys.type: HeadingBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (node) { - if (PlatformExtension.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; - return EdgeInsets.only(top: headingPaddings.elementAt(level)); - } +/// 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, + ]; - return const EdgeInsets.only(top: 12.0, bottom: 4.0); - }, - placeholderText: (node) => LocaleKeys.blockPlaceholders_heading.tr( - args: [node.attributes[HeadingBlockKeys.level].toString()], - ), - ), - textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level), - ), - ImageBlockKeys.type: CustomImageBlockComponentBuilder( - configuration: configuration, - showMenu: true, - menuBuilder: (Node node, CustomImageBlockComponentState state) => - Positioned( - top: 0, - right: 10, - child: ImageMenu(node: node, state: state), - ), - ), - TableBlockKeys.type: TableBlockComponentBuilder( - menuBuilder: (node, editorState, position, dir, onBuild, onClose) => - TableMenu( - node: node, - editorState: editorState, - position: position, - dir: dir, - onBuild: onBuild, - onClose: onClose, - ), - ), - TableCellBlockKeys.type: TableCellBlockComponentBuilder( - colorBuilder: (context, node) { - final String colorString = - node.attributes[TableCellBlockKeys.colBackgroundColor] ?? - node.attributes[TableCellBlockKeys.rowBackgroundColor] ?? - ''; - if (colorString.isEmpty) { - return null; + // 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 buildEditorCustomizedColor(context, node, colorString); - }, - menuBuilder: (node, editorState, position, dir, onBuild, onClose) => - TableMenu( - node: node, - editorState: editorState, - position: position, - dir: dir, - onBuild: onBuild, - onClose: onClose, - ), + return true; + }; + + builder.configuration = builder.configuration.copyWith( + blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric( + vertical: 1, + ), + ); + + builder.actionTrailingBuilder = (context, state) { + if (context.node.parent?.type == QuoteBlockKeys.type) { + return const SizedBox( + width: 24, + height: 24, + ); + } + return const SizedBox.shrink(); + }; + + 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; + } + if (overflowTypes.contains(type)) { + top = top / 2; + } + return ValueListenableBuilder( + valueListenable: EditorGlobalConfiguration.enableDragMenu, + builder: (_, enableDragMenu, child) { + return ValueListenableBuilder( + valueListenable: editorState.editableNotifier, + builder: (_, editable, child) { + return IgnorePointer( + ignoring: !editable, + child: Opacity( + opacity: editable && enableDragMenu ? 1.0 : 0.0, + child: 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, + bool alwaysDistributeSimpleTableColumnWidths = false, +}) { + final customBlockComponentBuilderMap = { + PageBlockKeys.type: CustomPageBlockComponentBuilder(), + ParagraphBlockKeys.type: _buildParagraphBlockComponentBuilder( + context, + configuration, + showParagraphPlaceholder, + placeholderText, ), - DatabaseBlockKeys.gridType: DatabaseViewBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.symmetric(vertical: 10), - ), + TodoListBlockKeys.type: _buildTodoListBlockComponentBuilder( + context, + configuration, ), - DatabaseBlockKeys.boardType: DatabaseViewBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.symmetric(vertical: 10), - ), + BulletedListBlockKeys.type: _buildBulletedListBlockComponentBuilder( + context, + configuration, ), - DatabaseBlockKeys.calendarType: DatabaseViewBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.symmetric(vertical: 10), - ), + NumberedListBlockKeys.type: _buildNumberedListBlockComponentBuilder( + context, + configuration, ), - CalloutBlockKeys.type: CalloutBlockComponentBuilder( - configuration: configuration, - defaultColor: calloutBGColor, + QuoteBlockKeys.type: _buildQuoteBlockComponentBuilder( + context, + configuration, ), - DividerBlockKeys.type: DividerBlockComponentBuilder( - configuration: configuration, - height: 28.0, - wrapper: (_, node, child) => MobileBlockActionButtons( - showThreeDots: false, - node: node, - editorState: editorState, - child: child, - ), + HeadingBlockKeys.type: _buildHeadingBlockComponentBuilder( + context, + configuration, + styleCustomizer, + customHeadingPadding, ), - MathEquationBlockKeys.type: MathEquationBlockComponentBuilder( - configuration: configuration, + ImageBlockKeys.type: _buildCustomImageBlockComponentBuilder( + context, + configuration, ), - CodeBlockKeys.type: CodeBlockComponentBuilder( - editorState: editorState, - configuration: configuration.copyWith( - textStyle: (_) => styleCustomizer.codeBlockStyleBuilder(), - placeholderTextStyle: (_) => styleCustomizer.codeBlockStyleBuilder(), - ), - styleBuilder: () => CodeBlockStyle( - backgroundColor: AFThemeExtension.of(context).calloutBGColor, - foregroundColor: AFThemeExtension.of(context).textColor.withAlpha(155), - ), - padding: const EdgeInsets.only(left: 20, right: 30, bottom: 34), - languagePickerBuilder: codeBlockLanguagePickerBuilder, - copyButtonBuilder: codeBlockCopyBuilder, + MultiImageBlockKeys.type: _buildMultiImageBlockComponentBuilder( + context, + configuration, ), - AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(), - SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(), - ToggleListBlockKeys.type: ToggleListBlockComponentBuilder( - configuration: configuration, + TableBlockKeys.type: _buildTableBlockComponentBuilder( + context, + configuration, ), - OutlineBlockKeys.type: OutlineBlockComponentBuilder( - configuration: configuration.copyWith( - placeholderTextStyle: (_) => - styleCustomizer.outlineBlockPlaceholderStyleBuilder(), - padding: (_) => const EdgeInsets.only(top: 12.0, bottom: 4.0), - ), + TableCellBlockKeys.type: _buildTableCellBlockComponentBuilder( + context, + configuration, ), - LinkPreviewBlockKeys.type: 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, - ), + 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, + ), + ToggleListBlockKeys.type: _buildToggleListBlockComponentBuilder( + context, + configuration, + styleCustomizer, + customHeadingPadding, + ), + OutlineBlockKeys.type: _buildOutlineBlockComponentBuilder( + context, + configuration, + styleCustomizer, + ), + LinkPreviewBlockKeys.type: _buildLinkPreviewBlockComponentBuilder( + context, + configuration, + ), + // Flutter doesn't support the video widget, so we forward the video block to the link preview block + VideoBlockKeys.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, + alwaysDistributeColumnWidths: alwaysDistributeSimpleTableColumnWidths, + ), + SimpleTableRowBlockKeys.type: _buildSimpleTableRowBlockComponentBuilder( + context, + configuration, + alwaysDistributeColumnWidths: alwaysDistributeSimpleTableColumnWidths, + ), + SimpleTableCellBlockKeys.type: _buildSimpleTableCellBlockComponentBuilder( + context, + configuration, + alwaysDistributeColumnWidths: alwaysDistributeSimpleTableColumnWidths, + ), + SimpleColumnsBlockKeys.type: _buildSimpleColumnsBlockComponentBuilder( + context, + configuration, + ), + SimpleColumnBlockKeys.type: _buildSimpleColumnBlockComponentBuilder( + context, + configuration, + ), }; final builders = { @@ -235,69 +436,663 @@ Map getEditorBuilderMap({ ...customBlockComponentBuilderMap, }; - if (editable) { - // customize the action builder. actually, we can customize them in their own builder. Put them here just for convenience. - for (final entry in builders.entries) { - if (entry.key == PageBlockKeys.type) { - continue; - } - final builder = entry.value; - - // customize the action builder. - final supportColorBuilderTypes = [ - ParagraphBlockKeys.type, - HeadingBlockKeys.type, - BulletedListBlockKeys.type, - NumberedListBlockKeys.type, - QuoteBlockKeys.type, - TodoListBlockKeys.type, - CalloutBlockKeys.type, - OutlineBlockKeys.type, - ToggleListBlockKeys.type, - ]; - - final supportAlignBuilderType = [ImageBlockKeys.type]; - final supportDepthBuilderType = [OutlineBlockKeys.type]; - final colorAction = [OptionAction.divider, OptionAction.color]; - final alignAction = [OptionAction.divider, OptionAction.align]; - final depthAction = [OptionAction.depth]; - - final List actions = [ - ...standardActions, - if (supportColorBuilderTypes.contains(entry.key)) ...colorAction, - if (supportAlignBuilderType.contains(entry.key)) ...alignAction, - if (supportDepthBuilderType.contains(entry.key)) ...depthAction, - ]; - - if (PlatformExtension.isDesktop) { - builder.showActions = - (node) => node.parent?.type != TableCellBlockKeys.type; - - builder.actionBuilder = (context, state) { - final top = builder.configuration.padding(context.node).top; - final padding = context.node.type == HeadingBlockKeys.type - ? EdgeInsets.only(top: top + 8.0) - : EdgeInsets.only(top: top + 2.0); - return Padding( - padding: padding, - child: BlockActionList( - blockComponentContext: context, - blockComponentState: state, - editorState: editorState, - actions: actions, - showSlashMenu: slashMenuItems != null - ? () => customSlashCommand( - slashMenuItems, - shouldInsertSlash: false, - style: styleCustomizer.selectionMenuStyleBuilder(), - ).handler.call(editorState) - : () {}, - ), - ); - }; - } - } - } - return builders; } + +SimpleTableBlockComponentBuilder _buildSimpleTableBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, { + bool alwaysDistributeColumnWidths = false, +}) { + final copiedConfiguration = configuration.copyWith( + padding: (node) { + final padding = configuration.padding(node); + if (UniversalPlatform.isDesktop) { + return padding; + } else { + return padding; + } + }, + ); + return SimpleTableBlockComponentBuilder( + configuration: copiedConfiguration, + alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, + ); +} + +SimpleTableRowBlockComponentBuilder _buildSimpleTableRowBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, { + bool alwaysDistributeColumnWidths = false, +}) { + return SimpleTableRowBlockComponentBuilder( + configuration: configuration, + alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, + ); +} + +SimpleTableCellBlockComponentBuilder _buildSimpleTableCellBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, { + bool alwaysDistributeColumnWidths = false, +}) { + return SimpleTableCellBlockComponentBuilder( + configuration: configuration, + alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, + ); +} + +ParagraphBlockComponentBuilder _buildParagraphBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, + ShowPlaceholder? showParagraphPlaceholder, + String Function(Node)? placeholderText, +) { + return ParagraphBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderText: placeholderText, + textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + textSpan: textSpan, + ), + 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, {TextSpan? textSpan}) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + textSpan: textSpan, + ), + 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, {TextSpan? textSpan}) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + textSpan: textSpan, + ), + 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, {TextSpan? textSpan}) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + textSpan: textSpan, + ), + 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, {TextSpan? textSpan}) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + textSpan: textSpan, + ), + textAlign: (node) => _buildTextAlignInTableCell( + context, + node: node, + configuration: configuration, + ), + indentPadding: (node, textDirection) { + if (UniversalPlatform.isMobile) { + return configuration.indentPadding(node, textDirection); + } + + if (node.isInTable) { + return textDirection == TextDirection.ltr + ? EdgeInsets.only(left: 24) + : EdgeInsets.only(right: 24); + } + + return EdgeInsets.zero; + }, + ), + ); +} + +HeadingBlockComponentBuilder _buildHeadingBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, + EditorStyleCustomizer styleCustomizer, + EdgeInsets? customHeadingPadding, +) { + return HeadingBlockComponentBuilder( + configuration: configuration.copyWith( + textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + textSpan: textSpan, + ), + 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) { + return styleCustomizer.headingStyleBuilder(level); + }, + ); +} + +CustomImageBlockComponentBuilder _buildCustomImageBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return CustomImageBlockComponentBuilder( + configuration: configuration, + showMenu: true, + menuBuilder: (node, state, imageStateNotifier) => Positioned( + top: 10, + right: 10, + child: ImageMenu( + node: node, + state: state, + imageStateNotifier: imageStateNotifier, + ), + ), + ); +} + +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: (node) { + if (UniversalPlatform.isMobile) { + return configuration.padding(node); + } + return 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, {TextSpan? textSpan}) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + textSpan: textSpan, + ), + indentPadding: (node, _) => EdgeInsets.only(left: 42), + ), + inlinePadding: (node) { + if (node.children.isEmpty) { + return const EdgeInsets.symmetric(vertical: 8.0); + } + return EdgeInsets.only(top: 8.0, bottom: 2.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(); +} + +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, {TextSpan? textSpan}) { + final textStyle = _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + textSpan: textSpan, + ); + 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: (node, {TextSpan? textSpan}) => + styleCustomizer.outlineBlockPlaceholderStyleBuilder(), + padding: (node) { + if (UniversalPlatform.isMobile) { + return configuration.padding(node); + } + return const EdgeInsets.only(top: 12.0, bottom: 4.0); + }, + ), + ); +} + +CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return CustomLinkPreviewBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (node) { + if (UniversalPlatform.isMobile) { + return configuration.padding(node); + } + return const EdgeInsets.symmetric(vertical: 10); + }, + ), + ); +} + +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, {TextSpan? textSpan}) => + styleCustomizer.subPageBlockTextStyleBuilder(), + padding: (node) { + if (UniversalPlatform.isMobile) { + return const EdgeInsets.symmetric(horizontal: 18); + } + return configuration.padding(node); + }, + ), + ); +} + +SimpleColumnsBlockComponentBuilder _buildSimpleColumnsBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return SimpleColumnsBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (node) { + if (UniversalPlatform.isMobile) { + return configuration.padding(node); + } + + return EdgeInsets.zero; + }, + ), + ); +} + +SimpleColumnBlockComponentBuilder _buildSimpleColumnBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return SimpleColumnBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (_) => EdgeInsets.zero, + ), + ); +} + +TextStyle _buildTextStyleInTableCell( + BuildContext context, { + required Node node, + required BlockComponentConfiguration configuration, + required TextSpan? textSpan, +}) { + TextStyle textStyle = configuration.textStyle(node, textSpan: textSpan); + + textStyle = textStyle.copyWith( + fontFamily: textSpan?.style?.fontFamily, + fontSize: textSpan?.style?.fontSize, + ); + + if (node.isInHeaderColumn || + node.isInHeaderRow || + node.isInBoldColumn || + node.isInBoldRow) { + textStyle = textStyle.copyWith( + fontWeight: FontWeight.bold, + ); + } + + final cellTextColor = node.textColorInColumn ?? node.textColorInRow; + + // enable it if we need to support the text color of the text span + // final isTextSpanColorNull = textSpan?.style?.color == null; + // final isTextSpanChildrenColorNull = + // textSpan?.children?.every((e) => e.style?.color == null) ?? true; + + 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 0000000000..62810545dd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_handler.dart @@ -0,0 +1,174 @@ +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/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy/workspace/presentation/widgets/draggable_item/draggable_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +const _excludeFromDropTarget = [ + ImageBlockKeys.type, + CustomImageBlockKeys.type, + MultiImageBlockKeys.type, + FileBlockKeys.type, + SimpleTableBlockKeys.type, + SimpleTableCellBlockKeys.type, + SimpleTableRowBlockKeys.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(); + disableAutoScrollWhenDragging = false; + }, + onMove: (details) { + disableAutoScrollWhenDragging = true; + + 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, _, __) => ValueListenableBuilder( + valueListenable: enableDocumentDragNotifier, + builder: (context, value, _) { + final enableDocumentDrag = value; + return DropTarget( + enable: dropState.isDropEnabled && enableDocumentDrag, + 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 0000000000..8b59809f3b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart @@ -0,0 +1,19 @@ +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; +} + +final enableDocumentDragNotifier = ValueNotifier(true); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_notification.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_notification.dart index 109a9e9915..fce8b4c16e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_notification.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_notification.dart @@ -2,7 +2,16 @@ import 'package:flutter/material.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -enum EditorNotificationType { none, undo, redo, exitEditing } +enum EditorNotificationType { + none, + undo, + redo, + exitEditing, + paste, + dragStart, + dragEnd, + turnInto, +} class EditorNotification { const EditorNotification({required this.type}); @@ -10,6 +19,10 @@ class EditorNotification { 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); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 0fe6347acc..edb19232be 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -1,68 +1,43 @@ import 'dart:ui' as ui; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/core/helpers/url_launcher.dart'; 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/align_toolbar_item/custom_text_align_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/background_color/theme_background_color.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/i18n/editor_i18n.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/slash_menu_items.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_command.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/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.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:appflowy/workspace/presentation/home/af_focus_manager.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys; +import 'package:appflowy_ui/appflowy_ui.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/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; -final codeBlockLocalization = CodeBlockLocalizations( - codeBlockNewParagraph: - LocaleKeys.settings_shortcuts_commands_codeBlockNewParagraph.tr(), - codeBlockIndentLines: - LocaleKeys.settings_shortcuts_commands_codeBlockIndentLines.tr(), - codeBlockOutdentLines: - LocaleKeys.settings_shortcuts_commands_codeBlockOutdentLines.tr(), - codeBlockSelectAll: - LocaleKeys.settings_shortcuts_commands_codeBlockSelectAll.tr(), - codeBlockPasteText: - LocaleKeys.settings_shortcuts_commands_codeBlockPasteText.tr(), - codeBlockAddTwoSpaces: - LocaleKeys.settings_shortcuts_commands_codeBlockAddTwoSpaces.tr(), -); - -final localizedCodeBlockCommands = - codeBlockCommands(localizations: codeBlockLocalization); - -final List commandShortcutEvents = [ - toggleToggleListCommand, - ...localizedCodeBlockCommands, - customCopyCommand, - customPasteCommand, - customCutCommand, - ...customTextAlignCommands, - ...standardCommandShortcutEvents, - emojiShortcutEvent, -]; - -final List defaultCommandShortcutEvents = [ - ...commandShortcutEvents.map((e) => e.copyWith()), -]; +import 'editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'editor_plugins/toolbar_item/custom_format_toolbar_items.dart'; +import 'editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/more_option_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/text_heading_toolbar_item.dart'; +import 'editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart'; /// Wrapper for the appflowy editor. class AppFlowyEditorPage extends StatefulWidget { @@ -90,7 +65,6 @@ class AppFlowyEditorPage extends StatefulWidget { final String Function(Node)? placeholderText; /// Used to provide an initial selection on Page-load - /// final Selection? initialSelection; final bool useViewInfoBloc; @@ -99,115 +73,87 @@ class AppFlowyEditorPage extends StatefulWidget { State createState() => _AppFlowyEditorPageState(); } -class _AppFlowyEditorPageState extends State { +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 commandShortcutEvents = [ - toggleToggleListCommand, - ...localizedCodeBlockCommands, - customCopyCommand, - customPasteCommand, - customCutCommand, - ...customTextAlignCommands, - ...standardCommandShortcutEvents, - emojiShortcutEvent, + late final List commandShortcuts = [ + ...commandShortcutEvents, ..._buildFindAndReplaceCommands(), ]; final List toolbarItems = [ - smartEditItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, - paragraphItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, - ...headingItems - ..forEach((e) => e.isActive = onlyShowInSingleSelectionAndTextType), - ...markdownFormatItems..forEach((e) => e.isActive = showInAnyTextType), - quoteItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, - bulletedListItem - ..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, - numberedListItem - ..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, - inlineMathEquationItem, - linkItem, - alignToolbarItem, - buildTextColorItem(), - buildHighlightColorItem(), - customizeFontToolbarItem, + improveWritingItem, + group0PaddingItem, + aiWriterItem, + customTextHeadingItem, + buildPaddingPlaceholderItem( + 1, + isActive: onlyShowInSingleTextTypeSelectionAndExcludeTable, + ), + ...customMarkdownFormatItems, + group1PaddingItem, + customTextColorItem, + group1PaddingItem, + customHighlightColorItem, + customInlineCodeItem, + suggestionsItem, + customLinkItem, + group4PaddingItem, + customTextAlignItem, + moreOptionItem, ]; - late final List slashMenuItems; - - List get characterShortcutEvents => [ - // code block - ...codeBlockCharacterEvents, - - // toggle list - formatGreaterToToggleList, - insertChildNodeInsideToggleList, - - // customize the slash menu command - customSlashCommand( - slashMenuItems, - style: styleCustomizer.selectionMenuStyleBuilder(), - ), - - customFormatGreaterEqual, - - ...standardCharacterShortcutEvents - ..removeWhere( - (shortcut) => [ - slashCommand, // Remove default slash command - formatGreaterEqual, // Overridden by customFormatGreaterEqual - ].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(), - ), - ]; + 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( - slashMenuItems, + _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( @@ -217,10 +163,31 @@ class _AppFlowyEditorPageState extends State { _initEditorL10n(); _initializeShortcuts(); - appFlowyEditorAutoScrollEdgeOffset = 220; - indentableBlockTypes.add(ToggleListBlockKeys.type); - convertibleBlockTypes.add(ToggleListBlockKeys.type); - slashMenuItems = _customSlashMenuItems(); + + AppFlowyRichTextKeys.partialSliced.addAll([ + MentionBlockKeys.mention, + InlineMathEquationKeys.formula, + ]); + + indentableBlockTypes.addAll([ + ToggleListBlockKeys.type, + CalloutBlockKeys.type, + QuoteBlockKeys.type, + ]); + convertibleBlockTypes.addAll([ + ToggleListBlockKeys.type, + CalloutBlockKeys.type, + QuoteBlockKeys.type, + ]); + + editorLaunchUrl = (url) { + if (url != null) { + afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); + } + + return Future.value(true); + }; + effectiveScrollController = widget.scrollController ?? ScrollController(); // disable the color parse in the HTML decoder. DocumentHTMLDecoder.enableColorParse = false; @@ -231,31 +198,127 @@ class _AppFlowyEditorPageState extends State { scrollController: effectiveScrollController, ); - // keep the previous font style when typing new text. - supportSlashMenuNodeWhiteList.addAll([ - ToggleListBlockKeys.type, - ]); 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(); - if (widget.initialSelection != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.editorState.updateSelectionWithReason( - widget.initialSelection, + 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()); } @@ -285,10 +348,17 @@ class _AppFlowyEditorPageState extends State { context.read().state.enableRtlToolbarItems, ); + final isViewDeleted = context.read().state.isDeleted; + final isLocked = + context.read()?.state.isLocked ?? false; + final editor = Directionality( textDirection: textDirection, child: AppFlowyEditor( editorState: widget.editorState, + editable: !isViewDeleted && !isLocked, + disableSelectionService: UniversalPlatform.isMobile && isLocked, + disableKeyboardService: UniversalPlatform.isMobile && isLocked, editorScrollController: editorScrollController, // setup the auto focus parameters autoFocus: widget.autoFocus ?? autoFocus, @@ -296,8 +366,11 @@ class _AppFlowyEditorPageState extends State { // setup the theme editorStyle: styleCustomizer.style(), // customize the block builders - blockComponentBuilders: getEditorBuilderMap( - slashMenuItems: slashMenuItems, + blockComponentBuilders: buildBlockComponentBuilders( + slashMenuItemsBuilder: (editorState, node) => _customSlashMenuItems( + editorState: editorState, + node: node, + ), context: context, editorState: widget.editorState, styleCustomizer: widget.styleCustomizer, @@ -306,25 +379,39 @@ class _AppFlowyEditorPageState extends State { ), // customize the shortcuts characterShortcutEvents: characterShortcutEvents, - commandShortcutEvents: commandShortcutEvents, + commandShortcutEvents: commandShortcuts, // customize the context menu items contextMenuItems: customContextMenuItems, // customize the header and footer. header: widget.header, + autoScrollEdgeOffset: UniversalPlatform.isDesktopOrWeb + ? 250 + : appFlowyEditorAutoScrollEdgeOffset, footer: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () async { // if the last one isn't a empty node, insert a new empty node. await _focusOnLastEmptyParagraph(); }, - child: VSpace(PlatformExtension.isDesktopOrWeb ? 200 : 400), + child: SizedBox( + width: double.infinity, + height: UniversalPlatform.isDesktopOrWeb ? 600 : 400, + ), + ), + dropTargetStyle: AppFlowyDropTargetStyle( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.8), + margin: const EdgeInsets.only(left: 44), ), ), ); + if (isViewDeleted) { + return editor; + } + final editorState = widget.editorState; - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { return AppFlowyMobileToolbar( toolbarHeight: 42.0, editorState: editorState, @@ -338,63 +425,75 @@ class _AppFlowyEditorPageState extends State { anchor: anchor, closeToolbar: closeToolbar, ), + floatingToolbarHeight: 32, child: editor, ), ); } - + final appTheme = AppFlowyTheme.of(context); return Center( - child: FloatingToolbar( - style: styleCustomizer.floatingToolbarStyleBuilder(), - items: toolbarItems, - editorState: editorState, - editorScrollController: editorScrollController, - textDirection: textDirection, - child: editor, + child: BlocProvider.value( + value: context.read(), + child: FloatingToolbar( + floatingToolbarHeight: 40, + padding: EdgeInsets.symmetric(horizontal: 6), + style: FloatingToolbarStyle( + backgroundColor: Theme.of(context).cardColor, + toolbarActiveColor: Color(0xffe0f8fd), + ), + items: toolbarItems, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(appTheme.borderRadius.l), + color: appTheme.surfaceColorScheme.primary, + boxShadow: appTheme.shadow.small, + ), + toolbarBuilder: (_, child, onDismiss, isMetricsChanged) => + BlocProvider.value( + value: context.read(), + child: DesktopFloatingToolbar( + editorState: editorState, + onDismiss: onDismiss, + enableAnimation: !isMetricsChanged, + child: child, + ), + ), + placeHolderBuilder: (_) => customPlaceholderItem, + editorState: editorState, + editorScrollController: editorScrollController, + textDirection: textDirection, + tooltipBuilder: (context, id, message, child) => + widget.styleCustomizer.buildToolbarItemTooltip( + context, + id, + message, + child, + ), + child: editor, + ), ), ); } - List _customSlashMenuItems() { - final items = [...standardSelectionMenuItems]; - final imageItem = items - .firstWhereOrNull((e) => e.name == AppFlowyEditorL10n.current.image); - if (imageItem != null) { - final imageItemIndex = items.indexOf(imageItem); - if (imageItemIndex != -1) { - items[imageItemIndex] = customImageMenuItem; - } - } - return [ - ...items, - inlineGridMenuItem(documentBloc), - referencedGridMenuItem, - inlineBoardMenuItem(documentBloc), - referencedBoardMenuItem, - inlineCalendarMenuItem(documentBloc), - referencedCalendarMenuItem, - referencedDocumentMenuItem, - calloutItem, - outlineItem, - mathEquationItem, - codeBlockItem(LocaleKeys.document_selectionMenu_codeBlock.tr()), - toggleListBlockItem, - emojiMenuItem, - autoGeneratorMenuItem, - dateMenuItem, - ]; + List _customSlashMenuItems({ + EditorState? editorState, + Node? node, + }) { + final documentBloc = context.read(); + final isLocalMode = documentBloc.isLocalMode; + final view = context.read().state.view; + return slashMenuItemsBuilder( + editorState: editorState, + node: node, + isLocalMode: isLocalMode, + documentBloc: documentBloc, + view: view, + ); } (bool, Selection?) _computeAutoFocusParameters() { if (widget.editorState.document.isEmpty) { return (true, Selection.collapsed(Position(path: [0]))); } - final nodes = - widget.editorState.document.root.children.where((e) => e.delta != null); - final isAllEmpty = nodes.isNotEmpty && nodes.every((e) => e.delta!.isEmpty); - if (isAllEmpty) { - return (true, Selection.collapsed(Position(path: nodes.first.path))); - } return const (false, null); } @@ -404,7 +503,7 @@ class _AppFlowyEditorPageState extends State { final customizeShortcuts = await settingsShortcutService.getCustomizeShortcuts(); await settingsShortcutService.updateCommandShortcuts( - commandShortcutEvents, + commandShortcuts, customizeShortcuts, ); } @@ -434,10 +533,11 @@ class _AppFlowyEditorPageState extends State { Material( child: DecoratedBox( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, + color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: FindAndReplaceMenuWidget( + showReplaceMenu: showReplaceMenu, editorState: editorState, onDismiss: onDismiss, ), @@ -448,8 +548,12 @@ class _AppFlowyEditorPageState extends State { } void _customizeBlockComponentBackgroundColorDecorator() { - blockComponentBackgroundColorDecorator = (Node node, String colorString) => - buildEditorCustomizedColor(context, node, colorString); + blockComponentBackgroundColorDecorator = (Node node, String colorString) { + if (mounted && context.mounted) { + return buildEditorCustomizedColor(context, node, colorString); + } + return null; + }; } void _initEditorL10n() => AppFlowyEditorL10n.current = EditorI18n(); @@ -471,8 +575,18 @@ class _AppFlowyEditorPageState extends State { Position(path: lastNode.path), ); } + + transaction.customSelectionType = SelectionType.inline; + transaction.reason = SelectionUpdateReason.uiEvent; + await editorState.apply(transaction); } + + void _loseFocus() { + if (!widget.editorState.isDisposed) { + widget.editorState.selection = null; + } + } } Color? buildEditorCustomizedColor( @@ -480,6 +594,10 @@ Color? buildEditorCustomizedColor( Node node, String colorString, ) { + if (!context.mounted) { + return null; + } + // the color string is from FlowyTint. final tintColor = FlowyTint.values.firstWhereOrNull( (e) => e.id == colorString, @@ -504,7 +622,11 @@ Color? buildEditorCustomizedColor( return AFThemeExtension.of(context).tableCellBGColor; } - return null; + try { + return colorString.tryToColor(); + } catch (e) { + return null; + } } bool showInAnyTextType(EditorState editorState) { 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 index b58b0a5646..6d01ed5f1b 100644 --- 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 @@ -5,6 +5,7 @@ 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'; @@ -31,16 +32,19 @@ class BlockAddButton extends StatelessWidget { 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(), ), ], ), 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 index e6a88bc4a8..9e0b241ab3 100644 --- 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 @@ -1,8 +1,7 @@ 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:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class BlockActionButton extends StatelessWidget { @@ -11,32 +10,33 @@ class BlockActionButton extends StatelessWidget { 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) { - return Align( - child: FlowyTooltip( - preferBelow: false, - richMessage: richMessage, - child: MouseRegion( + return FlowyTooltip( + richMessage: showTooltip ? richMessage : null, + child: FlowyIconButton( + width: 18.0, + hoverColor: Colors.transparent, + iconColorOnHover: Theme.of(context).iconTheme.color, + onPressed: onTap, + icon: MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grab, - child: IgnoreParentGestureWidget( - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.deferToChild, - child: FlowySvg( - svg, - size: const Size.square(18.0), - color: Theme.of(context).iconTheme.color, - ), - ), + child: FlowySvg( + svg, + size: const Size.square(18.0), + color: Theme.of(context).iconTheme.color, ), ), ), 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 index da7b3a0d38..6323f675cc 100644 --- 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 @@ -1,8 +1,9 @@ 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:flutter/material.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.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({ @@ -12,6 +13,7 @@ class BlockActionList extends StatelessWidget { required this.editorState, required this.actions, required this.showSlashMenu, + required this.blockComponentBuilder, }); final BlockComponentContext blockComponentContext; @@ -19,6 +21,7 @@ class BlockActionList extends StatelessWidget { final List actions; final VoidCallback showSlashMenu; final EditorState editorState; + final Map blockComponentBuilder; @override Widget build(BuildContext context) { @@ -31,14 +34,15 @@ class BlockActionList extends StatelessWidget { editorState: editorState, showSlashMenu: showSlashMenu, ), - const SizedBox(width: 4.0), + const HSpace(2.0), BlockOptionButton( blockComponentContext: blockComponentContext, blockComponentState: blockComponentState, actions: actions, editorState: editorState, + blockComponentBuilder: blockComponentBuilder, ), - const SizedBox(width: 4.0), + const HSpace(5.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 index a5617a5558..4efb1a55b2 100644 --- 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 @@ -1,147 +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/actions/block_action_button.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.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/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:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class BlockOptionButton extends StatelessWidget { +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 popoverActions = actions.map((e) { + 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), + 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: editorState); + return ColorOptionAction( + editorState: widget.editorState, + mutex: mutex, + ); case OptionAction.align: - return AlignOptionAction(editorState: editorState); + return AlignOptionAction(editorState: widget.editorState); case OptionAction.depth: - return DepthOptionAction(editorState: editorState); + return DepthOptionAction(editorState: widget.editorState); + case OptionAction.turnInto: + return TurnIntoOptionAction( + editorState: widget.editorState, + blockComponentBuilder: widget.blockComponentBuilder, + mutex: mutex, + ); default: return OptionActionWrapper(e); } }).toList(); - - return PopoverActionList( - popoverMutex: PopoverMutex(), - direction: - context.read().state.layoutDirection == - LayoutDirection.rtlLayout - ? PopoverDirection.rightWithCenterAligned - : PopoverDirection.leftWithCenterAligned, - actions: popoverActions, - onPopupBuilder: () { - keepEditorFocusNotifier.increase(); - blockComponentState.alwaysShowActions = true; - }, - onClosed: () { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - editorState.selectionType = null; - editorState.selection = null; - blockComponentState.alwaysShowActions = false; - keepEditorFocusNotifier.decrease(); - }); - }, - onSelected: (action, controller) { - if (action is OptionActionWrapper) { - _onSelectAction(action.inner); - controller.close(); - } - }, - buildChild: (controller) => _buildOptionButton(controller), - ); } - Widget _buildOptionButton(PopoverController controller) { - return BlockActionButton( - svg: FlowySvgs.drag_element_s, - richMessage: TextSpan( - children: [ - TextSpan( - // todo: customize the color to highlight the text. - text: LocaleKeys.document_plugins_optionAction_click.tr(), - ), - TextSpan( - text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(), - ), - ], - ), - onTap: () { - controller.show(); - - // update selection - _updateBlockSelection(); - }, - ); + void _onPopoverBuilder() { + keepEditorFocusNotifier.increase(); + widget.blockComponentState.alwaysShowActions = true; } - void _updateBlockSelection() { - final startNode = blockComponentContext.node; - var endNode = startNode; - while (endNode.children.isNotEmpty) { - endNode = endNode.children.last; + 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; } - final start = Position(path: startNode.path); - final end = endNode.selectable?.end() ?? - Position( - path: endNode.path, - offset: endNode.delta?.length ?? 0, + context.read().handleAction( + action.inner, + widget.blockComponentContext.node, ); - - editorState.selectionType = SelectionType.block; - editorState.selection = Selection( - start: start, - end: end, - ); - } - - void _onSelectAction(OptionAction action) { - final node = blockComponentContext.node; - final transaction = editorState.transaction; - switch (action) { - case OptionAction.delete: - transaction.deleteNode(node); - break; - case OptionAction.duplicate: - transaction.insertNode( - node.path.next, - node.copyWith(), - ); - break; - case OptionAction.turnInto: - break; - case OptionAction.moveUp: - transaction.moveNode(node.path.previous, node); - break; - case OptionAction.moveDown: - transaction.moveNode(node.path.next.next, node); - break; - case OptionAction.align: - case OptionAction.color: - case OptionAction.divider: - case OptionAction.depth: - throw UnimplementedError(); - } - editorState.apply(transaction); + 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 0000000000..abed98136d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart @@ -0,0 +1,690 @@ +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' + hide QuoteBlockKeys, quoteNode; +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 + } + + static Future turnIntoBlock( + String type, + Node node, + EditorState editorState, { + int? level, + String? currentViewId, + bool keepSelection = false, + }) 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, + editorState: editorState, + afterSelection: keepSelection ? selection : null, + )) { + return true; + } + + // try to turn into a page block + if (currentViewId != null && + await turnIntoPage( + type: toType, + selectedNodes: selectedNodes, + selection: selection, + currentViewId: currentViewId, + editorState: editorState, + )) { + 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 should not have children + if ([HeadingBlockKeys.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); + if (keepSelection) transaction.afterSelection = selection; + 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. + /// + static 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. + /// + static 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 + static Future turnIntoSingleToggleHeading({ + required String type, + required List selectedNodes, + required EditorState editorState, + 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.map((e) => e.deepCopy()), + ...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; + } + + static Future turnIntoPage({ + required String type, + required List selectedNodes, + required Selection selection, + required String currentViewId, + required EditorState editorState, + }) 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; + } + + static 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); + } + + static 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 0000000000..7bc1fba8d9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart @@ -0,0 +1,188 @@ +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/plugins/document/presentation/editor_plugins/plugins.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 +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; + + final offset = details.globalPosition; + + widget.editorState.selectionService.renderDropTargetForOffset( + offset, + interceptor: (context, targetNode) { + // if the cursor node is in a columns block or a column block, + // we will return the node's parent instead to support dragging a node to the inside of a columns block or a column block. + final parentColumnNode = targetNode.columnParent; + if (parentColumnNode != null) { + final position = getDragAreaPosition( + context, + targetNode, + offset, + ); + + if (position != null && position.$2 == HorizontalPosition.right) { + return parentColumnNode; + } + + if (position != null && + position.$2 == HorizontalPosition.left && + position.$1 == VerticalPosition.middle) { + return parentColumnNode; + } + } + + // return simple table block if the target node is in a simple table block + final parentSimpleTableNode = targetNode.parentTableNode; + if (parentSimpleTableNode != null) { + return parentSimpleTableNode; + } + + return targetNode; + }, + 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!, + interceptor: (context, targetNode) { + // if the cursor node is in a columns block or a column block, + // we will return the node's parent instead to support dragging a node to the inside of a columns block or a column block. + final parentColumnNode = targetNode.columnParent; + if (parentColumnNode != null) { + final position = getDragAreaPosition( + context, + targetNode, + globalPosition!, + ); + + if (position != null && position.$2 == HorizontalPosition.right) { + return parentColumnNode; + } + + if (position != null && + position.$2 == HorizontalPosition.left && + position.$1 == VerticalPosition.middle) { + return parentColumnNode; + } + } + + // return simple table block if the target node is in a simple table block + final parentSimpleTableNode = targetNode.parentTableNode; + if (parentSimpleTableNode != null) { + return parentSimpleTableNode; + } + + return targetNode; + }, + ); + + 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 0000000000..0b6c89599a --- /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 0000000000..fa6b771b74 --- /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 0000000000..ca99491b94 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart @@ -0,0 +1,277 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockKeys, quoteNode; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +enum HorizontalPosition { left, center, right } + +enum VerticalPosition { top, middle, bottom } + +List nodeTypesThatCanContainChildNode = [ + ParagraphBlockKeys.type, + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + QuoteBlockKeys.type, + TodoListBlockKeys.type, + ToggleListBlockKeys.type, +]; + +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; + } + + if (shouldIgnoreDragTarget( + editorState: editorState, + dragNode: node, + targetPath: acceptedPath, + )) { + Log.info('Drop ignored: node($node, ${node.path}), path($acceptedPath)'); + return; + } + + final position = getDragAreaPosition(context, targetNode, dragOffset); + if (position == null) { + Log.info('position is null'); + return; + } + + final (verticalPosition, horizontalPosition, _) = position; + Path newPath = targetNode.path; + + // if the horizontal position is right, creating a column block to contain the target node and the drag node + if (horizontalPosition == HorizontalPosition.right) { + // 1. if the targetNode is a column block, it means we should create a column block to contain the node and insert the column node to the target node's parent + // 2. if the targetNode is not a column block, it means we should create a columns block to contain the target node and the drag node + final transaction = editorState.transaction; + final targetNodeParent = targetNode.columnsParent; + + if (targetNodeParent != null) { + final length = targetNodeParent.children.length; + final ratios = targetNodeParent.children + .map( + (e) => + e.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ?? + 1.0 / length, + ) + .map((e) => e * length / (length + 1)) + .toList(); + + final columnNode = simpleColumnNode( + children: [node.deepCopy()], + ratio: 1.0 / (length + 1), + ); + for (final (index, column) in targetNodeParent.children.indexed) { + transaction.updateNode(column, { + ...column.attributes, + SimpleColumnBlockKeys.ratio: ratios[index], + }); + } + + transaction.insertNode(targetNode.path.next, columnNode); + transaction.deleteNode(node); + } else { + final columnsNode = simpleColumnsNode( + children: [ + simpleColumnNode(children: [targetNode.deepCopy()], ratio: 0.5), + simpleColumnNode(children: [node.deepCopy()], ratio: 0.5), + ], + ); + + transaction.insertNode(newPath, columnsNode); + transaction.deleteNode(targetNode); + transaction.deleteNode(node); + } + + if (transaction.operations.isNotEmpty) { + await editorState.apply(transaction); + } + return; + } else if (horizontalPosition == HorizontalPosition.left && + verticalPosition == VerticalPosition.middle) { + // 1. if the target node is a column block, we should create a column block to contain the node and insert the column node to the target node's parent + // 2. if the target node is not a column block, we should create a columns block to contain the target node and the drag node + final transaction = editorState.transaction; + final targetNodeParent = targetNode.columnsParent; + if (targetNodeParent != null) { + // find the previous sibling node of the target node + final length = targetNodeParent.children.length; + final ratios = targetNodeParent.children + .map( + (e) => + e.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ?? + 1.0 / length, + ) + .map((e) => e * length / (length + 1)) + .toList(); + final columnNode = simpleColumnNode( + children: [node.deepCopy()], + ratio: 1.0 / (length + 1), + ); + + for (final (index, column) in targetNodeParent.children.indexed) { + transaction.updateNode(column, { + ...column.attributes, + SimpleColumnBlockKeys.ratio: ratios[index], + }); + } + + transaction.insertNode(targetNode.path.previous, columnNode); + transaction.deleteNode(node); + } else { + final columnsNode = simpleColumnsNode( + children: [ + simpleColumnNode(children: [node.deepCopy()], ratio: 0.5), + simpleColumnNode(children: [targetNode.deepCopy()], ratio: 0.5), + ], + ); + + transaction.insertNode(newPath, columnsNode); + transaction.deleteNode(targetNode); + transaction.deleteNode(node); + } + + if (transaction.operations.isNotEmpty) { + await editorState.apply(transaction); + } + return; + } + // 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; + } else if (horizontalPosition == HorizontalPosition.center && + nodeTypesThatCanContainChildNode.contains(targetNode.type)) { + // check if the target node can contain a child node + 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; + + // | ----------------------------- block ----------------------------- | + // | 1. -- 88px --| 2. ---------------------------- | 3. ---- 1/5 ---- | + // 1. drag the node under the block as a sibling node + // 2. drag the node inside the block as a child node + // 3. create a column block to contain the node and the drag node + + // Horizontal position, please refer to the diagram above + // 88px is a hardcoded value, it can be changed based on the project's design + if (dragOffset.dx < globalBlockRect.left + 88) { + horizontalPosition = HorizontalPosition.left; + } else if (dragOffset.dx > globalBlockRect.right * 4.0 / 5.0) { + horizontalPosition = HorizontalPosition.right; + } else if (nodeTypesThatCanContainChildNode.contains(dragTargetNode.type)) { + horizontalPosition = HorizontalPosition.center; + } + + // | ----------------------------------------------------------------- | <- if the drag position is in this area, the vertical position is top + // | ----------------------------- block ----------------------------- | <- if the drag position is in this area, the vertical position is middle + // | ----------------------------------------------------------------- | <- if the drag position is in this area, the vertical position is bottom + + // Vertical position + final heightThird = globalBlockRect.height / 3; + if (dragOffset.dy < globalBlockRect.top + heightThird) { + verticalPosition = VerticalPosition.top; + } else if (dragOffset.dy < globalBlockRect.top + heightThird * 2) { + verticalPosition = VerticalPosition.middle; + } 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 && + targetNode.type != SimpleTableBlockKeys.type) { + 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 0000000000..2be8710a8a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart @@ -0,0 +1,120 @@ +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 the horizontal position is right, we need to show the indicator on the right side of the target node + // which represent moving the target node and drag node inside the column block. + if (horizontalPosition == HorizontalPosition.left && + verticalPosition == VerticalPosition.middle) { + return Positioned( + top: globalBlockRect.top, + height: globalBlockRect.height, + left: globalBlockRect.left + indicatorWidth, + child: Container( + width: 2, + color: Theme.of(context).colorScheme.primary, + ), + ); + } + + if (horizontalPosition == HorizontalPosition.right) { + return Positioned( + top: globalBlockRect.top, + height: globalBlockRect.height, + left: globalBlockRect.right - 2, + child: Container( + width: 2, + color: Theme.of(context).colorScheme.primary, + ), + ); + } + + // If the horizontal position is center, we need to show two indicators + //which represent moving the block as the child of the target node. + if (horizontalPosition == HorizontalPosition.center) { + 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 index eb4487fa49..a04190f8af 100644 --- 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 @@ -7,6 +7,7 @@ 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. /// @@ -35,7 +36,7 @@ class MobileBlockActionButtons extends StatelessWidget { @override Widget build(BuildContext context) { - if (!PlatformExtension.isMobile) { + if (!UniversalPlatform.isMobile) { return child; } @@ -74,7 +75,6 @@ class MobileBlockActionButtons extends StatelessWidget { context, showHeader: true, showCloseButton: true, - showDivider: true, showDragHandle: true, title: LocaleKeys.document_plugins_action.tr(), builder: (context) { @@ -91,7 +91,7 @@ class MobileBlockActionButtons extends StatelessWidget { case BlockActionBottomSheetType.duplicate: transaction.insertNode( node.path.next, - node.copyWith(), + node.deepCopy(), ); break; case BlockActionBottomSheetType.insertAbove: @@ -110,7 +110,6 @@ class MobileBlockActionButtons extends StatelessWidget { ), ); break; - default: } if (transaction.operations.isNotEmpty) { 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 0000000000..efa1c3e15c --- /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.table_align_left_s; + case OptionAlignType.center: + return FlowySvgs.table_align_center_s; + case OptionAlignType.right: + return FlowySvgs.table_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 0000000000..cb1ec36b56 --- /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 0000000000..bc083cd617 --- /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 0000000000..75e4339b5e --- /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 0000000000..571cb4baa0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart @@ -0,0 +1,141 @@ +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' + hide QuoteBlockKeys, quoteNode; +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 0000000000..c927fcf85f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart @@ -0,0 +1,281 @@ +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/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockKeys, quoteNode; +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) { + if (hasNonSupportedTypes) { + return buildItem( + pateItem, + textSuggestionItem, + context.read().editorState, + ); + } + + return _buildTurnIntoOptions(context, node); + } + + Widget _buildTurnIntoOptions(BuildContext context, Node node) { + final editorState = context.read().editorState; + SuggestionItem currentSuggestionItem = textSuggestionItem; + final List suggestionItems = suggestions.sublist(0, 4); + final List turnIntoItems = + suggestions.sublist(4, suggestions.length); + final textColor = Color(0xff99A1A8); + + void refreshSuggestions() { + final selection = editorState.selection; + if (selection == null || !selection.isSingle) return; + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.delta == null) return; + final nodeType = node.type; + SuggestionType? suggestionType; + if (nodeType == HeadingBlockKeys.type) { + final level = node.attributes[HeadingBlockKeys.level] ?? 1; + if (level == 1) { + suggestionType = SuggestionType.h1; + } else if (level == 2) { + suggestionType = SuggestionType.h2; + } else if (level == 3) { + suggestionType = SuggestionType.h3; + } + } else if (nodeType == ToggleListBlockKeys.type) { + final level = node.attributes[ToggleListBlockKeys.level]; + if (level == null) { + suggestionType = SuggestionType.toggle; + } else if (level == 1) { + suggestionType = SuggestionType.toggleH1; + } else if (level == 2) { + suggestionType = SuggestionType.toggleH2; + } else if (level == 3) { + suggestionType = SuggestionType.toggleH3; + } + } else { + suggestionType = nodeType2SuggestionType[nodeType]; + } + if (suggestionType == null) return; + suggestionItems.clear(); + turnIntoItems.clear(); + for (final item in suggestions) { + if (item.type.group == suggestionType.group && + item.type != suggestionType) { + suggestionItems.add(item); + } else { + turnIntoItems.add(item); + } + } + currentSuggestionItem = + suggestions.where((item) => item.type == suggestionType).first; + } + + refreshSuggestions(); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildSubTitle( + LocaleKeys.document_toolbar_suggestions.tr(), + textColor, + ), + ...List.generate(suggestionItems.length, (index) { + return buildItem( + suggestionItems[index], + currentSuggestionItem, + editorState, + ); + }), + buildSubTitle(LocaleKeys.document_toolbar_turnInto.tr(), textColor), + ...List.generate(turnIntoItems.length, (index) { + return buildItem( + turnIntoItems[index], + currentSuggestionItem, + editorState, + ); + }), + ], + ); + } + + Widget buildSubTitle(String text, Color color) { + return Container( + height: 32, + margin: EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: Alignment.centerLeft, + child: FlowyText.semibold( + text, + color: color, + figmaLineHeight: 16, + ), + ), + ); + } + + Widget buildItem( + SuggestionItem item, + SuggestionItem currentSuggestionItem, + EditorState state, + ) { + final isSelected = item.type == currentSuggestionItem.type; + return SizedBox( + height: 36, + child: FlowyButton( + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(item.svg), + iconPadding: 12, + text: FlowyText( + item.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + rightIcon: isSelected ? FlowySvg(FlowySvgs.toolbar_check_m) : null, + onTap: () => item.onTap.call(state, false), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action.dart deleted file mode 100644 index 09906e1429..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action.dart +++ /dev/null @@ -1,422 +0,0 @@ -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:appflowy_popover/appflowy_popover.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:styled_widget/styled_widget.dart'; - -const optionActionColorDefaultColor = 'appflowy_theme_default_color'; - -enum OptionAction { - delete, - duplicate, - turnInto, - moveUp, - moveDown, - - /// callout background color - color, - divider, - align, - depth; - - FlowySvgData get svg { - switch (this) { - case OptionAction.delete: - return FlowySvgs.delete_s; - case OptionAction.duplicate: - return FlowySvgs.copy_s; - case OptionAction.turnInto: - return const FlowySvgData('editor/turn_into'); - 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; - } - } - - 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.divider: - throw UnsupportedError('Divider does not have description'); - } - } -} - -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(); - } - } -} - -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 DividerOptionAction extends CustomActionCell { - @override - Widget buildWithContext(BuildContext context) { - return const Divider( - height: 1.0, - thickness: 1.0, - ); - } -} - -class AlignOptionAction extends PopoverActionCell { - AlignOptionAction({ - required this.editorState, - }); - - final EditorState editorState; - - @override - Widget? leftIcon(Color iconColor) { - return FlowySvg( - align.svg, - size: const Size.square(12), - ).padding(all: 2.0); - } - - @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: 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?.attributes['align']; - 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; - } - final transaction = editorState.transaction; - transaction.updateNode(node, { - 'align': align.name, - }); - await editorState.apply(transaction); - } -} - -class ColorOptionAction extends PopoverActionCell { - ColorOptionAction({ - required this.editorState, - }); - - final EditorState editorState; - - @override - Widget? leftIcon(Color iconColor) { - return const FlowySvg( - FlowySvgs.color_format_m, - size: Size.square(12), - ).padding(all: 2.0); - } - - @override - String get name => LocaleKeys.document_plugins_optionAction_color.tr(); - - @override - Widget Function( - BuildContext context, - PopoverController parentController, - PopoverController controller, - ) 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 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: Theme.of(context).colorScheme.onBackground, - ), - onTap: (option, index) async { - final transaction = editorState.transaction; - transaction.updateNode(node, { - blockComponentBackgroundColor: option.id, - }); - await editorState.apply(transaction); - - controller.close(); - parentController.close(); - }, - ); - }; -} - -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(12), - ).padding(all: 2.0); - } - - @override - String get name => LocaleKeys.document_plugins_optionAction_depth.tr(); - - @override - PopoverActionCellBuilder get builder => - (context, parentController, controller) { - final children = buildDepthOptions(context, (depth) async { - await onDepthChanged(depth); - controller.close(); - parentController.close(); - }); - - return SizedBox( - width: 42, - child: Column( - mainAxisSize: MainAxisSize.min, - children: children, - ), - ); - }; - - 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(); - } - - 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 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; -} - -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/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart new file mode 100644 index 0000000000..f78f7d35fd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -0,0 +1,601 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +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/application/document_bloc.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/view/view_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:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'operations/ai_writer_cubit.dart'; +import 'operations/ai_writer_entities.dart'; +import 'operations/ai_writer_node_extension.dart'; +import 'widgets/ai_writer_suggestion_actions.dart'; +import 'widgets/ai_writer_prompt_input_more_button.dart'; + +class AiWriterBlockKeys { + const AiWriterBlockKeys._(); + + static const String type = 'ai_writer'; + + static const String isInitialized = 'is_initialized'; + static const String selection = 'selection'; + static const String command = 'command'; + + /// Sample usage: + /// + /// `attributes: { + /// 'ai_writer_delta_suggestion': 'original' + /// }` + static const String suggestion = 'ai_writer_delta_suggestion'; + static const String suggestionOriginal = 'original'; + static const String suggestionReplacement = 'replacement'; +} + +Node aiWriterNode({ + required Selection? selection, + required AiWriterCommand command, +}) { + return Node( + type: AiWriterBlockKeys.type, + attributes: { + AiWriterBlockKeys.isInitialized: false, + AiWriterBlockKeys.selection: selection?.toJson(), + AiWriterBlockKeys.command: command.index, + }, + ); +} + +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, + ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => + node.children.isEmpty && + node.attributes[AiWriterBlockKeys.isInitialized] is bool && + node.attributes[AiWriterBlockKeys.selection] is Map? && + node.attributes[AiWriterBlockKeys.command] is int; +} + +class AiWriterBlockComponent extends BlockComponentStatefulWidget { + const AiWriterBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => _AIWriterBlockComponentState(); +} + +class _AIWriterBlockComponentState extends State { + final textController = TextEditingController(); + final overlayController = OverlayPortalController(); + final layerLink = LayerLink(); + final focusNode = FocusNode(); + + late final editorState = context.read(); + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + overlayController.show(); + context.read().register(widget.node); + }); + } + + @override + void dispose() { + textController.dispose(); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (UniversalPlatform.isMobile) { + return const SizedBox.shrink(); + } + + final documentId = context.read()?.documentId; + + return BlocProvider( + create: (_) => AIPromptInputBloc( + predefinedFormat: null, + objectId: documentId ?? editorState.document.root.id, + ), + child: LayoutBuilder( + builder: (context, constraints) { + return OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return Center( + child: CompositedTransformFollower( + link: layerLink, + showWhenUnlinked: false, + child: Container( + padding: const EdgeInsets.only( + left: 40.0, + bottom: 16.0, + ), + width: constraints.maxWidth, + child: Focus( + focusNode: focusNode, + child: OverlayContent( + editorState: editorState, + node: widget.node, + textController: textController, + ), + ), + ), + ), + ); + }, + child: CompositedTransformTarget( + link: layerLink, + child: BlocBuilder( + builder: (context, state) { + return SizedBox( + width: double.infinity, + height: 1.0, + ); + }, + ), + ), + ); + }, + ), + ); + } +} + +class OverlayContent extends StatefulWidget { + const OverlayContent({ + super.key, + required this.editorState, + required this.node, + required this.textController, + }); + + final EditorState editorState; + final Node node; + final TextEditingController textController; + + @override + State createState() => _OverlayContentState(); +} + +class _OverlayContentState extends State { + final showCommandsToggle = ValueNotifier(false); + + @override + void dispose() { + showCommandsToggle.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is IdleAiWriterState || + state is DocumentContentEmptyAiWriterState) { + return const SizedBox.shrink(); + } + + final command = (state as RegisteredAiWriter).command; + + final selection = widget.node.aiWriterSelection; + final hasSelection = selection != null && !selection.isCollapsed; + + final markdownText = switch (state) { + final ReadyAiWriterState ready => ready.markdownText, + final GeneratingAiWriterState generating => generating.markdownText, + _ => '', + }; + + final showSuggestedActions = + state is ReadyAiWriterState && !state.isFirstRun; + final isInitialReadyState = + state is ReadyAiWriterState && state.isFirstRun; + final showSuggestedActionsPopup = + showSuggestedActions && markdownText.isEmpty || + (markdownText.isNotEmpty && command != AiWriterCommand.explain); + final showSuggestedActionsWithin = showSuggestedActions && + markdownText.isNotEmpty && + command == AiWriterCommand.explain; + + final borderColor = Theme.of(context).isLightMode + ? Color(0x1F1F2329) + : Color(0xFF505469); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showSuggestedActionsPopup) ...[ + Container( + padding: EdgeInsets.all(4.0), + decoration: _getModalDecoration( + context, + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderColor: borderColor, + ), + child: SuggestionActionBar( + currentCommand: command, + hasSelection: hasSelection, + onTap: (action) { + _onSelectSuggestionAction(context, action); + }, + ), + ), + const VSpace(4.0 + 1.0), + ], + Container( + decoration: _getModalDecoration( + context, + color: null, + borderColor: borderColor, + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + constraints: BoxConstraints(maxHeight: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (markdownText.isNotEmpty) ...[ + Flexible( + child: DecoratedBox( + decoration: _secondaryContentDecoration(context), + child: SecondaryContentArea( + markdownText: markdownText, + onSelectSuggestionAction: (action) { + _onSelectSuggestionAction(context, action); + }, + command: command, + showSuggestionActions: showSuggestedActionsWithin, + hasSelection: hasSelection, + ), + ), + ), + Divider(height: 1.0), + ], + DecoratedBox( + decoration: markdownText.isNotEmpty + ? _mainContentDecoration(context) + : _getSingleChildDeocoration(context), + child: MainContentArea( + textController: widget.textController, + isDocumentEmpty: _isDocumentEmpty(), + isInitialReadyState: isInitialReadyState, + showCommandsToggle: showCommandsToggle, + ), + ), + ], + ), + ), + ValueListenableBuilder( + valueListenable: showCommandsToggle, + builder: (context, value, child) { + if (!value || !isInitialReadyState) { + return const SizedBox.shrink(); + } + return Align( + alignment: AlignmentDirectional.centerEnd, + child: MoreAiWriterCommands( + hasSelection: hasSelection, + editorState: widget.editorState, + onSelectCommand: (command) { + final state = context.read().state; + final showPredefinedFormats = state.showPredefinedFormats; + final predefinedFormat = state.predefinedFormat; + final text = widget.textController.text; + + context.read().runCommand( + command, + text, + showPredefinedFormats ? predefinedFormat : null, + ); + }, + ), + ); + }, + ), + ], + ); + }, + ); + } + + BoxDecoration _getModalDecoration( + BuildContext context, { + required Color? color, + required Color borderColor, + required BorderRadius borderRadius, + }) { + return BoxDecoration( + color: color, + border: Border.all( + color: borderColor, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: borderRadius, + boxShadow: Theme.of(context).isLightMode + ? ShadowConstants.lightSmall + : ShadowConstants.darkSmall, + ); + } + + BoxDecoration _getSingleChildDeocoration(BuildContext context) { + return BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ); + } + + BoxDecoration _secondaryContentDecoration(BuildContext context) { + return BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.vertical(top: Radius.circular(12.0)), + ); + } + + BoxDecoration _mainContentDecoration(BuildContext context) { + return BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.vertical(bottom: Radius.circular(12.0)), + ); + } + + void _onSelectSuggestionAction( + BuildContext context, + SuggestionAction action, + ) { + final predefinedFormat = + context.read().state.predefinedFormat; + context.read().runResponseAction( + action, + predefinedFormat, + ); + } + + bool _isDocumentEmpty() { + if (widget.editorState.isEmptyForContinueWriting()) { + final documentContext = widget.editorState.document.root.context; + if (documentContext == null) { + return true; + } + final view = documentContext.read().state.view; + if (view.name.isEmpty) { + return true; + } + } + return false; + } +} + +class SecondaryContentArea extends StatelessWidget { + const SecondaryContentArea({ + super.key, + required this.command, + required this.markdownText, + required this.showSuggestionActions, + required this.hasSelection, + required this.onSelectSuggestionAction, + }); + + final AiWriterCommand command; + final String markdownText; + final bool showSuggestionActions; + final bool hasSelection; + final void Function(SuggestionAction) onSelectSuggestionAction; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(8.0), + Container( + height: 24.0, + padding: EdgeInsets.symmetric(horizontal: 14.0), + alignment: AlignmentDirectional.centerStart, + child: FlowyText( + command.i18n, + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF666D76), + ), + ), + const VSpace(4.0), + Flexible( + child: SingleChildScrollView( + physics: ClampingScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: 14.0), + child: AIMarkdownText( + markdown: markdownText, + ), + ), + ), + if (showSuggestionActions) ...[ + const VSpace(4.0), + Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: SuggestionActionBar( + currentCommand: command, + hasSelection: hasSelection, + onTap: onSelectSuggestionAction, + ), + ), + ], + const VSpace(8.0), + ], + ), + ); + } +} + +class MainContentArea extends StatelessWidget { + const MainContentArea({ + super.key, + required this.textController, + required this.isInitialReadyState, + required this.isDocumentEmpty, + required this.showCommandsToggle, + }); + + final TextEditingController textController; + final bool isInitialReadyState; + final bool isDocumentEmpty; + final ValueNotifier showCommandsToggle; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + + if (state is ReadyAiWriterState) { + return DesktopPromptInput( + isStreaming: false, + hideDecoration: true, + hideFormats: [ + AiWriterCommand.fixSpellingAndGrammar, + AiWriterCommand.improveWriting, + AiWriterCommand.makeLonger, + AiWriterCommand.makeShorter, + ].contains(state.command), + textController: textController, + onSubmitted: (message, format, _) { + cubit.runCommand(state.command, message, format); + }, + onStopStreaming: () => cubit.stopStream(), + selectedSourcesNotifier: cubit.selectedSourcesNotifier, + onUpdateSelectedSources: (sources) { + cubit.selectedSourcesNotifier.value = [ + ...sources, + ]; + }, + extraBottomActionButton: isInitialReadyState + ? ValueListenableBuilder( + valueListenable: showCommandsToggle, + builder: (context, value, _) { + return AiWriterPromptMoreButton( + isEnabled: !isDocumentEmpty, + isSelected: value, + onTap: () => showCommandsToggle.value = !value, + ); + }, + ) + : null, + ); + } + if (state is GeneratingAiWriterState) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const HSpace(6.0), + Expanded( + child: AILoadingIndicator( + text: state.command == AiWriterCommand.explain + ? LocaleKeys.ai_analyzing.tr() + : LocaleKeys.ai_editing.tr(), + ), + ), + const HSpace(8.0), + PromptInputSendButton( + state: SendButtonState.streaming, + onSendPressed: () {}, + onStopStreaming: () => cubit.stopStream(), + ), + ], + ), + ); + } + if (state is ErrorAiWriterState) { + return Padding( + padding: EdgeInsets.all(8.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.toast_error_filled_s, + blendMode: null, + ), + const HSpace(8.0), + Expanded( + child: FlowyText( + state.error.message, + maxLines: null, + ), + ), + const HSpace(8.0), + FlowyIconButton( + width: 32, + hoverColor: Colors.transparent, + icon: FlowySvg( + FlowySvgs.toast_close_s, + size: Size.square(20), + ), + onPressed: () => cubit.exit(), + ), + ], + ), + ); + } + if (state is LocalAIStreamingAiWriterState) { + final text = switch (state.state) { + LocalAIStreamingState.notReady => + LocaleKeys.settings_aiPage_keys_localAINotReadyRetryLater.tr(), + LocalAIStreamingState.disabled => + LocaleKeys.settings_aiPage_keys_localAIDisabled.tr(), + }; + return Padding( + padding: EdgeInsets.all(8.0), + child: Row( + children: [ + const HSpace(8.0), + Opacity( + opacity: 0.5, + child: FlowyText(text), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart new file mode 100644 index 0000000000..70d627d327 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.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/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'operations/ai_writer_entities.dart'; + +const _improveWritingToolbarItemId = 'appflowy.editor.ai_improve_writing'; +const _aiWriterToolbarItemId = 'appflowy.editor.ai_writer'; + +final ToolbarItem improveWritingItem = ToolbarItem( + id: _improveWritingToolbarItemId, + group: 0, + isActive: onlyShowInTextTypeAndExcludeTable, + builder: (context, editorState, _, __, tooltipBuilder) => + ImproveWritingButton( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + ), +); + +final ToolbarItem aiWriterItem = ToolbarItem( + id: _aiWriterToolbarItemId, + group: 0, + isActive: onlyShowInTextTypeAndExcludeTable, + builder: (context, editorState, _, __, tooltipBuilder) => + AiWriterToolbarActionList( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + ), +); + +class AiWriterToolbarActionList extends StatefulWidget { + const AiWriterToolbarActionList({ + super.key, + required this.editorState, + this.tooltipBuilder, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + + @override + State createState() => + _AiWriterToolbarActionListState(); +} + +class _AiWriterToolbarActionListState extends State { + final popoverController = PopoverController(); + bool isSelected = false; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 2.0), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + popupBuilder: (context) => buildPopoverContent(), + child: buildChild(context), + ); + } + + Widget buildPopoverContent() { + return SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(4.0), + children: [ + actionWrapper(AiWriterCommand.improveWriting), + actionWrapper(AiWriterCommand.userQuestion), + actionWrapper(AiWriterCommand.fixSpellingAndGrammar), + // actionWrapper(AiWriterCommand.summarize), + actionWrapper(AiWriterCommand.explain), + divider(), + actionWrapper(AiWriterCommand.makeLonger), + actionWrapper(AiWriterCommand.makeShorter), + ], + ); + } + + Widget actionWrapper(AiWriterCommand command) { + return SizedBox( + height: 36, + child: FlowyButton( + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(command.icon), + iconPadding: 12, + text: FlowyText( + command.i18n, + figmaLineHeight: 20, + ), + onTap: () { + popoverController.close(); + _insertAiNode(widget.editorState, command); + }, + ), + ); + } + + Widget divider() { + return const Divider( + thickness: 1.0, + height: 1.0, + ); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; + final child = FlowyIconButton( + width: 48, + height: 32, + isSelected: isSelected, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.toolbar_ai_writer_m, + size: Size.square(20), + color: iconScheme.primary, + ), + HSpace(4), + FlowySvg( + FlowySvgs.toolbar_arrow_down_m, + size: Size(12, 20), + color: iconScheme.primary, + ), + ], + ), + onPressed: () { + if (_isAIWriterEnabled(widget.editorState)) { + keepEditorFocusNotifier.increase(); + popoverController.show(); + setState(() { + isSelected = true; + }); + } else { + showToastNotification( + message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), + ); + } + }, + ); + + return widget.tooltipBuilder?.call( + context, + _aiWriterToolbarItemId, + _isAIWriterEnabled(widget.editorState) + ? LocaleKeys.document_plugins_aiWriter_userQuestion.tr() + : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), + child, + ) ?? + child; + } +} + +class ImproveWritingButton extends StatelessWidget { + const ImproveWritingButton({ + super.key, + required this.editorState, + this.tooltipBuilder, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + final child = FlowyIconButton( + width: 36, + height: 32, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: FlowySvg( + FlowySvgs.toolbar_ai_improve_writing_m, + size: Size.square(20.0), + color: theme.iconColorScheme.primary, + ), + onPressed: () { + if (_isAIWriterEnabled(editorState)) { + keepEditorFocusNotifier.increase(); + _insertAiNode(editorState, AiWriterCommand.improveWriting); + } else { + showToastNotification( + message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), + ); + } + }, + ); + + return tooltipBuilder?.call( + context, + _aiWriterToolbarItemId, + _isAIWriterEnabled(editorState) + ? LocaleKeys.document_plugins_aiWriter_improveWriting.tr() + : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), + child, + ) ?? + child; + } +} + +void _insertAiNode(EditorState editorState, AiWriterCommand command) async { + final selection = editorState.selection?.normalized; + if (selection == null) { + return; + } + + final transaction = editorState.transaction + ..insertNode( + selection.end.path.next, + aiWriterNode( + selection: selection, + command: command, + ), + ) + ..selectionExtraInfo = {selectionExtraInfoDisableToolbar: true}; + + await editorState.apply( + transaction, + options: const ApplyOptions( + recordUndo: false, + inMemoryUpdate: true, + ), + withUpdateSelection: false, + ); +} + +bool _isAIWriterEnabled(EditorState editorState) { + return true; +} + +bool onlyShowInTextTypeAndExcludeTable( + EditorState editorState, +) { + return onlyShowInTextType(editorState) && notShowInTable(editorState); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart new file mode 100644 index 0000000000..1b495a5b23 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart @@ -0,0 +1,258 @@ +import 'dart:async'; + +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +import '../ai_writer_block_component.dart'; +import 'ai_writer_entities.dart'; +import 'ai_writer_node_extension.dart'; + +Future setAiWriterNodeIsInitialized( + EditorState editorState, + Node node, +) async { + final transaction = editorState.transaction + ..updateNode(node, { + AiWriterBlockKeys.isInitialized: true, + }); + + await editorState.apply( + transaction, + options: const ApplyOptions( + recordUndo: false, + inMemoryUpdate: true, + ), + withUpdateSelection: false, + ); + + final selection = node.aiWriterSelection; + if (selection != null && !selection.isCollapsed) { + unawaited( + editorState.updateSelectionWithReason( + selection, + extraInfo: {selectionExtraInfoDisableToolbar: true}, + ), + ); + } +} + +Future removeAiWriterNode( + EditorState editorState, + Node node, +) async { + final transaction = editorState.transaction..deleteNode(node); + await editorState.apply( + transaction, + options: const ApplyOptions(recordUndo: false), + withUpdateSelection: false, + ); +} + +Future formatSelection( + EditorState editorState, + Selection selection, + ApplySuggestionFormatType formatType, +) async { + final nodes = editorState.getNodesInSelection(selection).toList(); + if (nodes.isEmpty) { + return; + } + final transaction = editorState.transaction; + + if (nodes.length == 1) { + final node = nodes.removeAt(0); + if (node.delta != null) { + final delta = Delta() + ..retain(selection.start.offset) + ..retain( + selection.length, + attributes: formatType.attributes, + ); + transaction.addDeltaToComposeMap(node, delta); + } + } else { + final firstNode = nodes.removeAt(0); + final lastNode = nodes.removeLast(); + + if (firstNode.delta != null) { + final text = firstNode.delta!.toPlainText(); + final remainderLength = text.length - selection.start.offset; + final delta = Delta() + ..retain(selection.start.offset) + ..retain(remainderLength, attributes: formatType.attributes); + transaction.addDeltaToComposeMap(firstNode, delta); + } + + if (lastNode.delta != null) { + final delta = Delta() + ..retain(selection.end.offset, attributes: formatType.attributes); + transaction.addDeltaToComposeMap(lastNode, delta); + } + + for (final node in nodes) { + if (node.delta == null) { + continue; + } + final length = node.delta!.length; + if (length != 0) { + final delta = Delta() + ..retain(length, attributes: formatType.attributes); + transaction.addDeltaToComposeMap(node, delta); + } + } + } + + transaction.compose(); + await editorState.apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: true, + recordUndo: false, + ), + withUpdateSelection: false, + ); +} + +Future ensurePreviousNodeIsEmptyParagraph( + EditorState editorState, + Node aiWriterNode, +) async { + final previous = aiWriterNode.previous; + final needsEmptyParagraphNode = previous == null || + previous.type != ParagraphBlockKeys.type || + (previous.delta?.toPlainText().isNotEmpty ?? false); + + final Position position; + final transaction = editorState.transaction; + + if (needsEmptyParagraphNode) { + position = Position(path: aiWriterNode.path); + transaction.insertNode(aiWriterNode.path, paragraphNode()); + } else { + position = Position(path: previous.path); + } + transaction.afterSelection = Selection.collapsed(position); + + await editorState.apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: true, + recordUndo: false, + ), + ); + + return position; +} + +extension SaveAIResponseExtension on EditorState { + Future insertBelow({ + required Node node, + required String markdownText, + }) async { + final selection = this.selection?.normalized; + if (selection == null) { + return; + } + + final nodes = customMarkdownToDocument( + markdownText, + tableWidth: 250.0, + ).root.children.map((e) => e.deepCopy()).toList(); + if (nodes.isEmpty) { + return; + } + + final insertedPath = selection.end.path.next; + final lastDeltaLength = nodes.lastOrNull?.delta?.length ?? 0; + + final transaction = this.transaction + ..insertNodes(insertedPath, nodes) + ..afterSelection = Selection( + start: Position(path: insertedPath), + end: Position( + path: insertedPath.nextNPath(nodes.length - 1), + offset: lastDeltaLength, + ), + ); + + await apply(transaction); + } + + Future replace({ + required Selection selection, + required String text, + }) async { + final trimmedText = text.trim(); + if (trimmedText.isEmpty) { + return; + } + await switch (kdefaultReplacementType) { + AskAIReplacementType.markdown => + _replaceWithMarkdown(selection, trimmedText), + AskAIReplacementType.plainText => + _replaceWithPlainText(selection, trimmedText), + }; + } + + Future _replaceWithMarkdown( + Selection selection, + String markdownText, + ) async { + final nodes = customMarkdownToDocument(markdownText) + .root + .children + .map((e) => e.deepCopy()) + .toList(); + if (nodes.isEmpty) { + return; + } + + final nodesInSelection = getNodesInSelection(selection); + final newSelection = Selection( + start: selection.start, + end: Position( + path: selection.start.path.nextNPath(nodes.length - 1), + offset: nodes.lastOrNull?.delta?.length ?? 0, + ), + ); + + final transaction = this.transaction + ..insertNodes(selection.start.path, nodes) + ..deleteNodes(nodesInSelection) + ..afterSelection = newSelection; + await apply(transaction); + } + + Future _replaceWithPlainText( + Selection selection, + String plainText, + ) async { + final nodes = getNodesInSelection(selection); + if (nodes.isEmpty || nodes.any((element) => element.delta == null)) { + return; + } + + final replaceTexts = plainText.split('\n') + ..removeWhere((element) => element.isEmpty); + final transaction = this.transaction + ..replaceTexts( + nodes, + selection, + replaceTexts, + ); + await 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, + ); + this.selection = Selection( + start: selection.start, + end: end, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart new file mode 100644 index 0000000000..4bc13321b8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart @@ -0,0 +1,794 @@ +import 'dart:async'; + +import 'package:appflowy/ai/ai.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-ai/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +import '../../base/markdown_text_robot.dart'; +import 'ai_writer_block_operations.dart'; +import 'ai_writer_entities.dart'; +import 'ai_writer_node_extension.dart'; + +/// Enable the debug log for the AiWriterCubit. +/// +/// This is useful for debugging the AI writer cubit. +const _aiWriterCubitDebugLog = true; + +class AiWriterCubit extends Cubit { + AiWriterCubit({ + required this.documentId, + required this.editorState, + this.onCreateNode, + this.onRemoveNode, + this.onAppendToDocument, + AppFlowyAIService? aiService, + }) : _aiService = aiService ?? AppFlowyAIService(), + _textRobot = MarkdownTextRobot(editorState: editorState), + selectedSourcesNotifier = ValueNotifier([documentId]), + super(IdleAiWriterState()); + + final String documentId; + final EditorState editorState; + final AppFlowyAIService _aiService; + final MarkdownTextRobot _textRobot; + final void Function()? onCreateNode; + final void Function()? onRemoveNode; + final void Function()? onAppendToDocument; + + Node? aiWriterNode; + + final List records = []; + final ValueNotifier> selectedSourcesNotifier; + + @override + Future close() async { + selectedSourcesNotifier.dispose(); + await super.close(); + } + + Future exit({ + bool withDiscard = true, + bool withUnformat = true, + }) async { + if (aiWriterNode == null) { + return; + } + if (withDiscard) { + await _textRobot.discard( + afterSelection: aiWriterNode!.aiWriterSelection, + ); + } + _textRobot.clear(); + _textRobot.reset(); + onRemoveNode?.call(); + records.clear(); + selectedSourcesNotifier.value = [documentId]; + emit(IdleAiWriterState()); + + if (withUnformat) { + final selection = aiWriterNode!.aiWriterSelection; + if (selection == null) { + return; + } + await formatSelection( + editorState, + selection, + ApplySuggestionFormatType.clear, + ); + } + if (aiWriterNode != null) { + await removeAiWriterNode(editorState, aiWriterNode!); + aiWriterNode = null; + } + } + + void register(Node node) async { + if (node.isAiWriterInitialized) { + return; + } + if (aiWriterNode != null && node.id != aiWriterNode!.id) { + await removeAiWriterNode(editorState, node); + return; + } + + aiWriterNode = node; + onCreateNode?.call(); + + await setAiWriterNodeIsInitialized(editorState, node); + + final command = node.aiWriterCommand; + final (run, prompt) = await _addSelectionTextToRecords(command); + + _aiWriterCubitLog( + 'command: $command, run: $run, prompt: $prompt', + ); + + if (!run) { + await exit(); + return; + } + + runCommand(command, prompt, null); + } + + void runCommand( + AiWriterCommand command, + String prompt, + PredefinedFormat? predefinedFormat, + ) async { + if (aiWriterNode == null) { + return; + } + + await _textRobot.discard(); + _textRobot.clear(); + + switch (command) { + case AiWriterCommand.continueWriting: + await _startContinueWriting( + command, + predefinedFormat, + ); + break; + case AiWriterCommand.fixSpellingAndGrammar: + case AiWriterCommand.improveWriting: + case AiWriterCommand.makeLonger: + case AiWriterCommand.makeShorter: + await _startSuggestingEdits(command, prompt, predefinedFormat); + break; + case AiWriterCommand.explain: + await _startInforming(command, prompt, predefinedFormat); + break; + case AiWriterCommand.userQuestion when prompt.isNotEmpty: + _startAskingQuestion(prompt, predefinedFormat); + break; + case AiWriterCommand.userQuestion: + emit( + ReadyAiWriterState(AiWriterCommand.userQuestion, isFirstRun: true), + ); + break; + } + } + + void _retry({ + required PredefinedFormat? predefinedFormat, + }) async { + final lastQuestion = + records.lastWhereOrNull((record) => record.role == AiRole.user); + + if (lastQuestion != null && state is RegisteredAiWriter) { + runCommand( + (state as RegisteredAiWriter).command, + lastQuestion.content, + lastQuestion.format, + ); + } + } + + Future stopStream() async { + if (aiWriterNode == null) { + return; + } + + if (state is GeneratingAiWriterState) { + final generatingState = state as GeneratingAiWriterState; + + await _textRobot.stop( + attributes: ApplySuggestionFormatType.replace.attributes, + ); + + if (_textRobot.hasAnyResult) { + records.add(AiWriterRecord.ai(content: _textRobot.markdownText)); + } + + await AIEventStopCompleteText( + CompleteTextTaskPB( + taskId: generatingState.taskId, + ), + ).send(); + + emit( + ReadyAiWriterState( + generatingState.command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); + } + } + + void runResponseAction( + SuggestionAction action, [ + PredefinedFormat? predefinedFormat, + ]) async { + if (aiWriterNode == null) { + return; + } + + if (action case SuggestionAction.rewrite || SuggestionAction.tryAgain) { + _retry(predefinedFormat: predefinedFormat); + return; + } + if (action case SuggestionAction.discard || SuggestionAction.close) { + await exit(); + return; + } + + final selection = aiWriterNode?.aiWriterSelection; + if (selection == null) { + return; + } + + // Accept + // + // If the user clicks accept, we need to replace the selection with the AI's response + if (action case SuggestionAction.accept) { + // trim the markdown text to avoid extra new lines + final trimmedMarkdownText = _textRobot.markdownText.trim(); + + _aiWriterCubitLog( + 'trigger accept action, markdown text: $trimmedMarkdownText', + ); + + await formatSelection( + editorState, + selection, + ApplySuggestionFormatType.clear, + ); + + await _textRobot.deleteAINodes(); + + await _textRobot.replace( + selection: selection, + markdownText: trimmedMarkdownText, + ); + + await exit(withDiscard: false, withUnformat: false); + + return; + } + + if (action case SuggestionAction.keep) { + await _textRobot.persist(); + await exit(withDiscard: false); + return; + } + + if (action case SuggestionAction.insertBelow) { + if (state is! ReadyAiWriterState) { + return; + } + final command = (state as ReadyAiWriterState).command; + final markdownText = (state as ReadyAiWriterState).markdownText; + if (command == AiWriterCommand.explain && markdownText.isNotEmpty) { + final position = await ensurePreviousNodeIsEmptyParagraph( + editorState, + aiWriterNode!, + ); + _textRobot.start(position: position); + await _textRobot.persist(markdownText: markdownText); + } else if (_textRobot.hasAnyResult) { + await _textRobot.persist(); + } + + await formatSelection( + editorState, + selection, + ApplySuggestionFormatType.clear, + ); + await exit(withDiscard: false); + } + } + + bool hasUnusedResponse() { + return switch (state) { + ReadyAiWriterState( + isFirstRun: final isInitial, + markdownText: final markdownText, + ) => + !isInitial && (markdownText.isNotEmpty || _textRobot.hasAnyResult), + GeneratingAiWriterState() => true, + _ => false, + }; + } + + Future<(bool, String)> _addSelectionTextToRecords( + AiWriterCommand command, + ) async { + final node = aiWriterNode; + + // check the node is registered + if (node == null) { + return (false, ''); + } + + // check the selection is valid + final selection = node.aiWriterSelection?.normalized; + if (selection == null) { + return (false, ''); + } + + // if the command is continue writing, we don't need to get the selection text + if (command == AiWriterCommand.continueWriting) { + return (true, ''); + } + + // if the selection is collapsed, we don't need to get the selection text + if (selection.isCollapsed) { + return (true, ''); + } + + final selectionText = await editorState.getMarkdownInSelection(selection); + + if (command == AiWriterCommand.userQuestion) { + records.add( + AiWriterRecord.user(content: selectionText, format: null), + ); + + return (true, ''); + } else { + return (true, selectionText); + } + } + + Future _getDocumentContentFromTopToPosition(Position position) async { + final beginningToCursorSelection = Selection( + start: Position(path: [0]), + end: position, + ).normalized; + + final documentText = + (await editorState.getMarkdownInSelection(beginningToCursorSelection)) + .trim(); + + final view = await ViewBackendService.getView(documentId).toNullable(); + final viewName = view?.name ?? ''; + + return "$viewName\n$documentText".trim(); + } + + void _startAskingQuestion( + String prompt, + PredefinedFormat? format, + ) async { + if (aiWriterNode == null) { + return; + } + final command = AiWriterCommand.userQuestion; + + final stream = await _aiService.streamCompletion( + objectId: documentId, + text: prompt, + format: format, + history: records, + sourceIds: selectedSourcesNotifier.value, + completionType: command.toCompletionType(), + onStart: () async { + final position = await ensurePreviousNodeIsEmptyParagraph( + editorState, + aiWriterNode!, + ); + _textRobot.start(position: position); + records.add( + AiWriterRecord.user( + content: prompt, + format: format, + ), + ); + }, + processMessage: (text) async { + await _textRobot.appendMarkdownText( + text, + updateSelection: false, + attributes: ApplySuggestionFormatType.replace.attributes, + ); + onAppendToDocument?.call(); + }, + processAssistMessage: (text) async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + GeneratingAiWriterState( + command, + taskId: generatingState.taskId, + markdownText: generatingState.markdownText + text, + ), + ); + } + }, + onEnd: () async { + if (state case final GeneratingAiWriterState generatingState) { + await _textRobot.stop( + attributes: ApplySuggestionFormatType.replace.attributes, + ); + emit( + ReadyAiWriterState( + command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + } + }, + onError: (error) async { + emit(ErrorAiWriterState(command, error: error)); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + }, + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); + }, + ); + + if (stream != null) { + emit( + GeneratingAiWriterState( + command, + taskId: stream.$1, + ), + ); + } + } + + Future _startContinueWriting( + AiWriterCommand command, + PredefinedFormat? predefinedFormat, + ) async { + final position = aiWriterNode?.aiWriterSelection?.start; + if (position == null) { + return; + } + final text = await _getDocumentContentFromTopToPosition(position); + + if (text.isEmpty) { + final stateCopy = state; + emit(DocumentContentEmptyAiWriterState(command, onConfirm: exit)); + emit(stateCopy); + return; + } + + final stream = await _aiService.streamCompletion( + objectId: documentId, + text: text, + completionType: command.toCompletionType(), + history: records, + sourceIds: selectedSourcesNotifier.value, + format: predefinedFormat, + onStart: () async { + final position = await ensurePreviousNodeIsEmptyParagraph( + editorState, + aiWriterNode!, + ); + _textRobot.start(position: position); + records.add( + AiWriterRecord.user( + content: text, + format: predefinedFormat, + ), + ); + }, + processMessage: (text) async { + await _textRobot.appendMarkdownText( + text, + updateSelection: false, + attributes: ApplySuggestionFormatType.replace.attributes, + ); + onAppendToDocument?.call(); + }, + processAssistMessage: (text) async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + GeneratingAiWriterState( + command, + taskId: generatingState.taskId, + markdownText: generatingState.markdownText + text, + ), + ); + } + }, + onEnd: () async { + if (state case final GeneratingAiWriterState generatingState) { + await _textRobot.stop( + attributes: ApplySuggestionFormatType.replace.attributes, + ); + emit( + ReadyAiWriterState( + command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); + } + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + }, + onError: (error) async { + emit(ErrorAiWriterState(command, error: error)); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + }, + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); + }, + ); + if (stream != null) { + emit( + GeneratingAiWriterState(command, taskId: stream.$1), + ); + } + } + + Future _startSuggestingEdits( + AiWriterCommand command, + String prompt, + PredefinedFormat? predefinedFormat, + ) async { + final selection = aiWriterNode?.aiWriterSelection; + if (selection == null) { + return; + } + if (prompt.isEmpty) { + prompt = records.removeAt(0).content; + } + + final stream = await _aiService.streamCompletion( + objectId: documentId, + text: prompt, + format: predefinedFormat, + completionType: command.toCompletionType(), + history: records, + sourceIds: selectedSourcesNotifier.value, + onStart: () async { + await formatSelection( + editorState, + selection, + ApplySuggestionFormatType.original, + ); + final position = await ensurePreviousNodeIsEmptyParagraph( + editorState, + aiWriterNode!, + ); + _textRobot.start(position: position, previousSelection: selection); + records.add( + AiWriterRecord.user( + content: prompt, + format: predefinedFormat, + ), + ); + }, + processMessage: (text) async { + await _textRobot.appendMarkdownText( + text, + updateSelection: false, + attributes: ApplySuggestionFormatType.replace.attributes, + ); + onAppendToDocument?.call(); + + _aiWriterCubitLog( + 'received message: $text', + ); + }, + processAssistMessage: (text) async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + GeneratingAiWriterState( + command, + taskId: generatingState.taskId, + markdownText: generatingState.markdownText + text, + ), + ); + } + + _aiWriterCubitLog( + 'received assist message: $text', + ); + }, + onEnd: () async { + if (state case final GeneratingAiWriterState generatingState) { + await _textRobot.stop( + attributes: ApplySuggestionFormatType.replace.attributes, + ); + emit( + ReadyAiWriterState( + command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + + _aiWriterCubitLog( + 'returned response: ${_textRobot.markdownText}', + ); + } + }, + onError: (error) async { + emit(ErrorAiWriterState(command, error: error)); + records.add( + AiWriterRecord.ai(content: _textRobot.markdownText), + ); + }, + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); + }, + ); + if (stream != null) { + emit( + GeneratingAiWriterState(command, taskId: stream.$1), + ); + } + } + + Future _startInforming( + AiWriterCommand command, + String prompt, + PredefinedFormat? predefinedFormat, + ) async { + final selection = aiWriterNode?.aiWriterSelection; + if (selection == null) { + return; + } + if (prompt.isEmpty) { + prompt = records.removeAt(0).content; + } + + final stream = await _aiService.streamCompletion( + objectId: documentId, + text: prompt, + completionType: command.toCompletionType(), + history: records, + sourceIds: selectedSourcesNotifier.value, + format: predefinedFormat, + onStart: () async { + records.add( + AiWriterRecord.user( + content: prompt, + format: predefinedFormat, + ), + ); + }, + processMessage: (text) async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + GeneratingAiWriterState( + command, + taskId: generatingState.taskId, + markdownText: generatingState.markdownText + text, + ), + ); + } + }, + processAssistMessage: (_) async {}, + onEnd: () async { + if (state case final GeneratingAiWriterState generatingState) { + emit( + ReadyAiWriterState( + command, + isFirstRun: false, + markdownText: generatingState.markdownText, + ), + ); + records.add( + AiWriterRecord.ai(content: generatingState.markdownText), + ); + } + }, + onError: (error) async { + if (state case final GeneratingAiWriterState generatingState) { + records.add( + AiWriterRecord.ai(content: generatingState.markdownText), + ); + } + emit(ErrorAiWriterState(command, error: error)); + }, + onLocalAIStreamingStateChange: (state) { + emit(LocalAIStreamingAiWriterState(command, state: state)); + }, + ); + if (stream != null) { + emit( + GeneratingAiWriterState(command, taskId: stream.$1), + ); + } + } + + void _aiWriterCubitLog(String message) { + if (_aiWriterCubitDebugLog) { + Log.debug('[AiWriterCubit] $message'); + } + } +} + +mixin RegisteredAiWriter { + AiWriterCommand get command; +} + +sealed class AiWriterState { + const AiWriterState(); +} + +class IdleAiWriterState extends AiWriterState { + const IdleAiWriterState(); +} + +class ReadyAiWriterState extends AiWriterState with RegisteredAiWriter { + const ReadyAiWriterState( + this.command, { + required this.isFirstRun, + this.markdownText = '', + }); + + @override + final AiWriterCommand command; + + final bool isFirstRun; + final String markdownText; +} + +class GeneratingAiWriterState extends AiWriterState with RegisteredAiWriter { + const GeneratingAiWriterState( + this.command, { + required this.taskId, + this.progress = '', + this.markdownText = '', + }); + + @override + final AiWriterCommand command; + + final String taskId; + final String progress; + final String markdownText; +} + +class ErrorAiWriterState extends AiWriterState with RegisteredAiWriter { + const ErrorAiWriterState( + this.command, { + required this.error, + }); + + @override + final AiWriterCommand command; + + final AIError error; +} + +class DocumentContentEmptyAiWriterState extends AiWriterState + with RegisteredAiWriter { + const DocumentContentEmptyAiWriterState( + this.command, { + required this.onConfirm, + }); + + @override + final AiWriterCommand command; + + final void Function() onConfirm; +} + +class LocalAIStreamingAiWriterState extends AiWriterState + with RegisteredAiWriter { + const LocalAIStreamingAiWriterState( + this.command, { + required this.state, + }); + + @override + final AiWriterCommand command; + + final LocalAIStreamingState state; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart new file mode 100644 index 0000000000..f15c2e6d7f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart @@ -0,0 +1,159 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +import '../ai_writer_block_component.dart'; + +const kdefaultReplacementType = AskAIReplacementType.markdown; + +enum AskAIReplacementType { + markdown, + plainText, +} + +enum SuggestionAction { + accept, + discard, + close, + tryAgain, + rewrite, + keep, + insertBelow; + + String get i18n => switch (this) { + accept => LocaleKeys.suggestion_accept.tr(), + discard => LocaleKeys.suggestion_discard.tr(), + close => LocaleKeys.suggestion_close.tr(), + tryAgain => LocaleKeys.suggestion_tryAgain.tr(), + rewrite => LocaleKeys.suggestion_rewrite.tr(), + keep => LocaleKeys.suggestion_keep.tr(), + insertBelow => LocaleKeys.suggestion_insertBelow.tr(), + }; + + FlowySvg buildIcon(BuildContext context) { + final icon = switch (this) { + accept || keep => FlowySvgs.ai_fix_spelling_grammar_s, + discard || close => FlowySvgs.toast_close_s, + tryAgain || rewrite => FlowySvgs.ai_try_again_s, + insertBelow => FlowySvgs.suggestion_insert_below_s, + }; + + return FlowySvg( + icon, + size: Size.square(16.0), + color: switch (this) { + accept || keep => Color(0xFF278E42), + discard || close => Color(0xFFC40055), + _ => Theme.of(context).iconTheme.color, + }, + ); + } +} + +enum AiWriterCommand { + userQuestion, + explain, + // summarize, + continueWriting, + fixSpellingAndGrammar, + improveWriting, + makeShorter, + makeLonger; + + String defaultPrompt(String input) => switch (this) { + userQuestion => input, + explain => "Explain this phrase in a concise manner:\n\n$input", + // summarize => '$input\n\nTl;dr', + continueWriting => + 'Continue writing based on this existing text:\n\n$input', + fixSpellingAndGrammar => 'Correct this to standard English:\n\n$input', + improveWriting => 'Rewrite this in your own words:\n\n$input', + makeShorter => 'Make this text shorter:\n\n$input', + makeLonger => 'Make this text longer:\n\n$input', + }; + + String get i18n => switch (this) { + userQuestion => LocaleKeys.document_plugins_aiWriter_userQuestion.tr(), + explain => LocaleKeys.document_plugins_aiWriter_explain.tr(), + // summarize => LocaleKeys.document_plugins_aiWriter_summarize.tr(), + continueWriting => + LocaleKeys.document_plugins_aiWriter_continueWriting.tr(), + fixSpellingAndGrammar => + LocaleKeys.document_plugins_aiWriter_fixSpelling.tr(), + improveWriting => + LocaleKeys.document_plugins_smartEditImproveWriting.tr(), + makeShorter => LocaleKeys.document_plugins_aiWriter_makeShorter.tr(), + makeLonger => LocaleKeys.document_plugins_aiWriter_makeLonger.tr(), + }; + + FlowySvgData get icon => switch (this) { + userQuestion => FlowySvgs.ai_sparks_s, + explain => FlowySvgs.ai_explain_m, + // summarize => FlowySvgs.ai_summarize_s, + continueWriting || improveWriting => FlowySvgs.ai_improve_writing_s, + fixSpellingAndGrammar => FlowySvgs.ai_fix_spelling_grammar_s, + makeShorter => FlowySvgs.ai_make_shorter_s, + makeLonger => FlowySvgs.ai_make_longer_s, + }; + + CompletionTypePB toCompletionType() => switch (this) { + userQuestion => CompletionTypePB.UserQuestion, + explain => CompletionTypePB.ExplainSelected, + // summarize => CompletionTypePB.Summarize, + continueWriting => CompletionTypePB.ContinueWriting, + fixSpellingAndGrammar => CompletionTypePB.SpellingAndGrammar, + improveWriting => CompletionTypePB.ImproveWriting, + makeShorter => CompletionTypePB.MakeShorter, + makeLonger => CompletionTypePB.MakeLonger, + }; +} + +enum ApplySuggestionFormatType { + original(AiWriterBlockKeys.suggestionOriginal), + replace(AiWriterBlockKeys.suggestionReplacement), + clear(null); + + const ApplySuggestionFormatType(this.value); + final String? value; + + Map get attributes => {AiWriterBlockKeys.suggestion: value}; +} + +enum AiRole { + user, + system, + ai, +} + +class AiWriterRecord extends Equatable { + const AiWriterRecord.user({ + required this.content, + required this.format, + }) : role = AiRole.user; + + const AiWriterRecord.ai({ + required this.content, + }) : role = AiRole.ai, + format = null; + + final AiRole role; + final String content; + final PredefinedFormat? format; + + @override + List get props => [role, content, format]; + + CompletionRecordPB toPB() { + return CompletionRecordPB( + content: content, + role: switch (role) { + AiRole.user => ChatMessageTypePB.User, + AiRole.system || AiRole.ai => ChatMessageTypePB.System, + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart new file mode 100644 index 0000000000..881871b154 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart @@ -0,0 +1,179 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +import '../ai_writer_block_component.dart'; +import 'ai_writer_entities.dart'; + +extension AiWriterExtension on Node { + bool get isAiWriterInitialized { + return attributes[AiWriterBlockKeys.isInitialized]; + } + + Selection? get aiWriterSelection { + final selection = attributes[AiWriterBlockKeys.selection]; + if (selection == null) { + return null; + } + return Selection.fromJson(selection); + } + + AiWriterCommand get aiWriterCommand { + final index = attributes[AiWriterBlockKeys.command]; + return AiWriterCommand.values[index]; + } +} + +extension AiWriterNodeExtension on EditorState { + Future getMarkdownInSelection(Selection? selection) async { + selection ??= this.selection?.normalized; + if (selection == null || selection.isCollapsed) { + return ''; + } + + // if the selected nodes are not entirely selected, slice the nodes + final slicedNodes = []; + final List flattenNodes = getNodesInSelection(selection); + final List nodes = []; + + for (final node in flattenNodes) { + if (nodes.any((element) => element.isParentOf(node))) { + continue; + } + nodes.add(node); + } + + 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); + } + + for (final (i, node) in slicedNodes.indexed) { + final childNodesShouldBeDeleted = []; + for (final child in node.children) { + if (!child.path.inSelection(selection)) { + childNodesShouldBeDeleted.add(child); + } + } + for (final child in childNodesShouldBeDeleted) { + slicedNodes[i] = node.copyWith( + children: node.children.where((e) => e.id != child.id).toList(), + type: selection.startIndex != 0 ? ParagraphBlockKeys.type : node.type, + ); + } + } + + // use \n\n as line break to improve the ai response + // using \n will cause the ai response treat the text as a single line + final markdown = await customDocumentToMarkdown( + Document.blank()..insert([0], slicedNodes), + lineBreak: '\n', + ); + + // trim the last \n if it exists + return markdown.trimRight(); + } + + 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; + } + + /// Determines whether the document is empty up to the selection + /// + /// If empty and the title is also empty, the continue writing option will be disabled. + bool isEmptyForContinueWriting({ + Selection? selection, + }) { + if (selection != null && !selection.isCollapsed) { + return false; + } + + final effectiveSelection = Selection( + start: Position(path: [0]), + end: selection?.normalized.end ?? + this.selection?.normalized.end ?? + Position(path: getLastSelectable()?.$1.path ?? [0]), + ); + + // if the selected nodes are not entirely selected, slice the nodes + final slicedNodes = []; + final nodes = getNodesInSelection(effectiveSelection); + + for (final node in nodes) { + final delta = node.delta; + if (delta == null) { + continue; + } + + final slicedDelta = delta.slice( + node == nodes.first ? effectiveSelection.startIndex : 0, + node == nodes.last ? effectiveSelection.endIndex : delta.length, + ); + + final copiedNode = node.copyWith( + attributes: { + ...node.attributes, + blockComponentDelta: slicedDelta.toJson(), + }, + ); + + slicedNodes.add(copiedNode); + } + + // using less custom parsers to avoid futures + final markdown = documentToMarkdown( + Document.blank()..insert([0], slicedNodes), + customParsers: [ + const MathEquationNodeParser(), + const CalloutNodeParser(), + const ToggleListNodeParser(), + const CustomParagraphNodeParser(), + const SubPageNodeParser(), + const SimpleTableNodeParser(), + const LinkPreviewNodeParser(), + const FileBlockNodeParser(), + ], + ); + + return markdown.trim().isEmpty; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_gesture_detector.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_gesture_detector.dart new file mode 100644 index 0000000000..8a691acdfc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_gesture_detector.dart @@ -0,0 +1,39 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class AiWriterGestureDetector extends StatelessWidget { + const AiWriterGestureDetector({ + super.key, + required this.behavior, + required this.onPointerEvent, + this.child, + }); + + final HitTestBehavior behavior; + final void Function() onPointerEvent; + final Widget? child; + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + behavior: behavior, + gestures: { + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (instance) => instance + ..onTapDown = ((_) => onPointerEvent()) + ..onSecondaryTapDown = ((_) => onPointerEvent()) + ..onTertiaryTapDown = ((_) => onPointerEvent()), + ), + ImmediateMultiDragGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + ImmediateMultiDragGestureRecognizer>( + () => ImmediateMultiDragGestureRecognizer(), + (instance) => instance.onStart = (offset) => null, + ), + }, + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart new file mode 100644 index 0000000000..72b8d9560b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart @@ -0,0 +1,151 @@ +import 'package:appflowy/ai/ai.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_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/colorscheme/default_colorscheme.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 '../operations/ai_writer_entities.dart'; + +class AiWriterPromptMoreButton extends StatelessWidget { + const AiWriterPromptMoreButton({ + super.key, + required this.isEnabled, + required this.isSelected, + required this.onTap, + }); + + final bool isEnabled; + final bool isSelected; + final void Function() onTap; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + ignoring: !isEnabled, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: DesktopAIPromptSizes.actionBarButtonSize, + child: FlowyHover( + style: const HoverStyle( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + isSelected: () => isSelected, + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + LocaleKeys.ai_more.tr(), + fontSize: 12, + figmaLineHeight: 16, + color: isEnabled + ? Theme.of(context).hintColor + : Theme.of(context).disabledColor, + ), + const HSpace(2.0), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(8), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class MoreAiWriterCommands extends StatelessWidget { + const MoreAiWriterCommands({ + super.key, + required this.hasSelection, + required this.editorState, + required this.onSelectCommand, + }); + + final EditorState editorState; + final bool hasSelection; + final void Function(AiWriterCommand) onSelectCommand; + + @override + Widget build(BuildContext context) { + return Container( + // add one here to take into account the border of the main message box. + // It is configured to be on the outside to hide some graphical + // artifacts. + margin: EdgeInsets.only(top: 4.0 + 1.0), + padding: EdgeInsets.all(8.0), + constraints: BoxConstraints(minWidth: 240.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: Theme.of(context).brightness == Brightness.light + ? ColorSchemeConstants.lightBorderColor + : ColorSchemeConstants.darkBorderColor, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + boxShadow: Theme.of(context).isLightMode + ? ShadowConstants.lightSmall + : ShadowConstants.darkSmall, + ), + child: IntrinsicWidth( + child: Column( + spacing: 4.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: _getCommands( + hasSelection: hasSelection, + ), + ), + ), + ); + } + + List _getCommands({required bool hasSelection}) { + if (hasSelection) { + return [ + _bottomButton(AiWriterCommand.improveWriting), + _bottomButton(AiWriterCommand.fixSpellingAndGrammar), + _bottomButton(AiWriterCommand.explain), + const Divider(height: 1.0, thickness: 1.0), + _bottomButton(AiWriterCommand.makeLonger), + _bottomButton(AiWriterCommand.makeShorter), + ]; + } else { + return [ + _bottomButton(AiWriterCommand.continueWriting), + ]; + } + } + + Widget _bottomButton(AiWriterCommand command) { + return Builder( + builder: (context) { + return FlowyButton( + leftIcon: FlowySvg( + command.icon, + color: Theme.of(context).iconTheme.color, + ), + leftIconSize: const Size.square(20), + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + text: FlowyText( + command.i18n, + figmaLineHeight: 20, + ), + onTap: () => onSelectCommand(command), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart new file mode 100644 index 0000000000..ef8ee81219 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart @@ -0,0 +1,242 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/throttle.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.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:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../operations/ai_writer_cubit.dart'; +import 'ai_writer_gesture_detector.dart'; + +class AiWriterScrollWrapper extends StatefulWidget { + const AiWriterScrollWrapper({ + super.key, + required this.viewId, + required this.editorState, + required this.child, + }); + + final String viewId; + final EditorState editorState; + final Widget child; + + @override + State createState() => _AiWriterScrollWrapperState(); +} + +class _AiWriterScrollWrapperState extends State { + final overlayController = OverlayPortalController(); + late final throttler = Throttler(); + late final aiWriterCubit = AiWriterCubit( + documentId: widget.viewId, + editorState: widget.editorState, + onCreateNode: () { + aiWriterRegistered = true; + widget.editorState.service.keyboardService?.disableShortcuts(); + }, + onRemoveNode: () { + aiWriterRegistered = false; + widget.editorState.service.keyboardService?.enableShortcuts(); + widget.editorState.service.keyboardService?.enable(); + }, + onAppendToDocument: onAppendToDocument, + ); + + bool userHasScrolled = false; + bool aiWriterRegistered = false; + bool dialogShown = false; + + @override + void initState() { + super.initState(); + overlayController.show(); + } + + @override + void dispose() { + aiWriterCubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: aiWriterCubit, + child: NotificationListener( + onNotification: handleScrollNotification, + child: Focus( + autofocus: true, + onKeyEvent: handleKeyEvent, + child: MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + if (state is DocumentContentEmptyAiWriterState) { + showConfirmDialog( + context: context, + title: + LocaleKeys.ai_continueWritingEmptyDocumentTitle.tr(), + description: LocaleKeys + .ai_continueWritingEmptyDocumentDescription + .tr(), + onConfirm: state.onConfirm, + ); + } + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous is GeneratingAiWriterState && + current is ReadyAiWriterState, + listener: (context, state) { + widget.editorState.updateSelectionWithReason(null); + }, + ), + ], + child: OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return BlocBuilder( + builder: (context, state) { + return AiWriterGestureDetector( + behavior: state is RegisteredAiWriter + ? HitTestBehavior.translucent + : HitTestBehavior.deferToChild, + onPointerEvent: () => onTapOutside(context), + ); + }, + ); + }, + child: widget.child, + ), + ), + ), + ), + ); + } + + bool handleScrollNotification(ScrollNotification notification) { + if (!aiWriterRegistered) { + return false; + } + + if (notification is UserScrollNotification) { + debounceResetUserHasScrolled(); + userHasScrolled = true; + throttler.cancel(); + } + + return false; + } + + void debounceResetUserHasScrolled() { + Debounce.debounce( + 'user_has_scrolled', + const Duration(seconds: 3), + () => userHasScrolled = false, + ); + } + + void onTapOutside(BuildContext context) { + final aiWriterCubit = context.read(); + + if (aiWriterCubit.hasUnusedResponse()) { + showConfirmDialog( + context: context, + title: LocaleKeys.button_discard.tr(), + description: LocaleKeys.document_plugins_discardResponse.tr(), + confirmLabel: LocaleKeys.button_discard.tr(), + style: ConfirmPopupStyle.cancelAndOk, + onConfirm: stopAndExit, + onCancel: () {}, + ); + } else { + stopAndExit(); + } + } + + KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { + if (!aiWriterRegistered) { + return KeyEventResult.ignored; + } + if (dialogShown) { + return KeyEventResult.handled; + } + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + + switch (event.logicalKey) { + case LogicalKeyboardKey.escape: + if (aiWriterCubit.state case GeneratingAiWriterState _) { + aiWriterCubit.stopStream(); + } else if (aiWriterCubit.hasUnusedResponse()) { + dialogShown = true; + showConfirmDialog( + context: context, + title: LocaleKeys.button_discard.tr(), + description: LocaleKeys.document_plugins_discardResponse.tr(), + confirmLabel: LocaleKeys.button_discard.tr(), + style: ConfirmPopupStyle.cancelAndOk, + onConfirm: stopAndExit, + onCancel: () {}, + ).then((_) => dialogShown = false); + } else { + stopAndExit(); + } + return KeyEventResult.handled; + case LogicalKeyboardKey.keyC + when HardwareKeyboard.instance.isControlPressed: + if (aiWriterCubit.state case GeneratingAiWriterState _) { + aiWriterCubit.stopStream(); + } + return KeyEventResult.handled; + default: + break; + } + + return KeyEventResult.ignored; + } + + void onAppendToDocument() { + if (!aiWriterRegistered || userHasScrolled) { + return; + } + + throttler.call(() { + if (aiWriterCubit.aiWriterNode != null) { + final path = aiWriterCubit.aiWriterNode!.path; + + if (path.isEmpty) { + return; + } + + if (path.previous.isNotEmpty) { + final node = widget.editorState.getNodeAtPath(path.previous); + if (node != null && node.delta != null && node.delta!.isNotEmpty) { + widget.editorState.updateSelectionWithReason( + Selection.collapsed( + Position(path: path, offset: node.delta!.length), + ), + ); + return; + } + } + + widget.editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: path)), + ); + } + }); + } + + void stopAndExit() { + Future(() async { + await aiWriterCubit.stopStream(); + await aiWriterCubit.exit(); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_suggestion_actions.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_suggestion_actions.dart new file mode 100644 index 0000000000..d39ede2608 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_suggestion_actions.dart @@ -0,0 +1,110 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import '../operations/ai_writer_entities.dart'; + +class SuggestionActionBar extends StatelessWidget { + const SuggestionActionBar({ + super.key, + required this.currentCommand, + required this.hasSelection, + required this.onTap, + }); + + final AiWriterCommand currentCommand; + final bool hasSelection; + final void Function(SuggestionAction) onTap; + + @override + Widget build(BuildContext context) { + return SeparatedRow( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const HSpace(4.0), + children: _getSuggestedActions() + .map( + (action) => SuggestionActionButton( + action: action, + onTap: () => onTap(action), + ), + ) + .toList(), + ); + } + + List _getSuggestedActions() { + if (hasSelection) { + return switch (currentCommand) { + AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [ + SuggestionAction.keep, + SuggestionAction.discard, + SuggestionAction.rewrite, + ], + AiWriterCommand.explain => [ + SuggestionAction.insertBelow, + SuggestionAction.tryAgain, + SuggestionAction.close, + ], + AiWriterCommand.fixSpellingAndGrammar || + AiWriterCommand.improveWriting || + AiWriterCommand.makeShorter || + AiWriterCommand.makeLonger => + [ + SuggestionAction.accept, + SuggestionAction.discard, + SuggestionAction.insertBelow, + SuggestionAction.rewrite, + ], + }; + } else { + return switch (currentCommand) { + AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [ + SuggestionAction.keep, + SuggestionAction.discard, + SuggestionAction.rewrite, + ], + AiWriterCommand.explain => [ + SuggestionAction.insertBelow, + SuggestionAction.tryAgain, + SuggestionAction.close, + ], + _ => [ + SuggestionAction.keep, + SuggestionAction.discard, + SuggestionAction.rewrite, + ], + }; + } + } +} + +class SuggestionActionButton extends StatelessWidget { + const SuggestionActionButton({ + super.key, + required this.action, + required this.onTap, + }); + + final SuggestionAction action; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 28, + child: FlowyButton( + text: FlowyText( + action.i18n, + figmaLineHeight: 20, + ), + leftIcon: action.buildIcon(context), + iconPadding: 4.0, + margin: const EdgeInsets.symmetric( + horizontal: 6.0, + vertical: 4.0, + ), + onTap: onTap, + useIntrinsicWidth: true, + ), + ); + } +} 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 index a20618f961..cceac56c0d 100644 --- 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 @@ -1,21 +1,20 @@ 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:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.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: 'editor.align', + id: kAlignToolbarItemId, group: 4, isActive: onlyShowInTextType, - builder: (context, editorState, highlightColor, _) { + builder: (context, editorState, highlightColor, _, tooltipBuilder) { final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); @@ -38,35 +37,37 @@ final alignToolbarItem = ToolbarItem( data = FlowySvgs.toolbar_align_right_s; } - final child = FlowySvg( + Widget child = FlowySvg( data, size: const Size.square(16), color: isHighlight ? highlightColor : Colors.white, ); - return MouseRegion( - cursor: SystemMouseCursors.click, - child: FlowyTooltip( - message: LocaleKeys.document_plugins_optionAction_align.tr(), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: _AlignmentButtons( - child: child, - onAlignChanged: (align) async { - await editorState.updateNode( - selection, - (node) => node.copyWith( - attributes: { - ...node.attributes, - blockComponentAlign: align, - }, - ), - ); + 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; }, ); @@ -84,17 +85,17 @@ class _AlignmentButtons extends StatefulWidget { } class _AlignmentButtonsState extends State<_AlignmentButtons> { + final controller = PopoverController(); + @override Widget build(BuildContext context) { return AppFlowyPopover( windowPadding: const EdgeInsets.all(0), - margin: const EdgeInsets.all(4), + margin: const EdgeInsets.symmetric(vertical: 2.0), direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 10), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onTertiary, - borderRadius: const BorderRadius.all(Radius.circular(4)), - ), + decorationColor: Theme.of(context).colorScheme.onTertiary, + borderRadius: BorderRadius.circular(6.0), popupBuilder: (_) { keepEditorFocusNotifier.increase(); return _AlignButtons(onAlignChanged: widget.onAlignChanged); @@ -102,7 +103,12 @@ class _AlignmentButtonsState extends State<_AlignmentButtons> { onClose: () { keepEditorFocusNotifier.decrease(); }, - child: widget.child, + child: FlowyButton( + useIntrinsicWidth: true, + text: widget.child, + hoverColor: Colors.grey.withValues(alpha: 0.3), + onTap: () => controller.show(), + ), ); } } @@ -117,7 +123,7 @@ class _AlignButtons extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - height: 32, + height: 28, child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -159,17 +165,16 @@ class _AlignButton extends StatelessWidget { @override Widget build(BuildContext context) { - return MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: onTap, - child: FlowyTooltip( - message: tooltips, - child: FlowySvg( - icon, - size: const Size.square(16), - color: Colors.white, - ), + return FlowyButton( + useIntrinsicWidth: true, + hoverColor: Colors.grey.withValues(alpha: 0.3), + onTap: onTap, + text: FlowyTooltip( + message: tooltips, + child: FlowySvg( + icon, + size: const Size.square(16), + color: Colors.white, ), ), ); @@ -182,7 +187,7 @@ class _Divider extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.all(8), + 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 index e11c42ae99..bc3f5cffa1 100644 --- 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 @@ -21,12 +21,12 @@ final List customTextAlignCommands = [ final CommandShortcutEvent customTextLeftAlignCommand = CommandShortcutEvent( key: 'Align text to the left', command: 'ctrl+shift+l', - getDescription: LocaleKeys.settings_shortcuts_commands_textAlignLeft.tr, + getDescription: LocaleKeys.settings_shortcutsPage_commands_textAlignLeft.tr, handler: (editorState) => _textAlignHandler(editorState, leftAlignmentKey), ); -/// Windows / Linux : ctrl + shift + e -/// macOS : ctrl + shift + e +/// Windows / Linux : ctrl + shift + c +/// macOS : ctrl + shift + c /// Allows the user to align text to the center /// /// - support @@ -35,8 +35,8 @@ final CommandShortcutEvent customTextLeftAlignCommand = CommandShortcutEvent( /// final CommandShortcutEvent customTextCenterAlignCommand = CommandShortcutEvent( key: 'Align text to the center', - command: 'ctrl+shift+e', - getDescription: LocaleKeys.settings_shortcuts_commands_textAlignCenter.tr, + command: 'ctrl+shift+c', + getDescription: LocaleKeys.settings_shortcutsPage_commands_textAlignCenter.tr, handler: (editorState) => _textAlignHandler(editorState, centerAlignmentKey), ); @@ -51,7 +51,7 @@ final CommandShortcutEvent customTextCenterAlignCommand = CommandShortcutEvent( final CommandShortcutEvent customTextRightAlignCommand = CommandShortcutEvent( key: 'Align text to the right', command: 'ctrl+shift+r', - getDescription: LocaleKeys.settings_shortcuts_commands_textAlignRight.tr, + getDescription: LocaleKeys.settings_shortcutsPage_commands_textAlignRight.tr, handler: (editorState) => _textAlignHandler(editorState, rightAlignmentKey), ); 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 0000000000..ca26c5a263 --- /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/built_in_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart index d7e8194867..090ecdce78 100644 --- 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 @@ -1,22 +1,13 @@ -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/plugins/document/presentation/editor_plugins/plugins.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/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_popover/appflowy_popover.dart'; import 'package:appflowy_result/appflowy_result.dart'; -import 'package:easy_localization/easy_localization.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_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class BuiltInPageWidget extends StatefulWidget { const BuiltInPageWidget({ @@ -65,18 +56,15 @@ class _BuiltInPageWidgetState extends State { if (snapshot.hasData && page != null) { return _build(context, page); } + if (snapshot.connectionState == ConnectionState.done) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - // just delete the page if it is not found - _deletePage(); - }); - return const Center( - child: FlowyText('Cannot load the page'), - ); + // Delete the page if not found + WidgetsBinding.instance.addPostFrameCallback((_) => _deletePage()); + + return const Center(child: FlowyText('Cannot load the page')); } - return const Center( - child: CircularProgressIndicator(), - ); + + return const Center(child: CircularProgressIndicator()); }, future: future, ); @@ -86,20 +74,14 @@ class _BuiltInPageWidgetState extends State { return MouseRegion( onEnter: (_) => widget.editorState.service.scrollService?.disable(), onExit: (_) => widget.editorState.service.scrollService?.enable(), - child: SizedBox( - height: viewPB.pluginType == PluginType.calendar ? 700 : 400, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildMenu(context, viewPB), - Expanded(child: _buildPage(context, viewPB)), - ], - ), - ), + child: _buildPage(context, viewPB), ); } - Widget _buildPage(BuildContext context, ViewPB viewPB) { + Widget _buildPage(BuildContext context, ViewPB view) { + final verticalPadding = + context.read()?.verticalPadding ?? + 0.0; return Focus( focusNode: focusNode, onFocusChange: (value) { @@ -107,66 +89,10 @@ class _BuiltInPageWidgetState extends State { widget.editorState.service.selectionService.clearSelection(); } }, - child: widget.builder(viewPB), - ); - } - - Widget _buildMenu(BuildContext context, ViewPB viewPB) { - return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // information - FlowyIconButton( - tooltipText: LocaleKeys.tooltip_referencePage.tr( - namedArgs: {'name': viewPB.layout.name}, - ), - width: 24, - height: 24, - iconPadding: const EdgeInsets.all(3), - icon: const FlowySvg( - FlowySvgs.information_s, - ), - iconColorOnHover: Theme.of(context).colorScheme.onSecondary, - ), - // 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), - iconColorOnHover: Theme.of(context).colorScheme.onSecondary, - icon: const FlowySvg( - FlowySvgs.settings_s, - ), - onPressed: () => controller.show(), - ), - onSelected: (action, controller) async { - switch (action.inner) { - case _ActionType.viewDatabase: - getIt().add( - TabsEvent.openPlugin( - plugin: viewPB.plugin(), - view: viewPB, - ), - ); - break; - case _ActionType.delete: - final transaction = widget.editorState.transaction; - transaction.deleteNode(widget.node); - await widget.editorState.apply(transaction); - break; - } - controller.close(); - }, - ), - ], + child: Padding( + padding: EdgeInsets.symmetric(vertical: verticalPadding), + child: widget.builder(view), + ), ); } @@ -176,26 +102,3 @@ class _BuiltInPageWidgetState extends State { 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 0000000000..20b4b7901e --- /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 index df78f6261e..93b45cf46a 100644 --- 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 @@ -1,12 +1,11 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; -import 'package:appflowy/plugins/base/icon/icon_picker.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_popover/appflowy_popover.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/tab.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({ @@ -19,75 +18,205 @@ class EmojiPickerButton extends StatelessWidget { this.offset, this.direction, this.title, + this.showBorder = true, + this.enable = true, + this.margin, + this.buttonSize, + this.documentId, + this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); - final String emoji; + final EmojiIconData emoji; final double emojiSize; final Size emojiPickerSize; - final void Function(String emoji, PopoverController? controller) onSubmitted; + final void Function( + SelectedEmojiIconResult result, + 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; + final String? documentId; + final List tabs; @override Widget build(BuildContext context) { - if (PlatformExtension.isDesktopOrWeb) { - return AppFlowyPopover( - controller: popoverController, - constraints: BoxConstraints.expand( - width: emojiPickerSize.width, - height: emojiPickerSize.height, - ), + if (UniversalPlatform.isDesktopOrWeb) { + return _DesktopEmojiPickerButton( + emoji: emoji, + onSubmitted: onSubmitted, + emojiPickerSize: emojiPickerSize, + emojiSize: emojiSize, + defaultIcon: defaultIcon, offset: offset, - direction: direction ?? PopoverDirection.rightWithTopAligned, - popupBuilder: (_) => Container( - width: emojiPickerSize.width, - height: emojiPickerSize.height, - padding: const EdgeInsets.all(4.0), - child: EmojiSelectionMenu( - onSubmitted: (emoji) => onSubmitted(emoji, popoverController), - onExit: () {}, - ), - ), - child: emoji.isEmpty && defaultIcon != null - ? FlowyButton( - useIntrinsicWidth: true, - text: defaultIcon!, - onTap: popoverController.show, - ) - : FlowyTextButton( - emoji, - overflow: TextOverflow.visible, - fontSize: emojiSize, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 35.0), - fillColor: Colors.transparent, - mainAxisAlignment: MainAxisAlignment.center, - onPressed: popoverController.show, - ), + direction: direction, + title: title, + showBorder: showBorder, + enable: enable, + buttonSize: buttonSize, + tabs: tabs, + documentId: documentId, ); } - return FlowyTextButton( - emoji, - overflow: TextOverflow.visible, - fontSize: emojiSize, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 35.0), - fillColor: Colors.transparent, - mainAxisAlignment: MainAxisAlignment.center, - onPressed: () async { - final result = await context.push( - Uri( - path: MobileEmojiPickerScreen.routeName, - queryParameters: {MobileEmojiPickerScreen.pageTitle: title}, - ).toString(), - ); - if (result != null) { - onSubmitted(result.emoji, null); - } - }, + + return _MobileEmojiPickerButton( + emoji: emoji, + onSubmitted: onSubmitted, + emojiSize: emojiSize, + enable: enable, + title: title, + margin: margin, + tabs: tabs, + documentId: documentId, + ); + } +} + +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, + this.documentId, + this.tabs = const [PickerTabType.emoji, PickerTabType.icon], + }); + + final EmojiIconData emoji; + final double emojiSize; + final Size emojiPickerSize; + final void Function( + SelectedEmojiIconResult result, + 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; + final String? documentId; + final List tabs; + + @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( + initialType: emoji.type.toPickerTabType(), + tabs: tabs, + documentId: documentId, + 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, + this.documentId, + this.tabs = const [PickerTabType.emoji, PickerTabType.icon], + }); + + final EmojiIconData emoji; + final double emojiSize; + final void Function( + SelectedEmojiIconResult result, + PopoverController? controller, + ) onSubmitted; + final String? title; + final bool enable; + final EdgeInsets? margin; + final String? documentId; + final List tabs; + + @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, + MobileEmojiPickerScreen.iconSelectedType: emoji.type.name, + MobileEmojiPickerScreen.uploadDocumentId: documentId, + MobileEmojiPickerScreen.selectTabs: + tabs.map((e) => e.name).toList().join('-'), + }, + ).toString(), + ); + if (result != null) { + onSubmitted(result.toSelectedResult(), 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 0000000000..e386be709a --- /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 index 6e6e111df1..8548b9354c 100644 --- 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 @@ -2,8 +2,13 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; const _greater = '>'; +const _dash = '-'; const _equals = '='; -const _arrow = '⇒'; +const _equalGreater = '⇒'; +const _dashGreater = '→'; + +const _hyphen = '-'; +const _emDash = '—'; // This is an em dash — not a single dash - !! /// format '=' + '>' into an ⇒ /// @@ -18,11 +23,47 @@ final CharacterShortcutEvent customFormatGreaterEqual = CharacterShortcutEvent( handler: (editorState) async => _handleDoubleCharacterReplacement( editorState: editorState, character: _greater, - replacement: _arrow, + replacement: _equalGreater, prefixCharacter: _equals, ), ); +/// format '-' + '>' into ⇒ +/// +/// - support +/// - desktop +/// - mobile +/// - web +/// +final CharacterShortcutEvent customFormatDashGreater = CharacterShortcutEvent( + key: 'format - + > into ->', + character: _greater, + handler: (editorState) async => _handleDoubleCharacterReplacement( + editorState: editorState, + character: _greater, + replacement: _dashGreater, + prefixCharacter: _dash, + ), +); + +/// format two hyphens into an em dash +/// +/// - support +/// - desktop +/// - mobile +/// - web +/// +final CharacterShortcutEvent customFormatDoubleHyphenEmDash = + CharacterShortcutEvent( + key: 'format double hyphen into an em dash', + character: _hyphen, + handler: (editorState) async => _handleDoubleCharacterReplacement( + editorState: editorState, + character: _hyphen, + replacement: _emDash, + ), +); + /// If [prefixCharacter] is null or empty, [character] is used Future _handleDoubleCharacterReplacement({ required EditorState editorState, @@ -61,11 +102,29 @@ Future _handleDoubleCharacterReplacement({ return false; } + // insert the greater character first and convert it to the replacement character to support undo + final insert = editorState.transaction + ..insertText( + node, + selection.end.offset, + character, + ); + + await editorState.apply( + insert, + skipHistoryDebounce: true, + ); + + final afterSelection = editorState.selection; + if (afterSelection == null) { + return false; + } + final replace = editorState.transaction ..replaceText( node, - selection.end.offset - 1, - 1, + afterSelection.end.offset - 2, + 2, replacement, ); 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 index 9bcd0e18b8..11aed036d2 100644 --- 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 @@ -1,7 +1,7 @@ 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/mention/mention_block.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'; @@ -70,13 +70,12 @@ extension InsertDatabase on EditorState { node, selection.end.offset, 0, - r'$', - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: view.id, - }, - }, + MentionBlockKeys.mentionChar, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: view.id, + blockId: null, + ), ); } @@ -98,7 +97,7 @@ extension InsertDatabase on EditorState { final prefix = _referencedDatabasePrefix(view.layout); final ref = await ViewBackendService.createDatabaseLinkedView( parentViewId: view.id, - name: "$prefix ${view.name}", + name: "$prefix ${view.nameOrDefault}", layoutType: view.layout, databaseId: databaseId, ).then((value) => value.toNullable()); 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 index 0dcb8dd3e6..3f1440e100 100644 --- 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 @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - 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'; @@ -8,13 +6,18 @@ 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, -) async { + SelectionMenuService menuService, { + ViewLayoutPB? pageType, + bool? insertPage, +}) async { + keepEditorFocusNotifier.increase(); + menuService.dismiss(); _actionsMenuService?.dismiss(); @@ -27,10 +30,10 @@ Future showLinkToPageMenu( context: rootContext, handlers: [ InlinePageReferenceService( - currentViewId: "", + currentViewId: '', viewLayout: pageType, customTitle: titleFromPageType(pageType), - insertPage: pageType != ViewLayoutPB.Document, + insertPage: insertPage ?? pageType != ViewLayoutPB.Document, limitResults: 15, ), ], @@ -58,11 +61,11 @@ Future showLinkToPageMenu( startCharAmount: 0, ); - _actionsMenuService?.show(); + await _actionsMenuService?.show(); } } -String titleFromPageType(ViewLayoutPB layout) => switch (layout) { +String titleFromPageType(ViewLayoutPB? layout) => switch (layout) { ViewLayoutPB.Grid => LocaleKeys.inlineActions_gridReference.tr(), ViewLayoutPB.Document => LocaleKeys.inlineActions_docReference.tr(), ViewLayoutPB.Board => LocaleKeys.inlineActions_boardReference.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 0000000000..259777db94 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -0,0 +1,566 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:synchronized/synchronized.dart'; + +const _enableDebug = false; + +class MarkdownTextRobot { + MarkdownTextRobot({ + required this.editorState, + }); + + final EditorState editorState; + + final Lock _lock = Lock(); + + /// The text position where new nodes will be inserted + Position? _insertPosition; + + /// The markdown text to be inserted + String _markdownText = ''; + + /// The nodes inserted in the previous refresh. + Iterable _insertedNodes = []; + + /// Only for debug via [_enableDebug]. + final List _debugMarkdownTexts = []; + + /// Selection before the refresh. + Selection? _previousSelection; + + bool get hasAnyResult => _markdownText.isNotEmpty; + + String get markdownText => _markdownText; + + Selection? getInsertedSelection() { + final position = _insertPosition; + if (position == null) { + Log.error("Expected non-null insert markdown text position"); + return null; + } + + if (_insertedNodes.isEmpty) { + return Selection.collapsed(position); + } + return Selection( + start: position, + end: Position( + path: position.path.nextNPath(_insertedNodes.length - 1), + ), + ); + } + + List getInsertedNodes() { + final selection = getInsertedSelection(); + return selection == null ? [] : editorState.getNodesInSelection(selection); + } + + void start({ + Selection? previousSelection, + Position? position, + }) { + _insertPosition = position ?? editorState.selection?.start; + _previousSelection = previousSelection ?? editorState.selection; + + if (_enableDebug) { + Log.info( + 'MarkdownTextRobot start with insert text position: $_insertPosition', + ); + } + } + + /// The text will be inserted into the document but only in memory + Future appendMarkdownText( + String text, { + bool updateSelection = true, + Map? attributes, + }) async { + _markdownText += text; + + await _lock.synchronized(() async { + await _refresh( + inMemoryUpdate: true, + updateSelection: updateSelection, + attributes: attributes, + ); + }); + + if (_enableDebug) { + _debugMarkdownTexts.add(text); + Log.info( + 'MarkdownTextRobot receive markdown: ${jsonEncode(_debugMarkdownTexts)}', + ); + } + } + + Future stop({ + Map? attributes, + }) async { + await _lock.synchronized(() async { + await _refresh( + inMemoryUpdate: true, + attributes: attributes, + ); + }); + } + + /// Persist the text into the document + Future persist({ + String? markdownText, + }) async { + if (markdownText != null) { + _markdownText = markdownText; + } + + await _lock.synchronized(() async { + await _refresh(inMemoryUpdate: false, updateSelection: true); + }); + + if (_enableDebug) { + Log.info('MarkdownTextRobot stop'); + _debugMarkdownTexts.clear(); + } + } + + /// Replace the selected content with the AI's response + Future replace({ + required Selection selection, + required String markdownText, + }) async { + if (selection.isSingle) { + await _replaceInSameLine( + selection: selection, + markdownText: markdownText, + ); + } else { + await _replaceInMultiLines( + selection: selection, + markdownText: markdownText, + ); + } + } + + /// Delete the temporary inserted AI nodes + Future deleteAINodes() async { + final nodes = getInsertedNodes(); + final transaction = editorState.transaction..deleteNodes(nodes); + await editorState.apply( + transaction, + options: const ApplyOptions(recordUndo: false), + ); + } + + /// Discard the inserted content + Future discard({ + Selection? afterSelection, + }) async { + final start = _insertPosition; + if (start == null) { + return; + } + if (_insertedNodes.isEmpty) { + return; + } + + afterSelection ??= Selection.collapsed(start); + + // fallback to the calculated position if the selection is null. + final end = Position( + path: start.path.nextNPath(_insertedNodes.length - 1), + ); + final deletedNodes = editorState.getNodesInSelection( + Selection(start: start, end: end), + ); + final transaction = editorState.transaction + ..deleteNodes(deletedNodes) + ..afterSelection = afterSelection; + + await editorState.apply( + transaction, + options: const ApplyOptions(recordUndo: false, inMemoryUpdate: true), + ); + + if (_enableDebug) { + Log.info('MarkdownTextRobot discard'); + } + } + + void clear() { + _markdownText = ''; + _insertedNodes = []; + } + + void reset() { + _insertPosition = null; + } + + Future _refresh({ + required bool inMemoryUpdate, + bool updateSelection = false, + Map? attributes, + }) async { + final position = _insertPosition; + if (position == null) { + Log.error("Expected non-null insert markdown text position"); + return; + } + + // Convert markdown and deep copy the nodes, prevent ing the linked + // entities from being changed + final documentNodes = customMarkdownToDocument( + _markdownText, + tableWidth: 250.0, + ).root.children; + + // check if the first selected node before the refresh is a numbered list node + final previousSelection = _previousSelection; + final previousSelectedNode = previousSelection == null + ? null + : editorState.getNodeAtPath(previousSelection.start.path); + final firstNodeIsNumberedList = previousSelectedNode != null && + previousSelectedNode.type == NumberedListBlockKeys.type; + + final newNodes = attributes == null + ? documentNodes + : documentNodes.mapIndexed((index, node) { + final n = _styleDelta(node: node, attributes: attributes); + n.externalValues = AINodeExternalValues( + isAINode: true, + ); + if (index == 0 && n.type == NumberedListBlockKeys.type) { + if (firstNodeIsNumberedList) { + final builder = NumberedListIndexBuilder( + editorState: editorState, + node: previousSelectedNode, + ); + final firstIndex = builder.indexInSameLevel; + n.updateAttributes({ + NumberedListBlockKeys.number: firstIndex, + }); + } + + n.externalValues = AINodeExternalValues( + isAINode: true, + isFirstNumberedListNode: true, + ); + } + return n; + }).toList(); + + if (newNodes.isEmpty) { + return; + } + + final deleteTransaction = editorState.transaction + ..deleteNodes(getInsertedNodes()); + + await editorState.apply( + deleteTransaction, + options: ApplyOptions( + inMemoryUpdate: inMemoryUpdate, + recordUndo: false, + ), + ); + + final insertTransaction = editorState.transaction + ..insertNodes(position.path, newNodes); + + final lastDelta = newNodes.lastOrNull?.delta; + if (lastDelta != null) { + insertTransaction.afterSelection = Selection.collapsed( + Position( + path: position.path.nextNPath(newNodes.length - 1), + offset: lastDelta.length, + ), + ); + } + + await editorState.apply( + insertTransaction, + options: ApplyOptions( + inMemoryUpdate: inMemoryUpdate, + recordUndo: !inMemoryUpdate, + ), + withUpdateSelection: updateSelection, + ); + + _insertedNodes = newNodes; + } + + Node _styleDelta({ + required Node node, + required Map attributes, + }) { + if (node.delta != null) { + final delta = node.delta!; + final attributeDelta = Delta() + ..retain(delta.length, attributes: attributes); + final newDelta = delta.compose(attributeDelta); + final newAttributes = node.attributes; + newAttributes['delta'] = newDelta.toJson(); + node.updateAttributes(newAttributes); + } + + List? children; + if (node.children.isNotEmpty) { + children = node.children + .map((child) => _styleDelta(node: child, attributes: attributes)) + .toList(); + } + + return node.copyWith( + children: children, + ); + } + + /// If the selected content is in the same line, + /// keep the selected node and replace the delta. + Future _replaceInSameLine({ + required Selection selection, + required String markdownText, + }) async { + if (markdownText.isEmpty) { + assert(false, 'Expected non-empty markdown text'); + Log.error('Expected non-empty markdown text'); + return; + } + + selection = selection.normalized; + + // If the selection is not a single node, do nothing. + if (!selection.isSingle) { + assert(false, 'Expected single node selection'); + Log.error('Expected single node selection'); + return; + } + + final startIndex = selection.startIndex; + final endIndex = selection.endIndex; + final length = endIndex - startIndex; + + // Get the selected node. + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + assert(false, 'Expected non-null node and delta'); + Log.error('Expected non-null node and delta'); + return; + } + + // Convert the markdown text to delta. + // Question: Why we need to convert the markdown to document first? + // Answer: Because the markdown text may contain the list item, + // if we convert the markdown to delta directly, the list item will be + // treated as a normal text node, and the delta will be incorrect. + // For example, the markdown text is: + // ``` + // 1. item1 + // ``` + // if we convert the markdown to delta directly, the delta will be: + // ``` + // [ + // { + // "insert": "1. item1" + // } + // ] + // ``` + // if we convert the markdown to document first, the document will be: + // ``` + // [ + // { + // "type": "numbered_list", + // "children": [ + // { + // "insert": "item1" + // } + // ] + // } + // ] + final document = customMarkdownToDocument(markdownText); + final nodes = document.root.children; + final decoder = DeltaMarkdownDecoder(); + final markdownDelta = + nodes.firstOrNull?.delta ?? decoder.convert(markdownText); + + if (markdownDelta.isEmpty) { + assert(false, 'Expected non-empty markdown delta'); + Log.error('Expected non-empty markdown delta'); + return; + } + + // Replace the delta of the selected node. + final transaction = editorState.transaction; + + // it means the user selected the entire sentence, we just replace the node + if (startIndex == 0 && length == node.delta?.length) { + if (nodes.isNotEmpty && node.children.isNotEmpty) { + // merge the children of the selected node and the first node of the ai response + nodes[0] = nodes[0].copyWith( + children: [ + ...node.children.map((e) => e.deepCopy()), + ...nodes[0].children, + ], + ); + } + transaction + ..insertNodes(node.path.next, nodes) + ..deleteNode(node); + } else { + // it means the user selected a part of the sentence, we need to delete the + // selected part and insert the new delta. + transaction + ..deleteText(node, startIndex, length) + ..insertTextDelta(node, startIndex, markdownDelta); + + // Add the remaining nodes to the document. + final remainingNodes = nodes.skip(1); + if (remainingNodes.isNotEmpty) { + transaction.insertNodes( + node.path.next, + remainingNodes, + ); + } + } + + await editorState.apply(transaction); + } + + /// If the selected content is in multiple lines + Future _replaceInMultiLines({ + required Selection selection, + required String markdownText, + }) async { + selection = selection.normalized; + + // If the selection is a single node, do nothing. + if (selection.isSingle) { + assert(false, 'Expected multi-line selection'); + Log.error('Expected multi-line selection'); + return; + } + + final markdownNodes = customMarkdownToDocument( + markdownText, + tableWidth: 250.0, + ).root.children; + + // Get the selected nodes. + final flattenNodes = editorState.getNodesInSelection(selection); + final nodes = []; + for (final node in flattenNodes) { + if (nodes.any((element) => element.isParentOf(node))) { + continue; + } + nodes.add(node); + } + + // Note: Don't change its order, otherwise the delta will be incorrect. + // step 1. merge the first selected node and the first node from the ai response + // step 2. merge the last selected node and the last node from the ai response + // step 3. insert the middle nodes from the ai response + // step 4. delete the middle nodes + final transaction = editorState.transaction; + + // step 1 + final firstNode = nodes.firstOrNull; + final delta = firstNode?.delta; + final firstMarkdownNode = markdownNodes.firstOrNull; + final firstMarkdownDelta = firstMarkdownNode?.delta; + if (firstNode != null && + delta != null && + firstMarkdownNode != null && + firstMarkdownDelta != null) { + final startIndex = selection.startIndex; + final length = delta.length - startIndex; + + transaction + ..deleteText(firstNode, startIndex, length) + ..insertTextDelta(firstNode, startIndex, firstMarkdownDelta); + + // if the first markdown node has children, we need to insert the children + // and delete the children of the first node that are in the selection. + if (firstMarkdownNode.children.isNotEmpty) { + transaction.insertNodes( + firstNode.path.child(0), + firstMarkdownNode.children.map((e) => e.deepCopy()), + ); + } + + final nodesToDelete = + firstNode.children.where((e) => e.path.inSelection(selection)); + transaction.deleteNodes(nodesToDelete); + } + + // step 2 + bool handledLastNode = false; + final lastNode = nodes.lastOrNull; + final lastDelta = lastNode?.delta; + final lastMarkdownNode = markdownNodes.lastOrNull; + final lastMarkdownDelta = lastMarkdownNode?.delta; + if (lastNode != null && + lastDelta != null && + lastMarkdownNode != null && + lastMarkdownDelta != null && + firstNode?.id != lastNode.id) { + handledLastNode = true; + + final endIndex = selection.endIndex; + + transaction.deleteText(lastNode, 0, endIndex); + + // if the last node is same as the first node, it means we have replaced the + // selected text in the first node. + if (lastMarkdownNode.id != firstMarkdownNode?.id) { + transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta); + + if (lastMarkdownNode.children.isNotEmpty) { + transaction + ..insertNodes( + lastNode.path.child(0), + lastMarkdownNode.children.map((e) => e.deepCopy()), + ) + ..deleteNodes( + lastNode.children.where((e) => e.path.inSelection(selection)), + ); + } + } + } + + // step 3 + final insertedPath = selection.start.path.nextNPath(1); + final insertLength = handledLastNode ? 2 : 1; + if (markdownNodes.length > insertLength) { + transaction.insertNodes( + insertedPath, + markdownNodes + .skip(1) + .take(markdownNodes.length - insertLength) + .toList(), + ); + } + + // step 4 + final length = nodes.length - 2; + if (length > 0) { + final middleNodes = nodes.skip(1).take(length).toList(); + transaction.deleteNodes(middleNodes); + } + + await editorState.apply(transaction); + } +} + +class AINodeExternalValues extends NodeExternalValues { + const AINodeExternalValues({ + this.isAINode = false, + this.isFirstNumberedListNode = false, + }); + + final bool isAINode; + final bool isFirstNumberedListNode; +} 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 index 42ee1a63d1..007e4ea298 100644 --- 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 @@ -1,10 +1,13 @@ -import 'package:flutter/material.dart'; - +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; +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 = '+'; @@ -45,6 +48,7 @@ CharacterShortcutEvent pageReferenceShortcutPlusSign( ); InlineActionsMenuService? selectionMenuService; + Future inlinePageReferenceCommandHandler( String character, BuildContext context, @@ -54,7 +58,7 @@ Future inlinePageReferenceCommandHandler( String? previousChar, }) async { final selection = editorState.selection; - if (PlatformExtension.isMobile || selection == null) { + if (selection == null) { return false; } @@ -87,6 +91,8 @@ Future inlinePageReferenceCommandHandler( final service = InlineActionsService( context: context, handlers: [ + if (FeatureFlag.inlineSubPageMention.isOn) + InlineChildPageService(currentViewId: currentViewId), InlinePageReferenceService( currentViewId: currentViewId, limitResults: 10, @@ -106,17 +112,63 @@ Future inlinePageReferenceCommandHandler( } if (context.mounted) { - selectionMenuService = InlineActionsMenu( - context: service.context!, - editorState: editorState, - service: service, - initialResults: initialResults, - style: style, - startCharAmount: previousChar != null ? 2 : 1, - ); + keepEditorFocusNotifier.increase(); + selectionMenuService?.dismiss(); + selectionMenuService = UniversalPlatform.isMobile + ? MobileInlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + startCharAmount: previousChar != null ? 2 : 1, + style: style, + ) + : 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; + }, + ); + // disable the keyboard service + editorState.service.keyboardService?.disable(); - selectionMenuService?.show(); + await selectionMenuService?.show(); + + // enable the keyboard service + editorState.service.keyboardService?.enable(); } 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 index c56fbd09e9..3c997bbdc4 100644 --- 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 @@ -1,7 +1,9 @@ -import 'package:flutter/material.dart'; +import 'dart:math'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; class SelectableItemListMenu extends StatelessWidget { const SelectableItemListMenu({ @@ -10,11 +12,13 @@ class SelectableItemListMenu extends StatelessWidget { 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 @@ -24,9 +28,12 @@ class SelectableItemListMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder( + 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], @@ -53,8 +60,11 @@ class SelectableItem extends StatelessWidget { return SizedBox( height: 32, child: FlowyButton( - text: FlowyText.medium(item), - rightIcon: isSelected ? const FlowySvg(FlowySvgs.check_s) : null, + 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 index 8f3e4b7477..f1b082e0a7 100644 --- 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 @@ -1,6 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; - import 'package:flutter/material.dart'; class SelectableSvgWidget extends StatelessWidget { @@ -9,21 +8,31 @@ class SelectableSvgWidget extends StatelessWidget { 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) { - return FlowySvg( + final child = FlowySvg( data, - size: const Size.square(18.0), + 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; + } } } 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 index 532fc5e434..254c3d53bf 100644 --- 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 @@ -1,5 +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 index f50cf7f0e8..a20ea9aec5 100644 --- 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 @@ -1,59 +1,143 @@ +import 'dart:async'; + import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:synchronized/synchronized.dart'; enum TextRobotInputType { character, word, + sentence, } class TextRobot { - const 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 == '\n') { - return editorState.insertNewLine(); + if (text == separator) { + await insertNewParagraph(delay); + return; } - final lines = text.split('\n'); + final lines = _splitText(text, separator); for (final line in lines) { if (line.isEmpty) { - await editorState.insertNewLine(); + await insertNewParagraph(delay); continue; } switch (inputType) { case TextRobotInputType.character: - final iterator = line.runes.iterator; - while (iterator.moveNext()) { - await editorState.insertTextAtCurrentSelection( - iterator.currentAsString, - ); - await Future.delayed(delay); - } + await insertCharacter(line, delay); break; case TextRobotInputType.word: - final words = line.split(' '); - if (words.length == 1 || - (words.length == 2 && - (words.first.isEmpty || words.last.isEmpty))) { - await editorState.insertTextAtCurrentSelection( - line, - ); - } else { - for (final word in words.map((e) => '$e ')) { - await editorState.insertTextAtCurrentSelection( - word, - ); - } - } - await Future.delayed(delay); + 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 index ebbfb27db6..77245a9f95 100644 --- 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 @@ -1,5 +1,14 @@ +import 'dart:ui'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +bool _isTableType(String type) { + return [TableBlockKeys.type, SimpleTableBlockKeys.type].contains(type); +} + bool notShowInTable(EditorState editorState) { final selection = editorState.selection; if (selection == null) { @@ -7,12 +16,12 @@ bool notShowInTable(EditorState editorState) { } final nodes = editorState.getNodesInSelection(selection); return nodes.every((element) { - if (element.type == TableBlockKeys.type) { + if (_isTableType(element.type)) { return false; } var parent = element.parent; while (parent != null) { - if (parent.type == TableBlockKeys.type) { + if (_isTableType(parent.type)) { return false; } parent = parent.parent; @@ -27,3 +36,31 @@ bool onlyShowInSingleTextTypeSelectionAndExcludeTable( return onlyShowInSingleSelectionAndTextType(editorState) && notShowInTable(editorState); } + +bool enableSuggestions(EditorState editorState) { + final selection = editorState.selection; + if (selection == null || !selection.isSingle) { + return false; + } + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return false; + } + if (isNarrowWindow(editorState)) return false; + + return (node.delta != null && suggestionsItemTypes.contains(node.type)) && + notShowInTable(editorState); +} + +bool isNarrowWindow(EditorState editorState) { + final editorSize = editorState.renderBox?.size ?? Size.zero; + if (editorSize.width < 650) return true; + return false; +} + +final Set suggestionsItemTypes = { + ...toolbarItemWhiteList, + ToggleListBlockKeys.type, + TodoListBlockKeys.type, + CalloutBlockKeys.type, +}; 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 index 120343a277..735b6b15df 100644 --- 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 @@ -1,6 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; class MenuBlockButton extends StatelessWidget { 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 0000000000..80ac61a406 --- /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 index d0551b03af..23b73e75a9 100644 --- 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 @@ -1,5 +1,4 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -30,20 +29,23 @@ class BulletedListIcon extends StatelessWidget { return level; } - FlowySvg get icon { - final index = level % bulletedListIcons.length; - return FlowySvg(bulletedListIcons[index]); - } - @override Widget build(BuildContext context) { - final iconPadding = context.read().state.iconPadding; + 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: const BoxConstraints( - minWidth: 22, - minHeight: 22, - ), - margin: EdgeInsets.only(top: iconPadding, right: 8.0), + width: size, + height: 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 index 7b561e1ef1..a7fcccd186 100644 --- 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 @@ -1,5 +1,8 @@ import 'package:appflowy/generated/locale_keys.g.dart' show LocaleKeys; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.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_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart' show StringTranslateExtension; @@ -8,6 +11,7 @@ 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'; @@ -31,17 +35,22 @@ class CalloutBlockKeys { /// /// The value is a String. static const String icon = 'icon'; + + /// the type of [FlowyIconType] + static const String iconType = 'icon_type'; } // The one is inserted through selection menu Node calloutNode({ Delta? delta, - String emoji = '📌', + EmojiIconData? emoji, Color? defaultColor, }) { + final defaultEmoji = emoji ?? EmojiIconData.emoji('📌'); final attributes = { CalloutBlockKeys.delta: (delta ?? Delta()).toJson(), - CalloutBlockKeys.icon: emoji, + CalloutBlockKeys.icon: defaultEmoji.emoji, + CalloutBlockKeys.iconType: defaultEmoji.type, CalloutBlockKeys.backgroundColor: defaultColor?.toHex(), }; return Node( @@ -56,7 +65,7 @@ SelectionMenuItem calloutItem = SelectionMenuItem.node( iconData: Icons.note, keywords: [CalloutBlockKeys.type], nodeBuilder: (editorState, context) => - calloutNode(defaultColor: AFThemeExtension.of(context).calloutBGColor), + calloutNode(defaultColor: Colors.transparent), replace: (_, node) => node.delta?.isEmpty ?? false, updateSelection: (_, path, __, ___) { return Selection.single(path: path, startOffset: 0); @@ -68,9 +77,11 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder { CalloutBlockComponentBuilder({ super.configuration, required this.defaultColor, + required this.inlinePadding, }); final Color defaultColor; + final EdgeInsets Function(Node node) inlinePadding; @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { @@ -79,21 +90,22 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder { key: node.key, node: node, defaultColor: defaultColor, + inlinePadding: inlinePadding, configuration: configuration, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } - // validate the data of the node, if the result is false, the node will be rendered as a placeholder @override - bool validate(Node node) => - node.delta != null && - node.children.isEmpty && - node.attributes[CalloutBlockKeys.icon] is String; + BlockComponentValidate get validate => (node) => node.delta != null; } // the main widget for rendering the callout block @@ -103,11 +115,14 @@ class CalloutBlockComponentWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), required this.defaultColor, + required this.inlinePadding, }); final Color defaultColor; + final EdgeInsets Function(Node node) inlinePadding; @override State createState() => @@ -122,7 +137,8 @@ class _CalloutBlockComponentWidgetState BlockComponentConfigurable, BlockComponentTextDirectionMixin, BlockComponentAlignMixin, - BlockComponentBackgroundColorMixin { + BlockComponentBackgroundColorMixin, + NestedBlockComponentStatefulWidgetMixin { // the key used to forward focus to the richtext child @override final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text'); @@ -152,59 +168,120 @@ class _CalloutBlockComponentWidgetState } // get the emoji of the note block from the node's attributes or default to '📌' - String get emoji { + EmojiIconData get emoji { final icon = node.attributes[CalloutBlockKeys.icon]; - if (icon == null || icon.isEmpty) { - return '📌'; - } - return icon; + final type = + node.attributes[CalloutBlockKeys.iconType] ?? FlowyIconType.emoji; + EmojiIconData result = EmojiIconData.emoji('📌'); + try { + result = EmojiIconData(FlowyIconType.values.byName(type), icon); + } catch (_) {} + return result; } - // get access to the editor state via provider @override - late final editorState = Provider.of(context, listen: false); + Widget build(BuildContext context) { + Widget child = node.children.isEmpty + ? buildComponent(context) + : buildComponentWithChildren(context); + + if (UniversalPlatform.isDesktop) { + child = Padding( + padding: EdgeInsets.symmetric(vertical: 2.0), + child: child, + ); + } + + return child; + } + + @override + Widget buildComponentWithChildren(BuildContext context) { + Widget child = Stack( + children: [ + Positioned.fill( + left: UniversalPlatform.isMobile ? 0 : cachedLeft, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(6.0)), + color: backgroundColor, + ), + ), + ), + NestedListWidget( + indentPadding: indentPadding.copyWith(bottom: 8), + child: buildComponent(context, withBackgroundColor: false), + children: editorState.renderer.buildList( + context, + widget.node.children, + ), + ), + ], + ); + + if (UniversalPlatform.isMobile) { + child = Padding( + padding: padding, + child: child, + ); + } + + return child; + } // build the callout block widget @override - Widget build(BuildContext context) { + Widget buildComponent( + BuildContext context, { + bool withBackgroundColor = true, + }) { final textDirection = calculateTextDirection( layoutDirection: Directionality.maybeOf(context), ); - + final (emojiSize, emojiButtonSize) = calculateEmojiSize(); + final documentId = context.read()?.documentId; Widget child = Container( decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8.0)), - color: backgroundColor, + borderRadius: const BorderRadius.all(Radius.circular(6.0)), + color: withBackgroundColor ? backgroundColor : null, ), + padding: widget.inlinePadding(widget.node), width: double.infinity, alignment: alignment, child: Row( - crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, textDirection: textDirection, children: [ + const HSpace(6.0), // the emoji picker button for the note - Padding( - padding: const EdgeInsets.only( - top: 8.0, - left: 4.0, - right: 4.0, - ), - child: EmojiPickerButton( - key: ValueKey( - emoji.toString(), - ), // force to refresh the popover state - title: '', - emoji: emoji, - onSubmitted: (emoji, controller) { - setEmoji(emoji); - controller?.close(); - }, - ), + EmojiPickerButton( + // force to refresh the popover state + key: ValueKey(widget.node.id + emoji.emoji), + enable: editorState.editable, + title: '', + margin: UniversalPlatform.isMobile + ? const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0) + : EdgeInsets.zero, + emoji: emoji, + emojiSize: emojiSize, + showBorder: false, + buttonSize: emojiButtonSize, + documentId: documentId, + tabs: const [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ], + onSubmitted: (r, controller) { + setEmojiIconData(r.data); + if (!r.keepOpen) controller?.close(); + }, ), + if (UniversalPlatform.isDesktopOrWeb) const HSpace(6.0), Flexible( child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), + padding: const EdgeInsets.symmetric(vertical: 4.0), child: buildCalloutBlockComponent(context, textDirection), ), ), @@ -213,17 +290,26 @@ class _CalloutBlockComponentWidgetState ), ); - child = Padding( - key: blockComponentKey, - padding: padding, - child: child, - ); + if (UniversalPlatform.isMobile && node.children.isEmpty) { + child = Padding( + key: blockComponentKey, + padding: padding, + child: child, + ); + } else { + child = Container( + key: blockComponentKey, + padding: EdgeInsets.zero, + child: child, + ); + } child = BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, blockColor: editorState.editorStyle.selectionColor, + selectionAboveBlock: true, supportTypes: const [ BlockSelectionType.block, ], @@ -234,6 +320,7 @@ class _CalloutBlockComponentWidgetState child = BlockComponentActionWrapper( node: widget.node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } @@ -246,36 +333,46 @@ class _CalloutBlockComponentWidgetState BuildContext context, TextDirection textDirection, ) { - return Padding( - padding: padding, - child: AppFlowyRichText( - key: forwardKey, - delegate: this, - node: widget.node, - editorState: editorState, - placeholderText: placeholderText, - textSpanDecorator: (textSpan) => textSpan.updateTextStyle( - textStyle, - ), - placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle( - placeholderTextStyle, - ), - textDirection: textDirection, - cursorColor: editorState.editorStyle.cursorColor, - selectionColor: editorState.editorStyle.selectionColor, + return AppFlowyRichText( + key: forwardKey, + delegate: this, + node: widget.node, + editorState: editorState, + placeholderText: placeholderText, + textAlign: alignment?.toTextAlign ?? textAlign, + textSpanDecorator: (textSpan) => textSpan.updateTextStyle( + textStyleWithTextSpan(textSpan: textSpan), ), + placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle( + placeholderTextStyleWithTextSpan(textSpan: textSpan), + ), + textDirection: textDirection, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, ); } // set the emoji of the note block - Future setEmoji(String emoji) async { + Future setEmojiIconData(EmojiIconData data) async { final transaction = editorState.transaction ..updateNode(node, { - CalloutBlockKeys.icon: emoji, + CalloutBlockKeys.icon: data.emoji, + CalloutBlockKeys.iconType: data.type.name, }) ..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 0000000000..842f3f59fd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart @@ -0,0 +1,54 @@ +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) { + // ignore the shift+enter event, fallback to the default behavior + return false; + } else if (node.children.isEmpty) { + // insert a new paragraph within the callout block + final path = node.path.child(0); + final transaction = editorState.transaction; + transaction.insertNode( + path, + paragraphNode(), + ); + transaction.afterSelection = Selection.collapsed( + Position( + path: path, + ), + ); + await editorState.apply(transaction); + } + + 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 index 9c6ff01bf9..645de3b2f8 100644 --- 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 @@ -1,16 +1,17 @@ -import 'package:flutter/material.dart'; +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/home/toast.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); @@ -28,16 +29,25 @@ class _CopyButton extends StatelessWidget { 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: node.delta?.toPlainText(), + plainText: delta.toPlainText(), + inAppJson: jsonEncode(document.toJson()), ), ); if (context.mounted) { - showSnackBarMessage( - context, - LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(), + showToastNotification( + message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(), ); } }, 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 index 323c15be1e..c4c2e3e0ba 100644 --- 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 @@ -1,15 +1,18 @@ -import 'package:flutter/material.dart'; - 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'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.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/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; +import 'package:universal_platform/universal_platform.dart'; CodeBlockLanguagePickerBuilder codeBlockLanguagePickerBuilder = ( editorState, @@ -19,7 +22,7 @@ CodeBlockLanguagePickerBuilder codeBlockLanguagePickerBuilder = ( onMenuClose, onMenuOpen, }) => - _CodeBlockLanguageSelector( + CodeBlockLanguageSelector( editorState: editorState, language: selectedLanguage, supportedLanguages: supportedLanguages, @@ -28,8 +31,9 @@ CodeBlockLanguagePickerBuilder codeBlockLanguagePickerBuilder = ( onMenuOpen: onMenuOpen, ); -class _CodeBlockLanguageSelector extends StatefulWidget { - const _CodeBlockLanguageSelector({ +class CodeBlockLanguageSelector extends StatefulWidget { + const CodeBlockLanguageSelector({ + super.key, required this.editorState, required this.supportedLanguages, this.language, @@ -46,12 +50,11 @@ class _CodeBlockLanguageSelector extends StatefulWidget { final VoidCallback? onMenuClose; @override - State<_CodeBlockLanguageSelector> createState() => + State createState() => _CodeBlockLanguageSelectorState(); } -class _CodeBlockLanguageSelectorState - extends State<_CodeBlockLanguageSelector> { +class _CodeBlockLanguageSelectorState extends State { final controller = PopoverController(); @override @@ -70,12 +73,12 @@ class _CodeBlockLanguageSelectorState widget.language?.capitalize() ?? LocaleKeys.document_codeBlock_language_auto.tr(), constraints: const BoxConstraints(minWidth: 50), - fontColor: Theme.of(context).colorScheme.onBackground, + 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 (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { final language = await context .push(MobileCodeLanguagePickerScreen.routeName); if (language != null) { @@ -88,7 +91,7 @@ class _CodeBlockLanguageSelectorState ], ); - if (PlatformExtension.isDesktopOrWeb) { + if (UniversalPlatform.isDesktopOrWeb) { child = AppFlowyPopover( controller: controller, direction: PopoverDirection.bottomWithLeftAligned, @@ -136,7 +139,8 @@ class _LanguageSelectionPopoverState extends State<_LanguageSelectionPopover> { late List filteredLanguages = widget.supportedLanguages.map((e) => e.capitalize()).toList(); late int selectedIndex = - widget.supportedLanguages.indexOf(widget.language ?? ''); + widget.supportedLanguages.indexOf(widget.language?.toLowerCase() ?? ''); + final ItemScrollController languageListController = ItemScrollController(); @override void initState() { @@ -160,34 +164,91 @@ class _LanguageSelectionPopoverState extends State<_LanguageSelectionPopover> { @override Widget build(BuildContext context) { - return 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( - shrinkWrap: true, - items: filteredLanguages, - selectedIndex: selectedIndex, - onSelected: (index) => - widget.onLanguageSelected(filteredLanguages[index]), + 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 0000000000..c4e395f22f --- /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/columns/simple_column_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_component.dart new file mode 100644 index 0000000000..c426ad640f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_component.dart @@ -0,0 +1,221 @@ +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +Node simpleColumnNode({ + List? children, + double? ratio, +}) { + return Node( + type: SimpleColumnBlockKeys.type, + children: children ?? [paragraphNode()], + attributes: { + SimpleColumnBlockKeys.ratio: ratio, + }, + ); +} + +extension SimpleColumnBlockAttributes on Node { + // get the next column node of the current column node + // if the current column node is the last column node, return null + Node? get nextColumn { + final index = path.last; + final parent = this.parent; + if (parent == null || index == parent.children.length - 1) { + return null; + } + return parent.children[index + 1]; + } + + // get the previous column node of the current column node + // if the current column node is the first column node, return null + Node? get previousColumn { + final index = path.last; + final parent = this.parent; + if (parent == null || index == 0) { + return null; + } + return parent.children[index - 1]; + } +} + +class SimpleColumnBlockKeys { + const SimpleColumnBlockKeys._(); + + static const String type = 'simple_column'; + + /// @Deprecated Use [SimpleColumnBlockKeys.ratio] instead. + /// + /// This field is no longer used since v0.6.9 + @Deprecated('Use [SimpleColumnBlockKeys.ratio] instead.') + static const String width = 'width'; + + /// The ratio of the column width. + /// + /// The value is a double number between 0 and 1. + static const String ratio = 'ratio'; +} + +class SimpleColumnBlockComponentBuilder extends BlockComponentBuilder { + SimpleColumnBlockComponentBuilder({ + super.configuration, + }); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return SimpleColumnBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + ); + } + + @override + BlockComponentValidate get validate => (node) => node.children.isNotEmpty; +} + +class SimpleColumnBlockComponent extends BlockComponentStatefulWidget { + const SimpleColumnBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => + SimpleColumnBlockComponentState(); +} + +class SimpleColumnBlockComponentState 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 columnKey = GlobalKey(); + + late final EditorState editorState = context.read(); + + @override + Widget build(BuildContext context) { + Widget child = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: node.children.map( + (e) { + Widget child = Provider( + create: (_) => DatabasePluginWidgetBuilderSize( + verticalPadding: 0, + horizontalPadding: 0, + ), + child: editorState.renderer.build(context, e), + ); + if (SimpleColumnsBlockConstants.enableDebugBorder) { + child = DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Colors.blue, + ), + ), + child: child, + ); + } + return child; + }, + ).toList(), + ); + + child = Padding( + key: columnKey, + padding: padding, + child: child, + ); + + if (SimpleColumnsBlockConstants.enableDebugBorder) { + child = Container( + color: Colors.green.withValues( + alpha: 0.3, + ), + child: child, + ); + } + + // the column block does not support the block actions and selection + // because the column block is a layout wrapper, it does not have a content + 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, + }) { + 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 = columnKey.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); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_width_resizer.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_width_resizer.dart new file mode 100644 index 0000000000..69bec33c61 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_width_resizer.dart @@ -0,0 +1,164 @@ +import 'package:appflowy/plugins/document/presentation/editor_configuration.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/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class SimpleColumnBlockWidthResizer extends StatefulWidget { + const SimpleColumnBlockWidthResizer({ + super.key, + required this.columnNode, + required this.editorState, + this.height, + }); + + final Node columnNode; + final EditorState editorState; + final double? height; + + @override + State createState() => + _SimpleColumnBlockWidthResizerState(); +} + +class _SimpleColumnBlockWidthResizerState + extends State { + bool isDragging = false; + + ValueNotifier isHovering = ValueNotifier(false); + + @override + void dispose() { + isHovering.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => isHovering.value = true, + onExit: (_) { + // delay the hover state change to avoid flickering + Future.delayed(const Duration(milliseconds: 100), () { + if (!isDragging) { + isHovering.value = false; + } + }); + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: _onHorizontalDragStart, + onHorizontalDragUpdate: _onHorizontalDragUpdate, + onHorizontalDragEnd: _onHorizontalDragEnd, + onHorizontalDragCancel: _onHorizontalDragCancel, + child: ValueListenableBuilder( + valueListenable: isHovering, + builder: (context, isHovering, child) { + final hide = isDraggingAppFlowyEditorBlock.value || !isHovering; + return MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: Container( + width: 2, + height: widget.height ?? 20, + margin: EdgeInsets.symmetric(horizontal: 2), + color: !hide + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + ), + ); + }, + ), + ), + ); + } + + void _onHorizontalDragStart(DragStartDetails details) { + isDragging = true; + EditorGlobalConfiguration.enableDragMenu.value = false; + } + + void _onHorizontalDragUpdate(DragUpdateDetails details) { + if (!isDragging) { + return; + } + + // update the column width in memory + final columnNode = widget.columnNode; + final columnsNode = columnNode.columnsParent; + if (columnsNode == null) { + return; + } + final editorWidth = columnsNode.rect.width; + final rect = columnNode.rect; + final width = rect.width; + final originalRatio = columnNode.attributes[SimpleColumnBlockKeys.ratio]; + final newWidth = width + details.delta.dx; + + final transaction = widget.editorState.transaction; + final newRatio = newWidth / editorWidth; + transaction.updateNode(columnNode, { + ...columnNode.attributes, + SimpleColumnBlockKeys.ratio: newRatio, + }); + + if (newRatio < 0.1 && newRatio < originalRatio) { + return; + } + + final nextColumn = columnNode.nextColumn; + if (nextColumn != null) { + final nextColumnRect = nextColumn.rect; + final nextColumnWidth = nextColumnRect.width; + final newNextColumnWidth = nextColumnWidth - details.delta.dx; + final newNextColumnRatio = newNextColumnWidth / editorWidth; + if (newNextColumnRatio < 0.1) { + return; + } + transaction.updateNode(nextColumn, { + ...nextColumn.attributes, + SimpleColumnBlockKeys.ratio: newNextColumnRatio, + }); + } + + transaction.updateNode(columnsNode, { + ...columnsNode.attributes, + ColumnsBlockKeys.columnCount: columnsNode.children.length, + }); + + widget.editorState.apply( + transaction, + options: ApplyOptions(inMemoryUpdate: true), + ); + } + + void _onHorizontalDragEnd(DragEndDetails details) { + isHovering.value = false; + EditorGlobalConfiguration.enableDragMenu.value = true; + + if (!isDragging) { + return; + } + + // apply the transaction again to make sure the width is updated + final transaction = widget.editorState.transaction; + final columnsNode = widget.columnNode.columnsParent; + if (columnsNode == null) { + return; + } + for (final columnNode in columnsNode.children) { + transaction.updateNode(columnNode, { + ...columnNode.attributes, + }); + } + widget.editorState.apply(transaction); + + isDragging = false; + } + + void _onHorizontalDragCancel() { + isDragging = false; + isHovering.value = false; + EditorGlobalConfiguration.enableDragMenu.value = true; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_node_extension.dart new file mode 100644 index 0000000000..05389fb760 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_node_extension.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension SimpleColumnNodeExtension on Node { + /// Returns the parent [Node] of the current node if it is a [SimpleColumnsBlock]. + Node? get columnsParent { + Node? currentNode = parent; + while (currentNode != null) { + if (currentNode.type == SimpleColumnsBlockKeys.type) { + return currentNode; + } + currentNode = currentNode.parent; + } + return null; + } + + /// Returns the parent [Node] of the current node if it is a [SimpleColumnBlock]. + Node? get columnParent { + Node? currentNode = parent; + while (currentNode != null) { + if (currentNode.type == SimpleColumnBlockKeys.type) { + return currentNode; + } + currentNode = currentNode.parent; + } + return null; + } + + /// Returns whether the current node is in a [SimpleColumnsBlock]. + bool get isInColumnsBlock => columnsParent != null; + + /// Returns whether the current node is in a [SimpleColumnBlock]. + bool get isInColumnBlock => columnParent != null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_component.dart new file mode 100644 index 0000000000..58ecde5f2f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_component.dart @@ -0,0 +1,275 @@ +import 'dart:math'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.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:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +// if the children is not provided, it will create two columns by default. +// if the columnCount is provided, it will create the specified number of columns. +Node simpleColumnsNode({ + List? children, + int? columnCount, + double? ratio, +}) { + columnCount ??= 2; + children ??= List.generate( + columnCount, + (index) => simpleColumnNode( + ratio: ratio, + children: [paragraphNode()], + ), + ); + + // check the type of children + for (final child in children) { + if (child.type != SimpleColumnBlockKeys.type) { + Log.error('the type of children must be column, but got ${child.type}'); + } + } + + return Node( + type: SimpleColumnsBlockKeys.type, + children: children, + ); +} + +class SimpleColumnsBlockKeys { + const SimpleColumnsBlockKeys._(); + + static const String type = 'simple_columns'; +} + +class SimpleColumnsBlockComponentBuilder extends BlockComponentBuilder { + SimpleColumnsBlockComponentBuilder({super.configuration}); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return ColumnsBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + ); + } + + @override + BlockComponentValidate get validate => (node) => node.children.isNotEmpty; +} + +class ColumnsBlockComponent extends BlockComponentStatefulWidget { + const ColumnsBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => ColumnsBlockComponentState(); +} + +class ColumnsBlockComponentState 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 columnsKey = GlobalKey(); + + late final EditorState editorState = context.read(); + + final ScrollController scrollController = ScrollController(); + + final ValueNotifier heightValueNotifier = ValueNotifier(null); + + @override + void initState() { + super.initState(); + _updateColumnsBlock(); + } + + @override + void dispose() { + scrollController.dispose(); + heightValueNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildChildren(), + ); + + child = Align( + alignment: Alignment.topLeft, + child: child, + ); + + child = Padding( + key: columnsKey, + padding: padding, + child: child, + ); + + if (SimpleColumnsBlockConstants.enableDebugBorder) { + child = DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Colors.red, + width: 3.0, + ), + ), + child: child, + ); + } + + // the columns block does not support the block actions and selection + // because the columns block is a layout wrapper, it does not have a content + return NotificationListener( + onNotification: (v) => updateHeightValueNotifier(v), + child: SizeChangedLayoutNotifier(child: child), + ); + } + + List _buildChildren() { + final length = node.children.length; + final children = []; + for (var i = 0; i < length; i++) { + final childNode = node.children[i]; + final double ratio = + childNode.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ?? + 1.0 / length; + + Widget child = editorState.renderer.build(context, childNode); + + child = Expanded( + flex: (max(ratio, 0.1) * 10000).toInt(), + child: child, + ); + + children.add(child); + + if (i != length - 1) { + children.add( + ValueListenableBuilder( + valueListenable: heightValueNotifier, + builder: (context, height, child) { + return SimpleColumnBlockWidthResizer( + columnNode: childNode, + editorState: editorState, + height: height, + ); + }, + ), + ); + } + } + return children; + } + + // Update the existing columns block data + // if the column ratio is not existing, it will be set to 1.0 / columnCount + void _updateColumnsBlock() { + final transaction = editorState.transaction; + final length = node.children.length; + for (int i = 0; i < length; i++) { + final childNode = node.children[i]; + final ratio = childNode.attributes[SimpleColumnBlockKeys.ratio]; + if (ratio == null) { + transaction.updateNode(childNode, { + ...childNode.attributes, + SimpleColumnBlockKeys.ratio: 1.0 / length, + }); + } + } + if (transaction.operations.isNotEmpty) { + editorState.apply(transaction); + } + } + + bool updateHeightValueNotifier(SizeChangedLayoutNotification notification) { + if (!mounted) return true; + final height = _renderBox?.size.height; + if (heightValueNotifier.value == height) return true; + WidgetsBinding.instance.addPostFrameCallback((_) { + heightValueNotifier.value = height; + }); + return true; + } + + @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 = columnsKey.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); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart new file mode 100644 index 0000000000..d8820f8613 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart @@ -0,0 +1,6 @@ +class SimpleColumnsBlockConstants { + const SimpleColumnsBlockConstants._(); + + static const double minimumColumnWidth = 128.0; + static const bool enableDebugBorder = false; +} 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 index 6dc8724d26..cc496ff9d4 100644 --- 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 @@ -13,6 +13,11 @@ final List> customContextMenuItems = [ 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 index e188e6b29f..f108c7e26b 100644 --- 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 @@ -8,21 +8,17 @@ 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. -final inAppJsonFormat = CustomValueFormat( +const inAppJsonFormat = CustomValueFormat( applicationId: 'io.appflowy.InAppJsonType', - onDecode: (value, 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; - }, - onEncode: (value, platformType) => utf8.encode(value), + 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 { @@ -31,20 +27,53 @@ class ClipboardServiceData { 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) { @@ -56,6 +85,9 @@ class ClipboardService { 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': @@ -81,6 +113,10 @@ class ClipboardService { } Future getData() async { + if (_mockData != null) { + return _mockData!; + } + final reader = await SystemClipboard.instance?.read(); if (reader == null) { @@ -89,14 +125,14 @@ class ClipboardService { for (final item in reader.items) { final availableFormats = await item.rawReader!.getAvailableFormats(); - Log.debug( - 'availableFormats: $availableFormats', - ); + 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); + final uri = await reader.readValue(Formats.uri); (String, Uint8List?)? image; if (reader.canProvide(Formats.png)) { image = ('png', await reader.readFile(Formats.png)); @@ -104,13 +140,16 @@ class ClipboardService { 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, + plainText: plainText ?? uri?.uri.toString(), html: html, image: image, inAppJson: inAppJson, + tableJson: tableJson, ); } } @@ -138,3 +177,22 @@ extension on DataReader { 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 index 37f019d750..e56ccfc941 100644 --- 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 @@ -1,6 +1,7 @@ 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/plugins.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; @@ -20,23 +21,54 @@ final CommandShortcutEvent customCopyCommand = CommandShortcutEvent( handler: _copyCommandHandler, ); -CommandShortcutEventHandler _copyCommandHandler = (editorState) { +CommandShortcutEventHandler _copyCommandHandler = + (editorState) => handleCopyCommand(editorState); + +KeyEventResult handleCopyCommand( + EditorState editorState, { + bool isCut = false, +}) { final selection = editorState.selection?.normalized; - if (selection == null || selection.isCollapsed) { + if (selection == null) { return KeyEventResult.ignored; } - // plain text. - final text = editorState.getTextInSelection(selection).join('\n'); + String? text; + String? html; + String? inAppJson; - final nodes = editorState.getSelectedNodes(selection: selection); - final document = Document.blank()..insert([0], nodes); + 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; + } - // in app json - final inAppJson = jsonEncode(document.toJson()); + // plain text. + text = node.delta?.toPlainText(); - // html - final html = documentToHTML(document); + // 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( @@ -49,4 +81,68 @@ CommandShortcutEventHandler _copyCommandHandler = (editorState) { }(); 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's children instead. + filteredNodes.addAll(node.children.expand((e) => e.children)); + } else if (node.type == SimpleColumnBlockKeys.type) { + // if the node is a column block, we will fetch its children instead. + filteredNodes.addAll(node.children); + } else if (node.type == SimpleColumnsBlockKeys.type) { + // if the node is a columns block, we will fetch its children's 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 index e7f6fba181..9fea34edbf 100644 --- 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 @@ -1,7 +1,8 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart'; 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. /// @@ -19,7 +20,42 @@ final CommandShortcutEvent customCutCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _cutCommandHandler = (editorState) { - customCopyCommand.execute(editorState); - editorState.deleteSelectionIfNeeded(); + 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 index 842f00dbe9..6399d3b11f 100644 --- 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 @@ -1,17 +1,23 @@ +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/editor_state_paste_node_extension.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:http/http.dart' as http; import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; -/// Paste. -/// /// - support /// - desktop /// - web @@ -25,75 +31,147 @@ final CommandShortcutEvent customPasteCommand = CommandShortcutEvent( 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; } - // because the event handler is not async, so we need to use wrap the async function here - () async { - // 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; - - // paste as link preview - final result = await _pasteAsLinkPreview(editorState, plainText); - if (result) { - return; + doPaste(editorState).then((_) { + final context = editorState.document.root.context; + if (context != null && context.mounted) { + context.read().didPaste(); } - - // 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) { - await editorState.deleteSelectionIfNeeded(); - final result = await editorState.pasteInAppJson(inAppJson); - if (result) { - return; - } - } - - if (html != null && html.isNotEmpty) { - await editorState.deleteSelectionIfNeeded(); - final result = await editorState.pasteHtml(html); - if (result) { - return; - } - } - - if (image != null && image.$2?.isNotEmpty == true) { - await editorState.deleteSelectionIfNeeded(); - final result = await editorState.pasteImage(image.$1, image.$2!); - if (result) { - return; - } - } - - if (plainText != null && plainText.isNotEmpty) { - await editorState.pastePlainText(plainText); - } - }(); + }); 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) { + final currentSelection = editorState.selection; + if (currentSelection == null) { + await editorState.updateSelectionWithReason( + selection, + reason: SelectionUpdateReason.uiEvent, + ); + } + 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 { - if (text == null || !isURL(text)) { + final isMobile = UniversalPlatform.isMobile; + // the url should contain a protocol + if (text == null || !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) { @@ -101,18 +179,91 @@ Future _pasteAsLinkPreview( } 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; } + if (!isMobile) return false; + final bool isImageUrl; + try { + isImageUrl = await _isImageUrl(text); + } catch (e) { + Log.info('unable to get content header'); + return false; + } - final transaction = editorState.transaction; - transaction.insertNode( - selection.start.path, - linkPreviewNode(url: text), + if (!isImageUrl) return false; + + // insert the text with link format + final textTransaction = editorState.transaction + ..insertText( + node, + 0, + text, + attributes: {AppFlowyRichTextKeys.href: text}, + ); + await editorState.apply( + textTransaction, + skipHistoryDebounce: true, ); - await editorState.apply(transaction); + + // convert it to image or link preview node + final replacementInsertedNodes = [ + isImageUrl ? imageNode(url: text) : linkPreviewNode(url: text), + // if the next node is null, insert a empty paragraph node + if (node.next == null) paragraphNode(), + ]; + + final replacementTransaction = editorState.transaction + ..insertNodes( + selection.start.path, + replacementInsertedNodes, + ) + ..deleteNode(node) + ..afterSelection = Selection.collapsed( + Position(path: node.path.next), + ); + + await editorState.apply(replacementTransaction); 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; +} + +Future _isImageUrl(String text) async { + if (isNotImageUrl(text)) return false; + final response = await http.head(Uri.parse(text)); + + if (response.statusCode == 200) { + final contentType = response.headers['content-type']; + if (contentType != null) { + return contentType.startsWith('image/') && + defaultImageExtensions.any(contentType.contains); + } + } + + throw 'bad status code'; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart deleted file mode 100644 index 34c6c8fe06..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; - -extension PasteNodes on EditorState { - Future pasteSingleLineNode(Node insertedNode) async { - final selection = await deleteSelectionIfNeeded(); - if (selection == null) { - return; - } - final node = getNodeAtPath(selection.start.path); - final delta = node?.delta; - if (node == null || delta == null) { - return; - } - final transaction = this.transaction; - final insertedDelta = insertedNode.delta; - // if the node is empty and its type is paragprah, replace it with the inserted node. - if (delta.isEmpty && node.type == ParagraphBlockKeys.type) { - transaction.insertNode( - selection.end.path.next, - insertedNode, - ); - transaction.deleteNode(node); - final path = calculatePath(selection.end.path, [insertedNode]); - final offset = calculateLength([insertedNode]); - transaction.afterSelection = Selection.collapsed( - Position( - path: path, - offset: offset, - ), - ); - } else if (insertedDelta != null) { - // if the node is not empty, insert the delta from inserted node after the selection. - transaction.insertTextDelta(node, selection.endIndex, insertedDelta); - } - await apply(transaction); - } - - Future pasteMultiLineNodes(List nodes) async { - assert(nodes.length > 1); - - final selection = await deleteSelectionIfNeeded(); - if (selection == null) { - return; - } - final node = getNodeAtPath(selection.start.path); - final delta = node?.delta; - if (node == null || delta == null) { - return; - } - final transaction = this.transaction; - - final lastNodeLength = calculateLength(nodes); - // merge the current selected node delta into the nodes. - if (delta.isNotEmpty) { - nodes.first.insertDelta( - delta.slice(0, selection.startIndex), - insertAfter: false, - ); - - nodes.last.insertDelta( - delta.slice(selection.endIndex), - ); - } - - if (delta.isEmpty && node.type != ParagraphBlockKeys.type) { - nodes[0] = nodes.first.copyWith( - type: node.type, - attributes: { - ...node.attributes, - ...nodes.first.attributes, - }, - ); - } - - for (final child in node.children) { - nodes.last.insert(child); - } - - transaction.insertNodes(selection.end.path, nodes); - - // delete the current node. - transaction.deleteNode(node); - - final path = calculatePath(selection.start.path, nodes); - transaction.afterSelection = Selection.collapsed( - Position( - path: path, - offset: lastNodeLength, - ), - ); - - await apply(transaction); - } - - // delete the selection if it's not collapsed. - Future deleteSelectionIfNeeded() async { - final selection = this.selection; - if (selection == null) { - return null; - } - - // delete the selection first. - if (!selection.isCollapsed) { - await deleteSelection(selection); - } - - // fetch selection again.selection = editorState.selection; - assert(this.selection?.isCollapsed == true); - return this.selection; - } - - Path calculatePath(Path start, List nodes) { - var path = start; - for (var i = 0; i < nodes.length; i++) { - path = path.next; - } - path = path.previous; - if (nodes.last.children.isNotEmpty) { - return [ - ...path, - ...calculatePath([0], nodes.last.children.toList()), - ]; - } - return path; - } - - int calculateLength(List nodes) { - if (nodes.last.children.isNotEmpty) { - return calculateLength(nodes.last.children.toList()); - } - return nodes.last.delta?.length ?? 0; - } -} - -extension on Node { - void insertDelta(Delta delta, {bool insertAfter = true}) { - assert(delta.every((element) => element is TextInsert)); - if (this.delta == null) { - updateAttributes({ - blockComponentDelta: delta.toJson(), - }); - } else if (insertAfter) { - updateAttributes( - { - blockComponentDelta: this - .delta! - .compose( - Delta() - ..retain(this.delta!.length) - ..addAll(delta), - ) - .toJson(), - }, - ); - } else { - updateAttributes( - { - blockComponentDelta: delta - .compose( - Delta() - ..retain(delta.length) - ..addAll(this.delta!), - ) - .toJson(), - }, - ); - } - } -} 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 0000000000..c47c0c967d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart @@ -0,0 +1,56 @@ +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.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: pageId, + blockId: blockId, + ), + ); + 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 0000000000..dc05e852c9 --- /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 index e30bcf8a5d..3f11759545 100644 --- 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 @@ -1,25 +1,117 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:html2md/html2md.dart' as html2md; 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(); - } + final nodes = convertHtmlToNodes(html); // if there's no nodes being converted successfully, return false if (nodes.isEmpty) { return false; } if (nodes.length == 1) { await pasteSingleLineNode(nodes.first); + checkToShowPasteAsMenu(nodes.first); } else { await pasteMultiLineNodes(nodes.toList()); } return true; } + + // Convert the html to document nodes. + // For the google docs table, it will be fallback to the markdown parser. + List convertHtmlToNodes(String html) { + List nodes = htmlToDocument(html).root.children.toList(); + + // 1. 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(); + } + + // 2. replace the legacy table nodes with the new simple table nodes + for (int i = 0; i < nodes.length; i++) { + final node = nodes[i]; + if (node.type == TableBlockKeys.type) { + nodes[i] = _convertTableToSimpleTable(node); + } + } + + // 3. verify the nodes is empty or contains google table flag + // The table from Google Docs will contain the flag 'Google Table' + const googleDocsFlag = 'docs-internal-guid-'; + final isPasteFromGoogleDocs = html.contains(googleDocsFlag); + final isPasteFromAppleNotes = appleNotesRegex.hasMatch(html); + final containsTable = nodes.any( + (node) => + [TableBlockKeys.type, SimpleTableBlockKeys.type].contains(node.type), + ); + if ((nodes.isEmpty || isPasteFromGoogleDocs || containsTable) && + !isPasteFromAppleNotes) { + // fallback to the markdown parser + final markdown = html2md.convert(html); + nodes = customMarkdownToDocument(markdown, tableWidth: 200) + .root + .children + .toList(); + } + + // 4. check if the first node and the last node is bold, because google docs will wrap the table with bold tags + if (isPasteFromGoogleDocs) { + if (nodes.isNotEmpty && nodes.first.delta?.toPlainText() == '**') { + nodes.removeAt(0); + } + if (nodes.isNotEmpty && nodes.last.delta?.toPlainText() == '**') { + nodes.removeLast(); + } + } + + return nodes; + } + + // convert the legacy table node to the new simple table node + // from type 'table' to type 'simple_table' + 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.map((e) => e.deepCopy()).toList() ?? + [paragraphNode()], + ), + ); + } + rows.add(row); + } + + return simpleTableBlockNode( + children: rows.map((e) => simpleTableRowBlockNode(children: e)).toList(), + ); + } } 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 index 4de0961a85..d086f36bed 100644 --- 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 @@ -3,35 +3,82 @@ 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/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +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 { - static final supportedImageFormats = [ - 'png', - 'jpeg', - 'gif', - ]; + 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()), + ); - Future pasteImage(String format, Uint8List imageBytes) async { - if (!supportedImageFormats.contains(format)) { - return false; + 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( + message: LocaleKeys.document_imageBlock_error_invalidImageFormat.tr(), + ); + } + return false; + } + final isLocalMode = context.read().isLocalMode; final path = await getIt().getPath(); @@ -53,47 +100,85 @@ extension PasteFromImage on EditorState { await File(copyToPath).writeAsBytes(imageBytes); final String? path; - if (context.mounted) { - showSnackBarMessage( - context, - LocaleKeys.document_imageBlock_imageIsUploading.tr(), - ); - } - + CustomImageType type; if (isLocalMode) { path = await saveImageToLocalStorage(copyToPath); + type = CustomImageType.local; } else { - final result = await saveImageToCloudStorage(copyToPath); + final result = await saveImageToCloudStorage(copyToPath, documentId); final errorMessage = result.$2; if (errorMessage != null && context.mounted) { - showSnackBarMessage( - context, - errorMessage, + showToastNotification( + message: errorMessage, ); return false; } path = result.$1; + type = CustomImageType.internal; } if (path != null) { - await insertImageNode(path); + await insertImageNode(path, selection: selection, type: type); } - await File(copyToPath).delete(); return true; } catch (e) { Log.error('cannot copy image file', e); if (context.mounted) { - showSnackBarMessage( - context, - LocaleKeys.document_imageBlock_error_invalidImage.tr(), + showToastNotification( + 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 index 4cc17d599b..d4db86d80c 100644 --- 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 @@ -1,19 +1,33 @@ import 'dart:convert'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.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' hide Log; +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; + + // skip pasting a table block to another table block + final containsTable = + nodes.any((node) => node.type == SimpleTableBlockKeys.type); + if (containsTable) { + final selectedNodes = getSelectedNodes(withCopy: false); + if (selectedNodes.any((node) => node.parentTableNode != null)) { + return false; + } + } + 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; 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 index 114dde62a3..fcb12cefa5 100644 --- 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 @@ -1,15 +1,12 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:universal_platform/universal_platform.dart'; extension PasteFromPlainText on EditorState { Future pastePlainText(String plainText) async { - if (await pasteHtmlIfAvailable(plainText)) { - return; - } - await deleteSelectionIfNeeded(); - final nodes = plainText .split('\n') .map( @@ -17,14 +14,7 @@ extension PasteFromPlainText on EditorState { ..replaceAll(r'\r', '') ..trimRight(), ) - .map((e) { - // parse the url content - final Attributes attributes = {}; - if (hrefRegex.hasMatch(e)) { - attributes[AppFlowyRichTextKeys.href] = e; - } - return Delta()..insert(e, attributes: attributes); - }) + .map((e) => Delta()..insert(e)) .map((e) => paragraphNode(delta: e)) .toList(); if (nodes.isEmpty) { @@ -37,6 +27,28 @@ extension PasteFromPlainText on EditorState { } } + Future pasteText(String plainText) async { + if (await pasteHtmlIfAvailable(plainText)) { + return; + } + + await deleteSelectionIfNeeded(); + + /// try to parse the plain text as markdown + final nodes = customMarkdownToDocument(plainText).root.children; + if (nodes.isEmpty) { + /// if the markdown parser failed, fallback to the plain text parser + await pastePlainText(plainText); + return; + } + if (nodes.length == 1) { + await pasteSingleLineNode(nodes.first); + checkToShowPasteAsMenu(nodes.first); + } else { + await pasteMultiLineNodes(nodes.toList()); + } + } + Future pasteHtmlIfAvailable(String plainText) async { final selection = this.selection; if (selection == null || @@ -56,6 +68,29 @@ extension PasteFromPlainText on EditorState { AppFlowyRichTextKeys.href: plainText, }); await apply(transaction); + checkToShowPasteAsMenu(node); return true; } + + void checkToShowPasteAsMenu(Node node) { + if (selection == null || !selection!.isCollapsed) return; + if (UniversalPlatform.isMobile) return; + final href = _getLinkFromNode(node); + if (href != null) { + final context = document.root.context; + if (context != null && context.mounted) { + PasteAsMenuService(context: context, editorState: this).show(href); + } + } + } + + String? _getLinkFromNode(Node node) { + final delta = node.delta; + if (delta == null) return null; + final inserts = delta.whereType(); + if (inserts.isEmpty || inserts.length > 1) return null; + final link = inserts.first.attributes?.href; + if (link != null) return inserts.first.text; + return null; + } } 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 index e25f32f55f..96d39d7500 100644 --- 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 @@ -1,26 +1,33 @@ 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/plugins/base/emoji/emoji_picker_screen.dart'; -import 'package:appflowy/plugins/base/icon/icon_picker.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/shared/icon_emoji_picker/tab.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 'package:go_router/go_router.dart'; + +import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; double kDocumentCoverHeight = 98.0; double kDocumentTitlePadding = 20.0; @@ -30,10 +37,14 @@ class DocumentImmersiveCover extends StatefulWidget { super.key, required this.view, required this.userProfilePB, + required this.tabs, + this.fixedTitle, }); final ViewPB view; final UserProfilePB userProfilePB; + final String? fixedTitle; + final List tabs; @override State createState() => _DocumentImmersiveCoverState(); @@ -51,6 +62,9 @@ class _DocumentImmersiveCoverState extends State { void initState() { super.initState(); selectionNotifier?.addListener(_unfocus); + if (widget.view.name.isEmpty) { + focusNode.requestFocus(); + } } @override @@ -71,7 +85,9 @@ class _DocumentImmersiveCoverState extends State { child: BlocConsumer( listener: (context, state) { - textEditingController.text = state.name; + if (textEditingController.text != state.name) { + textEditingController.text = state.name; + } }, builder: (_, state) { final iconAndTitle = _buildIconAndTitle(context, state); @@ -84,19 +100,22 @@ class _DocumentImmersiveCoverState extends State { ); } - return Stack( - children: [ - _buildCover(context, state), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 24.0), - 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, + ), ), - ), - ], + ], + ), ); }, ), @@ -133,15 +152,29 @@ class _DocumentImmersiveCoverState extends State { if (documentFontFamily != null && fontFamily != documentFontFamily) { fontFamily = getGoogleFontSafely(documentFontFamily).fontFamily; } - return TextField( + + 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, - decoration: const InputDecoration( + minFontSize: 18.0, + decoration: InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, disabledBorder: InputBorder.none, focusedBorder: InputBorder.none, - hintText: '', + hintText: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), contentPadding: EdgeInsets.zero, ), scrollController: scrollController, @@ -149,30 +182,69 @@ class _DocumentImmersiveCoverState extends State { fontSize: 28.0, fontWeight: FontWeight.w700, fontFamily: fontFamily, - color: state.cover.type == PageStyleCoverImageType.none - ? null - : Colors.white, + color: + state.cover.isNone || state.cover.isPresets ? null : Colors.white, + overflow: TextOverflow.ellipsis, ), - onSubmitted: (value) { - scrollController.position.jumpTo(0); - context.read().add(ViewEvent.rename(value)); + 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, String icon) { + Widget _buildIcon(BuildContext context, EmojiIconData icon) { return GestureDetector( - child: EmojiIconWidget( - emoji: icon, - emojiSize: 26, + child: ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 34.0), + child: EmojiIconWidget( + emoji: icon, + emojiSize: 26, + ), ), onTap: () async { - final result = await context.push( - MobileEmojiPickerScreen.routeName, + 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( + initialType: icon.type.toPickerTabType(), + tabs: widget.tabs, + documentId: widget.view.id, + onSelectedEmoji: (r) { + pageStyleIconBloc.add( + PageStyleIconEvent.updateIcon(r.data, true), + ); + if (!r.keepOpen) Navigator.pop(context); + }, + ), + ), + ); + }, + builder: (_) => const SizedBox.shrink(), ); - if (result != null && context.mounted) { - context.read().add(ViewEvent.updateIcon(result.emoji)); - } }, ); } @@ -247,4 +319,40 @@ class _DocumentImmersiveCoverState extends State { 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 index bd2692e172..3006fc3104 100644 --- 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 @@ -1,10 +1,13 @@ 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/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'; +import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; + part 'document_immersive_cover_bloc.freezed.dart'; class DocumentImmersiveCoverBloc @@ -17,10 +20,14 @@ class DocumentImmersiveCoverBloc (event, emit) async { await event.when( initial: () async { + final latestView = await ViewBackendService.getView(view.id); add( DocumentImmersiveCoverEvent.updateCoverAndIcon( - view.cover, - view.icon.value, + latestView.fold( + (s) => s.cover, + (e) => view.cover, + ), + EmojiIconData.fromViewIconPB(view.icon), view.name, ), ); @@ -29,7 +36,7 @@ class DocumentImmersiveCoverBloc add( DocumentImmersiveCoverEvent.updateCoverAndIcon( view.cover, - view.icon.value, + EmojiIconData.fromViewIconPB(view.icon), view.name, ), ); @@ -63,9 +70,10 @@ class DocumentImmersiveCoverBloc @freezed class DocumentImmersiveCoverEvent with _$DocumentImmersiveCoverEvent { const factory DocumentImmersiveCoverEvent.initial() = Initial; + const factory DocumentImmersiveCoverEvent.updateCoverAndIcon( PageStyleCover? cover, - String? icon, + EmojiIconData? icon, String? name, ) = UpdateCoverAndIcon; } @@ -73,7 +81,7 @@ class DocumentImmersiveCoverEvent with _$DocumentImmersiveCoverEvent { @freezed class DocumentImmersiveCoverState with _$DocumentImmersiveCoverState { const factory DocumentImmersiveCoverState({ - @Default(null) String? icon, + @Default(null) EmojiIconData? icon, required PageStyleCover cover, @Default('') String name, }) = _DocumentImmersiveCoverState; 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 index d780a1260e..87c2815091 100644 --- 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 @@ -1,5 +1,9 @@ +import 'dart:async'; + import 'package:appflowy/plugins/database/widgets/database_view_widget.dart'; +import 'package:appflowy/plugins/document/presentation/compact_mode_event.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -13,8 +17,14 @@ class DatabaseBlockKeys { static const String parentID = 'parent_id'; static const String viewID = 'view_id'; + static const String enableCompactMode = 'enable_compact_mode'; } +const overflowTypes = { + DatabaseBlockKeys.gridType, + DatabaseBlockKeys.boardType, +}; + class DatabaseViewBlockComponentBuilder extends BlockComponentBuilder { DatabaseViewBlockComponentBuilder({ super.configuration, @@ -32,11 +42,15 @@ class DatabaseViewBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @override - bool validate(Node node) => + BlockComponentValidate get validate => (node) => node.children.isEmpty && node.attributes[DatabaseBlockKeys.parentID] is String && node.attributes[DatabaseBlockKeys.viewID] is String; @@ -48,6 +62,7 @@ class DatabaseBlockComponentWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @@ -65,37 +80,65 @@ class _DatabaseBlockComponentWidgetState @override BlockComponentConfiguration get configuration => widget.configuration; + late StreamSubscription compactModeSubscription; + EditorState? editorState; + + @override + void initState() { + super.initState(); + compactModeSubscription = + compactModeEventBus.on().listen((event) { + if (event.id != node.id) return; + final newAttributes = { + ...node.attributes, + DatabaseBlockKeys.enableCompactMode: event.enable, + }; + final theEditorState = editorState; + if (theEditorState == null) return; + final transaction = theEditorState.transaction; + transaction.updateNode(node, newAttributes); + theEditorState.apply(transaction); + }); + } + + @override + void dispose() { + super.dispose(); + compactModeSubscription.cancel(); + editorState = null; + } + @override Widget build(BuildContext context) { final editorState = Provider.of(context, listen: false); + this.editorState = editorState; Widget child = BuiltInPageWidget( node: widget.node, editorState: editorState, - builder: (viewPB) { - return DatabaseViewWidget( - key: ValueKey(viewPB.id), - view: viewPB, - ); - }, - ); - - child = Padding( - padding: padding, - child: FocusScope( - skipTraversal: true, - onFocusChange: (value) { - if (value) { - context.read().selection = null; - } - }, - child: child, + builder: (view) => Provider.value( + value: ReferenceState(true), + child: DatabaseViewWidget( + key: ValueKey(view.id), + view: view, + actionBuilder: widget.actionBuilder, + showActions: widget.showActions, + node: widget.node, + ), ), ); - if (widget.showActions && widget.actionBuilder != null) { - child = BlockComponentActionWrapper( - node: widget.node, - actionBuilder: widget.actionBuilder!, + child = FocusScope( + skipTraversal: true, + onFocusChange: (value) { + if (value && keepEditorFocusNotifier.value == 0) { + context.read().selection = null; + } + }, + child: child, + ); + + if (!editorState.editable) { + child = IgnorePointer( child: child, ); } 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 index 79af12b668..8f6c117833 100644 --- 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 @@ -11,13 +11,16 @@ import 'package:easy_localization/easy_localization.dart'; SelectionMenuItem referencedDocumentMenuItem = SelectionMenuItem( getName: LocaleKeys.document_plugins_referencedDocument.tr, icon: (editorState, onSelected, style) => SelectableSvgWidget( - data: FlowySvgs.document_s, + data: FlowySvgs.icon_document_s, isSelected: onSelected, style: style, ), keywords: ['page', 'notes', 'referenced page', 'referenced document'], - handler: (editorState, menuService, context) => - showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Document), + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + pageType: ViewLayoutPB.Document, + ), ); // Database References @@ -30,8 +33,11 @@ SelectionMenuItem referencedGridMenuItem = SelectionMenuItem( style: style, ), keywords: ['referenced', 'grid', 'database'], - handler: (editorState, menuService, context) => - showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Grid), + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + pageType: ViewLayoutPB.Grid, + ), ); SelectionMenuItem referencedBoardMenuItem = SelectionMenuItem( @@ -42,8 +48,11 @@ SelectionMenuItem referencedBoardMenuItem = SelectionMenuItem( style: style, ), keywords: ['referenced', 'board', 'kanban'], - handler: (editorState, menuService, context) => - showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Board), + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + pageType: ViewLayoutPB.Board, + ), ); SelectionMenuItem referencedCalendarMenuItem = SelectionMenuItem( @@ -54,6 +63,9 @@ SelectionMenuItem referencedCalendarMenuItem = SelectionMenuItem( style: style, ), keywords: ['referenced', 'calendar', 'database'], - handler: (editorState, menuService, context) => - showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Calendar), + 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 0000000000..905c033bda --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart @@ -0,0 +1,53 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +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]; + final mentionType = mention?[MentionBlockKeys.type]; + if (mentionPageId != null) { + text += await getMentionPageName(mentionPageId); + continue; + } else if (mentionType == MentionType.externalLink.name) { + final url = mention?[MentionBlockKeys.url] ?? ''; + final info = await LinkInfoCache.get(url); + text += info?.title ?? url; + 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/desktop_toolbar/color_picker.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart new file mode 100644 index 0000000000..162c7a1c34 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart @@ -0,0 +1,330 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/overlay_util.dart'; +import 'package:flutter/material.dart'; + +class ColorPicker extends StatefulWidget { + const ColorPicker({ + super.key, + required this.title, + required this.selectedColorHex, + required this.onSubmittedColorHex, + required this.colorOptions, + this.resetText, + this.customColorHex, + this.resetIconName, + this.showClearButton = false, + }); + + final String title; + final String? selectedColorHex; + final String? customColorHex; + final void Function(String? color, bool isCustomColor) onSubmittedColorHex; + final String? resetText; + final String? resetIconName; + final bool showClearButton; + + final List colorOptions; + + @override + State createState() => _ColorPickerState(); +} + +class _ColorPickerState extends State { + final TextEditingController _colorHexController = TextEditingController(); + final TextEditingController _colorOpacityController = TextEditingController(); + + @override + void initState() { + super.initState(); + final selectedColorHex = widget.selectedColorHex, + customColorHex = widget.customColorHex; + _colorHexController.text = + _extractColorHex(customColorHex ?? selectedColorHex) ?? 'FFFFFF'; + _colorOpacityController.text = + _convertHexToOpacity(customColorHex ?? selectedColorHex) ?? '100'; + } + + @override + Widget build(BuildContext context) { + return basicOverlay( + context, + width: 300, + height: 250, + children: [ + EditorOverlayTitle(text: widget.title), + const SizedBox(height: 6), + widget.showClearButton && + widget.resetText != null && + widget.resetIconName != null + ? ResetColorButton( + resetText: widget.resetText!, + resetIconName: widget.resetIconName!, + onPressed: (color) => + widget.onSubmittedColorHex.call(color, false), + ) + : const SizedBox.shrink(), + CustomColorItem( + colorController: _colorHexController, + opacityController: _colorOpacityController, + onSubmittedColorHex: (color) => + widget.onSubmittedColorHex.call(color, true), + ), + _buildColorItems( + widget.colorOptions, + widget.selectedColorHex, + ), + ], + ); + } + + Widget _buildColorItems( + List options, + String? selectedColor, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: options + .map((e) => _buildColorItem(e, e.colorHex == selectedColor)) + .toList(), + ); + } + + Widget _buildColorItem(ColorOption option, bool isChecked) { + return SizedBox( + height: 36, + child: TextButton.icon( + onPressed: () { + widget.onSubmittedColorHex(option.colorHex, false); + }, + icon: SizedBox.square( + dimension: 12, + child: Container( + decoration: BoxDecoration( + color: option.colorHex.tryToColor(), + shape: BoxShape.circle, + ), + ), + ), + style: buildOverlayButtonStyle(context), + label: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + option.name, + softWrap: false, + maxLines: 1, + overflow: TextOverflow.fade, + style: TextStyle( + color: Theme.of(context).textTheme.labelLarge?.color, + ), + ), + ), + // checkbox + if (isChecked) const FlowySvg(FlowySvgs.toolbar_check_m), + ], + ), + ), + ); + } + + String? _convertHexToOpacity(String? colorHex) { + if (colorHex == null) return null; + final opacityHex = colorHex.substring(2, 4); + final opacity = int.parse(opacityHex, radix: 16) / 2.55; + return opacity.toStringAsFixed(0); + } + + String? _extractColorHex(String? colorHex) { + if (colorHex == null) return null; + return colorHex.substring(4); + } +} + +class ResetColorButton extends StatelessWidget { + const ResetColorButton({ + super.key, + required this.resetText, + required this.resetIconName, + required this.onPressed, + }); + + final Function(String? color) onPressed; + final String resetText; + final String resetIconName; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 32, + child: TextButton.icon( + onPressed: () => onPressed(null), + icon: EditorSvg( + name: resetIconName, + width: 13, + height: 13, + color: Theme.of(context).iconTheme.color, + ), + label: Text( + resetText, + style: TextStyle( + color: Theme.of(context).hintColor, + ), + textAlign: TextAlign.left, + ), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.hovered)) { + return Theme.of(context).hoverColor; + } + return Colors.transparent; + }, + ), + alignment: Alignment.centerLeft, + ), + ), + ); + } +} + +class CustomColorItem extends StatefulWidget { + const CustomColorItem({ + super.key, + required this.colorController, + required this.opacityController, + required this.onSubmittedColorHex, + }); + + final TextEditingController colorController; + final TextEditingController opacityController; + final void Function(String color) onSubmittedColorHex; + + @override + State createState() => _CustomColorItemState(); +} + +class _CustomColorItemState extends State { + @override + Widget build(BuildContext context) { + return ExpansionTile( + tilePadding: const EdgeInsets.only(left: 8), + shape: Border.all( + color: Colors.transparent, + ), // remove the default border when it is expanded + title: Row( + children: [ + // color sample box + SizedBox.square( + dimension: 12, + child: Container( + decoration: BoxDecoration( + color: Color( + int.tryParse( + _combineColorHexAndOpacity( + widget.colorController.text, + widget.opacityController.text, + ), + ) ?? + 0xFFFFFFFF, + ), + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + AppFlowyEditorL10n.current.customColor, + style: Theme.of(context).textTheme.labelLarge, + // same style as TextButton.icon + ), + ), + ], + ), + children: [ + const SizedBox(height: 6), + _customColorDetailsTextField( + labelText: AppFlowyEditorL10n.current.hexValue, + controller: widget.colorController, + // update the color sample box when the text changes + onChanged: (_) => setState(() {}), + onSubmitted: _submitCustomColorHex, + ), + const SizedBox(height: 10), + _customColorDetailsTextField( + labelText: AppFlowyEditorL10n.current.opacity, + controller: widget.opacityController, + // update the color sample box when the text changes + onChanged: (_) => setState(() {}), + onSubmitted: _submitCustomColorHex, + ), + const SizedBox(height: 6), + ], + ); + } + + Widget _customColorDetailsTextField({ + required String labelText, + required TextEditingController controller, + Function(String)? onChanged, + Function(String)? onSubmitted, + }) { + return Padding( + padding: const EdgeInsets.only(right: 3), + child: TextField( + controller: controller, + decoration: InputDecoration( + labelText: labelText, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + ), + style: Theme.of(context).textTheme.bodyMedium, + onChanged: onChanged, + onSubmitted: onSubmitted, + ), + ); + } + + String _combineColorHexAndOpacity(String colorHex, String opacity) { + colorHex = _fixColorHex(colorHex); + opacity = _fixOpacity(opacity); + final opacityHex = (int.parse(opacity) * 2.55).round().toRadixString(16); + return '0x$opacityHex$colorHex'; + } + + String _fixColorHex(String colorHex) { + if (colorHex.length > 6) { + colorHex = colorHex.substring(0, 6); + } + if (int.tryParse(colorHex, radix: 16) == null) { + colorHex = 'FFFFFF'; + } + return colorHex; + } + + String _fixOpacity(String opacity) { + // if opacity is 0 - 99, return it + // otherwise return 100 + final RegExp regex = RegExp('^(0|[1-9][0-9]?)'); + if (regex.hasMatch(opacity)) { + return opacity; + } else { + return '100'; + } + } + + void _submitCustomColorHex(String value) { + final String color = _combineColorHexAndOpacity( + widget.colorController.text, + widget.opacityController.text, + ); + widget.onSubmittedColorHex(color); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart new file mode 100644 index 0000000000..03fc12a37c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart @@ -0,0 +1,132 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'toolbar_animation.dart'; + +class DesktopFloatingToolbar extends StatefulWidget { + const DesktopFloatingToolbar({ + super.key, + required this.editorState, + required this.child, + required this.onDismiss, + this.enableAnimation = true, + }); + + final EditorState editorState; + final Widget child; + final VoidCallback onDismiss; + final bool enableAnimation; + + @override + State createState() => _DesktopFloatingToolbarState(); +} + +class _DesktopFloatingToolbarState extends State { + EditorState get editorState => widget.editorState; + + _Position? position; + final toolbarController = getIt(); + + @override + void initState() { + super.initState(); + final selection = editorState.selection; + if (selection == null || selection.isCollapsed) { + return; + } + final selectionRect = editorState.selectionRects(); + if (selectionRect.isEmpty) return; + position = calculateSelectionMenuOffset(selectionRect.first); + toolbarController._addCallback(dismiss); + } + + @override + void dispose() { + toolbarController._removeCallback(dismiss); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (position == null) return Container(); + return Positioned( + left: position!.left, + top: position!.top, + right: position!.right, + child: widget.enableAnimation + ? ToolbarAnimationWidget(child: widget.child) + : widget.child, + ); + } + + void dismiss() { + widget.onDismiss.call(); + } + + _Position calculateSelectionMenuOffset( + Rect rect, + ) { + const toolbarHeight = 40, topLimit = toolbarHeight + 8; + final bool isLongMenu = onlyShowInSingleSelectionAndTextType(editorState); + final editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorSize = editorState.renderBox?.size ?? Size.zero; + final menuWidth = + isLongMenu ? (isNarrowWindow(editorState) ? 490.0 : 660.0) : 420.0; + final editorRect = editorOffset & editorSize; + final left = rect.left, leftStart = 50; + final top = + rect.top < topLimit ? rect.bottom + topLimit : rect.top - topLimit; + if (left + menuWidth > editorRect.right) { + return _Position( + editorRect.right - menuWidth, + top, + null, + ); + } else if (rect.left - leftStart > 0) { + return _Position(rect.left - leftStart, top, null); + } else { + return _Position(rect.left, top, null); + } + } +} + +class _Position { + _Position(this.left, this.top, this.right); + + final double? left; + final double? top; + final double? right; +} + +class FloatingToolbarController { + final Set _dismissCallbacks = {}; + final Set _displayListeners = {}; + + void _addCallback(VoidCallback callback) { + _dismissCallbacks.add(callback); + for (final listener in Set.of(_displayListeners)) { + listener.call(); + } + } + + void _removeCallback(VoidCallback callback) => + _dismissCallbacks.remove(callback); + + bool get isToolbarShowing => _dismissCallbacks.isNotEmpty; + + void addDisplayListener(VoidCallback listener) => + _displayListeners.add(listener); + + void removeDisplayListener(VoidCallback listener) => + _displayListeners.remove(listener); + + void hideToolbar() { + if (_dismissCallbacks.isEmpty) return; + for (final callback in _dismissCallbacks) { + callback.call(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart new file mode 100644 index 0000000000..002d569c7b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart @@ -0,0 +1,320 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.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 'link_search_text_field.dart'; + +class LinkCreateMenu extends StatefulWidget { + const LinkCreateMenu({ + super.key, + required this.editorState, + required this.onSubmitted, + required this.onDismiss, + required this.alignment, + required this.currentViewId, + required this.initialText, + }); + + final EditorState editorState; + final void Function(String link, bool isPage) onSubmitted; + final VoidCallback onDismiss; + final String currentViewId; + final String initialText; + final LinkMenuAlignment alignment; + + @override + State createState() => _LinkCreateMenuState(); +} + +class _LinkCreateMenuState extends State { + late LinkSearchTextField searchTextField = LinkSearchTextField( + currentViewId: widget.currentViewId, + initialSearchText: widget.initialText, + onEnter: () { + searchTextField.onSearchResult( + onLink: () => onSubmittedLink(), + onRecentViews: () => + onSubmittedPageLink(searchTextField.currentRecentView), + onSearchViews: () => + onSubmittedPageLink(searchTextField.currentSearchedView), + onEmpty: () {}, + ); + }, + onEscape: widget.onDismiss, + onDataRefresh: () { + if (mounted) setState(() {}); + }, + ); + + bool get isTextfieldEnable => searchTextField.isTextfieldEnable; + + String get searchText => searchTextField.searchText; + + bool get showAtTop => widget.alignment.isTop; + + bool showErrorText = false; + + @override + void initState() { + super.initState(); + searchTextField.requestFocus(); + searchTextField.searchRecentViews(); + final focusNode = searchTextField.focusNode; + bool hasFocus = focusNode.hasFocus; + focusNode.addListener(() { + if (hasFocus != focusNode.hasFocus && mounted) { + setState(() { + hasFocus = focusNode.hasFocus; + }); + } + }); + } + + @override + void dispose() { + searchTextField.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 320, + child: Column( + children: showAtTop + ? [ + searchTextField.buildResultContainer( + margin: EdgeInsets.only(bottom: 2), + context: context, + onLinkSelected: onSubmittedLink, + onPageLinkSelected: onSubmittedPageLink, + ), + buildSearchContainer(), + ] + : [ + buildSearchContainer(), + searchTextField.buildResultContainer( + margin: EdgeInsets.only(top: 2), + context: context, + onLinkSelected: onSubmittedLink, + onPageLinkSelected: onSubmittedPageLink, + ), + ], + ), + ); + } + + Widget buildSearchContainer() { + return Container( + width: 320, + decoration: buildToolbarLinkDecoration(context), + padding: EdgeInsets.all(8), + child: ValueListenableBuilder( + valueListenable: searchTextField.textEditingController, + builder: (context, _, __) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: searchTextField.buildTextField(context: context), + ), + HSpace(8), + FlowyTextButton( + LocaleKeys.document_toolbar_insert.tr(), + mainAxisAlignment: MainAxisAlignment.center, + padding: EdgeInsets.zero, + constraints: BoxConstraints(maxWidth: 72, minHeight: 32), + fontSize: 14, + fontColor: Colors.white, + fillColor: LinkStyle.fillThemeThick, + hoverColor: LinkStyle.fillThemeThick.withAlpha(200), + lineHeight: 20 / 14, + fontWeight: FontWeight.w600, + onPressed: onSubmittedLink, + ), + ], + ), + if (showErrorText) + Padding( + padding: const EdgeInsets.only(top: 4), + child: FlowyText.regular( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: LinkStyle.textStatusError, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + ], + ); + }, + ), + ); + } + + void onSubmittedLink() { + if (!isTextfieldEnable) { + setState(() { + showErrorText = true; + }); + return; + } + widget.onSubmitted(searchText, false); + } + + void onSubmittedPageLink(ViewPB view) async { + final workspaceId = context + .read() + ?.state + .currentWorkspace + ?.workspaceId ?? + ''; + final link = ShareConstants.buildShareUrl( + workspaceId: workspaceId, + viewId: view.id, + ); + widget.onSubmitted(link, true); + } +} + +void showLinkCreateMenu( + BuildContext context, + EditorState editorState, + Selection selection, + String currentViewId, +) { + if (!context.mounted) return; + final (left, top, right, bottom, alignment) = _getPosition(editorState); + + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final selectedText = editorState.getTextInSelection(selection).join(); + + OverlayEntry? overlay; + + void dismissOverlay() { + keepEditorFocusNotifier.decrease(); + overlay?.remove(); + overlay = null; + } + + keepEditorFocusNotifier.increase(); + overlay = FullScreenOverlayEntry( + top: top, + bottom: bottom, + left: left, + right: right, + dismissCallback: () => keepEditorFocusNotifier.decrease(), + builder: (context) { + return LinkCreateMenu( + alignment: alignment, + initialText: selectedText, + currentViewId: currentViewId, + editorState: editorState, + onSubmitted: (link, isPage) async { + await editorState.formatDelta(selection, { + BuiltInAttributeKey.href: link, + kIsPageLink: isPage, + }); + await editorState.updateSelectionWithReason( + null, + reason: SelectionUpdateReason.uiEvent, + ); + dismissOverlay(); + }, + onDismiss: dismissOverlay, + ); + }, + ).build(); + + Overlay.of(context, rootOverlay: true).insert(overlay!); +} + +// get a proper position for link menu +( + double? left, + double? top, + double? right, + double? bottom, + LinkMenuAlignment alignment, +) _getPosition( + EditorState editorState, +) { + final rect = editorState.selectionRects().first; + const menuHeight = 222.0, menuWidth = 320.0; + + double? left, right, top, bottom; + LinkMenuAlignment alignment = LinkMenuAlignment.topLeft; + final editorOffset = editorState.renderBox!.localToGlobal(Offset.zero), + editorSize = editorState.renderBox!.size; + final editorBottom = editorSize.height + editorOffset.dy, + editorRight = editorSize.width + editorOffset.dx; + final overflowBottom = rect.bottom + menuHeight > editorBottom, + overflowTop = rect.top - menuHeight < 0, + overflowLeft = rect.left - menuWidth < 0, + overflowRight = rect.right + menuWidth > editorRight; + + if (overflowTop && !overflowBottom) { + /// show at bottom + top = rect.bottom; + } else if (overflowBottom && !overflowTop) { + /// show at top + bottom = editorBottom - rect.top; + } else if (!overflowTop && !overflowBottom) { + /// show at bottom + top = rect.bottom; + } else { + top = 0; + } + + if (overflowLeft && !overflowRight) { + /// show at right + left = rect.left; + } else if (overflowRight && !overflowLeft) { + /// show at left + right = editorRight - rect.right; + } else if (!overflowLeft && !overflowRight) { + /// show at right + left = rect.left; + } else { + left = 0; + } + + if (left != null && top != null) { + alignment = LinkMenuAlignment.bottomRight; + } else if (left != null && bottom != null) { + alignment = LinkMenuAlignment.topRight; + } else if (right != null && top != null) { + alignment = LinkMenuAlignment.bottomLeft; + } else if (right != null && bottom != null) { + alignment = LinkMenuAlignment.topLeft; + } + + return (left, top, right, bottom, alignment); +} + +ShapeDecoration buildToolbarLinkDecoration( + BuildContext context, { + double radius = 12.0, +}) { + final theme = AppFlowyTheme.of(context); + return ShapeDecoration( + color: theme.surfaceColorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + ), + shadows: theme.shadow.small, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart new file mode 100644 index 0000000000..e90ee22a80 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart @@ -0,0 +1,516 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/util/theme_extension.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: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'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/util/link_util.dart'; +import 'package:flutter/services.dart'; +import 'link_create_menu.dart'; +import 'link_search_text_field.dart'; +import 'link_styles.dart'; + +class LinkEditMenu extends StatefulWidget { + const LinkEditMenu({ + super.key, + required this.linkInfo, + required this.onDismiss, + required this.onApply, + required this.onRemoveLink, + required this.currentViewId, + }); + + final LinkInfo linkInfo; + final ValueChanged onApply; + final ValueChanged onRemoveLink; + final VoidCallback onDismiss; + final String currentViewId; + + @override + State createState() => _LinkEditMenuState(); +} + +class _LinkEditMenuState extends State { + ValueChanged get onRemoveLink => widget.onRemoveLink; + + VoidCallback get onDismiss => widget.onDismiss; + + late TextEditingController linkNameController = + TextEditingController(text: linkInfo.name); + late FocusNode textFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent); + late FocusNode menuFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent); + late LinkInfo linkInfo = widget.linkInfo; + late LinkSearchTextField searchTextField; + bool isShowingSearchResult = false; + ViewPB? currentView; + bool showErrorText = false; + + @override + void initState() { + super.initState(); + final isPageLink = linkInfo.isPage; + if (isPageLink) getPageView(); + searchTextField = LinkSearchTextField( + initialSearchText: isPageLink ? '' : linkInfo.link, + initialViewId: linkInfo.viewId, + currentViewId: widget.currentViewId, + onEnter: onConfirm, + onEscape: () { + if (isShowingSearchResult) { + hideSearchResult(); + } else { + onDismiss(); + } + }, + onDataRefresh: () { + if (mounted) setState(() {}); + }, + )..searchRecentViews(); + makeSureHasFocus(); + } + + @override + void dispose() { + linkNameController.dispose(); + textFocusNode.dispose(); + menuFocusNode.dispose(); + searchTextField.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final showingRecent = + searchTextField.showingRecent && isShowingSearchResult; + final errorHeight = showErrorText ? 20.0 : 0.0; + return GestureDetector( + onTap: onDismiss, + child: Focus( + focusNode: menuFocusNode, + child: Container( + width: 400, + height: 250 + (showingRecent ? 32 : 0), + color: Colors.white.withAlpha(1), + child: Stack( + children: [ + GestureDetector( + onTap: hideSearchResult, + child: Container( + width: 400, + height: 192 + errorHeight, + decoration: buildToolbarLinkDecoration(context), + ), + ), + Positioned( + top: 16, + left: 20, + child: FlowyText.semibold( + LocaleKeys.document_toolbar_pageOrURL.tr(), + color: LinkStyle.textTertiary, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + Positioned( + top: 80 + errorHeight, + left: 20, + child: FlowyText.semibold( + LocaleKeys.document_toolbar_linkName.tr(), + color: LinkStyle.textTertiary, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + Positioned( + top: 144 + errorHeight, + left: 20, + child: buildButtons(), + ), + Positioned( + top: 100 + errorHeight, + left: 20, + child: buildNameTextField(), + ), + Positioned( + top: 36, + left: 20, + child: buildLinkField(), + ), + ], + ), + ), + ), + ); + } + + Widget buildLinkField() { + final showPageView = linkInfo.isPage && !isShowingSearchResult; + Widget child; + if (showPageView) { + child = buildPageView(); + } else if (!isShowingSearchResult) { + child = buildLinkView(); + } else { + return SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 360, + height: 32, + child: searchTextField.buildTextField( + autofocus: true, + context: context, + ), + ), + VSpace(6), + searchTextField.buildResultContainer( + context: context, + width: 360, + onPageLinkSelected: onPageSelected, + onLinkSelected: onLinkSelected, + ), + ], + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + child, + if (showErrorText) + Padding( + padding: const EdgeInsets.only(top: 4), + child: FlowyText.regular( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: LinkStyle.textStatusError, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + ], + ); + } + + Widget buildButtons() { + return GestureDetector( + onTap: hideSearchResult, + child: SizedBox( + width: 360, + height: 32, + child: Row( + children: [ + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m), + width: 32, + height: 32, + tooltipText: LocaleKeys.editor_removeLink.tr(), + preferBelow: false, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + border: Border.all(color: LinkStyle.borderColor(context)), + ), + onPressed: () => onRemoveLink.call(linkInfo), + ), + Spacer(), + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + border: Border.all(color: LinkStyle.borderColor(context)), + ), + child: FlowyTextButton( + LocaleKeys.button_cancel.tr(), + padding: EdgeInsets.zero, + mainAxisAlignment: MainAxisAlignment.center, + constraints: BoxConstraints(maxWidth: 78, minHeight: 32), + fontSize: 14, + lineHeight: 20 / 14, + fontColor: Theme.of(context).isLightMode + ? LinkStyle.textPrimary + : Theme.of(context).iconTheme.color, + fillColor: Colors.transparent, + fontWeight: FontWeight.w400, + onPressed: onDismiss, + ), + ), + HSpace(12), + ValueListenableBuilder( + valueListenable: linkNameController, + builder: (context, _, __) { + return FlowyTextButton( + LocaleKeys.settings_appearance_documentSettings_apply.tr(), + padding: EdgeInsets.zero, + mainAxisAlignment: MainAxisAlignment.center, + constraints: BoxConstraints(maxWidth: 78, minHeight: 32), + fontSize: 14, + lineHeight: 20 / 14, + hoverColor: LinkStyle.fillThemeThick.withAlpha(200), + fontColor: Colors.white, + fillColor: LinkStyle.fillThemeThick, + fontWeight: FontWeight.w400, + onPressed: onApply, + ); + }, + ), + ], + ), + ), + ); + } + + Widget buildNameTextField() { + return SizedBox( + width: 360, + height: 32, + child: TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + focusNode: textFocusNode, + autofocus: true, + textAlign: TextAlign.left, + controller: linkNameController, + style: TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + onChanged: (text) { + linkInfo = LinkInfo( + name: text, + link: linkInfo.link, + isPage: linkInfo.isPage, + ); + }, + decoration: LinkStyle.buildLinkTextFieldInputDecoration( + LocaleKeys.document_toolbar_linkNameHint.tr(), + context, + ), + ), + ); + } + + Widget buildPageView() { + late Widget child; + final view = currentView; + if (view == null) { + child = Center( + child: SizedBox.fromSize( + size: Size(10, 10), + child: CircularProgressIndicator(), + ), + ); + } else { + final viewName = view.name; + final displayName = viewName.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : viewName; + child = GestureDetector( + onTap: showSearchResult, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyTooltip( + preferBelow: false, + message: displayName, + child: Container( + height: 32, + padding: EdgeInsets.fromLTRB(8, 0, 8, 0), + child: Row( + children: [ + searchTextField.buildIcon(view), + HSpace(4), + Flexible( + child: FlowyText.regular( + displayName, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ), + ); + } + return Container( + width: 360, + height: 32, + decoration: buildDecoration(), + child: child, + ); + } + + Widget buildLinkView() { + return Container( + width: 360, + height: 32, + decoration: buildDecoration(), + child: FlowyTooltip( + preferBelow: false, + message: linkInfo.link, + child: GestureDetector( + onTap: showSearchResult, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + children: [ + FlowySvg(FlowySvgs.toolbar_link_earth_m), + HSpace(8), + Flexible( + child: FlowyText.regular( + linkInfo.link, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + KeyEventResult onFocusKeyEvent(FocusNode node, KeyEvent key) { + if (key is! KeyDownEvent) return KeyEventResult.ignored; + if (key.logicalKey == LogicalKeyboardKey.enter) { + onApply(); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.escape) { + onDismiss(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + Future makeSureHasFocus() async { + final focusNode = textFocusNode; + if (!mounted || focusNode.hasFocus) return; + focusNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + makeSureHasFocus(); + }); + } + + void onApply() { + if (isShowingSearchResult) { + onConfirm(); + return; + } + if (linkInfo.link.isEmpty) { + widget.onRemoveLink(linkInfo); + return; + } + if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) { + setState(() { + showErrorText = true; + }); + return; + } + widget.onApply.call(linkInfo); + } + + void onConfirm() { + searchTextField.onSearchResult( + onLink: onLinkSelected, + onRecentViews: () => onPageSelected(searchTextField.currentRecentView), + onSearchViews: () => onPageSelected(searchTextField.currentSearchedView), + onEmpty: () { + searchTextField.unfocus(); + }, + ); + menuFocusNode.requestFocus(); + } + + Future getPageView() async { + if (!linkInfo.isPage) return; + final (view, isInTrash, isDeleted) = + await ViewBackendService.getMentionPageStatus(linkInfo.viewId); + if (mounted) { + setState(() { + currentView = view; + }); + } + } + + void showSearchResult() { + setState(() { + if (linkInfo.isPage) searchTextField.updateText(''); + isShowingSearchResult = true; + searchTextField.requestFocus(); + }); + } + + void hideSearchResult() { + setState(() { + isShowingSearchResult = false; + searchTextField.unfocus(); + textFocusNode.unfocus(); + }); + } + + void onLinkSelected() { + if (mounted) { + linkInfo = LinkInfo( + name: linkInfo.name, + link: searchTextField.searchText, + ); + hideSearchResult(); + } + } + + Future onPageSelected(ViewPB view) async { + currentView = view; + final link = ShareConstants.buildShareUrl( + workspaceId: await UserBackendService.getCurrentWorkspace().fold( + (s) => s.id, + (f) => '', + ), + viewId: view.id, + ); + linkInfo = LinkInfo( + name: linkInfo.name, + link: link, + isPage: true, + ); + searchTextField.updateText(linkInfo.link); + if (mounted) { + setState(() { + isShowingSearchResult = false; + searchTextField.unfocus(); + }); + } + } + + BoxDecoration buildDecoration() => BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: LinkStyle.borderColor(context)), + ); +} + +class LinkInfo { + LinkInfo({this.isPage = false, required this.name, required this.link}); + + final bool isPage; + final String name; + final String link; + + Attributes toAttribute() => + {AppFlowyRichTextKeys.href: link, kIsPageLink: isPage}; + + String get viewId => isPage ? link.split('/').lastOrNull ?? '' : ''; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart new file mode 100644 index 0000000000..c992e40c61 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart @@ -0,0 +1,635 @@ +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/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.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/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/view/view_service.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: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:flutter_bloc/flutter_bloc.dart'; + +import 'link_create_menu.dart'; +import 'link_edit_menu.dart'; + +class LinkHoverTrigger extends StatefulWidget { + const LinkHoverTrigger({ + super.key, + required this.editorState, + required this.selection, + required this.node, + required this.attribute, + required this.size, + this.delayToShow = const Duration(milliseconds: 50), + this.delayToHide = const Duration(milliseconds: 300), + }); + + final EditorState editorState; + final Selection selection; + final Node node; + final Attributes attribute; + final Size size; + final Duration delayToShow; + final Duration delayToHide; + + @override + State createState() => _LinkHoverTriggerState(); +} + +class _LinkHoverTriggerState extends State { + final hoverMenuController = PopoverController(); + final editMenuController = PopoverController(); + final toolbarController = getIt(); + bool isHoverMenuShowing = false; + bool isHoverMenuHovering = false; + bool isHoverTriggerHovering = false; + + Size get size => widget.size; + + EditorState get editorState => widget.editorState; + + Selection get selection => widget.selection; + + Attributes get attribute => widget.attribute; + + late HoverTriggerKey triggerKey = HoverTriggerKey(widget.node.id, selection); + + @override + void initState() { + super.initState(); + getIt()._add(triggerKey, showLinkHoverMenu); + toolbarController.addDisplayListener(onToolbarShow); + } + + @override + void dispose() { + hoverMenuController.close(); + editMenuController.close(); + getIt()._remove(triggerKey, showLinkHoverMenu); + toolbarController.removeDisplayListener(onToolbarShow); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (v) { + isHoverTriggerHovering = true; + Future.delayed(widget.delayToShow, () { + if (isHoverTriggerHovering && !isHoverMenuShowing) { + showLinkHoverMenu(); + } + }); + }, + onExit: (v) { + isHoverTriggerHovering = false; + tryToDismissLinkHoverMenu(); + }, + child: buildHoverPopover( + buildEditPopover( + Container( + color: Colors.black.withAlpha(1), + width: size.width, + height: size.height, + ), + ), + ), + ); + } + + Widget buildHoverPopover(Widget child) { + return AppFlowyPopover( + controller: hoverMenuController, + direction: PopoverDirection.topWithLeftAligned, + offset: Offset(0, size.height), + onOpen: () { + keepEditorFocusNotifier.increase(); + isHoverMenuShowing = true; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + isHoverMenuShowing = false; + }, + margin: EdgeInsets.zero, + constraints: BoxConstraints( + maxWidth: max(320, size.width), + maxHeight: 48 + size.height, + ), + decorationColor: Colors.transparent, + popoverDecoration: BoxDecoration(), + popupBuilder: (context) => LinkHoverMenu( + attribute: widget.attribute, + triggerSize: size, + onEnter: (_) { + isHoverMenuHovering = true; + }, + onExit: (_) { + isHoverMenuHovering = false; + tryToDismissLinkHoverMenu(); + }, + onConvertTo: (type) => convertLinkTo(editorState, selection, type), + onOpenLink: openLink, + onCopyLink: () => copyLink(context), + onEditLink: showLinkEditMenu, + onRemoveLink: () => removeLink(editorState, selection), + ), + child: child, + ); + } + + Widget buildEditPopover(Widget child) { + final href = attribute.href ?? '', + isPage = attribute.isPage, + title = editorState.getTextInSelection(selection).join(); + final currentViewId = context.read()?.documentId ?? ''; + return AppFlowyPopover( + controller: editMenuController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: Offset(0, 0), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + margin: EdgeInsets.zero, + asBarrier: true, + decorationColor: Colors.transparent, + popoverDecoration: BoxDecoration(), + constraints: BoxConstraints( + maxWidth: 400, + minHeight: 282, + ), + popupBuilder: (context) => LinkEditMenu( + currentViewId: currentViewId, + linkInfo: LinkInfo(name: title, link: href, isPage: isPage), + onDismiss: () => editMenuController.close(), + onApply: (info) async { + final transaction = editorState.transaction; + transaction.replaceText( + widget.node, + selection.startIndex, + selection.length, + info.name, + attributes: info.toAttribute(), + ); + editMenuController.close(); + await editorState.apply(transaction); + }, + onRemoveLink: (linkinfo) => + onRemoveAndReplaceLink(editorState, selection, linkinfo.name), + ), + child: child, + ); + } + + void onToolbarShow() => hoverMenuController.close(); + + void showLinkHoverMenu() { + if (isHoverMenuShowing || toolbarController.isToolbarShowing || !mounted) { + return; + } + keepEditorFocusNotifier.increase(); + hoverMenuController.show(); + } + + void showLinkEditMenu() { + keepEditorFocusNotifier.increase(); + hoverMenuController.close(); + editMenuController.show(); + } + + void tryToDismissLinkHoverMenu() { + Future.delayed(widget.delayToHide, () { + if (isHoverMenuHovering || isHoverTriggerHovering) { + return; + } + hoverMenuController.close(); + }); + } + + Future openLink() async { + final href = widget.attribute.href ?? '', isPage = widget.attribute.isPage; + + if (isPage) { + final viewId = href.split('/').lastOrNull ?? ''; + if (viewId.isEmpty) { + await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true); + } else { + final (view, isInTrash, isDeleted) = + await ViewBackendService.getMentionPageStatus(viewId); + if (view != null) { + await handleMentionBlockTap(context, widget.editorState, view); + } + } + } else { + await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true); + } + } + + Future copyLink(BuildContext context) async { + final href = widget.attribute.href ?? ''; + await context.copyLink(href); + hoverMenuController.close(); + } + + void removeLink( + EditorState editorState, + Selection selection, + ) { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final index = selection.normalized.startIndex; + final length = selection.length; + final transaction = editorState.transaction + ..formatText( + node, + index, + length, + { + BuiltInAttributeKey.href: null, + kIsPageLink: null, + }, + ); + editorState.apply(transaction); + } + + Future convertLinkTo( + EditorState editorState, + Selection selection, + LinkConvertMenuCommand type, + ) async { + final url = widget.attribute.href ?? ''; + if (type == LinkConvertMenuCommand.toBookmark) { + await convertUrlToLinkPreview(editorState, selection, url); + } else if (type == LinkConvertMenuCommand.toMention) { + await convertUrlToMention(editorState, selection); + } else if (type == LinkConvertMenuCommand.toEmbed) { + await convertUrlToLinkPreview( + editorState, + selection, + url, + previewType: LinkEmbedKeys.embed, + ); + } + } + + void onRemoveAndReplaceLink( + EditorState editorState, + Selection selection, + String text, + ) { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final index = selection.normalized.startIndex; + final length = selection.length; + final transaction = editorState.transaction + ..replaceText( + node, + index, + length, + text, + attributes: { + BuiltInAttributeKey.href: null, + kIsPageLink: null, + }, + ); + editorState.apply(transaction); + } +} + +class LinkHoverMenu extends StatefulWidget { + const LinkHoverMenu({ + super.key, + required this.attribute, + required this.onEnter, + required this.onExit, + required this.triggerSize, + required this.onCopyLink, + required this.onOpenLink, + required this.onEditLink, + required this.onRemoveLink, + required this.onConvertTo, + }); + + final Attributes attribute; + final PointerEnterEventListener onEnter; + final PointerExitEventListener onExit; + final Size triggerSize; + final VoidCallback onCopyLink; + final VoidCallback onOpenLink; + final VoidCallback onEditLink; + final VoidCallback onRemoveLink; + final ValueChanged onConvertTo; + + @override + State createState() => _LinkHoverMenuState(); +} + +class _LinkHoverMenuState extends State { + ViewPB? currentView; + late bool isPage = widget.attribute.isPage; + late String href = widget.attribute.href ?? ''; + final popoverController = PopoverController(); + bool isConvertButtonSelected = false; + + @override + void initState() { + super.initState(); + if (isPage) getPageView(); + } + + @override + void dispose() { + super.dispose(); + popoverController.close(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: SizedBox( + width: max(320, widget.triggerSize.width), + height: 48, + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: 320, + height: 48, + decoration: buildToolbarLinkDecoration(context), + padding: EdgeInsets.fromLTRB(12, 8, 8, 8), + child: Row( + children: [ + Expanded(child: buildLinkWidget()), + Container( + height: 20, + width: 1, + color: Color(0xffE8ECF3) + .withAlpha(Theme.of(context).isLightMode ? 255 : 40), + margin: EdgeInsets.symmetric(horizontal: 6), + ), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_m), + tooltipText: LocaleKeys.editor_copyLink.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: widget.onCopyLink, + ), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_edit_m), + tooltipText: LocaleKeys.editor_editLink.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: widget.onEditLink, + ), + buildConvertButton(), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m), + tooltipText: LocaleKeys.editor_removeLink.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: widget.onRemoveLink, + ), + ], + ), + ), + ), + ), + ), + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: widget.onEnter, + onExit: widget.onExit, + child: GestureDetector( + onTap: widget.onOpenLink, + child: Container( + width: widget.triggerSize.width, + height: widget.triggerSize.height, + color: Colors.black.withAlpha(1), + ), + ), + ), + ], + ); + } + + Future getPageView() async { + final viewId = href.split('/').lastOrNull ?? ''; + final (view, isInTrash, isDeleted) = + await ViewBackendService.getMentionPageStatus(viewId); + if (mounted) { + setState(() { + currentView = view; + }); + } + } + + Widget buildLinkWidget() { + final view = currentView; + if (isPage && view == null) { + return SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ); + } + String text = ''; + if (isPage && view != null) { + text = view.name; + if (text.isEmpty) { + text = LocaleKeys.document_title_placeholder.tr(); + } + } else { + text = href; + } + return FlowyTooltip( + message: text, + preferBelow: false, + child: FlowyText.regular( + text, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + fontSize: 14, + ), + ); + } + + Widget buildConvertButton() { + return AppFlowyPopover( + offset: Offset(44, 10.0), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: popoverController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + popupBuilder: (context) => buildConvertMenu(), + child: FlowyIconButton( + icon: FlowySvg(FlowySvgs.turninto_m), + isSelected: isConvertButtonSelected, + tooltipText: LocaleKeys.editor_convertTo.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: () { + setState(() { + isConvertButtonSelected = true; + }); + showConvertMenu(); + }, + ), + ); + } + + Widget buildConvertMenu() { + return MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: + List.generate(LinkConvertMenuCommand.values.length, (index) { + final command = LinkConvertMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () { + widget.onConvertTo(command); + closeConvertMenu(); + }, + ), + ); + }), + ), + ), + ); + } + + void showConvertMenu() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + void closeConvertMenu() { + popoverController.close(); + } +} + +class HoverTriggerKey { + HoverTriggerKey(this.nodeId, this.selection); + + final String nodeId; + final Selection selection; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HoverTriggerKey && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + isSelectionSame(other.selection); + + bool isSelectionSame(Selection other) => + (selection.start == other.start && selection.end == other.end) || + (selection.start == other.end && selection.end == other.start); + + @override + int get hashCode => nodeId.hashCode ^ selection.hashCode; +} + +class LinkHoverTriggers { + final Map> _map = {}; + + void _add(HoverTriggerKey key, VoidCallback callback) { + final callbacks = _map[key] ?? {}; + callbacks.add(callback); + _map[key] = callbacks; + } + + void _remove(HoverTriggerKey key, VoidCallback callback) { + final callbacks = _map[key] ?? {}; + callbacks.remove(callback); + _map[key] = callbacks; + } + + void call(HoverTriggerKey key) { + final callbacks = _map[key] ?? {}; + if (callbacks.isEmpty) return; + callbacks.first.call(); + } +} + +enum LinkConvertMenuCommand { + toMention, + toBookmark, + toEmbed; + + String get title { + switch (this) { + case toMention: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + case toEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + } + } + + String get type { + switch (this) { + case toMention: + return MentionBlockKeys.type; + case toBookmark: + return LinkPreviewBlockKeys.type; + case toEmbed: + return LinkPreviewBlockKeys.type; + } + } +} + +extension LinkExtension on BuildContext { + Future copyLink(String link) async { + if (link.isEmpty) return; + await getIt() + .setData(ClipboardServiceData(plainText: link)); + if (mounted) { + showToastNotification( + message: LocaleKeys.shareAction_copyLinkSuccess.tr(), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart new file mode 100644 index 0000000000..d08442d779 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart @@ -0,0 +1,184 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.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'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/util/link_util.dart'; +import 'package:flutter/services.dart'; + +import 'link_create_menu.dart'; +import 'link_styles.dart'; + +void showReplaceMenu({ + required BuildContext context, + required EditorState editorState, + required Node node, + String? url, + required LTRB ltrb, + required ValueChanged onReplace, +}) { + OverlayEntry? overlay; + + void dismissOverlay() { + keepEditorFocusNotifier.decrease(); + overlay?.remove(); + overlay = null; + } + + keepEditorFocusNotifier.increase(); + overlay = FullScreenOverlayEntry( + top: ltrb.top, + bottom: ltrb.bottom, + left: ltrb.left, + right: ltrb.right, + dismissCallback: () => keepEditorFocusNotifier.decrease(), + builder: (context) { + return LinkReplaceMenu( + link: url ?? '', + onSubmitted: (link) async { + onReplace.call(link); + dismissOverlay(); + }, + onDismiss: dismissOverlay, + ); + }, + ).build(); + + Overlay.of(context, rootOverlay: true).insert(overlay!); +} + +class LinkReplaceMenu extends StatefulWidget { + const LinkReplaceMenu({ + super.key, + required this.onSubmitted, + required this.link, + required this.onDismiss, + }); + + final ValueChanged onSubmitted; + final VoidCallback onDismiss; + final String link; + + @override + State createState() => _LinkReplaceMenuState(); +} + +class _LinkReplaceMenuState extends State { + bool showErrorText = false; + late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent); + late TextEditingController textEditingController = + TextEditingController(text: widget.link); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + } + + @override + void dispose() { + focusNode.dispose(); + textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: 330, + padding: EdgeInsets.all(8), + decoration: buildToolbarLinkDecoration(context), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: buildLinkField()), + HSpace(8), + buildReplaceButton(), + ], + ), + ); + } + + Widget buildLinkField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 32, + child: TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + autofocus: true, + focusNode: focusNode, + textAlign: TextAlign.left, + controller: textEditingController, + style: TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + decoration: LinkStyle.buildLinkTextFieldInputDecoration( + LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_pasteHint + .tr(), + context, + showErrorBorder: showErrorText, + ), + ), + ), + if (showErrorText) + Padding( + padding: const EdgeInsets.only(top: 4), + child: FlowyText.regular( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: LinkStyle.textStatusError, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + ], + ); + } + + Widget buildReplaceButton() { + return FlowyTextButton( + LocaleKeys.button_replace.tr(), + padding: EdgeInsets.zero, + mainAxisAlignment: MainAxisAlignment.center, + constraints: BoxConstraints(maxWidth: 78, minHeight: 32), + fontSize: 14, + lineHeight: 20 / 14, + hoverColor: LinkStyle.fillThemeThick.withAlpha(200), + fontColor: Colors.white, + fillColor: LinkStyle.fillThemeThick, + fontWeight: FontWeight.w400, + onPressed: onSubmit, + ); + } + + void onSubmit() { + final link = textEditingController.text.trim(); + if (link.isEmpty || !isUri(link)) { + setState(() { + showErrorText = true; + }); + return; + } + widget.onSubmitted.call(link); + } + + KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) { + if (key is! KeyDownEvent) return KeyEventResult.ignored; + if (key.logicalKey == LogicalKeyboardKey.escape) { + widget.onDismiss.call(); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.enter) { + onSubmit(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart new file mode 100644 index 0000000000..97fd6abdad --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart @@ -0,0 +1,352 @@ +import 'dart:math'; + +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/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-folder/view.pb.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/util/link_util.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 'link_create_menu.dart'; +import 'link_styles.dart'; + +class LinkSearchTextField { + LinkSearchTextField({ + this.onEscape, + this.onEnter, + this.onDataRefresh, + this.initialViewId = '', + required this.currentViewId, + String? initialSearchText, + }) : textEditingController = TextEditingController( + text: isUri(initialSearchText ?? '') ? initialSearchText : '', + ); + + final TextEditingController textEditingController; + final String initialViewId; + final String currentViewId; + final ItemScrollController searchController = ItemScrollController(); + late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent); + final List searchedViews = []; + final List recentViews = []; + int selectedIndex = 0; + + final VoidCallback? onEscape; + final VoidCallback? onEnter; + final VoidCallback? onDataRefresh; + + String get searchText => textEditingController.text; + + bool get isTextfieldEnable => searchText.isNotEmpty && isUri(searchText); + + bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty; + + ViewPB get currentSearchedView => searchedViews[selectedIndex]; + + ViewPB get currentRecentView => recentViews[selectedIndex]; + + void dispose() { + textEditingController.dispose(); + focusNode.dispose(); + searchedViews.clear(); + recentViews.clear(); + } + + Widget buildTextField({ + bool autofocus = false, + bool showError = false, + required BuildContext context, + }) { + return TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + autofocus: autofocus, + focusNode: focusNode, + textAlign: TextAlign.left, + controller: textEditingController, + style: TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + onChanged: (text) { + if (text.isEmpty) { + searchedViews.clear(); + selectedIndex = 0; + onDataRefresh?.call(); + } else { + searchViews(text); + } + }, + decoration: LinkStyle.buildLinkTextFieldInputDecoration( + LocaleKeys.document_toolbar_linkInputHint.tr(), + context, + showErrorBorder: showError, + ), + ); + } + + Widget buildResultContainer({ + EdgeInsetsGeometry? margin, + required BuildContext context, + VoidCallback? onLinkSelected, + ValueChanged? onPageLinkSelected, + double width = 320.0, + }) { + return onSearchResult( + onEmpty: () => SizedBox.shrink(), + onLink: () => Container( + height: 48, + width: width, + padding: EdgeInsets.all(8), + margin: margin, + decoration: buildToolbarLinkDecoration(context), + child: FlowyButton( + leftIcon: FlowySvg(FlowySvgs.toolbar_link_earth_m), + isSelected: true, + text: FlowyText.regular( + searchText, + overflow: TextOverflow.ellipsis, + fontSize: 14, + figmaLineHeight: 20, + ), + onTap: onLinkSelected, + ), + ), + onRecentViews: () => Container( + width: width, + height: recentViews.length.clamp(1, 5) * 32.0 + 48, + margin: margin, + padding: EdgeInsets.all(8), + decoration: buildToolbarLinkDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 32, + padding: EdgeInsets.all(8), + child: FlowyText.semibold( + LocaleKeys.inlineActions_recentPages.tr(), + color: LinkStyle.textTertiary, + fontSize: 12, + figmaLineHeight: 16, + ), + ), + Flexible( + child: ListView.builder( + itemBuilder: (context, index) { + final currentView = recentViews[index]; + return buildPageItem( + currentView, + index == selectedIndex, + onPageLinkSelected, + ); + }, + itemCount: recentViews.length, + ), + ), + ], + ), + ), + onSearchViews: () => Container( + width: width, + height: searchedViews.length.clamp(1, 5) * 32.0 + 16, + margin: margin, + decoration: buildToolbarLinkDecoration(context), + child: ScrollablePositionedList.builder( + padding: EdgeInsets.all(8), + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + itemCount: searchedViews.length, + itemScrollController: searchController, + initialScrollIndex: max(0, selectedIndex), + itemBuilder: (context, index) { + final currentView = searchedViews[index]; + return buildPageItem( + currentView, + index == selectedIndex, + onPageLinkSelected, + ); + }, + ), + ), + ); + } + + Widget buildPageItem( + ViewPB view, + bool isSelected, + ValueChanged? onSubmittedPageLink, + ) { + final viewName = view.name; + final displayName = viewName.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : viewName; + final isCurrent = initialViewId == view.id; + return SizedBox( + height: 32, + child: FlowyButton( + isSelected: isSelected, + leftIcon: buildIcon(view, padding: EdgeInsets.zero), + text: FlowyText.regular( + displayName, + overflow: TextOverflow.ellipsis, + fontSize: 14, + figmaLineHeight: 20, + ), + rightIcon: isCurrent ? FlowySvg(FlowySvgs.toolbar_check_m) : null, + onTap: () => onSubmittedPageLink?.call(view), + ), + ); + } + + Widget buildIcon( + ViewPB view, { + EdgeInsetsGeometry padding = const EdgeInsets.only(top: 4), + }) { + if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20)); + final iconData = view.icon.toEmojiIconData(); + return Padding( + padding: padding, + child: RawEmojiIconWidget( + emoji: iconData, + emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20, + lineHeight: 1, + ), + ); + } + + void requestFocus() => focusNode.requestFocus(); + + void unfocus() => focusNode.unfocus(); + + void updateText(String text) => textEditingController.text = text; + + T onSearchResult({ + required ValueGetter onLink, + required ValueGetter onRecentViews, + required ValueGetter onSearchViews, + required ValueGetter onEmpty, + }) { + if (searchedViews.isEmpty && recentViews.isEmpty && searchText.isEmpty) { + return onEmpty.call(); + } + if (searchedViews.isEmpty && searchText.isNotEmpty) { + return onLink.call(); + } + if (searchedViews.isEmpty) return onRecentViews.call(); + return onSearchViews.call(); + } + + KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) { + if (key is! KeyDownEvent) return KeyEventResult.ignored; + int index = selectedIndex; + if (key.logicalKey == LogicalKeyboardKey.escape) { + onEscape?.call(); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.arrowUp) { + index = onSearchResult( + onLink: () => 0, + onRecentViews: () { + int result = index - 1; + if (result < 0) result = recentViews.length - 1; + return result; + }, + onSearchViews: () { + int result = index - 1; + if (result < 0) result = searchedViews.length - 1; + searchController.scrollTo( + index: result, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + ); + return result; + }, + onEmpty: () => 0, + ); + refreshIndex(index); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.arrowDown) { + index = onSearchResult( + onLink: () => 0, + onRecentViews: () { + int result = index + 1; + if (result >= recentViews.length) result = 0; + return result; + }, + onSearchViews: () { + int result = index + 1; + if (result >= searchedViews.length) result = 0; + searchController.scrollTo( + index: result, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + ); + return result; + }, + onEmpty: () => 0, + ); + refreshIndex(index); + return KeyEventResult.handled; + } else if (key.logicalKey == LogicalKeyboardKey.enter) { + onEnter?.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + Future searchRecentViews() async { + final recentService = getIt(); + final sectionViews = await recentService.recentViews(); + final views = sectionViews + .unique((e) => e.item.id) + .map((e) => e.item) + .where((e) => e.id != currentViewId) + .take(5) + .toList(); + recentViews.clear(); + recentViews.addAll(views); + selectedIndex = 0; + onDataRefresh?.call(); + } + + Future searchViews(String search) async { + final viewResult = await ViewBackendService.getAllViews(); + final allViews = viewResult + .toNullable() + ?.items + .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(10) + .toList(); + searchedViews.clear(); + searchedViews.addAll(allViews ?? []); + selectedIndex = 0; + onDataRefresh?.call(); + } + + void refreshIndex(int index) { + selectedIndex = index; + onDataRefresh?.call(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart new file mode 100644 index 0000000000..cabc00a312 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart @@ -0,0 +1,46 @@ +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flutter/material.dart'; + +class LinkStyle { + static const textTertiary = Color(0xFF99A1A8); + static const textStatusError = Color(0xffE71D32); + static const fillThemeThick = Color(0xFF00B5FF); + static const shadowMedium = Color(0x1F22251F); + static const textPrimary = Color(0xFF1F2329); + + static Color borderColor(BuildContext context) => + Theme.of(context).isLightMode ? Color(0xFFE8ECF3) : Color(0x64BDBDBD); + + static InputDecoration buildLinkTextFieldInputDecoration( + String hintText, + BuildContext context, { + bool showErrorBorder = false, + }) { + final border = OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: borderColor(context)), + ); + final enableBorder = border.copyWith( + borderSide: BorderSide( + color: showErrorBorder + ? LinkStyle.textStatusError + : LinkStyle.fillThemeThick, + ), + ); + const hintStyle = TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + color: LinkStyle.textTertiary, + ); + return InputDecoration( + hintText: hintText, + hintStyle: hintStyle, + contentPadding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + isDense: true, + border: border, + enabledBorder: border, + focusedBorder: enableBorder, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_animation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_animation.dart new file mode 100644 index 0000000000..7598a2b657 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_animation.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +class ToolbarAnimationWidget extends StatefulWidget { + const ToolbarAnimationWidget({ + super.key, + required this.child, + this.duration = const Duration(milliseconds: 150), + this.beginOpacity = 0.0, + this.endOpacity = 1.0, + this.beginScaleFactor = 0.95, + this.endScaleFactor = 1.0, + }); + + final Widget child; + final Duration duration; + final double beginScaleFactor; + final double endScaleFactor; + final double beginOpacity; + final double endOpacity; + + @override + State createState() => _ToolbarAnimationWidgetState(); +} + +class _ToolbarAnimationWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController controller; + late Animation fadeAnimation; + late Animation scaleAnimation; + + @override + void initState() { + super.initState(); + controller = AnimationController( + vsync: this, + duration: widget.duration, + ); + fadeAnimation = _buildFadeAnimation(); + scaleAnimation = _buildScaleAnimation(); + controller.forward(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (_, child) => Opacity( + opacity: fadeAnimation.value, + child: Transform.scale( + scale: scaleAnimation.value, + child: child, + ), + ), + child: widget.child, + ); + } + + Animation _buildFadeAnimation() { + return Tween( + begin: widget.beginOpacity, + end: widget.endOpacity, + ).animate( + CurvedAnimation( + parent: controller, + curve: Curves.easeInOut, + ), + ); + } + + Animation _buildScaleAnimation() { + return Tween( + begin: widget.beginScaleFactor, + end: widget.endScaleFactor, + ).animate( + CurvedAnimation( + parent: controller, + curve: Curves.easeInOut, + ), + ); + } +} 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 index 3fdf332eb3..6c09ca6a28 100644 --- 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 @@ -1,15 +1,17 @@ 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/home/toast.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({ @@ -28,11 +30,15 @@ class ErrorBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @override - bool validate(Node node) => true; + BlockComponentValidate get validate => (_) => true; } class ErrorBlockComponentWidget extends BlockComponentStatefulWidget { @@ -41,6 +47,7 @@ class ErrorBlockComponentWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @@ -59,33 +66,15 @@ class _ErrorBlockComponentWidgetState extends State @override Widget build(BuildContext context) { - Widget child = DecoratedBox( + Widget child = Container( + width: double.infinity, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, + color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), - child: FlowyButton( - onTap: () async { - showSnackBarMessage( - context, - LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(), - ); - await getIt().setData( - ClipboardServiceData(plainText: jsonEncode(node.toJson())), - ); - }, - text: SizedBox( - height: 52, - child: Row( - children: [ - const HSpace(4), - FlowyText( - LocaleKeys.document_errorBlock_theBlockIsNotSupported.tr(), - ), - ], - ), - ), - ), + child: UniversalPlatform.isDesktopOrWeb + ? _buildDesktopErrorBlock(context) + : _buildMobileErrorBlock(context), ); child = Padding( @@ -97,11 +86,12 @@ class _ErrorBlockComponentWidgetState extends State child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { child = MobileBlockActionButtons( node: node, editorState: context.read(), @@ -111,4 +101,64 @@ class _ErrorBlockComponentWidgetState extends State 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( + 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/file/file_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart new file mode 100644 index 0000000000..31ead6370c --- /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 0000000000..fe50224caa --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart @@ -0,0 +1,641 @@ +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_backend/protobuf/flowy-database2/file_entities.pbenum.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; + } + } + + FileUploadTypePB toFileUploadTypePB() { + switch (this) { + case FileUrlType.local: + return FileUploadTypePB.LocalFile; + case FileUrlType.network: + return FileUploadTypePB.NetworkFile; + case FileUrlType.cloud: + return FileUploadTypePB.CloudFile; + } + } +} + +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.actionTrailingBuilder, + 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!, + actionTrailingBuilder: widget.actionTrailingBuilder, + 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 0000000000..99529b3b8e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart @@ -0,0 +1,236 @@ +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/file/file_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.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_backend/protobuf/flowy-database2/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'; + +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]); + final fileUploadType = urlType.toFileUploadTypePB(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + HoverButton( + itemHeight: 20, + leftIcon: const FlowySvg(FlowySvgs.download_s), + name: LocaleKeys.button_download.tr(), + onTap: () { + final userProfile = widget.editorState.document.root.context + ?.read() + .state + .userProfilePB; + final url = widget.node.attributes[FileBlockKeys.url]; + final name = widget.node.attributes[FileBlockKeys.name]; + if (url != null && name != null) { + final filePB = MediaFilePB( + url: url, + name: name, + uploadType: fileUploadType, + ); + downloadMediaFile( + context, + filePB, + userProfile: userProfile, + ); + } + }, + ), + const VSpace(4), + 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 0000000000..132a7b8e7e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart @@ -0,0 +1,28 @@ +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 path = selection.end.path; + final node = getNodeAtPath(path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + final file = fileNode(url: '')..extraInfos = {'global_key': key}; + + final insertedPath = delta.isEmpty ? path : path.next; + final transaction = this.transaction + ..insertNode(insertedPath, file) + ..insertNode(insertedPath, paragraphNode()) + ..afterSelection = Selection.collapsed(Position(path: insertedPath.next)); + + 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 0000000000..4ef680d1b9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart @@ -0,0 +1,324 @@ +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.withValues(alpha: 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.withValues(alpha: 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 0000000000..8e6651ff73 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -0,0 +1,261 @@ +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/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.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) { + showToastNotification( + message: LocaleKeys.grid_media_downloadFailedToken.tr(), + ); + return; + } + + 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( + type: ToastificationType.error, + message: LocaleKeys.grid_media_downloadSuccess.tr(), + ); + } + } else if (context.mounted) { + showToastNotification( + 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( + message: LocaleKeys.grid_media_downloadSuccess.tr(), + ); + } + } else if (context.mounted) { + showToastNotification( + 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?.workspaceAuthType ?? AuthTypePB.Local) == AuthTypePB.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?.workspaceAuthType ?? AuthTypePB.Local) == AuthTypePB.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 0000000000..f4c7a76c0e --- /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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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 index ce756b9ffd..2d65c602f5 100644 --- 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 @@ -2,60 +2,102 @@ 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' hide WidgetBuilder; -import 'package:flowy_infra_ui/style_widget/text_input.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 { - bool showReplaceMenu = false; + 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 Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: FindMenu( - onDismiss: widget.onDismiss, - editorState: widget.editorState, - searchService: searchService, - onShowReplace: (value) => setState( - () => showReplaceMenu = value, - ), + return Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + }, + child: Actions( + actions: { + DismissIntent: CallbackAction( + onInvoke: (t) => widget.onDismiss.call(), ), - ), - showReplaceMenu - ? Padding( - padding: const EdgeInsets.only( - bottom: 8.0, - ), - child: ReplaceMenu( + }, + 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; + }), ), - ) - : const SizedBox.shrink(), - ], + ), + if (showReplaceMenu) + Padding( + padding: const EdgeInsets.only( + bottom: 8.0, + ), + child: ReplaceMenu( + editorState: widget.editorState, + searchService: searchService, + focusNode: replaceFocusNode, + ), + ), + ], + ), + ), + ), ); } } @@ -63,29 +105,30 @@ class _FindAndReplaceMenuWidgetState extends State { class FindMenu extends StatefulWidget { const FindMenu({ super.key, - required this.onDismiss, required this.editorState, required this.searchService, - required this.onShowReplace, + required this.showReplaceMenu, + required this.focusNode, + required this.onDismiss, + required this.onToggleShowReplace, }); final EditorState editorState; - final VoidCallback onDismiss; final SearchServiceV3 searchService; - final void Function(bool value) onShowReplace; + + final bool showReplaceMenu; + final FocusNode focusNode; + + final VoidCallback onDismiss; + final void Function() onToggleShowReplace; @override State createState() => _FindMenuState(); } class _FindMenuState extends State { - late final FocusNode findTextFieldFocusNode; + final textController = TextEditingController(); - final findTextEditingController = TextEditingController(); - - String queriedPattern = ''; - - bool showReplaceMenu = false; bool caseSensitive = false; @override @@ -95,11 +138,7 @@ class _FindMenuState extends State { widget.searchService.matchWrappers.addListener(_setState); widget.searchService.currentSelectedIndex.addListener(_setState); - findTextEditingController.addListener(_searchPattern); - - WidgetsBinding.instance.addPostFrameCallback((_) { - findTextFieldFocusNode.requestFocus(); - }); + textController.addListener(_searchPattern); } @override @@ -107,9 +146,7 @@ class _FindMenuState extends State { widget.searchService.matchWrappers.removeListener(_setState); widget.searchService.currentSelectedIndex.removeListener(_setState); widget.searchService.dispose(); - findTextEditingController.removeListener(_searchPattern); - findTextEditingController.dispose(); - findTextFieldFocusNode.dispose(); + textController.dispose(); super.dispose(); } @@ -123,40 +160,36 @@ class _FindMenuState extends State { const HSpace(4.0), // expand/collapse button _FindAndReplaceIcon( - icon: showReplaceMenu + icon: widget.showReplaceMenu ? FlowySvgs.drop_menu_show_s : FlowySvgs.drop_menu_hide_s, tooltipText: '', - onPressed: () { - widget.onShowReplace(!showReplaceMenu); - setState( - () => showReplaceMenu = !showReplaceMenu, - ); - }, + onPressed: widget.onToggleShowReplace, ), const HSpace(4.0), // find text input SizedBox( - width: 150, + width: 200, height: 30, - child: FlowyFormTextInput( - onFocusCreated: (focusNode) { - findTextFieldFocusNode = focusNode; - }, - onEditingComplete: () { + 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 findTextField - Future.delayed(const Duration(milliseconds: 50), () { - FocusScope.of(context).requestFocus( - findTextFieldFocusNode, - ); - }); + // 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(), + ); }, - controller: findTextEditingController, - hintText: LocaleKeys.findAndReplace_find.tr(), - textAlign: TextAlign.left, + decoration: _buildInputDecoration( + LocaleKeys.findAndReplace_find.tr(), + ), ), ), // the count of matches @@ -207,11 +240,8 @@ class _FindMenuState extends State { } void _searchPattern() { - if (findTextEditingController.text.isEmpty) { - return; - } - widget.searchService.findAndHighlight(findTextEditingController.text); - setState(() => queriedPattern = findTextEditingController.text); + widget.searchService.findAndHighlight(textController.text); + _setState(); } void _setState() { @@ -224,27 +254,24 @@ class ReplaceMenu extends StatefulWidget { super.key, required this.editorState, required this.searchService, - this.localizations, + required this.focusNode, }); final EditorState editorState; - - /// The localizations of the find and replace menu - final FindReplaceLocalizations? localizations; - final SearchServiceV3 searchService; + final FocusNode focusNode; + @override State createState() => _ReplaceMenuState(); } class _ReplaceMenuState extends State { - late final FocusNode replaceTextFieldFocusNode; - final replaceTextEditingController = TextEditingController(); + final textController = TextEditingController(); @override void dispose() { - replaceTextEditingController.dispose(); + textController.dispose(); super.dispose(); } @@ -255,29 +282,26 @@ class _ReplaceMenuState extends State { // placeholder for aligning the replace menu const HSpace(30), SizedBox( - width: 150, + width: 200, height: 30, - child: FlowyFormTextInput( - onFocusCreated: (focusNode) { - replaceTextFieldFocusNode = focusNode; + 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(), + ); }, - onEditingComplete: () { - 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 findTextField - Future.delayed(const Duration(milliseconds: 50), () { - FocusScope.of(context).requestFocus( - replaceTextFieldFocusNode, - ); - }); - }, - controller: replaceTextEditingController, - hintText: LocaleKeys.findAndReplace_replace.tr(), - textAlign: TextAlign.left, + decoration: _buildInputDecoration( + LocaleKeys.findAndReplace_replace.tr(), + ), ), ), - const HSpace(4.0), _FindAndReplaceIcon( onPressed: _replaceSelectedWord, iconBuilder: (_) => const Icon( @@ -294,7 +318,7 @@ class _ReplaceMenuState extends State { ), tooltipText: LocaleKeys.findAndReplace_replaceAll.tr(), onPressed: () => widget.searchService.replaceAllMatches( - replaceTextEditingController.text, + textController.text, ), ), ], @@ -302,7 +326,7 @@ class _ReplaceMenuState extends State { } void _replaceSelectedWord() { - widget.searchService.replaceSelectedWord(replaceTextEditingController.text); + widget.searchService.replaceSelectedWord(textController.text); } } @@ -328,10 +352,20 @@ class _FindAndReplaceIcon extends StatelessWidget { height: 24, onPressed: onPressed, icon: iconBuilder?.call(context) ?? - (icon != null ? FlowySvg(icon!) : const Placeholder()), + (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 index ab64e4adeb..e0f63e57c7 100644 --- 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 @@ -1,54 +1,226 @@ -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/settings/widgets/settings_appearance/font_family_setting.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +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_popover/appflowy_popover.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.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'; -final customizeFontToolbarItem = ToolbarItem( - id: 'editor.font', - group: 4, - isActive: onlyShowInTextType, - builder: (context, editorState, highlightColor, _) { - final selection = editorState.selection!; - final popoverController = PopoverController(); - return MouseRegion( - cursor: SystemMouseCursors.click, - child: FontFamilyDropDown( - currentFontFamily: '', - offset: const Offset(0, 12), - popoverController: popoverController, - onOpen: () => keepEditorFocusNotifier.increase(), - onClose: () => keepEditorFocusNotifier.decrease(), - showResetButton: true, - 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 => editorState - .formatDelta(selection, {AppFlowyRichTextKeys.fontFamily: null}), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: FlowyTooltip( - message: LocaleKeys.document_plugins_fonts.tr(), - child: const FlowySvg( - FlowySvgs.font_family_s, - size: Size.square(16.0), - color: Colors.white, +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, + margin: EdgeInsets.zero, + boxConstraints: const BoxConstraints( + maxWidth: 240, + maxHeight: 420, + ), + onClose: () { + query.value = ''; + widget.onClose?.call(); + }, + offset: widget.offset, + child: widget.child, + popupBuilder: (_) { + widget.onOpen?.call(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: FlowyTextField( + key: ThemeFontFamilySetting.textFieldKey, + hintText: LocaleKeys.settings_appearance_fontFamily_search.tr(), + autoFocus: false, + debounceDuration: const Duration(milliseconds: 300), + onChanged: (value) { + setState(() { + query.value = value; + }); + }, + ), ), + Container(height: 1, color: Theme.of(context).dividerColor), + 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 displayed.length >= 10 + ? Flexible( + child: ListView.builder( + padding: const EdgeInsets.all(8.0), + itemBuilder: (context, index) => + _fontFamilyItemButton( + context, + getGoogleFontSafely(displayed[index]), + ), + itemCount: displayed.length, + ), + ) + : Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: List.generate( + displayed.length, + (index) => _fontFamilyItemButton( + context, + getGoogleFontSafely(displayed[index]), + ), + ), + ), + ); + }, + ), + ], + ); + }, + ); + } + + 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: 36, + child: FlowyButton( + onHover: (_) => FocusScope.of(context).unfocus(), + text: FlowyText( + buttonFontFamily.fontFamilyDisplayName, + fontFamily: buttonFontFamily, + figmaLineHeight: 20, + fontWeight: FontWeight.w400, ), + rightIcon: + buttonFontFamily == widget.currentFontFamily.parseFontFamilyName() + ? const FlowySvg(FlowySvgs.toolbar_check_m) + : 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 index aac0713a7d..60211b9024 100644 --- 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 @@ -1,14 +1,17 @@ import 'dart:ui'; -import 'package:flutter/material.dart'; - import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; const String kLocalImagesKey = 'local_images'; List get builtInAssetImages => [ - "assets/images/app_flowy_abstract_cover_1.jpg", - "assets/images/app_flowy_abstract_cover_2.jpg", + '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 { 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 index 85633132d7..b891aecb6e 100644 --- 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 @@ -2,7 +2,7 @@ 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_header_node_widget.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'; @@ -30,8 +30,7 @@ class ChangeCoverPopoverBloc void _dispatch() { on((event, emit) async { await event.map( - fetchPickedImagePaths: - (FetchPickedImagePaths fetchPickedImagePaths) async { + fetchPickedImagePaths: (fetchPickedImagePaths) async { final imageNames = await _getPreviouslyPickedImagePaths(); emit( @@ -41,11 +40,11 @@ class ChangeCoverPopoverBloc ), ); }, - deleteImage: (DeleteImage deleteImage) async { + deleteImage: (deleteImage) async { final currentState = state; final currentlySelectedImage = node.attributes[DocumentHeaderBlockKeys.coverDetails]; - if (currentState is Loaded) { + if (currentState is _Loaded) { await _deleteImageInStorage(deleteImage.path); if (currentlySelectedImage == deleteImage.path) { _removeCoverImageFromNode(); @@ -54,15 +53,15 @@ class ChangeCoverPopoverBloc .where((path) => path != deleteImage.path) .toList(); _updateImagePathsInStorage(updateImageList); - emit(Loaded(updateImageList)); + emit(ChangeCoverPopoverState.loaded(updateImageList)); } }, - clearAllImages: (ClearAllImages clearAllImages) async { + clearAllImages: (clearAllImages) async { final currentState = state; final currentlySelectedImage = node.attributes[DocumentHeaderBlockKeys.coverDetails]; - if (currentState is Loaded) { + if (currentState is _Loaded) { for (final image in currentState.imageNames) { await _deleteImageInStorage(image); if (currentlySelectedImage == image) { @@ -70,7 +69,7 @@ class ChangeCoverPopoverBloc } } _updateImagePathsInStorage([]); - emit(const Loaded([])); + emit(const ChangeCoverPopoverState.loaded([])); } }, ); @@ -113,18 +112,18 @@ class ChangeCoverPopoverBloc class ChangeCoverPopoverEvent with _$ChangeCoverPopoverEvent { const factory ChangeCoverPopoverEvent.fetchPickedImagePaths({ @Default(false) bool selectLatestImage, - }) = FetchPickedImagePaths; + }) = _FetchPickedImagePaths; - const factory ChangeCoverPopoverEvent.deleteImage(String path) = DeleteImage; - const factory ChangeCoverPopoverEvent.clearAllImages() = ClearAllImages; + 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.initial() = _Initial; + const factory ChangeCoverPopoverState.loading() = _Loading; const factory ChangeCoverPopoverState.loaded( List imageNames, { @Default(false) selectLatestImage, - }) = Loaded; + }) = _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 0000000000..2c5062d408 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart @@ -0,0 +1,318 @@ +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/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/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: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( + 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)); + } + context + .read() + ?.add(ViewInfoEvent.titleChanged(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(); + } else if (event.logicalKey == LogicalKeyboardKey.tab) { + return KeyEventResult.handled; + } + + 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 index 560661d157..f5df4c0904 100644 --- 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 @@ -36,43 +36,49 @@ class _CoverImagePickerState extends State { ..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(), - ), - ); - } + state.maybeWhen( + networkImage: (successOrFail) { + successOrFail.fold( + (s) {}, + (e) => showSnapBar( + context, + LocaleKeys.document_plugins_cover_invalidImageUrl.tr(), + ), + ); + }, + done: (successOrFail) { + successOrFail.fold( + (l) => widget.onFileSubmit(l), + (r) => showSnapBar( + context, + LocaleKeys.document_plugins_cover_failedToAddImageToGallery + .tr(), + ), + ); + }, + orElse: () {}, + ); }, 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), + state.maybeMap( + loading: (_) => const SizedBox( + height: 180, + child: Center( + child: CircularProgressIndicator(), + ), + ), + orElse: () => CoverImagePreviewWidget(state: state), + ), const VSpace(10), NetworkImageUrlInput( onAdd: (url) { - context.read().add(UrlSubmit(url)); + context + .read() + .add(CoverImagePickerEvent.urlSubmit(url)); }, ), const VSpace(10), @@ -81,9 +87,9 @@ class _CoverImagePickerState extends State { widget.onBackPressed(); }, onSave: () { - context.read().add( - SaveToGallery(state), - ); + context + .read() + .add(CoverImagePickerEvent.saveToGallery(state)); }, ), ], @@ -111,11 +117,14 @@ class _NetworkImageUrlInputState extends State { @override void initState() { super.initState(); - urlController.addListener(() => setState(() {})); + urlController.addListener(_updateState); } + void _updateState() => setState(() {}); + @override void dispose() { + urlController.removeListener(_updateState); urlController.dispose(); super.dispose(); } @@ -193,7 +202,7 @@ class ImagePickerActionButtons extends StatelessWidget { class CoverImagePreviewWidget extends StatefulWidget { const CoverImagePreviewWidget({super.key, required this.state}); - final dynamic state; + final CoverImagePickerState state; @override State createState() => @@ -239,7 +248,9 @@ class _CoverImagePreviewWidgetState extends State { FlowyButton( hoverColor: Theme.of(context).hoverColor, onTap: () { - ctx.read().add(const PickFileImage()); + ctx + .read() + .add(const CoverImagePickerEvent.pickFileImage()); }, useIntrinsicWidth: true, leftIcon: const FlowySvg( @@ -247,6 +258,7 @@ class _CoverImagePreviewWidgetState extends State { size: Size(20, 20), ), text: FlowyText( + lineHeight: 1.0, LocaleKeys.document_plugins_cover_pickFromFiles.tr(), ), ), @@ -261,7 +273,9 @@ class _CoverImagePreviewWidgetState extends State { top: 10, child: InkWell( onTap: () { - ctx.read().add(const DeleteImage()); + ctx + .read() + .add(const CoverImagePickerEvent.deleteImage()); }, child: DecoratedBox( decoration: BoxDecoration( @@ -287,42 +301,42 @@ class _CoverImagePreviewWidgetState extends State { 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, + image: widget.state.whenOrNull( + networkImage: (successOrFail) { + return successOrFail.fold( + (path) => DecorationImage( + image: NetworkImage(path), + fit: BoxFit.cover, + ), + (r) => null, + ); + }, + fileImage: (path) { + return DecorationImage( + image: FileImage(File(path)), + fit: BoxFit.cover, + ); + }, + ), + ), + child: widget.state.whenOrNull( + initial: () => _buildFilePickerWidget(context), + networkImage: (successOrFail) => successOrFail.fold( + (l) => null, + (r) => _buildFilePickerWidget( + context, + ), + ), ), - 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(), + widget.state.maybeWhen( + fileImage: (_) => _buildImageDeleteButton(context), + networkImage: (successOrFail) => successOrFail.fold( + (l) => _buildImageDeleteButton(context), + (r) => const SizedBox.shrink(), + ), + orElse: () => 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 index 316f2ddd8f..64e21eb773 100644 --- 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 @@ -2,6 +2,7 @@ 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'; @@ -23,16 +24,14 @@ class CoverImagePickerBloc _dispatch(); } - static const allowedExtensions = ['jpg', 'png', 'jpeg']; - void _dispatch() { on( (event, emit) async { await event.map( - initialEvent: (InitialEvent initialEvent) { + initialEvent: (initialEvent) { emit(const CoverImagePickerState.initial()); }, - urlSubmit: (UrlSubmit urlSubmit) async { + urlSubmit: (urlSubmit) async { emit(const CoverImagePickerState.loading()); final validateImage = await _validateURL(urlSubmit.path); if (validateImage) { @@ -54,7 +53,7 @@ class CoverImagePickerBloc ); } }, - pickFileImage: (PickFileImage pickFileImage) async { + pickFileImage: (pickFileImage) async { final imagePickerResults = await _pickImages(); if (imagePickerResults != null) { emit(CoverImagePickerState.fileImage(imagePickerResults)); @@ -62,10 +61,10 @@ class CoverImagePickerBloc emit(const CoverImagePickerState.initial()); } }, - deleteImage: (DeleteImage deleteImage) { + deleteImage: (deleteImage) { emit(const CoverImagePickerState.initial()); }, - saveToGallery: (SaveToGallery saveToGallery) async { + saveToGallery: (saveToGallery) async { emit(const CoverImagePickerState.loading()); final saveImage = await _saveToGallery(saveToGallery.previousState); if (saveImage != null) { @@ -94,7 +93,7 @@ class CoverImagePickerBloc final List imagePaths = prefs.getStringList(kLocalImagesKey) ?? []; final directory = await _coverPath(); - if (state is FileImagePicked) { + if (state is _FileImagePicked) { try { final path = state.path; final newPath = p.join(directory, p.split(path).last); @@ -103,7 +102,7 @@ class CoverImagePickerBloc } catch (e) { return null; } - } else if (state is NetworkImagePicked) { + } else if (state is _NetworkImagePicked) { try { final url = state.successOrFail.fold((path) => path, (r) => null); if (url != null) { @@ -128,7 +127,7 @@ class CoverImagePickerBloc final result = await getIt().pickFiles( dialogTitle: LocaleKeys.document_plugins_cover_addLocalImage.tr(), type: FileType.image, - allowedExtensions: allowedExtensions, + allowedExtensions: defaultImageExtensions, ); if (result != null && result.files.isNotEmpty) { return result.files.first.path; @@ -176,7 +175,7 @@ class CoverImagePickerBloc if (ext != null && ext.isNotEmpty) { ext = ext.substring(1); } - if (allowedExtensions.contains(ext)) { + if (defaultImageExtensions.contains(ext)) { return ext; } return null; @@ -198,25 +197,25 @@ class CoverImagePickerBloc @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.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; + ) = _SaveToGallery; + const factory CoverImagePickerEvent.initialEvent() = _InitialEvent; } @freezed class CoverImagePickerState with _$CoverImagePickerState { - const factory CoverImagePickerState.initial() = Initial; - const factory CoverImagePickerState.loading() = Loading; + const factory CoverImagePickerState.initial() = _Initial; + const factory CoverImagePickerState.loading() = _Loading; const factory CoverImagePickerState.networkImage( FlowyResult successOrFail, - ) = NetworkImagePicked; - const factory CoverImagePickerState.fileImage(String path) = FileImagePicked; + ) = _NetworkImagePicked; + const factory CoverImagePickerState.fileImage(String path) = _FileImagePicked; const factory CoverImagePickerState.done( FlowyResult, FlowyError> successOrFail, - ) = Done; + ) = _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 index 7e576363cd..7265ef6f82 100644 --- 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 @@ -1,5 +1,7 @@ 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'; @@ -9,7 +11,6 @@ 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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; @@ -155,7 +156,7 @@ class _DesktopCoverState extends State { ); case CoverType.asset: return Image.asset( - widget.coverDetails!, + PageStyleCoverImageType.builtInImagePath(detail), fit: BoxFit.cover, ); case CoverType.color: 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 0000000000..16605367ca --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart @@ -0,0 +1,935 @@ +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/shared/icon_emoji_picker/tab.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, + required this.tabs, + }); + + final Node node; + final EditorState editorState; + final ValueChanged onIconChanged; + final ViewPB view; + final List tabs; + + @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, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + SizedBox( + height: _calculateOverallHeight(), + child: DocumentHeaderToolbar( + onIconOrCoverChanged: _saveIconOrCover, + node: widget.node, + editorState: widget.editorState, + hasCover: hasCover, + hasIcon: hasIcon, + offset: offset, + isCoverTitleHovered: isCoverTitleHovered, + documentId: view.id, + tabs: widget.tabs, + ), + ), + if (hasCover) + DocumentCover( + view: view, + editorState: widget.editorState, + node: widget.node, + coverType: coverType, + coverDetails: coverDetails, + onChangeCover: (type, details) => + _saveIconOrCover(cover: (type, details)), + ), + _buildAlignedCoverIcon(context), + ], + ), + _buildAlignedTitle(context), + ], + ); + }, + ); + } + + Widget _buildAlignedTitle(BuildContext context) { + return Center( + child: Container( + constraints: BoxConstraints( + maxWidth: widget.editorState.editorStyle.maxWidth ?? double.infinity, + ), + padding: widget.editorState.editorStyle.padding + + const EdgeInsets.symmetric(horizontal: 44), + child: MouseRegion( + onEnter: (event) => isCoverTitleHovered.value = true, + onExit: (event) => isCoverTitleHovered.value = false, + child: CoverTitle( + view: widget.view, + ), + ), + ), + ); + } + + Widget _buildAlignedCoverIcon(BuildContext context) { + if (!hasIcon) { + return const SizedBox.shrink(); + } + + return Positioned( + bottom: hasCover ? kToolbarHeight - kIconHeight / 2 : kToolbarHeight, + left: 0, + right: 0, + child: Center( + child: Container( + constraints: BoxConstraints( + maxWidth: + widget.editorState.editorStyle.maxWidth ?? double.infinity, + ), + padding: widget.editorState.editorStyle.padding + + const EdgeInsets.symmetric(horizontal: 44), + child: Row( + children: [ + DocumentIcon( + editorState: widget.editorState, + node: widget.node, + icon: viewIcon, + documentId: view.id, + onChangeIcon: (icon) => _saveIconOrCover(icon: icon), + ), + Spacer(), + ], + ), + ), + ), + ); + } + + 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, + this.documentId, + required this.isCoverTitleHovered, + required this.tabs, + }); + + 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 String? documentId; + final ValueNotifier isCoverTitleHovered; + final List tabs; + + @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( + tabs: widget.tabs, + documentId: widget.documentId, + onSelectedEmoji: (r) { + widget.onIconOrCoverChanged(icon: r.data); + if (!r.keepOpen) _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 + .withValues(alpha: 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 + .withValues(alpha: 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 { + final previousType = CoverType.fromString( + widget.node.attributes[DocumentHeaderBlockKeys.coverType], + ); + final previousDetails = + widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]; + + bool isFileType(CoverType type, String? details) => + type == CoverType.file && details != null && !isURL(details); + + if (isFileType(type, 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); + + // After cover change,delete from localstorage if previous cover was image type + if (isFileType(previousType, previousDetails) && _isLocalMode()) { + await deleteImageFromLocalStorage(previousDetails); + } + } + + 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.withValues(alpha: 0.5) + : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 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, + this.documentId, + }); + + final Node node; + final EditorState editorState; + final EmojiIconData icon; + final String? documentId; + 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( + initialType: widget.icon.type.toPickerTabType(), + tabs: const [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ], + documentId: widget.documentId, + onSelectedEmoji: (r) { + widget.onChangeIcon(r.data); + if (!r.keepOpen) _popoverController.close(); + }, + ); + }, + ); + } else { + child = GestureDetector( + child: child, + onTap: () async { + final result = await context.push( + Uri( + path: MobileEmojiPickerScreen.routeName, + queryParameters: { + MobileEmojiPickerScreen.iconSelectedType: widget.icon.type.name, + }, + ).toString(), + ); + if (result != null) { + widget.onChangeIcon(result); + } + }, + ); + } + + return child; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart deleted file mode 100644 index 7a606f33ef..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart +++ /dev/null @@ -1,752 +0,0 @@ -import 'dart:io'; - -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/base/icon/icon_picker.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.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.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/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:appflowy_popover/appflowy_popover.dart'; -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 'cover_editor.dart'; - -const double kCoverHeight = 250.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, - ); - } -} - -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 void Function(String icon) 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.isNotEmpty; - bool get hasCover => - coverType != CoverType.none || - (cover != null && cover?.type != PageStyleCoverImageType.none); - - String viewIcon = ''; - PageStyleCover? cover; - late final ViewListener viewListener; - - @override - void initState() { - super.initState(); - final value = widget.view.icon.value; - viewIcon = value.isNotEmpty ? value : icon ?? ''; - cover = widget.view.cover; - widget.node.addListener(_reload); - viewListener = ViewListener( - viewId: widget.view.id, - )..start( - onViewUpdated: (p0) { - setState(() { - viewIcon = p0.icon.value; - cover = p0.cover; - }); - }, - ); - } - - @override - void dispose() { - viewListener.stop(); - widget.node.removeListener(_reload); - super.dispose(); - } - - void _reload() => setState(() {}); - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - SizedBox( - height: _calculateOverallHeight(), - child: DocumentHeaderToolbar( - onIconOrCoverChanged: _saveIconOrCover, - node: widget.node, - editorState: widget.editorState, - hasCover: hasCover, - hasIcon: hasIcon, - ), - ), - if (hasCover) - DocumentCover( - view: widget.view, - editorState: widget.editorState, - node: widget.node, - coverType: coverType, - coverDetails: coverDetails, - onChangeCover: (type, details) => - _saveIconOrCover(cover: (type, details)), - ), - if (hasIcon) - Positioned( - left: PlatformExtension.isDesktopOrWeb ? 80 : 20, - // if hasCover, there shouldn't be icons present so the icon can - // be closer to the bottom. - bottom: - hasCover ? kToolbarHeight - kIconHeight / 2 : kToolbarHeight, - child: DocumentIcon( - editorState: widget.editorState, - node: widget.node, - icon: viewIcon, - onChangeIcon: (icon) => _saveIconOrCover(icon: icon), - ), - ), - ], - ); - } - - double _calculateOverallHeight() { - switch ((hasIcon, hasCover)) { - case (true, true): - return kCoverHeight + kToolbarHeight; - case (true, false): - return 50 + kIconHeight + kToolbarHeight; - case (false, true): - return kCoverHeight + kToolbarHeight; - case (false, false): - return kToolbarHeight; - } - } - - void _saveIconOrCover({(CoverType, String?)? cover, String? 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; - 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, - widget.editorState, - overwrite: true, - ); - } -} - -@visibleForTesting -class DocumentHeaderToolbar extends StatefulWidget { - const DocumentHeaderToolbar({ - super.key, - required this.node, - required this.editorState, - required this.hasCover, - required this.hasIcon, - required this.onIconOrCoverChanged, - }); - - final Node node; - final EditorState editorState; - final bool hasCover; - final bool hasIcon; - final void Function({(CoverType, String?)? cover, String? icon}) - onIconOrCoverChanged; - - @override - State createState() => _DocumentHeaderToolbarState(); -} - -class _DocumentHeaderToolbarState extends State { - bool isHidden = true; - bool isPopoverOpen = false; - - final PopoverController _popoverController = PopoverController(); - - @override - void initState() { - super.initState(); - - isHidden = PlatformExtension.isDesktopOrWeb; - } - - @override - Widget build(BuildContext context) { - Widget child = Container( - alignment: Alignment.bottomLeft, - width: double.infinity, - padding: PlatformExtension.isDesktopOrWeb - ? EdgeInsets.symmetric( - horizontal: EditorStyleCustomizer.documentPadding.right, - ) - : EdgeInsets.symmetric( - horizontal: EditorStyleCustomizer.documentPadding.left, - ), - child: SizedBox( - height: 28, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: buildRowChildren(), - ), - ), - ); - - if (PlatformExtension.isDesktopOrWeb) { - child = MouseRegion( - onEnter: (event) => setHidden(false), - onExit: (event) { - if (!isPopoverOpen) { - setHidden(true); - } - }, - opaque: false, - child: child, - ); - } - - return child; - } - - List buildRowChildren() { - if (isHidden || widget.hasCover && widget.hasIcon) { - return []; - } - final List children = []; - - if (!widget.hasCover) { - children.add( - FlowyButton( - leftIconSize: const Size.square(18), - onTap: () => widget.onIconOrCoverChanged( - cover: PlatformExtension.isDesktopOrWeb - ? (CoverType.asset, builtInAssetImages.first) - : (CoverType.color, '0xffe8e0ff'), - ), - useIntrinsicWidth: true, - leftIcon: const FlowySvg(FlowySvgs.image_s), - text: FlowyText.small( - LocaleKeys.document_plugins_cover_addCover.tr(), - ), - ), - ); - } - - if (widget.hasIcon) { - children.add( - FlowyButton( - leftIconSize: const Size.square(18), - onTap: () => widget.onIconOrCoverChanged(icon: ""), - useIntrinsicWidth: true, - leftIcon: const Icon( - Icons.emoji_emotions_outlined, - size: 18, - ), - text: FlowyText.small( - LocaleKeys.document_plugins_cover_removeIcon.tr(), - ), - ), - ); - } else { - Widget child = FlowyButton( - leftIconSize: const Size.square(18), - useIntrinsicWidth: true, - leftIcon: const Icon( - Icons.emoji_emotions_outlined, - size: 18, - ), - text: FlowyText.small( - LocaleKeys.document_plugins_cover_addIcon.tr(), - ), - onTap: PlatformExtension.isDesktop - ? null - : () async { - final result = await context.push( - MobileEmojiPickerScreen.routeName, - ); - if (result != null) { - widget.onIconOrCoverChanged(icon: result.emoji); - } - }, - ); - - if (PlatformExtension.isDesktop) { - child = AppFlowyPopover( - onClose: () => isPopoverOpen = false, - controller: _popoverController, - offset: const Offset(0, 8), - direction: PopoverDirection.bottomWithCenterAligned, - constraints: BoxConstraints.loose(const Size(360, 380)), - child: child, - popupBuilder: (BuildContext popoverContext) { - isPopoverOpen = true; - return FlowyIconPicker( - onSelected: (result) { - widget.onIconOrCoverChanged(icon: result.emoji); - _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 { - bool isOverlayButtonsHidden = true; - bool isPopoverOpen = false; - final PopoverController popoverController = PopoverController(); - - @override - Widget build(BuildContext context) { - return PlatformExtension.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, - ], - onSelectedLocalImage: (path) async { - context.pop(); - widget.onChangeCover(CoverType.file, 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, - ], - onSelectedLocalImage: (path) { - popoverController.close(); - onCoverChanged(CoverType.file, path); - }, - 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.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 = PlatformExtension.isDesktopOrWeb - ? Theme.of(context).colorScheme.surface.withOpacity(0.5) - : Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5); - final svgColor = PlatformExtension.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 String icon; - final void Function(String icon) 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 (PlatformExtension.isDesktopOrWeb) { - child = AppFlowyPopover( - direction: PopoverDirection.bottomWithCenterAligned, - controller: _popoverController, - offset: const Offset(0, 8), - constraints: BoxConstraints.loose(const Size(360, 380)), - child: child, - popupBuilder: (BuildContext popoverContext) { - return FlowyIconPicker( - onSelected: (result) { - widget.onChangeIcon(result.emoji); - _popoverController.close(); - }, - ); - }, - ); - } else { - child = GestureDetector( - child: child, - onTap: () async { - final result = await context.push( - MobileEmojiPickerScreen.routeName, - ); - if (result != null) { - widget.onChangeIcon(result.emoji); - } - }, - ); - } - - 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 index d85ccac645..cda76233d6 100644 --- 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 @@ -1,5 +1,20 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/appflowy_network_svg.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; +import 'package:string_validator/string_validator.dart'; + +import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import '../../../../base/icon/icon_widget.dart'; class EmojiIconWidget extends StatefulWidget { const EmojiIconWidget({ @@ -8,7 +23,7 @@ class EmojiIconWidget extends StatefulWidget { this.emojiSize = 60, }); - final String emoji; + final EmojiIconData emoji; final double emojiSize; @override @@ -27,15 +42,17 @@ class _EmojiIconWidgetState extends State { child: Container( decoration: BoxDecoration( color: !hover - ? Theme.of(context).colorScheme.inverseSurface.withOpacity(0.5) + ? Theme.of(context) + .colorScheme + .inverseSurface + .withValues(alpha: 0.5) : Colors.transparent, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, - child: EmojiText( + child: RawEmojiIconWidget( emoji: widget.emoji, - fontSize: widget.emojiSize, - textAlign: TextAlign.center, + emojiSize: widget.emojiSize, ), ), ); @@ -48,3 +65,155 @@ class _EmojiIconWidgetState extends State { }); } } + +class RawEmojiIconWidget extends StatefulWidget { + const RawEmojiIconWidget({ + super.key, + required this.emoji, + required this.emojiSize, + this.enableColor = true, + this.lineHeight, + }); + + final EmojiIconData emoji; + final double emojiSize; + final bool enableColor; + final double? lineHeight; + + @override + State createState() => _RawEmojiIconWidgetState(); +} + +class _RawEmojiIconWidgetState extends State { + UserProfilePB? userProfile; + + EmojiIconData get emoji => widget.emoji; + + @override + void initState() { + super.initState(); + loadUserProfile(); + } + + @override + void didUpdateWidget(RawEmojiIconWidget oldWidget) { + super.didUpdateWidget(oldWidget); + loadUserProfile(); + } + + @override + Widget build(BuildContext context) { + final defaultEmoji = SizedBox( + width: widget.emojiSize, + child: EmojiText( + emoji: '❓', + fontSize: widget.emojiSize, + textAlign: TextAlign.center, + ), + ); + try { + switch (widget.emoji.type) { + case FlowyIconType.emoji: + return FlowyText.emoji( + widget.emoji.emoji, + fontSize: widget.emojiSize, + textAlign: TextAlign.justify, + lineHeight: widget.lineHeight, + ); + case FlowyIconType.icon: + IconsData iconData = IconsData.fromJson( + jsonDecode(widget.emoji.emoji), + ); + if (!widget.enableColor) { + iconData = iconData.noColor(); + } + + final iconSize = widget.emojiSize; + return IconWidget( + iconsData: iconData, + size: iconSize, + ); + case FlowyIconType.custom: + final url = widget.emoji.emoji; + final isSvg = url.endsWith('.svg'); + final hasUserProfile = userProfile != null; + if (isURL(url)) { + Widget child = const SizedBox.shrink(); + if (isSvg) { + child = FlowyNetworkSvg( + url, + headers: + hasUserProfile ? _buildRequestHeader(userProfile!) : {}, + width: widget.emojiSize, + height: widget.emojiSize, + ); + } else if (hasUserProfile) { + child = FlowyNetworkImage( + url: url, + width: widget.emojiSize, + height: widget.emojiSize, + userProfilePB: userProfile, + errorWidgetBuilder: (context, url, error) { + return const SizedBox.shrink(); + }, + ); + } + return SizedBox.square( + dimension: widget.emojiSize, + child: child, + ); + } + final imageFile = File(url); + if (!imageFile.existsSync()) { + throw PathNotFoundException(url, const OSError()); + } + return SizedBox.square( + dimension: widget.emojiSize, + child: isSvg + ? SvgPicture.file( + File(url), + width: widget.emojiSize, + height: widget.emojiSize, + ) + : Image.file( + imageFile, + fit: BoxFit.cover, + width: widget.emojiSize, + height: widget.emojiSize, + ), + ); + } + } catch (e) { + Log.error("Display widget error: $e"); + return defaultEmoji; + } + } + + Map _buildRequestHeader(UserProfilePB userProfilePB) { + final header = {}; + final token = userProfilePB.token; + try { + final decodedToken = jsonDecode(token); + header['Authorization'] = 'Bearer ${decodedToken['access_token']}'; + } catch (e) { + Log.error('Unable to decode token: $e'); + } + return header; + } + + Future loadUserProfile() async { + if (userProfile != null) return; + if (emoji.type == FlowyIconType.custom) { + final userProfile = + (await UserBackendService.getCurrentUserProfile()).fold( + (userProfile) => userProfile, + (l) => null, + ); + if (mounted) { + setState(() { + this.userProfile = userProfile; + }); + } + } + } +} 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 0000000000..3d0c199ea2 --- /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.withValues(alpha: 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.withValues(alpha: 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/image/common.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart new file mode 100644 index 0000000000..24e10f229c --- /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.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart deleted file mode 100644 index c389977333..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart +++ /dev/null @@ -1,417 +0,0 @@ -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/widgets.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/image_placeholder.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/util/string_extension.dart'; -import 'package:appflowy/workspace/presentation/home/toast.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'; - -const kImagePlaceholderKey = 'imagePlaceholderKey'; - -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 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'; -} - -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: (context, state) => actionBuilder( - blockComponentContext, - state, - ), - showMenu: showMenu, - menuBuilder: menuBuilder, - ); - } - - @override - bool validate(Node node) => node.delta == null && 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 UnSupportImageWidget(); - } else { - child = ResizableImage( - src: src, - width: width, - height: height, - editable: editorState.editable, - alignment: alignment, - type: imageType, - onResize: (width) { - final transaction = editorState.transaction - ..updateNode(node, { - CustomImageBlockKeys.width: width, - }); - editorState.apply(transaction); - }, - ); - } - - if (PlatformExtension.isDesktopOrWeb) { - child = BlockSelectionContainer( - node: node, - delegate: this, - listenable: editorState.selectionNotifier, - blockColor: editorState.editorStyle.selectionColor, - supportTypes: const [ - BlockSelectionType.block, - ], - child: Padding( - key: imageKey, - padding: padding, - child: child, - ), - ); - } else { - child = Padding( - key: imageKey, - padding: padding, - 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 (PlatformExtension.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) { - 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 [ - // disable the copy link button if the image is hosted on appflowy cloud - // because the url needs the verification token to be accessible - if (!url.isAppFlowyCloudUrl) - 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); - }, - ), - 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/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 0000000000..7f0105134d --- /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,440 @@ +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/shared/custom_image_cache_manager.dart'; +import 'package:appflowy/shared/permission/permission_checker.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' 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:saver_gallery/saver_gallery.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, + ValueNotifier imageStateNotifier, +); + +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.actionTrailingBuilder, + 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); + final imageStateNotifier = + ValueNotifier(ResizableImageState.loading); + + 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, + onStateChange: (state) => imageStateNotifier.value = state, + 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, + selectionAboveBlock: true, + supportTypes: const [BlockSelectionType.block], + child: child, + ); + } + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, + 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) { + return Stack( + children: [ + editorState.editable + ? BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: + editorState.editorStyle.selectionColor, + child: child!, + ) + : child!, + if (value) + widget.menuBuilder!(widget.node, this, imageStateNotifier), + ], + ); + }, + 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 padding.topLeft & 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( + 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(); + // save the image to the photo library + await _saveImageToGallery(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; + } + + Future _saveImageToGallery(String url) async { + final permission = await PermissionChecker.checkPhotoPermission(context); + if (!permission) { + return; + } + + final imageFile = await CustomImageCacheManager().getSingleFile(url); + if (imageFile.existsSync()) { + final result = await SaverGallery.saveImage( + imageFile.readAsBytesSync(), + fileName: imageFile.basename, + skipIfExists: false, + ); + if (mounted) { + showToastNotification( + message: result.isSuccess + ? LocaleKeys.document_imageBlock_successToAddImageToGallery.tr() + : LocaleKeys.document_imageBlock_failedToAddImageToGallery.tr(), + ); + } + } + } +} 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 0000000000..d11d943066 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart @@ -0,0 +1,323 @@ +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/image/resizeable_image.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.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, + required this.imageStateNotifier, + }); + + final Node node; + final CustomImageBlockComponentState state; + final ValueNotifier imageStateNotifier; + + @override + State createState() => _ImageMenuState(); +} + +class _ImageMenuState extends State { + late final String? url = widget.node.attributes[CustomImageBlockKeys.url]; + + @override + Widget build(BuildContext context) { + final isPlaceholder = url == null || url!.isEmpty; + final theme = Theme.of(context); + return ValueListenableBuilder( + valueListenable: widget.imageStateNotifier, + builder: (_, state, child) { + if (state == ResizableImageState.loading && !isPlaceholder) { + return const SizedBox.shrink(); + } + + return Container( + height: 32, + decoration: BoxDecoration( + color: theme.cardColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + ], + borderRadius: BorderRadius.circular(4.0), + ), + child: Row( + children: [ + const HSpace(4), + if (!isPlaceholder) ...[ + 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), + ], + if (widget.state.editorState.editable) ...[ + if (!isPlaceholder) ...[ + _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( + message: LocaleKeys.message_copy_success.tr(), + ); + } + } catch (e) { + if (mounted) { + showToastNotification( + 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()?.userProfile ?? + context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: url!, + type: CustomImageType.fromIntValue( + widget.node.attributes[CustomImageBlockKeys.imageType] ?? 2, + ), + ), + ], + onDeleteImage: widget.state.editorState.editable + ? (_) async { + final transaction = widget.state.editorState.transaction; + transaction.deleteNode(widget.node); + await widget.state.editorState.apply(transaction); + } + : null, + ), + ), + ); + } + + 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 0000000000..f0310a4aa5 --- /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/embed_image_url_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart deleted file mode 100644 index d34a4fc3a8..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.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:flowy_infra_ui/flowy_infra_ui.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) { - return Column( - children: [ - FlowyTextField( - hintText: LocaleKeys.document_imageBlock_embedLink_placeholder.tr(), - onChanged: (value) => inputText = value, - onEditingComplete: submit, - ), - if (!isUrlValid) ...[ - const VSpace(8), - FlowyText( - LocaleKeys.document_plugins_cover_invalidImageUrl.tr(), - color: Theme.of(context).colorScheme.error, - ), - ], - const VSpace(8), - SizedBox( - width: 160, - child: FlowyButton( - showDefaultBoxDecorationOnMobile: true, - margin: const EdgeInsets.all(8.0), - text: FlowyText( - LocaleKeys.document_imageBlock_embedLink_label.tr(), - textAlign: TextAlign.center, - ), - onTap: submit, - ), - ), - ], - ); - } - - 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/flowy_image_picker.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart deleted file mode 100644 index 45f5b78507..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart +++ /dev/null @@ -1,40 +0,0 @@ -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'; -import 'package:flutter/material.dart'; - -class ImagePickerPage extends StatefulWidget { - const ImagePickerPage({ - super.key, - // required this.onSelected, - }); - - // final void Function(EmojiPickerResult) onSelected; - - @override - State createState() => _ImagePickerPageState(); -} - -class _ImagePickerPageState extends State { - @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_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart deleted file mode 100644 index ca765bc0ed..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart +++ /dev/null @@ -1,255 +0,0 @@ -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/align_toolbar_item.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/util/string_extension.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_popover/appflowy_popover.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/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[ImageBlockKeys.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), - // disable the copy link button if the image is hosted on appflowy cloud - // because the url needs the verification token to be accessible - if (!(url?.isAppFlowyCloudUrl ?? false)) ...[ - MenuBlockButton( - tooltip: LocaleKeys.editor_copyLink.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.delete_s, - onTap: deleteImage, - ), - const HSpace(4), - ], - ), - ); - } - - void copyImageLink() { - if (url != null) { - Clipboard.setData(ClipboardData(text: 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); - } -} - -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[ImageBlockKeys.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, { - ImageBlockKeys.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/image_picker_screen.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart index 5ea7a56c40..b1c6c94213 100644 --- 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 @@ -1,13 +1,40 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart'; 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 const ImagePickerPage(); + 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 index f193f91617..df975de731 100644 --- 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 @@ -1,35 +1,37 @@ 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/custom_image_block_component.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.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 Log, UploadImageMenu; -import 'package:appflowy_popover/appflowy_popover.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: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, - }); + const ImagePlaceholder({super.key, required this.node}); final Node node; @@ -45,12 +47,20 @@ class ImagePlaceholderState extends State { bool showLoading = false; String? errorMessage; + bool isDraggingFiles = false; + @override Widget build(BuildContext context) { final Widget child = DecoratedBox( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, + 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( @@ -61,9 +71,10 @@ class ImagePlaceholderState extends State { child: Row( children: [ const HSpace(10), - const FlowySvg( - FlowySvgs.image_placeholder_s, - size: Size.square(24), + FlowySvg( + FlowySvgs.slash_menu_icon_image_s, + size: const Size.square(24), + color: Theme.of(context).hintColor, ), const HSpace(10), ..._buildTrailing(context), @@ -73,7 +84,7 @@ class ImagePlaceholderState extends State { ), ); - if (PlatformExtension.isDesktopOrWeb) { + if (UniversalPlatform.isDesktopOrWeb) { return AppFlowyPopover( controller: controller, direction: PopoverDirection.bottomWithCenterAligned, @@ -85,18 +96,24 @@ class ImagePlaceholderState extends State { clickHandler: PopoverClickHandler.gestureDetector, popupBuilder: (context) { return UploadImageMenu( + allowMultipleImages: true, limitMaximumImageSize: !_isLocalMode(), supportTypes: const [ UploadImageType.local, UploadImageType.url, UploadImageType.unsplash, - UploadImageType.openAI, - UploadImageType.stabilityAI, ], - onSelectedLocalImage: (path) { + onSelectedLocalImages: (files) { controller.close(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - await insertLocalImage(path); + 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) { @@ -113,7 +130,27 @@ class ImagePlaceholderState extends State { }, ); }, - child: child, + 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( @@ -133,8 +170,11 @@ class ImagePlaceholderState extends State { List _buildTrailing(BuildContext context) { if (errorMessage != null) { return [ - FlowyText( - '${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}', + Flexible( + child: FlowyText( + '${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}', + maxLines: 3, + ), ), ]; } else if (showLoading) { @@ -147,15 +187,22 @@ class ImagePlaceholderState extends State { ]; } else { return [ - FlowyText( - LocaleKeys.document_plugins_image_addAnImage.tr(), + 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 (PlatformExtension.isDesktopOrWeb) { + if (UniversalPlatform.isDesktopOrWeb) { controller.show(); } else { final isLocalMode = _isLocalMode(); @@ -179,9 +226,15 @@ class ImagePlaceholderState extends State { UploadImageType.url, UploadImageType.unsplash, ], - onSelectedLocalImage: (path) async { + onSelectedLocalImages: (files) async { context.pop(); - await insertLocalImage(path); + + final items = files + .where((file) => file.path.isNotEmpty) + .map((file) => file.path) + .toList(); + + await insertMultipleLocalImages(items); }, onSelectedAIImage: (url) async { context.pop(); @@ -198,73 +251,106 @@ class ImagePlaceholderState extends State { } } - Future insertLocalImage(String? url) async { + Future insertMultipleLocalImages(List urls) async { controller.close(); - if (url == null || url.isEmpty) { + if (urls.isEmpty) { return; } - final transaction = editorState.transaction; - - String? path; - String? errorMessage; - CustomImageType imageType = CustomImageType.local; - - // if the user is using local authenticator, we need to save the image to local storage - if (_isLocalMode()) { - // don't limit the image size for local mode. - path = await saveImageToLocalStorage(url); - } else { - // else we should save the image to cloud storage - setState(() { - showLoading = true; - this.errorMessage = null; - }); - (path, errorMessage) = await saveImageToCloudStorage(url); - setState(() { - showLoading = false; - this.errorMessage = errorMessage; - }); - imageType = CustomImageType.internal; - } - - if (mounted && path == null) { - showSnackBarMessage( - context, - errorMessage == null - ? LocaleKeys.document_imageBlock_error_invalidImage.tr() - : ': $errorMessage', - ); - setState(() { - this.errorMessage = errorMessage; - }); - return; - } - - transaction.updateNode(widget.node, { - CustomImageBlockKeys.url: path, - CustomImageBlockKeys.imageType: imageType.toIntValue(), + setState(() { + showLoading = true; + errorMessage = null; }); - await editorState.apply(transaction); + 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 - showSnackBarMessage( + return showSnackBarMessage( context, LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); - return; } final path = await getIt().getPath(); - final imagePath = p.join( - path, - 'images', - ); + final imagePath = p.join(path, 'images'); try { // create the directory if not exists final directory = Directory(imagePath); @@ -279,7 +365,7 @@ class ImagePlaceholderState extends State { final response = await get(uri); await File(copyToPath).writeAsBytes(response.bodyBytes); - await insertLocalImage(copyToPath); + await insertMultipleLocalImages([copyToPath]); await File(copyToPath).delete(); } catch (e) { Log.error('cannot save image file', e); @@ -289,16 +375,16 @@ class ImagePlaceholderState extends State { Future insertNetworkImage(String url) async { if (url.isEmpty || !isURL(url)) { // show error - showSnackBarMessage( + return showSnackBarMessage( context, LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); - return; } final transaction = editorState.transaction; transaction.updateNode(widget.node, { - ImageBlockKeys.url: url, + CustomImageBlockKeys.url: url, + CustomImageBlockKeys.imageType: CustomImageType.external.toIntValue(), }); await editorState.apply(transaction); } 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 index c42e4f8147..5a30a4dda5 100644 --- 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 @@ -1,64 +1,100 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; 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: (editorState, isSelected, style) => SelectionMenuIconWidget( + icon: (_, isSelected, style) => SelectionMenuIconWidget( name: 'image', isSelected: isSelected, style: style, ), keywords: ['image', 'picture', 'img', 'photo'], - handler: (editorState, menuService, context) async { + 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((timeStamp) { + 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) { + final path = selection.end.path; + final node = getNodeAtPath(path); + final delta = node?.delta; + if (node == null || delta == 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, - ); - } + ..extraInfos = {kImagePlaceholderKey: key}; - transaction.afterSelection = Selection.collapsed( - Position( - path: node.path.next, - ), - ); - transaction.selectionExtraInfo = {}; + final insertedPath = delta.isEmpty ? path : path.next; + final transaction = this.transaction + ..insertNode(insertedPath, emptyImage) + ..insertNode(insertedPath, paragraphNode()) + ..afterSelection = Selection.collapsed(Position(path: insertedPath.next)); + + return apply(transaction); + } + + Future insertEmptyMultiImageBlock(GlobalKey key) 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 emptyBlock = multiImageNode() + ..extraInfos = {kMultiImagePlaceholderKey: key}; + + final insertedPath = delta.isEmpty ? path : path.next; + final transaction = this.transaction + ..insertNode(insertedPath, emptyBlock) + ..insertNode(insertedPath, paragraphNode()) + ..afterSelection = Selection.collapsed(Position(path: insertedPath.next)); 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 index 352a6c878e..74d1955312 100644 --- 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 @@ -2,13 +2,17 @@ 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/util/file_extension.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 { @@ -39,19 +43,13 @@ Future saveImageToLocalStorage(String localImagePath) async { Future<(String? path, String? errorMessage)> saveImageToCloudStorage( String localImagePath, + String documentId, ) async { - final size = localImagePath.fileSize; - if (size == null || size > 10 * 1024 * 1024) { - // 10MB - return ( - null, - LocaleKeys.document_imageBlock_uploadImageErrorImageSizeTooBig.tr(), - ); - } final documentService = DocumentService(); + Log.debug("Uploading image local path: $localImagePath"); final result = await documentService.uploadFile( localFilePath: localImagePath, - isAsync: false, + documentId: documentId, ); return result.fold( (s) async { @@ -61,6 +59,74 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage( ); return (s.url, null); }, - (e) => (null, e.msg), + (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; +} + +@visibleForTesting +int deleteImageTestCounter = 0; + +Future deleteImageFromLocalStorage(String localImagePath) async { + try { + await File(localImagePath) + .delete() + .whenComplete(() => deleteImageTestCounter++); + } catch (e) { + Log.error('cannot delete image file', e); + } +} 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 index aa8c6fe496..cb7fd457e0 100644 --- 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 @@ -1,8 +1,9 @@ +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'; -import 'package:flutter/material.dart'; final imageMobileToolbarItem = MobileToolbarItem.action( itemIconBuilder: (_, __, ___) => const FlowySvg(FlowySvgs.m_toolbar_imae_lg), @@ -10,7 +11,7 @@ final imageMobileToolbarItem = MobileToolbarItem.action( final imagePlaceholderKey = GlobalKey(); await editorState.insertEmptyImageBlock(imagePlaceholderKey); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + 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 0000000000..66a14d2c4a --- /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 0000000000..eb8ddba0b8 --- /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,415 @@ +import 'dart:io'; + +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/application/user/user_workspace_bloc.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:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../image_render.dart'; + +const _thumbnailItemSize = 100.0, _imageHeight = 400.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()?.userProfile ?? + context.read().state.userProfilePB; + } + + @override + Widget build(BuildContext context) { + final gallery = Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: _imageHeight, + 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.withValues(alpha: 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.withValues(alpha: 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, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + return SizedBox( + height: _imageHeight + _thumbnailItemSize + 20, + child: gallery, + ); + } + + 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.withValues(alpha: 0.6), + hoverColor: Colors.black.withValues(alpha: 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 0000000000..1abe57146e --- /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 0000000000..43d1c7ae36 --- /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,77 @@ +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; +import 'package:flutter/material.dart'; + +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: + 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 0000000000..51da975938 --- /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,374 @@ +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({List? images}) => Node( + type: MultiImageBlockKeys.type, + attributes: { + MultiImageBlockKeys.images: + MultiImageData(images: 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, + super.actionTrailingBuilder, + }); + + 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!, + actionTrailingBuilder: widget.actionTrailingBuilder, + 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 0000000000..dc95054e81 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart @@ -0,0 +1,441 @@ +import 'dart:io'; + +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/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/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:flutter/material.dart'; +import 'package:flutter/services.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.withValues(alpha: 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), + ); + showToastNotification( + message: 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 0000000000..313022bfab --- /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/open_ai_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart deleted file mode 100644 index 9cd6320518..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -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/startup/startup.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 OpenAIImageWidget extends StatefulWidget { - const OpenAIImageWidget({ - super.key, - required this.onSelectNetworkImage, - }); - - final void Function(String url) onSelectNetworkImage; - - @override - State createState() => _OpenAIImageWidgetState(); -} - -class _OpenAIImageWidgetState extends State { - Future, OpenAIError>>? future; - String query = ''; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: FlowyTextField( - hintText: LocaleKeys.document_imageBlock_ai_placeholder.tr(), - onChanged: (value) => query = value, - onEditingComplete: _search, - ), - ), - const HSpace(4.0), - FlowyButton( - useIntrinsicWidth: true, - text: FlowyText( - LocaleKeys.search_label.tr(), - ), - onTap: _search, - ), - ], - ), - const VSpace(12.0), - if (future != null) - Expanded( - child: FutureBuilder( - future: future, - builder: (context, value) { - final data = value.data; - if (!value.hasData || - value.connectionState != ConnectionState.done || - data == null) { - return const CircularProgressIndicator.adaptive(); - } - return data.fold( - (s) => GridView.count( - crossAxisCount: 3, - mainAxisSpacing: 16.0, - crossAxisSpacing: 10.0, - childAspectRatio: 4 / 3, - children: s - .map( - (e) => GestureDetector( - onTap: () => widget.onSelectNetworkImage(e), - child: Image.network(e), - ), - ) - .toList(), - ), - (e) => Center( - child: FlowyText( - e.message, - maxLines: 3, - textAlign: TextAlign.center, - ), - ), - ); - }, - ), - ), - ], - ); - } - - void _search() async { - final openAI = await getIt.getAsync(); - setState(() { - future = openAI.generateImage( - prompt: query, - n: 6, - ); - }); - } -} 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 index 58d5454b4b..da37945bf5 100644 --- 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 @@ -2,17 +2,26 @@ 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/plugins/document/application/prelude.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.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:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:string_validator/string_validator.dart'; +enum ResizableImageState { + loading, + loaded, + failed, +} + class ResizableImage extends StatefulWidget { const ResizableImage({ super.key, @@ -23,6 +32,8 @@ class ResizableImage extends StatefulWidget { required this.width, required this.src, this.height, + this.onDoubleTap, + this.onStateChange, }); final String src; @@ -31,6 +42,8 @@ class ResizableImage extends StatefulWidget { final double? height; final Alignment alignment; final bool editable; + final VoidCallback? onDoubleTap; + final ValueChanged? onStateChange; final void Function(double width) onResize; @@ -41,18 +54,17 @@ class ResizableImage extends StatefulWidget { const _kImageBlockComponentMinWidth = 30.0; class _ResizableImageState extends State { - late double imageWidth; + final documentService = DocumentService(); double initialOffset = 0; double moveDistance = 0; - Widget? _cacheImage; + late double imageWidth; + @visibleForTesting bool onFocus = false; - final documentService = DocumentService(); - UserProfilePB? _userProfilePB; @override @@ -61,7 +73,8 @@ class _ResizableImageState extends State { imageWidth = widget.width; - _userProfilePB = context.read().state.userProfilePB; + _userProfilePB = context.read()?.userProfile ?? + context.read().state.userProfilePB; } @override @@ -72,13 +85,12 @@ class _ResizableImageState extends State { width: max(_kImageBlockComponentMinWidth, imageWidth - moveDistance), height: widget.height, child: MouseRegion( - onEnter: (event) => setState(() { - onFocus = true; - }), - onExit: (event) => setState(() { - onFocus = false; - }), - child: _buildResizableImage(context), + onEnter: (_) => setState(() => onFocus = true), + onExit: (_) => setState(() => onFocus = false), + child: GestureDetector( + onDoubleTap: widget.onDoubleTap, + child: _buildResizableImage(context), + ), ), ), ); @@ -88,21 +100,39 @@ class _ResizableImageState extends State { 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, - errorWidgetBuilder: (context, url, error) => _ImageLoadFailedWidget( - width: imageWidth, - error: error, - ), - progressIndicatorBuilder: (context, url, progress) => - _buildLoading(context), + onImageLoaded: (isImageInCache) { + if (isImageInCache) { + widget.onStateChange?.call(ResizableImageState.loaded); + } + }, + progressIndicatorBuilder: (context, _, progress) { + if (progress.totalSize != null) { + if (progress.progress == 1) { + widget.onStateChange?.call(ResizableImageState.loaded); + } else { + widget.onStateChange?.call(ResizableImageState.loading); + } + } + + return _buildLoading(context); + }, + errorWidgetBuilder: (_, __, error) { + widget.onStateChange?.call(ResizableImageState.failed); + return _ImageLoadFailedWidget( + width: imageWidth, + error: error, + onRetry: () { + setState(() { + final retryCounter = FlowyNetworkRetryCounter(); + retryCounter.clear(tag: src, url: src); + }); + }, + ); + }, ); child = _cacheImage!; @@ -121,11 +151,7 @@ class _ResizableImageState extends State { left: 5, bottom: 0, width: 5, - onUpdate: (distance) { - setState(() { - moveDistance = distance; - }); - }, + onUpdate: (distance) => setState(() => moveDistance = distance), ), _buildEdgeGesture( context, @@ -133,11 +159,7 @@ class _ResizableImageState extends State { right: 5, bottom: 0, width: 5, - onUpdate: (distance) { - setState(() { - moveDistance = -distance; - }); - }, + onUpdate: (distance) => setState(() => moveDistance = -distance), ), ], ], @@ -154,9 +176,7 @@ class _ResizableImageState extends State { size: const Size(18, 18), child: const CircularProgressIndicator(), ), - SizedBox.fromSize( - size: const Size(10, 10), - ), + SizedBox.fromSize(size: const Size(10, 10)), Text(AppFlowyEditorL10n.current.loading), ], ), @@ -184,7 +204,7 @@ class _ResizableImageState extends State { }, onHorizontalDragUpdate: (details) { if (onUpdate != null) { - var offset = details.globalPosition.dx - initialOffset; + double offset = details.globalPosition.dx - initialOffset; if (widget.alignment == Alignment.center) { offset *= 2.0; } @@ -206,7 +226,7 @@ class _ResizableImageState extends State { child: Container( height: 40, decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), + color: Colors.black.withValues(alpha: 0.5), borderRadius: const BorderRadius.all( Radius.circular(5.0), ), @@ -225,44 +245,50 @@ class _ImageLoadFailedWidget extends StatelessWidget { const _ImageLoadFailedWidget({ required this.width, required this.error, + required this.onRetry, }); final double width; final Object error; + final VoidCallback onRetry; @override Widget build(BuildContext context) { final error = _getErrorMessage(); return Container( - height: 140, + height: 160, width: width, alignment: Alignment.center, - padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + padding: const EdgeInsets.symmetric(vertical: 8.0), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4.0)), - border: Border.all( - color: Colors.grey.withOpacity(0.6), - ), + border: Border.all(color: Colors.grey.withValues(alpha: 0.6)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const FlowySvg( FlowySvgs.broken_image_xl, - size: Size.square(48), + size: Size.square(36), ), FlowyText( AppFlowyEditorL10n.current.imageLoadFailed, + fontSize: 14, ), - const VSpace(6), + const VSpace(4), if (error != null) FlowyText( error, textAlign: TextAlign.center, - color: Theme.of(context).hintColor.withOpacity(0.6), + color: Theme.of(context).hintColor.withValues(alpha: 0.6), fontSize: 10, maxLines: 2, ), + const VSpace(12), + OutlinedRoundedButton( + text: LocaleKeys.chat_retry.tr(), + onTap: onRetry, + ), ], ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart deleted file mode 100644 index 0d5d986d10..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy_result/appflowy_result.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:flutter/material.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -class StabilityAIImageWidget extends StatefulWidget { - const StabilityAIImageWidget({ - super.key, - required this.onSelectImage, - }); - - final void Function(String url) onSelectImage; - - @override - State createState() => _StabilityAIImageWidgetState(); -} - -class _StabilityAIImageWidgetState extends State { - Future, StabilityAIRequestError>>? future; - String query = ''; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: FlowyTextField( - hintText: LocaleKeys - .document_imageBlock_stability_ai_placeholder - .tr(), - onChanged: (value) => query = value, - onEditingComplete: _search, - ), - ), - const HSpace(4.0), - FlowyButton( - useIntrinsicWidth: true, - text: FlowyText( - LocaleKeys.search_label.tr(), - ), - onTap: _search, - ), - ], - ), - const VSpace(12.0), - if (future != null) - Expanded( - child: FutureBuilder( - future: future, - builder: (context, value) { - final data = value.data; - if (!value.hasData || - value.connectionState != ConnectionState.done || - data == null) { - return const CircularProgressIndicator.adaptive(); - } - return data.fold( - (s) => GridView.count( - crossAxisCount: 3, - mainAxisSpacing: 16.0, - crossAxisSpacing: 10.0, - childAspectRatio: 4 / 3, - children: s.map( - (e) { - final base64Image = base64Decode(e); - return GestureDetector( - onTap: () async { - final tempDirectory = await getTemporaryDirectory(); - final path = p.join( - tempDirectory.path, - '${uuid()}.png', - ); - File(path).writeAsBytesSync(base64Image); - widget.onSelectImage(path); - }, - child: Image.memory(base64Image), - ); - }, - ).toList(), - ), - (e) => Center( - child: FlowyText( - e.message, - maxLines: 3, - textAlign: TextAlign.center, - ), - ), - ); - }, - ), - ), - ], - ); - } - - void _search() async { - final stabilityAI = await getIt.getAsync(); - setState(() { - future = stabilityAI.generateImage( - prompt: query, - n: 6, - ); - }); - } -} 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 index a7ec64e5ce..949e946188 100644 --- 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 @@ -48,7 +48,6 @@ class _UnsplashImageWidgetState extends State { @override void initState() { super.initState(); - randomPhotos = unsplash.photos .random(count: 18, orientation: PhotoOrientation.landscape) .goAndGet(); @@ -57,7 +56,6 @@ class _UnsplashImageWidgetState extends State { @override void dispose() { unsplash.close(); - super.dispose(); } @@ -112,7 +110,7 @@ class _UnsplashImageWidgetState extends State { } } -class _UnsplashImages extends StatelessWidget { +class _UnsplashImages extends StatefulWidget { const _UnsplashImages({ required this.type, required this.photos, @@ -123,32 +121,43 @@ class _UnsplashImages extends StatelessWidget { final List photos; final OnSelectUnsplashImage onSelectUnsplashImage; + @override + State<_UnsplashImages> createState() => _UnsplashImagesState(); +} + +class _UnsplashImagesState extends State<_UnsplashImages> { + int _selectedPhotoIndex = -1; + @override Widget build(BuildContext context) { - final crossAxisCount = switch (type) { + const mainAxisSpacing = 16.0; + final crossAxisCount = switch (widget.type) { UnsplashImageType.halfScreen => 3, UnsplashImageType.fullScreen => 2, }; - final mainAxisSpacing = switch (type) { - UnsplashImageType.halfScreen => 16.0, - UnsplashImageType.fullScreen => 8.0, + final crossAxisSpacing = switch (widget.type) { + UnsplashImageType.halfScreen => 10.0, + UnsplashImageType.fullScreen => 16.0, }; + return GridView.count( crossAxisCount: crossAxisCount, mainAxisSpacing: mainAxisSpacing, - crossAxisSpacing: 10.0, + crossAxisSpacing: crossAxisSpacing, childAspectRatio: 4 / 3, - children: photos - .map( - (photo) => _UnsplashImage( - type: type, - photo: photo, - onTap: () => onSelectUnsplashImage( - photo.urls.regular.toString(), - ), - ), - ) - .toList(), + 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(), ); } } @@ -158,11 +167,13 @@ class _UnsplashImage extends StatelessWidget { 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) { @@ -173,7 +184,19 @@ class _UnsplashImage extends StatelessWidget { return GestureDetector( onTap: onTap, - child: child, + 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, ); } @@ -188,37 +211,35 @@ class _UnsplashImage extends StatelessWidget { ), ), const HSpace(2.0), - FlowyText( - 'by ${photo.name}', - fontSize: 10.0, - ), + FlowyText('by ${photo.name}', fontSize: 10.0), ], ); } Widget _buildFullScreenImage(BuildContext context) { - return Stack( - children: [ - LayoutBuilder( - builder: (context, constraints) { - return Image.network( + 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: 6, - left: 6, - child: FlowyText.medium( - photo.name, - fontSize: 10.0, - color: Colors.white, + ), ), - ), - ], + Positioned( + bottom: 9, + left: 10, + child: FlowyText.medium( + photo.name, + fontSize: 13.0, + color: Colors.white, + ), + ), + ], + ), ); } } @@ -227,13 +248,9 @@ extension on Photo { String get name { if (user.username.isNotEmpty) { return user.username; - } - - if (user.name.isNotEmpty) { + } else if (user.name.isNotEmpty) { return user.name; - } - - if (user.email?.isNotEmpty == true) { + } else if (user.email?.isNotEmpty == true) { return user.email!; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart deleted file mode 100644 index 3403a1ff31..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart +++ /dev/null @@ -1,43 +0,0 @@ -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'; -import 'package:flutter/material.dart'; - -class UnSupportImageWidget extends StatelessWidget { - const UnSupportImageWidget({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, - 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/upload_image_file_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart deleted file mode 100644 index d4d94be091..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart +++ /dev/null @@ -1,67 +0,0 @@ -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_backend/log.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; -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'; - -class UploadImageFileWidget extends StatelessWidget { - const UploadImageFileWidget({ - super.key, - required this.onPickFile, - this.allowedExtensions = const ['jpg', 'png', 'jpeg'], - }); - - final void Function(String? path) onPickFile; - final List allowedExtensions; - - @override - Widget build(BuildContext context) { - final child = FlowyButton( - showDefaultBoxDecorationOnMobile: true, - text: Container( - margin: const EdgeInsets.all(4.0), - alignment: Alignment.center, - child: FlowyText( - LocaleKeys.document_imageBlock_upload_placeholder.tr(), - ), - ), - onTap: () => _uploadImage(context), - ); - - if (PlatformExtension.isDesktopOrWeb) { - return FlowyHover( - child: child, - ); - } - - return child; - } - - Future _uploadImage(BuildContext context) async { - if (PlatformExtension.isDesktopOrWeb) { - // on desktop, the users can pick a image file from folder - final result = await getIt().pickFiles( - dialogTitle: '', - type: FileType.custom, - allowedExtensions: allowedExtensions, - ); - onPickFile(result?.files.firstOrNull?.path); - } 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().pickImage(source: ImageSource.gallery); - onPickFile(result?.path); - } - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart deleted file mode 100644 index 4c9de6b07d..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart +++ /dev/null @@ -1,250 +0,0 @@ -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/embed_image_url_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.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_file_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/user/application/user_service.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide ColorOption; -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'; - -enum UploadImageType { - local, - url, - unsplash, - stabilityAI, - openAI, - color; - - String get description { - switch (this) { - case UploadImageType.local: - return LocaleKeys.document_imageBlock_upload_label.tr(); - case UploadImageType.url: - return LocaleKeys.document_imageBlock_embedLink_label.tr(); - case UploadImageType.unsplash: - return LocaleKeys.document_imageBlock_unsplash_label.tr(); - case UploadImageType.openAI: - return LocaleKeys.document_imageBlock_ai_label.tr(); - case UploadImageType.stabilityAI: - return LocaleKeys.document_imageBlock_stability_ai_label.tr(); - case UploadImageType.color: - return LocaleKeys.document_plugins_cover_colors.tr(); - } - } -} - -class UploadImageMenu extends StatefulWidget { - const UploadImageMenu({ - super.key, - required this.onSelectedLocalImage, - required this.onSelectedAIImage, - required this.onSelectedNetworkImage, - this.onSelectedColor, - this.supportTypes = UploadImageType.values, - this.limitMaximumImageSize = false, - }); - - final void Function(String? path) onSelectedLocalImage; - final void Function(String url) onSelectedAIImage; - final void Function(String url) onSelectedNetworkImage; - final void Function(String color)? onSelectedColor; - final List supportTypes; - final bool limitMaximumImageSize; - - @override - State createState() => _UploadImageMenuState(); -} - -class _UploadImageMenuState extends State { - late final List values; - int currentTabIndex = 0; - bool supportOpenAI = false; - bool supportStabilityAI = false; - - @override - void initState() { - super.initState(); - - values = widget.supportTypes; - UserBackendService.getCurrentUserProfile().then( - (value) { - final supportOpenAI = value.fold( - (s) => s.openaiKey.isNotEmpty, - (e) => false, - ); - final supportStabilityAI = value.fold( - (s) => s.stabilityAiKey.isNotEmpty, - (e) => false, - ); - if (supportOpenAI != this.supportOpenAI || - supportStabilityAI != this.supportStabilityAI) { - setState(() { - this.supportOpenAI = supportOpenAI; - this.supportStabilityAI = supportStabilityAI; - }); - } - }, - ); - } - - @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: MaterialStatePropertyAll( - PlatformExtension.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: PlatformExtension.isMobile ? 0 : 8.0, - ), - child: FlowyText(e.description), - ); - if (PlatformExtension.isDesktop) { - return FlowyHover( - style: const HoverStyle(borderRadius: BorderRadius.zero), - child: child, - ); - } - return child; - }, - ).toList(), - ), - const Divider( - height: 2, - ), - _buildTab(), - ], - ), - ); - } - - Widget _buildTab() { - final constraints = - PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null; - final type = values[currentTabIndex]; - switch (type) { - case UploadImageType.local: - return Container( - padding: const EdgeInsets.all(8.0), - alignment: Alignment.center, - constraints: constraints, - child: Column( - children: [ - UploadImageFileWidget( - onPickFile: widget.onSelectedLocalImage, - ), - if (widget.limitMaximumImageSize) ...[ - const VSpace(6.0), - FlowyText( - LocaleKeys.document_imageBlock_maximumImageSize.tr(), - fontSize: 12.0, - color: Theme.of(context).hintColor, - ), - ], - ], - ), - ); - 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.openAI: - return supportOpenAI - ? Expanded( - child: Container( - padding: const EdgeInsets.all(8.0), - constraints: constraints, - child: OpenAIImageWidget( - onSelectNetworkImage: widget.onSelectedAIImage, - ), - ), - ) - : Padding( - padding: const EdgeInsets.all(8.0), - child: FlowyText( - LocaleKeys.document_imageBlock_pleaseInputYourOpenAIKey.tr(), - ), - ); - case UploadImageType.stabilityAI: - return supportStabilityAI - ? Expanded( - child: Container( - padding: const EdgeInsets.all(8.0), - child: StabilityAIImageWidget( - onSelectImage: widget.onSelectedLocalImage, - ), - ), - ) - : Padding( - padding: const EdgeInsets.all(8.0), - child: FlowyText( - LocaleKeys.document_imageBlock_pleaseInputYourStabilityAIKey - .tr(), - ), - ); - case UploadImageType.color: - final theme = Theme.of(context); - final padding = PlatformExtension.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/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 0000000000..836f087797 --- /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 0000000000..28dccad72d --- /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,92 @@ +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.withValues(alpha: 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 0000000000..f08c74b84e --- /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 index 543cee1207..e41bdc1114 100644 --- 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 @@ -1,6 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_popover/appflowy_popover.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'; @@ -37,7 +36,6 @@ class _InlineMathEquationState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); return _IgnoreParentPointer( child: AppFlowyPopover( controller: popoverController, @@ -61,33 +59,40 @@ class _InlineMathEquationState extends State { }, offset: const Offset(0, 10), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), + padding: const EdgeInsets.symmetric(vertical: 2.0), child: MouseRegion( cursor: SystemMouseCursors.click, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const HSpace(2), - Math.tex( - widget.formula, - options: MathOptions( - style: MathStyle.text, - mathFontOptions: const FontOptions( - fontShape: FontStyle.italic, - ), - fontSize: 14.0, - color: widget.textStyle?.color ?? - theme.colorScheme.onBackground, - ), - ), - const HSpace(2), - ], - ), + 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 { 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 index 08c23df05b..cd3779cb6c 100644 --- 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 @@ -5,11 +5,13 @@ 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: 'editor.inline_math_equation', - group: 2, + id: _kInlineMathEquationToolbarItemId, + group: 4, isActive: onlyShowInSingleSelectionAndTextType, - builder: (context, editorState, highlightColor, _) { + builder: (context, editorState, highlightColor, _, tooltipBuilder) { final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); final isHighlight = nodes.allSatisfyInSelection(selection, (delta) { @@ -17,7 +19,7 @@ final ToolbarItem inlineMathEquationItem = ToolbarItem( (attributes) => attributes[InlineMathEquationKeys.formula] != null, ); }); - return SVGIconItemWidget( + final child = SVGIconItemWidget( iconBuilder: (_) => FlowySvg( FlowySvgs.math_lg, size: const Size.square(16), @@ -25,7 +27,6 @@ final ToolbarItem inlineMathEquationItem = ToolbarItem( ), isHighlight: isHighlight, highlightColor: highlightColor, - tooltip: LocaleKeys.document_plugins_createInlineMathEquation.tr(), onPressed: () async { final selection = editorState.selection; if (selection == null || selection.isCollapsed) { @@ -62,7 +63,7 @@ final ToolbarItem inlineMathEquationItem = ToolbarItem( node, selection.startIndex, selection.length, - '\$', + MentionBlockKeys.mentionChar, attributes: { InlineMathEquationKeys.formula: text, }, @@ -71,5 +72,16 @@ final ToolbarItem inlineMathEquationItem = ToolbarItem( 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 0000000000..040989243f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/keyboard_interceptor/keyboard_interceptor.dart @@ -0,0 +1,125 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; +import 'package:appflowy/shared/patterns/common_patterns.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:universal_platform/universal_platform.dart'; + +class EditorKeyboardInterceptor extends AppFlowyKeyboardServiceInterceptor { + @override + Future interceptInsert( + TextEditingDeltaInsertion insertion, + EditorState editorState, + List characterShortcutEvents, + ) async { + // Only check on the mobile platform: check if the inserted text is a link, if so, try to paste it as a link preview + final text = insertion.textInserted; + if (UniversalPlatform.isMobile && hrefRegex.hasMatch(text)) { + final result = customPasteCommand.execute(editorState); + return result == KeyEventResult.handled; + } + return false; + } + + @override + Future interceptReplace( + TextEditingDeltaReplacement replacement, + EditorState editorState, + List characterShortcutEvents, + ) async { + // Only check on the mobile platform: check if the replaced text is a link, if so, try to paste it as a link preview + final text = replacement.replacementText; + if (UniversalPlatform.isMobile && hrefRegex.hasMatch(text)) { + final result = customPasteCommand.execute(editorState); + return result == KeyEventResult.handled; + } + return false; + } + + @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_embed/link_embed_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart new file mode 100644 index 0000000000..baf9702a36 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart @@ -0,0 +1,310 @@ +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/callout/callout_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:appflowy_ui/appflowy_ui.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 'link_embed_menu.dart'; + +class LinkEmbedKeys { + const LinkEmbedKeys._(); + static const String previewType = 'preview_type'; + static const String embed = 'embed'; + static const String align = 'align'; +} + +Node linkEmbedNode({required String url}) => Node( + type: LinkPreviewBlockKeys.type, + attributes: { + LinkPreviewBlockKeys.url: url, + LinkEmbedKeys.previewType: LinkEmbedKeys.embed, + }, + ); + +class LinkEmbedBlockComponent extends BlockComponentStatefulWidget { + const LinkEmbedBlockComponent({ + super.key, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + required super.node, + }); + + @override + DefaultSelectableMixinState createState() => + LinkEmbedBlockComponentState(); +} + +class LinkEmbedBlockComponentState + extends DefaultSelectableMixinState + with BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + String get url => widget.node.attributes[LinkPreviewBlockKeys.url] ?? ''; + + LinkLoadingStatus status = LinkLoadingStatus.loading; + final parser = LinkParser(); + late LinkInfo linkInfo = LinkInfo(url: url); + + final showActionsNotifier = ValueNotifier(false); + bool isMenuShowing = false, isHovering = false; + + @override + void initState() { + super.initState(); + parser.addLinkInfoListener((v) { + final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); + if (mounted) { + setState(() { + if (hasNewInfo) { + linkInfo = v; + status = LinkLoadingStatus.idle; + } else if (!hasOldInfo) { + status = LinkLoadingStatus.error; + } + }); + } + }); + parser.start(url); + } + + @override + void dispose() { + parser.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget result = MouseRegion( + onEnter: (_) { + isHovering = true; + showActionsNotifier.value = true; + }, + onExit: (_) { + isHovering = false; + Future.delayed(const Duration(milliseconds: 200), () { + if (isMenuShowing || isHovering) return; + if (mounted) showActionsNotifier.value = false; + }); + }, + child: buildChild(context), + ); + final parent = node.parent; + EdgeInsets newPadding = padding; + if (parent?.type == CalloutBlockKeys.type) { + newPadding = padding.copyWith(right: padding.right + 10); + } + + result = Padding(padding: newPadding, child: result); + + if (widget.showActions && widget.actionBuilder != null) { + result = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: result, + ); + } + return result; + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + fillSceme = theme.fillColorScheme, + borderScheme = theme.borderColorScheme; + Widget child; + final isIdle = status == LinkLoadingStatus.idle; + if (isIdle) { + child = buildContent(context); + } else { + child = buildErrorLoadingWidget(context); + } + return Container( + height: 450, + key: widgetKey, + decoration: BoxDecoration( + color: isIdle ? Theme.of(context).cardColor : fillSceme.tertiaryHover, + borderRadius: BorderRadius.all(Radius.circular(16)), + border: Border.all(color: borderScheme.greyTertiary), + ), + child: Stack( + children: [ + child, + buildMenu(context), + ], + ), + ); + } + + Widget buildMenu(BuildContext context) { + return Positioned( + top: 12, + right: 12, + child: ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (context, showActions, child) { + if (!showActions) return SizedBox.shrink(); + return LinkEmbedMenu( + editorState: context.read(), + node: node, + onReload: () { + setState(() { + status = LinkLoadingStatus.loading; + }); + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) parser.start(url); + }); + }, + onMenuShowed: () { + isMenuShowing = true; + }, + onMenuHided: () { + isMenuShowing = false; + if (!isHovering && mounted) { + showActionsNotifier.value = false; + } + }, + ); + }, + ), + ); + } + + Widget buildContent(BuildContext context) { + final theme = AppFlowyTheme.of(context), textScheme = theme.textColorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + child: FlowyNetworkImage( + url: linkInfo.imageUrl ?? '', + width: MediaQuery.of(context).size.width, + ), + ), + ), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + afLaunchUrlString(url, addingHttpSchemeWhenFailed: true), + child: Container( + height: 64, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + child: Row( + children: [ + SizedBox.square( + dimension: 40, + child: Center( + child: linkInfo.buildIconWidget(size: Size.square(32)), + ), + ), + HSpace(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + linkInfo.siteName ?? '', + color: textScheme.primary, + fontSize: 14, + figmaLineHeight: 20, + fontWeight: FontWeight.w600, + overflow: TextOverflow.ellipsis, + ), + VSpace(4), + FlowyText.regular( + url, + color: textScheme.secondary, + fontSize: 12, + figmaLineHeight: 16, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget buildErrorLoadingWidget(BuildContext context) { + final theme = AppFlowyTheme.of(context), textSceme = theme.textColorScheme; + final isLoading = status == LinkLoadingStatus.loading; + return isLoading + ? Center( + child: SizedBox.square( + dimension: 64, + child: CircularProgressIndicator.adaptive(), + ), + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + FlowySvgs.embed_error_xl.path, + ), + VSpace(4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: [ + TextSpan( + text: '$url ', + style: TextStyle( + color: textSceme.primary, + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w700, + ), + ), + TextSpan( + text: LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_unableToDisplay + .tr(), + style: TextStyle( + color: textSceme.primary, + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + @override + Node get currentNode => node; + + @override + EdgeInsets get boxPadding => padding; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart new file mode 100644 index 0000000000..c3d2aebbcc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart @@ -0,0 +1,354 @@ +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/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/widgets.dart'; + +import 'link_embed_block_component.dart'; + +class LinkEmbedMenu extends StatefulWidget { + const LinkEmbedMenu({ + super.key, + required this.node, + required this.editorState, + required this.onMenuShowed, + required this.onMenuHided, + required this.onReload, + }); + + final Node node; + final EditorState editorState; + final VoidCallback onMenuShowed; + final VoidCallback onMenuHided; + final VoidCallback onReload; + + @override + State createState() => _LinkEmbedMenuState(); +} + +class _LinkEmbedMenuState extends State { + final turnintoController = PopoverController(); + final moreOptionController = PopoverController(); + int turnintoMenuNum = 0, moreOptionNum = 0, alignMenuNum = 0; + final moreOptionButtonKey = GlobalKey(); + bool get isTurnIntoShowing => turnintoMenuNum > 0; + bool get isMoreOptionShowing => moreOptionNum > 0; + bool get isAlignMenuShowing => alignMenuNum > 0; + + Node get node => widget.node; + EditorState get editorState => widget.editorState; + + String get url => node.attributes[LinkPreviewBlockKeys.url] ?? ''; + + @override + void dispose() { + super.dispose(); + turnintoController.close(); + moreOptionController.close(); + widget.onMenuHided.call(); + } + + @override + Widget build(BuildContext context) { + return buildChild(); + } + + Widget buildChild() { + final theme = AppFlowyTheme.of(context), + iconScheme = theme.iconColorScheme, + fillScheme = theme.fillColorScheme; + + return Container( + padding: EdgeInsets.all(4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: fillScheme.primaryAlpha80, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // FlowyIconButton( + // icon: FlowySvg( + // FlowySvgs.embed_fullscreen_m, + // color: iconScheme.tertiary, + // ), + // tooltipText: LocaleKeys.document_imageBlock_openFullScreen.tr(), + // preferBelow: false, + // onPressed: () {}, + // ), + FlowyIconButton( + icon: FlowySvg( + FlowySvgs.toolbar_link_m, + color: iconScheme.tertiary, + ), + radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), + tooltipText: LocaleKeys.editor_copyLink.tr(), + preferBelow: false, + onPressed: () => copyLink(context), + ), + buildconvertBotton(), + buildMoreOptionBotton(), + ], + ), + ); + } + + Widget buildconvertBotton() { + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; + return AppFlowyPopover( + offset: Offset(0, 6), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: turnintoController, + onOpen: () { + keepEditorFocusNotifier.increase(); + turnintoMenuNum++; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + turnintoMenuNum--; + checkToHideMenu(); + }, + popupBuilder: (context) => buildConvertMenu(), + child: FlowyIconButton( + icon: FlowySvg( + FlowySvgs.turninto_m, + color: iconScheme.tertiary, + ), + radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), + tooltipText: LocaleKeys.editor_convertTo.tr(), + preferBelow: false, + onPressed: showTurnIntoMenu, + ), + ); + } + + Widget buildConvertMenu() { + final types = LinkEmbedConvertCommand.values; + return Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: List.generate(types.length, (index) { + final command = types[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () { + if (command == LinkEmbedConvertCommand.toBookmark) { + final transaction = editorState.transaction; + transaction.updateNode(node, { + LinkPreviewBlockKeys.url: url, + LinkEmbedKeys.previewType: '', + }); + editorState.apply(transaction); + } else if (command == LinkEmbedConvertCommand.toMention) { + convertUrlPreviewNodeToMention(editorState, node); + } else if (command == LinkEmbedConvertCommand.toURL) { + convertUrlPreviewNodeToLink(editorState, node); + } + }, + ), + ); + }), + ), + ); + } + + Widget buildMoreOptionBotton() { + final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; + return AppFlowyPopover( + offset: Offset(0, 6), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: moreOptionController, + onOpen: () { + keepEditorFocusNotifier.increase(); + moreOptionNum++; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + moreOptionNum--; + checkToHideMenu(); + }, + popupBuilder: (context) => buildMoreOptionMenu(), + child: FlowyIconButton( + key: moreOptionButtonKey, + icon: FlowySvg( + FlowySvgs.toolbar_more_m, + color: iconScheme.tertiary, + ), + radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), + tooltipText: LocaleKeys.document_toolbar_moreOptions.tr(), + preferBelow: false, + onPressed: showMoreOptionMenu, + ), + ); + } + + Widget buildMoreOptionMenu() { + final types = LinkEmbedMenuCommand.values; + return Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: List.generate(types.length, (index) { + final command = types[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onEmbedMenuCommand(command), + ), + ); + }), + ), + ); + } + + void showTurnIntoMenu() { + keepEditorFocusNotifier.increase(); + turnintoController.show(); + checkToShowMenu(); + turnintoMenuNum++; + if (isMoreOptionShowing) closeMoreOptionMenu(); + } + + void closeTurnIntoMenu() { + turnintoController.close(); + checkToHideMenu(); + } + + void showMoreOptionMenu() { + keepEditorFocusNotifier.increase(); + moreOptionController.show(); + checkToShowMenu(); + moreOptionNum++; + if (isTurnIntoShowing) closeTurnIntoMenu(); + } + + void closeMoreOptionMenu() { + moreOptionController.close(); + checkToHideMenu(); + } + + void checkToHideMenu() { + Future.delayed(Duration(milliseconds: 200), () { + if (!mounted) return; + if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) { + widget.onMenuHided.call(); + } + }); + } + + void checkToShowMenu() { + if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) { + widget.onMenuShowed.call(); + } + } + + Future copyLink(BuildContext context) async { + await context.copyLink(url); + widget.onMenuHided.call(); + } + + void onEmbedMenuCommand(LinkEmbedMenuCommand command) { + switch (command) { + case LinkEmbedMenuCommand.openLink: + afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); + break; + case LinkEmbedMenuCommand.replace: + final box = moreOptionButtonKey.currentContext?.findRenderObject() + as RenderBox?; + if (box == null) return; + final p = box.localToGlobal(Offset.zero); + showReplaceMenu( + context: context, + editorState: editorState, + node: node, + url: url, + ltrb: LTRB(left: p.dx - 330, top: p.dy), + onReplace: (url) async { + await convertLinkBlockToOtherLinkBlock( + editorState, + node, + node.type, + url: url, + ); + }, + ); + break; + case LinkEmbedMenuCommand.reload: + widget.onReload.call(); + break; + case LinkEmbedMenuCommand.removeLink: + removeUrlPreviewLink(editorState, node); + break; + } + closeMoreOptionMenu(); + } +} + +enum LinkEmbedMenuCommand { + openLink, + replace, + reload, + removeLink; + + String get title { + switch (this) { + case openLink: + return LocaleKeys.editor_openLink.tr(); + case replace: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_replace + .tr(); + case reload: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_reload + .tr(); + case removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .tr(); + } + } +} + +enum LinkEmbedConvertCommand { + toMention, + toURL, + toBookmark; + + String get title { + switch (this) { + case toMention: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion + .tr(); + case toURL: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart new file mode 100644 index 0000000000..1907f68d29 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart @@ -0,0 +1,151 @@ +import 'dart:convert'; +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/appflowy_network_svg.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter/material.dart'; +import 'link_parsers/default_parser.dart'; +import 'link_parsers/youtube_parser.dart'; + +class LinkParser { + final Set> _listeners = >{}; + static final Map _hostToParsers = { + 'www.youtube.com': YoutubeParser(), + 'youtube.com': YoutubeParser(), + 'youtu.be': YoutubeParser(), + }; + + Future start(String url, {LinkInfoParser? parser}) async { + final uri = Uri.tryParse(LinkInfoParser.formatUrl(url)) ?? Uri.parse(url); + final data = await LinkInfoCache.get(uri); + if (data != null) { + refreshLinkInfo(data); + } + + final host = uri.host; + final currentParser = parser ?? _hostToParsers[host] ?? DefaultParser(); + await _getLinkInfo(uri, currentParser); + } + + Future _getLinkInfo(Uri uri, LinkInfoParser parser) async { + try { + final linkInfo = await parser.parse(uri) ?? LinkInfo(url: '$uri'); + if (!linkInfo.isEmpty()) await LinkInfoCache.set(uri, linkInfo); + refreshLinkInfo(linkInfo); + return linkInfo; + } catch (e, s) { + Log.error('get link info error: ', e, s); + refreshLinkInfo(LinkInfo(url: '$uri')); + return null; + } + } + + void refreshLinkInfo(LinkInfo info) { + for (final listener in _listeners) { + listener(info); + } + } + + void addLinkInfoListener(ValueChanged listener) { + _listeners.add(listener); + } + + void dispose() { + _listeners.clear(); + } +} + +class LinkInfo { + factory LinkInfo.fromJson(Map json) => LinkInfo( + siteName: json['siteName'], + url: json['url'] ?? '', + title: json['title'], + description: json['description'], + imageUrl: json['imageUrl'], + faviconUrl: json['faviconUrl'], + ); + + LinkInfo({ + required this.url, + this.siteName, + this.title, + this.description, + this.imageUrl, + this.faviconUrl, + }); + + final String url; + final String? siteName; + final String? title; + final String? description; + final String? imageUrl; + final String? faviconUrl; + + Map toJson() => { + 'url': url, + 'siteName': siteName, + 'title': title, + 'description': description, + 'imageUrl': imageUrl, + 'faviconUrl': faviconUrl, + }; + + @override + String toString() { + return 'LinkInfo{url: $url, siteName: $siteName, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl}'; + } + + bool isEmpty() { + return title == null; + } + + Widget buildIconWidget({Size size = const Size.square(20.0)}) { + final iconUrl = faviconUrl; + if (iconUrl == null) { + return FlowySvg(FlowySvgs.toolbar_link_earth_m, size: size); + } + if (iconUrl.endsWith('.svg')) { + return FlowyNetworkSvg( + iconUrl, + height: size.height, + width: size.width, + errorWidget: const FlowySvg(FlowySvgs.toolbar_link_earth_m), + ); + } + return FlowyNetworkImage( + url: iconUrl, + fit: BoxFit.contain, + height: size.height, + width: size.width, + errorWidgetBuilder: (context, error, stackTrace) => + const FlowySvg(FlowySvgs.toolbar_link_earth_m), + ); + } +} + +class LinkInfoCache { + static const _linkInfoPrefix = 'link_info'; + + static Future get(Uri uri) async { + final option = await getIt().getWithFormat( + '$_linkInfoPrefix$uri', + (value) => LinkInfo.fromJson(jsonDecode(value)), + ); + return option; + } + + static Future set(Uri uri, LinkInfo data) async { + await getIt().set( + '$_linkInfoPrefix$uri', + jsonEncode(data.toJson()), + ); + } +} + +enum LinkLoadingStatus { + loading, + idle, + error, +} 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 index 152e7ed20a..9be73fcc0b 100644 --- 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 @@ -1,17 +1,22 @@ -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/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/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/util/theme_extension.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'; +import 'package:appflowy_ui/appflowy_ui.dart'; + +import 'custom_link_parser.dart'; class CustomLinkPreviewWidget extends StatelessWidget { const CustomLinkPreviewWidget({ @@ -21,6 +26,8 @@ class CustomLinkPreviewWidget extends StatelessWidget { this.title, this.description, this.imageUrl, + this.isHovering = false, + this.status = LinkLoadingStatus.loading, }); final Node node; @@ -28,9 +35,14 @@ class CustomLinkPreviewWidget extends StatelessWidget { final String? description; final String? imageUrl; final String url; + final bool isHovering; + final LinkLoadingStatus status; @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context), + borderScheme = theme.borderColorScheme, + textScheme = theme.textColorScheme; final documentFontSize = context .read() .editorStyle @@ -38,73 +50,67 @@ class CustomLinkPreviewWidget extends StatelessWidget { .text .fontSize ?? 16.0; - final (fontSize, width) = PlatformExtension.isDesktopOrWeb - ? (documentFontSize, 180.0) + final isInDarkCallout = node.parent?.type == CalloutBlockKeys.type && + !Theme.of(context).isLightMode; + final (fontSize, width) = UniversalPlatform.isDesktopOrWeb + ? (documentFontSize, 160.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, + color: isHovering || isInDarkCallout + ? borderScheme.greyTertiaryHover + : borderScheme.greyTertiary, ), + borderRadius: BorderRadius.circular(16.0), ), - child: IntrinsicHeight( + child: SizedBox( + height: 96, 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, - ), - ), + buildImage(context), 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, - ), + padding: const EdgeInsets.fromLTRB(20, 12, 58, 12), + child: status != LinkLoadingStatus.idle + ? buildLoadingOrErrorWidget() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyText.medium( + title!, + overflow: TextOverflow.ellipsis, + fontSize: fontSize, + color: textScheme.primary, + figmaLineHeight: 20, + ), + ), + if (description != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: FlowyText( + description!, + overflow: TextOverflow.ellipsis, + fontSize: fontSize - 4, + figmaLineHeight: 16, + color: textScheme.primary, + ), + ), + FlowyText( + url.toString(), + overflow: TextOverflow.ellipsis, + color: textScheme.secondary, + fontSize: fontSize - 4, + figmaLineHeight: 16, + ), + ], ), - 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, - ), - ], - ), ), ), ], @@ -112,10 +118,13 @@ class CustomLinkPreviewWidget extends StatelessWidget { ), ); - if (PlatformExtension.isDesktopOrWeb) { - return InkWell( - onTap: () => afLaunchUrlString(url), - child: child, + if (UniversalPlatform.isDesktopOrWeb) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => afLaunchUrlString(url), + child: child, + ), ); } @@ -123,7 +132,10 @@ class CustomLinkPreviewWidget extends StatelessWidget { node: node, editorState: context.read(), extendActionWidgets: _buildExtendActionWidgets(context), - child: child, + child: GestureDetector( + onTap: () => afLaunchUrlString(url), + child: child, + ), ); } @@ -134,8 +146,8 @@ class CustomLinkPreviewWidget extends StatelessWidget { showTopBorder: false, text: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), leftIcon: const FlowySvg( - FlowySvgs.m_aa_link_s, - size: Size.square(20), + FlowySvgs.m_toolbar_link_m, + size: Size.square(18), ), onTap: () { context.pop(); @@ -147,4 +159,59 @@ class CustomLinkPreviewWidget extends StatelessWidget { ), ]; } + + Widget buildImage(BuildContext context) { + if (imageUrl?.isEmpty ?? true) { + return SizedBox.shrink(); + } + final theme = AppFlowyTheme.of(context), + fillScheme = theme.fillColorScheme, + iconScheme = theme.iconColorScheme; + final width = UniversalPlatform.isDesktopOrWeb ? 160.0 : 120.0; + return ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16.0), + bottomLeft: Radius.circular(16.0), + ), + child: Container( + width: width, + color: fillScheme.quaternary, + child: FlowyNetworkImage( + url: imageUrl!, + width: width, + errorWidgetBuilder: (_, __, ___) => Center( + child: FlowySvg( + FlowySvgs.toolbar_link_earth_m, + color: iconScheme.secondary, + size: Size.square(30), + ), + ), + ), + ), + ); + } + + Widget buildLoadingOrErrorWidget() { + if (status == LinkLoadingStatus.loading) { + return const Center( + child: SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator.adaptive(), + ), + ); + } else if (status == LinkLoadingStatus.error) { + return const Center( + child: SizedBox( + height: 16, + width: 16, + child: Icon( + Icons.error_outline, + color: Colors.red, + ), + ), + ); + } + return SizedBox.shrink(); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart new file mode 100644 index 0000000000..3f2128db52 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart @@ -0,0 +1,194 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter/material.dart'; + +import 'custom_link_preview.dart'; +import 'default_selectable_mixin.dart'; +import 'link_preview_menu.dart'; + +class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder { + CustomLinkPreviewBlockComponentBuilder({ + super.configuration, + }); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + final isEmbed = + node.attributes[LinkEmbedKeys.previewType] == LinkEmbedKeys.embed; + if (isEmbed) { + return LinkEmbedBlockComponent( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (_, state) => + actionBuilder(blockComponentContext, state), + ); + } + return CustomLinkPreviewBlockComponent( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + ); + } + + @override + BlockComponentValidate get validate => + (node) => node.attributes[LinkPreviewBlockKeys.url]!.isNotEmpty; +} + +class CustomLinkPreviewBlockComponent extends BlockComponentStatefulWidget { + const CustomLinkPreviewBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + DefaultSelectableMixinState createState() => + CustomLinkPreviewBlockComponentState(); +} + +class CustomLinkPreviewBlockComponentState + extends DefaultSelectableMixinState + with BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!; + + final parser = LinkParser(); + LinkLoadingStatus status = LinkLoadingStatus.loading; + late LinkInfo linkInfo = LinkInfo(url: url); + + final showActionsNotifier = ValueNotifier(false); + bool isMenuShowing = false, isHovering = false; + + @override + void initState() { + super.initState(); + parser.addLinkInfoListener((v) { + final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); + if (mounted) { + setState(() { + if (hasNewInfo) { + linkInfo = v; + status = LinkLoadingStatus.idle; + } else if (!hasOldInfo) { + status = LinkLoadingStatus.error; + } + }); + } + }); + parser.start(url); + } + + @override + void dispose() { + parser.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) { + isHovering = true; + showActionsNotifier.value = true; + }, + onExit: (_) { + isHovering = false; + Future.delayed(const Duration(milliseconds: 200), () { + if (isMenuShowing || isHovering) return; + if (mounted) showActionsNotifier.value = false; + }); + }, + hitTestBehavior: HitTestBehavior.opaque, + opaque: false, + child: ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (context, showActions, child) { + return buildPreview(showActions); + }, + ), + ); + } + + Widget buildPreview(bool showActions) { + Widget child = CustomLinkPreviewWidget( + key: widgetKey, + node: node, + url: url, + isHovering: showActions, + title: linkInfo.siteName, + description: linkInfo.description, + imageUrl: linkInfo.imageUrl, + status: status, + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + child = Stack( + children: [ + child, + if (showActions) + Positioned( + top: 12, + right: 12, + child: CustomLinkPreviewMenu( + onMenuShowed: () { + isMenuShowing = true; + }, + onMenuHided: () { + isMenuShowing = false; + if (!isHovering && mounted) { + showActionsNotifier.value = false; + } + }, + onReload: () { + setState(() { + status = LinkLoadingStatus.loading; + }); + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) parser.start(url); + }); + }, + node: node, + ), + ), + ], + ); + + final parent = node.parent; + EdgeInsets newPadding = padding; + if (parent?.type == CalloutBlockKeys.type) { + newPadding = padding.copyWith(right: padding.right + 10); + } + child = Padding(padding: newPadding, child: child); + + return child; + } + + @override + Node get currentNode => node; + + @override + EdgeInsets get boxPadding => padding; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart new file mode 100644 index 0000000000..c894811522 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/widgets.dart'; + +abstract class DefaultSelectableMixinState + extends State with SelectableMixin { + final widgetKey = GlobalKey(); + RenderBox? get _renderBox => + widgetKey.currentContext?.findRenderObject() as RenderBox?; + + Node get currentNode; + + EdgeInsets get boxPadding => EdgeInsets.zero; + + @override + Position start() => Position(path: currentNode.path); + + @override + Position end() => Position(path: currentNode.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 box = _renderBox; + if (box is RenderBox) { + return boxPadding.topLeft & box.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 box = widgetKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && box is RenderBox) { + return [ + box.localToGlobal(Offset.zero, ancestor: parentBox) & box.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: currentNode.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) => + _renderBox!.localToGlobal(offset); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart new file mode 100644 index 0000000000..7b52994654 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart @@ -0,0 +1,95 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy_backend/log.dart'; +// ignore: depend_on_referenced_packages +import 'package:html/parser.dart' as html_parser; +import 'package:http/http.dart' as http; +import 'dart:convert'; + +abstract class LinkInfoParser { + Future parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? headers, + }); + + static String formatUrl(String url) { + Uri? uri = Uri.tryParse(url); + if (uri == null) return url; + if (!uri.hasScheme) uri = Uri.tryParse('http://$url'); + if (uri == null) return url; + final isHome = (uri.hasEmptyPath || uri.path == '/') && !uri.hasQuery; + final homeUrl = '${uri.scheme}://${uri.host}/'; + if (isHome) return homeUrl; + return '$uri'; + } +} + +class DefaultParser implements LinkInfoParser { + @override + Future parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? headers, + }) async { + try { + final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery; + final http.Response response = + await http.get(link, headers: headers).timeout(timeout); + final code = response.statusCode; + if (code != 200 && isHome) { + throw Exception('Http request error: $code'); + } + + final contentType = response.headers['content-type']; + final charset = contentType?.split('charset=').lastOrNull; + String body = ''; + if (charset == null || + charset.toLowerCase() == 'latin-1' || + charset.toLowerCase() == 'iso-8859-1') { + body = latin1.decode(response.bodyBytes); + } else { + body = utf8.decode(response.bodyBytes, allowMalformed: true); + } + + final document = html_parser.parse(body); + + final siteName = document + .querySelector('meta[property="og:site_name"]') + ?.attributes['content']; + + String? title = document + .querySelector('meta[property="og:title"]') + ?.attributes['content']; + title ??= document.querySelector('title')?.text; + + String? description = document + .querySelector('meta[property="og:description"]') + ?.attributes['content']; + description ??= document + .querySelector('meta[name="description"]') + ?.attributes['content']; + + String? imageUrl = document + .querySelector('meta[property="og:image"]') + ?.attributes['content']; + if (imageUrl != null && !imageUrl.startsWith('http')) { + imageUrl = link.resolve(imageUrl).toString(); + } + + final favicon = + 'https://www.faviconextractor.com/favicon/${link.host}?larger=true'; + + return LinkInfo( + url: '$link', + siteName: siteName, + title: title, + description: description, + imageUrl: imageUrl, + faviconUrl: favicon, + ); + } catch (e) { + Log.error('Parse link $link error: $e'); + return null; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart new file mode 100644 index 0000000000..6f1ac6fb22 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:http/http.dart' as http; +import 'default_parser.dart'; + +class YoutubeParser implements LinkInfoParser { + @override + Future parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? headers, + }) async { + try { + final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery; + if (isHome) { + return DefaultParser().parse( + link, + timeout: timeout, + headers: headers, + ); + } + + final requestLink = + 'https://www.youtube.com/oembed?url=$link&format=json'; + final http.Response response = await http + .get(Uri.parse(requestLink), headers: headers) + .timeout(timeout); + final code = response.statusCode; + if (code != 200) { + throw Exception('Http request error: $code'); + } + + final youtubeInfo = YoutubeInfo.fromJson(jsonDecode(response.body)); + + final favicon = + 'https://www.google.com/s2/favicons?sz=64&domain=${link.host}'; + return LinkInfo( + url: '$link', + title: youtubeInfo.title, + siteName: youtubeInfo.authorName, + imageUrl: youtubeInfo.thumbnailUrl, + faviconUrl: favicon, + ); + } catch (e) { + Log.error('Parse link $link error: $e'); + return null; + } + } +} + +class YoutubeInfo { + YoutubeInfo({ + this.title, + this.authorName, + this.version, + this.providerName, + this.providerUrl, + this.thumbnailUrl, + }); + + YoutubeInfo.fromJson(Map json) { + title = json['title']; + authorName = json['author_name']; + version = json['version']; + providerName = json['provider_name']; + providerUrl = json['provider_url']; + thumbnailUrl = json['thumbnail_url']; + } + String? title; + String? authorName; + String? version; + String? providerName; + String? providerUrl; + String? thumbnailUrl; + + Map toJson() => { + 'title': title, + 'author_name': authorName, + 'version': version, + 'provider_name': providerName, + 'provider_url': providerUrl, + 'thumbnail_url': thumbnailUrl, + }; +} 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 deleted file mode 100644 index 6688cfe304..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart +++ /dev/null @@ -1,25 +0,0 @@ -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 index a83fbed589..2fb493dda3 100644 --- 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 @@ -1,108 +1,207 @@ 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/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.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'; -class LinkPreviewMenu extends StatefulWidget { - const LinkPreviewMenu({ +class CustomLinkPreviewMenu extends StatefulWidget { + const CustomLinkPreviewMenu({ super.key, + required this.onMenuShowed, + required this.onMenuHided, + required this.onReload, required this.node, - required this.state, }); - + final VoidCallback onMenuShowed; + final VoidCallback onMenuHided; + final VoidCallback onReload; final Node node; - final LinkPreviewBlockComponentState state; @override - State createState() => _LinkPreviewMenuState(); + State createState() => _CustomLinkPreviewMenuState(); } -class _LinkPreviewMenuState extends State { +class _CustomLinkPreviewMenuState extends State { + final popoverController = PopoverController(); + final buttonKey = GlobalKey(); + bool closed = false; + bool selected = false; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + widget.onMenuHided.call(); + } + @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_aa_link_s, - onTap: () => 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.delete_s, - onTap: deleteLinkPreviewNode, - ), - const HSpace(4), - ], + return AppFlowyPopover( + offset: Offset(0, 0.0), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: popoverController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + keepEditorFocusNotifier.decrease(); + if (!closed) { + closed = true; + return; + } else { + closed = false; + widget.onMenuHided.call(); + } + setState(() { + selected = false; + }); + }, + popupBuilder: (context) => buildMenu(), + child: FlowyIconButton( + key: buttonKey, + isSelected: selected, + icon: FlowySvg(FlowySvgs.toolbar_more_m), + onPressed: showPopover, ), ); } - void copyImageLink() { - final url = widget.node.attributes[ImageBlockKeys.url]; - if (url != null) { - Clipboard.setData(ClipboardData(text: url)); - showSnackBarMessage( - context, - LocaleKeys.document_plugins_urlPreview_copiedToPasteBoard.tr(), - ); + Widget buildMenu() { + return MouseRegion( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: + List.generate(LinkPreviewMenuCommand.values.length, (index) { + final command = LinkPreviewMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onTap(command), + ), + ); + }), + ), + ), + ); + } + + Future onTap(LinkPreviewMenuCommand command) async { + final editorState = context.read(); + final node = widget.node; + final url = node.attributes[LinkPreviewBlockKeys.url]; + switch (command) { + case LinkPreviewMenuCommand.convertToMention: + await convertUrlPreviewNodeToMention(editorState, node); + break; + case LinkPreviewMenuCommand.convertToUrl: + await convertUrlPreviewNodeToLink(editorState, node); + break; + case LinkPreviewMenuCommand.convertToEmbed: + final transaction = editorState.transaction; + transaction.updateNode(node, { + LinkPreviewBlockKeys.url: url, + LinkEmbedKeys.previewType: LinkEmbedKeys.embed, + }); + await editorState.apply(transaction); + break; + case LinkPreviewMenuCommand.copyLink: + if (url != null) { + await context.copyLink(url); + } + break; + case LinkPreviewMenuCommand.replace: + final box = buttonKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return; + final p = box.localToGlobal(Offset.zero); + showReplaceMenu( + context: context, + editorState: editorState, + node: node, + url: url, + ltrb: LTRB(left: p.dx - 330, top: p.dy), + onReplace: (url) async { + await convertLinkBlockToOtherLinkBlock( + editorState, + node, + node.type, + url: url, + ); + }, + ); + break; + case LinkPreviewMenuCommand.reload: + widget.onReload.call(); + break; + case LinkPreviewMenuCommand.removeLink: + await removeUrlPreviewLink(editorState, node); + break; + } + closePopover(); + } + + void showPopover() { + widget.onMenuShowed.call(); + keepEditorFocusNotifier.increase(); + popoverController.show(); + setState(() { + selected = true; + }); + } + + void closePopover() { + popoverController.close(); + widget.onMenuHided.call(); + } +} + +enum LinkPreviewMenuCommand { + convertToMention, + convertToUrl, + convertToEmbed, + copyLink, + replace, + reload, + removeLink; + + String get title { + switch (this) { + case convertToMention: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion + .tr(); + case LinkPreviewMenuCommand.convertToUrl: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case LinkPreviewMenuCommand.convertToEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + case LinkPreviewMenuCommand.copyLink: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_copyLink + .tr(); + case LinkPreviewMenuCommand.replace: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_replace + .tr(); + case LinkPreviewMenuCommand.reload: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_reload + .tr(); + case LinkPreviewMenuCommand.removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .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/paste_as/paste_as_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart new file mode 100644 index 0000000000..fb51cdcf47 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart @@ -0,0 +1,259 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.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:flutter/material.dart'; +import 'package:flutter/services.dart'; + +const _menuHeighgt = 188.0, _menuWidth = 288.0; + +class PasteAsMenuService { + PasteAsMenuService({ + required this.context, + required this.editorState, + }); + + final BuildContext context; + final EditorState editorState; + OverlayEntry? _menuEntry; + + void show(String href) { + WidgetsBinding.instance.addPostFrameCallback((_) => _show(href)); + } + + void dismiss() { + if (_menuEntry != null) { + keepEditorFocusNotifier.decrease(); + // editorState.service.scrollService?.enable(); + // editorState.service.keyboardService?.enable(); + } + _menuEntry?.remove(); + _menuEntry = null; + } + + void _show(String href) { + final Size editorSize = editorState.renderBox?.size ?? Size.zero; + if (editorSize == Size.zero) return; + final menuPosition = editorState.calculateMenuOffset( + menuWidth: _menuWidth, + menuHeight: _menuHeighgt, + ); + if (menuPosition == null) return; + final ltrb = menuPosition.ltrb; + + _menuEntry = OverlayEntry( + builder: (context) => SizedBox( + height: editorSize.height, + width: editorSize.width, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + ltrb.buildPositioned( + child: PasteAsMenu( + editorState: editorState, + onSelect: (t) { + final selection = editorState.selection; + if (selection == null) return; + final end = selection.end; + final urlSelection = Selection( + start: end.copyWith(offset: end.offset - href.length), + end: end, + ); + if (t == PasteMenuType.bookmark) { + convertUrlToLinkPreview(editorState, urlSelection, href); + } else if (t == PasteMenuType.mention) { + convertUrlToMention(editorState, urlSelection); + } else if (t == PasteMenuType.embed) { + convertUrlToLinkPreview( + editorState, + urlSelection, + href, + previewType: LinkEmbedKeys.embed, + ); + } + dismiss(); + }, + onDismiss: dismiss, + ), + ), + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + + keepEditorFocusNotifier.increase(); + // editorState.service.keyboardService?.disable(showCursor: true); + // editorState.service.scrollService?.disable(); + } +} + +class PasteAsMenu extends StatefulWidget { + const PasteAsMenu({ + super.key, + required this.onSelect, + required this.onDismiss, + required this.editorState, + }); + final ValueChanged onSelect; + final VoidCallback onDismiss; + final EditorState editorState; + + @override + State createState() => _PasteAsMenuState(); +} + +class _PasteAsMenuState extends State { + final focusNode = FocusNode(debugLabel: 'paste_as_menu'); + final ValueNotifier selectedIndexNotifier = ValueNotifier(0); + + EditorState get editorState => widget.editorState; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => focusNode.requestFocus(), + ); + editorState.selectionNotifier.addListener(dismiss); + } + + @override + void dispose() { + focusNode.dispose(); + selectedIndexNotifier.dispose(); + editorState.selectionNotifier.removeListener(dismiss); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Focus( + focusNode: focusNode, + onKeyEvent: onKeyEvent, + child: Container( + width: _menuWidth, + height: _menuHeighgt, + padding: EdgeInsets.all(6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: theme.surfaceColorScheme.primary, + boxShadow: theme.shadow.medium, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 32, + padding: EdgeInsets.all(8), + child: FlowyText.semibold( + color: theme.textColorScheme.primary, + LocaleKeys.document_plugins_linkPreview_typeSelection_pasteAs + .tr(), + ), + ), + ...List.generate( + PasteMenuType.values.length, + (i) => buildItem(PasteMenuType.values[i], i), + ), + ], + ), + ), + ); + } + + Widget buildItem(PasteMenuType type, int i) { + return ValueListenableBuilder( + valueListenable: selectedIndexNotifier, + builder: (context, value, child) { + final isSelected = i == value; + return SizedBox( + height: 36, + child: FlowyButton( + isSelected: isSelected, + text: FlowyText( + type.title, + ), + onTap: () => onSelect(type), + ), + ); + }, + ); + } + + void changeIndex(int index) => selectedIndexNotifier.value = index; + + KeyEventResult onKeyEvent(focus, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + int index = selectedIndexNotifier.value, + length = PasteMenuType.values.length; + if (event.logicalKey == LogicalKeyboardKey.enter) { + onSelect(PasteMenuType.values[index]); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + dismiss(); + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + dismiss(); + } else if ([LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowLeft] + .contains(event.logicalKey)) { + if (index == 0) { + index = length - 1; + } else { + index--; + } + changeIndex(index); + return KeyEventResult.handled; + } else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowRight] + .contains(event.logicalKey)) { + if (index == length - 1) { + index = 0; + } else { + index++; + } + changeIndex(index); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void onSelect(PasteMenuType type) => widget.onSelect.call(type); + + void dismiss() => widget.onDismiss.call(); +} + +enum PasteMenuType { + mention, + url, + bookmark, + embed, +} + +extension PasteMenuTypeExtension on PasteMenuType { + String get title { + switch (this) { + case PasteMenuType.mention: + return LocaleKeys.document_plugins_linkPreview_typeSelection_mention + .tr(); + case PasteMenuType.url: + return LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr(); + case PasteMenuType.bookmark: + return LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark + .tr(); + case PasteMenuType.embed: + return LocaleKeys.document_plugins_linkPreview_typeSelection_embed.tr(); + } + } +} 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 index 11dae6075d..8b193c70fb 100644 --- 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 @@ -1,12 +1,27 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +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'; -void convertUrlPreviewNodeToLink(EditorState editorState, Node node) { - assert(node.type == LinkPreviewBlockKeys.type); - final url = node.attributes[ImageBlockKeys.url]; +Future convertUrlPreviewNodeToLink( + EditorState editorState, + Node node, +) async { + if (node.type != LinkPreviewBlockKeys.type) { + return; + } + + final url = node.attributes[LinkPreviewBlockKeys.url]; + final delta = Delta() + ..insert( + url, + attributes: { + AppFlowyRichTextKeys.href: url, + }, + ); final transaction = editorState.transaction; transaction - ..insertNode(node.path, paragraphNode(text: url)) + ..insertNode(node.path, paragraphNode(delta: delta)) ..deleteNode(node); transaction.afterSelection = Selection.collapsed( Position( @@ -14,5 +29,174 @@ void convertUrlPreviewNodeToLink(EditorState editorState, Node node) { offset: url.length, ), ); - editorState.apply(transaction); + return editorState.apply(transaction); +} + +Future convertUrlPreviewNodeToMention( + EditorState editorState, + Node node, +) async { + if (node.type != LinkPreviewBlockKeys.type) { + return; + } + + final url = node.attributes[LinkPreviewBlockKeys.url]; + final delta = Delta() + ..insert( + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.externalLink.name, + MentionBlockKeys.url: 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); +} + +Future removeUrlPreviewLink( + EditorState editorState, + Node node, +) async { + if (node.type != LinkPreviewBlockKeys.type) { + return; + } + + final url = node.attributes[LinkPreviewBlockKeys.url]; + final delta = Delta()..insert(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); +} + +Future convertUrlToLinkPreview( + EditorState editorState, + Selection selection, + String url, { + String? previewType, +}) async { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final delta = node.delta; + if (delta == null) return; + final List beforeOperations = [], afterOperations = []; + int index = 0; + for (final insert in delta.whereType()) { + if (index < selection.startIndex) { + beforeOperations.add(insert); + } else if (index >= selection.endIndex) { + afterOperations.add(insert); + } + index += insert.length; + } + final transaction = editorState.transaction; + transaction + ..deleteNode(node) + ..insertNodes(node.path.next, [ + if (beforeOperations.isNotEmpty) + paragraphNode(delta: Delta(operations: beforeOperations)), + if (previewType == LinkEmbedKeys.embed) + linkEmbedNode(url: url) + else + linkPreviewNode(url: url), + if (afterOperations.isNotEmpty) + paragraphNode(delta: Delta(operations: afterOperations)), + ]); + await editorState.apply(transaction); +} + +Future convertUrlToMention( + EditorState editorState, + Selection selection, +) async { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final delta = node.delta; + if (delta == null) return; + String url = ''; + int index = 0; + for (final insert in delta.whereType()) { + if (index >= selection.startIndex && index < selection.endIndex) { + final href = insert.attributes?.href ?? ''; + if (href.isNotEmpty) { + url = href; + break; + } + } + index += insert.length; + } + final transaction = editorState.transaction; + transaction.replaceText( + node, + selection.startIndex, + selection.length, + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.externalLink.name, + MentionBlockKeys.url: url, + }, + }, + ); + await editorState.apply(transaction); +} + +Future convertLinkBlockToOtherLinkBlock( + EditorState editorState, + Node node, + String toType, { + String? url, +}) async { + final nodeType = node.type; + if (nodeType != LinkPreviewBlockKeys.type || + (nodeType == toType && url == null)) { + return; + } + final insertedNode = []; + + final afterUrl = url ?? node.attributes[LinkPreviewBlockKeys.url] ?? ''; + final previewType = node.attributes[LinkEmbedKeys.previewType]; + Node afterNode = node.copyWith( + type: toType, + attributes: { + LinkPreviewBlockKeys.url: afterUrl, + LinkEmbedKeys.previewType: previewType, + blockComponentBackgroundColor: + node.attributes[blockComponentBackgroundColor], + blockComponentTextDirection: node.attributes[blockComponentTextDirection], + blockComponentDelta: (node.delta ?? Delta()).toJson(), + }, + ); + afterNode = afterNode.copyWith(children: []); + insertedNode.add(afterNode); + insertedNode.addAll(node.children.map((e) => e.deepCopy())); + final transaction = editorState.transaction; + transaction.insertNodes( + node.path, + insertedNode, + ); + transaction.deleteNodes([node]); + await 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 index 56a9739531..2f724061ee 100644 --- 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 @@ -1,5 +1,8 @@ +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'; @@ -10,6 +13,7 @@ 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._(); @@ -37,7 +41,11 @@ Node mathEquationNode({ // defining the callout block menu item for selection SelectionMenuItem mathEquationItem = SelectionMenuItem.node( getName: LocaleKeys.document_plugins_mathEquation_name.tr, - iconData: Icons.text_fields_rounded, + 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, @@ -71,11 +79,15 @@ class MathEquationBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @override - bool validate(Node node) => + BlockComponentValidate get validate => (node) => node.children.isEmpty && node.attributes[MathEquationBlockKeys.formula] is String; } @@ -86,6 +98,7 @@ class MathEquationBlockComponentWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @@ -103,16 +116,24 @@ class MathEquationBlockComponentWidgetState @override Node get node => widget.node; - bool isHover = false; 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) => setState(() => isHover = value), + onHover: (value) => isHover.value = value, onTap: showEditingDialog, child: _build(context), ); @@ -124,7 +145,7 @@ class MathEquationBlockComponentWidgetState decoration: BoxDecoration( color: formula.isNotEmpty ? Colors.transparent - : Theme.of(context).colorScheme.surfaceVariant, + : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: FlowyHover( @@ -137,24 +158,42 @@ class MathEquationBlockComponentWidgetState ), ); + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, + child: child, + ); + } + + if (UniversalPlatform.isMobile) { + child = MobileBlockActionButtons( + node: node, + editorState: editorState, + child: child, + ); + } + child = Padding( padding: padding, child: child, ); - if (widget.showActions && widget.actionBuilder != null) { - child = BlockComponentActionWrapper( - node: node, - actionBuilder: widget.actionBuilder!, - child: child, - ); - } - - if (PlatformExtension.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(), + ), + ), + ], ); } @@ -167,10 +206,15 @@ class MathEquationBlockComponentWidgetState child: Row( children: [ const HSpace(10), - const Icon(Icons.text_fields_outlined), + 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, ), ], ), @@ -186,8 +230,18 @@ class MathEquationBlockComponentWidgetState ); } + 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() { - final controller = TextEditingController(text: formula); showDialog( context: context, builder: (context) { @@ -234,7 +288,7 @@ class MathEquationBlockComponentWidgetState actionsAlignment: MainAxisAlignment.spaceAround, ); }, - ).then((_) => controller.dispose()); + ); } void updateMathEquation(String mathEquation, BuildContext context) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart new file mode 100644 index 0000000000..9c9fe7905b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart @@ -0,0 +1,74 @@ +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'; + +/// Windows / Linux : ctrl + shift + e +/// macOS : cmd + shift + e +/// Allows the user to insert math equation by shortcut +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent insertInlineMathEquationCommand = + CommandShortcutEvent( + key: 'Insert inline math equation', + command: 'ctrl+shift+e', + macOSCommand: 'cmd+shift+e', + getDescription: LocaleKeys.document_plugins_mathEquation_name.tr, + handler: (editorState) { + final selection = editorState.selection; + if (selection == null || selection.isCollapsed || !selection.isSingle) { + return KeyEventResult.ignored; + } + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + return KeyEventResult.ignored; + } + if (node.delta == null || !toolbarItemWhiteList.contains(node.type)) { + return KeyEventResult.ignored; + } + final transaction = editorState.transaction; + final nodes = editorState.getNodesInSelection(selection); + final isHighlight = nodes.allSatisfyInSelection(selection, (delta) { + return delta.everyAttributes( + (attributes) => attributes[InlineMathEquationKeys.formula] != null, + ); + }); + if (isHighlight) { + final formula = delta + .slice(selection.startIndex, selection.endIndex) + .whereType() + .firstOrNull + ?.attributes?[InlineMathEquationKeys.formula]; + assert(formula != null); + if (formula == null) { + return KeyEventResult.ignored; + } + // 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, + }, + ); + } + editorState.apply(transaction); + return KeyEventResult.handled; + }, +); 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 0000000000..77f8c8d0a1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart @@ -0,0 +1,219 @@ +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( + 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 { + // 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, + MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.childPage, + pageId: newView.id, + blockId: null, + ), + ); + 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 0000000000..cb3196e9b7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart @@ -0,0 +1,260 @@ +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.buildMentionDateAttributes( + date: dateTime.toIso8601String(), + reminderId: reminderId, + reminderOption: data.reminderOption.name, + includeTime: data.includeTime, + ); + + // 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 index 29007ada98..0060d65bb7 100644 --- 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 @@ -1,20 +1,24 @@ -import 'package:flutter/material.dart'; - 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'; +import 'mention_link_block.dart'; + enum MentionType { page, - reminder, - date; + date, + externalLink, + childPage; static MentionType fromString(String value) => switch (value) { 'page' => page, 'date' => date, + 'externalLink' => externalLink, + 'childPage' => childPage, // Backwards compatibility 'reminder' => date, _ => throw UnimplementedError(), @@ -26,13 +30,13 @@ Node dateMentionNode() { delta: Delta( operations: [ TextInsert( - '\$', - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: DateTime.now().toIso8601String(), - }, - }, + MentionBlockKeys.mentionChar, + attributes: MentionBlockKeys.buildMentionDateAttributes( + date: DateTime.now().toIso8601String(), + reminderId: null, + reminderOption: null, + includeTime: false, + ), ), ], ), @@ -42,15 +46,52 @@ Node dateMentionNode() { 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'; + static const url = 'url'; // Related to Reminder and Date blocks static const date = 'date'; // Start Date static const includeTime = 'include_time'; + static const reminderId = 'reminder_id'; // ReminderID static const reminderOption = 'reminder_option'; + + static const mentionChar = '\$'; + + static Map buildMentionPageAttributes({ + required MentionType mentionType, + required String pageId, + required String? blockId, + }) { + return { + MentionBlockKeys.mention: { + MentionBlockKeys.type: mentionType.name, + MentionBlockKeys.pageId: pageId, + if (blockId != null) MentionBlockKeys.blockId: blockId, + }, + }; + } + + static Map buildMentionDateAttributes({ + required String date, + required String? reminderId, + required String? reminderOption, + required bool includeTime, + }) { + return { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: date, + MentionBlockKeys.includeTime: includeTime, + if (reminderId != null) MentionBlockKeys.reminderId: reminderId, + if (reminderOption != null) + MentionBlockKeys.reminderOption: reminderOption, + }, + }; + } } class MentionBlock extends StatelessWidget { @@ -74,8 +115,28 @@ class MentionBlock extends StatelessWidget { switch (type) { case MentionType.page: - final String pageId = mention[MentionBlockKeys.pageId]; + 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, @@ -83,6 +144,7 @@ class MentionBlock extends StatelessWidget { textStyle: textStyle, index: index, ); + case MentionType.date: final String date = mention[MentionBlockKeys.date]; final reminderOption = ReminderOption.values.firstWhereOrNull( @@ -94,13 +156,23 @@ class MentionBlock extends StatelessWidget { editorState: editorState, date: date, node: node, + textStyle: textStyle, index: index, reminderId: mention[MentionBlockKeys.reminderId], - reminderOption: reminderOption, + reminderOption: reminderOption ?? ReminderOption.none, includeTime: mention[MentionBlockKeys.includeTime] ?? false, ); - default: - return const SizedBox.shrink(); + case MentionType.externalLink: + final String? url = mention[MentionBlockKeys.url] as String?; + if (url == null) { + return const SizedBox.shrink(); + } + return MentionLinkBlock( + url: url, + editorState: editorState, + node: node, + index: index, + ); } } } 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 index 31fe8f0a4e..20f60be23d 100644 --- 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 @@ -2,32 +2,30 @@ 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_appearance_cubit.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_appflowy_date_picker.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/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:calendar_view/calendar_view.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({ @@ -36,8 +34,9 @@ class MentionDateBlock extends StatefulWidget { required this.date, required this.index, required this.node, + this.textStyle, this.reminderId, - this.reminderOption, + this.reminderOption = ReminderOption.none, this.includeTime = false, }); @@ -50,242 +49,151 @@ class MentionDateBlock extends StatefulWidget { /// null or empty final String? reminderId; - final ReminderOption? reminderOption; + 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 Widget build(BuildContext context) { if (parsedDate == null) { return const SizedBox.shrink(); } - final fontSize = context.read().state.fontSize; + final appearance = context.read(); + final reminder = context.read(); - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: context.read()), - BlocProvider.value( - value: context.read(), - ), - ], - child: 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); + if (appearance == null || reminder == null) { + return const SizedBox.shrink(); + } - final formattedDate = appearance.dateFormat - .formatDate(parsedDate!, _includeTime, appearance.timeFormat); + 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 timeStr = parsedDate != null - ? _timeFromDate(parsedDate!, appearance.timeFormat) - : null; + final formattedDate = appearance.dateFormat + .formatDate(parsedDate!, _includeTime, appearance.timeFormat); - final options = DatePickerOptions( - focusedDay: parsedDate, - popoverMutex: mutex, - selectedDay: parsedDate, - timeStr: timeStr, - includeTime: _includeTime, - dateFormat: appearance.dateFormat, - timeFormat: appearance.timeFormat, - selectedReminderOption: widget.reminderOption, - onIncludeTimeChanged: (includeTime) { - _includeTime = includeTime; + final options = DatePickerOptions( + focusedDay: parsedDate, + selectedDay: parsedDate, + includeTime: _includeTime, + dateFormat: appearance.dateFormat, + timeFormat: appearance.timeFormat, + selectedReminderOption: widget.reminderOption, + onIncludeTimeChanged: (includeTime, dateTime, _) { + _includeTime = includeTime; - if (![null, ReminderOption.none] - .contains(widget.reminderOption)) { - _updateReminder( - widget.reminderOption!, - reminder, - includeTime, - ); - } else { - _updateBlock( - parsedDate!.withoutTime, - includeTime: includeTime, - ); - } - }, - onStartTimeChanged: (time) { - final parsed = _parseTime(time, appearance.timeFormat); - parsedDate = parsedDate!.withoutTime - .add(Duration(hours: parsed.hour, minutes: parsed.minute)); + 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 (![null, ReminderOption.none] - .contains(widget.reminderOption)) { - _updateReminder( - widget.reminderOption!, - reminder, - _includeTime, - ); - } else { - _updateBlock(parsedDate!, includeTime: _includeTime); - } - }, - onDaySelected: (selectedDay, focusedDay) { - parsedDate = selectedDay; + if (widget.reminderOption != ReminderOption.none) { + _updateReminder( + widget.reminderOption, + reminder, + _includeTime, + ); + } else { + _updateBlock(selectedDay, includeTime: _includeTime); + } + }, + onReminderSelected: (reminderOption) => + _updateReminder(reminderOption, reminder), + ); - if (![null, ReminderOption.none] - .contains(widget.reminderOption)) { - _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, + ); - return GestureDetector( - onTapDown: (details) { - if (widget.editorState.editable) { - if (PlatformExtension.isMobile) { - showMobileBottomSheet( - context, - builder: (_) => DraggableScrollableSheet( - expand: false, - snap: true, - initialChildSize: 0.7, - minChildSize: 0.4, - snapSizes: const [0.4, 0.7, 1.0], - builder: (_, controller) => 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( - selectedDay: parsedDate, - timeStr: timeStr, - dateStr: parsedDate != null - ? options.dateFormat - .formatDate(parsedDate!, _includeTime) - : null, - includeTime: options.includeTime, - use24hFormat: options.timeFormat == - UserTimeFormatPB.TwentyFourHour, - rebuildOnDaySelected: true, - rebuildOnTimeChanged: true, - timeFormat: options.timeFormat.simplified, - selectedReminderOption: widget.reminderOption, - onDaySelected: options.onDaySelected, - onStartTimeChanged: (time) => options - .onStartTimeChanged - ?.call(time ?? ""), - onIncludeTimeChanged: - options.onIncludeTimeChanged, - liveDateFormatter: (selected) => - appearance.dateFormat.formatDate( - selected, - false, - appearance.timeFormat, - ), - onReminderSelected: (option) => - _updateReminder(option, reminder), - ), - ], - ), - ), - ), - ); - } else { - DatePickerMenu( - context: context, - editorState: widget.editorState, - ).show(details.globalPosition, options: options); - } - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FlowySvg( - widget.reminderId != null - ? FlowySvgs.clock_alarm_s - : FlowySvgs.date_s, - size: const Size.square(18.0), - color: reminder?.isAck == true - ? Theme.of(context).colorScheme.error - : null, - ), - const HSpace(2), - FlowyText( - formattedDate, - fontSize: fontSize, - color: reminder?.isAck == true - ? Theme.of(context).colorScheme.error - : null, - ), - ], + // 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: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + 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, + ), + ], ), - ); - }, - ), + ), + ); + }, ), ); } - DateTime _parseTime(String timeStr, UserTimeFormatPB timeFormat) { - final twelveHourFormat = DateFormat('hh:mm a'); - final twentyFourHourFormat = DateFormat('HH:mm'); - - try { - if (timeFormat == UserTimeFormatPB.TwelveHour) { - return twelveHourFormat.parseStrict(timeStr); - } - - return twentyFourHourFormat.parseStrict(timeStr); - } on FormatException { - Log.error("failed to parse time string ($timeStr)"); - return DateTime.now(); - } - } - - String _timeFromDate(DateTime date, UserTimeFormatPB timeFormat) { - final twelveHourFormat = DateFormat('HH:mm a'); - final twentyFourHourFormat = DateFormat('HH:mm'); - - if (timeFormat == TimeFormatPB.TwelveHour) { - return twelveHourFormat.format(date); - } - - return twentyFourHourFormat.format(date); - } - void _updateBlock( DateTime date, { - bool includeTime = false, + required bool includeTime, String? reminderId, ReminderOption? reminderOption, }) { @@ -293,21 +201,22 @@ class _MentionDateBlockState extends State { (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, - }, - }); + ..formatText( + widget.node, + widget.index, + 1, + MentionBlockKeys.buildMentionDateAttributes( + date: date.toIso8601String(), + reminderId: rId, + includeTime: includeTime, + 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 + // the cursor with the new block render widget.editorState.updateSelectionWithReason( widget.editorState.selection, ); @@ -342,7 +251,9 @@ class _MentionDateBlockState extends State { ReminderEvent.update( ReminderUpdate( id: widget.reminderId!, - scheduledAt: reminderOption.fromDate(parsedDate!), + scheduledAt: + reminderOption.getNotificationDateTime(parsedDate!), + date: parsedDate!, ), ), ); @@ -368,6 +279,8 @@ class _MentionDateBlockState extends State { 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()), @@ -375,4 +288,91 @@ class _MentionDateBlockState extends State { ), ); } + + 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_link_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart new file mode 100644 index 0000000000..06ebcb5002 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart @@ -0,0 +1,353 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.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 'mention_link_error_preview.dart'; +import 'mention_link_preview.dart'; + +class MentionLinkBlock extends StatefulWidget { + const MentionLinkBlock({ + super.key, + required this.url, + required this.editorState, + required this.node, + required this.index, + this.delayToShow = const Duration(milliseconds: 50), + this.delayToHide = const Duration(milliseconds: 300), + }); + + final String url; + final Duration delayToShow; + final Duration delayToHide; + final EditorState editorState; + final Node node; + final int index; + + @override + State createState() => _MentionLinkBlockState(); +} + +class _MentionLinkBlockState extends State { + final parser = LinkParser(); + _LoadingStatus status = _LoadingStatus.loading; + late LinkInfo linkInfo = LinkInfo(url: url); + final previewController = PopoverController(); + bool isHovering = false; + int previewFocusNum = 0; + bool isPreviewHovering = false; + bool showAtBottom = false; + final key = GlobalKey(); + + bool get isPreviewShowing => previewFocusNum > 0; + String get url => widget.url; + + EditorState get editorState => widget.editorState; + + Node get node => widget.node; + + int get index => widget.index; + + bool get readyForPreview => + status == _LoadingStatus.idle && !linkInfo.isEmpty(); + + @override + void initState() { + super.initState(); + + parser.addLinkInfoListener((v) { + final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); + if (mounted) { + setState(() { + if (hasNewInfo) { + linkInfo = v; + status = _LoadingStatus.idle; + } else if (!hasOldInfo) { + status = _LoadingStatus.error; + } + }); + } + }); + parser.start(url); + } + + @override + void dispose() { + super.dispose(); + parser.dispose(); + previewController.close(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + key: ValueKey(showAtBottom), + controller: previewController, + direction: showAtBottom + ? PopoverDirection.bottomWithLeftAligned + : PopoverDirection.topWithLeftAligned, + offset: Offset(0, showAtBottom ? -20 : 20), + onOpen: () { + keepEditorFocusNotifier.increase(); + previewFocusNum++; + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + previewFocusNum--; + }, + decorationColor: Colors.transparent, + popoverDecoration: BoxDecoration(), + margin: EdgeInsets.zero, + constraints: getConstraints(), + borderRadius: BorderRadius.circular(16), + popupBuilder: (context) => readyForPreview + ? MentionLinkPreview( + linkInfo: linkInfo, + showAtBottom: showAtBottom, + triggerSize: getSizeFromKey(), + onEnter: (e) { + isPreviewHovering = true; + }, + onExit: (e) { + isPreviewHovering = false; + tryToDismissPreview(); + }, + onCopyLink: () => copyLink(context), + onConvertTo: (s) => convertTo(s), + onRemoveLink: removeLink, + onOpenLink: openLink, + ) + : MentionLinkErrorPreview( + url: url, + triggerSize: getSizeFromKey(), + onEnter: (e) { + isPreviewHovering = true; + }, + onExit: (e) { + isPreviewHovering = false; + tryToDismissPreview(); + }, + onCopyLink: () => copyLink(context), + onConvertTo: (s) => convertTo(s), + onRemoveLink: removeLink, + onOpenLink: openLink, + ), + child: buildIconWithTitle(context), + ); + } + + Widget buildIconWithTitle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + final siteName = linkInfo.siteName, linkTitle = linkInfo.title ?? url; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: onEnter, + onExit: onExit, + child: GestureDetector( + onTap: () async { + await afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); + }, + child: FlowyHoverContainer( + style: + HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary), + applyStyle: isHovering, + child: Row( + mainAxisSize: MainAxisSize.min, + key: key, + children: [ + HSpace(2), + buildIcon(), + HSpace(4), + Flexible( + child: RichText( + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: [ + if (siteName != null) ...[ + TextSpan( + text: siteName, + style: theme.textStyle.body + .standard(color: theme.textColorScheme.secondary), + ), + WidgetSpan(child: HSpace(2)), + ], + TextSpan( + text: linkTitle, + style: theme.textStyle.body + .standard(color: theme.textColorScheme.primary), + ), + ], + ), + ), + ), + HSpace(2), + ], + ), + ), + ), + ); + } + + Widget buildIcon() { + const defaultWidget = FlowySvg(FlowySvgs.toolbar_link_earth_m); + Widget icon = defaultWidget; + if (status == _LoadingStatus.loading) { + icon = Padding( + padding: const EdgeInsets.all(2.0), + child: const CircularProgressIndicator(strokeWidth: 1), + ); + } else { + icon = linkInfo.buildIconWidget(); + } + return SizedBox( + height: 20, + width: 20, + child: icon, + ); + } + + RenderBox? get box => key.currentContext?.findRenderObject() as RenderBox?; + + Size getSizeFromKey() => box?.size ?? Size.zero; + + Future copyLink(BuildContext context) async { + await context.copyLink(url); + previewController.close(); + } + + Future openLink() async { + await afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); + } + + Future removeLink() async { + final transaction = editorState.transaction + ..replaceText(widget.node, widget.index, 1, url, attributes: {}); + await editorState.apply(transaction); + } + + Future convertTo(PasteMenuType type) async { + if (type == PasteMenuType.url) { + await toUrl(); + } else if (type == PasteMenuType.bookmark) { + await toLinkPreview(); + } else if (type == PasteMenuType.embed) { + await toLinkPreview(previewType: LinkEmbedKeys.embed); + } + } + + Future toUrl() async { + final transaction = editorState.transaction + ..replaceText( + widget.node, + widget.index, + 1, + url, + attributes: { + AppFlowyRichTextKeys.href: url, + }, + ); + await editorState.apply(transaction); + } + + Future toLinkPreview({String? previewType}) async { + final selection = Selection( + start: Position(path: node.path, offset: index), + end: Position(path: node.path, offset: index + 1), + ); + await convertUrlToLinkPreview( + editorState, + selection, + url, + previewType: previewType, + ); + } + + void changeHovering(bool hovering) { + if (isHovering == hovering) return; + if (mounted) { + setState(() { + isHovering = hovering; + }); + } + } + + void changeShowAtBottom(bool bottom) { + if (showAtBottom == bottom) return; + if (mounted) { + setState(() { + showAtBottom = bottom; + }); + } + } + + void tryToDismissPreview() { + Future.delayed(widget.delayToHide, () { + if (isHovering || isPreviewHovering) { + return; + } + previewController.close(); + }); + } + + void onEnter(PointerEnterEvent e) { + changeHovering(true); + final location = box?.localToGlobal(Offset.zero) ?? Offset.zero; + if (readyForPreview) { + if (location.dy < 300) { + changeShowAtBottom(true); + } else { + changeShowAtBottom(false); + } + } + Future.delayed(widget.delayToShow, () { + if (isHovering && !isPreviewShowing && status != _LoadingStatus.loading) { + showPreview(); + } + }); + } + + void onExit(PointerExitEvent e) { + changeHovering(false); + tryToDismissPreview(); + } + + void showPreview() { + if (!mounted) return; + keepEditorFocusNotifier.increase(); + previewController.show(); + previewFocusNum++; + } + + BoxConstraints getConstraints() { + final size = getSizeFromKey(); + if (!readyForPreview) { + return BoxConstraints( + maxWidth: max(320, size.width), + maxHeight: 48 + size.height, + ); + } + final hasImage = linkInfo.imageUrl?.isNotEmpty ?? false; + return BoxConstraints( + maxWidth: max(300, size.width), + maxHeight: hasImage ? 300 : 180, + ); + } +} + +enum _LoadingStatus { + loading, + idle, + error, +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart new file mode 100644 index 0000000000..df396108e4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart @@ -0,0 +1,232 @@ +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/util/theme_extension.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 MentionLinkErrorPreview extends StatefulWidget { + const MentionLinkErrorPreview({ + super.key, + required this.url, + required this.onEnter, + required this.onExit, + required this.onCopyLink, + required this.onRemoveLink, + required this.onConvertTo, + required this.onOpenLink, + required this.triggerSize, + }); + + final String url; + final PointerEnterEventListener onEnter; + final PointerExitEventListener onExit; + final VoidCallback onCopyLink; + final VoidCallback onRemoveLink; + final VoidCallback onOpenLink; + final ValueChanged onConvertTo; + final Size triggerSize; + + @override + State createState() => + _MentionLinkErrorPreviewState(); +} + +class _MentionLinkErrorPreviewState extends State { + final menuController = PopoverController(); + bool isConvertButtonSelected = false; + + @override + void dispose() { + super.dispose(); + menuController.close(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: SizedBox( + width: max(320, widget.triggerSize.width), + height: 48, + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: 320, + height: 48, + decoration: buildToolbarLinkDecoration(context), + padding: EdgeInsets.fromLTRB(12, 8, 8, 8), + child: Row( + children: [ + Expanded(child: buildLinkWidget()), + Container( + height: 20, + width: 1, + color: Color(0xffE8ECF3) + .withAlpha(Theme.of(context).isLightMode ? 255 : 40), + margin: EdgeInsets.symmetric(horizontal: 6), + ), + FlowyIconButton( + icon: FlowySvg(FlowySvgs.toolbar_link_m), + tooltipText: LocaleKeys.editor_copyLink.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: widget.onCopyLink, + ), + buildConvertButton(), + ], + ), + ), + ), + ), + ), + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: widget.onEnter, + onExit: widget.onExit, + child: GestureDetector( + onTap: widget.onOpenLink, + child: Container( + width: widget.triggerSize.width, + height: widget.triggerSize.height, + color: Colors.black.withAlpha(1), + ), + ), + ), + ], + ); + } + + Widget buildLinkWidget() { + final url = widget.url; + return FlowyTooltip( + message: url, + preferBelow: false, + child: FlowyText.regular( + url, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 20, + fontSize: 14, + ), + ); + } + + Widget buildConvertButton() { + return AppFlowyPopover( + offset: Offset(8, 10), + direction: PopoverDirection.bottomWithRightAligned, + margin: EdgeInsets.zero, + controller: menuController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + popupBuilder: (context) => buildConvertMenu(), + child: FlowyIconButton( + icon: FlowySvg(FlowySvgs.turninto_m), + isSelected: isConvertButtonSelected, + tooltipText: LocaleKeys.editor_convertTo.tr(), + preferBelow: false, + width: 36, + height: 32, + onPressed: () { + setState(() { + isConvertButtonSelected = true; + }); + showPopover(); + }, + ), + ); + } + + Widget buildConvertMenu() { + return MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: List.generate(MentionLinktErrorMenuCommand.values.length, + (index) { + final command = MentionLinktErrorMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onTap(command), + ), + ); + }), + ), + ), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + menuController.show(); + } + + void closePopover() { + menuController.close(); + } + + void onTap(MentionLinktErrorMenuCommand command) { + switch (command) { + case MentionLinktErrorMenuCommand.toURL: + widget.onConvertTo(PasteMenuType.url); + break; + case MentionLinktErrorMenuCommand.toBookmark: + widget.onConvertTo(PasteMenuType.bookmark); + break; + case MentionLinktErrorMenuCommand.toEmbed: + widget.onConvertTo(PasteMenuType.embed); + break; + case MentionLinktErrorMenuCommand.removeLink: + widget.onRemoveLink(); + break; + } + closePopover(); + } +} + +enum MentionLinktErrorMenuCommand { + toURL, + toBookmark, + toEmbed, + removeLink; + + String get title { + switch (this) { + case toURL: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + case toEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + case removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart new file mode 100644 index 0000000000..00b161379e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart @@ -0,0 +1,276 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.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 MentionLinkPreview extends StatefulWidget { + const MentionLinkPreview({ + super.key, + required this.linkInfo, + required this.onEnter, + required this.onExit, + required this.onCopyLink, + required this.onRemoveLink, + required this.onConvertTo, + required this.onOpenLink, + required this.triggerSize, + required this.showAtBottom, + }); + + final LinkInfo linkInfo; + final PointerEnterEventListener onEnter; + final PointerExitEventListener onExit; + final VoidCallback onCopyLink; + final VoidCallback onRemoveLink; + final VoidCallback onOpenLink; + final ValueChanged onConvertTo; + final Size triggerSize; + final bool showAtBottom; + + @override + State createState() => _MentionLinkPreviewState(); +} + +class _MentionLinkPreviewState extends State { + final menuController = PopoverController(); + bool isSelected = false; + + LinkInfo get linkInfo => widget.linkInfo; + + @override + void dispose() { + super.dispose(); + menuController.close(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context), + textColorScheme = theme.textColorScheme; + final imageUrl = linkInfo.imageUrl ?? '', + description = linkInfo.description ?? ''; + final imageHeight = 120.0; + final card = MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Container( + decoration: buildToolbarLinkDecoration(context, radius: 16), + width: 280, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (imageUrl.isNotEmpty) + ClipRRect( + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16)), + child: FlowyNetworkImage( + url: linkInfo.imageUrl ?? '', + width: 280, + height: imageHeight, + ), + ), + VSpace(12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FlowyText.semibold( + linkInfo.title ?? linkInfo.siteName ?? '', + fontSize: 14, + figmaLineHeight: 20, + color: textColorScheme.primary, + overflow: TextOverflow.ellipsis, + ), + ), + VSpace(4), + if (description.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FlowyText( + description, + fontSize: 12, + figmaLineHeight: 16, + color: textColorScheme.secondary, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + VSpace(36), + ], + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + height: 28, + child: Row( + children: [ + linkInfo.buildIconWidget(size: Size.square(16)), + HSpace(6), + Expanded( + child: FlowyText( + linkInfo.siteName ?? linkInfo.url, + fontSize: 12, + figmaLineHeight: 16, + color: textColorScheme.primary, + overflow: TextOverflow.ellipsis, + fontWeight: FontWeight.w700, + ), + ), + buildMoreOptionButton(), + ], + ), + ), + VSpace(12), + ], + ), + ), + ); + + final clickPlaceHolder = MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: widget.onEnter, + onExit: widget.onExit, + child: GestureDetector( + child: Container( + height: 20, + width: widget.triggerSize.width, + color: Colors.white.withAlpha(1), + ), + onTap: () { + widget.onOpenLink.call(); + closePopover(); + }, + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: widget.showAtBottom + ? [clickPlaceHolder, card] + : [card, clickPlaceHolder], + ); + } + + Widget buildMoreOptionButton() { + return AppFlowyPopover( + controller: menuController, + direction: PopoverDirection.topWithLeftAligned, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + margin: EdgeInsets.zero, + borderRadius: BorderRadius.circular(12), + popupBuilder: (context) => buildConvertMenu(), + child: FlowyIconButton( + width: 28, + height: 28, + isSelected: isSelected, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: FlowySvg( + FlowySvgs.toolbar_more_m, + size: Size.square(20), + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ), + ); + } + + Widget buildConvertMenu() { + return MouseRegion( + onEnter: widget.onEnter, + onExit: widget.onExit, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(0.0), + children: + List.generate(MentionLinktMenuCommand.values.length, (index) { + final command = MentionLinktMenuCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + onTap: () => onTap(command), + ), + ); + }), + ), + ), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + menuController.show(); + } + + void closePopover() { + menuController.close(); + } + + void onTap(MentionLinktMenuCommand command) { + switch (command) { + case MentionLinktMenuCommand.toURL: + widget.onConvertTo(PasteMenuType.url); + break; + case MentionLinktMenuCommand.toBookmark: + widget.onConvertTo(PasteMenuType.bookmark); + break; + case MentionLinktMenuCommand.toEmbed: + widget.onConvertTo(PasteMenuType.embed); + break; + case MentionLinktMenuCommand.copyLink: + widget.onCopyLink(); + break; + case MentionLinktMenuCommand.removeLink: + widget.onRemoveLink(); + break; + } + closePopover(); + } +} + +enum MentionLinktMenuCommand { + toURL, + toBookmark, + toEmbed, + copyLink, + removeLink; + + String get title { + switch (this) { + case toURL: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl + .tr(); + case toBookmark: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_toBookmark + .tr(); + case toEmbed: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed + .tr(); + case copyLink: + return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_copyLink + .tr(); + case removeLink: + return LocaleKeys + .document_plugins_linkPreview_linkPreviewMenu_removeLink + .tr(); + } + } +} 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 0000000000..28a698dde2 --- /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 index af58ce48af..ede690eb30 100644 --- 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 @@ -1,32 +1,46 @@ -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/mobile_router.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_text.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/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/util/string_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/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy_backend/log.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, - PlatformExtension, + 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:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; final pageMemorizer = {}; @@ -35,21 +49,117 @@ Node pageMentionNode(String viewId) { delta: Delta( operations: [ TextInsert( - '\$', - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: viewId, - }, - }, + MentionBlockKeys.mentionChar, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: viewId, + blockId: null, + ), ), ], ), ); } +class ReferenceState { + ReferenceState(this.isReference); + + final bool isReference; +} + 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: () => handleMentionBlockTap( + 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: () => handleMentionBlockTap( + 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, @@ -67,136 +177,77 @@ class MentionPageBlock extends StatefulWidget { final int index; @override - State createState() => _MentionPageBlockState(); + State createState() => _MentionSubPageBlockState(); } -class _MentionPageBlockState extends State { - late final EditorState editorState; - late final ViewListener viewListener = ViewListener(viewId: widget.pageId); - late Future viewPBFuture; - - @override - void initState() { - super.initState(); - - editorState = context.read(); - viewPBFuture = fetchView(widget.pageId); - viewListener.start( - onViewUpdated: (p0) { - pageMemorizer[p0.id] = p0; - viewPBFuture = fetchView(widget.pageId); - editorState.reload(); - }, - ); - } - - @override - void dispose() { - viewListener.stop(); - super.dispose(); - } +class _MentionSubPageBlockState extends State { + late bool isHandlingPaste = context.read().isHandlingPaste; @override Widget build(BuildContext context) { - return FutureBuilder( - initialData: pageMemorizer[widget.pageId], - future: viewPBFuture, - builder: (context, state) { - final view = state.data; - // memorize the result - pageMemorizer[widget.pageId] = view; - if (view == null) { - return const SizedBox.shrink(); - } + 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; + } - final iconSize = widget.textStyle?.fontSize ?? 16.0; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: FlowyHover( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: handleTap, - onDoubleTap: handleDoubleTap, - behavior: HitTestBehavior.translucent, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const HSpace(4), - view.icon.value.isNotEmpty - ? EmojiText( - emoji: view.icon.value, - fontSize: 12, - textAlign: TextAlign.center, - lineHeight: 1.3, - ) - : FlowySvg( - view.layout.icon, - size: Size.square(iconSize + 2.0), - ), - const HSpace(2), - FlowyText( - view.name, - decoration: TextDecoration.underline, - fontSize: widget.textStyle?.fontSize, - fontWeight: widget.textStyle?.fontWeight, - ), - const HSpace(2), - ], + 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: () => + handleMentionBlockTap(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: () => + handleMentionBlockTap(context, widget.editorState, view), + ); + } + }, + ), ); } - Future handleTap() async { - final view = await fetchView(widget.pageId); - if (view == null) { - Log.error('Page(${widget.pageId}) not found'); - return; - } - - if (PlatformExtension.isMobile && mounted) { - await context.pushView(view); - } else { - getIt().add( - TabsEvent.openPlugin(plugin: view.plugin(), view: view), - ); - } - } - - Future handleDoubleTap() async { - if (!PlatformExtension.isMobile) { - return; - } - - final currentViewId = context.read().documentId; - final viewId = await showPageSelectorSheet( - context, - currentViewId: currentViewId, - selectedViewId: widget.pageId, - ); - - if (viewId != null) { - // Update this nodes pageId - final transaction = widget.editorState.transaction - ..formatText( - widget.node, - widget.index, - 1, - { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: viewId, - }, - }, - ); - - await widget.editorState.apply(transaction, withUpdateSelection: false); - } - } - Future fetchView(String pageId) async { final view = await ViewBackendService.getView(pageId).then( (value) => value.toNullable(), @@ -220,10 +271,404 @@ class _MentionPageBlockState extends State { } void updateSelection() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - editorState.updateSelectionWithReason( - editorState.selection, + 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.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: widget.pageId, + blockId: null, + ), ); - }); + + 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 handleMentionBlockTap( + 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, + tabs: [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ].map((e) => e.name).toList(), + ); + } + } 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 newView = await showPageSelectorSheet( + context, + currentViewId: currentViewId, + selectedViewId: viewId, + ); + + if (newView != null) { + // Update this nodes pageId + final transaction = editorState.transaction + ..formatText( + node, + index, + 1, + MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: newView.id, + blockId: null, + ), + ); + + 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 + .orDefault(LocaleKeys.menuAppHeader_defaultNewPageName.tr()) + : ''; + } + + 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 index d15d24aab7..f3578f185e 100644 --- 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 @@ -1,30 +1,34 @@ -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/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/base/emoji/emoji_text.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( +Future showPageSelectorSheet( BuildContext context, { String? currentViewId, String? selectedViewId, + bool Function(ViewPB view)? filter, }) async { - return showMobileBottomSheet( + filter ??= (v) => !v.isSpace && v.parentViewId.isNotEmpty; + + return showMobileBottomSheet( context, title: LocaleKeys.document_mobilePageSelector_title.tr(), showHeader: true, showCloseButton: true, showDragHandle: true, - builder: (context) => Container( - margin: const EdgeInsets.only(top: 12.0), + useSafeArea: false, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (context) => ConstrainedBox( constraints: const BoxConstraints( maxHeight: 340, minHeight: 80, @@ -32,16 +36,22 @@ Future showPageSelectorSheet( child: _MobilePageSelectorBody( currentViewId: currentViewId, selectedViewId: selectedViewId, + filter: filter, ), ), ); } class _MobilePageSelectorBody extends StatefulWidget { - const _MobilePageSelectorBody({this.currentViewId, this.selectedViewId}); + const _MobilePageSelectorBody({ + this.currentViewId, + this.selectedViewId, + this.filter, + }); final String? currentViewId; final String? selectedViewId; + final bool Function(ViewPB view)? filter; @override State<_MobilePageSelectorBody> createState() => @@ -79,7 +89,10 @@ class _MobilePageSelectorBodyState extends State<_MobilePageSelectorBody> { ); } - final views = snapshot.data!; + final views = snapshot.data! + .where((v) => widget.filter?.call(v) ?? true) + .toList(); + if (widget.currentViewId != null) { views.removeWhere((v) => v.id == widget.currentViewId); } @@ -101,27 +114,27 @@ class _MobilePageSelectorBodyState extends State<_MobilePageSelectorBody> { } return Flexible( - child: ListView( - children: filtered - .map( - (view) => FlowyOptionTile.checkbox( - leftIcon: view.icon.value.isNotEmpty - ? EmojiText( - emoji: view.icon.value, - fontSize: 18, - textAlign: TextAlign.center, - lineHeight: 1.3, - ) - : FlowySvg( - view.layout.icon, - size: const Size.square(20), - ), - text: view.name, - isSelected: view.id == widget.selectedViewId, - onTap: () => Navigator.of(context).pop(view.id), - ), - ) - .toList(), + 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), + ); + }, ), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart deleted file mode 100644 index 8216ea5ab3..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart +++ /dev/null @@ -1,47 +0,0 @@ -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_editor/appflowy_editor.dart'; -import 'package:easy_localization/easy_localization.dart'; - -SelectionMenuItem dateMenuItem = SelectionMenuItem( - getName: LocaleKeys.document_plugins_insertDate.tr, - icon: (_, isSelected, style) => FlowySvg( - FlowySvgs.date_s, - color: isSelected - ? style.selectionMenuItemSelectedIconColor - : style.selectionMenuItemIconColor, - ), - keywords: ['insert date', 'date', 'time'], - handler: (editorState, menuService, context) => - _insertDateReference(editorState), -); - -Future _insertDateReference(EditorState editorState) 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, - selection.start.offset, - 0, - '\$', - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: DateTime.now().toIso8601String(), - }, - }, - ); - - await editorState.apply(transaction); -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/menu/menu_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/menu/menu_extension.dart new file mode 100644 index 0000000000..7dcd21f423 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/menu/menu_extension.dart @@ -0,0 +1,116 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +extension MenuExtension on EditorState { + MenuPosition? calculateMenuOffset({ + Rect? rect, + required double menuWidth, + required double menuHeight, + Offset menuOffset = const Offset(0, 10), + }) { + final selectionService = service.selectionService; + final selectionRects = selectionService.selectionRects; + late Rect startRect; + if (rect != null) { + startRect = rect; + } else { + if (selectionRects.isEmpty) return null; + startRect = selectionRects.first; + } + + final editorOffset = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorHeight = renderBox!.size.height; + final editorWidth = renderBox!.size.width; + + // show below default + Alignment alignment = Alignment.topLeft; + final bottomRight = startRect.bottomRight; + final topRight = startRect.topRight; + var startOffset = bottomRight + menuOffset; + Offset offset = Offset( + startOffset.dx, + startOffset.dy, + ); + + // show above + if (startOffset.dy + menuHeight >= editorOffset.dy + editorHeight) { + startOffset = topRight - menuOffset; + alignment = Alignment.bottomLeft; + + offset = Offset( + startOffset.dx, + editorHeight + editorOffset.dy - startOffset.dy, + ); + } + + // show on right + if (offset.dx + menuWidth < editorOffset.dx + editorWidth) { + offset = Offset( + offset.dx, + offset.dy, + ); + } else if (startOffset.dx - editorOffset.dx > menuWidth) { + // show on left + alignment = alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + offset = Offset( + editorWidth - offset.dx + editorOffset.dx, + offset.dy, + ); + } + return MenuPosition(align: alignment, offset: offset); + } +} + +class MenuPosition { + MenuPosition({ + required this.align, + required this.offset, + }); + + final Alignment align; + final Offset offset; + + LTRB get ltrb { + double? left, top, right, bottom; + switch (align) { + 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 LTRB(left: left, top: top, right: right, bottom: bottom); + } +} + +class LTRB { + LTRB({this.left, this.top, this.right, this.bottom}); + + final double? left; + final double? top; + final double? right; + final double? bottom; + + Positioned buildPositioned({required Widget child}) => Positioned( + left: left, + top: top, + right: right, + bottom: bottom, + child: child, + ); +} 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 index d97e6a5bc6..41bb8ce873 100644 --- 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 @@ -2,11 +2,13 @@ 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/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/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:collection/collection.dart'; import 'package:string_validator/string_validator.dart'; @@ -52,7 +54,9 @@ class EditorMigration { node = pageNode(children: children); } } else if (id == 'callout') { - final emoji = nodeV0.attributes['emoji'] ?? '📌'; + final icon = nodeV0.attributes[CalloutBlockKeys.icon] ?? '📌'; + final iconType = nodeV0.attributes[CalloutBlockKeys.iconType] ?? + FlowyIconType.emoji.name; final delta = nodeV0.children.whereType().fold(Delta(), (p, e) { final delta = migrateDelta(e.delta); @@ -62,8 +66,18 @@ class EditorMigration { } return p..insert('\n'); }); + EmojiIconData? emojiIconData; + try { + emojiIconData = + EmojiIconData(FlowyIconType.values.byName(iconType), icon); + } catch (e) { + Log.error( + 'migrateNode get EmojiIconData error with :${nodeV0.attributes}', + e, + ); + } node = calloutNode( - emoji: emoji, + emoji: emojiIconData, delta: delta, ); } else if (id == 'divider') { @@ -164,18 +178,17 @@ class EditorMigration { // Now, the cover is stored in the view.ext. static void migrateCoverIfNeeded( ViewPB view, - EditorState editorState, { + Attributes attributes, { bool overwrite = false, }) async { if (view.extra.isNotEmpty && !overwrite) { return; } - final root = editorState.document.root; final coverType = CoverType.fromString( - root.attributes[DocumentHeaderBlockKeys.coverType], + attributes[DocumentHeaderBlockKeys.coverType], ); - final coverDetails = root.attributes[DocumentHeaderBlockKeys.coverDetails]; + final coverDetails = attributes[DocumentHeaderBlockKeys.coverDetails]; Map extra = {}; @@ -191,7 +204,13 @@ class EditorMigration { } else { switch (coverType) { case CoverType.asset: - // The new version does not support the asset cover. + extra = { + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: + PageStyleCoverImageType.builtInImage.toString(), + ViewExtKeys.coverValueKey: coverDetails, + }, + }; break; case CoverType.color: extra = { @@ -221,6 +240,14 @@ class EditorMigration { }, }; } + } else { + extra = { + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: + PageStyleCoverImageType.localImage.toString(), + ViewExtKeys.coverValueKey: coverDetails, + }, + }; } break; default: 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 index 6b374f79f8..ba170e8d24 100644 --- 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 @@ -1,4 +1,7 @@ +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'; @@ -21,7 +24,7 @@ List buildMobileFloatingToolbarItems( ContextMenuButtonItem( label: LocaleKeys.editor_copy.tr(), onPressed: () { - copyCommand.execute(editorState); + customCopyCommand.execute(editorState); closeToolbar(); }, ), @@ -32,7 +35,7 @@ List buildMobileFloatingToolbarItems( ContextMenuButtonItem( label: LocaleKeys.editor_paste.tr(), onPressed: () { - pasteCommand.execute(editorState); + customPasteCommand.execute(editorState); closeToolbar(); }, ), @@ -100,14 +103,7 @@ class CustomMobileFloatingToolbar extends StatelessWidget { Widget build(BuildContext context) { return Animate( autoPlay: true, - effects: [ - const FadeEffect(duration: SelectionOverlay.fadeDuration), - MoveEffect( - curve: Curves.easeOutCubic, - begin: const Offset(0, 16), - duration: 100.milliseconds, - ), - ], + effects: _getEffects(context), child: AdaptiveTextSelectionToolbar.buttonItems( buttonItems: buildMobileFloatingToolbarItems( editorState, @@ -120,4 +116,32 @@ class CustomMobileFloatingToolbar extends StatelessWidget { ), ); } + + 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/utils.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart index 21f3b7ea68..ad4d523812 100644 --- 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 @@ -10,8 +10,6 @@ Future showEditLinkBottomSheet( ) { return showMobileBottomSheet( context, - showHeader: false, - showCloseButton: false, showDragHandle: true, padding: const EdgeInsets.symmetric(horizontal: 16), builder: (context) { 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 index 57670afadd..8e1a8533e0 100644 --- 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 @@ -6,7 +6,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_too 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:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockKeys, quoteNode; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; 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 index c5f4b77d62..997faaf2a9 100644 --- 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 @@ -1,6 +1,7 @@ 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'; @@ -25,7 +26,10 @@ class ColorItem extends StatelessWidget { 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 { @@ -48,10 +52,12 @@ class ColorItem extends StatelessWidget { ); }, icon: FlowySvgs.m_aa_font_color_m, - iconColor: selectedTextColor?.tryToColor(), - backgroundColor: selectedBackgroundColor?.tryToColor() ?? - theme.toolbarMenuItemBackgroundColor, - selectedBackgroundColor: selectedBackgroundColor?.tryToColor(), + iconColor: EditorFontColors.fromBuiltInColors( + context, + selectedTextColor?.tryToColor(), + ), + backgroundColor: backgroundColor ?? theme.toolbarMenuItemBackgroundColor, + selectedBackgroundColor: backgroundColor, isSelected: selectedBackgroundColor != null, showRightArrow: true, iconPadding: const EdgeInsets.only( 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 index 6b03ff6301..6ec777429c 100644 --- 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 @@ -1,6 +1,7 @@ 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'; @@ -21,8 +22,6 @@ Future showTextColorAndBackgroundColorPicker( await showMobileBottomSheet( context, showHeader: true, - showCloseButton: false, - showDivider: true, showDragHandle: true, showDoneButton: true, barrierColor: Colors.transparent, @@ -84,10 +83,10 @@ class _TextColorAndBackgroundColorState ), ), const VSpace(6.0), - _TextColors( + EditorTextColorWidget( selectedColor: selectedTextColor?.tryToColor(), onSelectedColor: (textColor) async { - final hex = textColor.alpha == 0 ? null : textColor.toHex(); + final hex = textColor.a == 0 ? null : textColor.toHex(); final selection = widget.selection; if (selection.isCollapsed) { widget.editorState.updateToggledStyle( @@ -121,11 +120,10 @@ class _TextColorAndBackgroundColorState ), ), const VSpace(6.0), - _BackgroundColors( + EditorBackgroundColors( selectedColor: selectedBackgroundColor?.tryToColor(), onSelectedColor: (backgroundColor) async { - final hex = - backgroundColor.alpha == 0 ? null : backgroundColor.toHex(); + final hex = backgroundColor.a == 0 ? null : backgroundColor.toHex(); final selection = widget.selection; if (selection.isCollapsed) { widget.editorState.updateToggledStyle( @@ -153,8 +151,9 @@ class _TextColorAndBackgroundColorState } } -class _BackgroundColors extends StatelessWidget { - _BackgroundColors({ +class EditorBackgroundColors extends StatelessWidget { + const EditorBackgroundColors({ + super.key, this.selectedColor, required this.onSelectedColor, }); @@ -162,29 +161,11 @@ class _BackgroundColors extends StatelessWidget { final Color? selectedColor; final void Function(Color color) onSelectedColor; - final colors = [ - 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(0xFFFFF176), - const Color(0xFF71E6B4), - const Color(0xFF80F1FF), - ]; - @override Widget build(BuildContext context) { + final colors = Theme.of(context).brightness == Brightness.light + ? EditorFontColors.lightColors + : EditorFontColors.darkColors; return GridView.count( crossAxisCount: _count, shrinkWrap: true, @@ -244,8 +225,9 @@ class _BackgroundColorItem extends StatelessWidget { } } -class _TextColors extends StatelessWidget { - _TextColors({ +class EditorTextColorWidget extends StatelessWidget { + EditorTextColorWidget({ + super.key, this.selectedColor, required this.onSelectedColor, }); @@ -313,7 +295,7 @@ class _TextColorItem extends StatelessWidget { child: FlowyText( 'A', fontSize: 24, - color: color.alpha == 0 ? null : color, + color: color.a == 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 index b1004a3eae..96431996f5 100644 --- 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 @@ -75,7 +75,7 @@ class FontFamilyItem extends StatelessWidget { } }); }, - text: (fontFamily ?? systemFonFamily).parseFontFamilyName(), + text: (fontFamily ?? systemFonFamily).fontFamilyDisplayName, fontFamily: fontFamily ?? systemFonFamily, backgroundColor: theme.toolbarMenuItemBackgroundColor, isSelected: false, 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 0000000000..e31d9f68a6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_attachment_item.dart @@ -0,0 +1,237 @@ +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 MobileQuickActionDivider(), + MobileQuickActionButton( + text: LocaleKeys.document_attachmentMenu_takePicture.tr(), + icon: FlowySvgs.camera_s, + iconSize: const Size.square(20), + onTap: () async => selectCamera(context), + ), + const MobileQuickActionDivider(), + MobileQuickActionButton( + text: LocaleKeys.document_attachmentMenu_chooseFile.tr(), + icon: FlowySvgs.file_s, + iconSize: const Size.square(20), + onTap: () async => selectFile(context), + ), + ], + ), + ); + } + + 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 0000000000..b3df4dfd39 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_menu_item_builder.dart @@ -0,0 +1,481 @@ +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' + hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; +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 index d0be5af466..c09368ff95 100644 --- 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 @@ -1,29 +1,25 @@ 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/base/type_option_menu_item.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.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/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/mobile_toolbar_v3/aa_menu/_toolbar_theme.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:go_router/go_router.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: () { @@ -75,12 +71,13 @@ Future showAddBlockMenu( enableDraggableScrollable: true, builder: (_) => Padding( padding: EdgeInsets.all(16 * context.scale), - child: _AddBlockMenu(selection: selection, editorState: editorState), + child: AddBlockMenu(selection: selection, editorState: editorState), ), ); -class _AddBlockMenu extends StatelessWidget { - const _AddBlockMenu({ +class AddBlockMenu extends StatelessWidget { + const AddBlockMenu({ + super.key, required this.selection, required this.editorState, }); @@ -90,243 +87,13 @@ class _AddBlockMenu extends StatelessWidget { @override Widget build(BuildContext context) { + final builder = AddBlockMenuItemBuilder( + editorState: editorState, + selection: selection, + ); return TypeOptionMenu( - values: buildTypeOptionMenuItemValues(context), + values: builder.buildTypeOptionMenuItemValues(context), scaleFactor: context.scale, ); } - - Future _insertBlock(Node node) async { - AppGlobals.rootNavKey.currentContext?.pop(true); - Future.delayed( - const Duration(milliseconds: 100), - () => editorState.insertBlockAfterCurrentSelection(selection, node), - ); - } - - List> buildTypeOptionMenuItemValues( - BuildContext context, - ) { - final colorMap = _colorMap(context); - return [ - // heading 1 - 3 - 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)), - ), - - // paragraph - TypeOptionMenuItemValue( - value: ParagraphBlockKeys.type, - backgroundColor: colorMap[ParagraphBlockKeys.type]!, - text: LocaleKeys.editor_text.tr(), - icon: FlowySvgs.m_add_block_paragraph_s, - onTap: (_, __) => _insertBlock(paragraphNode()), - ), - - // checkbox - 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)), - ), - - // quote - TypeOptionMenuItemValue( - value: QuoteBlockKeys.type, - backgroundColor: colorMap[QuoteBlockKeys.type]!, - text: LocaleKeys.editor_quote.tr(), - icon: FlowySvgs.m_add_block_quote_s, - onTap: (_, __) => _insertBlock(quoteNode()), - ), - - // 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()), - ), - - // image - 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); - }); - }, - ), - - // date - TypeOptionMenuItemValue( - value: ParagraphBlockKeys.type, - backgroundColor: colorMap[MentionBlockKeys.type]!, - text: LocaleKeys.editor_date.tr(), - icon: FlowySvgs.m_add_block_date_s, - onTap: (_, __) => _insertBlock(dateMentionNode()), - ), - // page - TypeOptionMenuItemValue( - value: ParagraphBlockKeys.type, - backgroundColor: colorMap[MentionBlockKeys.type]!, - text: LocaleKeys.editor_page.tr(), - icon: FlowySvgs.document_s, - onTap: (_, __) async { - AppGlobals.rootNavKey.currentContext?.pop(true); - - final currentViewId = getIt().latestOpenView?.id; - final viewId = await showPageSelectorSheet( - context, - currentViewId: currentViewId, - ); - - if (viewId != null) { - Future.delayed(const Duration(milliseconds: 100), () { - editorState.insertBlockAfterCurrentSelection( - selection, - pageMentionNode(viewId), - ); - }); - } - }, - ), - - // divider - 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 - TypeOptionMenuItemValue( - value: CalloutBlockKeys.type, - backgroundColor: colorMap[CalloutBlockKeys.type]!, - text: LocaleKeys.document_plugins_callout.tr(), - icon: FlowySvgs.m_add_block_callout_s, - onTap: (_, __) => _insertBlock(calloutNode()), - ), - TypeOptionMenuItemValue( - value: CodeBlockKeys.type, - backgroundColor: colorMap[CodeBlockKeys.type]!, - text: LocaleKeys.editor_codeBlockShortForm.tr(), - icon: FlowySvgs.m_add_block_code_s, - onTap: (_, __) => _insertBlock(codeBlockNode()), - ), - 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), - 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), - 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), - }; - } -} - -extension on EditorState { - 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/mobile_toolbar_v3/appflowy_mobile_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart index f3339de3a8..787ccfda9f 100644 --- 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 @@ -1,5 +1,6 @@ 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'; @@ -9,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_too 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'; @@ -18,6 +20,7 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; abstract class AppFlowyMobileToolbarWidgetService { void closeItemMenu(); + void closeKeyboard(); PropertyValueNotifier get showMenuNotifier; @@ -73,8 +76,7 @@ class _AppFlowyMobileToolbarState extends State { ValueListenableBuilder( valueListenable: isKeyboardShow, builder: (context, isKeyboardShow, __) { - return AnimatedContainer( - duration: const Duration(milliseconds: 110), + return SizedBox( // only adding padding when the keyboard is triggered by editor height: isKeyboardShow && widget.editorState.selection != null ? widget.toolbarHeight @@ -178,7 +180,13 @@ class _MobileToolbarState extends State<_MobileToolbar> // 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); + + /// when the [_MobileToolbar] disposed before the keyboard height can be updated in time, + /// there will be an issue with the height being 0 + /// this is used to globally record the height. + static double _globalCachedKeyboardHeight = 0.0; + ValueNotifier cachedKeyboardHeight = + ValueNotifier(_globalCachedKeyboardHeight); // used to check if click the same item again int? selectedMenuIndex; @@ -244,12 +252,12 @@ class _MobileToolbarState extends State<_MobileToolbar> children: [ const Divider( height: 0.5, - color: Color(0xFFEDEDED), + color: Color(0x7FEDEDED), ), _buildToolbar(context), const Divider( height: 0.5, - color: Color(0xFFEDEDED), + color: Color(0x7FEDEDED), ), _buildMenuOrSpacer(context), ], @@ -275,7 +283,9 @@ class _MobileToolbarState extends State<_MobileToolbar> if (!closeKeyboardInitiative && cachedKeyboardHeight.value != 0 && height == 0) { - widget.editorState.selection = null; + 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 @@ -285,6 +295,14 @@ class _MobileToolbarState extends State<_MobileToolbar> 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) { @@ -383,26 +401,35 @@ class _MobileToolbarState extends State<_MobileToolbar> return ValueListenableBuilder( valueListenable: cachedKeyboardHeight, builder: (_, height, ___) { - return AnimatedContainer( - duration: const Duration(microseconds: 110), - height: height, - child: ValueListenableBuilder( - valueListenable: showMenuNotifier, - builder: (_, showingMenu, __) { - return AnimatedContainer( - duration: const Duration(microseconds: 110), - height: height, - child: (showingMenu && selectedMenuIndex != null) - ? widget.toolbarItems[selectedMenuIndex!].menuBuilder?.call( - context, - widget.editorState, - this, - ) ?? - const SizedBox.shrink() - : const SizedBox.shrink(), - ); - }, - ), + 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, + ); + } + } + if (keyboardHeight > 0) { + _globalCachedKeyboardHeight = keyboardHeight; + } + return SizedBox( + height: keyboardHeight, + child: (showingMenu && selectedMenuIndex != null) + ? widget.toolbarItems[selectedMenuIndex!].menuBuilder?.call( + context, + widget.editorState, + this, + ) ?? + const SizedBox.shrink() + : const SizedBox.shrink(), + ); + }, ); }, ); 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 index d138e644cd..12e7d1bef7 100644 --- 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 @@ -1,6 +1,7 @@ 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'; @@ -106,9 +107,9 @@ class _AppFlowyMobileToolbarIconItemState final enable = widget.enable?.call() ?? true; return Padding( padding: const EdgeInsets.symmetric(vertical: 5), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { + child: AnimatedGestureDetector( + scaleFactor: 0.95, + onTapUp: () { widget.onTap(); _rebuild(); }, @@ -134,7 +135,7 @@ class _AppFlowyMobileToolbarIconItemState } void _rebuild() { - if (!context.mounted) { + if (!mounted) { return; } setState(() { 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 0000000000..518060ccf5 --- /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/biusc_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/biusc_toolbar_item.dart deleted file mode 100644 index f0bfda04a2..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/biusc_toolbar_item.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.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: backgroundColor?.tryToColor(), - ), - child: FlowySvg( - FlowySvgs.m_aa_font_color_m, - color: 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/keyboard_height_observer.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart index 912bdb044f..d72f722eb6 100644 --- 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 @@ -1,4 +1,5 @@ -import 'package:appflowy/startup/tasks/prelude.dart'; +import 'dart:io'; + import 'package:keyboard_height_plugin/keyboard_height_plugin.dart'; typedef KeyboardHeightCallback = void Function(double height); @@ -9,8 +10,6 @@ class KeyboardHeightObserver { KeyboardHeightObserver._() { _keyboardHeightPlugin.onKeyboardHeightChanged((height) { notify(height); - - currentKeyboardHeight = height; }); } @@ -34,14 +33,13 @@ class KeyboardHeightObserver { } void notify(double height) { - // the keyboard height will notify twice with the same value on Android 14 - if (ApplicationInfo.androidSDKVersion == 34) { - if (height == 0 && currentKeyboardHeight == 0) { - return; - } + // 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/toolbar_item_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/toolbar_item_builder.dart index adb1feeb35..35a3c37e74 100644 --- 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 @@ -12,6 +12,7 @@ final _defaultToolbarItems = [ aaToolbarItem, todoListToolbarItem, bulletedListToolbarItem, + addAttachmentItem, numberedListToolbarItem, boldToolbarItem, italicToolbarItem, @@ -35,6 +36,7 @@ final _listToolbarItems = [ underlineToolbarItem, strikethroughToolbarItem, colorToolbarItem, + addAttachmentItem, undoToolbarItem, redoToolbarItem, ]; 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 index a016a0863f..37444cd6e1 100644 --- 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 @@ -1,9 +1,8 @@ -import 'package:flutter/material.dart'; - 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({ @@ -239,7 +238,7 @@ extension MobileToolbarEditorState on EditorState { needToDeleteChildren = true; transaction.insertNodes( selection.end.path.next, - node.children.map((e) => e.copyWith()), + node.children.map((e) => e.deepCopy()), ); await apply(transaction); } @@ -339,4 +338,21 @@ extension MobileToolbarEditorState on EditorState { 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 index 8e63873641..f77083d21d 100644 --- 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 @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -8,36 +9,51 @@ class NumberedListIcon extends StatelessWidget { 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 textStyle = - context.read().editorStyle.textStyleConfiguration.text; - return Container( - constraints: const BoxConstraints( - minWidth: 22, - minHeight: 22, - ), - margin: const EdgeInsets.only(right: 8.0), - alignment: Alignment.center, - child: Center( - child: Text( - node.levelString, - style: textStyle, - textDirection: textDirection, + 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(left: 6.0, right: 10.0), + child: Text( + node.buildLevelString(context), + 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); +extension NumberedListNodeIndex on Node { + String buildLevelString(BuildContext context) { + final builder = NumberedListIndexBuilder( + editorState: context.read(), + node: this, + ); final indexInRootLevel = builder.indexInRootLevel; final indexInSameLevel = builder.indexInSameLevel; final level = indexInRootLevel % 3; @@ -50,11 +66,13 @@ extension on Node { } } -class _NumberedListIconBuilder { - _NumberedListIconBuilder({ +class NumberedListIndexBuilder { + NumberedListIndexBuilder({ + required this.editorState, required this.node, }); + final EditorState editorState; final Node node; // the level of the current node @@ -76,7 +94,13 @@ class _NumberedListIconBuilder { 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) { + final aiNodeExternalValues = + node.externalValues?.unwrapOrNull(); + + if (previous == null || + previous.type != NumberedListBlockKeys.type || + (aiNodeExternalValues != null && + aiNodeExternalValues.isFirstNumberedListNode)) { return node.attributes[NumberedListBlockKeys.number] ?? level; } @@ -85,10 +109,17 @@ class _NumberedListIconBuilder { startNumber = previous.attributes[NumberedListBlockKeys.number] as int?; level++; previous = previous.previous; + + // break the loop if the start number is found when the current node is an AI node + if (aiNodeExternalValues != null && startNumber != null) { + return startNumber + level - 1; + } } + if (startNumber != null) { - return startNumber + level - 1; + level = startNumber + level - 1; } + return level; } } 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 deleted file mode 100644 index d682a82f08..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/error.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -part 'error.freezed.dart'; -part 'error.g.dart'; - -@freezed -class OpenAIError with _$OpenAIError { - const factory OpenAIError({ - String? code, - required String message, - }) = _OpenAIError; - - factory OpenAIError.fromJson(Map json) => - _$OpenAIErrorFromJson(json); -} 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 deleted file mode 100644 index eb688b2c56..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart +++ /dev/null @@ -1,283 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_edit.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'); - } - } -} - -abstract class OpenAIRepository { - /// Get completions from GPT-3 - /// - /// [prompt] is the prompt text - /// [suffix] is the suffix text - /// [maxTokens] is the maximum number of tokens to generate - /// [temperature] is the temperature of the model - /// - Future> getCompletions({ - required String prompt, - String? suffix, - int maxTokens = 2048, - double temperature = .3, - }); - - Future getStreamedCompletions({ - required String prompt, - required Future Function() onStart, - required Future Function(TextCompletionResponse response) onProcess, - required Future Function() onEnd, - required void Function(OpenAIError error) onError, - String? suffix, - int maxTokens = 2048, - double temperature = 0.3, - bool useAction = false, - }); - - /// Get edits from GPT-3 - /// - /// [input] is the input text - /// [instruction] is the instruction text - /// [temperature] is the temperature of the model - /// - Future> getEdits({ - required String input, - required String instruction, - double temperature = 0.3, - }); - - /// Generate image from GPT-3 - /// - /// [prompt] is the prompt text - /// [n] is the number of images to generate - /// - /// the result is a list of urls - Future, OpenAIError>> generateImage({ - required String prompt, - int n = 1, - }); -} - -class HttpOpenAIRepository implements OpenAIRepository { - 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> getCompletions({ - required String prompt, - String? suffix, - int maxTokens = 2048, - double temperature = 0.3, - }) async { - final parameters = { - 'model': 'gpt-3.5-turbo-instruct', - 'prompt': prompt, - 'suffix': suffix, - 'max_tokens': maxTokens, - 'temperature': temperature, - 'stream': false, - }; - - final response = await client.post( - OpenAIRequestType.textCompletion.uri, - headers: headers, - body: json.encode(parameters), - ); - - if (response.statusCode == 200) { - return FlowyResult.success( - TextCompletionResponse.fromJson( - json.decode( - utf8.decode(response.bodyBytes), - ), - ), - ); - } else { - return FlowyResult.failure( - OpenAIError.fromJson(json.decode(response.body)['error']), - ); - } - } - - @override - Future getStreamedCompletions({ - required String prompt, - required Future Function() onStart, - required Future Function(TextCompletionResponse response) onProcess, - required Future Function() onEnd, - required void Function(OpenAIError 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( - OpenAIError.fromJson(json.decode(body)['error']), - ); - } - return; - } - - @override - Future> getEdits({ - required String input, - required String instruction, - double temperature = 0.3, - int n = 1, - }) async { - final parameters = { - 'model': 'gpt-4', - 'input': input, - 'instruction': instruction, - 'temperature': temperature, - 'n': n, - }; - - final response = await client.post( - OpenAIRequestType.textEdit.uri, - headers: headers, - body: json.encode(parameters), - ); - - if (response.statusCode == 200) { - return FlowyResult.success( - TextEditResponse.fromJson( - json.decode( - utf8.decode(response.bodyBytes), - ), - ), - ); - } else { - return FlowyResult.failure( - OpenAIError.fromJson(json.decode(response.body)['error']), - ); - } - } - - @override - Future, OpenAIError>> 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( - OpenAIError.fromJson(json.decode(response.body)['error']), - ); - } - } catch (error) { - return FlowyResult.failure(OpenAIError(message: error.toString())); - } - } -} 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 deleted file mode 100644 index 067049adbf..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 52cce9da4f..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/text_edit.dart +++ /dev/null @@ -1,24 +0,0 @@ -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/learn_more_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart deleted file mode 100644 index 17e89b1bca..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart +++ /dev/null @@ -1,8 +0,0 @@ -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/auto_completion_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart deleted file mode 100644 index 4d4ed15a1f..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart +++ /dev/null @@ -1,557 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.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/user_service.dart'; -import 'package:appflowy/workspace/presentation/home/toast.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:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/style_widget/text_field.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:http/http.dart' as http; -import 'package:provider/provider.dart'; - -class AutoCompletionBlockKeys { - const AutoCompletionBlockKeys._(); - - static const String type = 'auto_completion'; - static const String prompt = 'prompt'; - static const String startSelection = 'start_selection'; - static const String generationCount = 'generation_count'; -} - -Node autoCompletionNode({ - String prompt = '', - required Selection start, -}) { - return Node( - type: AutoCompletionBlockKeys.type, - attributes: { - AutoCompletionBlockKeys.prompt: prompt, - AutoCompletionBlockKeys.startSelection: start.toJson(), - AutoCompletionBlockKeys.generationCount: 0, - }, - ); -} - -SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node( - getName: LocaleKeys.document_plugins_autoGeneratorMenuItemName.tr, - iconData: Icons.generating_tokens, - keywords: ['ai', 'openai' 'writer', 'autogenerator'], - nodeBuilder: (editorState, _) { - final node = autoCompletionNode(start: editorState.selection!); - return node; - }, - replace: (_, node) => false, -); - -class AutoCompletionBlockComponentBuilder extends BlockComponentBuilder { - AutoCompletionBlockComponentBuilder(); - - @override - BlockComponentWidget build(BlockComponentContext blockComponentContext) { - final node = blockComponentContext.node; - return AutoCompletionBlockComponent( - key: node.key, - node: node, - showActions: showActions(node), - actionBuilder: (context, state) => actionBuilder( - blockComponentContext, - state, - ), - ); - } - - @override - bool validate(Node node) { - return node.children.isEmpty && - node.attributes[AutoCompletionBlockKeys.prompt] is String && - node.attributes[AutoCompletionBlockKeys.startSelection] is Map; - } -} - -class AutoCompletionBlockComponent extends BlockComponentStatefulWidget { - const AutoCompletionBlockComponent({ - super.key, - required super.node, - super.showActions, - super.actionBuilder, - super.configuration = const BlockComponentConfiguration(), - }); - - @override - State createState() => - _AutoCompletionBlockComponentState(); -} - -class _AutoCompletionBlockComponentState - extends State { - final controller = TextEditingController(); - final textFieldFocusNode = FocusNode(); - - late final editorState = context.read(); - late final SelectionGestureInterceptor interceptor; - - String get prompt => widget.node.attributes[AutoCompletionBlockKeys.prompt]; - int get generationCount => - widget.node.attributes[AutoCompletionBlockKeys.generationCount] ?? 0; - Selection? get startSelection { - final selection = - widget.node.attributes[AutoCompletionBlockKeys.startSelection]; - if (selection != null) { - return Selection.fromJson(selection); - } - return null; - } - - @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) { - return Card( - elevation: 5, - color: Theme.of(context).colorScheme.surface, - child: Container( - margin: const EdgeInsets.all(10), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AutoCompletionHeader(), - const Space(0, 10), - if (prompt.isEmpty && generationCount < 1) ...[ - _buildInputWidget(context), - const Space(0, 10), - AutoCompletionInputFooter( - onGenerate: _onGenerate, - onExit: _onExit, - ), - ] else ...[ - AutoCompletionFooter( - onKeep: _onExit, - onRewrite: _onRewrite, - onDiscard: _onDiscard, - ), - ], - ], - ), - ), - ); - } - - 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 { - final transaction = editorState.transaction..deleteNode(widget.node); - await editorState.apply( - transaction, - options: const ApplyOptions( - recordUndo: false, - ), - ); - } - - Future _onGenerate() async { - final loading = Loading(context); - await loading.start(); - - await _updateEditingText(); - - final userProfile = await UserBackendService.getCurrentUserProfile() - .then((value) => value.toNullable()); - if (userProfile == null) { - await loading.stop(); - if (mounted) { - showSnackBarMessage( - context, - LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(), - showCancel: true, - ); - } - return; - } - - final textRobot = TextRobot(editorState: editorState); - BarrierDialog? barrierDialog; - final openAIRepository = HttpOpenAIRepository( - client: http.Client(), - apiKey: userProfile.openaiKey, - ); - await openAIRepository.getStreamedCompletions( - prompt: controller.text, - onStart: () async { - await loading.stop(); - if (mounted) { - barrierDialog = BarrierDialog(context); - await barrierDialog?.show(); - await _makeSurePreviousNodeIsEmptyParagraphNode(); - } - }, - onProcess: (response) async { - if (response.choices.isNotEmpty) { - final text = response.choices.first.text; - await textRobot.autoInsertText( - text, - delay: Duration.zero, - ); - } - }, - onEnd: () async { - await barrierDialog?.dismiss(); - }, - onError: (error) async { - await loading.stop(); - if (mounted) { - showSnackBarMessage( - context, - error.message, - showCancel: true, - ); - } - }, - ); - await _updateGenerationCount(); - } - - Future _onDiscard() async { - final selection = startSelection; - if (selection != null) { - final start = selection.start.path; - final end = widget.node.previous?.path; - if (end != null) { - final transaction = editorState.transaction; - transaction.deleteNodesAtPath( - start, - end.last - start.last + 1, - ); - await editorState.apply(transaction); - await _makeSurePreviousNodeIsEmptyParagraphNode(); - } - } - return _onExit(); - } - - Future _onRewrite() async { - final previousOutput = _getPreviousOutput(); - if (previousOutput == null) { - return; - } - - final loading = Loading(context); - await loading.start(); - // clear previous response - final selection = startSelection; - if (selection != null) { - final start = selection.start.path; - final end = widget.node.previous?.path; - if (end != null) { - final transaction = editorState.transaction; - transaction.deleteNodesAtPath( - start, - end.last - start.last + 1, - ); - await editorState.apply(transaction); - } - } - // generate new response - final userProfile = await UserBackendService.getCurrentUserProfile() - .then((value) => value.toNullable()); - if (userProfile == null) { - await loading.stop(); - if (mounted) { - showSnackBarMessage( - context, - LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(), - showCancel: true, - ); - } - return; - } - final textRobot = TextRobot(editorState: editorState); - final openAIRepository = HttpOpenAIRepository( - client: http.Client(), - apiKey: userProfile.openaiKey, - ); - await openAIRepository.getStreamedCompletions( - prompt: _rewritePrompt(previousOutput), - onStart: () async { - await loading.stop(); - await _makeSurePreviousNodeIsEmptyParagraphNode(); - }, - onProcess: (response) async { - if (response.choices.isNotEmpty) { - final text = response.choices.first.text; - await textRobot.autoInsertText( - text, - delay: Duration.zero, - ); - } - }, - onEnd: () async {}, - onError: (error) async { - await loading.stop(); - if (mounted) { - showSnackBarMessage( - context, - error.message, - showCancel: true, - ); - } - }, - ); - await _updateGenerationCount(); - } - - 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; - } - - Future _updateEditingText() async { - final transaction = editorState.transaction; - transaction.updateNode( - widget.node, - { - AutoCompletionBlockKeys.prompt: controller.text, - }, - ); - await editorState.apply(transaction); - } - - Future _updateGenerationCount() async { - final transaction = editorState.transaction; - transaction.updateNode( - widget.node, - { - AutoCompletionBlockKeys.generationCount: generationCount + 1, - }, - ); - await editorState.apply(transaction); - } - - String _rewritePrompt(String previousOutput) { - return 'I am not satisfied with your previous response ($previousOutput) to the query ($prompt). Please provide an alternative response.'; - } - - Future _makeSurePreviousNodeIsEmptyParagraphNode() async { - // make sure the previous node is a empty paragraph node without any styles. - final transaction = editorState.transaction; - final previous = widget.node.previous; - final Selection selection; - if (previous == null || - previous.type != ParagraphBlockKeys.type || - (previous.delta?.toPlainText().isNotEmpty ?? false)) { - selection = Selection.single( - path: widget.node.path, - startOffset: 0, - ); - transaction.insertNode( - widget.node.path, - paragraphNode(), - ); - } else { - selection = Selection.single( - path: previous.path, - startOffset: 0, - ); - } - transaction.updateNode(widget.node, { - AutoCompletionBlockKeys.startSelection: selection.toJson(), - }); - transaction.afterSelection = selection; - await editorState.apply(transaction); - } - - void _subscribeSelectionGesture() { - interceptor = SelectionGestureInterceptor( - key: AutoCompletionBlockKeys.type, - canTap: (details) { - if (!context.isOffsetInside(details.globalPosition)) { - if (prompt.isNotEmpty || controller.text.isNotEmpty) { - // show dialog - showDialog( - context: context, - builder: (context) { - return 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( - AutoCompletionBlockKeys.type, - ); - } -} - -class AutoCompletionHeader extends StatelessWidget { - const AutoCompletionHeader({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - FlowyText.medium( - LocaleKeys.document_plugins_autoGeneratorTitleName.tr(), - fontSize: 14, - ), - const Spacer(), - FlowyButton( - useIntrinsicWidth: true, - text: FlowyText.regular( - LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(), - ), - onTap: () async { - await openLearnMorePage(); - }, - ), - ], - ); - } -} - -class AutoCompletionInputFooter extends StatelessWidget { - const AutoCompletionInputFooter({ - super.key, - required this.onGenerate, - required this.onExit, - }); - - final VoidCallback onGenerate; - final VoidCallback onExit; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - PrimaryTextButton( - LocaleKeys.button_generate.tr(), - onPressed: onGenerate, - ), - const Space(10, 0), - SecondaryTextButton( - LocaleKeys.button_cancel.tr(), - onPressed: onExit, - ), - Expanded( - child: Container( - alignment: Alignment.centerRight, - child: FlowyText.regular( - LocaleKeys.document_plugins_warning.tr(), - color: Theme.of(context).hintColor, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ); - } -} - -class AutoCompletionFooter extends StatelessWidget { - const AutoCompletionFooter({ - 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: [ - PrimaryTextButton( - LocaleKeys.button_keep.tr(), - onPressed: onKeep, - ), - const Space(10, 0), - SecondaryTextButton( - LocaleKeys.document_plugins_autoGeneratorRewrite.tr(), - onPressed: onRewrite, - ), - const Space(10, 0), - SecondaryTextButton( - LocaleKeys.button_discard.tr(), - onPressed: onDiscard, - ), - ], - ); - } -} 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 deleted file mode 100644 index 4c0c2bc91a..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 076a7877cd..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; - -class Loading { - Loading(this.context); - - BuildContext? loadingContext; - final BuildContext context; - - Future start() async => showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) { - loadingContext = context; - return const SimpleDialog( - elevation: 0.0, - backgroundColor: - Colors.transparent, // can change this to your preferred color - children: [ - Center( - child: CircularProgressIndicator(), - ), - ], - ); - }, - ); - - Future stop() async { - if (loadingContext != null) { - Navigator.of(loadingContext!).pop(); - loadingContext = null; - } - } -} - -class BarrierDialog { - BarrierDialog(this.context); - - late BuildContext loadingContext; - final BuildContext context; - - Future show() async => showDialog( - context: context, - barrierDismissible: false, - barrierColor: Colors.transparent, - builder: (BuildContext context) { - loadingContext = context; - return const SizedBox.shrink(); - }, - ); - - Future dismiss() async => Navigator.of(loadingContext).pop(); -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart deleted file mode 100644 index 70affa002a..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; - -enum SmartEditAction { - summarize, - fixSpelling, - improveWriting, - makeItLonger; - - String get toInstruction { - switch (this) { - case SmartEditAction.summarize: - return 'Tl;dr'; - case SmartEditAction.fixSpelling: - return 'Correct this to standard English:'; - case SmartEditAction.improveWriting: - return 'Rewrite this in your own words:'; - case SmartEditAction.makeItLonger: - return 'Make this text longer:'; - } - } - - String prompt(String input) { - switch (this) { - case SmartEditAction.summarize: - return '$input\n\nTl;dr'; - case SmartEditAction.fixSpelling: - return 'Correct this to standard English:\n\n$input'; - case SmartEditAction.improveWriting: - return 'Rewrite this:\n\n$input'; - case SmartEditAction.makeItLonger: - return 'Make this text longer:\n\n$input'; - } - } - - static SmartEditAction from(int index) { - switch (index) { - case 0: - return SmartEditAction.summarize; - case 1: - return SmartEditAction.fixSpelling; - case 2: - return SmartEditAction.improveWriting; - case 3: - return SmartEditAction.makeItLonger; - } - return SmartEditAction.fixSpelling; - } - - String get name { - switch (this) { - case SmartEditAction.summarize: - return LocaleKeys.document_plugins_smartEditSummarize.tr(); - case SmartEditAction.fixSpelling: - return LocaleKeys.document_plugins_smartEditFixSpelling.tr(); - case SmartEditAction.improveWriting: - return LocaleKeys.document_plugins_smartEditImproveWriting.tr(); - case SmartEditAction.makeItLonger: - return LocaleKeys.document_plugins_smartEditMakeLonger.tr(); - } - } -} - -class SmartEditActionWrapper extends ActionCell { - SmartEditActionWrapper(this.inner); - - final SmartEditAction 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/smart_edit_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart deleted file mode 100644 index f023d56abe..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart +++ /dev/null @@ -1,467 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/decoration.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:http/http.dart' as http; -import 'package:provider/provider.dart'; - -class SmartEditBlockKeys { - const SmartEditBlockKeys._(); - - static const type = 'smart_edit'; - - /// The instruction of the smart edit. - /// - /// It is a [SmartEditAction] value. - static const action = 'action'; - - /// The input of the smart edit. - static const content = 'content'; -} - -Node smartEditNode({ - required SmartEditAction action, - required String content, -}) { - return Node( - type: SmartEditBlockKeys.type, - attributes: { - SmartEditBlockKeys.action: action.index, - SmartEditBlockKeys.content: content, - }, - ); -} - -class SmartEditBlockComponentBuilder extends BlockComponentBuilder { - SmartEditBlockComponentBuilder(); - - @override - BlockComponentWidget build(BlockComponentContext blockComponentContext) { - final node = blockComponentContext.node; - return SmartEditBlockComponentWidget( - key: node.key, - node: node, - showActions: showActions(node), - actionBuilder: (context, state) => actionBuilder( - blockComponentContext, - state, - ), - ); - } - - @override - bool validate(Node node) => - node.attributes[SmartEditBlockKeys.action] is int && - node.attributes[SmartEditBlockKeys.content] is String; -} - -class SmartEditBlockComponentWidget extends BlockComponentStatefulWidget { - const SmartEditBlockComponentWidget({ - super.key, - required super.node, - super.showActions, - super.actionBuilder, - super.configuration = const BlockComponentConfiguration(), - }); - - @override - State createState() => - _SmartEditBlockComponentWidgetState(); -} - -class _SmartEditBlockComponentWidgetState - extends State { - final popoverController = PopoverController(); - final key = GlobalKey(debugLabel: 'smart_edit_input'); - - late final editorState = context.read(); - - @override - void initState() { - super.initState(); - - // todo: don't use a popover to show the content of the smart edit. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - popoverController.show(); - }); - } - - @override - void reassemble() { - super.reassemble(); - - final transaction = editorState.transaction..deleteNode(widget.node); - editorState.apply(transaction); - } - - @override - Widget build(BuildContext context) { - final width = _getEditorWidth(); - - return AppFlowyPopover( - controller: popoverController, - direction: PopoverDirection.bottomWithLeftAligned, - triggerActions: PopoverTriggerFlags.none, - margin: EdgeInsets.zero, - constraints: BoxConstraints(maxWidth: width), - decoration: FlowyDecoration.decoration( - Colors.transparent, - Colors.transparent, - ), - child: const SizedBox( - width: double.infinity, - ), - canClose: () async { - final completer = Completer(); - final state = key.currentState as _SmartEditInputWidgetState; - if (state.result.isEmpty) { - completer.complete(true); - } else { - await showDialog( - context: context, - builder: (context) { - return DiscardDialog( - onConfirm: () => completer.complete(true), - onCancel: () => completer.complete(false), - ); - }, - ); - } - return completer.future; - }, - onClose: () { - final transaction = editorState.transaction..deleteNode(widget.node); - editorState.apply(transaction); - }, - popupBuilder: (BuildContext popoverContext) { - return SmartEditInputWidget( - key: key, - node: widget.node, - editorState: editorState, - ); - }, - ); - } - - double _getEditorWidth() { - var width = double.infinity; - final editorSize = editorState.renderBox?.size; - final padding = editorState.editorStyle.padding; - if (editorSize != null) { - width = editorSize.width - padding.left - padding.right; - } - return width; - } -} - -class SmartEditInputWidget extends StatefulWidget { - const SmartEditInputWidget({ - required super.key, - required this.node, - required this.editorState, - }); - - final Node node; - final EditorState editorState; - - @override - State createState() => _SmartEditInputWidgetState(); -} - -class _SmartEditInputWidgetState extends State { - final focusNode = FocusNode(); - final client = http.Client(); - - SmartEditAction get action => SmartEditAction.from( - widget.node.attributes[SmartEditBlockKeys.action], - ); - String get content => widget.node.attributes[SmartEditBlockKeys.content]; - EditorState get editorState => widget.editorState; - - bool loading = true; - String result = ''; - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - editorState.service.keyboardService?.disable(); - // editorState.selection = null; - }); - - focusNode.requestFocus(); - _requestCompletions(); - } - - @override - void dispose() { - client.close(); - focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Card( - elevation: 5, - color: Theme.of(context).colorScheme.surface, - child: Container( - margin: const EdgeInsets.all(10), - child: _buildSmartEditPanel(context), - ), - ); - } - - Widget _buildSmartEditPanel(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeaderWidget(context), - const Space(0, 10), - _buildResultWidget(context), - const Space(0, 10), - _buildInputFooterWidget(context), - ], - ); - } - - Widget _buildHeaderWidget(BuildContext context) { - return Row( - children: [ - FlowyText.medium( - '${LocaleKeys.document_plugins_openAI.tr()}: ${action.name}', - fontSize: 14, - ), - const Spacer(), - FlowyButton( - useIntrinsicWidth: true, - text: FlowyText.regular( - LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(), - ), - onTap: () async { - await openLearnMorePage(); - }, - ), - ], - ); - } - - Widget _buildResultWidget(BuildContext context) { - final loadingWidget = Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: SizedBox.fromSize( - size: const Size.square(14), - child: const CircularProgressIndicator(), - ), - ); - if (result.isEmpty || loading) { - return loadingWidget; - } - return Flexible( - child: Text( - result, - ), - ); - } - - Widget _buildInputFooterWidget(BuildContext context) { - return Row( - children: [ - FlowyRichTextButton( - TextSpan( - children: [ - TextSpan( - text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(), - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - onPressed: () => _requestCompletions(rewrite: true), - ), - const Space(10, 0), - FlowyRichTextButton( - TextSpan( - children: [ - TextSpan( - text: LocaleKeys.button_replace.tr(), - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - onPressed: () async { - await _onReplace(); - await _onExit(); - }, - ), - const Space(10, 0), - FlowyRichTextButton( - TextSpan( - children: [ - TextSpan( - text: LocaleKeys.button_insertBelow.tr(), - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - onPressed: () async { - await _onInsertBelow(); - await _onExit(); - }, - ), - const Space(10, 0), - FlowyRichTextButton( - TextSpan( - children: [ - TextSpan( - text: LocaleKeys.button_cancel.tr(), - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - onPressed: () async => _onExit(), - ), - const Spacer(), - Expanded( - child: Container( - alignment: Alignment.centerRight, - child: FlowyText.regular( - LocaleKeys.document_plugins_warning.tr(), - color: Theme.of(context).hintColor, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ); - } - - Future _onReplace() async { - 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; - } - - editorState.selection = Selection( - start: selection.start, - end: Position( - path: [selection.start.path.first + replaceTexts.length - 1], - offset: endOffset, - ), - ); - } - - Future _onInsertBelow() async { - final selection = editorState.selection?.normalized; - if (selection == null) { - return; - } - final insertedText = result.split('\n') - ..removeWhere((element) => element.isEmpty); - final transaction = editorState.transaction; - transaction.insertNodes( - selection.end.path.next, - insertedText.map( - (e) => paragraphNode( - text: e, - ), - ), - ); - transaction.afterSelection = Selection( - start: Position(path: selection.end.path.next), - end: Position( - path: [selection.end.path.next.first + insertedText.length], - ), - ); - await editorState.apply(transaction); - } - - Future _onExit() async { - final transaction = editorState.transaction..deleteNode(widget.node); - return editorState.apply( - transaction, - options: const ApplyOptions( - recordUndo: false, - ), - ); - } - - Future _requestCompletions({bool rewrite = false}) async { - if (rewrite) { - setState(() { - loading = true; - result = ""; - }); - } - final openAIRepository = await getIt.getAsync(); - - var lines = content.split('\n\n'); - if (action == SmartEditAction.summarize) { - lines = [lines.join('\n')]; - } - for (var i = 0; i < lines.length; i++) { - final element = lines[i]; - await openAIRepository.getStreamedCompletions( - useAction: true, - prompt: action.prompt(element), - onStart: () async { - setState(() { - loading = false; - }); - }, - onProcess: (response) async { - setState(() { - if (response.choices.first.text != '\n') { - result += response.choices.first.text; - } - }); - }, - onEnd: () async { - setState(() { - if (i != lines.length - 1) { - result += '\n'; - } - }); - }, - onError: (error) async { - showSnackBarMessage( - context, - error.message, - showCancel: true, - ); - await _onExit(); - }, - ); - } - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart deleted file mode 100644 index a0f7002889..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/user/application/user_service.dart'; -import 'package:appflowy/workspace/presentation/home/toast.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:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flutter/material.dart'; - -final ToolbarItem smartEditItem = ToolbarItem( - id: 'appflowy.editor.smart_edit', - group: 0, - isActive: onlyShowInSingleSelectionAndTextType, - builder: (context, editorState, _, __) => SmartEditActionList( - editorState: editorState, - ), -); - -class SmartEditActionList extends StatefulWidget { - const SmartEditActionList({ - super.key, - required this.editorState, - }); - - final EditorState editorState; - - @override - State createState() => _SmartEditActionListState(); -} - -class _SmartEditActionListState extends State { - bool isOpenAIEnabled = false; - - @override - void initState() { - super.initState(); - - UserBackendService.getCurrentUserProfile().then((value) { - setState(() { - isOpenAIEnabled = value.fold( - (s) => s.openaiKey.isNotEmpty, - (_) => false, - ); - }); - }); - } - - @override - Widget build(BuildContext context) { - return PopoverActionList( - direction: PopoverDirection.bottomWithLeftAligned, - actions: SmartEditAction.values - .map((action) => SmartEditActionWrapper(action)) - .toList(), - onClosed: () => keepEditorFocusNotifier.decrease(), - buildChild: (controller) { - keepEditorFocusNotifier.increase(); - return FlowyIconButton( - hoverColor: Colors.transparent, - tooltipText: isOpenAIEnabled - ? LocaleKeys.document_plugins_smartEdit.tr() - : LocaleKeys.document_plugins_smartEditDisabled.tr(), - preferBelow: false, - icon: const Icon( - Icons.lightbulb_outline, - size: 15, - color: Colors.white, - ), - onPressed: () { - if (isOpenAIEnabled) { - controller.show(); - } else { - showSnackBarMessage( - context, - LocaleKeys.document_plugins_smartEditDisabled.tr(), - showCancel: true, - ); - } - }, - ); - }, - onSelected: (action, controller) { - controller.close(); - _insertSmartEditNode(action); - }, - ); - } - - Future _insertSmartEditNode( - SmartEditActionWrapper actionWrapper, - ) async { - final selection = widget.editorState.selection?.normalized; - if (selection == null) { - return; - } - final input = widget.editorState.getTextInSelection(selection); - while (input.last.isEmpty) { - input.removeLast(); - } - final transaction = widget.editorState.transaction; - transaction.insertNode( - selection.normalized.end.path.next, - smartEditNode( - action: actionWrapper.inner, - content: input.join('\n\n'), - ), - ); - await widget.editorState.apply( - transaction, - options: const ApplyOptions( - recordUndo: false, - ), - withUpdateSelection: false, - ); - } -} 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 index c564a61e27..e3120356d9 100644 --- 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 @@ -2,12 +2,13 @@ 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:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; class OutlineBlockKeys { const OutlineBlockKeys._(); @@ -17,15 +18,6 @@ class OutlineBlockKeys { static const String depth = 'depth'; } -// defining the callout block menu item for selection -SelectionMenuItem outlineItem = SelectionMenuItem.node( - getName: LocaleKeys.document_selectionMenu_outline.tr, - iconData: Icons.list_alt, - keywords: ['outline', 'table of contents'], - nodeBuilder: (editorState, _) => outlineBlockNode(), - replace: (_, node) => node.delta?.isEmpty ?? false, -); - Node outlineBlockNode() { return Node( type: OutlineBlockKeys.type, @@ -38,6 +30,11 @@ enum _OutlineBlockStatus { success; } +final _availableBlockTypes = [ + HeadingBlockKeys.type, + ToggleListBlockKeys.type, +]; + class OutlineBlockComponentBuilder extends BlockComponentBuilder { OutlineBlockComponentBuilder({ super.configuration, @@ -55,11 +52,15 @@ class OutlineBlockComponentBuilder extends BlockComponentBuilder { blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @override - bool validate(Node node) => node.children.isEmpty; + BlockComponentValidate get validate => (node) => node.children.isEmpty; } class OutlineBlockWidget extends BlockComponentStatefulWidget { @@ -68,6 +69,7 @@ class OutlineBlockWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @@ -79,7 +81,9 @@ class _OutlineBlockWidgetState extends State with BlockComponentConfigurable, BlockComponentTextDirectionMixin, - BlockComponentBackgroundColorMixin { + BlockComponentBackgroundColorMixin, + DefaultSelectableMixin, + SelectableMixin { // Change the value if the heading block type supports heading levels greater than '3' static const maxVisibleDepth = 6; @@ -91,8 +95,18 @@ class _OutlineBlockWidgetState extends State @override late EditorState editorState = context.read(); - late Stream<(TransactionTime, Transaction)> stream = - editorState.transactionStream; + late Stream stream = editorState.transactionStream; + + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: OutlineBlockKeys.type, + ); + + @override + GlobalKey> get containerKey => widget.node.key; + + @override + GlobalKey> get forwardKey => widget.node.key; @override Widget build(BuildContext context) { @@ -101,19 +115,36 @@ class _OutlineBlockWidgetState extends State builder: (context, snapshot) { Widget child = _buildOutlineBlock(); - if (PlatformExtension.isDesktopOrWeb) { + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + remoteSelection: editorState.remoteSelections, + blockColor: editorState.editorStyle.selectionColor, + selectionAboveBlock: true, + supportTypes: const [ + BlockSelectionType.block, + ], + child: child, + ); + + if (UniversalPlatform.isDesktopOrWeb) { if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: widget.node, actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } } else { - child = MobileBlockActionButtons( - node: node, - editorState: editorState, - child: child, + child = Padding( + padding: padding, + child: MobileBlockActionButtons( + node: node, + editorState: editorState, + child: child, + ), ); } @@ -171,10 +202,11 @@ class _OutlineBlockWidgetState extends State } return Container( + key: blockComponentKey, constraints: const BoxConstraints( minHeight: 40.0, ), - padding: padding, + padding: UniversalPlatform.isMobile ? EdgeInsets.zero : padding, child: Container( padding: const EdgeInsets.symmetric( vertical: 2.0, @@ -202,22 +234,43 @@ class _OutlineBlockWidgetState extends State } (_OutlineBlockStatus, Iterable) getHeadingNodes() { - final children = editorState.document.root.children; - final int level = - node.attributes[OutlineBlockKeys.depth] ?? maxVisibleDepth; - var headings = children.where( - (e) => e.type == HeadingBlockKeys.type && e.delta?.isNotEmpty == true, + 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.attributes[HeadingBlockKeys.level] <= level); + 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 { @@ -226,7 +279,7 @@ class OutlineItemWidget extends StatelessWidget { required this.node, required this.textDirection, }) { - assert(node.type == HeadingBlockKeys.type); + assert(_availableBlockTypes.contains(node.type)); } final Node node; @@ -237,31 +290,22 @@ class OutlineItemWidget extends StatelessWidget { final editorState = context.read(); final textStyle = editorState.editorStyle.textStyleConfiguration; final style = textStyle.href.combine(textStyle.text); - return FlowyHover( - style: HoverStyle( - hoverColor: Theme.of(context).hoverColor, - ), - builder: (context, onHover) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => scrollToBlock(context), - child: Row( - textDirection: textDirection, - children: [ - HSpace(node.leftIndent), - Text( - node.outlineItemText, - textDirection: textDirection, - style: style.copyWith( - color: onHover - ? Theme.of(context).colorScheme.onSecondary - : null, - ), - ), - ], + return FlowyButton( + onTap: () => scrollToBlock(context), + text: Row( + textDirection: textDirection, + children: [ + HSpace(node.leftIndent), + Flexible( + child: Text( + node.outlineItemText, + textDirection: textDirection, + style: style, + overflow: TextOverflow.ellipsis, + ), ), - ); - }, + ], + ), ); } @@ -280,13 +324,20 @@ class OutlineItemWidget extends StatelessWidget { extension on Node { double get leftIndent { - assert(type == HeadingBlockKeys.type); - if (type != HeadingBlockKeys.type) { + assert(_availableBlockTypes.contains(type)); + + if (!_availableBlockTypes.contains(type)) { return 0.0; } - final level = attributes[HeadingBlockKeys.level]; - final indent = (level - 1) * 15.0 + 10.0; - return indent; + + 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 { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_block/custom_page_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_block/custom_page_block_component.dart new file mode 100644 index 0000000000..731ba4c7cd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_block/custom_page_block_component.dart @@ -0,0 +1,117 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/block_component/base_component/widget/ignore_parent_gesture.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class CustomPageBlockComponentBuilder extends BlockComponentBuilder { + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + return CustomPageBlockComponent( + key: blockComponentContext.node.key, + node: blockComponentContext.node, + header: blockComponentContext.header, + footer: blockComponentContext.footer, + ); + } +} + +class CustomPageBlockComponent extends BlockComponentStatelessWidget { + const CustomPageBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + this.header, + this.footer, + }); + + final Widget? header; + final Widget? footer; + + @override + Widget build(BuildContext context) { + final editorState = context.read(); + final scrollController = context.read(); + final items = node.children; + + if (scrollController == null || scrollController.shrinkWrap) { + return SingleChildScrollView( + child: Builder( + builder: (context) { + final scroller = Scrollable.maybeOf(context); + if (scroller != null) { + editorState.updateAutoScroller(scroller); + } + return Column( + children: [ + if (header != null) header!, + ...items.map( + (e) => Container( + constraints: BoxConstraints( + maxWidth: + editorState.editorStyle.maxWidth ?? double.infinity, + ), + padding: editorState.editorStyle.padding, + child: editorState.renderer.build(context, e), + ), + ), + if (footer != null) footer!, + ], + ); + }, + ), + ); + } else { + int extentCount = 0; + if (header != null) extentCount++; + if (footer != null) extentCount++; + + return ScrollablePositionedList.builder( + shrinkWrap: scrollController.shrinkWrap, + itemCount: items.length + extentCount, + itemBuilder: (context, index) { + editorState.updateAutoScroller(Scrollable.of(context)); + if (header != null && index == 0) { + return IgnoreEditorSelectionGesture( + child: header!, + ); + } + + if (footer != null && index == (items.length - 1) + extentCount) { + return IgnoreEditorSelectionGesture( + child: footer!, + ); + } + + final childNode = items[index - (header != null ? 1 : 0)]; + final isOverflowType = overflowTypes.contains(childNode.type); + + final item = Container( + constraints: BoxConstraints( + maxWidth: editorState.editorStyle.maxWidth ?? double.infinity, + ), + padding: isOverflowType + ? EdgeInsets.zero + : editorState.editorStyle.padding, + child: editorState.renderer.build( + context, + childNode, + ), + ); + + return isOverflowType ? item : Center(child: item); + }, + itemScrollController: scrollController.itemScrollController, + scrollOffsetController: scrollController.scrollOffsetController, + itemPositionsListener: scrollController.itemPositionsListener, + scrollOffsetListener: scrollController.scrollOffsetListener, + ); + } + } +} 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 index 1d6b100534..45a23bc6ac 100644 --- 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 @@ -1,31 +1,41 @@ 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/material.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 @@ -33,13 +43,11 @@ class PageStyleCoverImage extends StatelessWidget { final backgroundColor = context.pageStyleBackgroundColor; return BlocBuilder( builder: (context, state) { - return Row( + return Column( children: [ - _buildOptionGroup( - context, - backgroundColor, - state, - ), + _buildOptionGroup(context, backgroundColor, state), + const VSpace(16.0), + _buildPreview(context, state), ], ); }, @@ -51,46 +59,124 @@ class PageStyleCoverImage extends StatelessWidget { Color backgroundColor, DocumentPageStyleState state, ) { - return Expanded( - child: Container( - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: const BorderRadius.horizontal( - left: Radius.circular(12), - right: Radius.circular(12), + 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(), ), - ), - 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(), - ), - ], - ), + _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, @@ -99,18 +185,17 @@ class PageStyleCoverImage extends StatelessWidget { showHeader: true, showRemoveButton: true, onRemove: () { - context.read().add( - DocumentPageStyleEvent.updateCoverImage( - PageStyleCover.none(), - ), - ); + pageStyleBloc.add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover.none(), + ), + ); }, - title: LocaleKeys.pageStyle_pageCover.tr(), - barrierColor: Colors.transparent, - backgroundColor: Theme.of(context).colorScheme.background, + title: LocaleKeys.pageStyle_presets.tr(), + backgroundColor: AFThemeExtension.of(context).background, builder: (_) { return BlocProvider.value( - value: context.read(), + value: pageStyleBloc, child: const PageCoverBottomSheet(), ); }, @@ -141,39 +226,41 @@ class PageStyleCoverImage extends StatelessWidget { (f) => null, ); final isAppFlowyCloud = - userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud; + userProfile?.workspaceAuthType == AuthTypePB.Server; 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); + (result, _) = await saveImageToCloudStorage(path, documentId); type = PageStyleCoverImageType.customImage; } if (!context.mounted) { return; } if (result == null) { - showSnapBar( + return showSnapBar( context, - LocaleKeys.document_plugins_image_imageUploadFailed, + LocaleKeys.document_plugins_image_imageUploadFailed.tr(), ); - return; } context.read().add( DocumentPageStyleEvent.updateCoverImage( - PageStyleCover( - type: type, - value: result, - ), + 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, @@ -181,37 +268,33 @@ class PageStyleCoverImage extends StatelessWidget { showDoneButton: true, showHeader: true, showRemoveButton: true, - title: LocaleKeys.pageStyle_coverImage.tr(), - barrierColor: Colors.transparent, - backgroundColor: Theme.of(context).colorScheme.background, + title: LocaleKeys.pageStyle_unsplash.tr(), + backgroundColor: backgroundColor, onRemove: () { - context.read().add( - DocumentPageStyleEvent.updateCoverImage( - PageStyleCover.none(), - ), - ); + pageStyleBloc.add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover.none(), + ), + ); }, builder: (_) { return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.6, - minHeight: 80, - ), + constraints: BoxConstraints(maxHeight: maxHeight, minHeight: 80), child: BlocProvider.value( - value: context.read(), + value: pageStyleBloc, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: UnsplashImageWidget( type: UnsplashImageType.fullScreen, onSelectUnsplashImage: (url) { - context.read().add( - DocumentPageStyleEvent.updateCoverImage( - PageStyleCover( - type: PageStyleCoverImageType.unsplashImage, - value: url, - ), - ), - ); + pageStyleBloc.add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover( + type: PageStyleCoverImageType.unsplashImage, + value: url, + ), + ), + ); }, ), ), @@ -308,6 +391,7 @@ class _CoverOptionButton extends StatelessWidget { duration: Durations.medium1, decoration: selected ? ShapeDecoration( + color: const Color(0x141AC3F2), shape: RoundedRectangleBorder( side: const BorderSide( width: 1.50, 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 index 5dc49fa8ff..a71b083c81 100644 --- 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 @@ -1,35 +1,44 @@ 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_search_text_field.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_picker.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/shared/icon_emoji_picker/tab.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 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; -class PageStyleIcon extends StatelessWidget { +import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; + +class PageStyleIcon extends StatefulWidget { const PageStyleIcon({ super.key, required this.view, + required this.tabs, }); final ViewPB view; + final List tabs; + @override + State createState() => _PageStyleIconState(); +} + +class _PageStyleIconState extends State { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => PageStyleIconBloc(view: view) + create: (_) => PageStyleIconBloc(view: widget.view) ..add(const PageStyleIconEvent.initial()), child: BlocBuilder( builder: (context, state) { - final icon = state.icon ?? ''; + final icon = state.icon; return GestureDetector( - onTap: () => _showIconSelector(context, icon), + onTap: () => icon == null ? null : _showIconSelector(context, icon), behavior: HitTestBehavior.opaque, child: Container( height: 52, @@ -42,11 +51,15 @@ class PageStyleIcon extends StatelessWidget { const HSpace(16.0), FlowyText(LocaleKeys.document_plugins_emoji.tr()), const Spacer(), - FlowyText( - icon.isNotEmpty ? icon : LocaleKeys.pageStyle_none.tr(), - color: icon.isEmpty ? context.pageStyleTextColor : null, - fontSize: icon.isNotEmpty ? 22.0 : 16.0, - ), + (icon?.isEmpty ?? true) + ? FlowyText( + LocaleKeys.pageStyle_none.tr(), + fontSize: 16.0, + ) + : RawEmojiIconWidget( + emoji: icon!, + emojiSize: 16.0, + ), const HSpace(6.0), const FlowySvg(FlowySvgs.m_page_style_arrow_right_s), const HSpace(12.0), @@ -59,35 +72,34 @@ class PageStyleIcon extends StatelessWidget { ); } - void _showIconSelector(BuildContext context, String selectedIcon) { + void _showIconSelector(BuildContext context, EmojiIconData icon) { + Navigator.pop(context); + final pageStyleIconBloc = PageStyleIconBloc(view: widget.view) + ..add(const PageStyleIconEvent.initial()); showMobileBottomSheet( context, showDragHandle: true, showDivider: false, - showDoneButton: true, showHeader: true, title: LocaleKeys.titleBar_pageIcon.tr(), - barrierColor: Colors.transparent, - backgroundColor: Theme.of(context).colorScheme.background, - isScrollControlled: true, + backgroundColor: AFThemeExtension.of(context).background, enableDraggableScrollable: true, minChildSize: 0.6, initialChildSize: 0.61, - showRemoveButton: true, - onRemove: () { - context.read().add( - const PageStyleIconEvent.updateIcon('', true), - ); - }, - scrollableWidgetBuilder: (_, controller) { + scrollableWidgetBuilder: (ctx, controller) { return BlocProvider.value( - value: context.read(), + value: pageStyleIconBloc, child: Expanded( - child: Scrollbar( - controller: controller, - child: _IconSelector( - scrollController: controller, - ), + child: FlowyIconEmojiPicker( + initialType: icon.type.toPickerTabType(), + documentId: widget.view.id, + tabs: widget.tabs, + onSelectedEmoji: (r) { + pageStyleIconBloc.add( + PageStyleIconEvent.updateIcon(r.data, true), + ); + if (!r.keepOpen) Navigator.pop(ctx); + }, ), ), ); @@ -96,143 +108,3 @@ class PageStyleIcon extends StatelessWidget { ); } } - -class _IconSelector extends StatefulWidget { - const _IconSelector({ - required this.scrollController, - }); - - final ScrollController scrollController; - - @override - State<_IconSelector> createState() => _IconSelectorState(); -} - -class _IconSelectorState extends State<_IconSelector> { - EmojiData? emojiData; - List availableEmojis = []; - - @override - void initState() { - super.initState(); - - // load the emoji data from cache if it's available - if (kCachedEmojiData != null) { - emojiData = kCachedEmojiData; - availableEmojis = _setupAvailableEmojis(emojiData!); - } else { - EmojiData.builtIn().then( - (value) { - kCachedEmojiData = value; - setState(() { - emojiData = value; - availableEmojis = _setupAvailableEmojis(value); - }); - }, - ); - } - } - - @override - Widget build(BuildContext context) { - if (emojiData == null) { - return const Center(child: CircularProgressIndicator()); - } - - return RepaintBoundary( - child: BlocBuilder( - builder: (_, state) => Column( - children: [ - _buildSearchBar(context), - Expanded( - child: GridView.count( - crossAxisCount: _getEmojiPerLine(context), - controller: widget.scrollController, - children: [ - for (final emoji in availableEmojis) - _buildEmoji(context, emoji, state.icon), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildEmoji( - BuildContext context, - String emoji, - String? selectedEmoji, - ) { - Widget child = Center( - child: FlowyText.emoji( - emoji, - fontSize: 24, - ), - ); - - if (emoji == selectedEmoji) { - child = Container( - margin: const EdgeInsets.all(8.0), - decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - side: const BorderSide( - width: 1.50, - strokeAlign: BorderSide.strokeAlignOutside, - color: Color(0xFF00BCF0), - ), - borderRadius: BorderRadius.circular(9), - ), - ), - child: child, - ); - } - - return GestureDetector( - onTap: () { - context.read().add( - PageStyleIconEvent.updateIcon(emoji, true), - ); - }, - child: child, - ); - } - - List _setupAvailableEmojis(EmojiData emojiData) { - final categories = emojiData.categories; - availableEmojis = categories - .map((e) => e.emojiIds.map((e) => emojiData.getEmojiById(e))) - .expand((e) => e) - .toList(); - return availableEmojis; - } - - int _getEmojiPerLine(BuildContext context) { - final width = MediaQuery.of(context).size.width; - return width ~/ 48.0; // the size of the emoji - } - - Widget _buildSearchBar(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 12.0, - ), - child: FlowyMobileSearchTextField( - onChanged: (keyword) { - if (emojiData == null) { - return; - } - - final filtered = emojiData!.filterByKeyword(keyword); - final availableEmojis = _setupAvailableEmojis(filtered); - - setState(() { - this.availableEmojis = availableEmojis; - }); - }, - ), - ); - } -} 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 index 4d8b0ebf81..b2cd77f312 100644 --- 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 @@ -1,3 +1,4 @@ +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'; @@ -17,7 +18,7 @@ class PageStyleIconBloc extends Bloc { initial: () async { add( PageStyleIconEvent.updateIcon( - view.icon.value, + view.icon.toEmojiIconData(), false, ), ); @@ -25,7 +26,7 @@ class PageStyleIconBloc extends Bloc { onViewUpdated: (view) { add( PageStyleIconEvent.updateIcon( - view.icon.value, + view.icon.toEmojiIconData(), false, ), ); @@ -33,14 +34,10 @@ class PageStyleIconBloc extends Bloc { ); }, updateIcon: (icon, shouldUpdateRemote) async { - emit( - state.copyWith( - icon: icon, - ), - ); + emit(state.copyWith(icon: icon)); if (shouldUpdateRemote && icon != null) { await ViewBackendService.updateViewIcon( - viewId: view.id, + view: view, viewIcon: icon, ); } @@ -63,8 +60,9 @@ class PageStyleIconBloc extends Bloc { @freezed class PageStyleIconEvent with _$PageStyleIconEvent { const factory PageStyleIconEvent.initial() = Initial; + const factory PageStyleIconEvent.updateIcon( - String? icon, + EmojiIconData? icon, bool shouldUpdateRemote, ) = UpdateIconInner; } @@ -72,7 +70,7 @@ class PageStyleIconEvent with _$PageStyleIconEvent { @freezed class PageStyleIconState with _$PageStyleIconState { const factory PageStyleIconState({ - @Default(null) String? icon, + @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 index bbe0fda27c..211e287d15 100644 --- 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 @@ -8,9 +8,11 @@ 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; @@ -117,6 +119,7 @@ class _OptionGroup extends StatelessWidget { duration: Durations.medium1, decoration: selected ? ShapeDecoration( + color: const Color(0x141AC3F2), shape: RoundedRectangleBorder( side: const BorderSide( width: 1.50, @@ -180,7 +183,10 @@ class _FontButton extends StatelessWidget { const HSpace(16.0), FlowyText(LocaleKeys.titleBar_font.tr()), const Spacer(), - FlowyText(fontFamilyDisplayName), + FlowyText( + fontFamilyDisplayName, + color: context.pageStyleTextColor, + ), const HSpace(6.0), const FlowySvg(FlowySvgs.m_page_style_arrow_right_s), const HSpace(12.0), @@ -193,6 +199,9 @@ class _FontButton extends StatelessWidget { } void _showFontSelector(BuildContext context) { + final pageStyleBloc = context.read(); + context.pop(); + showMobileBottomSheet( context, showDragHandle: true, @@ -200,15 +209,13 @@ class _FontButton extends StatelessWidget { showDoneButton: true, showHeader: true, title: LocaleKeys.titleBar_font.tr(), - barrierColor: Colors.transparent, - backgroundColor: Theme.of(context).colorScheme.background, - isScrollControlled: true, + backgroundColor: AFThemeExtension.of(context).background, enableDraggableScrollable: true, minChildSize: 0.6, initialChildSize: 0.61, scrollableWidgetBuilder: (_, controller) { return BlocProvider.value( - value: context.read(), + value: pageStyleBloc, child: BlocBuilder( builder: (context, state) { return Expanded( @@ -219,11 +226,11 @@ class _FontButton extends StatelessWidget { selectedFontFamilyName: state.fontFamily ?? defaultFontFamily, onFontFamilySelected: (fontFamilyName) { - context.read().add( - DocumentPageStyleEvent.updateFontFamily( - fontFamilyName, - ), - ); + pageStyleBloc.add( + DocumentPageStyleEvent.updateFontFamily( + fontFamilyName, + ), + ); }, ), ), 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 index 18238ffc79..013a056a7c 100644 --- 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 @@ -3,6 +3,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style 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/shared/icon_emoji_picker/tab.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'; @@ -12,9 +13,11 @@ class PageStyleBottomSheet extends StatelessWidget { const PageStyleBottomSheet({ super.key, required this.view, + required this.tabs, }); final ViewPB view; + final List tabs; @override Widget build(BuildContext context) { @@ -25,12 +28,12 @@ class PageStyleBottomSheet extends StatelessWidget { children: [ // cover image FlowyText( - LocaleKeys.pageStyle_backgroundImage.tr(), + LocaleKeys.pageStyle_coverImage.tr(), color: context.pageStyleTextColor, fontSize: 14.0, ), const VSpace(8.0), - PageStyleCoverImage(), + PageStyleCoverImage(documentId: view.id), const VSpace(20.0), // layout: font size, line height and font family. FlowyText( @@ -50,6 +53,7 @@ class PageStyleBottomSheet extends StatelessWidget { const VSpace(8.0), PageStyleIcon( view: view, + tabs: tabs, ), ], ), 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 index 867fcf236f..d9cf060e3b 100644 --- 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 @@ -1,4 +1,5 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; class CalloutNodeParser extends NodeParser { @@ -9,8 +10,6 @@ class CalloutNodeParser extends NodeParser { @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() @@ -18,9 +17,15 @@ class CalloutNodeParser extends NodeParser { .split('\n') .map((e) => '> $e') .join('\n'); + final type = node.attributes[CalloutBlockKeys.iconType]; + final icon = type == FlowyIconType.emoji.name || type == null || type == "" + ? node.attributes[CalloutBlockKeys.icon] + : null; + + final content = icon == null ? markdown : "> $icon\n$markdown"; + return ''' -> $icon -$markdown +$content '''; } 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 index a33d99fd82..d4b6bb444f 100644 --- 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 @@ -1,4 +1,11 @@ +import 'dart:io'; + +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:archive/archive.dart'; +import 'package:path/path.dart' as p; + +import '../image/custom_image_block_component/custom_image_block_component.dart'; class CustomImageNodeParser extends NodeParser { const CustomImageNodeParser(); @@ -9,8 +16,69 @@ class CustomImageNodeParser extends NodeParser { @override String transform(Node node, DocumentMarkdownEncoder? encoder) { assert(node.children.isEmpty); - final url = node.attributes[ImageBlockKeys.url]; + final url = node.attributes[CustomImageBlockKeys.url]; assert(url != null); return '![]($url)\n'; } } + +class CustomImageNodeFileParser extends NodeParser { + const CustomImageNodeFileParser(this.files, this.dirPath); + + final List> files; + final String dirPath; + + @override + String get id => ImageBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + assert(node.children.isEmpty); + final url = node.attributes[CustomImageBlockKeys.url]; + final hasFile = File(url).existsSync(); + if (hasFile) { + final bytes = File(url).readAsBytesSync(); + files.add( + Future.value( + ArchiveFile(p.join(dirPath, p.basename(url)), bytes.length, bytes), + ), + ); + return '![](${p.join(dirPath, p.basename(url))})\n'; + } + assert(url != null); + return '![]($url)\n'; + } +} + +class CustomMultiImageNodeFileParser extends NodeParser { + const CustomMultiImageNodeFileParser(this.files, this.dirPath); + + final List> files; + final String dirPath; + + @override + String get id => MultiImageBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + assert(node.children.isEmpty); + final images = node.attributes[MultiImageBlockKeys.images] as List; + final List markdownImages = []; + for (final image in images) { + final String url = image['url'] ?? ''; + if (url.isEmpty) continue; + final hasFile = File(url).existsSync(); + if (hasFile) { + final bytes = File(url).readAsBytesSync(); + final filePath = p.join(dirPath, p.basename(url)); + files.add( + Future.value(ArchiveFile(filePath, bytes.length, bytes)), + ); + markdownImages.add('![]($filePath)'); + } else { + markdownImages.add('![]($url)'); + } + } + return markdownImages.join('\n'); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_paragraph_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_paragraph_node_parser.dart new file mode 100644 index 0000000000..b7d7674137 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_paragraph_node_parser.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class CustomParagraphNodeParser extends NodeParser { + const CustomParagraphNodeParser(); + + @override + String get id => ParagraphBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + final delta = node.delta; + if (delta != null) { + for (final o in delta) { + final attribute = o.attributes ?? {}; + final Map? mention = attribute[MentionBlockKeys.mention] ?? {}; + if (mention == null) continue; + + /// filter date reminder node, and return it + final String date = mention[MentionBlockKeys.date] ?? ''; + if (date.isNotEmpty) { + final dateTime = DateTime.tryParse(date); + if (dateTime == null) continue; + return '${DateFormat.yMMMd().format(dateTime)}\n'; + } + + /// filter reference page + final String pageId = mention[MentionBlockKeys.pageId] ?? ''; + if (pageId.isNotEmpty) { + return '[]($pageId)\n'; + } + } + } + return const TextNodeParser().transform(node, encoder); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/database_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/database_node_parser.dart new file mode 100644 index 0000000000..3ba599d491 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/database_node_parser.dart @@ -0,0 +1,53 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart'; +import 'package:appflowy/workspace/application/settings/share/export_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:archive/archive.dart'; +import 'package:path/path.dart' as p; + +abstract class DatabaseNodeParser extends NodeParser { + DatabaseNodeParser(this.files, this.dirPath); + + final List> files; + final String dirPath; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + final String viewId = node.attributes[DatabaseBlockKeys.viewID] ?? ''; + if (viewId.isEmpty) return ''; + files.add(_convertDatabaseToCSV(viewId)); + return '[](${p.join(dirPath, '$viewId.csv')})\n'; + } + + Future _convertDatabaseToCSV(String viewId) async { + final result = await BackendExportService.exportDatabaseAsCSV(viewId); + final filePath = p.join(dirPath, '$viewId.csv'); + ArchiveFile file = ArchiveFile.string(filePath, ''); + result.fold( + (s) => file = ArchiveFile.string(filePath, s.data), + (f) => Log.error('convertDatabaseToCSV error with $viewId, error: $f'), + ); + return file; + } +} + +class GridNodeParser extends DatabaseNodeParser { + GridNodeParser(super.files, super.dirPath); + + @override + String get id => DatabaseBlockKeys.gridType; +} + +class BoardNodeParser extends DatabaseNodeParser { + BoardNodeParser(super.files, super.dirPath); + + @override + String get id => DatabaseBlockKeys.boardType; +} + +class CalendarNodeParser extends DatabaseNodeParser { + CalendarNodeParser(super.files, super.dirPath); + + @override + String get id => DatabaseBlockKeys.calendarType; +} 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 index 0b694f396e..c0a15629b8 100644 --- 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 @@ -1,4 +1,9 @@ export 'callout_node_parser.dart'; export 'custom_image_node_parser.dart'; +export 'custom_paragraph_node_parser.dart'; +export 'database_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 0000000000..e57ededcec --- /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 0000000000..c7ce69d221 --- /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 0000000000..d756c25d6b --- /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 index 3e4fc3a764..4ad7734643 100644 --- 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 @@ -1,3 +1,2 @@ -export 'callout_node_parser.dart'; -export 'math_equation_node_parser.dart'; -export 'toggle_list_node_parser.dart'; +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 0000000000..09973021f1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_simple_table_parser.dart @@ -0,0 +1,116 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:universal_platform/universal_platform.dart'; + +class MarkdownSimpleTableParser extends CustomMarkdownParser { + const MarkdownSimpleTableParser({ + this.tableWidth, + }); + + final double? tableWidth; + + @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, + enableHeaderRow: true, + columnWidths: UniversalPlatform.isMobile || tableWidth == null + ? null + : {for (var i = 0; i < th.length; i++) i.toString(): tableWidth!}, + ), + ]; + } +} 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 0000000000..b01e797595 --- /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/sub_page_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart new file mode 100644 index 0000000000..1cf0c569bc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart @@ -0,0 +1,20 @@ +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_editor/appflowy_editor.dart'; + +class SubPageNodeParser extends NodeParser { + const SubPageNodeParser(); + + @override + String get id => SubPageBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + final String viewId = node.attributes[SubPageBlockKeys.viewId] ?? ''; + if (viewId.isNotEmpty) { + final view = pageMemorizer[viewId]; + return '[$viewId](${view?.name ?? ''})\n'; + } + return ''; + } +} 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 index 8afef3ec0f..4161036a08 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -1,10 +1,19 @@ export 'actions/block_action_list.dart'; -export 'actions/option_action.dart'; +export 'actions/option/option_actions.dart'; +export 'ai/ai_writer_block_component.dart'; +export 'ai/ai_writer_toolbar_item.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 'columns/simple_column_block_component.dart'; +export 'columns/simple_column_block_width_resizer.dart'; +export 'columns/simple_column_node_extension.dart'; +export 'columns/simple_columns_block_component.dart'; export 'context_menu/custom_context_menu.dart'; export 'copy_and_paste/custom_copy_command.dart'; export 'copy_and_paste/custom_cut_command.dart'; @@ -14,27 +23,34 @@ 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_header_node_widget.dart'; -export 'image/image_menu.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/biusc_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'; @@ -42,13 +58,21 @@ 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/auto_completion_node_widget.dart'; -export 'openai/widgets/smart_edit_node_widget.dart'; -export 'openai/widgets/smart_edit_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_component.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_shortcut_event.dart'; +export 'toggle/toggle_block_shortcuts.dart'; +export 'video/video_block_component.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_component.dart new file mode 100644 index 0000000000..39ab2c5327 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_component.dart @@ -0,0 +1,324 @@ +import 'dart:async'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +/// In memory cache of the quote block height to avoid flashing when the quote block is updated. +Map _quoteBlockHeightCache = {}; + +typedef QuoteBlockIconBuilder = Widget Function( + BuildContext context, + Node node, +); + +class QuoteBlockKeys { + const QuoteBlockKeys._(); + + static const String type = 'quote'; + + static const String delta = blockComponentDelta; + + static const String backgroundColor = blockComponentBackgroundColor; + + static const String textDirection = blockComponentTextDirection; +} + +Node quoteNode({ + Delta? delta, + String? textDirection, + Attributes? attributes, + Iterable? children, +}) { + attributes ??= {'delta': (delta ?? Delta()).toJson()}; + return Node( + type: QuoteBlockKeys.type, + attributes: { + ...attributes, + if (textDirection != null) QuoteBlockKeys.textDirection: textDirection, + }, + children: children ?? [], + ); +} + +class QuoteBlockComponentBuilder extends BlockComponentBuilder { + QuoteBlockComponentBuilder({ + super.configuration, + this.iconBuilder, + }); + + final QuoteBlockIconBuilder? iconBuilder; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return QuoteBlockComponentWidget( + key: node.key, + node: node, + configuration: configuration, + iconBuilder: iconBuilder, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => node.delta != null; +} + +class QuoteBlockComponentWidget extends BlockComponentStatefulWidget { + const QuoteBlockComponentWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + this.iconBuilder, + }); + + final QuoteBlockIconBuilder? iconBuilder; + + @override + State createState() => + _QuoteBlockComponentWidgetState(); +} + +class _QuoteBlockComponentWidgetState extends State + with + SelectableMixin, + DefaultSelectableMixin, + BlockComponentConfigurable, + BlockComponentBackgroundColorMixin, + BlockComponentTextDirectionMixin, + BlockComponentAlignMixin, + NestedBlockComponentStatefulWidgetMixin { + @override + final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text'); + + @override + GlobalKey> get containerKey => widget.node.key; + + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: QuoteBlockKeys.type, + ); + + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + late ValueNotifier quoteBlockHeightNotifier = ValueNotifier( + _quoteBlockHeightCache[node.id] ?? 0, + ); + + StreamSubscription? _transactionSubscription; + + final GlobalKey layoutBuilderKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + _updateQuoteBlockHeight(); + } + + @override + void dispose() { + _transactionSubscription?.cancel(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + key: layoutBuilderKey, + onNotification: (notification) { + _updateQuoteBlockHeight(); + return true; + }, + child: SizeChangedLayoutNotifier( + child: node.children.isEmpty + ? buildComponent(context) + : buildComponentWithChildren(context), + ), + ); + } + + @override + Widget buildComponentWithChildren(BuildContext context) { + final Widget child = Stack( + children: [ + Positioned.fill( + left: UniversalPlatform.isMobile ? padding.left : cachedLeft, + right: UniversalPlatform.isMobile ? padding.right : 0, + child: Container( + color: backgroundColor, + ), + ), + NestedListWidget( + indentPadding: indentPadding, + child: buildComponent(context, withBackgroundColor: false), + children: editorState.renderer.buildList( + context, + widget.node.children, + ), + ), + ], + ); + + return child; + } + + @override + Widget buildComponent( + BuildContext context, { + bool withBackgroundColor = true, + }) { + final textDirection = calculateTextDirection( + layoutDirection: Directionality.maybeOf(context), + ); + + Widget child = AppFlowyRichText( + key: forwardKey, + delegate: this, + node: widget.node, + editorState: editorState, + textAlign: alignment?.toTextAlign ?? textAlign, + placeholderText: placeholderText, + textSpanDecorator: (textSpan) => textSpan.updateTextStyle( + textStyleWithTextSpan(textSpan: textSpan), + ), + placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle( + placeholderTextStyleWithTextSpan(textSpan: textSpan), + ), + textDirection: textDirection, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, + cursorWidth: editorState.editorStyle.cursorWidth, + ); + + child = Container( + width: double.infinity, + alignment: alignment, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + textDirection: textDirection, + children: [ + widget.iconBuilder != null + ? widget.iconBuilder!(context, node) + : ValueListenableBuilder( + valueListenable: quoteBlockHeightNotifier, + builder: (context, height, child) { + return QuoteIcon(height: height); + }, + ), + Flexible( + child: child, + ), + ], + ), + ), + ); + + child = Container( + color: withBackgroundColor ? backgroundColor : null, + child: Padding( + key: blockComponentKey, + padding: padding, + child: child, + ), + ); + + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + remoteSelection: editorState.remoteSelections, + blockColor: editorState.editorStyle.selectionColor, + selectionAboveBlock: true, + supportTypes: const [ + BlockSelectionType.block, + ], + child: child, + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + actionTrailingBuilder: widget.actionTrailingBuilder, + child: child, + ); + } + + return child; + } + + void _updateQuoteBlockHeight() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final renderObject = layoutBuilderKey.currentContext?.findRenderObject(); + double height = _quoteBlockHeightCache[node.id] ?? 0; + if (renderObject != null && renderObject is RenderBox) { + if (UniversalPlatform.isMobile) { + height = renderObject.size.height - padding.top; + } else { + height = renderObject.size.height - padding.top * 2; + } + } else { + height = 0; + } + + quoteBlockHeightNotifier.value = height; + _quoteBlockHeightCache[node.id] = height; + }); + } +} + +class QuoteIcon extends StatelessWidget { + const QuoteIcon({ + super.key, + this.height = 0, + }); + + final double height; + + @override + Widget build(BuildContext context) { + final textScaleFactor = + context.read().editorStyle.textScaleFactor; + return Container( + alignment: Alignment.center, + constraints: + const BoxConstraints(minWidth: 22, minHeight: 22, maxHeight: 22) * + textScaleFactor, + padding: const EdgeInsets.only(right: 6.0), + child: SizedBox( + width: 3 * textScaleFactor, + // use overflow box to ensure the container can overflow the height so that the children of the quote block can have the quote + child: OverflowBox( + alignment: Alignment.topCenter, + maxHeight: height, + child: Container( + width: 3 * textScaleFactor, + height: height, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); + } +} 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 0000000000..47c6549923 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart @@ -0,0 +1,54 @@ +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) { + // ignore the shift+enter event, fallback to the default behavior + return false; + } else if (node.children.isEmpty && + selection.endIndex == node.delta?.length) { + // insert a new paragraph within the callout block + final path = node.path.child(0); + final transaction = editorState.transaction; + transaction.insertNode( + path, + paragraphNode(), + ); + transaction.afterSelection = Selection.collapsed( + Position( + path: path, + ), + ); + await editorState.apply(transaction); + return true; + } + + return false; +}; 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 0000000000..40d4c54163 --- /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 0000000000..13b2fea5ee --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart @@ -0,0 +1,91 @@ +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/emoji/emoji_actions_command.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, + customFormatDashGreater, + customFormatDoubleHyphenEmDash, + + customFormatNumberToNumberedList, + customFormatSignToHeading, + + ...standardCharacterShortcutEvents + ..removeWhere( + (shortcut) => [ + slashCommand, // Remove default slash command + formatGreaterEqual, // Overridden by customFormatGreaterEqual + formatNumberToNumberedList, // Overridden by customFormatNumberToNumberedList + formatSignToHeading, // Overridden by customFormatSignToHeading + formatDoubleHyphenEmDash, // Overridden by customFormatDoubleHyphenEmDash + ].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(), + ), + + /// show emoji list + /// - Using `:` + emojiCommand(context), + ]; +} 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 0000000000..aedfcff432 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart @@ -0,0 +1,82 @@ +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/math_equation/math_equation_shortcut.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/custom_delete_command.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, + + customDeleteCommand, + insertInlineMathEquationCommand, + + // remove standard shortcuts for copy, cut, paste, todo + ...standardCommandShortcutEvents + ..removeWhere( + (shortcut) => [ + copyCommand, + cutCommand, + pasteCommand, + pasteTextWithoutFormattingCommand, + toggleTodoListCommand, + undoCommand, + redoCommand, + exitEditingCommand, + ...tableCommands, + deleteCommand, + ].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/custom_delete_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/custom_delete_command.dart new file mode 100644 index 0000000000..e0663c2bcf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/custom_delete_command.dart @@ -0,0 +1,115 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +/// Delete key event. +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent customDeleteCommand = CommandShortcutEvent( + key: 'Delete Key', + getDescription: () => AppFlowyEditorL10n.current.cmdDeleteRight, + command: 'delete, shift+delete', + handler: _deleteCommandHandler, +); + +CommandShortcutEventHandler _deleteCommandHandler = (editorState) { + final selection = editorState.selection; + final selectionType = editorState.selectionType; + if (selection == null) { + return KeyEventResult.ignored; + } + if (selectionType == SelectionType.block) { + return _deleteInBlockSelection(editorState); + } else if (selection.isCollapsed) { + return _deleteInCollapsedSelection(editorState); + } else { + return _deleteInNotCollapsedSelection(editorState); + } +}; + +/// Handle delete key event when selection is collapsed. +CommandShortcutEventHandler _deleteInCollapsedSelection = (editorState) { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return KeyEventResult.ignored; + } + + final position = selection.start; + final node = editorState.getNodeAtPath(position.path); + final delta = node?.delta; + if (node == null || delta == null) { + return KeyEventResult.ignored; + } + + final transaction = editorState.transaction; + + if (position.offset == delta.length) { + final Node? tableParent = + node.findParent((element) => element.type == SimpleTableBlockKeys.type); + Node? nextTableParent; + final next = node.findDownward((element) { + nextTableParent = element + .findParent((element) => element.type == SimpleTableBlockKeys.type); + // break if only one is in a table or they're in different tables + return tableParent != nextTableParent || + // merge the next node with delta + element.delta != null; + }); + // table nodes should be deleted using the table menu + // in-table paragraphs should only be deleted inside the table + if (next != null && tableParent == nextTableParent) { + if (next.children.isNotEmpty) { + final path = node.path + [node.children.length]; + transaction.insertNodes(path, next.children); + } + transaction + ..deleteNode(next) + ..mergeText( + node, + next, + ); + editorState.apply(transaction); + return KeyEventResult.handled; + } + } else { + final nextIndex = delta.nextRunePosition(position.offset); + if (nextIndex <= delta.length) { + transaction.deleteText( + node, + position.offset, + nextIndex - position.offset, + ); + editorState.apply(transaction); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; +}; + +/// Handle delete key event when selection is not collapsed. +CommandShortcutEventHandler _deleteInNotCollapsedSelection = (editorState) { + final selection = editorState.selection; + if (selection == null || selection.isCollapsed) { + return KeyEventResult.ignored; + } + editorState.deleteSelection(selection); + return KeyEventResult.handled; +}; + +CommandShortcutEventHandler _deleteInBlockSelection = (editorState) { + final selection = editorState.selection; + if (selection == null || editorState.selectionType != SelectionType.block) { + return KeyEventResult.ignored; + } + final transaction = editorState.transaction; + transaction.deleteNodesAtPath(selection.start.path); + editorState + .apply(transaction) + .then((value) => editorState.selectionType = null); + + return KeyEventResult.handled; +}; 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 0000000000..4eaa131390 --- /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 0000000000..a65cd61c83 --- /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 0000000000..b0e271fbe8 --- /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 0000000000..4454e9efaf --- /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 0000000000..ee6020793c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart @@ -0,0 +1,353 @@ +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, + this.alwaysDistributeColumnWidths = false, + }); + + final bool alwaysDistributeColumnWidths; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return SimpleTableBlockWidget( + key: node.key, + node: node, + configuration: configuration, + alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + 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.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + required this.alwaysDistributeColumnWidths, + }); + + final bool alwaysDistributeColumnWidths; + + @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, + alwaysDistributeColumnWidths: widget.alwaysDistributeColumnWidths, + ); + + 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!, + actionTrailingBuilder: widget.actionTrailingBuilder, + 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 0000000000..29b3c3455f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart @@ -0,0 +1,601 @@ +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, + this.alwaysDistributeColumnWidths = false, + }); + + final bool alwaysDistributeColumnWidths; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return SimpleTableCellBlockWidget( + key: node.key, + node: node, + configuration: configuration, + alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => true; +} + +class SimpleTableCellBlockWidget extends BlockComponentStatefulWidget { + const SimpleTableCellBlockWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + required this.alwaysDistributeColumnWidths, + }); + + final bool alwaysDistributeColumnWidths; + + @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: widget.alwaysDistributeColumnWidths ? null : 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, _) { + final previousCell = node.getPreviousCellInSameRow(); + return Stack( + children: [ + DecoratedBox( + decoration: _buildDecoration(), + child: child!, + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: SimpleTableColumnResizeHandle( + node: node, + ), + ), + if (node.columnIndex != 0 && previousCell != null) + Positioned( + left: 0, + top: 0, + bottom: 0, + // pass the previous node to the resize handle + // to make the resize handle work correctly + child: SimpleTableColumnResizeHandle( + node: previousCell, + isPreviousCell: true, + ), + ), + ], + ); + }, + ); + }, + ); + }, + ); + }, + 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.withValues(alpha: 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 Colors.transparent; + } + + 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 0000000000..295a636e09 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart @@ -0,0 +1,325 @@ +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); + + /// resizingCell is the cell that the user is resizing + /// + /// This value is available on mobile only + final ValueNotifier resizingCell = ValueNotifier(null); + + /// Scroll controller for the table + ScrollController? horizontalScrollController; + + 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(); + resizingCell.dispose(); + } +} + +class SimpleTableConstants { + /// Table + static const defaultColumnWidth = 160.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; + + static const actionSheetBottomSheetHeight = 320.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).withValues(alpha: 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); + + Color? get simpleTableQuickActionBackgroundColor => + Theme.of(this).isLightMode ? null : const Color(0xFFBBC3CD); +} 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 0000000000..4906ed85eb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart @@ -0,0 +1,500 @@ +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, () { + if (context.mounted) { + _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; + } + + final beforeSelection = editorState.selection; + + // 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, + useSafeArea: false, + enablePadding: false, + builder: (context) => Provider.value( + value: simpleTableContext, + child: SimpleTableBottomSheet( + tableNode: tableNode, + editorState: editorState, + ), + ), + ); + + simpleTableContext.isSelectingTable.value = false; + keepEditorFocusNotifier.decrease(); + + // remove the extra info + if (beforeSelection != null) { + await editorState.updateSelectionWithReason( + beforeSelection, + customSelectionType: SelectionType.inline, + reason: SelectionUpdateReason.uiEvent, + extraInfo: {}, + ); + } + } +} 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 0000000000..c545036f35 --- /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.getFirstFocusableChild(); + final endNode = cell.getLastFocusableChild(); + 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.getFirstFocusableChild(); + final endNode = cell.getLastFocusableChild(); + 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.getFirstFocusableChild(); + final endNode = cell.getLastFocusableChild(); + 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 0000000000..d5dfc02474 --- /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 0000000000..a2fdac4ca2 --- /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 0000000000..a60ece2c2c --- /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,59 @@ +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 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 columnColors = tableNode.columnColors; + + final transaction = this.transaction; + transaction.updateNode(tableNode, { + SimpleTableBlockKeys.enableHeaderColumn: enable, + // remove the previous background color if the header column is enable again + if (enable) + SimpleTableBlockKeys.columnColors: columnColors + ..removeWhere((key, _) => key == '0'), + }); + 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 rowColors = tableNode.rowColors; + + final transaction = this.transaction; + transaction.updateNode(tableNode, { + SimpleTableBlockKeys.enableHeaderRow: enable, + // remove the previous background color if the header row is enable again + if (enable) + SimpleTableBlockKeys.rowColors: rowColors + ..removeWhere((key, _) => key == '0'), + }); + 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 0000000000..7a92aa3c7e --- /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 0000000000..875da5fffe --- /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,1085 @@ +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, + ); + + final rowBoldAttributes = _remapSource( + this.rowBoldAttributes, + index, + comparator: (iKey, index) => iKey >= index, + ); + + final rowTextColors = _remapSource( + this.rowTextColors, + index, + comparator: (iKey, index) => iKey >= index, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.rowColors, + rowColors, + ) + .mergeValues( + SimpleTableBlockKeys.rowAligns, + rowAligns, + ) + .mergeValues( + SimpleTableBlockKeys.rowBoldAttributes, + rowBoldAttributes, + ) + .mergeValues( + SimpleTableBlockKeys.rowTextColors, + rowTextColors, + ); + } 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 columnBoldAttributes = _remapSource( + this.columnBoldAttributes, + index, + comparator: (iKey, index) => iKey >= index, + ); + + final columnTextColors = _remapSource( + this.columnTextColors, + 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, + ) + .mergeValues( + SimpleTableBlockKeys.columnBoldAttributes, + columnBoldAttributes, + ) + .mergeValues( + SimpleTableBlockKeys.columnTextColors, + columnTextColors, + ); + } 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, + ); + + final (rowBoldAttributes, duplicatedRowBoldAttribute) = + _findDuplicatedEntryAndRemap( + this.rowBoldAttributes, + index, + ); + + final (rowTextColors, duplicatedRowTextColor) = + _findDuplicatedEntryAndRemap( + this.rowTextColors, + index, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.rowColors, + rowColors, + duplicatedEntry: duplicatedRowColor, + ) + .mergeValues( + SimpleTableBlockKeys.rowAligns, + rowAligns, + duplicatedEntry: duplicatedRowAlign, + ) + .mergeValues( + SimpleTableBlockKeys.rowBoldAttributes, + rowBoldAttributes, + duplicatedEntry: duplicatedRowBoldAttribute, + ) + .mergeValues( + SimpleTableBlockKeys.rowTextColors, + rowTextColors, + duplicatedEntry: duplicatedRowTextColor, + ); + } 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, + ); + + final (columnBoldAttributes, duplicatedColumnBoldAttribute) = + _findDuplicatedEntryAndRemap( + this.columnBoldAttributes, + index, + ); + + final (columnTextColors, duplicatedColumnTextColor) = + _findDuplicatedEntryAndRemap( + this.columnTextColors, + index, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.columnColors, + columnColors, + duplicatedEntry: duplicatedColumnColor, + ) + .mergeValues( + SimpleTableBlockKeys.columnAligns, + columnAligns, + duplicatedEntry: duplicatedColumnAlign, + ) + .mergeValues( + SimpleTableBlockKeys.columnWidths, + columnWidths, + duplicatedEntry: duplicatedColumnWidth, + ) + .mergeValues( + SimpleTableBlockKeys.columnBoldAttributes, + columnBoldAttributes, + duplicatedEntry: duplicatedColumnBoldAttribute, + ) + .mergeValues( + SimpleTableBlockKeys.columnTextColors, + columnTextColors, + duplicatedEntry: duplicatedColumnTextColor, + ); + } 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, + ); + + final columnBoldAttributes = _remapSource( + this.columnBoldAttributes, + index, + increment: false, + comparator: (iKey, index) => iKey > index, + filterIndex: index, + ); + + final columnTextColors = _remapSource( + this.columnTextColors, + index, + increment: false, + comparator: (iKey, index) => iKey > index, + filterIndex: index, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.columnColors, + columnColors, + ) + .mergeValues( + SimpleTableBlockKeys.columnAligns, + columnAligns, + ) + .mergeValues( + SimpleTableBlockKeys.columnWidths, + columnWidths, + ) + .mergeValues( + SimpleTableBlockKeys.columnBoldAttributes, + columnBoldAttributes, + ) + .mergeValues( + SimpleTableBlockKeys.columnTextColors, + columnTextColors, + ); + } 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, + ); + + final rowBoldAttributes = _remapSource( + this.rowBoldAttributes, + index, + increment: false, + comparator: (iKey, index) => iKey > index, + filterIndex: index, + ); + + final rowTextColors = _remapSource( + this.rowTextColors, + index, + increment: false, + comparator: (iKey, index) => iKey > index, + filterIndex: index, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.rowColors, + rowColors, + ) + .mergeValues( + SimpleTableBlockKeys.rowAligns, + rowAligns, + ) + .mergeValues( + SimpleTableBlockKeys.rowBoldAttributes, + rowBoldAttributes, + ) + .mergeValues( + SimpleTableBlockKeys.rowTextColors, + rowTextColors, + ); + } 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()]; + final duplicatedColumnBoldAttribute = + this.columnBoldAttributes[fromIndex.toString()]; + final duplicatedColumnTextColor = + this.columnTextColors[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, + ); + + final columnBoldAttributes = _remapSource( + this.columnBoldAttributes, + fromIndex, + increment: fromIndex > toIndex, + comparator: (iKey, index) { + if (fromIndex > toIndex) { + return iKey < fromIndex && iKey >= toIndex; + } else { + return iKey > fromIndex && iKey <= toIndex; + } + }, + filterIndex: fromIndex, + ); + + final columnTextColors = _remapSource( + this.columnTextColors, + 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, + ) + .mergeValues( + SimpleTableBlockKeys.columnBoldAttributes, + columnBoldAttributes, + duplicatedEntry: duplicatedColumnBoldAttribute != null + ? MapEntry( + toIndex.toString(), + duplicatedColumnBoldAttribute, + ) + : null, + removeNullValue: true, + ) + .mergeValues( + SimpleTableBlockKeys.columnTextColors, + columnTextColors, + duplicatedEntry: duplicatedColumnTextColor != null + ? MapEntry( + toIndex.toString(), + duplicatedColumnTextColor, + ) + : 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()]; + final duplicatedRowBoldAttribute = + this.rowBoldAttributes[fromIndex.toString()]; + final duplicatedRowTextColor = this.rowTextColors[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, + ); + + final rowBoldAttributes = _remapSource( + this.rowBoldAttributes, + fromIndex, + increment: fromIndex > toIndex, + comparator: (iKey, index) { + if (fromIndex > toIndex) { + return iKey < fromIndex && iKey >= toIndex; + } else { + return iKey > fromIndex && iKey <= toIndex; + } + }, + filterIndex: fromIndex, + ); + + final rowTextColors = _remapSource( + this.rowTextColors, + 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, + ) + .mergeValues( + SimpleTableBlockKeys.rowBoldAttributes, + rowBoldAttributes, + duplicatedEntry: duplicatedRowBoldAttribute != null + ? MapEntry( + toIndex.toString(), + duplicatedRowBoldAttribute, + ) + : null, + removeNullValue: true, + ) + .mergeValues( + SimpleTableBlockKeys.rowTextColors, + rowTextColors, + duplicatedEntry: duplicatedRowTextColor != null + ? MapEntry( + toIndex.toString(), + duplicatedRowTextColor, + ) + : 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 0000000000..1a2e21c305 --- /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,878 @@ +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()] as Object?; + if (width == null) { + return SimpleTableConstants.defaultColumnWidth; + } + return width.toDouble( + defaultValue: 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? getFirstFocusableChild() { + if (children.isEmpty) { + return this; + } + return children.first.getFirstFocusableChild(); + } + + /// Get last focusable child in the table cell. + /// + /// If the current node is not a table cell node, it will return null. + Node? getLastFocusableChild() { + if (children.isEmpty) { + return this; + } + return children.last.getLastFocusableChild(); + } + + /// 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; + } +} + +extension on Object { + double toDouble({double defaultValue = 0}) { + if (this is double) { + return this as double; + } + if (this is String) { + return double.tryParse(this as String) ?? defaultValue; + } + if (this is int) { + return (this as int).toDouble(); + } + return defaultValue; + } +} 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 0000000000..c5bb8bac83 --- /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 0000000000..02e384ac02 --- /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 0000000000..7afa7da66b --- /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,445 @@ +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 { + 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 { + 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 { + await clearColumnTextAlign(tableCellNode: tableCellNode); + + 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 { + await clearRowTextAlign(tableCellNode: tableCellNode); + + 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); + } + + /// Clear the text align of the column at the index where the table cell node is located. + Future clearColumnTextAlign({ + required Node tableCellNode, + }) async { + final parentTableNode = tableCellNode.parentTableNode; + if (parentTableNode == null) { + Log.warn('parent table node is null'); + return; + } + final columnIndex = tableCellNode.columnIndex; + final transaction = this.transaction; + for (var i = 0; i < parentTableNode.rowLength; i++) { + final cell = parentTableNode.getTableCellNode( + rowIndex: i, + columnIndex: columnIndex, + ); + if (cell == null) { + continue; + } + for (final child in cell.children) { + transaction.updateNode(child, { + blockComponentAlign: null, + }); + } + } + if (transaction.operations.isNotEmpty) { + await apply(transaction); + } + } + + /// Clear the text align of the row at the index where the table cell node is located. + Future clearRowTextAlign({ + required Node tableCellNode, + }) async { + final parentTableNode = tableCellNode.parentTableNode; + if (parentTableNode == null) { + Log.warn('parent table node is null'); + return; + } + final rowIndex = tableCellNode.rowIndex; + final transaction = this.transaction; + for (var i = 0; i < parentTableNode.columnLength; i++) { + final cell = parentTableNode.getTableCellNode( + rowIndex: rowIndex, + columnIndex: i, + ); + if (cell == null) { + continue; + } + for (final child in cell.children) { + transaction.updateNode( + child, + { + blockComponentAlign: null, + }, + ); + } + } + if (transaction.operations.isNotEmpty) { + 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 0000000000..99f23d1ee9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart @@ -0,0 +1,124 @@ +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, + this.alwaysDistributeColumnWidths = false, + }); + + final bool alwaysDistributeColumnWidths; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return SimpleTableRowBlockWidget( + key: node.key, + node: node, + configuration: configuration, + alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (_) => true; +} + +class SimpleTableRowBlockWidget extends BlockComponentStatefulWidget { + const SimpleTableRowBlockWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.actionTrailingBuilder, + super.configuration = const BlockComponentConfiguration(), + required this.alwaysDistributeColumnWidths, + }); + + final bool alwaysDistributeColumnWidths; + + @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()); + } + + final child = editorState.renderer.build(context, node.children[i]); + cells.add( + widget.alwaysDistributeColumnWidths ? Flexible(child: child) : child, + ); + + // 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 0000000000..70b2b07660 --- /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,94 @@ +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_operations.dart'; +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_shortcuts/simple_table_navigation_command.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; + final nextBlock = tableCellNode.parentTableNode?.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 if (nextBlock != null) { + if (nextBlock.type != SimpleTableBlockKeys.type) { + newSelection = Selection.collapsed( + Position( + path: nextBlock.path, + ), + ); + } else { + return tableNavigationArrowDownCommand.handler(editorState); + } + } + } 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 0000000000..f9a80ced5e --- /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 0000000000..196357b5b0 --- /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 0000000000..f6919f3b04 --- /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 0000000000..f4a9bd9946 --- /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 0000000000..1fb8e07603 --- /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 0000000000..1cc278ca99 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_commands.dart @@ -0,0 +1,22 @@ +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_navigation_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 = [ + tableNavigationArrowDownCommand, + 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 0000000000..bfc31e8abc --- /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,50 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_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_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; + } + } + if (node.type != CalloutBlockKeys.type) { + 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_navigation_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_navigation_command.dart new file mode 100644 index 0000000000..534879f56f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_navigation_command.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +final CommandShortcutEvent tableNavigationArrowDownCommand = + CommandShortcutEvent( + key: 'table navigation', + getDescription: () => 'table navigation', + command: 'arrow down', + handler: _tableNavigationArrowDownHandler, +); + +KeyEventResult _tableNavigationArrowDownHandler(EditorState editorState) { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return KeyEventResult.ignored; + } + + final nextNode = editorState.getNodeAtPath(selection.start.path.next); + if (nextNode == null) { + return KeyEventResult.ignored; + } + + if (nextNode.type == SimpleTableBlockKeys.type) { + final firstCell = nextNode.getTableCellNode(rowIndex: 0, columnIndex: 0); + if (firstCell != null) { + final firstFocusableChild = firstCell.getFirstFocusableChild(); + if (firstFocusableChild != null) { + editorState.updateSelectionWithReason( + Selection.collapsed( + Position(path: firstFocusableChild.path), + ), + ); + 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_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 0000000000..a33b08f66e --- /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 0000000000..93b072fa88 --- /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 0000000000..187dbaf31d --- /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,185 @@ +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, + this.alwaysDistributeColumnWidths = 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; + + /// Refer to [SimpleTableWidget.alwaysDistributeColumnWidths]. + final bool alwaysDistributeColumnWidths; + + @override + State createState() => + _DesktopSimpleTableWidgetState(); +} + +class _DesktopSimpleTableWidgetState extends State { + SimpleTableContext get simpleTableContext => widget.simpleTableContext; + + final scrollController = ScrollController(); + late final editorState = context.read(); + + @override + void initState() { + super.initState(); + + simpleTableContext.horizontalScrollController = scrollController; + } + + @override + void dispose() { + simpleTableContext.horizontalScrollController = null; + 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 + // IntrinsicHeight is used to make the table size fit the content. + Widget child = IntrinsicHeight( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildRows(), + ), + ); + + if (widget.alwaysDistributeColumnWidths) { + child = Padding( + padding: SimpleTableConstants.tablePadding, + child: child, + ); + } else { + child = Scrollbar( + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: Padding( + padding: SimpleTableConstants.tablePadding, + // IntrinsicWidth is used to make the table size fit the content. + child: IntrinsicWidth(child: child), + ), + ), + ); + } + + 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 0000000000..bf8720b88a --- /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,126 @@ +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, + this.alwaysDistributeColumnWidths = 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; + + /// Refer to [SimpleTableWidget.alwaysDistributeColumnWidths]. + final bool alwaysDistributeColumnWidths; + + @override + State createState() => + _MobileSimpleTableWidgetState(); +} + +class _MobileSimpleTableWidgetState extends State { + SimpleTableContext get simpleTableContext => widget.simpleTableContext; + + final scrollController = ScrollController(); + late final editorState = context.read(); + + @override + void initState() { + super.initState(); + + simpleTableContext.horizontalScrollController = scrollController; + } + + @override + void dispose() { + simpleTableContext.horizontalScrollController = null; + 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 0000000000..b81ff89ee8 --- /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,1297 @@ +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, + ), + ), + SimpleTableQuickAction( + type: SimpleTableMoreAction.paste, + 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: + type == SimpleTableMoreAction.delete ? null : BlendMode.srcIn, + size: const Size.square(24), + color: context.simpleTableQuickActionBackgroundColor, + ), + ), + ); + } +} + +/// 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: (increaseCounter) async => _onActionTap( + context, + type: SimpleTableMoreAction.insertAbove, + increaseCounter: increaseCounter, + ), + ), + const HSpace(2), + SimpleTableInsertAction( + type: SimpleTableMoreAction.insertBelow, + enableRightBorder: true, + onTap: (increaseCounter) async => _onActionTap( + context, + type: SimpleTableMoreAction.insertBelow, + increaseCounter: increaseCounter, + ), + ), + ], + ), + SimpleTableMoreActionType.column => Row( + children: [ + SimpleTableInsertAction( + type: SimpleTableMoreAction.insertLeft, + enableLeftBorder: true, + onTap: (increaseCounter) async => _onActionTap( + context, + type: SimpleTableMoreAction.insertLeft, + increaseCounter: increaseCounter, + ), + ), + const HSpace(2), + SimpleTableInsertAction( + type: SimpleTableMoreAction.insertRight, + enableRightBorder: true, + onTap: (increaseCounter) async => _onActionTap( + context, + type: SimpleTableMoreAction.insertRight, + increaseCounter: increaseCounter, + ), + ), + ], + ), + }; + } + + Future _onActionTap( + BuildContext context, { + required SimpleTableMoreAction type, + required int increaseCounter, + }) async { + 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; + await editorState.insertRowInTable( + tableNode, + cellNode.rowIndex, + ); + case SimpleTableMoreAction.insertBelow: + await editorState.insertRowInTable( + tableNode, + cellNode.rowIndex + 1, + ); + // scroll to the next cell position + editorState.scrollService?.scrollTo( + SimpleTableConstants.defaultRowHeight, + duration: Durations.short3, + ); + case SimpleTableMoreAction.insertLeft: + // update the highlight status for the selecting column + simpleTableContext.selectingColumn.value = cellNode.columnIndex + 1; + await editorState.insertColumnInTable( + tableNode, + cellNode.columnIndex, + ); + case SimpleTableMoreAction.insertRight: + await editorState.insertColumnInTable( + tableNode, + cellNode.columnIndex + 1, + ); + final horizontalScrollController = + simpleTableContext.horizontalScrollController; + if (horizontalScrollController != null) { + final previousWidth = horizontalScrollController.offset; + horizontalScrollController.jumpTo( + previousWidth + SimpleTableConstants.defaultColumnWidth, + ); + } + + default: + assert(false, 'Unsupported action: $type'); + } + } +} + +class SimpleTableInsertAction extends StatefulWidget { + 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 ValueChanged onTap; + + @override + State createState() => + _SimpleTableInsertActionState(); +} + +class _SimpleTableInsertActionState extends State { + // used to count how many times the action is tapped + int increaseCounter = 0; + + @override + Widget build(BuildContext context) { + return Expanded( + child: DecoratedBox( + decoration: ShapeDecoration( + color: context.simpleTableInsertActionBackgroundColor, + shape: _buildBorder(), + ), + child: AnimatedGestureDetector( + onTapUp: () => widget.onTap(increaseCounter++), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(1), + child: FlowySvg( + widget.type.leftIconSvg, + size: const Size.square(22), + ), + ), + FlowyText( + widget.type.name, + fontSize: 12, + figmaLineHeight: 16, + ), + ], + ), + ), + ), + ); + } + + RoundedRectangleBorder _buildBorder() { + const radius = Radius.circular( + SimpleTableConstants.actionSheetButtonRadius, + ); + return RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: widget.enableLeftBorder ? radius : Radius.zero, + bottomLeft: widget.enableLeftBorder ? radius : Radius.zero, + topRight: widget.enableRightBorder ? radius : Radius.zero, + bottomRight: widget.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 + // keep the action menu open if the action is enable header row or enable header column + if (action != SimpleTableMoreAction.enableHeaderRow && + action != SimpleTableMoreAction.enableHeaderColumn) { + 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, + activeTrackColor: Theme.of(context).colorScheme.primary, + onChanged: (_) => _toggle(), + ), + ), + ); + }, + ); + } + + 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, + ), + ), + SimpleTableQuickAction( + type: SimpleTableMoreAction.paste, + 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 0000000000..269498b341 --- /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,238 @@ +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, + useSafeArea: false, + showDragHandle: true, + showDivider: false, + enablePadding: 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 0000000000..1655920ef5 --- /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,96 @@ +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: () { + // cancel the selection to avoid flashing the selection + editorState.selection = null; + + 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 0000000000..29a24ba623 --- /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,223 @@ +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: () { + // cancel the selection to avoid flashing the selection + widget.editorState.selection = null; + + 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 0000000000..00ca444afd --- /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,226 @@ +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: () { + // cancel the selection to avoid flashing the selection + widget.editorState.selection = null; + + 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 0000000000..a042e632ea --- /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 0000000000..ef55081a14 --- /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 0000000000..f9df88ccf0 --- /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 0000000000..e241f8bf72 --- /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 0000000000..97519422ec --- /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,486 @@ +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, + this.scrollController, + }); + + final SimpleTableMoreActionType type; + final Node cellNode; + final EditorState editorState; + final ScrollController? scrollController; + + @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 + ...menuState == _SimpleTableBottomSheetMenuState.cellActionMenu + ? _buildScrollableContent() + : _buildNonScrollableContent(), + ], + ); + } + + 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: true, + showRemoveButton: false, + title: widget.type.name.capitalize(), + onClose: () => setState(() { + menuState = _SimpleTableBottomSheetMenuState.cellActionMenu; + }), + onDone: (_) => Navigator.pop(context), + ); + default: + throw UnimplementedError('Unsupported menu state: $menuState'); + } + } + + List _buildScrollableContent() { + return [ + SizedBox( + height: SimpleTableConstants.actionSheetBottomSheetHeight, + child: Scrollbar( + controller: widget.scrollController, + child: SingleChildScrollView( + controller: widget.scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ..._buildContent(), + + // safe area padding + VSpace(context.bottomSheetPadding() * 2), + ], + ), + ), + ), + ), + ]; + } + + List _buildNonScrollableContent() { + return [ + ..._buildContent(), + + // safe area padding + VSpace(context.bottomSheetPadding()), + ]; + } + + 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.a == 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.a == 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, + this.scrollController, + }); + + final Node tableNode; + final EditorState editorState; + final ScrollController? scrollController; + + @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 + SizedBox( + height: SimpleTableConstants.actionSheetBottomSheetHeight, + child: Scrollbar( + controller: widget.scrollController, + child: SingleChildScrollView( + controller: widget.scrollController, + child: Column( + children: [ + // content + ..._buildContent(), + + // safe area padding + VSpace(context.bottomSheetPadding() * 2), + ], + ), + ), + ), + ), + ], + ); + } + + 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 0000000000..ab941cb4d1 --- /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,201 @@ +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:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class SimpleTableColumnResizeHandle extends StatefulWidget { + const SimpleTableColumnResizeHandle({ + super.key, + required this.node, + this.isPreviousCell = false, + }); + + final Node node; + final bool isPreviousCell; + + @override + State createState() => + _SimpleTableColumnResizeHandleState(); +} + +class _SimpleTableColumnResizeHandleState + extends State { + late final simpleTableContext = context.read(); + + bool isStartDragging = false; + + // record the previous position of the drag, only used on mobile + double previousDx = 0; + + @override + Widget build(BuildContext context) { + return UniversalPlatform.isMobile + ? _buildMobileResizeHandle() + : _buildDesktopResizeHandle(); + } + + Widget _buildDesktopResizeHandle() { + 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, + ), + ), + ), + ); + } + + Widget _buildMobileResizeHandle() { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPressStart: _onLongPressStart, + onLongPressMoveUpdate: _onLongPressMoveUpdate, + onLongPressEnd: _onLongPressEnd, + onLongPressCancel: _onLongPressCancel, + child: ValueListenableBuilder( + valueListenable: simpleTableContext.resizingCell, + builder: (context, resizingCell, child) { + final isSameColumnIndex = + widget.node.columnIndex == resizingCell?.columnIndex; + if (!isSameColumnIndex) { + return child!; + } + return Container( + width: 10, + alignment: !widget.isPreviousCell + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + width: 2, + color: Theme.of(context).colorScheme.primary, + ), + ); + }, + child: Container( + width: 10, + color: Colors.transparent, + ), + ), + ); + } + + 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 _onLongPressStart(LongPressStartDetails details) { + isStartDragging = true; + simpleTableContext.resizingCell.value = widget.node; + + HapticFeedback.lightImpact(); + } + + 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 _onLongPressMoveUpdate(LongPressMoveUpdateDetails 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.offsetFromOrigin.dx - previousDx, + ); + + previousDx = details.offsetFromOrigin.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, + ); + } + + void _onLongPressEnd(LongPressEndDetails details) { + if (!isStartDragging) { + return; + } + + isStartDragging = false; + + // apply the updated column width + context.read().updateColumnWidth( + tableCellNode: widget.node, + width: widget.node.columnWidth, + ); + + previousDx = 0; + + simpleTableContext.resizingCell.value = null; + } + + void _onLongPressCancel() { + isStartDragging = false; + simpleTableContext.resizingCell.value = null; + } +} 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 0000000000..0de08d6e75 --- /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 0000000000..2467d45395 --- /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 0000000000..d4df194c05 --- /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,596 @@ +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?; + + late final simpleTableContext = context.read(); + Node? tableNode; + Node? tableCellNode; + + @override + void initState() { + super.initState(); + + tableCellNode = context.read().hoveringTableCell.value; + tableNode = tableCellNode?.parentTableNode; + 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) { + if (tableNode == null) { + return const SizedBox.shrink(); + } + + return AppFlowyPopover( + onOpen: () => _onOpen(tableCellNode: tableCellNode), + onClose: () => _onClose(), + canClose: () async { + return true; + }, + 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); + + final cell = table.getTableCellNode( + rowIndex: 0, + columnIndex: columnIndex, + ); + if (cell == null) { + return; + } + + // update selection + editorState.selection = Selection.collapsed( + Position( + path: cell.path.child(0), + ), + ); + } + + 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); + + final cell = table.getTableCellNode( + rowIndex: 0, + columnIndex: columnIndex + 1, + ); + if (cell == null) { + return; + } + + // update selection + editorState.selection = Selection.collapsed( + Position( + path: cell.path.child(0), + ), + ); + } + + 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); + + final cell = table.getTableCellNode(rowIndex: rowIndex, columnIndex: 0); + if (cell == null) { + return; + } + + // update selection + editorState.selection = Selection.collapsed( + Position( + path: cell.path.child(0), + ), + ); + } + + 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); + + final cell = table.getTableCellNode(rowIndex: rowIndex + 1, columnIndex: 0); + if (cell == null) { + return; + } + + // update selection + editorState.selection = Selection.collapsed( + Position( + path: cell.path.child(0), + ), + ); + } + + 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 0000000000..bb87196051 --- /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 0000000000..98d1e9f246 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart @@ -0,0 +1,85 @@ +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, + this.alwaysDistributeColumnWidths = 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; + + /// Whether the columns should ignore their widths and fill available space + final bool alwaysDistributeColumnWidths; + + @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, + alwaysDistributeColumnWidths: widget.alwaysDistributeColumnWidths, + ) + : MobileSimpleTableWidget( + simpleTableContext: widget.simpleTableContext, + node: widget.node, + enableAddColumnButton: widget.enableAddColumnButton, + enableAddRowButton: widget.enableAddRowButton, + enableAddColumnAndRowButton: widget.enableAddColumnAndRowButton, + enableHoverEffect: widget.enableHoverEffect, + isFeedback: widget.isFeedback, + alwaysDistributeColumnWidths: widget.alwaysDistributeColumnWidths, + ); + } +} 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 0000000000..322e3e6a89 --- /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 0000000000..3d6fe113c1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart @@ -0,0 +1,158 @@ +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu.dart'; +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; +import 'package:appflowy_editor/appflowy_editor.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 { + 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) { + final isLight = Theme.of(context).brightness == Brightness.light; + _selectionMenuService?.dismiss(); + _selectionMenuService = UniversalPlatform.isMobile + ? MobileSelectionMenu( + context: context, + editorState: editorState, + selectionMenuItems: items, + deleteSlashByDefault: shouldInsertSlash, + deleteKeywordsByDefault: deleteKeywordsByDefault, + singleColumn: singleColumn, + style: isLight + ? MobileSelectionMenuStyle.light + : MobileSelectionMenuStyle.dark, + startOffset: editorState.selection?.start.offset ?? 0, + ) + : SelectionMenu( + context: context, + editorState: editorState, + selectionMenuItems: items, + deleteSlashByDefault: shouldInsertSlash, + deleteKeywordsByDefault: deleteKeywordsByDefault, + singleColumn: singleColumn, + style: style, + ); + + // disable the keyboard service + editorState.service.keyboardService?.disable(); + + await _selectionMenuService?.show(); + // enable the keyboard service + editorState.service.keyboardService?.enable(); + } + + 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 0000000000..46d1b4eabb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/ai_writer_item.dart @@ -0,0 +1,81 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.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', +]; + +SelectionMenuItem aiWriterSlashMenuItem = SelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_aiWriter.tr, + keywords: [ + ..._keywords, + LocaleKeys.document_slashMenu_name_aiWriter.tr(), + ], + handler: (editorState, _, __) async => + _insertAiWriter(editorState, AiWriterCommand.userQuestion), + icon: (_, isSelected, style) => SelectableSvgWidget( + data: AiWriterCommand.userQuestion.icon, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, +); + +SelectionMenuItem continueWritingSlashMenuItem = SelectionMenuItem( + getName: LocaleKeys.document_plugins_aiWriter_continueWriting.tr, + keywords: [ + ..._keywords, + LocaleKeys.document_plugins_aiWriter_continueWriting.tr(), + ], + handler: (editorState, _, __) async => + _insertAiWriter(editorState, AiWriterCommand.continueWriting), + icon: (_, isSelected, style) => SelectableSvgWidget( + data: AiWriterCommand.continueWriting.icon, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, +); + +Future _insertAiWriter( + EditorState editorState, + AiWriterCommand action, +) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null || node.delta == null) { + return; + } + final newNode = aiWriterNode( + selection: selection, + command: action, + ); + + // default insert after + final path = node.path.next; + final transaction = editorState.transaction + ..insertNode(path, newNode) + ..afterSelection = null; + + await editorState.apply( + transaction, + options: const ApplyOptions( + recordUndo: false, + 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 0000000000..3fee0d0adb --- /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 0000000000..6f553f5216 --- /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 0000000000..44c346a302 --- /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 0000000000..176b67586a --- /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 0000000000..04a8ee1b7e --- /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.buildMentionDateAttributes( + date: DateTime.now().toIso8601String(), + reminderId: null, + reminderOption: null, + includeTime: false, + ), + ); + + 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 0000000000..a6c4001a68 --- /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 0000000000..890ba113cc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/emoji_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/emoji/emoji_actions_command.dart'; +import 'package:appflowy/plugins/emoji/emoji_menu.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:universal_platform/universal_platform.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(); + if (UniversalPlatform.isMobile || selection == null) { + return; + } + + final node = getNodeAtPath(selection!.end.path); + final delta = node?.delta; + if (node == null || delta == null || node.type == CodeBlockKeys.type) { + return; + } + emojiMenuService = EmojiMenu(editorState: this, overlay: container); + emojiMenuService?.show(''); + } +} 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 0000000000..64c529bee8 --- /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 0000000000..115ef22abe --- /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 0000000000..844b73c3e1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart @@ -0,0 +1,45 @@ +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 = buildImageSlashMenuItem(); + +SelectionMenuItem buildImageSlashMenuItem({FlowySvgData? svg}) => + SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_image.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertImageBlock(), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: svg ?? 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 0000000000..3274e4ab95 --- /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/mobile_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart new file mode 100644 index 0000000000..b71b54ad40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart @@ -0,0 +1,131 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.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_items.dart'; + +final List mobileItems = [ + textStyleMobileSlashMenuItem, + listMobileSlashMenuItem, + toggleListMobileSlashMenuItem, + fileAndMediaMobileSlashMenuItem, + mobileTableSlashMenuItem, + visualsMobileSlashMenuItem, + dateOrReminderSlashMenuItem, + buildSubpageSlashMenuItem(svg: FlowySvgs.type_page_m), + advancedMobileSlashMenuItem, +]; + +final List mobileItemsInTale = [ + textStyleMobileSlashMenuItem, + listMobileSlashMenuItem, + toggleListMobileSlashMenuItem, + fileAndMediaMobileSlashMenuItem, + visualsMobileSlashMenuItem, + dateOrReminderSlashMenuItem, + buildSubpageSlashMenuItem(svg: FlowySvgs.type_page_m), + advancedMobileSlashMenuItem, +]; + +SelectionMenuItemHandler _handler = (_, __, ___) {}; + +MobileSelectionMenuItem textStyleMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_textStyle.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_text_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + paragraphSlashMenuItem, + heading1SlashMenuItem, + heading2SlashMenuItem, + heading3SlashMenuItem, + ], +); + +MobileSelectionMenuItem listMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_list.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_bulleted_list_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + todoListSlashMenuItem, + bulletedListSlashMenuItem, + numberedListSlashMenuItem, + ], +); + +MobileSelectionMenuItem toggleListMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_toggle.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_toggle_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + toggleListSlashMenuItem, + toggleHeading1SlashMenuItem, + toggleHeading2SlashMenuItem, + toggleHeading3SlashMenuItem, + ], +); + +MobileSelectionMenuItem fileAndMediaMobileSlashMenuItem = + MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_fileAndMedia.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_file_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + buildImageSlashMenuItem(svg: FlowySvgs.slash_menu_image_m), + photoGallerySlashMenuItem, + fileSlashMenuItem, + ], +); + +MobileSelectionMenuItem visualsMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_visuals.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_visuals_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + calloutSlashMenuItem, + dividerSlashMenuItem, + quoteSlashMenuItem, + ], +); + +MobileSelectionMenuItem advancedMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_advanced.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.drag_element_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + codeBlockSlashMenuItem, + mathEquationSlashMenuItem, + ], +); 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 0000000000..2bb04156d6 --- /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 0000000000..795f27bc0f --- /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 0000000000..5ad8df64b0 --- /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 0000000000..95ef1a123b --- /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 0000000000..7c7f887a67 --- /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_columns_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_columns_item.dart new file mode 100644 index 0000000000..8609b76e70 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_columns_item.dart @@ -0,0 +1,97 @@ +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 _baseKeywords = [ + 'columns', + 'column block', +]; + +final _twoColumnsKeywords = [ + ..._baseKeywords, + 'two columns', + '2 columns', +]; + +final _threeColumnsKeywords = [ + ..._baseKeywords, + 'three columns', + '3 columns', +]; + +final _fourColumnsKeywords = [ + ..._baseKeywords, + 'four columns', + '4 columns', +]; + +// 2 columns menu item +SelectionMenuItem twoColumnsSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_twoColumns.tr(), + keywords: _twoColumnsKeywords, + nodeBuilder: (editorState, __) => _buildColumnsNode(editorState, 2), + replace: (_, node) => node.delta?.isEmpty ?? false, + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_two_columns_s, + isSelected: isSelected, + style: style, + ), + updateSelection: (_, path, __, ___) { + return Selection.single( + path: path.child(0).child(0), + startOffset: 0, + ); + }, +); + +// 3 columns menu item +SelectionMenuItem threeColumnsSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_threeColumns.tr(), + keywords: _threeColumnsKeywords, + nodeBuilder: (editorState, __) => _buildColumnsNode(editorState, 3), + replace: (_, node) => node.delta?.isEmpty ?? false, + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_three_columns_s, + isSelected: isSelected, + style: style, + ), + updateSelection: (_, path, __, ___) { + return Selection.single( + path: path.child(0).child(0), + startOffset: 0, + ); + }, +); + +// 4 columns menu item +SelectionMenuItem fourColumnsSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_fourColumns.tr(), + keywords: _fourColumnsKeywords, + nodeBuilder: (editorState, __) => _buildColumnsNode(editorState, 4), + replace: (_, node) => node.delta?.isEmpty ?? false, + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_four_columns_s, + isSelected: isSelected, + style: style, + ), + updateSelection: (_, path, __, ___) { + return Selection.single( + path: path.child(0).child(0), + startOffset: 0, + ); + }, +); + +Node _buildColumnsNode(EditorState editorState, int columnCount) { + return simpleColumnsNode( + columnCount: columnCount, + ratio: 1.0 / columnCount, + ); +} 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 0000000000..9e16571d39 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_table_item.dart @@ -0,0 +1,86 @@ +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, + ), +); + +SelectionMenuItem mobileTableSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_simpleTable.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 0000000000..fada0addd9 --- /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,93 @@ +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'; +import 'package:universal_platform/universal_platform.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) { + final isMobile = UniversalPlatform.isMobile; + return FlowyText.regular( + name, + fontSize: isMobile ? 16.0 : 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) { + final isMobile = UniversalPlatform.isMobile; + return SelectableSvgWidget( + data: data, + isSelected: isSelected, + size: isMobile ? Size.square(20) : null, + 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 0000000000..27be8e4f03 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_items.dart @@ -0,0 +1,23 @@ +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_columns_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 0000000000..1052dbbe3e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart @@ -0,0 +1,50 @@ +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 = buildSubpageSlashMenuItem(); + +SelectionMenuItem buildSubpageSlashMenuItem({FlowySvgData? svg}) => + 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: svg ?? 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 0000000000..518dccb35e --- /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.editor_checkbox.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 0000000000..d93dd3c738 --- /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 0000000000..137f592902 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart @@ -0,0 +1,209 @@ +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'slash_menu_items/mobile_items.dart'; +import 'slash_menu_items/slash_menu_items.dart'; + +/// Build slash menu items +/// +List slashMenuItemsBuilder({ + bool isLocalMode = false, + DocumentBloc? documentBloc, + EditorState? editorState, + Node? node, + ViewPB? view, +}) { + final isInTable = node != null && node.parentTableCellNode != null; + final isMobile = UniversalPlatform.isMobile; + bool isEmpty = false; + if (editorState == null || editorState.isEmptyForContinueWriting()) { + if (view == null || view.name.isEmpty) { + isEmpty = true; + } + } + if (isMobile) { + if (isInTable) { + return mobileItemsInTale; + } else { + return mobileItems; + } + } else { + if (isInTable) { + return _simpleTableSlashMenuItems(); + } else { + return _defaultSlashMenuItems( + isLocalMode: isLocalMode, + documentBloc: documentBloc, + isEmpty: isEmpty, + ); + } + } +} + +/// 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, + bool isEmpty = false, +}) { + return [ + // ai + if (!isLocalMode) ...[ + if (!isEmpty) continueWritingSlashMenuItem, + 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, + + // columns + // 2-4 columns + twoColumnsSlashMenuItem, + threeColumnsSlashMenuItem, + fourColumnsSlashMenuItem, + + // 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/stability_ai/stability_ai_client.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart deleted file mode 100644 index c3cbd11c97..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:http/http.dart' as http; - -enum StabilityAIRequestType { - imageGenerations; - - Uri get uri { - switch (this) { - case StabilityAIRequestType.imageGenerations: - return Uri.parse( - 'https://api.stability.ai/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image', - ); - } - } -} - -abstract class StabilityAIRepository { - /// Generate image from Stability AI - /// - /// [prompt] is the prompt text - /// [n] is the number of images to generate - /// - /// the return value is a list of base64 encoded images - Future, StabilityAIRequestError>> generateImage({ - required String prompt, - int n = 1, - }); -} - -class HttpStabilityAIRepository implements StabilityAIRepository { - const HttpStabilityAIRepository({ - 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, StabilityAIRequestError>> generateImage({ - required String prompt, - int n = 1, - }) async { - final parameters = { - 'text_prompts': [ - { - 'text': prompt, - } - ], - 'samples': n, - }; - - try { - final response = await client.post( - StabilityAIRequestType.imageGenerations.uri, - headers: headers, - body: json.encode(parameters), - ); - - final data = json.decode( - utf8.decode(response.bodyBytes), - ); - if (response.statusCode == 200) { - final artifacts = data['artifacts'] as List; - final base64Images = artifacts - .map( - (e) => e['base64'].toString(), - ) - .toList(); - return FlowyResult.success(base64Images); - } else { - return FlowyResult.failure( - StabilityAIRequestError( - data['message'].toString(), - ), - ); - } - } catch (error) { - return FlowyResult.failure( - StabilityAIRequestError( - error.toString(), - ), - ); - } - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart deleted file mode 100644 index c699237762..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart +++ /dev/null @@ -1,10 +0,0 @@ -class StabilityAIRequestError { - StabilityAIRequestError(this.message); - - final String message; - - @override - String toString() { - return 'StabilityAIRequestError{message: $message}'; - } -} 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 0000000000..a549e87f83 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/block_transaction_handler.dart @@ -0,0 +1,300 @@ +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:flutter/material.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 { + if (context.mounted) { + 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 0000000000..0ce2b74a74 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart @@ -0,0 +1,419 @@ +import 'package:appflowy/generated/locale_keys.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/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/shared/icon_emoji_picker/flowy_icon_emoji_picker.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_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.actionTrailingBuilder, + 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(); + } + + final textStyle = textStyleWithTextSpan(); + + 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 + ? RawEmojiIconWidget( + emoji: view.icon.toEmojiIconData(), + emojiSize: textStyle.fontSize ?? 16.0, + ) + : 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!, + actionTrailingBuilder: widget.actionTrailingBuilder, + child: child, + ); + } + + if (UniversalPlatform.isMobile) { + child = Padding( + padding: padding, + 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 0000000000..c5c7398bdb --- /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 index b1fac34423..0abba733fb 100644 --- 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 @@ -1,10 +1,11 @@ -import 'package:flutter/material.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +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 'dart:math' as math; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; const tableActions = [ TableOptionAction.addAfter, 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 index afb2b63f49..993ee9b5a7 100644 --- 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 @@ -3,7 +3,6 @@ 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:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -137,7 +136,7 @@ class TableColorOptionAction extends PopoverActionCell { colors: colors, selected: selectedColor, border: Border.all( - color: Theme.of(context).colorScheme.onBackground, + color: AFThemeExtension.of(context).onBackground, ), onTap: (option, index) async { final backgroundColor = 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 index deb45c2182..95841051d7 100644 --- 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 @@ -1,5 +1,4 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -17,7 +16,13 @@ class TodoListIcon extends StatelessWidget { @override Widget build(BuildContext context) { - final iconPadding = context.read().state.iconPadding; + // 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, @@ -26,16 +31,18 @@ class TodoListIcon extends StatelessWidget { onCheck(); }, child: Container( - constraints: const BoxConstraints( - minWidth: 22, - minHeight: 22, + constraints: BoxConstraints( + minWidth: iconSize, + minHeight: iconSize, ), - margin: EdgeInsets.only(top: iconPadding, right: 8.0), + 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 index 638efe7cf1..9e93f80ce4 100644 --- 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 @@ -1,8 +1,11 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:easy_localization/easy_localization.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 ToggleListBlockKeys { const ToggleListBlockKeys._(); @@ -20,6 +23,11 @@ class ToggleListBlockKeys { /// 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({ @@ -30,17 +38,41 @@ Node toggleListBlockNode({ Attributes? attributes, Iterable? children, }) { + delta ??= Delta()..insert(text ?? ''); return Node( type: ToggleListBlockKeys.type, + children: children ?? [], attributes: { - ToggleListBlockKeys.collapsed: collapsed, - ToggleListBlockKeys.delta: - (delta ?? (Delta()..insert(text ?? ''))).toJson(), - if (attributes != null) ...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, }, - children: children ?? [], ); } @@ -57,10 +89,14 @@ 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; @@ -69,16 +105,21 @@ class ToggleListBlockComponentBuilder extends BlockComponentBuilder { node: node, configuration: configuration, padding: padding, + textStyleBuilder: textStyleBuilder, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), + actionTrailingBuilder: (context, state) => actionTrailingBuilder( + blockComponentContext, + state, + ), ); } @override - bool validate(Node node) => node.delta != null; + BlockComponentValidate get validate => (node) => node.delta != null; } class ToggleListBlockComponentWidget extends BlockComponentStatefulWidget { @@ -87,11 +128,14 @@ class ToggleListBlockComponentWidget extends BlockComponentStatefulWidget { required super.node, super.showActions, super.actionBuilder, + super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), this.padding = const EdgeInsets.all(0), + this.textStyleBuilder, }); final EdgeInsets padding; + final TextStyle Function(int level)? textStyleBuilder; @override State createState() => @@ -136,6 +180,8 @@ class _ToggleListBlockComponentWidgetState bool get collapsed => node.attributes[ToggleListBlockKeys.collapsed] ?? false; + int? get level => node.attributes[ToggleListBlockKeys.level] as int?; + @override Widget build(BuildContext context) { return collapsed @@ -143,72 +189,41 @@ class _ToggleListBlockComponentWidgetState : 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, + ), + ), + Provider( + create: (context) => + DatabasePluginWidgetBuilderSize(horizontalPadding: 0.0), + child: NestedListWidget( + indentPadding: indentPadding, + child: buildComponent(context), + children: editorState.renderer.buildList( + context, + widget.node.children, + ), + ), + ), + ], + ); + } + @override Widget buildComponent( BuildContext context, { bool withBackgroundColor = false, }) { - final textDirection = calculateTextDirection( - layoutDirection: Directionality.maybeOf(context), - ); - - Widget child = Container( - color: backgroundColor, - width: double.infinity, - alignment: alignment, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - textDirection: textDirection, - children: [ - // the emoji picker button for the note - Container( - constraints: const BoxConstraints(minWidth: 26, minHeight: 22), - padding: const EdgeInsets.only(right: 4.0), - child: AnimatedRotation( - turns: collapsed ? 0.0 : 0.25, - duration: const Duration(milliseconds: 200), - child: FlowyIconButton( - width: 18.0, - icon: const Icon( - Icons.arrow_right, - size: 18.0, - ), - onPressed: onCollapsed, - ), - ), - ), - - Flexible( - child: AppFlowyRichText( - key: forwardKey, - delegate: this, - node: widget.node, - editorState: editorState, - placeholderText: placeholderText, - lineHeight: 1.5, - textSpanDecorator: (textSpan) => textSpan.updateTextStyle( - textStyle, - ), - placeholderTextSpanDecorator: (textSpan) => - textSpan.updateTextStyle( - placeholderTextStyle, - ), - textDirection: textDirection, - textAlign: alignment?.toTextAlign, - cursorColor: editorState.editorStyle.cursorColor, - selectionColor: editorState.editorStyle.selectionColor, - ), - ), - ], - ), - ); - - child = Padding( - key: blockComponentKey, - padding: padding, - child: child, - ); + Widget child = _buildToggleBlock(); child = BlockSelectionContainer( node: node, @@ -221,10 +236,23 @@ class _ToggleListBlockComponentWidgetState 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!, + actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } @@ -232,11 +260,173 @@ class _ToggleListBlockComponentWidgetState 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: UniversalPlatform.isMobile + ? const EdgeInsets.symmetric(horizontal: 26.0) + : 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( + textStyleWithTextSpan(textSpan: textSpan), + ); + if (level != null) { + result = result.updateTextStyle( + widget.textStyleBuilder?.call(level), + ); + } + return result; + }, + placeholderTextSpanDecorator: (textSpan) { + var result = textSpan.updateTextStyle( + textStyleWithTextSpan(textSpan: textSpan), + ); + if (level != null && widget.textStyleBuilder != null) { + result = result.updateTextStyle( + widget.textStyleBuilder?.call(level), + ); + } + return result.updateTextStyle( + placeholderTextStyleWithTextSpan(textSpan: textSpan), + ); + }, + 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_shortcut_event.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcut_event.dart deleted file mode 100644 index db5eae3218..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcut_event.dart +++ /dev/null @@ -1,147 +0,0 @@ -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'; - -const _greater = '>'; - -/// Convert '> ' to toggle list -/// -/// - support -/// - desktop -/// - mobile -/// - web -/// -CharacterShortcutEvent formatGreaterToToggleList = CharacterShortcutEvent( - key: 'format greater to quote', - character: ' ', - handler: (editorState) async => formatMarkdownSymbol( - editorState, - (node) => node.type != ToggleListBlockKeys.type, - (_, text, __) => text == _greater, - (_, node, delta) => [ - toggleListBlockNode( - delta: delta.compose(Delta()..delete(_greater.length)), - ), - ], - ), -); - -/// 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), - paragraphNode(), - ], - ) - ..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 (PlatformExtension.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; -}; 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 0000000000..f3059bf1be --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart @@ -0,0 +1,303 @@ +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) { + await BlockActionOptionCubit.turnIntoSingleToggleHeading( + type: ToggleListBlockKeys.type, + selectedNodes: [node], + level: level, + delta: delta, + editorState: editorState, + 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 bool collapsed = + node.attributes[ToggleListBlockKeys.collapsed] ?? false; + 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/toolbar_item/custom_format_toolbar_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart new file mode 100644 index 0000000000..d4f3d21f46 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart @@ -0,0 +1,135 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/material.dart'; + +import 'custom_placeholder_toolbar_item.dart'; +import 'toolbar_id_enum.dart'; + +final List customMarkdownFormatItems = [ + _FormatToolbarItem( + id: ToolbarId.bold, + name: 'bold', + svg: FlowySvgs.toolbar_bold_m, + ), + group1PaddingItem, + _FormatToolbarItem( + id: ToolbarId.underline, + name: 'underline', + svg: FlowySvgs.toolbar_underline_m, + ), + group1PaddingItem, + _FormatToolbarItem( + id: ToolbarId.italic, + name: 'italic', + svg: FlowySvgs.toolbar_inline_italic_m, + ), +]; + +final ToolbarItem customInlineCodeItem = _FormatToolbarItem( + id: ToolbarId.code, + name: 'code', + svg: FlowySvgs.toolbar_inline_code_m, + group: 2, +); + +class _FormatToolbarItem extends ToolbarItem { + _FormatToolbarItem({ + required ToolbarId id, + required String name, + required FlowySvgData svg, + super.group = 1, + }) : super( + id: id.id, + isActive: showInAnyTextType, + builder: ( + context, + editorState, + highlightColor, + iconColor, + tooltipBuilder, + ) { + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighlight = nodes.allSatisfyInSelection( + selection, + (delta) => + delta.isNotEmpty && + delta.everyAttributes((attr) => attr[name] == true), + ); + + final hoverColor = isHighlight + ? highlightColor + : EditorStyleCustomizer.toolbarHoverColor(context); + final isDark = !Theme.of(context).isLightMode; + final theme = AppFlowyTheme.of(context); + + final child = FlowyIconButton( + width: 36, + height: 32, + hoverColor: hoverColor, + isSelected: isHighlight, + icon: FlowySvg( + svg, + size: Size.square(20.0), + color: (isDark && isHighlight) + ? Color(0xFF282E3A) + : theme.iconColorScheme.primary, + ), + onPressed: () => editorState.toggleAttribute( + name, + selection: selection, + ), + ); + + if (tooltipBuilder != null) { + return tooltipBuilder( + context, + id.id, + _getTooltipText(id), + child, + ); + } + return child; + }, + ); +} + +String _getTooltipText(ToolbarId id) { + switch (id) { + case ToolbarId.underline: + return '${LocaleKeys.toolbar_underline.tr()}${shortcutTooltips( + '⌘ + U', + 'CTRL + U', + 'CTRL + U', + )}'; + case ToolbarId.bold: + return '${LocaleKeys.toolbar_bold.tr()}${shortcutTooltips( + '⌘ + B', + 'CTRL + B', + 'CTRL + B', + )}'; + case ToolbarId.italic: + return '${LocaleKeys.toolbar_italic.tr()}${shortcutTooltips( + '⌘ + I', + 'CTRL + I', + 'CTRL + I', + )}'; + case ToolbarId.code: + return '${LocaleKeys.document_toolbar_inlineCode.tr()}${shortcutTooltips( + '⌘ + E', + 'CTRL + E', + 'CTRL + E', + )}'; + default: + return ''; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart new file mode 100644 index 0000000000..46f2c02c5a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart @@ -0,0 +1,227 @@ +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/desktop_toolbar/color_picker.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'toolbar_id_enum.dart'; + +String? _customHighlightColorHex; + +final customHighlightColorItem = ToolbarItem( + id: ToolbarId.highlightColor.id, + group: 1, + isActive: showInAnyTextType, + builder: (context, editorState, highlightColor, iconColor, tooltipBuilder) => + HighlightColorPickerWidget( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + highlightColor: highlightColor, + ), +); + +class HighlightColorPickerWidget extends StatefulWidget { + const HighlightColorPickerWidget({ + super.key, + required this.editorState, + this.tooltipBuilder, + required this.highlightColor, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + final Color highlightColor; + + @override + State createState() => + _HighlightColorPickerWidgetState(); +} + +class _HighlightColorPickerWidgetState + extends State { + final popoverController = PopoverController(); + + bool isSelected = false; + + EditorState get editorState => widget.editorState; + + Color get highlightColor => widget.highlightColor; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + } + + @override + Widget build(BuildContext context) { + if (editorState.selection == null) { + return const SizedBox.shrink(); + } + final selectionRectList = editorState.selectionRects(); + final top = + selectionRectList.isEmpty ? 0.0 : selectionRectList.first.height; + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: Offset(0, top), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + margin: EdgeInsets.zero, + popupBuilder: (context) => buildPopoverContent(), + child: buildChild(context), + ); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorScheme.primary; + + final child = FlowyIconButton( + width: 36, + height: 32, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: SizedBox( + width: 20, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.toolbar_text_highlight_m, + size: Size(20, 16), + color: iconColor, + ), + buildColorfulDivider(iconColor), + ], + ), + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ); + + return widget.tooltipBuilder?.call( + context, + ToolbarId.highlightColor.id, + AppFlowyEditorL10n.current.highlightColor, + child, + ) ?? + child; + } + + Widget buildColorfulDivider(Color? iconColor) { + final List colors = []; + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighLight = nodes.allSatisfyInSelection(selection, (delta) { + if (delta.everyAttributes((attr) => attr.isEmpty)) { + return false; + } + + return delta.everyAttributes((attr) { + final textColorHex = attr[AppFlowyRichTextKeys.backgroundColor]; + if (textColorHex != null) colors.add(textColorHex); + return (textColorHex != null); + }); + }); + + final colorLength = colors.length; + if (colors.isEmpty || !isHighLight) { + return Container( + width: 20, + height: 4, + color: iconColor, + ); + } + return SizedBox( + width: 20, + height: 4, + child: Row( + children: List.generate(colorLength, (index) { + final currentColor = int.tryParse(colors[index]); + return Container( + width: 20 / colorLength, + height: 4, + color: currentColor == null ? iconColor : Color(currentColor), + ); + }), + ), + ); + } + + Widget buildPopoverContent() { + final List colors = []; + + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighlight = nodes.allSatisfyInSelection(selection, (delta) { + if (delta.everyAttributes((attr) => attr.isEmpty)) { + return false; + } + + return delta.everyAttributes((attributes) { + final highlightColorHex = + attributes[AppFlowyRichTextKeys.backgroundColor]; + if (highlightColorHex != null) colors.add(highlightColorHex); + return highlightColorHex != null; + }); + }); + bool showClearButton = false; + nodes.allSatisfyInSelection(selection, (delta) { + if (!showClearButton) { + showClearButton = delta.whereType().any( + (element) { + return element.attributes?[AppFlowyRichTextKeys.backgroundColor] != + null; + }, + ); + } + return true; + }); + return MouseRegion( + child: ColorPicker( + title: AppFlowyEditorL10n.current.highlightColor, + showClearButton: showClearButton, + selectedColorHex: + (colors.length == 1 && isHighlight) ? colors.first : null, + customColorHex: _customHighlightColorHex, + colorOptions: generateHighlightColorOptions(), + onSubmittedColorHex: (color, isCustomColor) { + if (isCustomColor) { + _customHighlightColorHex = color; + } + formatHighlightColor( + editorState, + editorState.selection, + color, + withUpdateSelection: true, + ); + hidePopover(); + }, + resetText: AppFlowyEditorL10n.current.clearHighlightColor, + resetIconName: 'clear_highlight_color', + ), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + void hidePopover() { + popoverController.close(); + keepEditorFocusNotifier.decrease(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart new file mode 100644 index 0000000000..8c9e6b69da --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart @@ -0,0 +1,96 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'toolbar_id_enum.dart'; + +const kIsPageLink = 'is_page_link'; + +final customLinkItem = ToolbarItem( + id: ToolbarId.link.id, + group: 4, + isActive: (state) => + !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), + builder: (context, editorState, highlightColor, iconColor, tooltipBuilder) { + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHref = nodes.allSatisfyInSelection(selection, (delta) { + return delta.everyAttributes( + (attributes) => attributes[AppFlowyRichTextKeys.href] != null, + ); + }); + + final isDark = !Theme.of(context).isLightMode; + final hoverColor = isHref + ? highlightColor + : EditorStyleCustomizer.toolbarHoverColor(context); + final theme = AppFlowyTheme.of(context); + final child = FlowyIconButton( + width: 36, + height: 32, + hoverColor: hoverColor, + isSelected: isHref, + icon: FlowySvg( + FlowySvgs.toolbar_link_m, + size: Size.square(20.0), + color: (isDark && isHref) + ? Color(0xFF282E3A) + : theme.iconColorScheme.primary, + ), + onPressed: () { + getIt().hideToolbar(); + if (!isHref) { + final viewId = context.read()?.documentId ?? ''; + showLinkCreateMenu(context, editorState, selection, viewId); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + getIt() + .call(HoverTriggerKey(nodes.first.id, selection)); + }); + } + }, + ); + + if (tooltipBuilder != null) { + return tooltipBuilder( + context, + ToolbarId.highlightColor.id, + AppFlowyEditorL10n.current.link, + child, + ); + } + + return child; + }, +); + +extension AttributeExtension on Attributes { + bool get isPage { + if (this[kIsPageLink] is bool) { + return this[kIsPageLink]; + } + return false; + } +} + +enum LinkMenuAlignment { + topLeft, + topRight, + bottomLeft, + bottomRight, +} + +extension LinkMenuAlignmentExtension on LinkMenuAlignment { + bool get isTop => + this == LinkMenuAlignment.topLeft || this == LinkMenuAlignment.topRight; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart new file mode 100644 index 0000000000..e087731c82 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart @@ -0,0 +1,49 @@ +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'toolbar_id_enum.dart'; + +final ToolbarItem customPlaceholderItem = ToolbarItem( + id: ToolbarId.placeholder.id, + group: -1, + isActive: (editorState) => true, + builder: (context, __, ___, ____, _____) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 5), + child: Container( + width: 1, + color: Color(0xffE8ECF3).withAlpha(isDark ? 40 : 255), + ), + ); + }, +); + +ToolbarItem buildPaddingPlaceholderItem( + int group, { + bool Function(EditorState editorState)? isActive, +}) => + ToolbarItem( + id: ToolbarId.paddingPlaceHolder.id, + group: group, + isActive: isActive, + builder: (context, __, ___, ____, _____) => HSpace(4), + ); + +ToolbarItem group0PaddingItem = buildPaddingPlaceholderItem( + 0, + isActive: onlyShowInTextTypeAndExcludeTable, +); + +ToolbarItem group1PaddingItem = + buildPaddingPlaceholderItem(1, isActive: showInAnyTextType); + +ToolbarItem group4PaddingItem = buildPaddingPlaceholderItem( + 4, + isActive: (state) => + !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart new file mode 100644 index 0000000000..efaff532f4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart @@ -0,0 +1,215 @@ +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/toolbar_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'toolbar_id_enum.dart'; + +final ToolbarItem customTextAlignItem = ToolbarItem( + id: ToolbarId.textAlign.id, + group: 4, + isActive: (state) => + !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), + builder: ( + context, + editorState, + highlightColor, + iconColor, + tooltipBuilder, + ) { + return TextAlignActionList( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + highlightColor: highlightColor, + ); + }, +); + +class TextAlignActionList extends StatefulWidget { + const TextAlignActionList({ + super.key, + required this.editorState, + required this.highlightColor, + this.tooltipBuilder, + this.child, + this.onSelect, + this.popoverController, + this.popoverDirection = PopoverDirection.bottomWithLeftAligned, + this.showOffset = const Offset(0, 2), + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + final Color highlightColor; + final Widget? child; + final VoidCallback? onSelect; + final PopoverController? popoverController; + final PopoverDirection popoverDirection; + final Offset showOffset; + + @override + State createState() => _TextAlignActionListState(); +} + +class _TextAlignActionListState extends State { + late PopoverController popoverController = + widget.popoverController ?? PopoverController(); + + bool isSelected = false; + + EditorState get editorState => widget.editorState; + + Color get highlightColor => widget.highlightColor; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + direction: widget.popoverDirection, + offset: widget.showOffset, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + popupBuilder: (context) => buildPopoverContent(), + child: widget.child ?? buildChild(context), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorScheme.primary; + final child = FlowyIconButton( + width: 48, + height: 32, + isSelected: isSelected, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.toolbar_alignment_m, + size: Size.square(20), + color: iconColor, + ), + HSpace(4), + FlowySvg( + FlowySvgs.toolbar_arrow_down_m, + size: Size(12, 20), + color: iconColor, + ), + ], + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ); + + return widget.tooltipBuilder?.call( + context, + ToolbarId.textAlign.id, + LocaleKeys.document_toolbar_textAlign.tr(), + child, + ) ?? + child; + } + + Widget buildPopoverContent() { + return MouseRegion( + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(4.0), + children: List.generate(TextAlignCommand.values.length, (index) { + final command = TextAlignCommand.values[index]; + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighlight = nodes.every( + (n) => n.attributes[blockComponentAlign] == command.name, + ); + + return SizedBox( + height: 36, + child: FlowyButton( + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(command.svg), + iconPadding: 12, + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + rightIcon: + isHighlight ? FlowySvg(FlowySvgs.toolbar_check_m) : null, + onTap: () { + command.onAlignChanged(editorState); + widget.onSelect?.call(); + popoverController.close(); + }, + ), + ); + }), + ), + ); + } +} + +enum TextAlignCommand { + left(FlowySvgs.toolbar_text_align_left_m), + center(FlowySvgs.toolbar_text_align_center_m), + right(FlowySvgs.toolbar_text_align_right_m); + + const TextAlignCommand(this.svg); + + final FlowySvgData svg; + + String get title { + switch (this) { + case left: + return LocaleKeys.document_toolbar_alignLeft.tr(); + case center: + return LocaleKeys.document_toolbar_alignCenter.tr(); + case right: + return LocaleKeys.document_toolbar_alignRight.tr(); + } + } + + Future onAlignChanged(EditorState editorState) async { + final selection = editorState.selection!; + + await editorState.updateNode( + selection, + (node) => node.copyWith( + attributes: { + ...node.attributes, + blockComponentAlign: name, + }, + ), + selectionExtraInfo: { + selectionExtraInfoDoNotAttachTextService: true, + selectionExtraInfoDisableFloatingToolbar: true, + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart new file mode 100644 index 0000000000..9f5a917b89 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart @@ -0,0 +1,226 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'toolbar_id_enum.dart'; + +String? _customColorHex; + +final customTextColorItem = ToolbarItem( + id: ToolbarId.textColor.id, + group: 1, + isActive: showInAnyTextType, + builder: (context, editorState, highlightColor, iconColor, tooltipBuilder) => + TextColorPickerWidget( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + highlightColor: highlightColor, + ), +); + +class TextColorPickerWidget extends StatefulWidget { + const TextColorPickerWidget({ + super.key, + required this.editorState, + this.tooltipBuilder, + required this.highlightColor, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + final Color highlightColor; + + @override + State createState() => _TextColorPickerWidgetState(); +} + +class _TextColorPickerWidgetState extends State { + final popoverController = PopoverController(); + + bool isSelected = false; + + EditorState get editorState => widget.editorState; + + Color get highlightColor => widget.highlightColor; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + } + + @override + Widget build(BuildContext context) { + if (editorState.selection == null) { + return const SizedBox.shrink(); + } + final selectionRectList = editorState.selectionRects(); + final top = + selectionRectList.isEmpty ? 0.0 : selectionRectList.first.height; + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: Offset(0, top), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + margin: EdgeInsets.zero, + popupBuilder: (context) => buildPopoverContent(), + child: buildChild(context), + ); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorScheme.primary; + final child = FlowyIconButton( + width: 36, + height: 32, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: SizedBox( + width: 20, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.toolbar_text_color_m, + size: Size(20, 16), + color: iconColor, + ), + buildColorfulDivider(iconColor), + ], + ), + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ); + + return widget.tooltipBuilder?.call( + context, + ToolbarId.textColor.id, + LocaleKeys.document_toolbar_textColor.tr(), + child, + ) ?? + child; + } + + Widget buildColorfulDivider(Color? iconColor) { + final List colors = []; + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighLight = nodes.allSatisfyInSelection(selection, (delta) { + if (delta.everyAttributes((attr) => attr.isEmpty)) { + return false; + } + + return delta.everyAttributes((attr) { + final textColorHex = attr[AppFlowyRichTextKeys.textColor]; + if (textColorHex != null) colors.add(textColorHex); + return (textColorHex != null); + }); + }); + + final colorLength = colors.length; + if (colors.isEmpty || !isHighLight) { + return Container( + width: 20, + height: 4, + color: iconColor, + ); + } + return SizedBox( + width: 20, + height: 4, + child: Row( + children: List.generate(colorLength, (index) { + final currentColor = int.tryParse(colors[index]); + return Container( + width: 20 / colorLength, + height: 4, + color: currentColor == null ? iconColor : Color(currentColor), + ); + }), + ), + ); + } + + Widget buildPopoverContent() { + bool showClearButton = false; + final List colors = []; + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighLight = nodes.allSatisfyInSelection(selection, (delta) { + if (delta.everyAttributes((attr) => attr.isEmpty)) { + return false; + } + + return delta.everyAttributes((attr) { + final textColorHex = attr[AppFlowyRichTextKeys.textColor]; + if (textColorHex != null) colors.add(textColorHex); + return (textColorHex != null); + }); + }); + nodes.allSatisfyInSelection( + selection, + (delta) { + if (!showClearButton) { + showClearButton = delta.whereType().any( + (element) { + return element.attributes?[AppFlowyRichTextKeys.textColor] != + null; + }, + ); + } + return true; + }, + ); + return MouseRegion( + child: ColorPicker( + title: LocaleKeys.document_toolbar_textColor.tr(), + showClearButton: showClearButton, + selectedColorHex: + (colors.length == 1 && isHighLight) ? colors.first : null, + customColorHex: _customColorHex, + colorOptions: generateTextColorOptions(), + onSubmittedColorHex: (color, isCustomColor) { + if (isCustomColor) { + _customColorHex = color; + } + formatFontColor( + editorState, + editorState.selection, + color, + withUpdateSelection: true, + ); + hidePopover(); + }, + resetText: AppFlowyEditorL10n.current.resetToDefaultColor, + resetIconName: 'reset_text_color', + ), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + void hidePopover() { + popoverController.close(); + keepEditorFocusNotifier.decrease(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart new file mode 100644 index 0000000000..46b707a8d3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart @@ -0,0 +1,455 @@ +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_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +// ignore: implementation_imports +import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.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 'custom_text_align_toolbar_item.dart'; +import 'text_suggestions_toolbar_item.dart'; + +const _kMoreOptionItemId = 'editor.more_option'; +const kFontToolbarItemId = 'editor.font'; + +@visibleForTesting +const kFontFamilyToolbarItemKey = ValueKey('FontFamilyToolbarItem'); + +final ToolbarItem moreOptionItem = ToolbarItem( + id: _kMoreOptionItemId, + group: 5, + isActive: showInAnyTextType, + builder: ( + context, + editorState, + highlightColor, + iconColor, + tooltipBuilder, + ) { + return MoreOptionActionList( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + highlightColor: highlightColor, + ); + }, +); + +class MoreOptionActionList extends StatefulWidget { + const MoreOptionActionList({ + super.key, + required this.editorState, + required this.highlightColor, + this.tooltipBuilder, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + final Color highlightColor; + + @override + State createState() => _MoreOptionActionListState(); +} + +class _MoreOptionActionListState extends State { + final popoverController = PopoverController(); + PopoverController fontPopoverController = PopoverController(); + PopoverController suggestionsPopoverController = PopoverController(); + PopoverController textAlignPopoverController = PopoverController(); + + bool isSelected = false; + + EditorState get editorState => widget.editorState; + + Color get highlightColor => widget.highlightColor; + + MoreOptionCommand? tappedCommand; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + fontPopoverController.close(); + suggestionsPopoverController.close(); + textAlignPopoverController.close(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 2.0), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + popupBuilder: (context) => buildPopoverContent(), + child: buildChild(context), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + Widget buildChild(BuildContext context) { + final iconColor = Theme.of(context).iconTheme.color; + final child = FlowyIconButton( + width: 36, + height: 32, + isSelected: isSelected, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: FlowySvg( + FlowySvgs.toolbar_more_m, + size: Size.square(20), + color: iconColor, + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ); + + return widget.tooltipBuilder?.call( + context, + _kMoreOptionItemId, + LocaleKeys.document_toolbar_moreOptions.tr(), + child, + ) ?? + child; + } + + Color? getFormulaColor() { + if (isFormulaHighlight(editorState)) { + return widget.highlightColor; + } + return null; + } + + Color? getStrikethroughColor() { + final selection = editorState.selection; + if (selection == null || selection.isCollapsed) { + return null; + } + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + return null; + } + + final nodes = editorState.getNodesInSelection(selection); + final isHighlight = nodes.allSatisfyInSelection( + selection, + (delta) => + delta.isNotEmpty && + delta.everyAttributes( + (attr) => attr[MoreOptionCommand.strikethrough.name] == true, + ), + ); + return isHighlight ? widget.highlightColor : null; + } + + Widget buildPopoverContent() { + final showFormula = onlyShowInSingleSelectionAndTextType(editorState); + const fontColor = Color(0xff99A1A8); + final isNarrow = isNarrowWindow(editorState); + return MouseRegion( + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(4.0), + children: [ + if (isNarrow) ...[ + buildTurnIntoSelector(), + buildCommandItem(MoreOptionCommand.link), + buildTextAlignSelector(), + ], + buildFontSelector(), + buildCommandItem( + MoreOptionCommand.strikethrough, + rightIcon: FlowyText( + shortcutTooltips( + '⌘⇧S', + 'Ctrl⇧S', + 'Ctrl⇧S', + ).trim(), + color: fontColor, + fontSize: 12, + figmaLineHeight: 16, + fontWeight: FontWeight.w400, + ), + ), + if (showFormula) + buildCommandItem( + MoreOptionCommand.formula, + rightIcon: FlowyText( + shortcutTooltips( + '⌘⇧E', + 'Ctrl⇧E', + 'Ctrl⇧E', + ).trim(), + color: fontColor, + fontSize: 12, + figmaLineHeight: 16, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ); + } + + Widget buildCommandItem( + MoreOptionCommand command, { + Widget? rightIcon, + VoidCallback? onTap, + }) { + final isFontCommand = command == MoreOptionCommand.font; + return SizedBox( + height: 36, + child: FlowyButton( + key: isFontCommand ? kFontFamilyToolbarItemKey : null, + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(command.svg), + rightIcon: rightIcon, + iconPadding: 12, + text: FlowyText( + command.title, + figmaLineHeight: 20, + fontWeight: FontWeight.w400, + ), + onTap: onTap ?? + () { + command.onExecute(editorState, context); + hideOtherPopovers(command); + if (command != MoreOptionCommand.font) { + popoverController.close(); + } + }, + ), + ); + } + + Widget buildFontSelector() { + final selection = editorState.selection!; + final String? currentFontFamily = editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.fontFamily); + return FontFamilyDropDown( + currentFontFamily: currentFontFamily ?? '', + offset: const Offset(-240, 0), + popoverController: fontPopoverController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + onFontFamilyChanged: (fontFamily) async { + fontPopoverController.close(); + popoverController.close(); + try { + await editorState.formatDelta(selection, { + AppFlowyRichTextKeys.fontFamily: fontFamily, + }); + } catch (e) { + Log.error('Failed to set font family: $e'); + } + }, + onResetFont: () async { + fontPopoverController.close(); + popoverController.close(); + await editorState + .formatDelta(selection, {AppFlowyRichTextKeys.fontFamily: null}); + }, + child: buildCommandItem( + MoreOptionCommand.font, + rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), + ), + ); + } + + Widget buildTurnIntoSelector() { + final selectionRects = editorState.selectionRects(); + double height = -6; + if (selectionRects.isNotEmpty) height = selectionRects.first.height; + return SuggestionsActionList( + editorState: editorState, + popoverController: suggestionsPopoverController, + popoverDirection: PopoverDirection.leftWithTopAligned, + showOffset: Offset(-8, height), + onSelect: () => getIt().hideToolbar(), + child: buildCommandItem( + MoreOptionCommand.suggestions, + rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), + onTap: () { + if (tappedCommand == MoreOptionCommand.suggestions) return; + hideOtherPopovers(MoreOptionCommand.suggestions); + keepEditorFocusNotifier.increase(); + suggestionsPopoverController.show(); + }, + ), + ); + } + + Widget buildTextAlignSelector() { + return TextAlignActionList( + editorState: editorState, + popoverController: textAlignPopoverController, + popoverDirection: PopoverDirection.leftWithTopAligned, + showOffset: Offset(-8, 0), + onSelect: () => getIt().hideToolbar(), + highlightColor: highlightColor, + child: buildCommandItem( + MoreOptionCommand.textAlign, + rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), + onTap: () { + if (tappedCommand == MoreOptionCommand.textAlign) return; + hideOtherPopovers(MoreOptionCommand.textAlign); + keepEditorFocusNotifier.increase(); + textAlignPopoverController.show(); + }, + ), + ); + } + + void hideOtherPopovers(MoreOptionCommand currentCommand) { + if (tappedCommand == currentCommand) return; + if (tappedCommand == MoreOptionCommand.font) { + fontPopoverController.close(); + fontPopoverController = PopoverController(); + } else if (tappedCommand == MoreOptionCommand.suggestions) { + suggestionsPopoverController.close(); + suggestionsPopoverController = PopoverController(); + } else if (tappedCommand == MoreOptionCommand.textAlign) { + textAlignPopoverController.close(); + textAlignPopoverController = PopoverController(); + } + tappedCommand = currentCommand; + } +} + +enum MoreOptionCommand { + suggestions(FlowySvgs.turninto_s), + link(FlowySvgs.toolbar_link_m), + textAlign( + FlowySvgs.toolbar_alignment_m, + ), + font(FlowySvgs.type_font_m), + strikethrough(FlowySvgs.type_strikethrough_m), + formula(FlowySvgs.type_formula_m); + + const MoreOptionCommand(this.svg); + + final FlowySvgData svg; + + String get title { + switch (this) { + case suggestions: + return LocaleKeys.document_toolbar_turnInto.tr(); + case link: + return LocaleKeys.document_toolbar_link.tr(); + case textAlign: + return LocaleKeys.button_align.tr(); + case font: + return LocaleKeys.document_toolbar_font.tr(); + case strikethrough: + return LocaleKeys.editor_strikethrough.tr(); + case formula: + return LocaleKeys.document_toolbar_equation.tr(); + } + } + + Future onExecute(EditorState editorState, BuildContext context) async { + final selection = editorState.selection!; + if (this == link) { + final nodes = editorState.getNodesInSelection(selection); + final isHref = nodes.allSatisfyInSelection(selection, (delta) { + return delta.everyAttributes( + (attributes) => attributes[AppFlowyRichTextKeys.href] != null, + ); + }); + getIt().hideToolbar(); + if (isHref) { + getIt().call( + HoverTriggerKey(nodes.first.id, selection), + ); + } else { + final viewId = context.read()?.documentId ?? ''; + showLinkCreateMenu(context, editorState, selection, viewId); + } + } else if (this == strikethrough) { + await editorState.toggleAttribute(name); + } else if (this == formula) { + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + final transaction = editorState.transaction; + final isHighlight = isFormulaHighlight(editorState); + 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); + } + } +} + +bool isFormulaHighlight(EditorState editorState) { + 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 || delta == null) { + return false; + } + + final nodes = editorState.getNodesInSelection(selection); + return nodes.allSatisfyInSelection(selection, (delta) { + return delta.everyAttributes( + (attributes) => attributes[InlineMathEquationKeys.formula] != null, + ); + }); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart new file mode 100644 index 0000000000..5778b6b8a4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart @@ -0,0 +1,247 @@ +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/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'toolbar_id_enum.dart'; + +final ToolbarItem customTextHeadingItem = ToolbarItem( + id: ToolbarId.textHeading.id, + group: 1, + isActive: onlyShowInSingleTextTypeSelectionAndExcludeTable, + builder: ( + context, + editorState, + highlightColor, + iconColor, + tooltipBuilder, + ) { + return TextHeadingActionList( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + ); + }, +); + +class TextHeadingActionList extends StatefulWidget { + const TextHeadingActionList({ + super.key, + required this.editorState, + this.tooltipBuilder, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + + @override + State createState() => _TextHeadingActionListState(); +} + +class _TextHeadingActionListState extends State { + final popoverController = PopoverController(); + + bool isSelected = false; + + @override + void dispose() { + super.dispose(); + popoverController.close(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 2.0), + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + popupBuilder: (context) => buildPopoverContent(), + child: buildChild(context), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorScheme.primary; + final child = FlowyIconButton( + width: 48, + height: 32, + isSelected: isSelected, + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.toolbar_text_format_m, + size: Size.square(20), + color: iconColor, + ), + HSpace(4), + FlowySvg( + FlowySvgs.toolbar_arrow_down_m, + size: Size(12, 20), + color: iconColor, + ), + ], + ), + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + ); + + return widget.tooltipBuilder?.call( + context, + ToolbarId.textHeading.id, + LocaleKeys.document_toolbar_textSize.tr(), + child, + ) ?? + child; + } + + Widget buildPopoverContent() { + final selectingCommand = getSelectingCommand(); + return MouseRegion( + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(4.0), + children: List.generate(TextHeadingCommand.values.length, (index) { + final command = TextHeadingCommand.values[index]; + return SizedBox( + height: 36, + child: FlowyButton( + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(command.svg), + iconPadding: 12, + text: FlowyText( + command.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + rightIcon: selectingCommand == command + ? FlowySvg(FlowySvgs.toolbar_check_m) + : null, + onTap: () { + if (command == selectingCommand) return; + command.onExecute(widget.editorState); + popoverController.close(); + }, + ), + ); + }), + ), + ); + } + + TextHeadingCommand? getSelectingCommand() { + final editorState = widget.editorState; + final selection = editorState.selection; + if (selection == null || !selection.isSingle) { + return null; + } + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.delta == null) { + return null; + } + final nodeType = node.type; + if (nodeType == ParagraphBlockKeys.type) return TextHeadingCommand.text; + if (nodeType == HeadingBlockKeys.type) { + final level = node.attributes[HeadingBlockKeys.level] ?? 1; + if (level == 1) return TextHeadingCommand.h1; + if (level == 2) return TextHeadingCommand.h2; + if (level == 3) return TextHeadingCommand.h3; + } + return null; + } +} + +enum TextHeadingCommand { + text(FlowySvgs.type_text_m), + h1(FlowySvgs.type_h1_m), + h2(FlowySvgs.type_h2_m), + h3(FlowySvgs.type_h3_m); + + const TextHeadingCommand(this.svg); + + final FlowySvgData svg; + + String get title { + switch (this) { + case text: + return AppFlowyEditorL10n.current.text; + case h1: + return LocaleKeys.document_toolbar_h1.tr(); + case h2: + return LocaleKeys.document_toolbar_h2.tr(); + case h3: + return LocaleKeys.document_toolbar_h3.tr(); + } + } + + void onExecute(EditorState state) { + switch (this) { + case text: + formatNodeToText(state); + break; + case h1: + _turnInto(state, 1); + break; + case h2: + _turnInto(state, 2); + break; + case h3: + _turnInto(state, 3); + break; + } + } + + Future _turnInto(EditorState state, int level) async { + final selection = state.selection!; + final node = state.getNodeAtPath(selection.start.path)!; + await BlockActionOptionCubit.turnIntoBlock( + HeadingBlockKeys.type, + node, + state, + level: level, + keepSelection: true, + ); + } +} + +void formatNodeToText(EditorState editorState) { + final selection = editorState.selection!; + final node = editorState.getNodeAtPath(selection.start.path)!; + final delta = (node.delta ?? Delta()).toJson(); + editorState.formatNode( + selection, + (node) => node.copyWith( + type: ParagraphBlockKeys.type, + attributes: { + blockComponentDelta: delta, + blockComponentBackgroundColor: + node.attributes[blockComponentBackgroundColor], + blockComponentTextDirection: + node.attributes[blockComponentTextDirection], + }, + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart new file mode 100644 index 0000000000..48f5d3f403 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart @@ -0,0 +1,536 @@ +import 'dart:collection'; +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/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; +import 'package:appflowy_ui/appflowy_ui.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 'text_heading_toolbar_item.dart'; +import 'toolbar_id_enum.dart'; + +@visibleForTesting +const kSuggestionsItemKey = ValueKey('SuggestionsItem'); + +@visibleForTesting +const kSuggestionsItemListKey = ValueKey('SuggestionsItemList'); + +final ToolbarItem suggestionsItem = ToolbarItem( + id: ToolbarId.suggestions.id, + group: 3, + isActive: enableSuggestions, + builder: ( + context, + editorState, + highlightColor, + iconColor, + tooltipBuilder, + ) { + return SuggestionsActionList( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + ); + }, +); + +class SuggestionsActionList extends StatefulWidget { + const SuggestionsActionList({ + super.key, + required this.editorState, + this.tooltipBuilder, + this.child, + this.onSelect, + this.popoverController, + this.popoverDirection = PopoverDirection.bottomWithLeftAligned, + this.showOffset = const Offset(0, 2), + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + final Widget? child; + final VoidCallback? onSelect; + final PopoverController? popoverController; + final PopoverDirection popoverDirection; + final Offset showOffset; + + @override + State createState() => _SuggestionsActionListState(); +} + +class _SuggestionsActionListState extends State { + late PopoverController popoverController = + widget.popoverController ?? PopoverController(); + + bool isSelected = false; + + final List suggestionItems = suggestions.sublist(0, 4); + final List turnIntoItems = + suggestions.sublist(4, suggestions.length); + + EditorState get editorState => widget.editorState; + + SuggestionItem currentSuggestionItem = textSuggestionItem; + + @override + void initState() { + super.initState(); + refreshSuggestions(); + editorState.selectionNotifier.addListener(refreshSuggestions); + } + + @override + void dispose() { + editorState.selectionNotifier.removeListener(refreshSuggestions); + popoverController.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + direction: widget.popoverDirection, + offset: widget.showOffset, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () { + setState(() { + isSelected = false; + }); + keepEditorFocusNotifier.decrease(); + }, + constraints: const BoxConstraints(maxWidth: 240, maxHeight: 400), + popupBuilder: (context) => buildPopoverContent(context), + child: widget.child ?? buildChild(context), + ); + } + + void showPopover() { + keepEditorFocusNotifier.increase(); + popoverController.show(); + } + + Widget buildChild(BuildContext context) { + final theme = AppFlowyTheme.of(context), + iconColor = theme.iconColorScheme.primary; + final child = FlowyHover( + isSelected: () => isSelected, + style: HoverStyle( + hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), + foregroundColorOnHover: Theme.of(context).iconTheme.color, + ), + resetHoverOnRebuild: false, + child: FlowyTooltip( + preferBelow: true, + child: RawMaterialButton( + key: kSuggestionsItemKey, + constraints: BoxConstraints(maxHeight: 32, minWidth: 60), + clipBehavior: Clip.antiAlias, + hoverElevation: 0, + highlightElevation: 0, + shape: RoundedRectangleBorder(borderRadius: Corners.s6Border), + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + focusColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + elevation: 0, + onPressed: () { + setState(() { + isSelected = true; + }); + showPopover(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + currentSuggestionItem.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + HSpace(4), + FlowySvg( + FlowySvgs.toolbar_arrow_down_m, + size: Size(12, 20), + color: iconColor, + ), + ], + ), + ), + ), + ), + ); + + return widget.tooltipBuilder?.call( + context, + ToolbarId.suggestions.id, + currentSuggestionItem.title, + child, + ) ?? + child; + } + + Widget buildPopoverContent(BuildContext context) { + final textColor = Color(0xff99A1A8); + return MouseRegion( + child: SingleChildScrollView( + key: kSuggestionsItemListKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildSubTitle( + LocaleKeys.document_toolbar_suggestions.tr(), + textColor, + ), + ...List.generate(suggestionItems.length, (index) { + return buildItem(suggestionItems[index]); + }), + buildSubTitle(LocaleKeys.document_toolbar_turnInto.tr(), textColor), + ...List.generate(turnIntoItems.length, (index) { + return buildItem(turnIntoItems[index]); + }), + ], + ), + ), + ); + } + + Widget buildItem(SuggestionItem item) { + final isSelected = item.type == currentSuggestionItem.type; + return SizedBox( + height: 36, + child: FlowyButton( + leftIconSize: const Size.square(20), + leftIcon: FlowySvg(item.svg), + iconPadding: 12, + text: FlowyText( + item.title, + fontWeight: FontWeight.w400, + figmaLineHeight: 20, + ), + rightIcon: isSelected ? FlowySvg(FlowySvgs.toolbar_check_m) : null, + onTap: () { + item.onTap(widget.editorState, true); + widget.onSelect?.call(); + popoverController.close(); + }, + ), + ); + } + + Widget buildSubTitle(String text, Color color) { + return Container( + height: 32, + margin: EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: Alignment.centerLeft, + child: FlowyText.semibold( + text, + color: color, + figmaLineHeight: 16, + ), + ), + ); + } + + void refreshSuggestions() { + final selection = editorState.selection; + if (selection == null || !selection.isSingle) { + return; + } + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.delta == null) { + return; + } + final nodeType = node.type; + SuggestionType? suggestionType; + if (nodeType == HeadingBlockKeys.type) { + final level = node.attributes[HeadingBlockKeys.level] ?? 1; + if (level == 1) { + suggestionType = SuggestionType.h1; + } else if (level == 2) { + suggestionType = SuggestionType.h2; + } else if (level == 3) { + suggestionType = SuggestionType.h3; + } + } else if (nodeType == ToggleListBlockKeys.type) { + final level = node.attributes[ToggleListBlockKeys.level]; + if (level == null) { + suggestionType = SuggestionType.toggle; + } else if (level == 1) { + suggestionType = SuggestionType.toggleH1; + } else if (level == 2) { + suggestionType = SuggestionType.toggleH2; + } else if (level == 3) { + suggestionType = SuggestionType.toggleH3; + } + } else { + suggestionType = nodeType2SuggestionType[nodeType]; + } + if (suggestionType == null) return; + suggestionItems.clear(); + turnIntoItems.clear(); + for (final item in suggestions) { + if (item.type.group == suggestionType.group && + item.type != suggestionType) { + suggestionItems.add(item); + } else { + turnIntoItems.add(item); + } + } + currentSuggestionItem = + suggestions.where((item) => item.type == suggestionType).first; + if (mounted) setState(() {}); + } +} + +class SuggestionItem { + SuggestionItem({ + required this.type, + required this.title, + required this.svg, + required this.onTap, + }); + + final SuggestionType type; + final String title; + final FlowySvgData svg; + final Function(EditorState state, bool keepSelection) onTap; +} + +enum SuggestionGroup { textHeading, list, toggle, quote, page } + +enum SuggestionType { + text(SuggestionGroup.textHeading), + h1(SuggestionGroup.textHeading), + h2(SuggestionGroup.textHeading), + h3(SuggestionGroup.textHeading), + checkbox(SuggestionGroup.list), + bulleted(SuggestionGroup.list), + numbered(SuggestionGroup.list), + toggle(SuggestionGroup.toggle), + toggleH1(SuggestionGroup.toggle), + toggleH2(SuggestionGroup.toggle), + toggleH3(SuggestionGroup.toggle), + callOut(SuggestionGroup.quote), + quote(SuggestionGroup.quote), + page(SuggestionGroup.page); + + const SuggestionType(this.group); + + final SuggestionGroup group; +} + +final textSuggestionItem = SuggestionItem( + type: SuggestionType.text, + title: AppFlowyEditorL10n.current.text, + svg: FlowySvgs.type_text_m, + onTap: (state, _) => formatNodeToText(state), +); + +final h1SuggestionItem = SuggestionItem( + type: SuggestionType.h1, + title: LocaleKeys.document_toolbar_h1.tr(), + svg: FlowySvgs.type_h1_m, + onTap: (state, keepSelection) => _turnInto( + state, + HeadingBlockKeys.type, + level: 1, + keepSelection: keepSelection, + ), +); + +final h2SuggestionItem = SuggestionItem( + type: SuggestionType.h2, + title: LocaleKeys.document_toolbar_h2.tr(), + svg: FlowySvgs.type_h2_m, + onTap: (state, keepSelection) => _turnInto( + state, + HeadingBlockKeys.type, + level: 2, + keepSelection: keepSelection, + ), +); + +final h3SuggestionItem = SuggestionItem( + type: SuggestionType.h3, + title: LocaleKeys.document_toolbar_h3.tr(), + svg: FlowySvgs.type_h3_m, + onTap: (state, keepSelection) => _turnInto( + state, + HeadingBlockKeys.type, + level: 3, + keepSelection: keepSelection, + ), +); + +final checkboxSuggestionItem = SuggestionItem( + type: SuggestionType.checkbox, + title: LocaleKeys.editor_checkbox.tr(), + svg: FlowySvgs.type_todo_m, + onTap: (state, keepSelection) => _turnInto( + state, + TodoListBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final bulletedSuggestionItem = SuggestionItem( + type: SuggestionType.bulleted, + title: LocaleKeys.editor_bulletedListShortForm.tr(), + svg: FlowySvgs.type_bulleted_list_m, + onTap: (state, keepSelection) => _turnInto( + state, + BulletedListBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final numberedSuggestionItem = SuggestionItem( + type: SuggestionType.numbered, + title: LocaleKeys.editor_numberedListShortForm.tr(), + svg: FlowySvgs.type_numbered_list_m, + onTap: (state, keepSelection) => _turnInto( + state, + NumberedListBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final toggleSuggestionItem = SuggestionItem( + type: SuggestionType.toggle, + title: LocaleKeys.editor_toggleListShortForm.tr(), + svg: FlowySvgs.type_toggle_list_m, + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final toggleH1SuggestionItem = SuggestionItem( + type: SuggestionType.toggleH1, + title: LocaleKeys.editor_toggleHeading1ShortForm.tr(), + svg: FlowySvgs.type_toggle_h1_m, + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + level: 1, + keepSelection: keepSelection, + ), +); + +final toggleH2SuggestionItem = SuggestionItem( + type: SuggestionType.toggleH2, + title: LocaleKeys.editor_toggleHeading2ShortForm.tr(), + svg: FlowySvgs.type_toggle_h2_m, + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + level: 2, + keepSelection: keepSelection, + ), +); + +final toggleH3SuggestionItem = SuggestionItem( + type: SuggestionType.toggleH3, + title: LocaleKeys.editor_toggleHeading3ShortForm.tr(), + svg: FlowySvgs.type_toggle_h3_m, + onTap: (state, keepSelection) => _turnInto( + state, + ToggleListBlockKeys.type, + level: 3, + keepSelection: keepSelection, + ), +); + +final callOutSuggestionItem = SuggestionItem( + type: SuggestionType.callOut, + title: LocaleKeys.document_plugins_callout.tr(), + svg: FlowySvgs.type_callout_m, + onTap: (state, keepSelection) => _turnInto( + state, + CalloutBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final quoteSuggestionItem = SuggestionItem( + type: SuggestionType.quote, + title: LocaleKeys.editor_quote.tr(), + svg: FlowySvgs.type_quote_m, + onTap: (state, keepSelection) => _turnInto( + state, + QuoteBlockKeys.type, + keepSelection: keepSelection, + ), +); + +final pateItem = SuggestionItem( + type: SuggestionType.page, + title: LocaleKeys.editor_page.tr(), + svg: FlowySvgs.icon_document_s, + onTap: (state, keepSelection) => _turnInto( + state, + SubPageBlockKeys.type, + viewId: getIt().latestOpenView?.id, + keepSelection: keepSelection, + ), +); + +Future _turnInto( + EditorState state, + String type, { + int? level, + String? viewId, + bool keepSelection = true, +}) async { + final selection = state.selection!; + final node = state.getNodeAtPath(selection.start.path)!; + await BlockActionOptionCubit.turnIntoBlock( + type, + node, + state, + level: level, + currentViewId: viewId, + keepSelection: keepSelection, + ); +} + +final suggestions = UnmodifiableListView([ + textSuggestionItem, + h1SuggestionItem, + h2SuggestionItem, + h3SuggestionItem, + checkboxSuggestionItem, + bulletedSuggestionItem, + numberedSuggestionItem, + toggleSuggestionItem, + toggleH1SuggestionItem, + toggleH2SuggestionItem, + toggleH3SuggestionItem, + callOutSuggestionItem, + quoteSuggestionItem, + pateItem, +]); + +final nodeType2SuggestionType = UnmodifiableMapView({ + ParagraphBlockKeys.type: SuggestionType.text, + NumberedListBlockKeys.type: SuggestionType.numbered, + BulletedListBlockKeys.type: SuggestionType.bulleted, + QuoteBlockKeys.type: SuggestionType.quote, + TodoListBlockKeys.type: SuggestionType.checkbox, + CalloutBlockKeys.type: SuggestionType.callOut, +}); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/toolbar_id_enum.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/toolbar_id_enum.dart new file mode 100644 index 0000000000..8a97bb6648 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/toolbar_id_enum.dart @@ -0,0 +1,19 @@ +enum ToolbarId { + bold, + underline, + italic, + code, + highlightColor, + textColor, + link, + placeholder, + paddingPlaceHolder, + textAlign, + moreOption, + textHeading, + suggestions, +} + +extension ToolbarIdExtension on ToolbarId { + String get id => 'editor.$name'; +} 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 0000000000..34b0bdc8f9 --- /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 0000000000..243532e8ce --- /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 0000000000..b56066ae8b --- /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 0000000000..d08ce05510 --- /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 0000000000..36ea3d2704 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart @@ -0,0 +1,57 @@ +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Undo +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent customUndoCommand = CommandShortcutEvent( + key: 'undo', + getDescription: () => AppFlowyEditorL10n.current.cmdUndo, + command: 'ctrl+z', + macOSCommand: 'cmd+z', + handler: (editorState) { + final context = editorState.document.root.context; + if (context == null) { + return KeyEventResult.ignored; + } + final editorContext = context.read(); + if (editorContext.coverTitleFocusNode.hasFocus) { + 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) { + final context = editorState.document.root.context; + if (context == null) { + return KeyEventResult.ignored; + } + final editorContext = context.read(); + if (editorContext.coverTitleFocusNode.hasFocus) { + return KeyEventResult.ignored; + } + + EditorNotification.redo().post(); + return KeyEventResult.handled; + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/video/video_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/video/video_block_component.dart new file mode 100644 index 0000000000..f41d4526ea --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/video/video_block_component.dart @@ -0,0 +1,6 @@ +class VideoBlockKeys { + const VideoBlockKeys._(); + + static const String type = 'video'; + static const String url = 'url'; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index cdf2dcfffd..3664c9aee7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -1,62 +1,112 @@ -import 'dart:math'; +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/mention/mention_block.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/util/theme_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' hide Log; +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'; + +import 'editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'editor_plugins/toolbar_item/more_option_toolbar_item.dart'; class EditorStyleCustomizer { - EditorStyleCustomizer({required this.context, required this.padding}); + EditorStyleCustomizer({ + required this.context, + required this.padding, + this.width, + this.editorState, + }); final BuildContext context; final EdgeInsets padding; + final double? width; + final EditorState? editorState; + + 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; + + static Color? toolbarHoverColor(BuildContext context) { + return Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.secondary + : AFThemeExtension.of(context).toolbarHoverColor; + } EditorStyle style() { - if (PlatformExtension.isDesktopOrWeb) { + if (UniversalPlatform.isDesktopOrWeb) { return desktop(); - } else if (PlatformExtension.isMobile) { + } else if (UniversalPlatform.isMobile) { return mobile(); } throw UnimplementedError(); } - static EdgeInsets get documentPadding => PlatformExtension.isMobile - ? const EdgeInsets.only(left: 24, right: 24) - : const EdgeInsets.only(left: 40, right: 40 + 44); - 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; - final fontFamily = appearance.fontFamily; + String fontFamily = appearance.fontFamily; + if (fontFamily.isEmpty && appearanceFont.isNotEmpty) { + fontFamily = appearanceFont; + } + + final cursorColor = (editorState?.editable ?? true) + ? (appearance.cursorColor ?? + DefaultAppearanceSettings.getDefaultCursorColor(context)) + : Colors.transparent; return EditorStyle.desktop( padding: padding, - cursorColor: appearance.cursorColor ?? - DefaultAppearanceSettings.getDefaultDocumentCursorColor(context), + maxWidth: width, + cursorColor: cursorColor, selectionColor: appearance.selectionColor ?? - DefaultAppearanceSettings.getDefaultDocumentSelectionColor(context), + DefaultAppearanceSettings.getDefaultSelectionColor(context), defaultTextDirection: appearance.defaultTextDirection, textStyleConfiguration: TextStyleConfiguration( + lineHeight: 1.4, + applyHeightToFirstAscent: true, + applyHeightToLastDescent: true, text: baseTextStyle(fontFamily).copyWith( fontSize: fontSize, - color: theme.colorScheme.onBackground, - height: 1.5, + color: afThemeExtension.onBackground, ), bold: baseTextStyle(fontFamily, fontWeight: FontWeight.bold).copyWith( fontWeight: FontWeight.w600, @@ -74,37 +124,43 @@ class EditorStyleCustomizer { ), code: GoogleFonts.robotoMono( textStyle: baseTextStyle(fontFamily).copyWith( - fontSize: fontSize - 2, + fontSize: fontSize, fontWeight: FontWeight.normal, color: Colors.red, - backgroundColor: theme.colorScheme.inverseSurface.withOpacity(0.8), + backgroundColor: + theme.colorScheme.inverseSurface.withValues(alpha: 0.8), ), ), ), textSpanDecorator: customizeAttributeDecorator, textScaleFactor: context.watch().state.textScaleFactor, + textSpanOverlayBuilder: _buildTextSpanOverlay, ); } 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 ?? defaultFontFamily; + 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); - final codeFontSize = max(0.0, fontSize - 2); + return EditorStyle.mobile( padding: padding, defaultTextDirection: defaultTextDirection, textStyleConfiguration: TextStyleConfiguration( + lineHeight: lineHeight, text: baseTextStyle.copyWith( fontSize: fontSize, - color: theme.colorScheme.onBackground, - height: lineHeight, + color: afThemeExtension.onBackground, ), bold: baseTextStyle.copyWith(fontWeight: FontWeight.w600), italic: baseTextStyle.copyWith(fontStyle: FontStyle.italic), @@ -118,21 +174,20 @@ class EditorStyleCustomizer { ), code: GoogleFonts.robotoMono( textStyle: baseTextStyle.copyWith( - fontSize: codeFontSize, + fontSize: fontSize, fontWeight: FontWeight.normal, - fontStyle: FontStyle.italic, color: Colors.red, - backgroundColor: Colors.grey.withOpacity(0.3), + backgroundColor: Colors.grey.withValues(alpha: 0.3), ), ), applyHeightToFirstAscent: true, applyHeightToLastDescent: true, ), textSpanDecorator: customizeAttributeDecorator, - mobileDragHandleBallSize: const Size.square(12.0), magnifierSize: const Size(144, 96), - textScaleFactor: - context.watch().state.textScaleFactor, + textScaleFactor: textScaleFactor, + mobileDragHandleLeftExtend: 12.0, + mobileDragHandleWidthExtend: 24.0, ); } @@ -140,9 +195,7 @@ class EditorStyleCustomizer { final String? fontFamily; final List fontSizes; final double fontSize; - final FontWeight fontWeight = - level <= 2 ? FontWeight.w700 : FontWeight.w600; - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { final state = context.read().state; fontFamily = state.fontFamily; fontSize = state.fontLayout.fontSize; @@ -159,68 +212,117 @@ class EditorStyleCustomizer { fontSize, ]; } - return baseTextStyle(fontFamily, fontWeight: fontWeight).copyWith( + return baseTextStyle(fontFamily, fontWeight: FontWeight.w600).copyWith( fontSize: fontSizes.elementAtOrNull(level - 1) ?? fontSize, ); } - TextStyle codeBlockStyleBuilder() { + CodeBlockStyle codeBlockStyleBuilder() { final fontSize = context.read().state.fontSize; final fontFamily = context.read().state.codeFontFamily; - return baseTextStyle(fontFamily).copyWith( - fontSize: fontSize, - height: 1.5, - color: Theme.of(context).colorScheme.onBackground, + + 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: Theme.of(context).colorScheme.onBackground.withOpacity(0.6), + color: AFThemeExtension.of(context).onBackground.withValues(alpha: 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: theme.colorScheme.onBackground, - selectionMenuItemIconColor: theme.colorScheme.onBackground, + selectionMenuItemTextColor: afThemeExtension.onBackground, + selectionMenuItemIconColor: afThemeExtension.onBackground, selectionMenuItemSelectedIconColor: theme.colorScheme.onSurface, selectionMenuItemSelectedTextColor: theme.colorScheme.onSurface, - selectionMenuItemSelectedColor: theme.hoverColor, + selectionMenuItemSelectedColor: afThemeExtension.greyHover, + selectionMenuUnselectedLabelColor: afThemeExtension.onBackground, + selectionMenuDividerColor: afThemeExtension.greyHover, + selectionMenuLinkBorderColor: afThemeExtension.greyHover, + selectionMenuInvalidLinkColor: afThemeExtension.onBackground, + selectionMenuButtonColor: afThemeExtension.greyHover, + selectionMenuButtonTextColor: afThemeExtension.onBackground, + selectionMenuButtonIconColor: afThemeExtension.onBackground, + selectionMenuButtonBorderColor: afThemeExtension.greyHover, + selectionMenuTabIndicatorColor: afThemeExtension.greyHover, ); } InlineActionsMenuStyle inlineActionsMenuStyleBuilder() { final theme = Theme.of(context); + final afThemeExtension = AFThemeExtension.of(context); return InlineActionsMenuStyle( backgroundColor: theme.cardColor, - groupTextColor: theme.colorScheme.onBackground.withOpacity(.8), - menuItemTextColor: theme.colorScheme.onBackground, + groupTextColor: afThemeExtension.onBackground.withValues(alpha: .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) { + if (fontFamily == null || fontFamily == defaultFontFamily) { return TextStyle(fontWeight: fontWeight); } try { return getGoogleFontSafely(fontFamily, fontWeight: fontWeight); } on Exception { - if ([defaultFontFamily, fallbackFontFamily, builtInCodeFontFamily] - .contains(fontFamily)) { + if ([defaultFontFamily, builtInCodeFontFamily].contains(fontFamily)) { return TextStyle(fontFamily: fontFamily, fontWeight: fontWeight); } @@ -241,6 +343,26 @@ class EditorStyleCustomizer { return before; } + final suggestion = attributes[AiWriterBlockKeys.suggestion] as String?; + final newStyle = suggestion == null + ? after.style + : _styleSuggestion(after.style, suggestion); + + if (attributes.backgroundColor != null) { + final color = EditorFontColors.fromBuiltInColors( + context, + attributes.backgroundColor!, + ); + if (color != null) { + return TextSpan( + text: before.text, + style: newStyle?.merge( + TextStyle(backgroundColor: color), + ), + ); + } + } + // try to refresh font here. if (attributes.fontFamily != null) { try { @@ -249,7 +371,7 @@ class EditorStyleCustomizer { } else { return TextSpan( text: before.text, - style: after.style?.merge( + style: newStyle?.merge( getGoogleFontSafely(attributes.fontFamily!), ), ); @@ -266,7 +388,7 @@ class EditorStyleCustomizer { final type = mention[MentionBlockKeys.type]; return WidgetSpan( alignment: PlaceholderAlignment.middle, - style: after.style, + style: newStyle, child: MentionBlock( key: ValueKey( switch (type) { @@ -278,7 +400,7 @@ class EditorStyleCustomizer { node: node, index: index, mention: mention, - textStyle: after.style, + textStyle: newStyle, ), ); } @@ -287,19 +409,20 @@ class EditorStyleCustomizer { 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: style().textStyleConfiguration.text, + textStyle: after.style ?? style().textStyleConfiguration.text, ), ); } // customize the link on mobile final href = attributes[AppFlowyRichTextKeys.href] as String?; - if (PlatformExtension.isMobile && href != null) { + if (UniversalPlatform.isMobile && href != null) { return TextSpan( style: before.style, text: text.text, @@ -340,13 +463,175 @@ class EditorStyleCustomizer { ); } - return defaultTextSpanDecoratorForAttribute( - context, - node, - index, - text, - before, - after, + if (suggestion != null) { + return TextSpan( + text: before.text, + style: newStyle, + ); + } + + if (href != null) { + return TextSpan( + style: before.style, + text: text.text, + mouseCursor: SystemMouseCursors.click, + ); + } else { + return before; + } + } + + Widget buildToolbarItemTooltip( + BuildContext context, + String id, + String message, + Widget child, + ) { + final tooltipMessage = _buildTooltipMessage(id, message); + child = FlowyTooltip( + richMessage: tooltipMessage, + preferBelow: false, + verticalOffset: 24, + 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: 6), + child: FlowyHover( + style: HoverStyle( + hoverColor: Colors.grey.withValues(alpha: 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'), + 'editor.inline_math_equation': ( + LocaleKeys.document_plugins_createInlineMathEquation.tr(), + 'Shift+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; + } + + TextStyle? _styleSuggestion(TextStyle? style, String suggestion) { + if (style == null) { + return null; + } + final isLight = Theme.of(context).isLightMode; + final textColor = isLight ? Color(0xFF007296) : Color(0xFF49CFF4); + final underlineColor = isLight ? Color(0x33005A7A) : Color(0x3349CFF4); + return switch (suggestion) { + AiWriterBlockKeys.suggestionOriginal => style.copyWith( + color: Theme.of(context).disabledColor, + decoration: TextDecoration.lineThrough, + ), + AiWriterBlockKeys.suggestionReplacement => style.copyWith( + color: textColor, + decoration: TextDecoration.underline, + decorationColor: underlineColor, + decorationThickness: 1.0, + ), + _ => style, + }; + } + + List _buildTextSpanOverlay( + BuildContext context, + Node node, + SelectableMixin delegate, + ) { + if (UniversalPlatform.isMobile) return []; + final delta = node.delta; + if (delta == null) return []; + final widgets = []; + final textInserts = delta.whereType(); + int index = 0; + final editorState = context.read(); + for (final textInsert in textInserts) { + if (textInsert.attributes?.href != null) { + final nodeSelection = Selection( + start: Position(path: node.path, offset: index), + end: Position( + path: node.path, + offset: index + textInsert.length, + ), + ); + final rectList = delegate.getRectsInSelection(nodeSelection); + if (rectList.isNotEmpty) { + for (final rect in rectList) { + widgets.add( + Positioned( + left: rect.left, + top: rect.top, + child: SizedBox( + width: rect.width, + height: rect.height, + child: LinkHoverTrigger( + editorState: editorState, + selection: nodeSelection, + attribute: textInsert.attributes!, + node: node, + size: rect.size, + ), + ), + ), + ); + } + } + } + index += textInsert.length; + } + return widgets; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/share/share_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/share/share_button.dart deleted file mode 100644 index fa15bc10d5..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/share/share_button.dart +++ /dev/null @@ -1,208 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/application/document_share_bloc.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/util/string_extension.dart'; -import 'package:appflowy/workspace/application/export/document_exporter.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-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_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 DocumentShareButton extends StatelessWidget { - const DocumentShareButton({ - super.key, - required this.view, - }); - - final ViewPB view; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => getIt(param1: view), - child: BlocListener( - listener: (context, state) { - state.mapOrNull( - finish: (state) { - state.successOrFail.fold( - (data) => _handleExportData(context, data), - _handleExportError, - ); - }, - ); - }, - child: BlocBuilder( - builder: (context, state) => ConstrainedBox( - constraints: const BoxConstraints.expand( - height: 30, - width: 100, - ), - child: ShareActionList(view: view), - ), - ), - ), - ); - } - - void _handleExportData(BuildContext context, ExportDataPB exportData) { - switch (exportData.exportType) { - case ExportType.Markdown: - showSnackBarMessage( - context, - LocaleKeys.settings_files_exportFileSuccess.tr(), - ); - break; - case ExportType.Link: - case ExportType.Text: - break; - case ExportType.HTML: - showSnackBarMessage( - context, - LocaleKeys.settings_files_exportFileSuccess.tr(), - ); - break; - } - } - - void _handleExportError(FlowyError error) { - showMessageToast(error.msg); - } -} - -class ShareActionList extends StatefulWidget { - const ShareActionList({ - super.key, - required this.view, - }); - - final ViewPB view; - - @override - State createState() => ShareActionListState(); -} - -@visibleForTesting -class ShareActionListState 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 docShareBloc = 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(), - onPressed: () {}, - textColor: Theme.of(context).colorScheme.onPrimary, - ), - ), - onSelected: (action, controller) async { - switch (action.inner) { - case ShareAction.markdown: - final exportPath = await getIt().saveFile( - dialogTitle: '', - // encode the file name in case it contains special characters - fileName: '${name.toFileName()}.md', - ); - if (exportPath != null) { - docShareBloc.add( - DocumentShareEvent.share( - DocumentShareType.markdown, - exportPath, - ), - ); - } - break; - case ShareAction.html: - final exportPath = await getIt().saveFile( - dialogTitle: '', - fileName: '${name.toFileName()}.html', - ); - if (exportPath != null) { - docShareBloc.add( - DocumentShareEvent.share( - DocumentShareType.html, - exportPath, - ), - ); - } - break; - case ShareAction.clipboard: - final documentExporter = DocumentExporter(widget.view); - final result = - await documentExporter.export(DocumentExportType.markdown); - result.fold( - (markdown) => getIt() - .setData(ClipboardServiceData(plainText: markdown)), - (error) => showMessageToast(error.msg), - ); - break; - } - controller.close(); - }, - ); - } - - void listenOnViewUpdated() { - name = widget.view.name; - viewListener.start( - onViewUpdated: (view) { - name = view.name; - }, - ); - } -} - -enum ShareAction { - markdown, - html, - clipboard, -} - -class ShareActionWrapper extends ActionCell { - ShareActionWrapper(this.inner); - - final ShareAction inner; - - Widget? icon(Color iconColor) => null; - - @override - String get name { - switch (inner) { - case ShareAction.markdown: - return LocaleKeys.shareAction_markdown.tr(); - case ShareAction.html: - return LocaleKeys.shareAction_html.tr(); - case ShareAction.clipboard: - return LocaleKeys.shareAction_clipboard.tr(); - } - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart new file mode 100644 index 0000000000..c116680c2e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart @@ -0,0 +1,66 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'emoji_menu.dart'; + +const _emojiCharacter = ':'; +final _letterRegExp = RegExp(r'^[a-zA-Z]$'); + +CharacterShortcutEvent emojiCommand(BuildContext context) => + CharacterShortcutEvent( + key: 'Opens Emoji Menu', + character: '', + regExp: _letterRegExp, + handler: (editorState) async { + return false; + }, + handlerWithCharacter: (editorState, character) { + emojiMenuService = EmojiMenu( + overlay: Overlay.of(context), + editorState: editorState, + ); + return emojiCommandHandler(editorState, context, character); + }, + ); + +EmojiMenuService? emojiMenuService; + +Future emojiCommandHandler( + EditorState editorState, + BuildContext context, + String character, +) async { + final selection = editorState.selection; + + if (UniversalPlatform.isMobile || selection == null) { + return false; + } + + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null || node.type == CodeBlockKeys.type) { + return false; + } + + if (selection.end.offset > 0) { + final plain = delta.toPlainText(); + + final previousCharacter = plain[selection.end.offset - 1]; + if (previousCharacter != _emojiCharacter) return false; + if (!context.mounted) return false; + + if (!selection.isCollapsed) return false; + + await editorState.insertTextAtPosition( + character, + position: selection.start, + ); + + emojiMenuService?.show(character); + return true; + } + + return false; +} diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart new file mode 100644 index 0000000000..b1b1e7cdbb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart @@ -0,0 +1,413 @@ +import 'dart:math'; + +import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; +import 'package:appflowy_editor/appflowy_editor.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/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; + +import 'emoji_menu.dart'; + +class EmojiHandler extends StatefulWidget { + const EmojiHandler({ + super.key, + required this.editorState, + required this.menuService, + required this.onDismiss, + required this.onSelectionUpdate, + required this.onEmojiSelect, + this.cancelBySpaceHandler, + this.initialSearchText = '', + }); + + final EditorState editorState; + final EmojiMenuService menuService; + final VoidCallback onDismiss; + final VoidCallback onSelectionUpdate; + final SelectEmojiItemHandler onEmojiSelect; + final String initialSearchText; + final bool Function()? cancelBySpaceHandler; + + @override + State createState() => _EmojiHandlerState(); +} + +class _EmojiHandlerState extends State { + final focusNode = FocusNode(debugLabel: 'emoji_menu_handler'); + final scrollController = ScrollController(); + late EmojiData emojiData; + final List searchedEmojis = []; + bool loaded = false; + int invalidCounter = 0; + late int startOffset; + late String _search = widget.initialSearchText; + double emojiHeight = 36.0; + final configuration = EmojiPickerConfiguration( + defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none, + ); + + int get startCharAmount => widget.initialSearchText.length; + + set search(String search) { + _search = search; + _doSearch(); + } + + final ValueNotifier selectedIndexNotifier = ValueNotifier(0); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => focusNode.requestFocus(), + ); + + startOffset = + (widget.editorState.selection?.endIndex ?? 0) - startCharAmount; + + if (kCachedEmojiData != null) { + loadEmojis(kCachedEmojiData!); + } else { + EmojiData.builtIn().then( + (value) { + kCachedEmojiData = value; + loadEmojis(value); + }, + ); + } + } + + @override + void dispose() { + focusNode.dispose(); + selectedIndexNotifier.dispose(); + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final noEmojis = searchedEmojis.isEmpty; + return Focus( + focusNode: focusNode, + onKeyEvent: onKeyEvent, + child: Container( + constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360), + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6.0), + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withAlpha(25), + ), + ], + ), + child: noEmojis ? buildLoading() : buildEmojis(), + ), + ); + } + + Widget buildLoading() { + return SizedBox( + width: 400, + height: 40, + child: Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ), + ), + ); + } + + Widget buildEmojis() { + return SizedBox( + height: + (searchedEmojis.length / configuration.perLine).ceil() * emojiHeight, + child: GridView.builder( + controller: scrollController, + itemCount: searchedEmojis.length, + padding: const EdgeInsets.symmetric(horizontal: 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: configuration.perLine, + ), + itemBuilder: (context, index) { + final currentEmoji = searchedEmojis[index]; + final emojiId = currentEmoji.id; + final emoji = emojiData.getEmojiById( + emojiId, + skinTone: configuration.defaultSkinTone, + ); + return ValueListenableBuilder( + valueListenable: selectedIndexNotifier, + builder: (context, value, child) { + final isSelected = value == index; + return SizedBox.square( + dimension: emojiHeight, + child: FlowyButton( + isSelected: isSelected, + margin: EdgeInsets.zero, + radius: Corners.s8Border, + text: ManualTooltip( + key: ValueKey('$emojiId-$isSelected'), + message: currentEmoji.name, + showAutomaticlly: isSelected, + preferBelow: false, + child: FlowyText.emoji( + emoji, + fontSize: configuration.emojiSize, + ), + ), + onTap: () => onSelect(index), + ), + ); + }, + ); + }, + ), + ); + } + + void changeSelectedIndex(int index) => selectedIndexNotifier.value = index; + + void loadEmojis(EmojiData data) { + emojiData = data; + searchedEmojis.clear(); + searchedEmojis.addAll(emojiData.emojis.values); + if (mounted) { + setState(() { + loaded = true; + }); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + _doSearch(); + }); + } + + void _doSearch() { + if (!loaded || !mounted) return; + final enableEmptySearch = widget.initialSearchText.isEmpty; + if ((_search.startsWith(' ') || _search.isEmpty) && !enableEmptySearch) { + widget.onDismiss.call(); + return; + } + final searchEmojiData = emojiData.filterByKeyword(_search); + setState(() { + searchedEmojis.clear(); + searchedEmojis.addAll(searchEmojiData.emojis.values); + changeSelectedIndex(0); + _scrollToItem(); + }); + if (searchedEmojis.isEmpty) { + widget.onDismiss.call(); + } + } + + KeyEventResult onKeyEvent(focus, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + const moveKeys = [ + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + ]; + + if (event.logicalKey == LogicalKeyboardKey.enter) { + onSelect(selectedIndexNotifier.value); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + // Workaround to bring focus back to editor + widget.editorState + .updateSelectionWithReason(widget.editorState.selection); + widget.onDismiss.call(); + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + if (_search.isEmpty) { + if (widget.initialSearchText.isEmpty) { + widget.onDismiss.call(); + return KeyEventResult.handled; + } + if (_canDeleteLastCharacter()) { + widget.editorState.deleteBackward(); + } else { + // Workaround for editor regaining focus + widget.editorState.apply( + widget.editorState.transaction + ..afterSelection = widget.editorState.selection, + ); + } + widget.onDismiss.call(); + } else { + widget.onSelectionUpdate(); + widget.editorState.deleteBackward(); + _deleteCharacterAtSelection(); + } + + return KeyEventResult.handled; + } else if (event.character != null && + !moveKeys.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; + } + + return KeyEventResult.handled; + } + + void onSelect(int index) { + widget.onEmojiSelect.call( + context, + (startOffset - startCharAmount, startOffset + _search.length), + emojiData.getEmojiById(searchedEmojis[index].id), + ); + widget.onDismiss.call(); + } + + 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) { + final index = selectedIndexNotifier.value, + perLine = configuration.perLine, + remainder = index % perLine, + length = searchedEmojis.length, + currentLine = index ~/ perLine, + maxLine = (length / perLine).ceil(); + + final heightBefore = currentLine * emojiHeight; + if (key == LogicalKeyboardKey.arrowUp) { + if (currentLine == 0) { + final exceptLine = max(0, maxLine - 1); + changeSelectedIndex(min(exceptLine * perLine + remainder, length - 1)); + } else if (currentLine > 0) { + changeSelectedIndex(index - perLine); + } + } else if (key == LogicalKeyboardKey.arrowDown) { + if (currentLine == maxLine - 1) { + changeSelectedIndex(remainder); + } else if (currentLine < maxLine - 1) { + changeSelectedIndex(min(index + perLine, length - 1)); + } + } else if (key == LogicalKeyboardKey.arrowLeft) { + if (index == 0) { + changeSelectedIndex(length - 1); + } else if (index > 0) { + changeSelectedIndex(index - 1); + } + } else if (key == LogicalKeyboardKey.arrowRight) { + if (index == length - 1) { + changeSelectedIndex(0); + } else if (index < length - 1) { + changeSelectedIndex(index + 1); + } + } + final heightAfter = + (selectedIndexNotifier.value ~/ configuration.perLine) * emojiHeight; + + if (mounted && (heightAfter != heightBefore)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToItem(); + }); + } + } + + void _scrollToItem() { + final noEmojis = searchedEmojis.isEmpty; + if (noEmojis || !mounted) return; + final currentItem = selectedIndexNotifier.value; + final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight; + final maxExtent = scrollController.position.maxScrollExtent; + final jumpTo = (exceptHeight - maxExtent > 10 * emojiHeight) + ? exceptHeight + : min(exceptHeight, maxExtent); + scrollController.animateTo( + jumpTo, + duration: Duration(milliseconds: 300), + curve: Curves.linear, + ); + } + + 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 + _search.length - 1, + ); + } + + 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; + } +} + +typedef SelectEmojiItemHandler = void Function( + BuildContext context, + (int start, int end) replacement, + String emoji, +); diff --git a/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart new file mode 100644 index 0000000000..29f130d77d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart @@ -0,0 +1,231 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'emoji_actions_command.dart'; +import 'emoji_handler.dart'; + +abstract class EmojiMenuService { + void show(String character); + + void dismiss(); +} + +class EmojiMenu extends EmojiMenuService { + EmojiMenu({ + required this.overlay, + required this.editorState, + this.cancelBySpaceHandler, + this.menuHeight = 400, + this.menuWidth = 300, + }); + + final EditorState editorState; + final double menuHeight; + final double menuWidth; + final OverlayState overlay; + final bool Function()? cancelBySpaceHandler; + + Offset _offset = Offset.zero; + Alignment _alignment = Alignment.topLeft; + OverlayEntry? _menuEntry; + bool selectionChangedByMenu = false; + String initialCharacter = ''; + + @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); + } + emojiMenuService = null; + } + + void _onSelectionUpdate() => selectionChangedByMenu = true; + + @override + void show(String character) { + initialCharacter = character; + WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + } + + void _show() { + final selectionService = editorState.service.selectionService; + final selectionRects = selectionService.selectionRects; + if (selectionRects.isEmpty) { + return; + } + + final Size editorSize = editorState.renderBox!.size; + + calculateSelectionMenuOffset(selectionRects.first); + + final (left, top, right, bottom) = _getPosition(); + + _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: EmojiHandler( + editorState: editorState, + menuService: this, + onDismiss: dismiss, + onSelectionUpdate: _onSelectionUpdate, + cancelBySpaceHandler: cancelBySpaceHandler, + initialSearchText: initialCharacter, + onEmojiSelect: ( + BuildContext context, + (int, int) replacement, + String emoji, + ) async { + final selection = editorState.selection; + + if (selection == null) return; + final node = + editorState.document.nodeAtPath(selection.end.path); + if (node == null) return; + final transaction = editorState.transaction + ..deleteText( + node, + replacement.$1, + replacement.$2 - replacement.$1, + ) + ..insertText( + node, + replacement.$1, + emoji, + ); + await editorState.apply(transaction); + }, + ), + ), + ], + ), + ), + ), + ); + + overlay.insert(_menuEntry!); + + keepEditorFocusNotifier.increase(); + 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() { + 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); + } + + void calculateSelectionMenuOffset(Rect rect) { + // 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 menuOffset = Offset(0, 10); + 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; + var offset = bottomRight + menuOffset; + _offset = Offset( + offset.dx, + offset.dy, + ); + + // show above + if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) { + offset = topRight - menuOffset; + _alignment = Alignment.bottomLeft; + + _offset = Offset( + offset.dx, + editorHeight + editorOffset.dy - offset.dy, + ); + } + + // show on right + if (_offset.dx + menuWidth < editorOffset.dx + editorWidth) { + _offset = Offset( + _offset.dx, + _offset.dy, + ); + } else if (offset.dx - editorOffset.dx > menuWidth) { + // show on left + _alignment = _alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + _offset = Offset( + editorWidth - _offset.dx + editorOffset.dx, + _offset.dy, + ); + } + } +} 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 0000000000..6dbd38affb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart @@ -0,0 +1,83 @@ +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]), + iconBuilder: (_) => 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.buildMentionPageAttributes( + mentionType: MentionType.childPage, + pageId: view.id, + blockId: null, + ), + ); + + 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 index 6f3bf087a8..747c8667f8 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - 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'; @@ -8,6 +6,7 @@ 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(), @@ -122,13 +121,13 @@ class DateReferenceService extends InlineActionsDelegate { node, start, end, - '\$', - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: date.toIso8601String(), - }, - }, + MentionBlockKeys.mentionChar, + attributes: MentionBlockKeys.buildMentionDateAttributes( + date: date.toIso8601String(), + includeTime: false, + reminderId: null, + reminderOption: null, + ), ); await editorState.apply(transaction); @@ -139,22 +138,36 @@ class DateReferenceService extends InlineActionsDelegate { final tomorrow = today.add(const Duration(days: 1)); final yesterday = today.subtract(const Duration(days: 1)); - _allOptions = [ - _itemFromDate( + late InlineActionsMenuItem todayItem; + late InlineActionsMenuItem tomorrowItem; + late InlineActionsMenuItem yesterdayItem; + + try { + todayItem = _itemFromDate( today, LocaleKeys.relativeDates_today.tr(), [DateFormat.yMd(_locale).format(today)], - ), - _itemFromDate( + ); + tomorrowItem = _itemFromDate( tomorrow, LocaleKeys.relativeDates_tomorrow.tr(), [DateFormat.yMd(_locale).format(tomorrow)], - ), - _itemFromDate( + ); + yesterdayItem = _itemFromDate( yesterday, LocaleKeys.relativeDates_yesterday.tr(), [DateFormat.yMd(_locale).format(yesterday)], - ), + ); + } catch (e) { + todayItem = _itemFromDate(today); + tomorrowItem = _itemFromDate(tomorrow); + yesterdayItem = _itemFromDate(yesterday); + } + + _allOptions = [ + todayItem, + tomorrowItem, + yesterdayItem, ]; } @@ -174,7 +187,17 @@ class DateReferenceService extends InlineActionsDelegate { String? label, List? keywords, ]) { - final labelStr = label ?? DateFormat.yMd(_locale).format(date); + late String labelStr; + if (label != null) { + labelStr = label; + } else { + try { + labelStr = DateFormat.yMd(_locale).format(date); + } catch (e) { + // fallback to en-US + labelStr = DateFormat.yMd('en-US').format(date); + } + } return InlineActionsMenuItem( label: labelStr.capitalize(), 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 index 0bbfd3359d..9853d6757c 100644 --- 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 @@ -1,15 +1,16 @@ import 'dart:async'; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_text.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'; @@ -19,7 +20,7 @@ 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:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter/material.dart'; // const _channel = "InlinePageReference"; @@ -65,8 +66,9 @@ class InlinePageReferenceService extends InlineActionsDelegate { _recentViewsInitialized = true; + final sectionViews = await _recentService.recentViews(); final views = - (await _recentService.recentViews()).reversed.toSet().toList(); + sectionViews.unique((e) => e.item.id).map((e) => e.item).toList(); // Filter by viewLayout views.retainWhere( @@ -124,7 +126,15 @@ class InlinePageReferenceService extends InlineActionsDelegate { items = allViews .where( - (view) => view.name.toLowerCase().contains(search.toLowerCase()), + (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)) @@ -176,9 +186,8 @@ class InlinePageReferenceService extends InlineActionsDelegate { if (context.mounted) { return Dialogs.show( context, - child: FlowyErrorPage.message( - e.msg, - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + child: AppFlowyErrorPage( + error: e, ), ); } @@ -211,43 +220,47 @@ class InlinePageReferenceService extends InlineActionsDelegate { node, replace.$1, replace.$2, - '\$', - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: view.id, - }, - }, + MentionBlockKeys.mentionChar, + attributes: MentionBlockKeys.buildMentionPageAttributes( + mentionType: MentionType.page, + pageId: view.id, + blockId: null, + ), ); await editorState.apply(transaction); } InlineActionsMenuItem _fromView(ViewPB view) => InlineActionsMenuItem( - keywords: [view.name.toLowerCase()], - label: view.name, - icon: (onSelected) => view.icon.value.isNotEmpty - ? EmojiText( - emoji: view.icon.value, - fontSize: 12, - textAlign: TextAlign.center, - lineHeight: 1.3, - ) - : view.defaultIcon(), + keywords: [view.nameOrDefault.toLowerCase()], + label: view.nameOrDefault, + iconBuilder: (onSelected) { + final child = view.icon.value.isNotEmpty + ? RawEmojiIconWidget( + emoji: view.icon.toEmojiIconData(), + emojiSize: 16.0, + lineHeight: 18.0 / 16.0, + ) + : view.defaultIcon(size: const Size(16, 16)); + return SizedBox( + width: 16, + child: child, + ); + }, 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; - // } +// Future _fromSearchResult( +// SearchResultPB result, +// ) async { +// final viewRes = await ViewBackendService.getView(result.viewId); +// final view = viewRes.toNullable(); +// if (view == null) { +// return null; +// } - // return _fromView(view); - // } +// 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 index 319e6091c8..471f1c9211 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart @@ -147,15 +147,13 @@ class ReminderReferenceService extends InlineActionsDelegate { node, start, end, - '\$', - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.date.name, - MentionBlockKeys.date: date.toIso8601String(), - MentionBlockKeys.reminderId: reminder.id, - MentionBlockKeys.reminderOption: ReminderOption.atTimeOfEvent.name, - }, - }, + MentionBlockKeys.mentionChar, + attributes: MentionBlockKeys.buildMentionDateAttributes( + date: date.toIso8601String(), + reminderId: reminder.id, + reminderOption: ReminderOption.atTimeOfEvent.name, + includeTime: false, + ), ); await editorState.apply(transaction); @@ -170,17 +168,32 @@ class ReminderReferenceService extends InlineActionsDelegate { final tomorrow = today.add(const Duration(days: 1)); final oneWeek = today.add(const Duration(days: 7)); - _allOptions = [ - _itemFromDate( + late InlineActionsMenuItem todayItem; + late InlineActionsMenuItem oneWeekItem; + + try { + todayItem = _itemFromDate( tomorrow, LocaleKeys.relativeDates_tomorrow.tr(), [DateFormat.yMd(_locale).format(tomorrow)], - ), - _itemFromDate( + ); + } catch (e) { + todayItem = _itemFromDate(today); + } + + try { + oneWeekItem = _itemFromDate( oneWeek, LocaleKeys.relativeDates_oneWeek.tr(), [DateFormat.yMd(_locale).format(oneWeek)], - ), + ); + } catch (e) { + oneWeekItem = _itemFromDate(oneWeek); + } + + _allOptions = [ + todayItem, + oneWeekItem, ]; } @@ -200,7 +213,17 @@ class ReminderReferenceService extends InlineActionsDelegate { String? label, List? keywords, ]) { - final labelStr = label ?? DateFormat.yMd(_locale).format(date); + late String labelStr; + if (label != null) { + labelStr = label; + } else { + try { + labelStr = DateFormat.yMd(_locale).format(date); + } catch (e) { + // fallback to en-US + labelStr = DateFormat.yMd('en-US').format(date); + } + } return InlineActionsMenuItem( label: labelStr.capitalize(), @@ -219,6 +242,8 @@ class ReminderReferenceService extends InlineActionsDelegate { 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 index 845eaf8c69..e0e03e7dec 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart @@ -1,7 +1,9 @@ +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.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_editor/appflowy_editor.dart'; +import 'package:universal_platform/universal_platform.dart'; const inlineActionCharacter = '@'; @@ -20,13 +22,14 @@ CharacterShortcutEvent inlineActionsCommand( ); InlineActionsMenuService? selectionMenuService; + Future inlineActionsCommandHandler( EditorState editorState, InlineActionsService service, InlineActionsMenuStyle style, ) async { final selection = editorState.selection; - if (PlatformExtension.isMobile || selection == null) { + if (selection == null) { return false; } @@ -49,15 +52,31 @@ Future inlineActionsCommandHandler( } if (service.context != null) { - selectionMenuService = InlineActionsMenu( - context: service.context!, - editorState: editorState, - service: service, - initialResults: initialResults, - style: style, - ); + keepEditorFocusNotifier.increase(); + selectionMenuService?.dismiss(); + selectionMenuService = UniversalPlatform.isMobile + ? MobileInlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + ) + : InlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + ); - selectionMenuService?.show(); + // disable the keyboard service + editorState.service.keyboardService?.disable(); + + await selectionMenuService?.show(); + + // enable the keyboard service + editorState.service.keyboardService?.enable(); } 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 index 3521a889e8..651e739abc 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart @@ -1,14 +1,16 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; 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(); + Future show(); + void dismiss(); } @@ -20,12 +22,14 @@ class InlineActionsMenu extends InlineActionsMenuService { 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; @@ -40,6 +44,7 @@ class InlineActionsMenu extends InlineActionsMenuService { if (_menuEntry != null) { editorState.service.keyboardService?.enable(); editorState.service.scrollService?.enable(); + keepEditorFocusNotifier.decrease(); } _menuEntry?.remove(); @@ -57,8 +62,13 @@ class InlineActionsMenu extends InlineActionsMenuService { void _onSelectionUpdate() => selectionChangedByMenu = true; @override - void show() { - WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + Future show() { + final completer = Completer(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _show(); + completer.complete(); + }); + return completer.future; } void _show() { @@ -137,6 +147,7 @@ class InlineActionsMenu extends InlineActionsMenuService { onSelectionUpdate: _onSelectionUpdate, style: style, startCharAmount: startCharAmount, + cancelBySpaceHandler: cancelBySpaceHandler, ), ), ), 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 index c1f1a9e237..1fe2703870 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart @@ -12,20 +12,20 @@ typedef SelectItemHandler = void Function( class InlineActionsMenuItem { InlineActionsMenuItem({ required this.label, - this.icon, + this.iconBuilder, this.keywords, this.onSelected, }); final String label; - final Widget Function(bool onSelected)? icon; + final Widget Function(bool onSelected)? iconBuilder; final List? keywords; final SelectItemHandler? onSelected; } class InlineActionsResult { InlineActionsResult({ - required this.title, + this.title, required this.results, this.startsWithKeywords, }); @@ -33,7 +33,9 @@ class InlineActionsResult { /// Localized title to be displayed above the results /// of the current group. /// - final String title; + /// 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. 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 index 94c9d62a74..63ccb04839 100644 --- 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 @@ -1,8 +1,5 @@ import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - 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'; @@ -12,13 +9,16 @@ 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 _menuHeight = 300; +const double kInlineMenuHeight = 300; +const double kInlineMenuWidth = 400; const double _contentHeight = 260; extension _StartWithsSort on List { @@ -49,7 +49,7 @@ extension _StartWithsSort on List { ); } -const _invalidSearchesAmount = 20; +const _invalidSearchesAmount = 10; class InlineActionsHandler extends StatefulWidget { const InlineActionsHandler({ @@ -62,6 +62,7 @@ class InlineActionsHandler extends StatefulWidget { required this.onSelectionUpdate, required this.style, this.startCharAmount = 1, + this.cancelBySpaceHandler, }); final InlineActionsService service; @@ -72,6 +73,7 @@ class InlineActionsHandler extends StatefulWidget { final VoidCallback onSelectionUpdate; final InlineActionsMenuStyle style; final int startCharAmount; + final bool Function()? cancelBySpaceHandler; @override State createState() => _InlineActionsHandlerState(); @@ -81,8 +83,6 @@ class _InlineActionsHandlerState extends State { final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler'); final _scrollController = ScrollController(); - Timer? _debounce; - late List results = widget.results; int invalidCounter = 0; late int startOffset; @@ -90,8 +90,7 @@ class _InlineActionsHandlerState extends State { String _search = ''; set search(String search) { _search = search; - _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 200), _doSearch); + _doSearch(); } Future _doSearch() async { @@ -109,10 +108,13 @@ class _InlineActionsHandlerState extends State { : 0; if (invalidCounter >= _invalidSearchesAmount) { + widget.onDismiss(); + // Workaround to bring focus back to editor await widget.editorState .updateSelectionWithReason(widget.editorState.selection); - return widget.onDismiss(); + + return; } _resetSelection(); @@ -143,7 +145,6 @@ class _InlineActionsHandlerState extends State { void dispose() { _scrollController.dispose(); _focusNode.dispose(); - _debounce?.cancel(); super.dispose(); } @@ -153,7 +154,10 @@ class _InlineActionsHandlerState extends State { focusNode: _focusNode, onKeyEvent: onKeyEvent, child: Container( - constraints: BoxConstraints.loose(const Size(200, _menuHeight)), + constraints: const BoxConstraints( + maxHeight: kInlineMenuHeight, + minWidth: kInlineMenuWidth, + ), decoration: BoxDecoration( color: widget.style.backgroundColor, borderRadius: BorderRadius.circular(6.0), @@ -161,7 +165,7 @@ class _InlineActionsHandlerState extends State { BoxShadow( blurRadius: 5, spreadRadius: 1, - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), ), ], ), @@ -286,12 +290,17 @@ class _InlineActionsHandlerState extends State { /// 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; - } - - if (moveKeys.contains(event.logicalKey)) { + } else if (moveKeys.contains(event.logicalKey)) { _moveSelection(event.logicalKey); return KeyEventResult.handled; } 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 index 1392dd9b21..123cfc1177 100644 --- 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 @@ -1,5 +1,6 @@ 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'; @@ -40,8 +41,10 @@ class InlineActionsGroup extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlowyText.medium(result.title, color: style.groupTextColor), - const SizedBox(height: 4), + if (result.title != null) ...[ + FlowyText.medium(result.title!, color: style.groupTextColor), + const SizedBox(height: 4), + ], ...result.results.mapIndexed( (index, item) => InlineActionsWidget( item: item, @@ -89,14 +92,30 @@ class InlineActionsWidget extends StatefulWidget { class _InlineActionsWidgetState extends State { @override Widget build(BuildContext context) { + final iconBuilder = widget.item.iconBuilder; + final hasIcon = iconBuilder != null; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: SizedBox( - width: 200, + width: kInlineMenuWidth, child: FlowyButton( + expand: true, isSelected: widget.isSelected, - leftIcon: widget.item.icon?.call(widget.isSelected), - text: FlowyText.regular(widget.item.label), + text: Row( + children: [ + if (hasIcon) ...[ + iconBuilder.call(widget.isSelected), + SizedBox(width: 12), + ], + Flexible( + child: FlowyText.regular( + widget.item.label, + figmaLineHeight: 18, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), onTap: _onPressed, ), ), 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 0000000000..238b6bd85d --- /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 0000000000..a826ae0253 --- /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 0000000000..803c9867c9 --- /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 0000000000..649d7c0883 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/constants.dart @@ -0,0 +1,55 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/startup/startup.dart'; + +class ShareConstants { + static const String testBaseWebDomain = 'test.appflowy.com'; + static const String defaultBaseWebDomain = 'https://appflowy.com'; + + static String buildPublishUrl({ + required String nameSpace, + required String publishName, + }) { + final baseShareDomain = + getIt().appflowyCloudConfig.base_web_domain; + final url = '$baseShareDomain/$nameSpace/$publishName'.addSchemaIfNeeded(); + return url; + } + + static String buildNamespaceUrl({ + required String nameSpace, + bool withHttps = false, + }) { + final baseShareDomain = + getIt().appflowyCloudConfig.base_web_domain; + String url = baseShareDomain.addSchemaIfNeeded(); + if (!withHttps) { + url = url.replaceFirst('https://', ''); + } + return '$url/$nameSpace'; + } + + static String buildShareUrl({ + required String workspaceId, + required String viewId, + String? blockId, + }) { + final baseShareDomain = + getIt().appflowyCloudConfig.base_web_domain; + final url = '$baseShareDomain/app/$workspaceId/$viewId'.addSchemaIfNeeded(); + if (blockId == null || blockId.isEmpty) { + return url; + } + return '$url?blockId=$blockId'; + } +} + +extension on String { + String addSchemaIfNeeded() { + final schema = Uri.parse(this).scheme; + // if the schema is empty, add https schema by default + if (schema.isEmpty) { + return 'https://$this'; + } + return this; + } +} 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 0000000000..9d6adee7df --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart @@ -0,0 +1,220 @@ +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_s, + 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()}.zip', + ); + 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( + message: LocaleKeys.message_copy_success.tr(), + ); + }, + (error) => showToastNotification(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.withValues(alpha: 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 0000000000..1c957016e4 --- /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.withValues(alpha: 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 0000000000..eae1d56a18 --- /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 0000000000..244ded0bf6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart @@ -0,0 +1,699 @@ +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( + message: LocaleKeys.publish_publishSuccessfully.tr(), + ), + (error) => showToastNotification( + message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', + type: ToastificationType.error, + ), + ); + } else if (state.unpublishResult != null) { + state.unpublishResult!.fold( + (value) => showToastNotification( + message: LocaleKeys.publish_unpublishSuccessfully.tr(), + ), + (error) => showToastNotification( + message: LocaleKeys.publish_unpublishFailed.tr(), + description: error.msg, + type: ToastificationType.error, + ), + ); + } else if (state.updatePathNameResult != null) { + state.updatePathNameResult!.fold( + (value) => showToastNotification( + message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ), + (error) { + Log.error('update path name failed: $error'); + + showToastNotification( + 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( + message: LocaleKeys.message_copy_success.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.withValues(alpha: 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( + 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.withValues(alpha: 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( + 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 0000000000..e683518526 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart @@ -0,0 +1,448 @@ +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.workspaceAuthType == AuthTypePB.Server, + (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, path: path); + } + return result.fold( + (s) { + if (path != null) { + switch (type) { + case ShareType.html: + case ShareType.csv: + case ShareType.json: + case ShareType.rawDatabaseData: + File(path).writeAsStringSync(s); + return FlowyResult.success(type); + case ShareType.markdown: + 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 0000000000..9020441b4e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart @@ -0,0 +1,87 @@ +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, + compactModeId: view.id, + enableCompactMode: false, + )..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( + message: LocaleKeys.settings_files_exportFileSuccess.tr(), + ); + break; + default: + break; + } + } + + void _handleExportError(BuildContext context, FlowyError error) { + showToastNotification( + 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 0000000000..4decb1c092 --- /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 0000000000..190fe9ddd8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart @@ -0,0 +1,123 @@ +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( + message: LocaleKeys.message_copy_success.tr(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart index 64ce2940c5..24755b4aa7 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart @@ -42,7 +42,7 @@ class TrashBloc extends Bloc { emit(state.copyWith(objects: e.trash)); }, putback: (e) async { - final result = await _service.putback(e.trashId); + final result = await TrashService.putback(e.trashId); await _handleResult(result, emit); }, delete: (e) async { diff --git a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart index b6d319009b..69fe613d82 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart @@ -10,7 +10,7 @@ class TrashService { return FolderEventListTrashItems().send(); } - Future> putback(String trashId) { + static Future> putback(String trashId) { final id = TrashIdPB.create()..id = trashId; return FolderEventRestoreTrashItem(id).send(); diff --git a/frontend/appflowy_flutter/lib/plugins/trash/trash.dart b/frontend/appflowy_flutter/lib/plugins/trash/trash.dart index a25609edd3..f3fb4a8bbe 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/trash.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/trash.dart @@ -1,17 +1,18 @@ -export "./src/sizes.dart"; -export "./src/trash_cell.dart"; -export "./src/trash_header.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/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 'package:appflowy/generated/locale_keys.g.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) { @@ -26,6 +27,9 @@ class TrashPluginBuilder extends PluginBuilder { @override PluginType get pluginType => PluginType.trash; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class TrashPluginConfig implements PluginConfig { @@ -49,20 +53,25 @@ class TrashPlugin extends Plugin { } 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) => leftBarItem; + Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem; @override Widget? get rightBarItem => null; @override - Widget buildWidget({PluginContext? context, required bool shrinkWrap}) => - const TrashPage( - key: ValueKey('TrashPage'), - ); + 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 index b50f95342a..16e82a3089 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart @@ -1,9 +1,11 @@ +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/workspace/presentation/widgets/dialogs.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'; @@ -12,7 +14,6 @@ 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'; @@ -101,33 +102,37 @@ class _TrashPageState extends State { const Spacer(), IntrinsicWidth( child: FlowyButton( - text: FlowyText.medium(LocaleKeys.trash_restoreAll.tr()), + text: FlowyText.medium( + LocaleKeys.trash_restoreAll.tr(), + lineHeight: 1.0, + ), leftIcon: const FlowySvg(FlowySvgs.restore_s), - onTap: () { - NavigatorAlertDialog( - title: LocaleKeys.trash_confirmRestoreAll_title.tr(), - confirm: () { - context - .read() - .add(const TrashEvent.restoreAll()); - }, - ).show(context); - }, + 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()), + text: FlowyText.medium( + LocaleKeys.trash_deleteAll.tr(), + lineHeight: 1.0, + ), leftIcon: const FlowySvg(FlowySvgs.delete_s), - onTap: () { - NavigatorAlertDialog( - title: LocaleKeys.trash_confirmDeleteAll_title.tr(), - confirm: () { - context.read().add(const TrashEvent.deleteAll()); - }, - ).show(context); - }, + onTap: () => showConfirmDeletionDialog( + context: context, + name: LocaleKeys.trash_confirmDeleteAll_title.tr(), + description: LocaleKeys.trash_confirmDeleteAll_caption.tr(), + onConfirm: () => + context.read().add(const TrashEvent.deleteAll()), + ), ), ), ], @@ -152,24 +157,26 @@ class _TrashPageState extends State { height: 42, child: TrashCell( object: object, - onRestore: () { - NavigatorAlertDialog( - title: LocaleKeys.deletePagePrompt_restore.tr(), - confirm: () { - context - .read() - .add(TrashEvent.putback(object.id)); - }, - ).show(context); - }, - onDelete: () { - NavigatorAlertDialog( - title: LocaleKeys.deletePagePrompt_deletePermanent.tr(), - confirm: () { - context.read().add(TrashEvent.delete(object)); - }, - ).show(context); - }, + 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)), + ), ), ); }, 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 0000000000..702c0f7764 --- /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/appflowy_network_image.dart b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart index d37b2e0838..090db27ddc 100644 --- a/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart +++ b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart @@ -1,17 +1,21 @@ import 'dart:convert'; +import 'package:appflowy/generated/locale_keys.g.dart'; 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:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.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 { +class FlowyNetworkImage extends StatefulWidget { const FlowyNetworkImage({ super.key, this.userProfilePB, @@ -21,57 +25,252 @@ class FlowyNetworkImage extends StatelessWidget { this.progressIndicatorBuilder, this.errorWidgetBuilder, required this.url, + this.maxRetries = 5, + this.retryDuration = const Duration(seconds: 6), + this.retryErrorCodes = const {404}, + this.onImageLoaded, }); - final UserProfilePB? userProfilePB; + /// The URL of the image. final String url; + + /// The width of the image. final double? width; + + /// The height of the image. final double? height; + + /// The fit of the image. final BoxFit fit; + + /// The user profile. + /// + /// If the userProfilePB is not null, the image will be downloaded with the access token. + final UserProfilePB? userProfilePB; + + /// The progress indicator builder. final ProgressIndicatorBuilder? progressIndicatorBuilder; + + /// The error widget builder. final LoadingErrorWidgetBuilder? errorWidgetBuilder; + /// Retry loading the image if it fails. + final int maxRetries; + + /// Retry duration + final Duration retryDuration; + + /// Retry error codes. + final Set retryErrorCodes; + + final void Function(bool isImageInCache)? onImageLoaded; + + @override + FlowyNetworkImageState createState() => FlowyNetworkImageState(); +} + +class FlowyNetworkImageState extends State { + final manager = CustomImageCacheManager(); + final retryCounter = FlowyNetworkRetryCounter(); + + // This is used to clear the retry count when the widget is disposed in case of the url is the same. + String? retryTag; + + @override + void initState() { + super.initState(); + + assert(isURL(widget.url)); + + if (widget.url.isAppFlowyCloudUrl) { + assert( + widget.userProfilePB != null && widget.userProfilePB!.token.isNotEmpty, + ); + } + + retryTag = retryCounter.add(widget.url); + + manager.getFileFromCache(widget.url).then((file) { + widget.onImageLoaded?.call( + file != null && + file.file.path.isNotEmpty && + file.originalUrl == widget.url, + ); + }); + } + + @override + void reassemble() { + super.reassemble(); + + if (retryTag != null) { + retryCounter.clear( + tag: retryTag!, + url: widget.url, + maxRetries: widget.maxRetries, + ); + } + } + + @override + void dispose() { + if (retryTag != null) { + retryCounter.clear( + tag: retryTag!, + url: widget.url, + maxRetries: widget.maxRetries, + ); + } + + super.dispose(); + } + @override Widget build(BuildContext context) { - assert(isURL(url)); + return ListenableBuilder( + listenable: retryCounter, + builder: (context, child) { + final retryCount = retryCounter.getRetryCount(widget.url); + return CachedNetworkImage( + key: ValueKey('${widget.url}_$retryCount'), + cacheManager: manager, + httpHeaders: _buildRequestHeader(), + imageUrl: widget.url, + fit: widget.fit, + width: widget.width, + height: widget.height, + progressIndicatorBuilder: widget.progressIndicatorBuilder, + errorWidget: _errorWidgetBuilder, + errorListener: (value) async { + Log.error( + 'Unable to load image: ${value.toString()} - retryCount: $retryCount', + ); - 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()); + // clear the cache and retry + await manager.removeFile(widget.url); + _retryLoadImage(); + }, + ); }, ); } - Map _header() { + /// if the error is 404 and the retry count is less than the max retries, it return a loading indicator. + Widget _errorWidgetBuilder(BuildContext context, String url, Object error) { + final retryCount = retryCounter.getRetryCount(url); + if (error is HttpExceptionWithStatus) { + if (widget.retryErrorCodes.contains(error.statusCode) && + retryCount < widget.maxRetries) { + final fakeDownloadProgress = DownloadProgress(url, null, 0); + return widget.progressIndicatorBuilder?.call( + context, + url, + fakeDownloadProgress, + ) ?? + const Center( + child: _SensitiveContent(), + ); + } + + if (error.statusCode == 422) { + // Unprocessable Entity: Used when the server understands the request but cannot process it due to + //semantic issues (e.g., sensitive keywords). + return const _SensitiveContent(); + } + } + + return widget.errorWidgetBuilder?.call(context, url, error) ?? + const SizedBox.shrink(); + } + + Map _buildRequestHeader() { final header = {}; - final token = userProfilePB?.token; + final token = widget.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'); + Log.error('Unable to decode token: $e'); } } return header; } + + void _retryLoadImage() { + final retryCount = retryCounter.getRetryCount(widget.url); + if (retryCount < widget.maxRetries) { + Future.delayed(widget.retryDuration, () { + Log.debug( + 'Retry load image: ${widget.url}, retry count: $retryCount', + ); + // Increment the retry count for the URL to trigger the image rebuild. + retryCounter.increment(widget.url); + }); + } + } +} + +/// This class is used to count the number of retries for a given URL. +@visibleForTesting +class FlowyNetworkRetryCounter with ChangeNotifier { + FlowyNetworkRetryCounter._(); + + factory FlowyNetworkRetryCounter() => _instance; + static final _instance = FlowyNetworkRetryCounter._(); + + final Map _values = {}; + Map get values => {..._values}; + + /// Get the retry count for a given URL. + int getRetryCount(String url) => _values[url] ?? 0; + + /// Add a new URL to the retry counter. Don't call notifyListeners() here. + /// + /// This function will return a tag, use it to clear the retry count. + /// Because the url may be the same, we need to add a unique tag to the url. + String add(String url) { + _values.putIfAbsent(url, () => 0); + return url + uuid(); + } + + /// Increment the retry count for a given URL. + void increment(String url) { + final count = _values[url]; + if (count == null) { + _values[url] = 1; + } else { + _values[url] = count + 1; + } + notifyListeners(); + } + + /// Clear the retry count for a given tag. + void clear({ + required String tag, + required String url, + int? maxRetries, + }) { + _values.remove(tag); + + final retryCount = _values[url]; + if (maxRetries == null || + (retryCount != null && retryCount >= maxRetries)) { + _values.remove(url); + } + } + + /// Reset the retry counter. + void reset() { + _values.clear(); + } +} + +class _SensitiveContent extends StatelessWidget { + const _SensitiveContent(); + + @override + Widget build(BuildContext context) { + return FlowyText(LocaleKeys.ai_contentPolicyViolation.tr()); + } } diff --git a/frontend/appflowy_flutter/lib/shared/appflowy_network_svg.dart b/frontend/appflowy_flutter/lib/shared/appflowy_network_svg.dart new file mode 100644 index 0000000000..33c3bb2c0a --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/appflowy_network_svg.dart @@ -0,0 +1,197 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:flowy_svg/flowy_svg.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +import 'custom_image_cache_manager.dart'; + +class FlowyNetworkSvg extends StatefulWidget { + FlowyNetworkSvg( + this.url, { + Key? key, + this.cacheKey, + this.placeholder, + this.errorWidget, + this.width, + this.height, + this.headers, + this.fit = BoxFit.contain, + this.alignment = Alignment.center, + this.matchTextDirection = false, + this.allowDrawingOutsideViewBox = false, + this.semanticsLabel, + this.excludeFromSemantics = false, + this.theme = const SvgTheme(), + this.fadeDuration = Duration.zero, + this.colorFilter, + this.placeholderBuilder, + BaseCacheManager? cacheManager, + }) : cacheManager = cacheManager ?? CustomImageCacheManager(), + super(key: key ?? ValueKey(url)); + + final String url; + final String? cacheKey; + final Widget? placeholder; + final Widget? errorWidget; + final double? width; + final double? height; + final ColorFilter? colorFilter; + final Map? headers; + final BoxFit fit; + final AlignmentGeometry alignment; + final bool matchTextDirection; + final bool allowDrawingOutsideViewBox; + final String? semanticsLabel; + final bool excludeFromSemantics; + final SvgTheme theme; + final Duration fadeDuration; + final WidgetBuilder? placeholderBuilder; + final BaseCacheManager cacheManager; + + @override + State createState() => _FlowyNetworkSvgState(); + + static Future preCache( + String imageUrl, { + String? cacheKey, + BaseCacheManager? cacheManager, + }) { + final key = cacheKey ?? _generateKeyFromUrl(imageUrl); + cacheManager ??= DefaultCacheManager(); + return cacheManager.downloadFile(key); + } + + static Future clearCacheForUrl( + String imageUrl, { + String? cacheKey, + BaseCacheManager? cacheManager, + }) { + final key = cacheKey ?? _generateKeyFromUrl(imageUrl); + cacheManager ??= DefaultCacheManager(); + return cacheManager.removeFile(key); + } + + static Future clearCache({BaseCacheManager? cacheManager}) { + cacheManager ??= DefaultCacheManager(); + return cacheManager.emptyCache(); + } + + static String _generateKeyFromUrl(String url) => url.split('?').first; +} + +class _FlowyNetworkSvgState extends State + with SingleTickerProviderStateMixin { + bool _isLoading = false; + bool _isError = false; + File? _imageFile; + late String _cacheKey; + + late final AnimationController _controller; + late final Animation _animation; + + @override + void initState() { + super.initState(); + _cacheKey = + widget.cacheKey ?? FlowyNetworkSvg._generateKeyFromUrl(widget.url); + _controller = AnimationController( + vsync: this, + duration: widget.fadeDuration, + ); + _animation = Tween(begin: 0.0, end: 1.0).animate(_controller); + _loadImage(); + } + + Future _loadImage() async { + try { + _setToLoadingAfter15MsIfNeeded(); + + var file = (await widget.cacheManager.getFileFromMemory(_cacheKey))?.file; + + file ??= await widget.cacheManager.getSingleFile( + widget.url, + key: _cacheKey, + headers: widget.headers ?? {}, + ); + + _imageFile = file; + _isLoading = false; + + _setState(); + + await _controller.forward(); + } catch (e) { + log('CachedNetworkSVGImage: $e'); + + _isError = true; + _isLoading = false; + + _setState(); + } + } + + void _setToLoadingAfter15MsIfNeeded() => Future.delayed( + const Duration(milliseconds: 15), + () { + if (!_isLoading && _imageFile == null && !_isError) { + _isLoading = true; + _setState(); + } + }, + ); + + void _setState() => mounted ? setState(() {}) : null; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.width, + height: widget.height, + child: _buildImage(), + ); + } + + Widget _buildImage() { + if (_isLoading) return _buildPlaceholderWidget(); + + if (_isError) return _buildErrorWidget(); + + return FadeTransition( + opacity: _animation, + child: _buildSVGImage(), + ); + } + + Widget _buildPlaceholderWidget() => + Center(child: widget.placeholder ?? const SizedBox()); + + Widget _buildErrorWidget() => + Center(child: widget.errorWidget ?? const SizedBox()); + + Widget _buildSVGImage() { + if (_imageFile == null) return const SizedBox(); + + return SvgPicture.file( + _imageFile!, + fit: widget.fit, + width: widget.width, + height: widget.height, + alignment: widget.alignment, + matchTextDirection: widget.matchTextDirection, + allowDrawingOutsideViewBox: widget.allowDrawingOutsideViewBox, + colorFilter: widget.colorFilter, + semanticsLabel: widget.semanticsLabel, + excludeFromSemantics: widget.excludeFromSemantics, + placeholderBuilder: widget.placeholderBuilder, + theme: widget.theme, + ); + } +} 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 0000000000..dfee65e420 --- /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 0000000000..4f6e1ecfeb --- /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 0000000000..661e86dcb7 --- /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/error_code/error_code_map.dart b/frontend/appflowy_flutter/lib/shared/error_code/error_code_map.dart new file mode 100644 index 0000000000..2d54c93cbe --- /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/error_page/error_page.dart b/frontend/appflowy_flutter/lib/shared/error_page/error_page.dart new file mode 100644 index 0000000000..9661fd822a --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/error_page/error_page.dart @@ -0,0 +1,272 @@ +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/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/startup/startup.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/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.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 getIt().setData( + ClipboardServiceData(plainText: 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: () => getIt().setData( + ClipboardServiceData(plainText: 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: FlowyText(LocaleKeys.appName.tr()), + useIntrinsicWidth: true, + leftIcon: const Padding( + padding: EdgeInsets.all(4.0), + child: FlowySvg(FlowySvgData('login/github-mark')), + ), + onTap: () async { + await afLaunchUri(_gitHubNewBugUri); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/feature_flags.dart b/frontend/appflowy_flutter/lib/shared/feature_flags.dart index 94b45da631..7ea66076df 100644 --- a/frontend/appflowy_flutter/lib/shared/feature_flags.dart +++ b/frontend/appflowy_flutter/lib/shared/feature_flags.dart @@ -32,6 +32,15 @@ enum FeatureFlag { // 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; @@ -85,12 +94,18 @@ enum 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; } @@ -100,14 +115,17 @@ enum FeatureFlag { } switch (this) { - case FeatureFlag.collaborativeWorkspace: - case FeatureFlag.membersSettings: + case FeatureFlag.planBilling: case FeatureFlag.search: - case FeatureFlag.unknown: - return false; 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; } } @@ -123,6 +141,12 @@ enum FeatureFlag { 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 ''; } 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 0000000000..5942271206 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart @@ -0,0 +1,166 @@ +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( + 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( + 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/google_fonts_extension.dart b/frontend/appflowy_flutter/lib/shared/google_fonts_extension.dart index bfd67afc8c..3e6a69153a 100644 --- a/frontend/appflowy_flutter/lib/shared/google_fonts_extension.dart +++ b/frontend/appflowy_flutter/lib/shared/google_fonts_extension.dart @@ -1,7 +1,12 @@ -import 'package:appflowy_backend/log.dart'; +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( @@ -12,19 +17,27 @@ TextStyle getGoogleFontSafely( double? letterSpacing, double? lineHeight, }) { - try { - return GoogleFonts.getFont( - fontFamily, + // 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, ); - } catch (e) { - Log.error( - 'Font family $fontFamily is not available, using default font family instead', - ); + } else { + try { + return GoogleFonts.getFont( + fontFamily, + fontWeight: fontWeight, + fontSize: fontSize, + color: fontColor, + letterSpacing: letterSpacing, + height: lineHeight, + ); + } catch (_) {} } return TextStyle( 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 0000000000..40b9c1d6fa --- /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.withValues(alpha: 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.withValues(alpha: 0.12); + } + + Color get pickerButtonBoarderColor { + return Theme.of(this).isLightMode + ? const Color(0x1E171717) + : Colors.white.withValues(alpha: 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 0000000000..4520a2b118 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_search_bar.dart @@ -0,0 +1,215 @@ +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(); + EmojiSkinTone skinTone = lastSelectedEmojiSkinTone ?? EmojiSkinTone.none; + + @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( + skinTone: skinTone, + emojiData: widget.emojiData, + onRandomEmojiSelected: widget.onRandomEmojiSelected, + ), + const HSpace(8.0), + FlowyEmojiSkinToneSelector( + onEmojiSkinToneChanged: (v) { + setState(() { + skinTone = v; + }); + widget.onSkinToneChanged.call(v); + }, + ), + ], + ), + ); + } +} + +class _RandomEmojiButton extends StatelessWidget { + const _RandomEmojiButton({ + required this.skinTone, + required this.emojiData, + required this.onRandomEmojiSelected, + }); + + final EmojiSkinTone skinTone; + 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; + final emojiId = random.$1; + final emoji = emojiData.getEmojiById( + emojiId, + skinTone: skinTone, + ); + onRandomEmojiSelected( + emojiId, + emoji, + ); + }, + ), + ), + ); + } +} + +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(); + + /// Sometimes focus is lost due to the [SelectionGestureInterceptor] in [KeyboardServiceWidgetState] + /// this is to ensure that focus can be regained within a short period of time + 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/plugins/base/emoji/emoji_skin_tone.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_skin_tone.dart similarity index 78% rename from frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_skin_tone.dart rename to frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_skin_tone.dart index e8da112660..aa97980182 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_skin_tone.dart +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_skin_tone.dart @@ -1,11 +1,11 @@ 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/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.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; @@ -57,7 +57,7 @@ class _FlowyEmojiSkinToneSelectorState child: FlowyTooltip( message: LocaleKeys.emoji_selectSkinTone.tr(), child: _buildIconButton( - lastSelectedEmojiSkinTone?.icon ?? '✋', + lastSelectedEmojiSkinTone?.icon ?? '👋', () => controller.show(), ), ), @@ -65,19 +65,22 @@ class _FlowyEmojiSkinToneSelectorState } Widget _buildIconButton(String icon, VoidCallback onPressed) { - return FlowyIconButton( - key: emojiSkinToneKey(icon), - icon: Padding( - // add a left padding to align the emoji center - padding: const EdgeInsets.only( - left: 3.0, - ), - child: FlowyText( - icon, - fontSize: 22.0, - ), + 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, ), - onPressed: onPressed, ); } } @@ -86,17 +89,17 @@ extension EmojiSkinToneIcon on EmojiSkinTone { String get icon { switch (this) { case EmojiSkinTone.none: - return '✋'; + return '👋'; case EmojiSkinTone.light: - return '✋🏻'; + return '👋🏻'; case EmojiSkinTone.mediumLight: - return '✋🏼'; + return '👋🏼'; case EmojiSkinTone.medium: - return '✋🏽'; + return '👋🏽'; case EmojiSkinTone.mediumDark: - return '✋🏾'; + return '👋🏾'; case EmojiSkinTone.dark: - return '✋🏿'; + 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 0000000000..b04b38a45a --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart @@ -0,0 +1,277 @@ +import 'dart:math'; + +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:flutter/services.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'icon_uploader.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; +} + +extension FlowyIconTypeToPickerTabType on FlowyIconType { + PickerTabType? toPickerTabType() => name.toPickerTabType(); +} + +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); + + factory EmojiIconData.custom(String url) => + EmojiIconData(FlowyIconType.custom, url); + + 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 SelectedEmojiIconResult { + SelectedEmojiIconResult(this.data, this.keepOpen); + + final EmojiIconData data; + final bool keepOpen; + + FlowyIconType get type => data.type; + + String get emoji => data.emoji; +} + +extension EmojiIconDataToSelectedResultExtension on EmojiIconData { + SelectedEmojiIconResult toSelectedResult({bool keepOpen = false}) => + SelectedEmojiIconResult(this, keepOpen); +} + +class FlowyIconEmojiPicker extends StatefulWidget { + const FlowyIconEmojiPicker({ + super.key, + this.onSelectedEmoji, + this.initialType, + this.documentId, + this.enableBackgroundColorSelection = true, + this.tabs = const [ + PickerTabType.emoji, + PickerTabType.icon, + ], + }); + + final ValueChanged? onSelectedEmoji; + final bool enableBackgroundColorSelection; + final List tabs; + final PickerTabType? initialType; + final String? documentId; + + @override + State createState() => _FlowyIconEmojiPickerState(); +} + +class _FlowyIconEmojiPickerState extends State + with SingleTickerProviderStateMixin { + late TabController controller; + int currentIndex = 0; + + @override + void initState() { + super.initState(); + final initialType = widget.initialType; + if (initialType != null) { + currentIndex = max(widget.tabs.indexOf(initialType), 0); + } + controller = TabController( + initialIndex: currentIndex, + length: widget.tabs.length, + vsync: this, + ); + controller.addListener(() { + final currentType = widget.tabs[currentIndex]; + if (currentType == PickerTabType.custom) { + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + }); + } + + @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().toSelectedResult()); + }, + ), + ], + ), + ), + const FlowyDivider(), + Expanded( + child: TabBarView( + controller: controller, + children: widget.tabs.map((tab) { + switch (tab) { + case PickerTabType.emoji: + return _buildEmojiPicker(); + case PickerTabType.icon: + return _buildIconPicker(); + case PickerTabType.custom: + return _buildIconUploader(); + } + }).toList(), + ), + ), + ], + ); + } + + Widget _buildEmojiPicker() { + return FlowyEmojiPicker( + ensureFocus: true, + emojiPerLine: _getEmojiPerLine(context), + onEmojiSelected: (r) { + widget.onSelectedEmoji?.call( + EmojiIconData.emoji(r.emoji).toSelectedResult(keepOpen: r.isRandom), + ); + SystemChannels.textInput.invokeMethod('TextInput.hide'); + }, + ); + } + + 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( + ensureFocus: true, + enableBackgroundColorSelection: widget.enableBackgroundColorSelection, + onSelectedIcon: (r) { + widget.onSelectedEmoji?.call( + r.data.toEmojiIconData().toSelectedResult(keepOpen: r.isRandom), + ); + SystemChannels.textInput.invokeMethod('TextInput.hide'); + }, + ); + } + + Widget _buildIconUploader() { + return IconUploader( + documentId: widget.documentId ?? '', + ensureFocus: true, + onUrl: (url) { + widget.onSelectedEmoji + ?.call(SelectedEmojiIconResult(EmojiIconData.custom(url), false)); + }, + ); + } +} + +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 0000000000..a053595bbd --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon.dart @@ -0,0 +1,107 @@ +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.toLowerCase().contains(lowercaseKey)) || + icon.name.toLowerCase().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'; + } +} + +class RecentIcon { + factory RecentIcon.fromJson(Map json) => + RecentIcon(_$IconFromJson(json), json['groupName'] ?? ''); + + RecentIcon(this.icon, this.groupName); + + final Icon icon; + final String groupName; + + String get name => icon.name; + + List get keywords => icon.keywords; + + String get content => icon.content; + + Map toJson() => _$IconToJson( + Icon(name: name, keywords: keywords, content: content), + )..addAll({'groupName': groupName}); +} 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 0000000000..b4221ff42a --- /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 0000000000..0d57d12d3c --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart @@ -0,0 +1,537 @@ +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 IconPickerResult { + IconPickerResult(this.data, this.isRandom); + + final IconsData data; + final bool isRandom; +} + +extension IconsDataToIconPickerResultExtension on IconsData { + IconPickerResult toResult({bool isRandom = false}) => + IconPickerResult(this, isRandom); +} + +class FlowyIconPicker extends StatefulWidget { + const FlowyIconPicker({ + super.key, + required this.onSelectedIcon, + required this.enableBackgroundColorSelection, + this.iconPerLine = 9, + this.ensureFocus = false, + }); + + final bool enableBackgroundColorSelection; + final ValueChanged onSelectedIcon; + final int iconPerLine; + final bool ensureFocus; + + @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) { + final filterRecentIcons = recentIcons + .sublist( + 0, + min(recentIcons.length, widget.iconPerLine), + ) + .skipWhile((e) => e.groupName.isEmpty) + .map((e) => e.icon) + .toList(); + if (filterRecentIcons.isNotEmpty) { + iconGroups.add( + IconGroup( + name: _kRecentIconGroupName, + icons: filterRecentIcons, + ), + ); + } + } + 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( + ensureFocus: widget.ensureFocus, + onRandomTap: () { + final value = kIconGroups?.randomIcon(); + if (value == null) { + return; + } + final color = widget.enableBackgroundColorSelection + ? generateRandomSpaceColor() + : null; + widget.onSelectedIcon( + IconsData( + value.$1.name, + value.$2.name, + color, + ).toResult(isRandom: true), + ); + RecentIcons.putIcon(RecentIcon(value.$2, value.$1.name)); + }, + 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: (r) => widget.onSelectedIcon.call(r.toResult()), + iconPerLine: widget.iconPerLine, + ); + } + return IconPicker( + iconGroups: iconGroups, + enableBackgroundColorSelection: widget.enableBackgroundColorSelection, + onSelectedIcon: (r) => widget.onSelectedIcon.call(r.toResult()), + iconPerLine: widget.iconPerLine, + ); + }, + ); + } +} + +class IconsData { + IconsData(this.groupName, this.iconName, this.color); + + final String groupName; + final String iconName; + final String? color; + + String get iconString => jsonEncode({ + 'groupName': groupName, + 'iconName': iconName, + if (color != null) 'color': color, + }); + + EmojiIconData toEmojiIconData() => EmojiIconData.icon(this); + + IconsData noColor() => IconsData(groupName, iconName, null); + + static IconsData fromJson(dynamic json) { + return IconsData( + json['groupName'], + json['iconName'], + json['color'], + ); + } + + String? get svgString => kIconGroups + ?.firstWhereOrNull((group) => group.name == groupName) + ?.icons + .firstWhereOrNull((icon) => icon.name == iconName) + ?.content; +} + +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(); + PopoverController? childPopoverController; + + @override + void dispose() { + super.dispose(); + childPopoverController = null; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: hideColorSelector, + child: NotificationListener( + onNotification: (notificationInfo) { + if (notificationInfo is ScrollStartNotification) { + hideColorSelector(); + } + return true; + }, + child: 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, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemBuilder: (context, index) { + final icon = iconGroup.icons[index]; + return widget.enableBackgroundColorSelection + ? _Icon( + icon: icon, + mutex: mutex, + onOpen: (childPopoverController) { + this.childPopoverController = + childPopoverController; + }, + onSelectedColor: (context, color) { + String groupName = iconGroup.name; + if (groupName == _kRecentIconGroupName) { + groupName = getGroupName(index); + } + widget.onSelectedIcon( + IconsData( + groupName, + icon.name, + color, + ), + ); + RecentIcons.putIcon(RecentIcon(icon, groupName)); + PopoverContainer.of(context).close(); + }, + ) + : _IconNoBackground( + icon: icon, + onSelectedIcon: () { + String groupName = iconGroup.name; + if (groupName == _kRecentIconGroupName) { + groupName = getGroupName(index); + } + widget.onSelectedIcon( + IconsData( + groupName, + icon.name, + null, + ), + ); + RecentIcons.putIcon(RecentIcon(icon, groupName)); + }, + ); + }, + ), + const VSpace(12.0), + if (index == widget.iconGroups.length - 1) ...[ + const StreamlinePermit(), + const VSpace(12.0), + ], + ], + ); + }, + ), + ), + ); + } + + void hideColorSelector() { + childPopoverController?.close(); + childPopoverController = null; + } + + String getGroupName(int index) { + final recentIcons = RecentIcons.getIconsSync(); + try { + return recentIcons[index].groupName; + } catch (e) { + Log.error('getGroupName with index: $index error', e); + return ''; + } + } +} + +class _IconNoBackground extends StatelessWidget { + const _IconNoBackground({ + required this.icon, + required this.onSelectedIcon, + this.isSelected = false, + }); + + final Icon icon; + final bool isSelected; + final VoidCallback onSelectedIcon; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: icon.displayName, + preferBelow: false, + child: FlowyButton( + isSelected: isSelected, + 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, + this.onOpen, + }); + + final Icon icon; + final PopoverMutex mutex; + final void Function(BuildContext context, String color) onSelectedColor; + final ValueChanged? onOpen; + + @override + State<_Icon> createState() => _IconState(); +} + +class _IconState extends State<_Icon> { + final PopoverController _popoverController = PopoverController(); + bool isSelected = false; + + @override + void dispose() { + super.dispose(); + _popoverController.close(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + controller: _popoverController, + offset: const Offset(0, 6), + mutex: widget.mutex, + onClose: () { + updateIsSelected(false); + }, + clickHandler: PopoverClickHandler.gestureDetector, + child: _IconNoBackground( + icon: widget.icon, + isSelected: isSelected, + onSelectedIcon: () { + updateIsSelected(true); + _popoverController.show(); + widget.onOpen?.call(_popoverController); + }, + ), + popupBuilder: (context) { + return Container( + padding: const EdgeInsets.all(6.0), + child: IconColorPicker( + onSelected: (color) => widget.onSelectedColor(context, color), + ), + ); + }, + ); + } + + void updateIsSelected(bool isSelected) { + setState(() { + this.isSelected = isSelected; + }); + } +} + +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 0000000000..a12be47684 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_search_bar.dart @@ -0,0 +1,183 @@ +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, + this.ensureFocus = false, + }); + + final VoidCallback onRandomTap; + final bool ensureFocus; + 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, + ensureFocus: widget.ensureFocus, + ), + ), + 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, + this.ensureFocus = false, + }); + + final IconKeywordChangedCallback 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(); + + /// Sometimes focus is lost due to the [SelectionGestureInterceptor] in [KeyboardServiceWidgetState] + /// this is to ensure that focus can be regained within a short period of time + 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/icon_uploader.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart new file mode 100644 index 0000000000..c303160ffe --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart @@ -0,0 +1,466 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/appflowy_network_svg.dart'; +import 'package:appflowy/shared/custom_image_cache_manager.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy/shared/permission/permission_checker.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/util/default_extensions.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.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_ui/flowy_infra_ui.dart'; +import 'package:flowy_svg/flowy_svg.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; + +@visibleForTesting +class IconUploader extends StatefulWidget { + const IconUploader({ + super.key, + required this.onUrl, + required this.documentId, + this.ensureFocus = false, + }); + + final ValueChanged onUrl; + final String documentId; + final bool ensureFocus; + + @override + State createState() => _IconUploaderState(); +} + +class _IconUploaderState extends State { + bool isActive = false; + bool isHovering = false; + bool isUploading = false; + + final List<_Image> pickedImages = []; + final FocusNode focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + + /// Sometimes focus is lost due to the [SelectionGestureInterceptor] in [KeyboardServiceWidgetState] + /// this is to ensure that focus can be regained within a short period of time + if (widget.ensureFocus) { + Future.delayed(const Duration(milliseconds: 200), () { + if (!mounted || focusNode.hasFocus) return; + focusNode.requestFocus(); + }); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + enableDocumentDragNotifier.value = false; + }); + } + + @override + void dispose() { + super.dispose(); + WidgetsBinding.instance.addPostFrameCallback((_) { + enableDocumentDragNotifier.value = true; + }); + focusNode.dispose(); + } + + @override + Widget build(BuildContext context) { + return Shortcuts( + shortcuts: { + LogicalKeySet( + Platform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.keyV, + ): _PasteIntent(), + }, + child: Actions( + actions: { + _PasteIntent: CallbackAction<_PasteIntent>( + onInvoke: (intent) => pasteAsAnImage(), + ), + }, + child: Focus( + autofocus: true, + focusNode: focusNode, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Expanded( + child: DropTarget( + onDragEntered: (_) => setState(() => isActive = true), + onDragExited: (_) => setState(() => isActive = false), + onDragDone: (details) => loadImage(details.files), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: pickImage, + child: DottedBorder( + dashPattern: const [3, 3], + radius: const Radius.circular(8), + borderType: BorderType.RRect, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of(context).hintColor, + child: Container( + alignment: Alignment.center, + decoration: isHovering + ? BoxDecoration( + color: Color(0x0F1F2329), + borderRadius: BorderRadius.circular(8), + ) + : null, + child: pickedImages.isEmpty + ? (isActive + ? hoveringWidget() + : dragHint(context)) + : previewImage(), + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: Row( + children: [ + Spacer(), + if (pickedImages.isNotEmpty) + Padding( + padding: EdgeInsets.only(right: 8), + child: _ChangeIconButton( + onTap: pickImage, + ), + ), + _ConfirmButton( + onTap: uploadImage, + enable: pickedImages.isNotEmpty, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget hoveringWidget() { + return Container( + color: Color(0xffE0F8FF), + child: Center( + child: FlowyText( + LocaleKeys.emojiIconPicker_iconUploader_dropToUpload.tr(), + ), + ), + ); + } + + Widget dragHint(BuildContext context) { + final style = TextStyle( + fontSize: 14, + color: Color(0xff666D76), + fontWeight: FontWeight.w500, + ); + return Padding( + padding: EdgeInsets.symmetric(horizontal: 32), + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + TextSpan( + text: + LocaleKeys.emojiIconPicker_iconUploader_placeholderLeft.tr(), + ), + TextSpan( + text: LocaleKeys.emojiIconPicker_iconUploader_placeholderUpload + .tr(), + style: style.copyWith(color: Color(0xff00BCF0)), + ), + TextSpan( + text: + LocaleKeys.emojiIconPicker_iconUploader_placeholderRight.tr(), + mouseCursor: SystemMouseCursors.click, + ), + ], + style: style, + ), + ), + ); + } + + Widget previewImage() { + final image = pickedImages.first; + final url = image.url; + if (image is _FileImage) { + if (url.endsWith(_svgSuffix)) { + return SvgPicture.file( + File(url), + width: 200, + height: 200, + ); + } + return Image.file( + File(url), + width: 200, + height: 200, + ); + } else if (image is _NetworkImage) { + if (url.endsWith(_svgSuffix)) { + return FlowyNetworkSvg( + url, + width: 200, + height: 200, + ); + } + return FlowyNetworkImage( + width: 200, + height: 200, + url: url, + ); + } + return const SizedBox.shrink(); + } + + void loadImage(List files) { + final imageFiles = files + .where( + (file) => + file.mimeType?.startsWith('image/') ?? + false || + imgExtensionRegex.hasMatch(file.name) || + file.name.endsWith(_svgSuffix), + ) + .toList(); + if (imageFiles.isEmpty) return; + if (mounted) { + setState(() { + pickedImages.clear(); + pickedImages.add(_FileImage(imageFiles.first.path)); + }); + } + } + + Future pickImage() 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: List.of(defaultImageExtensions)..add('svg'), + ); + loadImage(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(); + loadImage(result); + } + } + + Future uploadImage() async { + if (pickedImages.isEmpty || isUploading) return; + isUploading = true; + String? result; + final userProfileResult = await UserBackendService.getCurrentUserProfile(); + final userProfile = userProfileResult.fold( + (userProfile) => userProfile, + (l) => null, + ); + final isLocalMode = (userProfile?.workspaceAuthType ?? AuthTypePB.Local) == + AuthTypePB.Local; + if (isLocalMode) { + result = await pickedImages.first.saveToLocal(); + } else { + result = await pickedImages.first.uploadToCloud(widget.documentId); + } + isUploading = false; + if (result?.isNotEmpty ?? false) { + widget.onUrl.call(result!); + } + } + + Future pasteAsAnImage() async { + final data = await getIt().getData(); + final plainText = data.plainText; + Log.info('pasteAsAnImage plainText:$plainText'); + if (plainText == null) return; + if (isURL(plainText) && (await validateImage(plainText))) { + setState(() { + pickedImages.clear(); + pickedImages.add(_NetworkImage(plainText)); + }); + } + } + + Future validateImage(String imageUrl) async { + Response res; + try { + res = await get(Uri.parse(imageUrl)); + } catch (e) { + return false; + } + if (res.statusCode != 200) return false; + final Map data = res.headers; + return checkIfImage(data['content-type']); + } + + bool checkIfImage(String? param) { + if (param == 'image/jpeg' || + param == 'image/png' || + param == 'image/gif' || + param == 'image/tiff' || + param == 'image/webp' || + param == 'image/svg+xml' || + param == 'image/svg') { + return true; + } + return false; + } +} + +class _ChangeIconButton extends StatelessWidget { + const _ChangeIconButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return SizedBox( + height: 32, + width: 84, + child: FlowyButton( + text: FlowyText( + LocaleKeys.emojiIconPicker_iconUploader_change.tr(), + fontSize: 14.0, + fontWeight: FontWeight.w500, + figmaLineHeight: 20.0, + color: isDark ? Colors.white : Color(0xff1F2329), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + margin: const EdgeInsets.symmetric(horizontal: 14.0), + backgroundColor: Theme.of(context).colorScheme.surface, + hoverColor: + (isDark ? Colors.white : Color(0xffD1D8E0)).withValues(alpha: 0.9), + decoration: BoxDecoration( + border: Border.all(color: isDark ? Colors.white : Color(0xffD1D8E0)), + borderRadius: BorderRadius.circular(10), + ), + onTap: onTap, + ), + ); + } +} + +class _ConfirmButton extends StatelessWidget { + const _ConfirmButton({required this.onTap, this.enable = true}); + + final VoidCallback onTap; + final bool enable; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: Opacity( + opacity: enable ? 1.0 : 0.5, + child: PrimaryRoundedButton( + text: LocaleKeys.button_confirm.tr(), + figmaLineHeight: 20.0, + onTap: enable ? onTap : null, + ), + ), + ); + } +} + +const _svgSuffix = '.svg'; + +class _PasteIntent extends Intent {} + +abstract class _Image { + String get url; + + Future saveToLocal(); + + Future uploadToCloud(String documentId); + + String get pureUrl => url.split('?').first; +} + +class _FileImage extends _Image { + _FileImage(this.url); + + @override + final String url; + + @override + Future saveToLocal() => saveImageToLocalStorage(url); + + @override + Future uploadToCloud(String documentId) async { + final (url, errorMsg) = await saveImageToCloudStorage( + this.url, + documentId, + ); + if (errorMsg?.isNotEmpty ?? false) { + Log.error('upload icon image :${this.url} error :$errorMsg'); + } + return url; + } +} + +class _NetworkImage extends _Image { + _NetworkImage(this.url); + + @override + final String url; + + @override + Future saveToLocal() async { + final file = await CustomImageCacheManager().downloadFile(pureUrl); + return file.file.path; + } + + @override + Future uploadToCloud(String documentId) async { + final file = await CustomImageCacheManager().downloadFile(pureUrl); + final (url, errorMsg) = await saveImageToCloudStorage( + file.file.path, + documentId, + ); + if (errorMsg?.isNotEmpty ?? false) { + Log.error('upload icon image :${this.url} error :$errorMsg'); + } + return url; + } +} 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 0000000000..8c5891bb6e --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/recent_icons.dart @@ -0,0 +1,109 @@ +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(RecentIcon icon) async { + await _put( + FlowyIconType.icon, + jsonEncode(icon.toJson()), + ); + } + + static Future> getEmojiIds() async { + await _load(); + return _dataMap[FlowyIconType.emoji.name] ?? []; + } + + static Future> getIcons() async { + await _load(); + return getIconsSync(); + } + + static List getIconsSync() { + final iconList = _dataMap[FlowyIconType.icon.name] ?? []; + try { + final List result = []; + for (final map in iconList) { + final recentIcon = + RecentIcon.fromJson(jsonDecode(map) as Map); + if (recentIcon.groupName.isEmpty) { + continue; + } + result.add(recentIcon); + } + return result; + } catch (e) { + Log.error('RecentIcons getIcons with :$iconList', e); + } + return []; + } + + @visibleForTesting + static void clear() { + _dataMap.clear(); + getIt().remove(KVKeys.recentIcons); + } + + 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 0000000000..f28ae0f9a8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/tab.dart @@ -0,0 +1,80 @@ +import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; +import 'package:flutter/material.dart'; + +enum PickerTabType { + emoji, + icon, + custom; + + String get tr { + switch (this) { + case PickerTabType.emoji: + return 'Emojis'; + case PickerTabType.icon: + return 'Icons'; + case PickerTabType.custom: + return 'Upload'; + } + } +} + +extension StringToPickerTabType on String { + PickerTabType? toPickerTabType() { + try { + return PickerTabType.values.byName(this); + } on ArgumentError { + return null; + } + } +} + +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/loading.dart b/frontend/appflowy_flutter/lib/shared/loading.dart new file mode 100644 index 0000000000..f8d1c6fc86 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/loading.dart @@ -0,0 +1,49 @@ +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; + } +} 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 0000000000..912f96bd05 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.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:archive/archive.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:share_plus/share_plus.dart'; + +Document customMarkdownToDocument( + String markdown, { + double? tableWidth, +}) { + return markdownToDocument( + markdown, + markdownParsers: [ + const MarkdownCodeBlockParser(), + MarkdownSimpleTableParser(tableWidth: tableWidth), + ], + ); +} + +Future customDocumentToMarkdown( + Document document, { + String path = '', + AsyncValueSetter? onArchive, + String lineBreak = '', +}) async { + final List> fileFutures = []; + + /// create root Archive and directory + final id = document.root.id, + archive = Archive(), + resourceDir = ArchiveFile('$id/', 0, null)..isFile = false, + fileName = p.basenameWithoutExtension(path), + dirName = resourceDir.name; + + String markdown = ''; + try { + markdown = documentToMarkdown( + document, + lineBreak: lineBreak, + customParsers: [ + const MathEquationNodeParser(), + const CalloutNodeParser(), + const ToggleListNodeParser(), + CustomImageNodeFileParser(fileFutures, dirName), + CustomMultiImageNodeFileParser(fileFutures, dirName), + GridNodeParser(fileFutures, dirName), + BoardNodeParser(fileFutures, dirName), + CalendarNodeParser(fileFutures, dirName), + const CustomParagraphNodeParser(), + const SubPageNodeParser(), + const SimpleTableNodeParser(), + const LinkPreviewNodeParser(), + const FileBlockNodeParser(), + ], + ); + } catch (e) { + Log.error('documentToMarkdown error: $e'); + } + + /// create resource directory + if (fileFutures.isNotEmpty) archive.addFile(resourceDir); + + for (final fileFuture in fileFutures) { + archive.addFile(await fileFuture); + } + + /// add markdown file to Archive + final dataBytes = utf8.encode(markdown); + archive.addFile(ArchiveFile('$fileName-$id.md', dataBytes.length, dataBytes)); + + if (archive.isNotEmpty && path.isNotEmpty) { + if (onArchive == null) { + final zipEncoder = ZipEncoder(); + final zip = zipEncoder.encode(archive); + if (zip != null) { + final zipFile = await File(path).writeAsBytes(zip); + if (Platform.isIOS) { + await Share.shareUri(zipFile.uri); + await zipFile.delete(); + } else if (Platform.isAndroid) { + await Share.shareXFiles([XFile(zipFile.path)]); + await zipFile.delete(); + } + Log.info('documentToMarkdownFiles to $path'); + } + } else { + await onArchive.call(archive); + } + } + return markdown; +} diff --git a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart index fce937908a..862f9c4778 100644 --- a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart +++ b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart @@ -7,12 +7,29 @@ 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, .gif, .webm +/// It only allows the following image extensions: .png, .jpg, .jpeg, .gif, .webm, .webp, .bmp /// const _imgUrlPattern = - r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm)(\?[^\s[",><]*)?'; + r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.jpeg|.gif|.webm|.webp|.bmp)(\?[^\s[",><]*)?'; final imgUrlRegex = RegExp(_imgUrlPattern); +const _singleLineMarkdownImagePattern = "^!\\[.*\\]\\(($_hrefPattern)\\)\$"; +final singleLineMarkdownImageRegex = RegExp(_singleLineMarkdownImagePattern); + +/// 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); @@ -21,3 +38,20 @@ 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); + +const _wordPattern = r"\S+"; +final wordRegex = RegExp(_wordPattern); + +const _appleNotesPattern = + r'\s*'; +final appleNotesRegex = RegExp(_appleNotesPattern); 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 0000000000..418dd47f3d --- /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 index 7ae91fc73e..4b5ad56ab1 100644 --- a/frontend/appflowy_flutter/lib/shared/permission/permission_checker.dart +++ b/frontend/appflowy_flutter/lib/shared/permission/permission_checker.dart @@ -9,9 +9,9 @@ import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_d 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'; +import 'package:universal_platform/universal_platform.dart'; class PermissionChecker { static Future checkPhotoPermission(BuildContext context) async { @@ -48,7 +48,7 @@ class PermissionChecker { } else if (status.isDenied) { // https://github.com/Baseflow/flutter-permission-handler/issues/1262#issuecomment-2006340937 Permission permission = Permission.photos; - if (defaultTargetPlatform == TargetPlatform.android && + if (UniversalPlatform.isAndroid && ApplicationInfo.androidSDKVersion <= 32) { permission = Permission.storage; } @@ -61,4 +61,43 @@ class PermissionChecker { 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 0000000000..786d666060 --- /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.withValues(alpha: 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 0000000000..149cadae04 --- /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 0000000000..81831346e9 --- /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 0000000000..b8db272a4b --- /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 0000000000..ba9ce0fabd --- /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/version_checker/version_checker.dart b/frontend/appflowy_flutter/lib/shared/version_checker/version_checker.dart new file mode 100644 index 0000000000..6e50a922a7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/version_checker/version_checker.dart @@ -0,0 +1,96 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:auto_updater/auto_updater.dart'; +import 'package:collection/collection.dart'; +import 'package:http/http.dart' as http; +import 'package:universal_platform/universal_platform.dart'; +import 'package:xml/xml.dart' as xml; + +final versionChecker = VersionChecker(); + +/// Version checker class to handle update checks using appcast XML feeds +class VersionChecker { + factory VersionChecker() => _instance; + + VersionChecker._internal(); + String? _feedUrl; + + static final VersionChecker _instance = VersionChecker._internal(); + + /// Sets the appcast XML feed URL + void setFeedUrl(String url) { + _feedUrl = url; + + if (UniversalPlatform.isWindows || UniversalPlatform.isMacOS) { + autoUpdater.setFeedURL(url); + // disable the auto update check + autoUpdater.setScheduledCheckInterval(0); + } + } + + /// Checks for updates by fetching and parsing the appcast XML + /// Returns a list of [AppcastItem] or throws an exception if the feed URL is not set + Future checkForUpdateInformation() async { + if (_feedUrl == null) { + Log.error('Feed URL is not set'); + return null; + } + + try { + final response = await http.get(Uri.parse(_feedUrl!)); + if (response.statusCode != 200) { + Log.info('Failed to fetch appcast XML: ${response.statusCode}'); + return null; + } + + // Parse XML content + final document = xml.XmlDocument.parse(response.body); + final items = document.findAllElements('item'); + + // Convert XML items to AppcastItem objects + return items + .map(_parseAppcastItem) + .nonNulls + .firstWhereOrNull((e) => e.os == ApplicationInfo.os); + } catch (e) { + Log.info('Failed to check for updates: $e'); + } + + return null; + } + + /// For Windows and macOS, calling this API will trigger the auto updater to check for updates + /// For Linux, it will open the official website in the browser if there is a new version + + Future checkForUpdate() async { + if (UniversalPlatform.isLinux) { + // open the official website in the browser + await afLaunchUrlString('https://appflowy.com/download'); + } else { + await autoUpdater.checkForUpdates(); + } + } + + AppcastItem? _parseAppcastItem(xml.XmlElement item) { + final enclosure = item.findElements('enclosure').firstOrNull; + return AppcastItem.fromJson({ + 'title': item.findElements('title').firstOrNull?.innerText, + 'versionString': item + .findElements('sparkle:shortVersionString') + .firstOrNull + ?.innerText, + 'displayVersionString': item + .findElements('sparkle:shortVersionString') + .firstOrNull + ?.innerText, + 'releaseNotesUrl': + item.findElements('releaseNotesLink').firstOrNull?.innerText, + 'pubDate': item.findElements('pubDate').firstOrNull?.innerText, + 'fileURL': enclosure?.getAttribute('url') ?? '', + 'os': enclosure?.getAttribute('sparkle:os') ?? '', + 'criticalUpdate': + enclosure?.getAttribute('sparkle:criticalUpdate') ?? false, + }); + } +} 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 0000000000..4738be78f3 --- /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 index bd735aa4a7..5a8c0fa651 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -3,8 +3,6 @@ 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/openai_client.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_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'; @@ -12,11 +10,9 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/appflowy_cloud_task.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/auth/supabase_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'; @@ -27,6 +23,7 @@ import 'package:appflowy/workspace/application/settings/appearance/desktop_appea 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'; @@ -35,13 +32,12 @@ 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_editor/appflowy_editor.dart' hide Log; 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:http/http.dart' as http; +import 'package:universal_platform/universal_platform.dart'; class DependencyResolver { static Future resolve( @@ -84,47 +80,13 @@ void _resolveCommonService( () => mode.isTest ? MockApplicationDataStorage() : ApplicationDataStorage(), ); - getIt.registerFactoryAsync( - () async { - final result = await UserBackendService.getCurrentUserProfile(); - return result.fold( - (s) { - return HttpOpenAIRepository( - client: http.Client(), - apiKey: s.openaiKey, - ); - }, - (e) { - throw Exception('Failed to get user profile: ${e.msg}'); - }, - ); - }, - ); - - getIt.registerFactoryAsync( - () async { - final result = await UserBackendService.getCurrentUserProfile(); - return result.fold( - (s) { - return HttpStabilityAIRepository( - client: http.Client(), - apiKey: s.stabilityAiKey, - ); - }, - (e) { - throw Exception('Failed to get user profile: ${e.msg}'); - }, - ); - }, - ); - getIt.registerFactory( () => ClipboardService(), ); // theme getIt.registerFactory( - () => PlatformExtension.isMobile ? MobileAppearance() : DesktopAppearance(), + () => UniversalPlatform.isMobile ? MobileAppearance() : DesktopAppearance(), ); getIt.registerFactory( @@ -140,13 +102,10 @@ void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { case AuthenticatorType.local: getIt.registerFactory( () => BackendAuthService( - AuthenticatorPB.Local, + AuthTypePB.Local, ), ); break; - case AuthenticatorType.supabase: - getIt.registerFactory(() => SupabaseAuthService()); - break; case AuthenticatorType.appflowyCloud: case AuthenticatorType.appflowyCloudSelfHost: case AuthenticatorType.appflowyCloudDevelop: @@ -168,6 +127,9 @@ void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { getIt.registerFactory(() => SplashBloc()); getIt.registerLazySingleton(() => NetworkListener()); getIt.registerLazySingleton(() => CachedRecentService()); + getIt.registerLazySingleton( + () => SubscriptionSuccessListenable(), + ); } void _resolveHomeDeps(GetIt getIt) { @@ -180,8 +142,8 @@ void _resolveHomeDeps(GetIt getIt) { ); // share - getIt.registerFactoryParam( - (view, _) => DocumentShareBloc(view: view), + getIt.registerFactoryParam( + (view, _) => ShareBloc(view: view), ); getIt.registerSingleton(ActionNavigationBloc()); @@ -206,11 +168,6 @@ void _resolveFolderDeps(GetIt getIt) { ), ); - // Settings - getIt.registerFactoryParam( - (user, _) => SettingsDialogBloc(user), - ); - // User getIt.registerFactoryParam( (user, _) => SettingsUserViewBloc(user), diff --git a/frontend/appflowy_flutter/lib/startup/entry_point.dart b/frontend/appflowy_flutter/lib/startup/entry_point.dart index 13987751b3..33eb1bf982 100644 --- a/frontend/appflowy_flutter/lib/startup/entry_point.dart +++ b/frontend/appflowy_flutter/lib/startup/entry_point.dart @@ -6,8 +6,6 @@ import 'package:flutter/material.dart'; class AppFlowyApplication implements EntryPoint { @override Widget create(LaunchConfiguration config) { - return SplashScreen( - isAnon: config.isAnon, - ); + return SplashScreen(isAnon: config.isAnon); } } diff --git a/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart b/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart index e675cf1459..5bb08e3fdf 100644 --- a/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart +++ b/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart @@ -1,12 +1,12 @@ -library flowy_plugin; - -import 'package:flutter/widgets.dart'; +library; 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"; @@ -18,6 +18,7 @@ enum PluginType { board, calendar, databaseDocument, + chat, } typedef PluginId = String; @@ -57,7 +58,7 @@ abstract class PluginBuilder { /// 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 => ViewLayoutPB.Document; + ViewLayoutPB? get layoutType; } abstract class PluginConfig { @@ -71,14 +72,22 @@ abstract class PluginWidgetBuilder with NavigationItem { EdgeInsets get contentPadding => const EdgeInsets.symmetric(horizontal: 40, vertical: 28); - Widget buildWidget({PluginContext? context, required bool shrinkWrap}); + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + Map? data, + }); } class PluginContext { - PluginContext({required this.onDeleted}); + PluginContext({ + this.userProfile, + this.onDeleted, + }); // calls when widget of the plugin get deleted - final Function(ViewPB, int?) onDeleted; + final Function(ViewPB, int?)? onDeleted; + final UserProfilePB? userProfile; } void registerPlugin({required PluginBuilder builder, PluginConfig? config}) { diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index d285b20dcd..7a282b3856 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -1,13 +1,15 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:appflowy/env/cloud_env.dart'; -import 'package:appflowy/startup/tasks/feature_flag_task.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'package:appflowy/util/expand_views.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'; @@ -15,6 +17,7 @@ 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; @@ -30,6 +33,8 @@ class FlowyRunnerContext { } Future runAppFlowy({bool isAnon = false}) async { + Log.info('restart AppFlowy: isAnon: $isAnon'); + if (kReleaseMode) { await FlowyRunner.run( AppFlowyApplication(), @@ -110,21 +115,24 @@ class FlowyRunner { [ // this task should be first task, for handling platform errors. // don't catch errors in test mode - if (!mode.isUnitTest) const PlatformErrorCatcherTask(), + 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(), + DebugTask(), const FeatureFlagTask(), // localization const InitLocalizationTask(), // init the app window - const InitAppWindowTask(), + InitAppWindowTask(), // Init Rust SDK InitRustSDKTask(customApplicationPath: applicationDataDirectory), // Load Plugins, like document, grid ... const PluginLoadTask(), + const FileStorageTask(), // init the app widget // ignore in test mode @@ -132,8 +140,9 @@ class FlowyRunner { // 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(), + // The auto update task should be placed after the ApplicationInfoTask to fetch the latest version. + if (!mode.isIntegrationTest) AutoUpdateTask(), const HotKeyTask(), - if (isSupabaseEnabled) InitSupabaseTask(), if (isAppFlowyCloudEnabled) InitAppFlowyCloudTask(), const InitAppWidgetTask(), const InitPlatformServiceTask(), @@ -177,6 +186,11 @@ Future initGetIt( }, ); getIt.registerSingleton(PluginSandbox()); + getIt.registerSingleton(ViewExpanderRegistry()); + getIt.registerSingleton(LinkHoverTriggers()); + getIt.registerSingleton( + FloatingToolbarController(), + ); await DependencyResolver.resolve(getIt, mode); } @@ -202,6 +216,7 @@ abstract class LaunchTask { LaunchTaskType get type => LaunchTaskType.dataProcessing; Future initialize(LaunchContext context); + Future dispose(); } @@ -243,7 +258,9 @@ enum IntegrationMode { // test mode bool get isTest => isUnitTest || isIntegrationTest; + bool get isUnitTest => this == IntegrationMode.unitTest; + bool get isIntegrationTest => this == IntegrationMode.integrationTest; // release mode diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index ad1fc542f5..98b76802d4 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -1,13 +1,11 @@ import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - 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/reminder/reminder_bloc.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'; @@ -16,16 +14,23 @@ import 'package:appflowy/workspace/application/notification/notification_service 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:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:appflowy_ui/appflowy_ui.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'; @@ -41,6 +46,8 @@ class InitAppWidgetTask extends LaunchTask { await NotificationService.initialize(); + await loadIconGroups(); + final widget = context.getIt().create(context.config); final appearanceSetting = await UserSettingsBackendService().getAppearanceSetting(); @@ -57,7 +64,6 @@ class InitAppWidgetTask extends LaunchTask { child: widget, ); - Bloc.observer = ApplicationBlocObserver(); runApp( EasyLocalization( supportedLocales: const [ @@ -74,6 +80,7 @@ class InitAppWidgetTask extends LaunchTask { Locale('el', 'GR'), Locale('fr', 'FR'), Locale('fr', 'CA'), + Locale('he'), Locale('hu', 'HU'), Locale('id', 'ID'), Locale('it', 'IT'), @@ -92,6 +99,7 @@ class InitAppWidgetTask extends LaunchTask { Locale('zh', 'TW'), Locale('fa'), Locale('hin'), + Locale('mr', 'IN'), ], path: 'assets/translations', fallbackLocale: const Locale('en'), @@ -164,9 +172,6 @@ class _ApplicationWidgetState extends State { ), BlocProvider.value(value: getIt()), BlocProvider.value(value: getIt()), - BlocProvider.value( - value: getIt()..add(const ReminderEvent.started()), - ), ], child: BlocListener( listenWhen: (_, curr) => curr.action != null, @@ -174,20 +179,32 @@ class _ApplicationWidgetState extends State { final action = state.action; WidgetsBinding.instance.addPostFrameCallback((_) { if (action?.type == ActionType.openView && - PlatformExtension.isDesktop) { - final view = action!.arguments?[ActionArgumentKeys.view]; + 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) { - AppGlobals.rootNavKey.currentContext?.pushView(view); + getIt().openPlugin( + view, + arguments: { + PluginArgumentKeys.selection: nodePath, + PluginArgumentKeys.blockId: blockId, + }, + ); } } else if (action?.type == ActionType.openRow && - PlatformExtension.isMobile) { + 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, { - PluginArgumentKeys.rowId: rowId, - }); + AppGlobals.rootNavKey.currentContext?.pushView( + view, + arguments: { + PluginArgumentKeys.rowId: rowId, + }, + ); } } }); @@ -195,31 +212,59 @@ class _ApplicationWidgetState extends State { child: BlocBuilder( builder: (context, state) { _setSystemOverlayStyle(state); - return 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, - !PlatformExtension.isMobile && FeatureFlag.search.isOn - ? CommandPalette( - notifier: _commandPaletteNotifier, - child: child, - ) - : child, + 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( + debugShowCheckedModeBanner: false, + theme: state.lightTheme, + darkTheme: state.darkTheme, + themeMode: state.themeMode, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: state.locale, + routerConfig: routerConfig, + builder: (context, child) { + final themeBuilder = AppFlowyDefaultTheme(); + final brightness = Theme.of(context).brightness; + + return AnimatedAppFlowyTheme( + data: brightness == Brightness.light + ? themeBuilder.light() + : themeBuilder.dark(), + 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, ); }, ), @@ -229,23 +274,12 @@ class _ApplicationWidgetState extends State { void _setSystemOverlayStyle(AppearanceSettingsState state) { if (Platform.isAndroid) { - SystemUiOverlayStyle style = SystemUiOverlayStyle.dark; - final themeMode = state.themeMode; - if (themeMode == ThemeMode.dark) { - style = SystemUiOverlayStyle.light; - } else if (themeMode == ThemeMode.light) { - style = SystemUiOverlayStyle.dark; - } else { - final brightness = Theme.of(context).brightness; - // reverse the brightness of the system status bar. - style = brightness == Brightness.dark - ? SystemUiOverlayStyle.light - : SystemUiOverlayStyle.dark; - } - + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + overlays: [], + ); SystemChrome.setSystemUIOverlayStyle( - style.copyWith( - statusBarColor: Colors.transparent, + const SystemUiOverlayStyle( systemNavigationBarColor: Colors.transparent, ), ); @@ -254,17 +288,11 @@ class _ApplicationWidgetState extends State { } class AppGlobals { - // static GlobalKey scaffoldMessengerKey = GlobalKey(); static GlobalKey rootNavKey = GlobalKey(); - static NavigatorState get nav => rootNavKey.currentState!; -} -class ApplicationBlocObserver extends BlocObserver { - @override - void onError(BlocBase bloc, Object error, StackTrace stackTrace) { - Log.debug(error); - super.onError(bloc, error, stackTrace); - } + static NavigatorState get nav => rootNavKey.currentState!; + + static BuildContext get context => rootNavKey.currentContext!; } Future appTheme(String themeName) async { 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 index 8a40051211..5636ed70cb 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart @@ -6,12 +6,19 @@ import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/startup/startup.dart'; class WindowSizeManager { - static const double minWindowHeight = 600.0; - static const double minWindowWidth = 800.0; + 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; + // Default windows size + static const double defaultWindowHeight = 960.0; + static const double defaultWindowWidth = 1280.0; + + static const double maxScaleFactor = 2.0; + static const double minScaleFactor = 0.5; + static const width = 'width'; static const height = 'height'; @@ -32,7 +39,10 @@ class WindowSizeManager { Future getSize() async { final defaultWindowSize = jsonEncode( - {WindowSizeManager.height: 600.0, WindowSizeManager.width: 800.0}, + { + WindowSizeManager.height: defaultWindowHeight, + WindowSizeManager.width: defaultWindowWidth, + }, ); final windowSize = await getIt().get(KVKeys.windowSize); final size = json.decode( @@ -64,4 +74,35 @@ class WindowSizeManager { 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 index b8da2cd1ad..362b27a85a 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -5,12 +5,13 @@ 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/startup/tasks/supabase_task.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'; @@ -20,48 +21,50 @@ 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() { - if (_deeplinkSubscription == null) { - _deeplinkSubscription = _appLinks.uriLinkStream.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(); - _deeplinkSubscription = null; - }, - ); - if (Platform.isWindows) { - // register deep link for Windows - registerProtocolHandler(appflowyDeepLinkSchema); - } - } else { - _deeplinkSubscription?.resume(); + _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); } } - final _appLinks = AppLinks(); - ValueNotifier? _stateNotifier = ValueNotifier(null); + Completer>? _completer; - // The AppLinks is a singleton, so we need to cancel the previous subscription - // before creating a new one. - static StreamSubscription? _deeplinkSubscription; + set completer(Completer>? value) { + Log.debug('AppFlowyCloudDeepLink: $hashCode completer'); + _completer = value; + } + + late final StreamSubscription _deepLinkSubscription; Future dispose() async { - _deeplinkSubscription?.pause(); + Log.debug('AppFlowyCloudDeepLink: $hashCode dispose'); + await _deepLinkSubscription.cancel(); + _stateNotifier?.dispose(); _stateNotifier = null; + completer = null; } void registerCompleter( Completer> completer, ) { - _completer = completer; + this.completer = completer; } VoidCallback subscribeDeepLinkLoadingState( @@ -80,6 +83,13 @@ class AppFlowyCloudDeepLink { void unsubscribeDeepLinkLoadingState(VoidCallback listener) => _stateNotifier?.removeListener(listener); + Future passGotrueTokenResponse( + GotrueTokenResponsePB gotrueTokenResponse, + ) async { + final uri = _buildDeepLinkUri(gotrueTokenResponse); + await _handleUri(uri); + } + Future _handleUri( Uri? uri, ) async { @@ -88,15 +98,21 @@ class AppFlowyCloudDeepLink { if (uri == null) { Log.error('onDeepLinkError: Unexpected empty deep link callback'); _completer?.complete(FlowyResult.failure(AuthError.emptyDeepLink)); - _completer = null; + 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, + authenticator: AuthTypePB.Server, map: { AuthServiceMapKeys.signInURL: uri.toString(), AuthServiceMapKeys.deviceId: deviceId, @@ -119,16 +135,15 @@ class AppFlowyCloudDeepLink { Log.error(err); final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { - showSnackBarMessage( - context, - err.msg, + showToastNotification( + message: err.msg, ); } }, ); } else { _completer?.complete(result); - _completer = null; + completer = null; } }, (err) { @@ -143,7 +158,7 @@ class AppFlowyCloudDeepLink { } } else { _completer?.complete(FlowyResult.failure(err)); - _completer = null; + completer = null; } }, ); @@ -160,6 +175,61 @@ class AppFlowyCloudDeepLink { ..msg = uri.path, ); } + + bool _isPaymentSuccessUri(Uri uri) { + return uri.host == 'payment-success'; + } + + Uri? _buildDeepLinkUri(GotrueTokenResponsePB gotrueTokenResponse) { + final params = {}; + + if (gotrueTokenResponse.hasAccessToken() && + gotrueTokenResponse.accessToken.isNotEmpty) { + params['access_token'] = gotrueTokenResponse.accessToken; + } + + if (gotrueTokenResponse.hasExpiresAt()) { + params['expires_at'] = gotrueTokenResponse.expiresAt.toString(); + } + + if (gotrueTokenResponse.hasExpiresIn()) { + params['expires_in'] = gotrueTokenResponse.expiresIn.toString(); + } + + if (gotrueTokenResponse.hasProviderRefreshToken() && + gotrueTokenResponse.providerRefreshToken.isNotEmpty) { + params['provider_refresh_token'] = + gotrueTokenResponse.providerRefreshToken; + } + + if (gotrueTokenResponse.hasProviderAccessToken() && + gotrueTokenResponse.providerAccessToken.isNotEmpty) { + params['provider_token'] = gotrueTokenResponse.providerAccessToken; + } + + if (gotrueTokenResponse.hasRefreshToken() && + gotrueTokenResponse.refreshToken.isNotEmpty) { + params['refresh_token'] = gotrueTokenResponse.refreshToken; + } + + if (gotrueTokenResponse.hasTokenType() && + gotrueTokenResponse.tokenType.isNotEmpty) { + params['token_type'] = gotrueTokenResponse.tokenType; + } + + if (params.isEmpty) { + return null; + } + + final fragment = params.entries + .map( + (e) => + '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}', + ) + .join('&'); + + return Uri.parse('appflowy-flutter://login-callback#$fragment'); + } } class InitAppFlowyCloudTask extends LaunchTask { @@ -208,3 +278,35 @@ enum DeepLinkState { 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/auto_update_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/auto_update_task.dart new file mode 100644 index 0000000000..b666392544 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/auto_update_task.dart @@ -0,0 +1,205 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/version_checker/version_checker.dart'; +import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:auto_updater/auto_updater.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../startup.dart'; + +class AutoUpdateTask extends LaunchTask { + AutoUpdateTask(); + + static const _feedUrl = + 'https://github.com/AppFlowy-IO/AppFlowy/releases/latest/download/appcast-{os}-{arch}.xml'; + final _listener = _AppFlowyAutoUpdaterListener(); + + @override + Future initialize(LaunchContext context) async { + // the auto updater is not supported on mobile + if (UniversalPlatform.isMobile) { + return; + } + + // don't use await here, because the auto updater is not a blocking operation + unawaited(_setupAutoUpdater()); + + ApplicationInfo.isCriticalUpdateNotifier.addListener( + _showCriticalUpdateDialog, + ); + } + + @override + Future dispose() async { + autoUpdater.removeListener(_listener); + + ApplicationInfo.isCriticalUpdateNotifier.removeListener( + _showCriticalUpdateDialog, + ); + } + + // On macOS and windows, we use auto_updater to check for updates. + // On linux, we use the version checker to check for updates because the auto_updater is not supported. + Future _setupAutoUpdater() async { + Log.info( + '[AutoUpdate] current version: ${ApplicationInfo.applicationVersion}, current cpu architecture: ${ApplicationInfo.architecture}', + ); + + // Since the appcast.xml is not supported the arch, we separate the feed url by os and arch. + final feedUrl = _feedUrl + .replaceAll('{os}', ApplicationInfo.os) + .replaceAll('{arch}', ApplicationInfo.architecture); + + // the auto updater is only supported on macOS and windows, so we don't need to check the platform + if (UniversalPlatform.isMacOS || UniversalPlatform.isWindows) { + autoUpdater.addListener(_listener); + } + + Log.info('[AutoUpdate] feed url: $feedUrl'); + + versionChecker.setFeedUrl(feedUrl); + final item = await versionChecker.checkForUpdateInformation(); + if (item != null) { + ApplicationInfo.latestAppcastItem = item; + ApplicationInfo.latestVersionNotifier.value = + item.displayVersionString ?? ''; + } + } + + void _showCriticalUpdateDialog() { + showCustomConfirmDialog( + context: AppGlobals.rootNavKey.currentContext!, + title: LocaleKeys.autoUpdate_criticalUpdateTitle.tr(), + description: LocaleKeys.autoUpdate_criticalUpdateDescription.tr( + namedArgs: { + 'currentVersion': ApplicationInfo.applicationVersion, + 'newVersion': ApplicationInfo.latestVersion, + }, + ), + builder: (context) => const SizedBox.shrink(), + // if the update is critical, dont allow the user to dismiss the dialog + barrierDismissible: false, + showCloseButton: false, + enableKeyboardListener: false, + closeOnConfirm: false, + confirmLabel: LocaleKeys.autoUpdate_criticalUpdateButton.tr(), + onConfirm: () async { + await versionChecker.checkForUpdate(); + }, + ); + } +} + +class _AppFlowyAutoUpdaterListener extends UpdaterListener { + @override + void onUpdaterBeforeQuitForUpdate(AppcastItem? item) {} + + @override + void onUpdaterCheckingForUpdate(Appcast? appcast) { + // Due to the reason documented in the following link, the update will not be found if the user has skipped the update. + // We have to check the skipped version manually. + // https://sparkle-project.org/documentation/api-reference/Classes/SPUUpdater.html#/c:objc(cs)SPUUpdater(im)checkForUpdateInformation + final items = appcast?.items; + if (items != null) { + final String? currentPlatform; + if (UniversalPlatform.isMacOS) { + currentPlatform = 'macos'; + } else if (UniversalPlatform.isWindows) { + currentPlatform = 'windows'; + } else { + currentPlatform = null; + } + + final matchingItem = items.firstWhereOrNull( + (item) => item.os == currentPlatform, + ); + + if (matchingItem != null) { + _updateVersionNotifier(matchingItem); + + Log.info( + '[AutoUpdate] latest version: ${matchingItem.displayVersionString}', + ); + } + } + } + + @override + void onUpdaterError(UpdaterError? error) { + Log.error('[AutoUpdate] On update error: $error'); + } + + @override + void onUpdaterUpdateNotAvailable(UpdaterError? error) { + Log.info('[AutoUpdate] Update not available $error'); + } + + @override + void onUpdaterUpdateAvailable(AppcastItem? item) { + _updateVersionNotifier(item); + + Log.info('[AutoUpdate] Update available: ${item?.displayVersionString}'); + } + + @override + void onUpdaterUpdateDownloaded(AppcastItem? item) { + Log.info('[AutoUpdate] Update downloaded: ${item?.displayVersionString}'); + } + + @override + void onUpdaterUpdateCancelled(AppcastItem? item) { + _updateVersionNotifier(item); + + Log.info('[AutoUpdate] Update cancelled: ${item?.displayVersionString}'); + } + + @override + void onUpdaterUpdateInstalled(AppcastItem? item) { + _updateVersionNotifier(item); + + Log.info('[AutoUpdate] Update installed: ${item?.displayVersionString}'); + } + + @override + void onUpdaterUpdateSkipped(AppcastItem? item) { + _updateVersionNotifier(item); + + Log.info('[AutoUpdate] Update skipped: ${item?.displayVersionString}'); + } + + void _updateVersionNotifier(AppcastItem? item) { + if (item != null) { + ApplicationInfo.latestAppcastItem = item; + ApplicationInfo.latestVersionNotifier.value = + item.displayVersionString ?? ''; + } + } +} + +class AppFlowyAutoUpdateVersion { + AppFlowyAutoUpdateVersion({ + required this.latestVersion, + required this.currentVersion, + required this.isForceUpdate, + }); + + factory AppFlowyAutoUpdateVersion.initial() => AppFlowyAutoUpdateVersion( + latestVersion: '0.0.0', + currentVersion: '0.0.0', + isForceUpdate: false, + ); + + final String latestVersion; + final String currentVersion; + + final bool isForceUpdate; + + bool get isUpdateAvailable => latestVersion != currentVersion; +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart index 3fe2513565..9a34e84f70 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart @@ -1,18 +1,45 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; - -import '../startup.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:talker/talker.dart'; +import 'package:talker_bloc_logger/talker_bloc_logger.dart'; +import 'package:universal_platform/universal_platform.dart'; class DebugTask extends LaunchTask { - const DebugTask(); + DebugTask(); + + final Talker talker = Talker(); @override Future initialize(LaunchContext context) async { - // the hotkey manager is not supported on mobile - if (PlatformExtension.isMobile && kDebugMode) { + // hide the keyboard on mobile + if (UniversalPlatform.isMobile && kDebugMode) { await SystemChannels.textInput.invokeMethod('TextInput.hide'); } + + // log the bloc events + if (kDebugMode) { + Bloc.observer = TalkerBlocObserver( + talker: talker, + settings: TalkerBlocLoggerSettings( + // Disabled by default to prevent mixing with AppFlowy logs + // Enable to observe all bloc events + enabled: false, + printEventFullData: false, + printStateFullData: false, + printChanges: true, + printClosings: true, + printCreations: true, + transitionFilter: (_, transition) { + // By default, observe all transitions + // You can add your own filter here if needed + // when you want to observer a specific bloc + return true; + }, + ), + ); + } } @override diff --git a/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart index 61e1f52460..2c90afbdda 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart @@ -1,8 +1,11 @@ import 'dart:io'; import 'package:appflowy_backend/log.dart'; +import 'package:auto_updater/auto_updater.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:version/version.dart'; import '../startup.dart'; @@ -11,6 +14,42 @@ class ApplicationInfo { static String applicationVersion = ''; static String buildNumber = ''; static String deviceId = ''; + static String architecture = ''; + static String os = ''; + + // macOS major version + static int? macOSMajorVersion; + static int? macOSMinorVersion; + + // latest version + static ValueNotifier latestVersionNotifier = ValueNotifier(''); + // the version number is like 0.9.0 + static String get latestVersion => latestVersionNotifier.value; + + // If the latest version is greater than the current version, it means there is an update available + static bool get isUpdateAvailable { + try { + if (latestVersion.isEmpty) { + return false; + } + return Version.parse(latestVersion) > Version.parse(applicationVersion); + } catch (e) { + return false; + } + } + + // the latest appcast item + static AppcastItem? _latestAppcastItem; + static AppcastItem? get latestAppcastItem => _latestAppcastItem; + static set latestAppcastItem(AppcastItem? value) { + _latestAppcastItem = value; + + isCriticalUpdateNotifier.value = value?.criticalUpdate == true; + } + + // is critical update + static ValueNotifier isCriticalUpdateNotifier = ValueNotifier(false); + static bool get isCriticalUpdate => isCriticalUpdateNotifier.value; } class ApplicationInfoTask extends LaunchTask { @@ -21,43 +60,65 @@ class ApplicationInfoTask extends LaunchTask { 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; - } + ApplicationInfo.applicationVersion = packageInfo.version; + ApplicationInfo.buildNumber = packageInfo.buildNumber; String? deviceId; + String? architecture; + String? os; try { if (Platform.isAndroid) { final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo; deviceId = androidInfo.device; + architecture = androidInfo.supportedAbis.firstOrNull; + os = 'android'; } else if (Platform.isIOS) { final IosDeviceInfo iosInfo = await deviceInfoPlugin.iosInfo; deviceId = iosInfo.identifierForVendor; + architecture = iosInfo.utsname.machine; + os = 'ios'; } else if (Platform.isMacOS) { final MacOsDeviceInfo macInfo = await deviceInfoPlugin.macOsInfo; deviceId = macInfo.systemGUID; + architecture = macInfo.arch; + os = 'macos'; } else if (Platform.isWindows) { final WindowsDeviceInfo windowsInfo = await deviceInfoPlugin.windowsInfo; deviceId = windowsInfo.deviceId; + // we only support x86_64 on Windows + architecture = 'x86_64'; + os = 'windows'; } else if (Platform.isLinux) { final LinuxDeviceInfo linuxInfo = await deviceInfoPlugin.linuxInfo; deviceId = linuxInfo.machineId; + // we only support x86_64 on Linux + architecture = 'x86_64'; + os = 'linux'; } else { deviceId = null; + architecture = null; + os = null; } } catch (e) { Log.error('Failed to get platform version, $e'); } ApplicationInfo.deviceId = deviceId ?? ''; + ApplicationInfo.architecture = architecture ?? ''; + ApplicationInfo.os = os ?? ''; } @override 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 0000000000..0695ceeab5 --- /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 index 6f1a338c3f..e64e0f98de 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -1,5 +1,6 @@ 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'; @@ -9,12 +10,14 @@ import 'package:appflowy/mobile/presentation/database/mobile_calendar_events_scr 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_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'; @@ -26,12 +29,16 @@ 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/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:appflowy_editor/appflowy_editor.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( @@ -44,12 +51,11 @@ GoRouter generateRouter(Widget child) { // Routes in both desktop and mobile _signInScreenRoute(), _skipLogInScreenRoute(), - _encryptSecretScreenRoute(), _workspaceErrorScreenRoute(), // Desktop only - if (!PlatformExtension.isMobile) _desktopHomeScreenRoute(), + if (UniversalPlatform.isDesktop) _desktopHomeScreenRoute(), // Mobile only - if (PlatformExtension.isMobile) ...[ + if (UniversalPlatform.isMobile) ...[ // settings _mobileHomeSettingPageRoute(), _mobileCloudSettingAppFlowyCloudPageRoute(), @@ -61,6 +67,7 @@ GoRouter generateRouter(Widget child) { _mobileGridScreenRoute(), _mobileBoardScreenRoute(), _mobileCalendarScreenRoute(), + _mobileChatScreenRoute(), // card detail page _mobileCardDetailScreenRoute(), _mobileDateCellEditScreenRoute(), @@ -90,6 +97,12 @@ GoRouter generateRouter(Widget child) { _mobileCalendarEventsPageRoute(), _mobileBlockSettingsPageRoute(), + + // notifications + _mobileNotificationMultiSelectPageRoute(), + + // invite members + _mobileInviteMembersPageRoute(), ], // Desktop and Mobile @@ -106,18 +119,6 @@ GoRouter generateRouter(Widget child) { ); }, ), - GoRoute( - path: SignUpScreen.routeName, - pageBuilder: (context, state) { - return CustomTransitionPage( - child: SignUpScreen( - router: getIt(), - ), - transitionsBuilder: _buildFadeTransition, - transitionDuration: _slowDuration, - ); - }, - ), ], ); } @@ -157,33 +158,11 @@ StatefulShellRoute _mobileHomeScreenWithNavigationBarRoute() { ), ], ), - // Enable search feature after we have a search page. - // StatefulShellBranch( - // routes: [ - // GoRoute( - // path: '/d', - // builder: (BuildContext context, GoRouterState state) => - // const RootPlaceholderScreen( - // label: 'Search', - // detailsPath: '/d/details', - // ), - // routes: [ - // GoRoute( - // path: 'details', - // builder: (BuildContext context, GoRouterState state) => - // const DetailsPlaceholderScreen( - // label: 'Search Page details', - // ), - // ), - // ], - // ), - // ], - // ), StatefulShellBranch( routes: [ GoRoute( - path: MobileNotificationsScreen.routeName, - builder: (_, __) => const MobileNotificationsScreen(), + path: MobileNotificationsScreenV2.routeName, + builder: (_, __) => const MobileNotificationsScreenV2(), ), ], ), @@ -201,6 +180,30 @@ GoRoute _mobileHomeSettingPageRoute() { ); } +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, @@ -268,10 +271,35 @@ GoRoute _mobileEmojiPickerPageRoute() { pageBuilder: (context, state) { final title = state.uri.queryParameters[MobileEmojiPickerScreen.pageTitle]; + final selectTabs = + state.uri.queryParameters[MobileEmojiPickerScreen.selectTabs] ?? ''; + final selectedType = state + .uri.queryParameters[MobileEmojiPickerScreen.iconSelectedType] + ?.toPickerTabType(); + final documentId = + state.uri.queryParameters[MobileEmojiPickerScreen.uploadDocumentId]; + List tabs = []; + try { + tabs = selectTabs + .split('-') + .map((e) => PickerTabType.values.byName(e)) + .toList(); + } on ArgumentError catch (e) { + Log.error('convert selectTabs to pickerTab error', e); + } return MaterialExtendedPage( - child: MobileEmojiPickerScreen( - title: title, - ), + child: tabs.isEmpty + ? MobileEmojiPickerScreen( + title: title, + selectedType: selectedType, + documentId: documentId, + ) + : MobileEmojiPickerScreen( + title: title, + selectedType: selectedType, + tabs: tabs, + documentId: documentId, + ), ); }, ); @@ -430,23 +458,6 @@ GoRoute _workspaceErrorScreenRoute() { ); } -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, @@ -480,9 +491,54 @@ GoRoute _mobileEditorScreenRoute() { 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]; + + final selectTabs = + state.uri.queryParameters[MobileDocumentScreen.viewSelectTabs] ?? ''; + List tabs = []; + try { + tabs = selectTabs + .split('-') + .map((e) => PickerTabType.values.byName(e)) + .toList(); + } on ArgumentError catch (e) { + Log.error('convert selectTabs to pickerTab error', e); + } + if (tabs.isEmpty) { + tabs = const [PickerTabType.emoji, PickerTabType.icon]; + } return MaterialExtendedPage( - child: MobileDocumentScreen(id: id, title: title), + child: MobileDocumentScreen( + id: id, + title: title, + showMoreButton: showMoreButton ?? true, + fixedTitle: fixedTitle, + blockId: blockId, + tabs: tabs, + ), + ); + }, + ); +} + +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), ); }, ); @@ -547,10 +603,25 @@ GoRoute _mobileCardDetailScreenRoute() { parentNavigatorKey: AppGlobals.rootNavKey, path: MobileRowDetailPage.routeName, pageBuilder: (context, state) { - final args = state.extra as Map; + var extra = state.extra as Map?; + + if (kDebugMode && extra == null) { + extra = _dynamicValues; + } + + if (extra == null) { + return const MaterialExtendedPage( + child: SizedBox.shrink(), + ); + } + final databaseController = - args[MobileRowDetailPage.argDatabaseController]; - final rowId = args[MobileRowDetailPage.argRowId]!; + extra[MobileRowDetailPage.argDatabaseController]; + final rowId = extra[MobileRowDetailPage.argRowId]!; + + if (kDebugMode) { + _dynamicValues = extra; + } return MaterialExtendedPage( child: MobileRowDetailPage( @@ -595,7 +666,7 @@ GoRoute _rootRoute(Widget child) { (user) => DesktopHomeScreen.routeName, (error) => null, ); - if (routeName != null && !PlatformExtension.isMobile) return routeName; + if (routeName != null && !UniversalPlatform.isMobile) return routeName; return null; }, @@ -618,3 +689,8 @@ Widget _buildFadeTransition( 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 index 0475d9de77..9e23a0017c 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/hot_key.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/hot_key.dart @@ -1,5 +1,5 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:universal_platform/universal_platform.dart'; import '../startup.dart'; @@ -9,7 +9,7 @@ class HotKeyTask extends LaunchTask { @override Future initialize(LaunchContext context) async { // the hotkey manager is not supported on mobile - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { return; } await hotKeyManager.unregisterAll(); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart b/frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart index 3899959b02..9a75607d74 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart @@ -1,3 +1,4 @@ +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'; @@ -29,6 +30,14 @@ class PluginLoadTask extends LaunchTask { builder: DatabaseDocumentPluginBuilder(), config: DatabaseDocumentPluginConfig(), ); + registerPlugin( + builder: DatabaseDocumentPluginBuilder(), + config: DatabaseDocumentPluginConfig(), + ); + registerPlugin( + builder: AIChatPluginBuilder(), + config: AIChatPluginConfig(), + ); } @override diff --git a/frontend/appflowy_flutter/lib/startup/tasks/platform_error_catcher.dart b/frontend/appflowy_flutter/lib/startup/tasks/platform_error_catcher.dart index 9d088bb5d4..c2c64536b2 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/platform_error_catcher.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/platform_error_catcher.dart @@ -1,5 +1,7 @@ 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'; @@ -17,6 +19,23 @@ class PlatformErrorCatcherTask extends LaunchTask { 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 diff --git a/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart b/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart index 84c379da24..9e8f9df49a 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart @@ -1,7 +1,9 @@ export 'app_widget.dart'; export 'appflowy_cloud_task.dart'; +export 'auto_update_task.dart'; export 'debug_task.dart'; export 'device_info_task.dart'; +export 'feature_flag_task.dart'; export 'generate_router.dart'; export 'hot_key.dart'; export 'load_plugin.dart'; @@ -9,7 +11,7 @@ export 'localization.dart'; export 'memory_leak_detector.dart'; export 'platform_error_catcher.dart'; export 'platform_service.dart'; -export 'rust_sdk.dart'; -export 'supabase_task.dart'; -export 'windows.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/rust_sdk.dart b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart index c02b450d79..c406dd161a 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart @@ -5,9 +5,8 @@ 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 'package:path_provider/path_provider.dart'; import '../startup.dart'; @@ -29,7 +28,6 @@ class InitRustSDKTask extends LaunchTask { 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, @@ -63,7 +61,6 @@ AppFlowyConfiguration _makeAppFlowyConfiguration( device_id: deviceId, platform: Platform.operatingSystem, authenticator_type: env.authenticatorType.value, - supabase_config: env.supabaseConfig, appflowy_cloud_config: env.appflowyCloudConfig, envs: rustEnvs, ); @@ -76,10 +73,10 @@ Future appFlowyApplicationDataDirectory() async { case IntegrationMode.develop: final Directory documentsDir = await getApplicationSupportDirectory() .then((directory) => directory.create()); - return Directory(path.join(documentsDir.path, 'data_dev')).create(); + return Directory(path.join(documentsDir.path, 'data_dev')); case IntegrationMode.release: final Directory documentsDir = await getApplicationSupportDirectory(); - return Directory(path.join(documentsDir.path, 'data')).create(); + return Directory(path.join(documentsDir.path, 'data')); 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 0000000000..9076569a9c --- /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/supabase_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/supabase_task.dart deleted file mode 100644 index cb8981acdd..0000000000 --- a/frontend/appflowy_flutter/lib/startup/tasks/supabase_task.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:appflowy/env/cloud_env.dart'; -import 'package:appflowy/user/application/supabase_realtime.dart'; -import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; -import 'package:flutter/foundation.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:path/path.dart' as p; -import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:url_protocol/url_protocol.dart'; - -import '../startup.dart'; - -// ONLY supports in macOS and Windows now. -// -// If you need to update the schema, please update the following files: -// - appflowy_flutter/macos/Runner/Info.plist (macOS) -// - the callback url in Supabase dashboard -const appflowyDeepLinkSchema = 'appflowy-flutter'; -const supabaseLoginCallback = '$appflowyDeepLinkSchema://login-callback'; - -const hiveBoxName = 'appflowy_supabase_authentication'; - -// Used to store the session of the supabase in case of the user switch the different folder. -Supabase? supabase; -SupabaseRealtimeService? realtimeService; - -class InitSupabaseTask extends LaunchTask { - @override - Future initialize(LaunchContext context) async { - if (!isSupabaseEnabled) { - return; - } - - await supabase?.dispose(); - supabase = null; - final initializedSupabase = await Supabase.initialize( - url: getIt().supabaseConfig.url, - anonKey: getIt().supabaseConfig.anon_key, - debug: kDebugMode, - authOptions: const FlutterAuthClientOptions( - localStorage: SupabaseLocalStorage(), - ), - ); - - if (realtimeService != null) { - await realtimeService?.dispose(); - realtimeService = null; - } - realtimeService = SupabaseRealtimeService(supabase: initializedSupabase); - - supabase = initializedSupabase; - - if (Platform.isWindows) { - // register deep link for Windows - registerProtocolHandler(appflowyDeepLinkSchema); - } - } - - @override - Future dispose() async { - await realtimeService?.dispose(); - realtimeService = null; - await supabase?.dispose(); - supabase = null; - } -} - -/// customize the supabase auth storage -/// -/// We don't use the default one because it always save the session in the document directory. -/// When we switch to the different folder, the session still exists. -class SupabaseLocalStorage extends LocalStorage { - const SupabaseLocalStorage(); - - @override - Future initialize() async { - HiveCipher? encryptionCipher; - - // customize the path for Hive - final path = await getIt().getPath(); - Hive.init(p.join(path, 'supabase_auth')); - await Hive.openBox( - hiveBoxName, - encryptionCipher: encryptionCipher, - ); - } - - @override - Future hasAccessToken() { - return Future.value( - Hive.box(hiveBoxName).containsKey( - supabasePersistSessionKey, - ), - ); - } - - @override - Future accessToken() { - return Future.value( - Hive.box(hiveBoxName).get(supabasePersistSessionKey) as String?, - ); - } - - @override - Future removePersistedSession() { - return Hive.box(hiveBoxName).delete(supabasePersistSessionKey); - } - - @override - Future persistSession(String persistSessionString) { - return Hive.box(hiveBoxName).put( - supabasePersistSessionKey, - persistSessionString, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/windows.dart b/frontend/appflowy_flutter/lib/startup/tasks/windows.dart index fa24a602b3..20b8b0b56e 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/windows.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/windows.dart @@ -1,18 +1,21 @@ +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 { - const InitAppWindowTask({ - this.title = 'AppFlowy', - }); + InitAppWindowTask({this.title = 'AppFlowy'}); final String title; + final windowSizeManager = WindowSizeManager(); @override Future initialize(LaunchContext context) async { @@ -24,7 +27,7 @@ class InitAppWindowTask extends LaunchTask with WindowListener { await windowManager.ensureInitialized(); windowManager.addListener(this); - final windowSize = await WindowSizeManager().getSize(); + final windowSize = await windowSizeManager.getSize(); final windowOptions = WindowOptions( size: windowSize, minimumSize: const Size( @@ -38,15 +41,75 @@ class InitAppWindowTask extends LaunchTask with WindowListener { title: title, ); - await windowManager.waitUntilReadyToShow(windowOptions, () async { - await windowManager.show(); - await windowManager.focus(); + final position = await windowSizeManager.getPosition(); - final position = await WindowSizeManager().getPosition(); - if (position != null) { - await windowManager.setPosition(position); - } - }); + 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 @@ -54,15 +117,7 @@ class InitAppWindowTask extends LaunchTask with WindowListener { super.onWindowResize(); final currentWindowSize = await windowManager.getSize(); - return WindowSizeManager().setSize(currentWindowSize); - } - - @override - void onWindowMaximize() async { - super.onWindowMaximize(); - - final currentWindowSize = await windowManager.getSize(); - return WindowSizeManager().setSize(currentWindowSize); + return windowSizeManager.setSize(currentWindowSize); } @override @@ -70,9 +125,11 @@ class InitAppWindowTask extends LaunchTask with WindowListener { super.onWindowMoved(); final position = await windowManager.getPosition(); - return WindowSizeManager().setPosition(position); + return windowSizeManager.setPosition(position); } @override - Future dispose() async {} + Future dispose() async { + windowManager.removeListener(this); + } } 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 index 81dd8ed9cf..4f4cece9bb 100644 --- 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 @@ -18,7 +18,7 @@ class AppFlowyCloudAuthService implements AuthService { AppFlowyCloudAuthService(); final BackendAuthService _backendAuthService = BackendAuthService( - AuthenticatorPB.AppFlowyCloud, + AuthTypePB.Server, ); @override @@ -32,12 +32,17 @@ class AppFlowyCloudAuthService implements AuthService { } @override - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params = const {}, }) async { - throw UnimplementedError(); + return _backendAuthService.signInWithEmailPassword( + email: email, + password: password, + params: params, + ); } @override @@ -56,7 +61,7 @@ class AppFlowyCloudAuthService implements AuthService { (data) async { // Open the webview with oauth url final uri = Uri.parse(data.oauthUrl); - final isSuccess = await afLaunchUrl( + final isSuccess = await afLaunchUri( uri, mode: LaunchMode.externalApplication, webOnlyWindowName: '_self', @@ -106,6 +111,17 @@ class AppFlowyCloudAuthService implements AuthService { ); } + @override + Future> signInWithPasscode({ + required String email, + required String passcode, + }) async { + return _backendAuthService.signInWithPasscode( + email: email, + passcode: passcode, + ); + } + @override Future> getUser() async { return UserBackendService.getCurrentUserProfile(); @@ -121,6 +137,8 @@ extension ProviderTypePBExtension on ProviderTypePB { 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 index deba0f3700..8be71dc648 100644 --- 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 @@ -10,6 +10,7 @@ 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 { @@ -19,7 +20,7 @@ class AppFlowyCloudMockAuthService implements AuthService { final String userEmail; final BackendAuthService _appFlowyAuthService = - BackendAuthService(AuthenticatorPB.Supabase); + BackendAuthService(AuthTypePB.Server); @override Future> signUp({ @@ -32,7 +33,8 @@ class AppFlowyCloudMockAuthService implements AuthService { } @override - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params = const {}, @@ -46,7 +48,7 @@ class AppFlowyCloudMockAuthService implements AuthService { Map params = const {}, }) async { final payload = SignInUrlPayloadPB.create() - ..authenticator = AuthenticatorPB.AppFlowyCloud + ..authenticator = AuthTypePB.Server // don't use nanoid here, the gotrue server will transform the email ..email = userEmail; @@ -56,7 +58,7 @@ class AppFlowyCloudMockAuthService implements AuthService { return getSignInURLResult.fold( (urlPB) async { final payload = OauthSignInPB( - authenticator: AuthenticatorPB.AppFlowyCloud, + authenticator: AuthTypePB.Server, map: { AuthServiceMapKeys.signInURL: urlPB.signInUrl, AuthServiceMapKeys.deviceId: deviceId, @@ -64,12 +66,18 @@ class AppFlowyCloudMockAuthService implements AuthService { ); Log.info("UserEventOauthSignIn with payload: $payload"); return UserEventOauthSignIn(payload).send().then((value) { - value.fold((l) => null, (err) => Log.error(err)); + value.fold( + (l) => null, + (err) { + debugPrint("mock auth service Error: $err"); + Log.error(err); + }, + ); return value; }); }, (r) { - Log.error(r); + debugPrint("mock auth service error: $r"); return FlowyResult.failure(r); }, ); @@ -99,4 +107,12 @@ class AppFlowyCloudMockAuthService implements AuthService { Future> getUser() async { return UserBackendService.getCurrentUserProfile(); } + + @override + Future> signInWithPasscode({ + required String email, + required String passcode, + }) async { + throw UnimplementedError(); + } } diff --git a/frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart b/frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart index 06ddd90238..14d1ed42d6 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart @@ -2,22 +2,6 @@ import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; class AuthError { - static final supabaseSignInError = FlowyError() - ..msg = 'supabase sign in error -10001' - ..code = ErrorCode.UserUnauthorized; - - static final supabaseSignUpError = FlowyError() - ..msg = 'supabase sign up error -10002' - ..code = ErrorCode.UserUnauthorized; - - static final supabaseSignInWithOauthError = FlowyError() - ..msg = 'supabase sign in with oauth error -10003' - ..code = ErrorCode.UserUnauthorized; - - static final supabaseGetUserError = FlowyError() - ..msg = 'unable to get user from supabase -10004' - ..code = ErrorCode.UserUnauthorized; - static final signInWithOauthError = FlowyError() ..msg = 'sign in with oauth error -10003' ..code = ErrorCode.UserUnauthorized; diff --git a/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart index 4a69e847d5..9879b9a18e 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart @@ -1,12 +1,10 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; class AuthServiceMapKeys { const AuthServiceMapKeys._(); - // for supabase auth use only. - static const String uuid = 'uuid'; static const String email = 'email'; static const String deviceId = 'device_id'; static const String signInURL = 'sign_in_url'; @@ -25,7 +23,8 @@ abstract class AuthService { /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params, @@ -77,6 +76,17 @@ abstract class AuthService { Map params, }); + /// Authenticates a user with a passcode sent to their email. + /// + /// - `email`: The email address of the user. + /// - `passcode`: The passcode of the user. + /// + /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. + Future> signInWithPasscode({ + required String email, + required String passcode, + }); + /// Signs out the currently authenticated user. Future signOut(); 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 index 9147fb4fb9..cab8cd170c 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart @@ -6,9 +6,9 @@ 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_backend/protobuf/flowy-user/workspace.pb.dart'; 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'; @@ -16,10 +16,11 @@ import 'device_id.dart'; class BackendAuthService implements AuthService { BackendAuthService(this.authType); - final AuthenticatorPB authType; + final AuthTypePB authType; @override - Future> signInWithEmailPassword({ + Future> + signInWithEmailPassword({ required String email, required String password, Map params = const {}, @@ -29,8 +30,7 @@ class BackendAuthService implements AuthService { ..password = password ..authType = authType ..deviceId = await getDeviceId(); - final response = UserEventSignInWithEmailPassword(request).send(); - return response.then((value) => value); + return UserEventSignInWithEmailPassword(request).send(); } @override @@ -65,15 +65,14 @@ class BackendAuthService implements AuthService { Map params = const {}, }) async { const password = "Guest!@123456"; - final uid = uuid(); - final userEmail = "$uid@appflowy.io"; + final userEmail = "anon@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 + ..authType = AuthTypePB.Local ..deviceId = await getDeviceId(); final response = await UserEventSignUp(request).send().then( (value) => value, @@ -84,7 +83,7 @@ class BackendAuthService implements AuthService { @override Future> signUpWithOAuth({ required String platform, - AuthenticatorPB authType = AuthenticatorPB.Local, + AuthTypePB authType = AuthTypePB.Local, Map params = const {}, }) async { return FlowyResult.failure( @@ -107,4 +106,12 @@ class BackendAuthService implements AuthService { // No need to pass the redirect URL. return UserBackendService.signInWithMagicLink(email, ''); } + + @override + Future> signInWithPasscode({ + required String email, + required String passcode, + }) async { + return UserBackendService.signInWithPasscode(email, passcode); + } } diff --git a/frontend/appflowy_flutter/lib/user/application/auth/supabase_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/supabase_auth_service.dart deleted file mode 100644 index 0dc48d7ef7..0000000000 --- a/frontend/appflowy_flutter/lib/user/application/auth/supabase_auth_service.dart +++ /dev/null @@ -1,252 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/startup/tasks/prelude.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/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:flutter/foundation.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -import 'auth_error.dart'; - -class SupabaseAuthService implements AuthService { - SupabaseAuthService(); - - SupabaseClient get _client => Supabase.instance.client; - GoTrueClient get _auth => _client.auth; - - final BackendAuthService _backendAuthService = BackendAuthService( - AuthenticatorPB.Supabase, - ); - - @override - Future> signUp({ - required String name, - required String email, - required String password, - Map params = const {}, - }) async { - // fetch the uuid from supabase. - final response = await _auth.signUp( - email: email, - password: password, - ); - final uuid = response.user?.id; - if (uuid == null) { - return FlowyResult.failure(AuthError.supabaseSignUpError); - } - // assign the uuid to our backend service. - // and will transfer this logic to backend later. - return _backendAuthService.signUp( - name: name, - email: email, - password: password, - params: { - AuthServiceMapKeys.uuid: uuid, - }, - ); - } - - @override - Future> signInWithEmailPassword({ - required String email, - required String password, - Map params = const {}, - }) async { - try { - final response = await _auth.signInWithPassword( - email: email, - password: password, - ); - final uuid = response.user?.id; - if (uuid == null) { - return FlowyResult.failure(AuthError.supabaseSignInError); - } - return _backendAuthService.signInWithEmailPassword( - email: email, - password: password, - params: { - AuthServiceMapKeys.uuid: uuid, - }, - ); - } on AuthException catch (e) { - Log.error(e); - return FlowyResult.failure(AuthError.supabaseSignInError); - } - } - - @override - Future> signUpWithOAuth({ - required String platform, - Map params = const {}, - }) async { - // Before signing in, sign out any existing users. Otherwise, the callback will be triggered even if the user doesn't click the 'Sign In' button on the website - if (_auth.currentUser != null) { - await _auth.signOut(); - } - - final provider = platform.toProvider(); - final completer = supabaseLoginCompleter( - onSuccess: (userId, userEmail) async { - return _setupAuth( - map: { - AuthServiceMapKeys.uuid: userId, - AuthServiceMapKeys.email: userEmail, - AuthServiceMapKeys.deviceId: await getDeviceId(), - }, - ); - }, - ); - - final response = await _auth.signInWithOAuth( - provider, - queryParams: queryParamsForProvider(provider), - redirectTo: supabaseLoginCallback, - ); - if (!response) { - completer.complete( - FlowyResult.failure(AuthError.supabaseSignInWithOauthError), - ); - } - return completer.future; - } - - @override - Future signOut() async { - await _auth.signOut(); - await _backendAuthService.signOut(); - } - - @override - Future> signUpAsGuest({ - Map params = const {}, - }) async { - // supabase don't support guest login. - // so, just forward to our backend. - return _backendAuthService.signUpAsGuest(); - } - - @override - Future> signInWithMagicLink({ - required String email, - Map params = const {}, - }) async { - final completer = supabaseLoginCompleter( - onSuccess: (userId, userEmail) async { - return _setupAuth( - map: { - AuthServiceMapKeys.uuid: userId, - AuthServiceMapKeys.email: userEmail, - AuthServiceMapKeys.deviceId: await getDeviceId(), - }, - ); - }, - ); - - await _auth.signInWithOtp( - email: email, - emailRedirectTo: kIsWeb ? null : supabaseLoginCallback, - ); - return completer.future; - } - - @override - Future> getUser() async { - return UserBackendService.getCurrentUserProfile(); - } - - Future> getSupabaseUser() async { - final user = _auth.currentUser; - if (user == null) { - return FlowyResult.failure(AuthError.supabaseGetUserError); - } - return FlowyResult.success(user); - } - - Future> _setupAuth({ - required Map map, - }) async { - final payload = OauthSignInPB( - authenticator: AuthenticatorPB.Supabase, - map: map, - ); - - return UserEventOauthSignIn(payload).send().then((value) => value); - } -} - -extension on String { - OAuthProvider toProvider() { - switch (this) { - case 'github': - return OAuthProvider.github; - case 'google': - return OAuthProvider.google; - case 'discord': - return OAuthProvider.discord; - default: - throw UnimplementedError(); - } - } -} - -/// Creates a completer that listens to Supabase authentication state changes and -/// completes when a user signs in. -/// -/// This function sets up a listener on Supabase's authentication state. When a user -/// signs in, it triggers the provided [onSuccess] callback with the user's `id` and -/// `email`. Once the [onSuccess] callback is executed and a response is received, -/// the completer completes with the response, and the listener is canceled. -/// -/// Parameters: -/// - [onSuccess]: A callback function that's executed when a user signs in. It -/// should take in a user's `id` and `email` and return a `Future` containing either -/// a `FlowyError` or a `UserProfilePB`. -/// -/// Returns: -/// A completer of type `FlowyResult`. This completer completes -/// with the response from the [onSuccess] callback when a user signs in. -Completer> supabaseLoginCompleter({ - required Future> Function( - String userId, - String userEmail, - ) onSuccess, -}) { - final completer = Completer>(); - late final StreamSubscription subscription; - final auth = Supabase.instance.client.auth; - - subscription = auth.onAuthStateChange.listen((event) async { - final user = event.session?.user; - if (event.event == AuthChangeEvent.signedIn && user != null) { - final response = await onSuccess( - user.id, - user.email ?? user.newEmail ?? '', - ); - // Only cancel the subscription if the Event is signedIn. - await subscription.cancel(); - completer.complete(response); - } - }); - return completer; -} - -Map queryParamsForProvider(OAuthProvider provider) { - switch (provider) { - case OAuthProvider.google: - return { - 'access_type': 'offline', - 'prompt': 'consent', - }; - case OAuthProvider.github: - case OAuthProvider.discord: - default: - return {}; - } -} diff --git a/frontend/appflowy_flutter/lib/user/application/auth/supabase_mock_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/supabase_mock_auth_service.dart deleted file mode 100644 index bd2620caaa..0000000000 --- a/frontend/appflowy_flutter/lib/user/application/auth/supabase_mock_auth_service.dart +++ /dev/null @@ -1,113 +0,0 @@ -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/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:supabase_flutter/supabase_flutter.dart'; - -import 'auth_error.dart'; - -/// Only used for testing. -class SupabaseMockAuthService implements AuthService { - SupabaseMockAuthService(); - static OauthSignInPB? signInPayload; - - SupabaseClient get _client => Supabase.instance.client; - GoTrueClient get _auth => _client.auth; - - final BackendAuthService _appFlowyAuthService = - BackendAuthService(AuthenticatorPB.Supabase); - - @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 { - const password = "AppFlowyTest123!"; - const email = "supabase_integration_test@appflowy.io"; - try { - if (_auth.currentSession == null) { - try { - await _auth.signInWithPassword( - password: password, - email: email, - ); - } catch (e) { - Log.error(e); - return FlowyResult.failure(AuthError.supabaseSignUpError); - } - } - // Check if the user is already logged in. - final session = _auth.currentSession!; - final uuid = session.user.id; - - // Create the OAuth sign-in payload. - final payload = OauthSignInPB( - authenticator: AuthenticatorPB.Supabase, - map: { - AuthServiceMapKeys.uuid: uuid, - AuthServiceMapKeys.email: email, - AuthServiceMapKeys.deviceId: 'MockDeviceId', - }, - ); - - // Send the sign-in event and handle the response. - return UserEventOauthSignIn(payload).send().then((value) => value); - } on AuthException catch (e) { - Log.error(e); - return FlowyResult.failure(AuthError.supabaseSignInError); - } - } - - @override - Future signOut() async { - // await _auth.signOut(); - await _appFlowyAuthService.signOut(); - } - - @override - Future> signUpAsGuest({ - Map params = const {}, - }) async { - // supabase don't support guest login. - // so, just forward to our backend. - 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/encrypt_secret_bloc.dart b/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart deleted file mode 100644 index 19b8101ae8..0000000000 --- a/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart +++ /dev/null @@ -1,114 +0,0 @@ -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/password/password_bloc.dart b/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart new file mode 100644 index 0000000000..80dd5ca3c9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart @@ -0,0 +1,241 @@ +import 'dart:convert'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/user/application/password/password_http_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_backend/protobuf/flowy-user/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 'password_bloc.freezed.dart'; + +class PasswordBloc extends Bloc { + PasswordBloc(this.userProfile) : super(PasswordState.initial()) { + on( + (event, emit) async { + await event.when( + init: () async => _init(), + changePassword: (oldPassword, newPassword) async => _onChangePassword( + emit, + oldPassword: oldPassword, + newPassword: newPassword, + ), + setupPassword: (newPassword) async => _onSetupPassword( + emit, + newPassword: newPassword, + ), + forgotPassword: (email) async => _onForgotPassword( + emit, + email: email, + ), + checkHasPassword: () async => _onCheckHasPassword( + emit, + ), + cancel: () {}, + ); + }, + ); + } + + final UserProfilePB userProfile; + late final PasswordHttpService passwordHttpService; + + bool _isInitialized = false; + + Future _init() async { + if (userProfile.workspaceAuthType == AuthTypePB.Local) { + Log.debug('PasswordBloc: skip init because user is local authenticator'); + return; + } + + final baseUrl = await getAppFlowyCloudUrl(); + try { + final authToken = jsonDecode(userProfile.token)['access_token']; + passwordHttpService = PasswordHttpService( + baseUrl: baseUrl, + authToken: authToken, + ); + _isInitialized = true; + } catch (e) { + Log.error('PasswordBloc: _init: error: $e'); + } + } + + Future _onChangePassword( + Emitter emit, { + required String oldPassword, + required String newPassword, + }) async { + if (!_isInitialized) { + Log.info('changePassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('changePassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.changePassword( + currentPassword: oldPassword, + newPassword: newPassword, + ); + + emit( + state.copyWith( + isSubmitting: false, + changePasswordResult: result, + ), + ); + } + + Future _onSetupPassword( + Emitter emit, { + required String newPassword, + }) async { + if (!_isInitialized) { + Log.info('setupPassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('setupPassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.setupPassword( + newPassword: newPassword, + ); + + emit( + state.copyWith( + isSubmitting: false, + hasPassword: result.fold( + (success) => true, + (error) => false, + ), + setupPasswordResult: result, + ), + ); + } + + Future _onForgotPassword( + Emitter emit, { + required String email, + }) async { + if (!_isInitialized) { + Log.info('forgotPassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('forgotPassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.forgotPassword(email: email); + + emit( + state.copyWith( + isSubmitting: false, + forgotPasswordResult: result, + ), + ); + } + + Future _onCheckHasPassword(Emitter emit) async { + if (!_isInitialized) { + Log.info('checkHasPassword: not initialized'); + return; + } + + if (state.isSubmitting) { + Log.info('checkHasPassword: already submitting'); + return; + } + + _clearState(emit, true); + + final result = await passwordHttpService.checkHasPassword(); + + emit( + state.copyWith( + isSubmitting: false, + hasPassword: result.fold( + (success) => success, + (error) => false, + ), + checkHasPasswordResult: result, + ), + ); + } + + void _clearState(Emitter emit, bool isSubmitting) { + emit( + state.copyWith( + isSubmitting: isSubmitting, + changePasswordResult: null, + setupPasswordResult: null, + forgotPasswordResult: null, + checkHasPasswordResult: null, + ), + ); + } +} + +@freezed +class PasswordEvent with _$PasswordEvent { + const factory PasswordEvent.init() = Init; + + // Change password + const factory PasswordEvent.changePassword({ + required String oldPassword, + required String newPassword, + }) = ChangePassword; + + // Setup password + const factory PasswordEvent.setupPassword({ + required String newPassword, + }) = SetupPassword; + + // Forgot password + const factory PasswordEvent.forgotPassword({ + required String email, + }) = ForgotPassword; + + // Check has password + const factory PasswordEvent.checkHasPassword() = CheckHasPassword; + + // Cancel operation + const factory PasswordEvent.cancel() = Cancel; +} + +@freezed +class PasswordState with _$PasswordState { + const factory PasswordState({ + required bool isSubmitting, + required bool hasPassword, + required FlowyResult? changePasswordResult, + required FlowyResult? setupPasswordResult, + required FlowyResult? forgotPasswordResult, + required FlowyResult? checkHasPasswordResult, + }) = _PasswordState; + + factory PasswordState.initial() => const PasswordState( + isSubmitting: false, + hasPassword: false, + changePasswordResult: null, + setupPasswordResult: null, + forgotPasswordResult: null, + checkHasPasswordResult: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/user/application/password/password_http_service.dart b/frontend/appflowy_flutter/lib/user/application/password/password_http_service.dart new file mode 100644 index 0000000000..723ded57e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/password/password_http_service.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; + +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:http/http.dart' as http; + +enum PasswordEndpoint { + changePassword, + forgotPassword, + setupPassword, + checkHasPassword; + + String get path { + switch (this) { + case PasswordEndpoint.changePassword: + return '/gotrue/user/change-password'; + case PasswordEndpoint.forgotPassword: + return '/gotrue/user/recover'; + case PasswordEndpoint.setupPassword: + return '/gotrue/user/change-password'; + case PasswordEndpoint.checkHasPassword: + return '/gotrue/user/auth-info'; + } + } + + String get method { + switch (this) { + case PasswordEndpoint.changePassword: + case PasswordEndpoint.setupPassword: + case PasswordEndpoint.forgotPassword: + return 'POST'; + case PasswordEndpoint.checkHasPassword: + return 'GET'; + } + } + + Uri uri(String baseUrl) => Uri.parse('$baseUrl$path'); +} + +class PasswordHttpService { + PasswordHttpService({ + required this.baseUrl, + required this.authToken, + }); + + final String baseUrl; + final String authToken; + + final http.Client client = http.Client(); + + Map get headers => { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $authToken', + }; + + /// Changes the user's password + /// + /// [currentPassword] - The user's current password + /// [newPassword] - The new password to set + Future> changePassword({ + required String currentPassword, + required String newPassword, + }) async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.changePassword, + body: { + 'current_password': currentPassword, + 'password': newPassword, + }, + errorMessage: 'Failed to change password', + ); + + return result.fold( + (data) => FlowyResult.success(true), + (error) => FlowyResult.failure(error), + ); + } + + /// Sends a password reset email to the user + /// + /// [email] - The email address of the user + Future> forgotPassword({ + required String email, + }) async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.forgotPassword, + body: {'email': email}, + errorMessage: 'Failed to send password reset email', + ); + + return result.fold( + (data) => FlowyResult.success(true), + (error) => FlowyResult.failure(error), + ); + } + + /// Sets up a password for a user that doesn't have one + /// + /// [newPassword] - The new password to set + Future> setupPassword({ + required String newPassword, + }) async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.setupPassword, + body: {'password': newPassword}, + errorMessage: 'Failed to setup password', + ); + + return result.fold( + (data) => FlowyResult.success(true), + (error) => FlowyResult.failure(error), + ); + } + + /// Checks if the user has a password set + Future> checkHasPassword() async { + final result = await _makeRequest( + endpoint: PasswordEndpoint.checkHasPassword, + errorMessage: 'Failed to check password status', + ); + + return result.fold( + (data) => FlowyResult.success(data['has_password'] ?? false), + (error) => FlowyResult.failure(error), + ); + } + + /// Makes a request to the specified endpoint with the given body + Future> _makeRequest({ + required PasswordEndpoint endpoint, + Map? body, + String errorMessage = 'Request failed', + }) async { + try { + final uri = endpoint.uri(baseUrl); + http.Response response; + + if (endpoint.method == 'POST') { + response = await client.post( + uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + ); + } else if (endpoint.method == 'GET') { + response = await client.get( + uri, + headers: headers, + ); + } else { + return FlowyResult.failure( + FlowyError(msg: 'Invalid request method: ${endpoint.method}'), + ); + } + + if (response.statusCode == 200) { + if (response.body.isNotEmpty) { + return FlowyResult.success(jsonDecode(response.body)); + } + return FlowyResult.success(true); + } else { + final errorBody = + response.body.isNotEmpty ? jsonDecode(response.body) : {}; + + Log.info( + '${endpoint.name} request failed: ${response.statusCode}, $errorBody ', + ); + + return FlowyResult.failure( + FlowyError( + msg: errorBody['msg'] ?? errorMessage, + ), + ); + } + } catch (e) { + Log.error('${endpoint.name} request failed: error: $e'); + + return FlowyResult.failure( + FlowyError(msg: 'Network error: ${e.toString()}'), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart index d50c6fc795..24f53e48e8 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart @@ -1,6 +1,7 @@ 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'; @@ -17,11 +18,14 @@ 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(); @@ -37,55 +41,59 @@ class ReminderBloc extends Bloc { on( (event, emit) async { await event.when( - markAllRead: () async { - final unreadReminders = - state.pastReminders.where((reminder) => !reminder.isRead); - - final reminders = [...state.reminders]; - final updatedReminders = []; - for (final reminder in unreadReminders) { - reminders.remove(reminder); - - reminder.isRead = true; - await _reminderService.updateReminder(reminder: reminder); - - updatedReminders.add(reminder); - } - - reminders.addAll(updatedReminders); - emit(state.copyWith(reminders: reminders)); - }, started: () async { - final remindersOrFailure = await _reminderService.fetchReminders(); + Log.info('Start fetching reminders'); - remindersOrFailure.fold( - (reminders) => emit(state.copyWith(reminders: reminders)), - (error) => Log.error(error), + 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 unitOrFailure = - await _reminderService.removeReminder(reminderId: reminderId); + final result = await _reminderService.removeReminder( + reminderId: reminderId, + ); - unitOrFailure.fold( + 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(error), + (error) => Log.error( + 'Failed to remove reminder($reminderId): $error', + ), ); }, add: (reminder) async { - final unitOrFailure = - await _reminderService.addReminder(reminder: reminder); + // check the timestamp in the reminder + if (reminder.createdAt == null) { + reminder.freeze(); + reminder = reminder.rebuild((update) { + update.meta[ReminderMetaKeys.createdAt] = + DateTime.now().millisecondsSinceEpoch.toString(); + }); + } - return unitOrFailure.fold( + 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(error), + (error) => Log.error('Failed to add reminder: $error'), ); }, addById: (reminderId, objectId, scheduledAt, meta) async => add( @@ -102,8 +110,9 @@ class ReminderBloc extends Bloc { ), ), update: (updateObject) async { - final reminder = state.reminders - .firstWhereOrNull((r) => r.id == updateObject.id); + final reminder = state.reminders.firstWhereOrNull( + (r) => r.id == updateObject.id, + ); if (reminder == null) { return; @@ -111,18 +120,23 @@ class ReminderBloc extends Bloc { final newReminder = updateObject.merge(a: reminder); final failureOrUnit = await _reminderService.updateReminder( - reminder: updateObject.merge(a: reminder), + 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(error), + (error) => Log.error( + 'Failed to update reminder(${reminder.id}): $error', + ), ); }, pressReminder: (reminderId, path, view) { @@ -171,11 +185,167 @@ class ReminderBloc extends Bloc { ); } }, + 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), @@ -239,14 +409,31 @@ class ReminderEvent with _$ReminderEvent { // Update a reminder (eg. isAck, isRead, etc.) const factory ReminderEvent.update(ReminderUpdate update) = _Update; - // Mark all unread reminders as read + // 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 @@ -259,6 +446,8 @@ class ReminderUpdate { this.isRead, this.scheduledAt, this.includeTime, + this.isArchived, + this.date, }); final String id; @@ -266,17 +455,27 @@ class ReminderUpdate { 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; + 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, @@ -327,7 +526,7 @@ class ReminderState { } late final List _reminders; - List get reminders => _reminders; + List get reminders => _reminders.unique((e) => e.id); late final List pastReminders; late final List upcomingReminders; diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart index 94bf638de5..1b5aeaeb43 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart @@ -4,6 +4,15 @@ 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 { @@ -12,4 +21,46 @@ extension ReminderExtension on ReminderPB { 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/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart index a317924216..9691a1269b 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -3,6 +3,7 @@ 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' @@ -29,20 +30,27 @@ class SignInBloc extends Bloc { on( (event, emit) async { await event.when( - signedInWithUserEmailAndPassword: () async => _onSignIn(emit), - signedInWithOAuth: (platform) async => _onSignInWithOAuth( + signInWithEmailAndPassword: (email, password) async => + _onSignInWithEmailAndPassword( emit, - platform, + email: email, + password: password, ), - signedInAsGuest: () async => _onSignInAsGuest(emit), - signedWithMagicLink: (email) async => _onSignInWithMagicLink( + signInWithOAuth: (platform) async => _onSignInWithOAuth( emit, - email, + platform: platform, ), - deepLinkStateChange: (result) => _onDeepLinkStateChange( + signInAsGuest: () async => _onSignInAsGuest(emit), + signInWithMagicLink: (email) async => _onSignInWithMagicLink( emit, - result, + email: email, ), + signInWithPasscode: (email, passcode) async => _onSignInWithPasscode( + emit, + email: email, + passcode: passcode, + ), + deepLinkStateChange: (result) => _onDeepLinkStateChange(emit, result), cancel: () { emit( state.copyWith( @@ -72,9 +80,7 @@ class SignInBloc extends Bloc { ); }, switchLoginType: (type) { - emit( - state.copyWith(loginType: type), - ); + emit(state.copyWith(loginType: type)); }, ); }, @@ -127,28 +133,34 @@ class SignInBloc extends Bloc { } } - Future _onSignIn( - Emitter emit, - ) async { + Future _onSignInWithEmailAndPassword( + Emitter emit, { + required String email, + required String password, + }) async { final result = await authService.signInWithEmailPassword( - email: state.email ?? '', - password: state.password ?? '', + email: email, + password: password, ); emit( result.fold( - (userProfile) => state.copyWith( - isSubmitting: false, - successOrFail: FlowyResult.success(userProfile), - ), + (gotrueTokenResponse) { + getIt().passGotrueTokenResponse( + gotrueTokenResponse, + ); + return state.copyWith( + isSubmitting: false, + ); + }, (error) => _stateFromCode(error), ), ); } Future _onSignInWithOAuth( - Emitter emit, - String platform, - ) async { + Emitter emit, { + required String platform, + }) async { emit( state.copyWith( isSubmitting: true, @@ -158,9 +170,7 @@ class SignInBloc extends Bloc { ), ); - final result = await authService.signUpWithOAuth( - platform: platform, - ); + final result = await authService.signUpWithOAuth(platform: platform); emit( result.fold( (userProfile) => state.copyWith( @@ -173,9 +183,16 @@ class SignInBloc extends Bloc { } Future _onSignInWithMagicLink( - Emitter emit, - String email, - ) async { + Emitter emit, { + required String email, + }) async { + if (state.isSubmitting) { + Log.error('Sign in with magic link is already in progress'); + return; + } + + Log.info('Sign in with magic link: $email'); + emit( state.copyWith( isSubmitting: true, @@ -185,20 +202,59 @@ class SignInBloc extends Bloc { ), ); - final result = await authService.signInWithMagicLink( - email: email, - ); + final result = await authService.signInWithMagicLink(email: email); emit( result.fold( (userProfile) => state.copyWith( - isSubmitting: true, + isSubmitting: false, ), (error) => _stateFromCode(error), ), ); } + Future _onSignInWithPasscode( + Emitter emit, { + required String email, + required String passcode, + }) async { + if (state.isSubmitting) { + Log.error('Sign in with passcode is already in progress'); + return; + } + + Log.info('Sign in with passcode: $email, $passcode'); + + emit( + state.copyWith( + isSubmitting: true, + emailError: null, + passwordError: null, + successOrFail: null, + ), + ); + + final result = await authService.signInWithPasscode( + email: email, + passcode: passcode, + ); + + emit( + result.fold( + (gotrueTokenResponse) { + getIt().passGotrueTokenResponse( + gotrueTokenResponse, + ); + return state.copyWith( + isSubmitting: false, + ); + }, + (error) => _stateFromCode(error), + ), + ); + } + Future _onSignInAsGuest( Emitter emit, ) async { @@ -224,6 +280,8 @@ class SignInBloc extends Bloc { } SignInState _stateFromCode(FlowyError error) { + Log.error('SignInState _stateFromCode: ${error.msg}'); + switch (error.code) { case ErrorCode.EmailFormatInvalid: return state.copyWith( @@ -238,10 +296,20 @@ class SignInBloc extends Bloc { emailError: null, ); case ErrorCode.UserUnauthorized: + final errorMsg = error.msg; + String msg = LocaleKeys.signIn_generalError.tr(); + if (errorMsg.contains('rate limit') || + errorMsg.contains('For security purposes')) { + msg = LocaleKeys.signIn_tooFrequentVerificationCodeRequest.tr(); + } else if (errorMsg.contains('invalid')) { + msg = LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr(); + } else if (errorMsg.contains('Invalid login credentials')) { + msg = LocaleKeys.signIn_invalidLoginCredentials.tr(); + } return state.copyWith( isSubmitting: false, successOrFail: FlowyResult.failure( - FlowyError(msg: LocaleKeys.signIn_limitRateError.tr()), + FlowyError(msg: msg), ), ); default: @@ -257,19 +325,35 @@ class SignInBloc extends Bloc { @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; + // Sign in methods + const factory SignInEvent.signInWithEmailAndPassword({ + required String email, + required String password, + }) = SignInWithEmailAndPassword; + const factory SignInEvent.signInWithOAuth({ + required String platform, + }) = SignInWithOAuth; + const factory SignInEvent.signInAsGuest() = SignInAsGuest; + const factory SignInEvent.signInWithMagicLink({ + required String email, + }) = SignInWithMagicLink; + const factory SignInEvent.signInWithPasscode({ + required String email, + required String passcode, + }) = SignInWithPasscode; + + // Event handlers + const factory SignInEvent.emailChanged({ + required String email, + }) = EmailChanged; + const factory SignInEvent.passwordChanged({ + required String password, + }) = PasswordChanged; const factory SignInEvent.deepLinkStateChange(DeepLinkResult result) = DeepLinkStateChange; - const factory SignInEvent.cancel() = _Cancel; - const factory SignInEvent.switchLoginType(LoginType type) = _SwitchLoginType; + + 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 diff --git a/frontend/appflowy_flutter/lib/user/application/supabase_realtime.dart b/frontend/appflowy_flutter/lib/user/application/supabase_realtime.dart deleted file mode 100644 index ba3dcb6cb7..0000000000 --- a/frontend/appflowy_flutter/lib/user/application/supabase_realtime.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/user/application/user_auth_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-user/protobuf.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -/// A service to manage realtime interactions with Supabase. -/// -/// `SupabaseRealtimeService` handles subscribing to table changes in Supabase -/// based on the authentication state of a user. The service is initialized with -/// a reference to a Supabase instance and sets up the necessary subscriptions -/// accordingly. -class SupabaseRealtimeService { - SupabaseRealtimeService({required this.supabase}) { - _subscribeAuthState(); - _subscribeTablesChanges(); - - _authStateListener.start( - didSignIn: () { - _subscribeTablesChanges(); - isLoggingOut = false; - }, - onInvalidAuth: (message) async { - Log.error(message); - await channel?.unsubscribe(); - channel = null; - if (!isLoggingOut) { - isLoggingOut = true; - await runAppFlowy(); - } - }, - ); - } - - final Supabase supabase; - final _authStateListener = UserAuthStateListener(); - - bool isLoggingOut = false; - - RealtimeChannel? channel; - StreamSubscription? authStateSubscription; - - void _subscribeAuthState() { - final auth = Supabase.instance.client.auth; - authStateSubscription = auth.onAuthStateChange.listen((state) async { - Log.info("Supabase auth state change: ${state.event}"); - }); - } - - Future _subscribeTablesChanges() async { - final result = await UserBackendService.getCurrentUserProfile(); - result.fold( - (userProfile) { - Log.info("Start listening supabase table changes"); - - // https://supabase.com/docs/guides/realtime/postgres-changes - - const ops = RealtimeChannelConfig(ack: true); - channel?.unsubscribe(); - channel = supabase.client.channel("table-db-changes", opts: ops); - for (final name in [ - "document", - "folder", - "database", - "database_row", - "w_database", - ]) { - channel?.onPostgresChanges( - event: PostgresChangeEvent.insert, - schema: 'public', - table: 'af_collab_update_$name', - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'uid', - value: userProfile.id, - ), - callback: _onPostgresChangesCallback, - ); - } - - channel?.onPostgresChanges( - event: PostgresChangeEvent.update, - schema: 'public', - table: 'af_user', - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'uid', - value: userProfile.id, - ), - callback: _onPostgresChangesCallback, - ); - - channel?.subscribe( - (status, [err]) { - Log.info( - "subscribe channel statue: $status, err: $err", - ); - }, - ); - }, - (_) => null, - ); - } - - Future dispose() async { - await _authStateListener.stop(); - await authStateSubscription?.cancel(); - await channel?.unsubscribe(); - } - - void _onPostgresChangesCallback(PostgresChangePayload payload) { - try { - final jsonStr = jsonEncode(payload); - final pb = RealtimePayloadPB.create()..jsonStr = jsonStr; - UserEventPushRealtimeEvent(pb).send(); - } catch (e) { - Log.error(e); - } - } -} diff --git a/frontend/appflowy_flutter/lib/user/application/user_listener.dart b/frontend/appflowy_flutter/lib/user/application/user_listener.dart index ece9cef8a8..d3ebe0201b 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_listener.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_listener.dart @@ -1,6 +1,7 @@ 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'; @@ -11,15 +12,20 @@ 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/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; -typedef DidUserWorkspaceUpdateCallback = void Function( +typedef DidUpdateUserWorkspaceCallback = void Function( + UserWorkspacePB workspace, +); +typedef DidUpdateUserWorkspacesCallback = void Function( RepeatedUserWorkspacePB workspaces, ); typedef UserProfileNotifyValue = FlowyResult; +typedef DidUpdateUserWorkspaceSetting = void Function( + WorkspaceSettingsPB settings, +); class UserListener { UserListener({ @@ -31,19 +37,29 @@ class UserListener { UserNotificationParser? _userParser; StreamSubscription? _subscription; PublishNotifier? _profileNotifier = PublishNotifier(); - DidUserWorkspaceUpdateCallback? didUpdateUserWorkspaces; + + /// 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, - void Function(RepeatedUserWorkspacePB)? didUpdateUserWorkspaces, + DidUpdateUserWorkspacesCallback? onUserWorkspaceListUpdated, + void Function(UserWorkspacePB)? onUserWorkspaceUpdated, + DidUpdateUserWorkspaceSetting? onUserWorkspaceSettingUpdated, }) { if (onProfileUpdated != null) { _profileNotifier?.addPublishListener(onProfileUpdated); } - if (didUpdateUserWorkspaces != null) { - this.didUpdateUserWorkspaces = didUpdateUserWorkspaces; - } + this.onUserWorkspaceListUpdated = onUserWorkspaceListUpdated; + this.onUserWorkspaceUpdated = onUserWorkspaceUpdated; + this.onUserWorkspaceSettingUpdated = onUserWorkspaceSettingUpdated; _userParser = UserNotificationParser( id: _userProfile.id.toString(), @@ -77,32 +93,41 @@ class UserListener { result.map( (r) { final value = RepeatedUserWorkspacePB.fromBuffer(r); - didUpdateUserWorkspaces?.call(value); + onUserWorkspaceListUpdated?.call(value); }, ); break; + case user.UserNotification.DidUpdateUserWorkspace: + result.map( + (r) => onUserWorkspaceUpdated?.call(UserWorkspacePB.fromBuffer(r)), + ); + case user.UserNotification.DidUpdateWorkspaceSetting: + result.map( + (r) => onUserWorkspaceSettingUpdated + ?.call(WorkspaceSettingsPB.fromBuffer(r)), + ); + break; default: break; } } } -typedef WorkspaceSettingNotifyValue - = FlowyResult; +typedef WorkspaceLatestNotifyValue = FlowyResult; -class UserWorkspaceListener { - UserWorkspaceListener(); +class FolderListener { + FolderListener(); - PublishNotifier? _settingChangedNotifier = + final PublishNotifier _latestChangedNotifier = PublishNotifier(); FolderNotificationListener? _listener; void start({ - void Function(WorkspaceSettingNotifyValue)? onSettingUpdated, + void Function(WorkspaceLatestNotifyValue)? onLatestUpdated, }) { - if (onSettingUpdated != null) { - _settingChangedNotifier?.addPublishListener(onSettingUpdated); + if (onLatestUpdated != null) { + _latestChangedNotifier.addPublishListener(onLatestUpdated); } // The "current-workspace" is predefined in the backend. Do not try to @@ -120,13 +145,11 @@ class UserWorkspaceListener { switch (ty) { case FolderNotification.DidUpdateWorkspaceSetting: result.fold( - (payload) => _settingChangedNotifier?.value = - FlowyResult.success(WorkspaceSettingPB.fromBuffer(payload)), - (error) => - _settingChangedNotifier?.value = FlowyResult.failure(error), + (payload) => _latestChangedNotifier.value = + FlowyResult.success(WorkspaceLatestPB.fromBuffer(payload)), + (error) => _latestChangedNotifier.value = FlowyResult.failure(error), ); break; - default: break; } @@ -134,8 +157,6 @@ class UserWorkspaceListener { Future stop() async { await _listener?.stop(); - - _settingChangedNotifier?.dispose(); - _settingChangedNotifier = null; + _latestChangedNotifier.dispose(); } } diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 5eeaacdc77..3ec181e009 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -1,16 +1,31 @@ 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'; -class UserBackendService { - UserBackendService({ - required this.userId, - }); +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; @@ -25,8 +40,6 @@ class UserBackendService { String? password, String? email, String? iconUrl, - String? openAIKey, - String? stabilityAiKey, }) { final payload = UpdateUserProfilePayloadPB.create()..id = userId; @@ -46,14 +59,6 @@ class UserBackendService { payload.iconUrl = iconUrl; } - if (openAIKey != null) { - payload.openaiKey = openAIKey; - } - - if (stabilityAiKey != null) { - payload.stabilityAiKey = stabilityAiKey; - } - return UserEventUpdateUserProfile(payload).send(); } @@ -71,6 +76,26 @@ class UserBackendService { return UserEventMagicLinkSignIn(payload).send(); } + static Future> + signInWithPasscode( + String email, + String passcode, + ) async { + final payload = PasscodeSignInPB(email: email, passcode: passcode); + return UserEventPasscodeSignIn(payload).send(); + } + + Future> signInWithPassword( + String email, + String password, + ) { + final payload = SignInPayloadPB( + email: email, + password: password, + ); + return UserEventSignInWithEmailPassword(payload).send(); + } + static Future> signOut() { return UserEventSignOut().send(); } @@ -96,12 +121,17 @@ class UserBackendService { }); } - Future> openWorkspace(String workspaceId) { - final payload = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + Future> openWorkspace( + String workspaceId, + AuthTypePB authType, + ) { + final payload = OpenUserWorkspacePB() + ..workspaceId = workspaceId + ..authType = authType; return UserEventOpenWorkspace(payload).send(); } - Future> getCurrentWorkspace() { + static Future> getCurrentWorkspace() { return FolderEventReadCurrentWorkspace().send().then((result) { return result.fold( (workspace) => FlowyResult.success(workspace), @@ -110,25 +140,13 @@ class UserBackendService { }); } - 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, + AuthTypePB authType, ) { - final request = CreateWorkspacePB.create()..name = name; + final request = CreateWorkspacePB.create() + ..name = name + ..authType = authType; return UserEventCreateWorkspace(request).send(); } @@ -164,7 +182,7 @@ class UserBackendService { String workspaceId, ) async { final data = QueryWorkspacePB()..workspaceId = workspaceId; - return UserEventGetWorkspaceMember(data).send(); + return UserEventGetWorkspaceMembers(data).send(); } Future> addWorkspaceMember( @@ -219,4 +237,66 @@ class UserBackendService { 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/workspace_error_bloc.dart b/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart index ce51fdd10b..7ff50dbd02 100644 --- a/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart @@ -1,9 +1,7 @@ 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'; @@ -22,20 +20,10 @@ class WorkspaceErrorBloc void _dispatch() { on( (event, emit) async { - await event.when( + 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( (_) { @@ -68,7 +56,6 @@ class WorkspaceErrorBloc 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; diff --git a/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart b/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart index a9b11cb42e..ddb1a07f96 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart @@ -74,9 +74,8 @@ class AnonUserItem extends StatelessWidget { @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 isDisabled = isSelected || user.workspaceAuthType != AuthTypePB.Local; + final desc = "${user.name}\t ${user.workspaceAuthType}\t"; final child = SizedBox( height: 30, child: FlowyButton( 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 index 3ecacf0961..ccad6c0a26 100644 --- 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 @@ -1,10 +1,10 @@ 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:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flutter/material.dart'; void handleOpenWorkspaceError(BuildContext context, FlowyError error) { @@ -15,24 +15,22 @@ void handleOpenWorkspaceError(BuildContext context, FlowyError error) { getIt().pushWorkspaceErrorScreen(context, userFolder, error); break; case ErrorCode.InvalidEncryptSecret: - showSnapBar( - context, - error.msg, + case ErrorCode.NetworkError: + showToastNotification( + message: error.msg, + type: ToastificationType.error, ); break; - case ErrorCode.HttpError: - showSnapBar( - context, - error.msg, - ); default: - showSnapBar( - context, - error.msg, - onClosed: () { - getIt().signOut(); - runAppFlowy(); - }, + showToastNotification( + 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 deleted file mode 100644 index 9abd417df3..0000000000 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart +++ /dev/null @@ -1,25 +0,0 @@ -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 index 084a360666..11f321232e 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart @@ -1,2 +1 @@ export 'handle_open_workspace_error.dart'; -export 'handle_user_profile_result.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/router.dart b/frontend/appflowy_flutter/lib/user/presentation/router.dart index a93f6e449b..339c2f29f7 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/router.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/router.dart @@ -4,12 +4,12 @@ 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:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; 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) {} @@ -21,10 +21,6 @@ class AuthRouter { 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 @@ -47,7 +43,7 @@ class AuthRouter { (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 (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { context.go( MobileHomeScreen.routeName, ); @@ -61,20 +57,6 @@ class AuthRouter { ); } - 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, @@ -114,7 +96,7 @@ class SplashRouter { void pushHomeScreen( BuildContext context, ) { - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { context.push( MobileHomeScreen.routeName, ); @@ -128,7 +110,7 @@ class SplashRouter { void goHomeScreen( BuildContext context, ) { - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { context.go( MobileHomeScreen.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 deleted file mode 100644 index 1edd20b671..0000000000 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart +++ /dev/null @@ -1,132 +0,0 @@ -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/material.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: (p0) {}, - ), - ), - 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 index 088da38978..2aeba87995 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart @@ -1,7 +1,5 @@ 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 index b351ff33d0..40901e92e1 100644 --- 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 @@ -1,13 +1,19 @@ import 'package:appflowy/core/frameless_window.dart'; 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/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/anonymous_sign_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/user/presentation/widgets/widgets.dart'; +import 'package:appflowy_ui/appflowy_ui.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({ @@ -16,59 +22,55 @@ class DesktopSignInScreen extends StatelessWidget { @override Widget build(BuildContext context) { - const indicatorMinHeight = 4.0; + final theme = AppFlowyTheme.of(context); + return BlocBuilder( builder: (context, state) { + final bottomPadding = UniversalPlatform.isDesktop ? 20.0 : 24.0; return Scaffold( - appBar: const PreferredSize( - preferredSize: Size(double.infinity, 60), - child: MoveWindowDetector(), - ), + appBar: _buildAppBar(), body: Center( child: AuthFormContainer( children: [ + const Spacer(), + + // logo and title FlowyLogoTitle( title: LocaleKeys.welcomeText.tr(), - logoSize: const Size(60, 60), + logoSize: Size.square(36), ), - const VSpace(30), + VSpace(theme.spacing.xxl), - // const SignInAnonymousButton(), - const SignInWithMagicLinkButtons(), + // continue with email and password + isLocalAuthEnabled + ? const SignInAnonymousButtonV3() + : const ContinueWithEmailAndPassword(), + + VSpace(theme.spacing.xxl), // third-party sign in. - const VSpace(20), - if (isAuthEnabled) ...[ const _OrDivider(), - const VSpace(10), + VSpace(theme.spacing.xxl), const ThirdPartySignInButtons(), + VSpace(theme.spacing.xxl), ], - const VSpace(20), - // anonymous sign in - const SignInAnonymousButtonV2(), - const VSpace(10), + // sign in agreement + const SignInAgreement(), - SwitchSignInSignUpButton( - onTap: () { - final type = state.loginType == LoginType.signIn - ? LoginType.signUp - : LoginType.signIn; - context.read().add( - SignInEvent.switchLoginType(type), - ); - }, + const Spacer(), + + // anonymous sign in and settings + const Row( + mainAxisSize: MainAxisSize.min, + children: [ + DesktopSignInSettingsButton(), + HSpace(20), + SignInAnonymousButtonV2(), + ], ), - - // loading status - const VSpace(indicatorMinHeight), - state.isSubmitting - ? const LinearProgressIndicator( - minHeight: indicatorMinHeight, - ) - : const VSpace(indicatorMinHeight), - const VSpace(20), + VSpace(bottomPadding), ], ), ), @@ -76,6 +78,45 @@ class DesktopSignInScreen extends StatelessWidget { }, ); } + + 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) { + final theme = AppFlowyTheme.of(context); + return AFGhostIconTextButton( + text: LocaleKeys.signIn_settings.tr(), + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + size: AFButtonSize.s, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, + ), + onTap: () => showSimpleSettingsDialog(context), + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.settings_s, + size: Size.square(20), + color: theme.textColorScheme.secondary, + ); + }, + ); + } } class _OrDivider extends StatelessWidget { @@ -83,20 +124,28 @@ class _OrDivider extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Row( children: [ - const Flexible( + Flexible( child: Divider( thickness: 1, + color: theme.borderColorScheme.greyTertiary, ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: FlowyText.regular(LocaleKeys.signIn_or.tr()), + child: Text( + LocaleKeys.signIn_or.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.secondary, + ), + ), ), - const Flexible( + Flexible( child: Divider( thickness: 1, + color: theme.borderColorScheme.greyTertiary, ), ), ], 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 index 2e2e1e8f39..9eb7d5a965 100644 --- 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 @@ -1,13 +1,17 @@ -import 'package:flutter/material.dart'; +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/anonymous_sign_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/user/presentation/widgets/flowy_logo_title.dart'; +import 'package:appflowy_ui/appflowy_ui.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'; @@ -18,40 +22,29 @@ class MobileSignInScreen extends StatelessWidget { @override Widget build(BuildContext context) { - const double spacing = 16; - final colorScheme = Theme.of(context).colorScheme; return BlocBuilder( builder: (context, state) { + final theme = AppFlowyTheme.of(context); return Scaffold( + resizeToAvoidBottomInset: false, body: Padding( - padding: const EdgeInsets.symmetric(vertical: 50, horizontal: 40), + padding: const EdgeInsets.symmetric(vertical: 38, horizontal: 40), child: Column( children: [ - const Spacer(flex: 4), - _buildLogo(), - const VSpace(spacing * 2), - _buildWelcomeText(), - _buildAppNameText(colorScheme), - const VSpace(spacing * 2), - const SignInWithMagicLinkButtons(), - const VSpace(spacing), - if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme), - const VSpace(spacing), - const SignInAnonymousButtonV2(), - const VSpace(spacing), - SwitchSignInSignUpButton( - onTap: () { - final type = state.loginType == LoginType.signIn - ? LoginType.signUp - : LoginType.signIn; - context.read().add( - SignInEvent.switchLoginType(type), - ); - }, - ), - const VSpace(spacing), + const Spacer(), + FlowyLogoTitle(title: LocaleKeys.welcomeText.tr()), + VSpace(theme.spacing.xxl), + isLocalAuthEnabled + ? const SignInAnonymousButtonV3() + : const ContinueWithEmailAndPassword(), + VSpace(theme.spacing.xxl), + if (isAuthEnabled) ...[ + _buildThirdPartySignInButtons(context), + VSpace(theme.spacing.xxl), + ], + const SignInAgreement(), + const Spacer(), _buildSettingsButton(context), - if (!isAuthEnabled) const Spacer(flex: 2), ], ), ), @@ -60,34 +53,8 @@ class MobileSignInScreen extends StatelessWidget { ); } - Widget _buildWelcomeText() { - return FlowyText( - LocaleKeys.welcomeTo.tr(), - textAlign: TextAlign.center, - fontSize: 32, - fontWeight: FontWeight.w700, - ); - } - - Widget _buildLogo() { - return const FlowySvg( - FlowySvgs.flowy_logo_xl, - size: Size.square(64), - blendMode: null, - ); - } - - Widget _buildAppNameText(ColorScheme colorScheme) { - return FlowyText( - LocaleKeys.appName.tr(), - textAlign: TextAlign.center, - fontSize: 32, - color: const Color(0xFF00BCF0), - fontWeight: FontWeight.w700, - ); - } - - Widget _buildThirdPartySignInButtons(ColorScheme colorScheme) { + Widget _buildThirdPartySignInButtons(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( children: [ Row( @@ -96,32 +63,57 @@ class MobileSignInScreen extends StatelessWidget { const Expanded(child: Divider()), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: FlowyText( + child: Text( LocaleKeys.signIn_or.tr(), - color: colorScheme.onSecondary, + style: TextStyle( + fontSize: 16, + color: theme.textColorScheme.secondary, + ), ), ), const Expanded(child: Divider()), ], ), const VSpace(16), - const ThirdPartySignInButtons(), + // 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 FlowyButton( - text: FlowyText( - LocaleKeys.signIn_settings.tr(), - textAlign: TextAlign.center, - fontSize: 12.0, - fontWeight: FontWeight.w500, - decoration: TextDecoration.underline, - ), - onTap: () { - context.push(MobileLaunchSettingsPage.routeName); - }, + final theme = AppFlowyTheme.of(context); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + AFGhostIconTextButton( + text: LocaleKeys.signIn_settings.tr(), + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + size: AFButtonSize.s, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, + ), + onTap: () => context.push(MobileLaunchSettingsPage.routeName), + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.settings_s, + size: Size.square(20), + color: theme.textColorScheme.secondary, + ); + }, + ), + const HSpace(24), + isLocalAuthEnabled + ? const ChangeCloudModeButton() + : 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 index 63f38db424..b359b2e217 100644 --- 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 @@ -2,18 +2,14 @@ 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:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../helpers/helpers.dart'; +import 'package:universal_platform/universal_platform.dart'; class SignInScreen extends StatelessWidget { - const SignInScreen({ - super.key, - }); + const SignInScreen({super.key}); static const routeName = '/SignInScreen'; @@ -22,26 +18,27 @@ class SignInScreen extends StatelessWidget { return BlocProvider( create: (context) => getIt(), child: BlocConsumer( - listener: (context, state) { - final successOrFail = state.successOrFail; - if (successOrFail != null) { - handleUserProfileResult( - successOrFail, - context, - getIt(), - ); - } - }, + listener: _showSignInError, builder: (context, state) { - final isLoading = context.read().state.isSubmitting; - if (PlatformExtension.isMobile) { - return isLoading - ? const MobileLoadingScreen() - : const MobileSignInScreen(); - } - return const DesktopSignInScreen(); + return UniversalPlatform.isDesktop + ? const DesktopSignInScreen() + : const MobileSignInScreen(); }, ), ); } + + void _showSignInError(BuildContext context, SignInState state) { + final successOrFail = state.successOrFail; + if (successOrFail != null) { + successOrFail.fold( + (userProfile) { + getIt().goHomeScreen(context, userProfile); + }, + (error) { + Log.error('Sign in error: $error'); + }, + ); + } + } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart new file mode 100644 index 0000000000..a7a1b9722d --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart @@ -0,0 +1,57 @@ +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:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SignInAnonymousButtonV3 extends StatelessWidget { + const SignInAnonymousButtonV3({ + 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_continueWithLocalModel.tr(); + final onTap = state.anonUsers.isEmpty + ? () { + context + .read() + .add(const SignInEvent.signInAsGuest()); + } + : () { + final bloc = context.read(); + final user = bloc.state.anonUsers.first; + bloc.add(AnonUserEvent.openAnonUser(user)); + }; + return AFFilledTextButton.primary( + text: text, + size: AFButtonSize.l, + alignment: Alignment.center, + onTap: onTap, + ); + }, + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button/anonymous_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button/anonymous_sign_in_button.dart new file mode 100644 index 0000000000..351527137f --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button/anonymous_sign_in_button.dart @@ -0,0 +1,16 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class AnonymousSignInButton extends StatelessWidget { + const AnonymousSignInButton({super.key}); + + @override + Widget build(BuildContext context) { + return AFGhostButton.normal( + onTap: () {}, + builder: (context, isHovering, disabled) { + return const Placeholder(); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart new file mode 100644 index 0000000000..c4cf504ef5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart @@ -0,0 +1,23 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class ContinueWithEmail extends StatelessWidget { + const ContinueWithEmail({ + super.key, + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return AFFilledTextButton.primary( + text: LocaleKeys.signIn_continueWithEmail.tr(), + size: AFButtonSize.l, + alignment: Alignment.center, + onTap: onTap, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart new file mode 100644 index 0000000000..5027874418 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart @@ -0,0 +1,187 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart'; +import 'package:appflowy_ui/appflowy_ui.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'; + +class ContinueWithEmailAndPassword extends StatefulWidget { + const ContinueWithEmailAndPassword({super.key}); + + @override + State createState() => + _ContinueWithEmailAndPasswordState(); +} + +class _ContinueWithEmailAndPasswordState + extends State { + final controller = TextEditingController(); + final focusNode = FocusNode(); + final emailKey = GlobalKey(); + + bool _hasPushedContinueWithMagicLinkOrPasscodePage = false; + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return BlocListener( + listener: (context, state) { + final successOrFail = state.successOrFail; + // only push the continue with magic link or passcode page if the magic link is sent successfully + if (successOrFail != null) { + successOrFail.fold( + (_) => emailKey.currentState?.clearError(), + (error) => emailKey.currentState?.syncError( + errorText: error.msg, + ), + ); + } else if (successOrFail == null && !state.isSubmitting) { + emailKey.currentState?.clearError(); + } + }, + child: Column( + children: [ + AFTextField( + key: emailKey, + controller: controller, + hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(), + onSubmitted: (value) => _signInWithEmail( + context, + value, + ), + ), + VSpace(theme.spacing.l), + ContinueWithEmail( + onTap: () => _signInWithEmail( + context, + controller.text, + ), + ), + VSpace(theme.spacing.l), + ContinueWithPassword( + onTap: () { + final email = controller.text; + + if (!isEmail(email)) { + emailKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidEmail.tr(), + ); + return; + } + + _pushContinueWithPasswordPage( + context, + email, + ); + }, + ), + ], + ), + ); + } + + void _signInWithEmail(BuildContext context, String email) { + if (!isEmail(email)) { + emailKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidEmail.tr(), + ); + return; + } + + context + .read() + .add(SignInEvent.signInWithMagicLink(email: email)); + + _pushContinueWithMagicLinkOrPasscodePage( + context, + email, + ); + } + + void _pushContinueWithMagicLinkOrPasscodePage( + BuildContext context, + String email, + ) { + if (_hasPushedContinueWithMagicLinkOrPasscodePage) { + return; + } + + final signInBloc = context.read(); + + // push the a continue with magic link or passcode screen + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: signInBloc, + child: ContinueWithMagicLinkOrPasscodePage( + email: email, + backToLogin: () { + Navigator.pop(context); + + emailKey.currentState?.clearError(); + + _hasPushedContinueWithMagicLinkOrPasscodePage = false; + }, + onEnterPasscode: (passcode) { + signInBloc.add( + SignInEvent.signInWithPasscode( + email: email, + passcode: passcode, + ), + ); + }, + ), + ), + ), + ); + + _hasPushedContinueWithMagicLinkOrPasscodePage = true; + } + + void _pushContinueWithPasswordPage( + BuildContext context, + String email, + ) { + final signInBloc = context.read(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: signInBloc, + child: ContinueWithPasswordPage( + email: email, + backToLogin: () { + emailKey.currentState?.clearError(); + Navigator.pop(context); + }, + onEnterPassword: (password) => signInBloc.add( + SignInEvent.signInWithEmailAndPassword( + email: email, + password: password, + ), + ), + onForgotPassword: () { + // todo: implement forgot password + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart new file mode 100644 index 0000000000..ec4fd1bbee --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart @@ -0,0 +1,226 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_ui/appflowy_ui.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 ContinueWithMagicLinkOrPasscodePage extends StatefulWidget { + const ContinueWithMagicLinkOrPasscodePage({ + super.key, + required this.backToLogin, + required this.email, + required this.onEnterPasscode, + }); + + final String email; + final VoidCallback backToLogin; + final ValueChanged onEnterPasscode; + + @override + State createState() => + _ContinueWithMagicLinkOrPasscodePageState(); +} + +class _ContinueWithMagicLinkOrPasscodePageState + extends State { + final passcodeController = TextEditingController(); + + bool isEnteringPasscode = false; + + ToastificationItem? toastificationItem; + + final inputPasscodeKey = GlobalKey(); + + @override + void dispose() { + passcodeController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + final successOrFail = state.successOrFail; + if (successOrFail != null && successOrFail.isFailure) { + successOrFail.onFailure((error) { + inputPasscodeKey.currentState?.syncError( + errorText: LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr(), + ); + }); + } + }, + child: Scaffold( + body: Center( + child: SizedBox( + width: 320, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo, title and description + ..._buildLogoTitleAndDescription(), + + // Enter code manually + ..._buildEnterCodeManually(), + + // Back to login + ..._buildBackToLogin(), + ], + ), + ), + ), + ), + ); + } + + List _buildEnterCodeManually() { + // todo: ask designer to provide the spacing + final spacing = VSpace(20); + + if (!isEnteringPasscode) { + return [ + AFFilledTextButton.primary( + text: LocaleKeys.signIn_enterCodeManually.tr(), + onTap: () => setState(() => isEnteringPasscode = true), + size: AFButtonSize.l, + alignment: Alignment.center, + ), + spacing, + ]; + } + + return [ + // Enter code manually + AFTextField( + key: inputPasscodeKey, + controller: passcodeController, + hintText: LocaleKeys.signIn_enterCode.tr(), + keyboardType: TextInputType.number, + autoFocus: true, + onSubmitted: (passcode) { + if (passcode.isEmpty) { + inputPasscodeKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidVerificationCode.tr(), + ); + } else { + widget.onEnterPasscode(passcode); + } + }, + ), + // todo: ask designer to provide the spacing + VSpace(12), + + // continue to login + AFFilledTextButton.primary( + text: LocaleKeys.signIn_continueToSignIn.tr(), + onTap: () { + final passcode = passcodeController.text; + if (passcode.isEmpty) { + inputPasscodeKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidVerificationCode.tr(), + ); + } else { + widget.onEnterPasscode(passcode); + } + }, + size: AFButtonSize.l, + alignment: Alignment.center, + ), + + spacing, + ]; + } + + List _buildBackToLogin() { + return [ + AFGhostTextButton( + text: LocaleKeys.signIn_backToLogin.tr(), + size: AFButtonSize.s, + onTap: widget.backToLogin, + padding: EdgeInsets.zero, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + ), + ]; + } + + List _buildLogoTitleAndDescription() { + final theme = AppFlowyTheme.of(context); + final spacing = VSpace(theme.spacing.xxl); + if (!isEnteringPasscode) { + return [ + // logo + const AFLogo(), + spacing, + + // title + Text( + LocaleKeys.signIn_checkYourEmail.tr(), + style: theme.textStyle.heading3.enhanced( + color: theme.textColorScheme.primary, + ), + ), + spacing, + + // description + Text( + LocaleKeys.signIn_temporaryVerificationLinkSent.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + Text( + widget.email, + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + spacing, + ]; + } else { + return [ + // logo + const AFLogo(), + spacing, + + // title + Text( + LocaleKeys.signIn_enterCode.tr(), + style: theme.textStyle.heading3.enhanced( + color: theme.textColorScheme.primary, + ), + ), + spacing, + + // description + Text( + LocaleKeys.signIn_temporaryVerificationCodeSent.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + Text( + widget.email, + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + textAlign: TextAlign.center, + ), + spacing, + ]; + } + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart new file mode 100644 index 0000000000..5bfd191e22 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart @@ -0,0 +1,21 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class ContinueWithPassword extends StatelessWidget { + const ContinueWithPassword({ + super.key, + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return AFOutlinedTextButton.normal( + text: 'Continue with password', + size: AFButtonSize.l, + alignment: Alignment.center, + onTap: onTap, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart new file mode 100644 index 0000000000..1e2ed6e100 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart @@ -0,0 +1,196 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.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'; + +class ContinueWithPasswordPage extends StatefulWidget { + const ContinueWithPasswordPage({ + super.key, + required this.backToLogin, + required this.email, + required this.onEnterPassword, + required this.onForgotPassword, + }); + + final String email; + final VoidCallback backToLogin; + final ValueChanged onEnterPassword; + final VoidCallback onForgotPassword; + + @override + State createState() => + _ContinueWithPasswordPageState(); +} + +class _ContinueWithPasswordPageState extends State { + final passwordController = TextEditingController(); + final inputPasswordKey = GlobalKey(); + + @override + void dispose() { + passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SizedBox( + width: 320, + child: BlocListener( + listener: (context, state) { + final successOrFail = state.successOrFail; + if (successOrFail != null && successOrFail.isFailure) { + successOrFail.onFailure((error) { + inputPasswordKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidLoginCredentials.tr(), + ); + }); + } else if (state.passwordError != null) { + inputPasswordKey.currentState?.syncError( + errorText: LocaleKeys.signIn_invalidLoginCredentials.tr(), + ); + } else { + inputPasswordKey.currentState?.clearError(); + } + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo and title + ..._buildLogoAndTitle(), + + // Password input and buttons + ..._buildPasswordSection(), + + // Back to login + ..._buildBackToLogin(), + ], + ), + ), + ), + ), + ); + } + + List _buildLogoAndTitle() { + final theme = AppFlowyTheme.of(context); + final spacing = VSpace(theme.spacing.xxl); + return [ + // logo + const AFLogo(), + spacing, + + // title + Text( + LocaleKeys.signIn_enterPassword.tr(), + style: theme.textStyle.heading3.enhanced( + color: theme.textColorScheme.primary, + ), + ), + spacing, + + // email display + RichText( + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.signIn_loginAs.tr(), + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + ), + TextSpan( + text: ' ${widget.email}', + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + ], + ), + ), + spacing, + ]; + } + + List _buildPasswordSection() { + final theme = AppFlowyTheme.of(context); + final iconSize = 20.0; + return [ + // Password input + AFTextField( + key: inputPasswordKey, + controller: passwordController, + hintText: LocaleKeys.signIn_enterPassword.tr(), + autoFocus: true, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + inputPasswordKey.currentState?.syncObscured(!isObscured); + }, + ), + onSubmitted: widget.onEnterPassword, + ), + // todo: ask designer to provide the spacing + VSpace(8), + + // Forgot password button + Align( + alignment: Alignment.centerLeft, + child: AFGhostTextButton( + text: LocaleKeys.signIn_forgotPassword.tr(), + size: AFButtonSize.s, + padding: EdgeInsets.zero, + onTap: widget.onForgotPassword, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + ), + ), + VSpace(20), + + // Continue button + AFFilledTextButton.primary( + text: LocaleKeys.web_continue.tr(), + onTap: () => widget.onEnterPassword(passwordController.text), + size: AFButtonSize.l, + alignment: Alignment.center, + ), + VSpace(20), + ]; + } + + List _buildBackToLogin() { + return [ + AFGhostTextButton( + text: LocaleKeys.signIn_backToLogin.tr(), + size: AFButtonSize.s, + onTap: widget.backToLogin, + padding: EdgeInsets.zero, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + ), + ]; + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart new file mode 100644 index 0000000000..8e126db7ad --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart @@ -0,0 +1,20 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flutter/material.dart'; + +class AFLogo extends StatelessWidget { + const AFLogo({ + super.key, + this.size = const Size.square(36), + }); + + final Size size; + + @override + Widget build(BuildContext context) { + return FlowySvg( + FlowySvgs.app_logo_xl, + blendMode: null, + size: size, + ); + } +} 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 index 1b2c463389..45e4fe7273 100644 --- 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 @@ -1,18 +1,16 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy_editor/appflowy_editor.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, - }); + const SignInWithMagicLinkButtons({super.key}); @override State createState() => @@ -22,10 +20,12 @@ class SignInWithMagicLinkButtons extends StatefulWidget { class _SignInWithMagicLinkButtonsState extends State { final controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); @override void dispose() { controller.dispose(); + _focusNode.dispose(); super.dispose(); } @@ -35,13 +35,23 @@ class _SignInWithMagicLinkButtonsState crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - height: 48.0, + 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), @@ -54,21 +64,21 @@ class _SignInWithMagicLinkButtonsState void _sendMagicLink(BuildContext context, String email) { if (!isEmail(email)) { - showSnackBarMessage( - context, - LocaleKeys.signIn_invalidEmail.tr(), - duration: const Duration(seconds: 8), + showToastNotification( + message: LocaleKeys.signIn_invalidEmail.tr(), + type: ToastificationType.error, ); return; } - // if (context.read().state.isSubmitting) { - // return; - // } - context.read().add(SignInEvent.signedWithMagicLink(email)); - showSnackBarMessage( - context, - LocaleKeys.signIn_magicLinkSent.tr(), - duration: const Duration(seconds: 1000), + + context + .read() + .add(SignInEvent.signInWithMagicLink(email: email)); + + showConfirmDialog( + context: context, + title: LocaleKeys.signIn_magicLinkSent.tr(), + description: LocaleKeys.signIn_magicLinkSentDescription.tr(), ); } } @@ -88,17 +98,17 @@ class _ConfirmButton extends StatelessWidget { LoginType.signIn => LocaleKeys.signIn_signInWithMagicLink.tr(), LoginType.signUp => LocaleKeys.signIn_signUpWithMagicLink.tr(), }; - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { return ElevatedButton( style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 56), + 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, - fontWeight: FontWeight.w500, ), ); } else { @@ -111,6 +121,7 @@ class _ConfirmButton extends StatelessWidget { 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 0000000000..76ce87ffc1 --- /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:appflowy_ui/appflowy_ui.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) { + final theme = AppFlowyTheme.of(context); + final textStyle = theme.textStyle.caption.standard( + color: theme.textColorScheme.secondary, + ); + final underlinedTextStyle = theme.textStyle.caption.underline( + color: theme.textColorScheme.secondary, + ); + return RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.web_signInAgreement.tr(), + style: textStyle, + ), + TextSpan( + text: '${LocaleKeys.web_termOfUse.tr()} ', + style: underlinedTextStyle, + mouseCursor: SystemMouseCursors.click, + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString('https://appflowy.com/terms'), + ), + TextSpan( + text: '${LocaleKeys.web_and.tr()} ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.web_privacyPolicy.tr(), + style: underlinedTextStyle, + mouseCursor: SystemMouseCursors.click, + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString('https://appflowy.com/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 index e8d6bac536..33ef1d7bb0 100644 --- 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 @@ -1,91 +1,14 @@ +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/anon_user_bloc.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_ui/appflowy_ui.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'; -/// Used in DesktopSignInScreen and MobileSignInScreen -class SignInAnonymousButton extends StatelessWidget { - const SignInAnonymousButton({ - super.key, - }); - - @override - Widget build(BuildContext context) { - final isMobile = PlatformExtension.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, @@ -108,30 +31,35 @@ class SignInAnonymousButtonV2 extends StatelessWidget { }, child: BlocBuilder( builder: (context, state) { - final text = state.anonUsers.isEmpty - ? LocaleKeys.signIn_loginStartWithAnonymous.tr() - : LocaleKeys.signIn_continueAnonymousUser.tr(); + final theme = AppFlowyTheme.of(context); final onTap = state.anonUsers.isEmpty ? () { context .read() - .add(const SignInEvent.signedInAsGuest()); + .add(const SignInEvent.signInAsGuest()); } : () { final bloc = context.read(); final user = bloc.state.anonUsers.first; bloc.add(AnonUserEvent.openAnonUser(user)); }; - return MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: onTap, - child: FlowyText( - text, - color: Colors.blue, - fontSize: 12, - ), + return AFGhostIconTextButton( + text: LocaleKeys.signIn_anonymousMode.tr(), + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, ), + size: AFButtonSize.s, + onTap: onTap, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.anonymous_mode_m, + color: theme.textColorScheme.secondary, + ); + }, ); }, ), @@ -141,3 +69,39 @@ class SignInAnonymousButtonV2 extends StatelessWidget { ); } } + +class ChangeCloudModeButton extends StatelessWidget { + const ChangeCloudModeButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return AFGhostIconTextButton( + text: LocaleKeys.signIn_switchToAppFlowyCloud.tr(), + textColor: (context, isHovering, disabled) { + return theme.textColorScheme.secondary; + }, + size: AFButtonSize.s, + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, + ), + onTap: () async { + await useAppFlowyBetaCloudWithURL( + kAppflowyCloudUrl, + AuthenticatorType.appflowyCloud, + ); + await runAppFlowy(); + }, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.cloud_mode_m, + size: Size.square(20), + color: theme.textColorScheme.secondary, + ); + }, + ); + } +} 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 index e25fcf3a35..7067844500 100644 --- 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 @@ -1,64 +1,36 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; -class MobileSignInOrLogoutButton extends StatelessWidget { - const MobileSignInOrLogoutButton({ +class MobileLogoutButton extends StatelessWidget { + const MobileLogoutButton({ super.key, this.icon, - required this.labelText, + required this.text, + this.textColor, required this.onPressed, }); final FlowySvgData? icon; - final String labelText; + final String text; + final Color? textColor; final VoidCallback onPressed; @override Widget build(BuildContext context) { - final style = Theme.of(context); - return GestureDetector( + return AFOutlinedIconTextButton.normal( + text: text, onTap: onPressed, - child: Container( - height: 48, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all( - Radius.circular(4), - ), - border: Border.all( - color: 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( - labelText, - fontSize: 14.0, - fontWeight: FontWeight.w500, - ), - ], - ), - ), + size: AFButtonSize.l, + iconBuilder: (context, isHovering, disabled) { + if (icon == null) { + return const SizedBox.shrink(); + } + return FlowySvg( + icon!, + size: Size.square(18), + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_button.dart new file mode 100644 index 0000000000..9a7234ab6b --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_button.dart @@ -0,0 +1,153 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.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.onTap, + required this.type, + }); + + final VoidCallback onTap; + final double height; + final double fontSize; + final ThirdPartySignInButtonType type; + + @override + Widget build(BuildContext context) { + return AFOutlinedIconTextButton.normal( + text: type.labelText, + onTap: onTap, + size: AFButtonSize.l, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + type.icon, + size: Size.square(16), + blendMode: type.blendMode, + ); + }, + ); + } +} + +class DesktopThirdPartySignInButton extends StatelessWidget { + const DesktopThirdPartySignInButton({ + super.key, + required this.type, + required this.onTap, + }); + + final ThirdPartySignInButtonType type; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return AFOutlinedIconTextButton.normal( + text: type.labelText, + onTap: onTap, + size: AFButtonSize.l, + iconBuilder: (context, isHovering, disabled) { + return FlowySvg( + type.icon, + size: Size.square(18), + blendMode: type.blendMode, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart new file mode 100644 index 0000000000..8d27846c46 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart @@ -0,0 +1,204 @@ +import 'dart:io'; + +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy_ui/appflowy_ui.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.signInWithOAuth(platform: provider), + ); + } +} + +class _DesktopThirdPartySignIn extends StatefulWidget { + const _DesktopThirdPartySignIn({ + required this.onSignIn, + }); + + final _SignInCallback onSignIn; + + @override + State<_DesktopThirdPartySignIn> createState() => + _DesktopThirdPartySignInState(); +} + +class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> { + bool isExpanded = false; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Column( + children: [ + DesktopThirdPartySignInButton( + key: signInWithGoogleButtonKey, + type: ThirdPartySignInButtonType.google, + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google), + ), + VSpace(theme.spacing.l), + DesktopThirdPartySignInButton( + type: ThirdPartySignInButtonType.apple, + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple), + ), + ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), + ], + ); + } + + List _buildExpandedButtons() { + final theme = AppFlowyTheme.of(context); + return [ + VSpace(theme.spacing.l), + DesktopThirdPartySignInButton( + type: ThirdPartySignInButtonType.github, + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github), + ), + VSpace(theme.spacing.l), + DesktopThirdPartySignInButton( + type: ThirdPartySignInButtonType.discord, + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + ), + ]; + } + + List _buildCollapsedButtons() { + final theme = AppFlowyTheme.of(context); + return [ + VSpace(theme.spacing.l), + AFGhostTextButton( + text: 'More options', + padding: EdgeInsets.zero, + textColor: (context, isHovering, disabled) { + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + onTap: () { + setState(() { + isExpanded = !isExpanded; + }); + }, + ), + ]; + } +} + +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, + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple), + ), + const VSpace(padding), + ], + MobileThirdPartySignInButton( + key: signInWithGoogleButtonKey, + type: ThirdPartySignInButtonType.google, + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google), + ), + ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), + ], + ); + } + + List _buildExpandedButtons() { + return [ + const VSpace(padding), + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.github, + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github), + ), + const VSpace(padding), + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.discord, + onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + ), + ]; + } + + List _buildCollapsedButtons() { + final theme = AppFlowyTheme.of(context); + return [ + const VSpace(padding * 2), + AFGhostTextButton( + text: 'More options', + textColor: (context, isHovering, disabled) { + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.textColorScheme.theme; + }, + onTap: () { + setState(() { + isExpanded = !isExpanded; + }); + }, + ), + ]; + } +} 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 deleted file mode 100644 index ce446320a3..0000000000 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/user/application/sign_in_bloc.dart'; -import 'package:appflowy/user/presentation/presentation.dart'; -import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy_editor/appflowy_editor.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'; - -class ThirdPartySignInButtons extends StatelessWidget { - /// Used in DesktopSignInScreen, MobileSignInScreen and SettingThirdPartyLogin - const ThirdPartySignInButtons({ - super.key, - }); - - @override - Widget build(BuildContext context) { - // Get themeMode from AppearanceSettingsCubit - // When user changes themeMode, it changes the state in AppearanceSettingsCubit, but the themeMode for the MaterialApp won't change, it only got updated(get value from AppearanceSettingsCubit) when user open the app again. Thus, we should get themeMode from AppearanceSettingsCubit rather than MediaQuery. - - final themeModeFromCubit = - context.watch().state.themeMode; - - final isDarkMode = themeModeFromCubit == ThemeMode.system - ? MediaQuery.of(context).platformBrightness == Brightness.dark - : themeModeFromCubit == ThemeMode.dark; - - return BlocBuilder( - builder: (context, state) { - final (googleText, githubText, discordText) = switch (state.loginType) { - LoginType.signIn => ( - LocaleKeys.signIn_signInWithGoogle.tr(), - LocaleKeys.signIn_signInWithGithub.tr(), - LocaleKeys.signIn_signInWithDiscord.tr() - ), - LoginType.signUp => ( - LocaleKeys.signIn_signUpWithGoogle.tr(), - LocaleKeys.signIn_signUpWithGithub.tr(), - LocaleKeys.signIn_signUpWithDiscord.tr() - ), - }; - return Column( - children: [ - _ThirdPartySignInButton( - key: const Key('signInWithGoogleButton'), - icon: FlowySvgs.google_mark_xl, - labelText: googleText, - onPressed: () { - _signInWithGoogle(context); - }, - ), - const VSpace(8), - _ThirdPartySignInButton( - icon: isDarkMode - ? FlowySvgs.github_mark_white_xl - : FlowySvgs.github_mark_black_xl, - labelText: githubText, - onPressed: () { - _signInWithGithub(context); - }, - ), - const VSpace(8), - _ThirdPartySignInButton( - icon: isDarkMode - ? FlowySvgs.discord_mark_white_xl - : FlowySvgs.discord_mark_blurple_xl, - labelText: discordText, - onPressed: () { - _signInWithDiscord(context); - }, - ), - ], - ); - }, - ); - } -} - -class _ThirdPartySignInButton extends StatelessWidget { - /// Build button based on current Platform(mobile or desktop). - const _ThirdPartySignInButton({ - super.key, - required this.icon, - required this.labelText, - required this.onPressed, - }); - - final FlowySvgData icon; - final String labelText; - - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - if (PlatformExtension.isMobile) { - return MobileSignInOrLogoutButton( - icon: icon, - labelText: labelText, - onPressed: onPressed, - ); - } else { - return _DesktopSignInButton( - icon: icon, - labelText: labelText, - onPressed: onPressed, - ); - } - } -} - -class _DesktopSignInButton extends StatelessWidget { - const _DesktopSignInButton({ - required this.icon, - required this.labelText, - required this.onPressed, - }); - - final FlowySvgData icon; - final String labelText; - - 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( - icon, - blendMode: null, - ), - ), - ), - label: Container( - padding: const EdgeInsets.only(left: 8), - alignment: Alignment.centerLeft, - child: FlowyText( - labelText, - fontSize: 14, - ), - ), - style: ButtonStyle( - overlayColor: MaterialStateProperty.resolveWith( - (states) { - if (states.contains(MaterialState.hovered)) { - return style.colorScheme.onSecondaryContainer; - } - return null; - }, - ), - shape: MaterialStateProperty.all( - const RoundedRectangleBorder( - borderRadius: Corners.s6Border, - ), - ), - side: MaterialStateProperty.all( - BorderSide( - color: style.dividerColor, - ), - ), - ), - onPressed: onPressed, - ), - ); - } -} - -void _signInWithGoogle(BuildContext context) { - context.read().add( - const SignInEvent.signedInWithOAuth('google'), - ); -} - -void _signInWithGithub(BuildContext context) { - context.read().add(const SignInEvent.signedInWithOAuth('github')); -} - -void _signInWithDiscord(BuildContext context) { - context - .read() - .add(const SignInEvent.signedInWithOAuth('discord')); -} 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 index 974e2b5927..6d79b896c1 100644 --- 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 @@ -1,5 +1,7 @@ -export 'magic_link_sign_in_buttons.dart'; +export 'continue_with/continue_with_email_and_password.dart'; +export 'sign_in_agreement.dart'; export 'sign_in_anonymous_button.dart'; export 'sign_in_or_logout_button.dart'; -export 'switch_sign_in_sign_up_button.dart'; -export 'third_party_sign_in_buttons.dart'; +export 'third_party_sign_in_button/third_party_sign_in_button.dart'; +// export 'switch_sign_in_sign_up_button.dart'; +export 'third_party_sign_in_button/third_party_sign_in_buttons.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 deleted file mode 100644 index 8aea8dde55..0000000000 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart +++ /dev/null @@ -1,220 +0,0 @@ -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 index b125abf93c..ee089dfce0 100644 --- 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 @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; @@ -10,15 +8,14 @@ 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/workspace/presentation/settings/widgets/settings_language_view.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; -import 'package:appflowy_popover/appflowy_popover.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}); @@ -36,9 +33,7 @@ class _SkipLogInScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: const _SkipLoginMoveWindow(), - body: Center( - child: _renderBody(context), - ), + body: Center(child: _renderBody(context)), ); } @@ -50,7 +45,7 @@ class _SkipLogInScreenState extends State { const Spacer(), FlowyLogoTitle( title: LocaleKeys.welcomeText.tr(), - logoSize: Size.square(PlatformExtension.isMobile ? 80 : 40), + logoSize: Size.square(UniversalPlatform.isMobile ? 80 : 40), ), const VSpace(32), GoButton( @@ -73,9 +68,7 @@ class _SkipLogInScreenState extends State { SizedBox( width: size.width * 0.7, child: FolderWidget( - createFolderCallback: () async { - _didCustomizeFolder = true; - }, + createFolderCallback: () async => _didCustomizeFolder = true, ), ), const Spacer(), @@ -88,24 +81,16 @@ class _SkipLogInScreenState extends State { Future _autoRegister(BuildContext context) async { final result = await getIt().signUpAsGuest(); result.fold( - (user) { - getIt().goHomeScreen(context, user); - }, - (error) { - Log.error(error); - }, + (user) => getIt().goHomeScreen(context, user), + (error) => Log.error(error), ); } - Future _relaunchAppAndAutoRegister() async { - await runAppFlowy(isAnon: true); - } + Future _relaunchAppAndAutoRegister() async => runAppFlowy(isAnon: true); } class SkipLoginPageFooter extends StatelessWidget { - const SkipLoginPageFooter({ - super.key, - }); + const SkipLoginPageFooter({super.key}); @override Widget build(BuildContext context) { @@ -116,7 +101,7 @@ class SkipLoginPageFooter extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (!PlatformExtension.isMobile) const HSpace(placeholderWidth), + if (!UniversalPlatform.isMobile) const HSpace(placeholderWidth), const Expanded(child: SubscribeButtons()), const SizedBox( width: placeholderWidth, @@ -135,9 +120,7 @@ class SkipLoginPageFooter extends StatelessWidget { } class SubscribeButtons extends StatelessWidget { - const SubscribeButtons({ - super.key, - }); + const SubscribeButtons({super.key}); @override Widget build(BuildContext context) { @@ -168,10 +151,7 @@ class SubscribeButtons extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - FlowyText.regular( - LocaleKeys.and.tr(), - fontSize: FontSizes.s12, - ), + FlowyText.regular(LocaleKeys.and.tr(), fontSize: FontSizes.s12), FlowyTextButton( LocaleKeys.subscribeNewsletterText.tr(), padding: const EdgeInsets.symmetric(horizontal: 4.0), @@ -190,9 +170,7 @@ class SubscribeButtons extends StatelessWidget { } class LanguageSelectorOnWelcomePage extends StatelessWidget { - const LanguageSelectorOnWelcomePage({ - super.key, - }); + const LanguageSelectorOnWelcomePage({super.key}); @override Widget build(BuildContext context) { @@ -205,24 +183,16 @@ class LanguageSelectorOnWelcomePage extends StatelessWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, children: [ - const FlowySvg( - FlowySvgs.ethernet_m, - size: Size.square(20), - ), + 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), - ); + return FlowyText(languageFromLocale(currentLocale)); }, ), - const FlowySvg( - FlowySvgs.drop_menu_hide_m, - size: Size.square(20), - ), + const FlowySvg(FlowySvgs.drop_menu_hide_m, size: Size.square(20)), ], ), ), @@ -231,15 +201,68 @@ class LanguageSelectorOnWelcomePage extends StatelessWidget { if (easyLocalization == null) { return const SizedBox.shrink(); } - final allLocales = easyLocalization.supportedLocales; + return LanguageItemsListView( - allLocales: allLocales, + 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}); @@ -248,10 +271,7 @@ class GoButton extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => AnonUserBloc() - ..add( - const AnonUserEvent.initial(), - ), + create: (context) => AnonUserBloc()..add(const AnonUserEvent.initial()), child: BlocListener( listener: (context, state) async { if (state.openedAnonUser != null) { @@ -265,7 +285,6 @@ class GoButton extends StatelessWidget { : LocaleKeys.signIn_continueAnonymousUser.tr(); final textWidget = Row( - // mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: FlowyText.medium( @@ -274,22 +293,6 @@ class GoButton extends StatelessWidget { fontSize: 14, ), ), - // Tooltip( - // message: LocaleKeys.settings_menu_configServerGuide.tr(), - // child: Container( - // width: 30.0, - // decoration: const BoxDecoration( - // shape: BoxShape.circle, - // ), - // child: Center( - // child: Icon( - // Icons.help, - // color: Colors.white, - // weight: 2, - // ), - // ), - // ), - // ), ], ); @@ -325,15 +328,8 @@ class _SkipLoginMoveWindow extends StatelessWidget const _SkipLoginMoveWindow(); @override - Widget build(BuildContext context) { - return const Row( - children: [ - Expanded( - child: MoveWindowDetector(), - ), - ], - ); - } + 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 index fe02f193cc..4062cedf8e 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart @@ -8,18 +8,14 @@ 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:appflowy_editor/appflowy_editor.dart' hide Log; 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, - }); + const SplashScreen({super.key, required this.isAnon}); final bool isAnon; @@ -64,38 +60,21 @@ class SplashScreen extends StatelessWidget { 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); + final result = await FolderEventGetCurrentWorkspaceSetting().send(); + result.fold( + (workspaceSetting) { + // After login, replace Splash screen by corresponding home screen + getIt().goHomeScreen( + context, + ); }, + (error) => handleOpenWorkspaceError(context, error), ); } void _handleUnauthenticated(BuildContext context, Unauthenticated result) { // replace Splash screen as root page - if (isAuthEnabled || PlatformExtension.isMobile) { + if (isAuthEnabled || UniversalPlatform.isMobile) { context.go(SignInScreen.routeName); } else { // if the env is not configured, we will skip to the 'skip login screen'. @@ -117,11 +96,8 @@ class Body extends StatelessWidget { Widget build(BuildContext context) { return Container( alignment: Alignment.center, - child: PlatformExtension.isMobile - ? const FlowySvg( - FlowySvgs.flowy_logo_xl, - blendMode: null, - ) + child: UniversalPlatform.isMobile + ? const FlowySvg(FlowySvgs.app_logo_xl, blendMode: null) : const _DesktopSplashBody(), ); } 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 index d79127e04c..af6d4ad770 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart @@ -1,7 +1,6 @@ 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'; @@ -86,7 +85,6 @@ class WorkspaceErrorScreen extends StatelessWidget { const VSpace(50), const LogoutButton(), const VSpace(20), - const ResetWorkspaceButton(), ]); return Center( @@ -157,43 +155,3 @@ class LogoutButton extends StatelessWidget { ); } } - -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 index 2be9ed6484..af5e7367e5 100644 --- 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 @@ -1,10 +1,10 @@ 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:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -33,9 +33,10 @@ class DesktopWorkspaceStartScreen extends StatelessWidget { Widget _renderBody(WorkspaceState state) { final body = state.successOrFailure.fold( (_) => _renderList(state.workspaces), - (error) => FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + (error) => Center( + child: AppFlowyErrorPage( + error: error, + ), ), ); return body; 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 index b3c1f1cd0a..a6124da60b 100644 --- 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 @@ -1,10 +1,10 @@ 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:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -57,7 +57,7 @@ class _MobileWorkspaceStartScreenState children: [ const Spacer(), const FlowySvg( - FlowySvgs.flowy_logo_xl, + FlowySvgs.app_logo_xl, size: Size.square(64), blendMode: null, ), @@ -129,9 +129,10 @@ class _MobileWorkspaceStartScreenState ); }, (error) { - return FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + return Center( + child: AppFlowyErrorPage( + error: error, + ), ); }, ); 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 index a03b3bcf63..a8a9305539 100644 --- 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 @@ -3,9 +3,9 @@ import 'package:appflowy/user/presentation/screens/workspace_start_screen/deskto 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:appflowy_editor/appflowy_editor.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 { @@ -24,7 +24,7 @@ class WorkspaceStartScreen extends StatelessWidget { ..add(const WorkspaceEvent.initial()), child: BlocBuilder( builder: (context, state) { - if (PlatformExtension.isMobile) { + if (UniversalPlatform.isMobile) { return MobileWorkspaceStartScreen( 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 index 9927ee2457..c0b8e7e5ae 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart @@ -1,24 +1,23 @@ import 'package:flutter/material.dart'; class AuthFormContainer extends StatelessWidget { - const AuthFormContainer({super.key, required this.children}); + const AuthFormContainer({ + super.key, + required this.children, + }); final List children; - static const double width = 340; + static const double width = 320; @override Widget build(BuildContext context) { return SizedBox( width: width, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: children, - ), - ), + 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 index caf415b177..14b1c896a9 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart @@ -1,8 +1,7 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:flowy_infra/size.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; +import 'package:appflowy_ui/appflowy_ui.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({ @@ -16,24 +15,19 @@ class FlowyLogoTitle extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return SizedBox( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - SizedBox.fromSize( - size: logoSize, - child: const FlowySvg( - FlowySvgs.flowy_logo_xl, - blendMode: null, - ), - ), - const VSpace(40), - FlowyText.regular( + AFLogo(size: logoSize), + const VSpace(20), + Text( title, - fontSize: FontSizes.s24, - fontFamily: - GoogleFonts.poppins(fontWeight: FontWeight.w500).fontFamily, - color: Theme.of(context).colorScheme.tertiary, + style: theme.textStyle.heading3.enhanced( + color: theme.textColorScheme.primary, + ), ), ], ), diff --git a/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart b/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart index 6777beb0e1..f1bf9262a0 100644 --- a/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart +++ b/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart @@ -1,9 +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 index 34925235cb..61694367bb 100644 --- a/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart +++ b/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart @@ -5,12 +5,17 @@ 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')}'; + final alpha = (a * 255).toInt().toRadixString(16).padLeft(2, '0'); + final red = (r * 255).toInt().toRadixString(16).padLeft(2, '0'); + final green = (g * 255).toInt().toRadixString(16).padLeft(2, '0'); + final blue = (b * 255).toInt().toRadixString(16).padLeft(2, '0'); + + return '0x$alpha$red$green$blue'.toLowerCase(); } /// return a random color static Color random({double opacity = 1.0}) { return Color((math.Random().nextDouble() * 0xFFFFFF).toInt()) - .withOpacity(opacity); + .withValues(alpha: opacity); } } 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 0000000000..603a66d6cf --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/default_extensions.dart @@ -0,0 +1,23 @@ +/// 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', +]; + +bool isNotImageUrl(String url) { + final nonImageSuffixRegex = RegExp( + r'(\.(io|html|php|json|txt|js|css|xml|md|log)(\?.*)?(#.*)?$)|/$', + caseSensitive: false, + ); + return nonImageSuffixRegex.hasMatch(url); +} diff --git a/frontend/appflowy_flutter/lib/util/expand_views.dart b/frontend/appflowy_flutter/lib/util/expand_views.dart new file mode 100644 index 0000000000..115c0d2d29 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/expand_views.dart @@ -0,0 +1,40 @@ +import 'package:flutter/cupertino.dart'; + +class ViewExpanderRegistry { + /// the key is view id + final Map> _viewExpanders = {}; + + bool isViewExpanded(String id) => getExpander(id)?.isViewExpanded ?? false; + + void register(String id, ViewExpander expander) { + final expanders = _viewExpanders[id] ?? {}; + expanders.add(expander); + _viewExpanders[id] = expanders; + } + + void unregister(String id, ViewExpander expander) { + final expanders = _viewExpanders[id] ?? {}; + expanders.remove(expander); + if (expanders.isEmpty) { + _viewExpanders.remove(id); + } else { + _viewExpanders[id] = expanders; + } + } + + ViewExpander? getExpander(String id) { + final expanders = _viewExpanders[id] ?? {}; + return expanders.isEmpty ? null : expanders.first; + } +} + +class ViewExpander { + ViewExpander(this._isExpandedCallback, this._expandCallback); + + final ValueGetter _isExpandedCallback; + final VoidCallback _expandCallback; + + bool get isViewExpanded => _isExpandedCallback.call(); + + void expand() => _expandCallback.call(); +} diff --git a/frontend/appflowy_flutter/lib/util/field_type_extension.dart b/frontend/appflowy_flutter/lib/util/field_type_extension.dart index 9d26149a21..4ef11bf5c6 100644 --- a/frontend/appflowy_flutter/lib/util/field_type_extension.dart +++ b/frontend/appflowy_flutter/lib/util/field_type_extension.dart @@ -4,6 +4,7 @@ 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) { @@ -22,6 +23,9 @@ extension FieldTypeExtension on FieldType { 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(), }; @@ -34,13 +38,22 @@ extension FieldTypeExtension on FieldType { FieldType.Checkbox => FlowySvgs.checkbox_s, FieldType.URL => FlowySvgs.url_s, FieldType.Checklist => FlowySvgs.checklist_s, - FieldType.LastEditedTime => FlowySvgs.last_modified_s, - FieldType.CreatedTime => FlowySvgs.created_at_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), @@ -54,6 +67,9 @@ extension FieldTypeExtension on FieldType { 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(), }; @@ -71,6 +87,80 @@ extension FieldTypeExtension on FieldType { 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/workspace/presentation/settings/widgets/settings_appearance/levenshtein.dart b/frontend/appflowy_flutter/lib/util/levenshtein.dart similarity index 100% rename from frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/levenshtein.dart rename to frontend/appflowy_flutter/lib/util/levenshtein.dart 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 0000000000..b5cfeb64fe --- /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 index 9838eb4a40..b8dd390627 100644 --- a/frontend/appflowy_flutter/lib/util/share_log_files.dart +++ b/frontend/appflowy_flutter/lib/util/share_log_files.dart @@ -1,7 +1,8 @@ import 'dart:io'; +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/presentation/home/toast.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'; @@ -23,9 +24,9 @@ Future shareLogFiles(BuildContext? context) async { if (archiveLogFiles.isEmpty) { if (context != null && context.mounted) { - showSnackBarMessage( - context, - LocaleKeys.noLogFiles.tr(), + showToastNotification( + message: LocaleKeys.noLogFiles.tr(), + type: ToastificationType.error, ); } return; @@ -38,20 +39,40 @@ Future shareLogFiles(BuildContext? context) async { final zip = zipEncoder.encode(archive); if (zip == null) { + if (context != null && context.mounted) { + showToastNotification( + message: LocaleKeys.noLogFiles.tr(), + type: ToastificationType.error, + ); + } return; } // create a zipped appflowy logs file - final path = Platform.isAndroid ? '/storage/emulated/0/Download' : dir.path; - final zipFile = - await File(p.join(path, 'appflowy_logs.zip')).writeAsBytes(zip); + 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); - } else { - await Share.shareXFiles([XFile(zipFile.path)]); + 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( + message: e.toString(), + type: ToastificationType.error, + ); + } } - - // delete the zipped appflowy logs file - await zipFile.delete(); } diff --git a/frontend/appflowy_flutter/lib/util/string_extension.dart b/frontend/appflowy_flutter/lib/util/string_extension.dart index cadd27fe80..84dba35e1a 100644 --- a/frontend/appflowy_flutter/lib/util/string_extension.dart +++ b/frontend/appflowy_flutter/lib/util/string_extension.dart @@ -1,9 +1,12 @@ 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/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' hide Icon; extension StringExtension on String { static const _specialCharacters = r'\/:*?"<>| '; @@ -45,4 +48,47 @@ extension StringExtension on String { // 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; + } +} + +extension CounterExtension on String { + Counters getCounter() { + final wordCount = wordRegex.allMatches(this).length; + final charCount = runes.length; + return Counters(wordCount: wordCount, charCount: charCount); + } } 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 0000000000..c7b56699d3 --- /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/throttle.dart b/frontend/appflowy_flutter/lib/util/throttle.dart index c8c6dcf0ca..0aaa9f2d3a 100644 --- a/frontend/appflowy_flutter/lib/util/throttle.dart +++ b/frontend/appflowy_flutter/lib/util/throttle.dart @@ -16,6 +16,10 @@ class Throttler { }); } + void cancel() { + _timer?.cancel(); + } + 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 0000000000..cdeb9834fc --- /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 0000000000..593ea337c1 --- /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 index 467115368d..5bc64b6785 100644 --- 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 @@ -21,11 +21,11 @@ class ActionNavigationBloc currentAction = currentAction.copyWith(arguments: {}); } - action.arguments?.addAll({ActionArgumentKeys.view: view}); + currentAction.arguments?.addAll({ActionArgumentKeys.view: view}); } } - emit(state.copyWith(action: action, nextActions: nextActions)); + emit(state.copyWith(action: currentAction, nextActions: nextActions)); if (nextActions.isNotEmpty) { final newActions = [...nextActions]; 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 index ee68ea7c0d..52663ea219 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/action_navigation/navigation_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/action_navigation/navigation_action.dart @@ -7,6 +7,7 @@ enum ActionType { class ActionArgumentKeys { static String view = "view"; static String nodePath = "node_path"; + static String blockId = "block_id"; static String rowId = "row_id"; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart b/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart index b3cb390e8e..c3190a8e40 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart @@ -9,11 +9,11 @@ class DefaultAppearanceSettings { static const kDefaultThemeName = "Default"; static const kDefaultTheme = BuiltInTheme.defaultTheme; - static Color getDefaultDocumentCursorColor(BuildContext context) { + static Color getDefaultCursorColor(BuildContext context) { return Theme.of(context).colorScheme.primary; } - static Color getDefaultDocumentSelectionColor(BuildContext context) { - return Theme.of(context).colorScheme.primary.withOpacity(0.2); + static Color getDefaultSelectionColor(BuildContext context) { + return Theme.of(context).colorScheme.primary.withValues(alpha: 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 index 0c45cd8bf4..01f638fe7a 100644 --- 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 @@ -1,196 +1,348 @@ 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/result.pb.dart'; import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'command_palette_bloc.freezed.dart'; -const _searchChannel = 'CommandPalette'; +class Debouncer { + Debouncer({required this.delay}); + + final Duration delay; + Timer? _timer; + + void run(void Function() action) { + _timer?.cancel(); + _timer = Timer(delay, action); + } + + void dispose() { + _timer?.cancel(); + } +} class CommandPaletteBloc extends Bloc { CommandPaletteBloc() : super(CommandPaletteState.initial()) { - _searchListener.start( - onResultsChanged: _onResultsChanged, - onResultsClosed: _onResultsClosed, - ); + on<_SearchChanged>(_onSearchChanged); + on<_PerformSearch>(_onPerformSearch); + on<_NewSearchStream>(_onNewSearchStream); + on<_ResultsChanged>(_onResultsChanged); + on<_TrashChanged>(_onTrashChanged); + on<_WorkspaceChanged>(_onWorkspaceChanged); + on<_ClearSearch>(_onClearSearch); _initTrash(); - _dispatch(); } - Timer? _debounceOnChanged; - final TrashService _trashService = TrashService(); - final SearchListener _searchListener = SearchListener( - channel: _searchChannel, + final Debouncer _searchDebouncer = Debouncer( + delay: const Duration(milliseconds: 300), ); + final TrashService _trashService = TrashService(); final TrashListener _trashListener = TrashListener(); - String? _oldQuery; + String? _activeQuery; String? _workspaceId; @override Future close() { _trashListener.close(); - _searchListener.stop(); + _searchDebouncer.dispose(); + state.searchResponseStream?.dispose(); 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, didClose) { - if (state.query != _oldQuery) { - emit(state.copyWith(results: [])); - } - - final searchResults = _filterDuplicates(results.items); - searchResults.sort((a, b) => b.score.compareTo(a.score)); - - emit( - state.copyWith( - results: searchResults, - isLoading: !didClose, - ), - ); - }, - workspaceChanged: (workspaceId) { - _workspaceId = workspaceId; - emit(state.copyWith(results: [], query: '', isLoading: false)); - }, - ); - }); - } - Future _initTrash() async { _trashListener.start( - trashUpdated: (trashOrFailed) { - final trash = trashOrFailed.fold( - (trash) => trash, - (error) => null, - ); - - add(CommandPaletteEvent.trashChanged(trash: trash)); - }, + trashUpdated: (trashOrFailed) => add( + CommandPaletteEvent.trashChanged( + trash: trashOrFailed.toNullable(), + ), + ), ); final trashOrFailure = await _trashService.readTrash(); - final trashRes = trashOrFailure.fold( - (trash) => trash, - (error) => null, - ); - - add(CommandPaletteEvent.trashChanged(trash: trashRes?.items)); - } - - void _debounceOnSearchChanged(String value) { - _debounceOnChanged?.cancel(); - _debounceOnChanged = Timer( - const Duration(milliseconds: 300), - () => _performSearch(value), + trashOrFailure.fold( + (trash) => add(CommandPaletteEvent.trashChanged(trash: trash.items)), + (error) => debugPrint('Failed to load trash: $error'), ); } - List _filterDuplicates(List results) { - final currentItems = [...state.results]; - final res = [...results]; + FutureOr _onSearchChanged( + _SearchChanged event, + Emitter emit, + ) { + _searchDebouncer.run( + () { + if (!isClosed) { + add(CommandPaletteEvent.performSearch(search: event.search)); + } + }, + ); + } - for (final item in results) { - final duplicateIndex = currentItems.indexWhere((a) => a.id == item.id); - if (duplicateIndex == -1) { - continue; - } + FutureOr _onPerformSearch( + _PerformSearch event, + Emitter emit, + ) async { + if (event.search.isEmpty && event.search != state.query) { + emit( + state.copyWith( + query: null, + searching: false, + serverResponseItems: [], + localResponseItems: [], + combinedResponseItems: {}, + resultSummaries: [], + generatingAIOverview: false, + ), + ); + } else { + emit(state.copyWith(query: event.search, searching: true)); + _activeQuery = event.search; - final duplicate = currentItems[duplicateIndex]; - if (item.score < duplicate.score) { - res.remove(item); - } else { - currentItems.remove(duplicate); - } + unawaited( + SearchBackendService.performSearch( + event.search, + workspaceId: _workspaceId, + ).then( + (result) => result.fold( + (stream) { + if (!isClosed && _activeQuery == event.search) { + add(CommandPaletteEvent.newSearchStream(stream: stream)); + } + }, + (error) { + debugPrint('Search error: $error'); + if (!isClosed) { + add( + CommandPaletteEvent.resultsChanged( + searchId: '', + searching: false, + generatingAIOverview: false, + ), + ); + } + }, + ), + ), + ); + } + } + + FutureOr _onNewSearchStream( + _NewSearchStream event, + Emitter emit, + ) { + state.searchResponseStream?.dispose(); + emit( + state.copyWith( + searchId: event.stream.searchId, + searchResponseStream: event.stream, + ), + ); + + event.stream.listen( + onLocalItems: (items, searchId) => _handleResultsUpdate( + searchId: searchId, + localItems: items, + ), + onServerItems: (items, searchId, searching, generatingAIOverview) => + _handleResultsUpdate( + searchId: searchId, + serverItems: items, + searching: searching, + generatingAIOverview: generatingAIOverview, + ), + onSummaries: (summaries, searchId, searching, generatingAIOverview) => + _handleResultsUpdate( + searchId: searchId, + summaries: summaries, + searching: searching, + generatingAIOverview: generatingAIOverview, + ), + onFinished: (searchId) => _handleResultsUpdate( + searchId: searchId, + searching: false, + ), + ); + } + + void _handleResultsUpdate({ + required String searchId, + List? serverItems, + List? localItems, + List? summaries, + bool searching = true, + bool generatingAIOverview = false, + }) { + if (_isActiveSearch(searchId)) { + add( + CommandPaletteEvent.resultsChanged( + searchId: searchId, + serverItems: serverItems, + localItems: localItems, + summaries: summaries, + searching: searching, + generatingAIOverview: generatingAIOverview, + ), + ); + } + } + + FutureOr _onResultsChanged( + _ResultsChanged event, + Emitter emit, + ) async { + if (state.searchId != event.searchId) return; + + final combinedItems = {}; + for (final item in event.serverItems ?? state.serverResponseItems) { + combinedItems[item.id] = SearchResultItem( + id: item.id, + icon: item.icon, + displayName: item.displayName, + content: item.content, + workspaceId: item.workspaceId, + ); } - return res..addAll(currentItems); + for (final item in event.localItems ?? state.localResponseItems) { + combinedItems.putIfAbsent( + item.id, + () => SearchResultItem( + id: item.id, + icon: item.icon, + displayName: item.displayName, + content: '', + workspaceId: item.workspaceId, + ), + ); + } + + emit( + state.copyWith( + serverResponseItems: event.serverItems ?? state.serverResponseItems, + localResponseItems: event.localItems ?? state.localResponseItems, + resultSummaries: event.summaries ?? state.resultSummaries, + combinedResponseItems: combinedItems, + searching: event.searching, + generatingAIOverview: event.generatingAIOverview, + ), + ); } - void _performSearch(String value) => - add(CommandPaletteEvent.performSearch(search: value)); + FutureOr _onTrashChanged( + _TrashChanged event, + Emitter emit, + ) async { + if (event.trash != null) { + emit(state.copyWith(trash: event.trash!)); + } else { + final trashOrFailure = await _trashService.readTrash(); + trashOrFailure.fold((trash) { + emit(state.copyWith(trash: trash.items)); + }, (error) { + // Optionally handle error; otherwise, we simply do nothing. + }); + } + } - void _onResultsChanged(RepeatedSearchResultPB results) => - add(CommandPaletteEvent.resultsChanged(results: results)); + FutureOr _onWorkspaceChanged( + _WorkspaceChanged event, + Emitter emit, + ) { + _workspaceId = event.workspaceId; + emit( + state.copyWith( + query: '', + serverResponseItems: [], + localResponseItems: [], + combinedResponseItems: {}, + resultSummaries: [], + searching: false, + generatingAIOverview: false, + ), + ); + } - void _onResultsClosed(RepeatedSearchResultPB results) => - add(CommandPaletteEvent.resultsChanged(results: results, didClose: true)); + FutureOr _onClearSearch( + _ClearSearch event, + Emitter emit, + ) { + emit(CommandPaletteState.initial().copyWith(trash: state.trash)); + } + + bool _isActiveSearch(String searchId) => + !isClosed && state.searchId == searchId; } @freezed class CommandPaletteEvent with _$CommandPaletteEvent { const factory CommandPaletteEvent.searchChanged({required String search}) = _SearchChanged; - const factory CommandPaletteEvent.performSearch({required String search}) = _PerformSearch; - + const factory CommandPaletteEvent.newSearchStream({ + required SearchResponseStream stream, + }) = _NewSearchStream; const factory CommandPaletteEvent.resultsChanged({ - required RepeatedSearchResultPB results, - @Default(false) bool didClose, + required String searchId, + required bool searching, + required bool generatingAIOverview, + List? serverItems, + List? localItems, + List? summaries, }) = _ResultsChanged; const factory CommandPaletteEvent.trashChanged({ @Default(null) List? trash, }) = _TrashChanged; - const factory CommandPaletteEvent.workspaceChanged({ @Default(null) String? workspaceId, }) = _WorkspaceChanged; + const factory CommandPaletteEvent.clearSearch() = _ClearSearch; +} + +class SearchResultItem { + const SearchResultItem({ + required this.id, + required this.icon, + required this.content, + required this.displayName, + this.workspaceId, + }); + + final String id; + final String content; + final ResultIconPB icon; + final String displayName; + final String? workspaceId; } @freezed class CommandPaletteState with _$CommandPaletteState { const CommandPaletteState._(); - const factory CommandPaletteState({ @Default(null) String? query, - required List results, - required bool isLoading, + @Default([]) List serverResponseItems, + @Default([]) List localResponseItems, + @Default({}) Map combinedResponseItems, + @Default([]) List resultSummaries, + @Default(null) SearchResponseStream? searchResponseStream, + required bool searching, + required bool generatingAIOverview, @Default([]) List trash, + @Default(null) String? searchId, }) = _CommandPaletteState; - factory CommandPaletteState.initial() => - const CommandPaletteState(results: [], isLoading: false); + factory CommandPaletteState.initial() => const CommandPaletteState( + searching: false, + generatingAIOverview: 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 deleted file mode 100644 index e68c18bd3e..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_listener.dart +++ /dev/null @@ -1,81 +0,0 @@ -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.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-search/result.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(RepeatedSearchResultPB)? onResultsChanged, - void Function(RepeatedSearchResultPB)? 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 = - RepeatedSearchResultPB.fromBuffer(payload), - (err) => Log.error(err), - ); - break; - case SearchNotification.DidCloseResults: - result.fold( - (payload) => _updateDidCloseNotifier?.value = - RepeatedSearchResultPB.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 index aedc6fc03e..6b6ea6d5c0 100644 --- 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 @@ -1,31 +1,54 @@ +import 'package:appflowy/generated/flowy_svgs.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_backend/protobuf/flowy-search/result.pb.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; 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 { +extension GetIcon on ResultIconPB { Widget? getIcon() { - if (icon.ty == ResultIconTypePB.Emoji) { - return icon.value.isNotEmpty - ? Text( - icon.value, - style: const TextStyle(fontSize: 18.0), - ) + final iconValue = value, iconType = ty; + if (iconType == ResultIconTypePB.Emoji) { + return iconValue.isNotEmpty + ? FlowyText.emoji(iconValue, fontSize: 18) : null; - } else if (icon.ty == ResultIconTypePB.Icon) { - return FlowySvg(icon.getViewSvg(), size: const Size.square(20)); + } else if (ty == ResultIconTypePB.Icon) { + if (_resultIconValueTypes.contains(iconValue)) { + return FlowySvg(getViewSvg(), size: const Size.square(18)); + } + return RawEmojiIconWidget( + emoji: EmojiIconData(iconType.toFlowyIconType(), value), + emojiSize: 18, + ); } - return null; } } +extension ResultIconTypePBToFlowyIconType on ResultIconTypePB { + FlowyIconType toFlowyIconType() { + switch (this) { + case ResultIconTypePB.Emoji: + return FlowyIconType.emoji; + case ResultIconTypePB.Icon: + return FlowyIconType.icon; + case ResultIconTypePB.Url: + return FlowyIconType.custom; + default: + return FlowyIconType.custom; + } + } +} + extension _ToViewIcon on ResultIconPB { FlowySvgData getViewSvg() => switch (value) { - "0" => FlowySvgs.document_s, - "1" => FlowySvgs.grid_s, - "2" => FlowySvgs.board_s, - "3" => FlowySvgs.date_s, - _ => FlowySvgs.document_s, + "0" => FlowySvgs.icon_document_s, + "1" => FlowySvgs.icon_grid_s, + "2" => FlowySvgs.icon_board_s, + "3" => FlowySvgs.icon_calendar_s, + "4" => FlowySvgs.chat_ai_page_s, + _ => FlowySvgs.icon_document_s, }; } + +const _resultIconValueTypes = {'0', '1', '2', '3', '4'}; diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart new file mode 100644 index 0000000000..e5953ae61b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'search_result_list_bloc.freezed.dart'; + +class SearchResultListBloc + extends Bloc { + SearchResultListBloc() : super(SearchResultListState.initial()) { + // Register event handlers + on<_OnHoverSummary>(_onHoverSummary); + on<_OnHoverResult>(_onHoverResult); + on<_OpenPage>(_onOpenPage); + } + + FutureOr _onHoverSummary( + _OnHoverSummary event, + Emitter emit, + ) { + emit( + state.copyWith( + hoveredSummary: event.summary, + hoveredResult: null, + userHovered: event.userHovered, + openPageId: null, + ), + ); + } + + FutureOr _onHoverResult( + _OnHoverResult event, + Emitter emit, + ) { + emit( + state.copyWith( + hoveredSummary: null, + hoveredResult: event.item, + userHovered: event.userHovered, + openPageId: null, + ), + ); + } + + FutureOr _onOpenPage( + _OpenPage event, + Emitter emit, + ) { + emit(state.copyWith(openPageId: event.pageId)); + } +} + +@freezed +class SearchResultListEvent with _$SearchResultListEvent { + const factory SearchResultListEvent.onHoverSummary({ + required SearchSummaryPB summary, + required bool userHovered, + }) = _OnHoverSummary; + const factory SearchResultListEvent.onHoverResult({ + required SearchResultItem item, + required bool userHovered, + }) = _OnHoverResult; + + const factory SearchResultListEvent.openPage({ + required String pageId, + }) = _OpenPage; +} + +@freezed +class SearchResultListState with _$SearchResultListState { + const SearchResultListState._(); + const factory SearchResultListState({ + @Default(null) SearchSummaryPB? hoveredSummary, + @Default(null) SearchResultItem? hoveredResult, + @Default(null) String? openPageId, + @Default(false) bool userHovered, + }) = _SearchResultListState; + + factory SearchResultListState.initial() => const SearchResultListState(); +} 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 index 53a229ae66..89e5b604f8 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart @@ -1,22 +1,131 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/query.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/search_filter.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:nanoid/nanoid.dart'; +import 'package:fixnum/fixnum.dart'; class SearchBackendService { - static Future> performSearch( + static Future> performSearch( String keyword, { String? workspaceId, - String? channel, }) async { + final searchId = nanoid(6); + final stream = SearchResponseStream(searchId: searchId); + final filter = SearchFilterPB(workspaceId: workspaceId); final request = SearchQueryPB( search: keyword, filter: filter, - channel: channel, + searchId: searchId, + streamPort: Int64(stream.nativePort), ); - return SearchEventSearch(request).send(); + unawaited(SearchEventSearch(request).send()); + return FlowyResult.success(stream); + } +} + +class SearchResponseStream { + SearchResponseStream({required this.searchId}) { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + (Uint8List data) => _onResultsChanged(data), + ); + } + + final String searchId; + final RawReceivePort _port = RawReceivePort(); + final StreamController _controller = StreamController.broadcast(); + late StreamSubscription _subscription; + void Function( + List items, + String searchId, + bool searching, + bool generatingAIOverview, + )? _onServerItems; + void Function( + List summaries, + String searchId, + bool searching, + bool generatingAIOverview, + )? _onSummaries; + + void Function( + List items, + String searchId, + )? _onLocalItems; + + void Function(String searchId)? _onFinished; + int get nativePort => _port.sendPort.nativePort; + + Future dispose() async { + await _subscription.cancel(); + _port.close(); + } + + void _onResultsChanged(Uint8List data) { + final searchState = SearchStatePB.fromBuffer(data); + + if (searchState.hasResponse()) { + if (searchState.response.hasSearchResult()) { + _onServerItems?.call( + searchState.response.searchResult.items, + searchId, + searchState.response.searching, + searchState.response.generatingAiSummary, + ); + } + if (searchState.response.hasSearchSummary()) { + _onSummaries?.call( + searchState.response.searchSummary.items, + searchId, + searchState.response.searching, + searchState.response.generatingAiSummary, + ); + } + + if (searchState.response.hasLocalSearchResult()) { + _onLocalItems?.call( + searchState.response.localSearchResult.items, + searchId, + ); + } + } else { + _onFinished?.call(searchId); + } + } + + void listen({ + required void Function( + List items, + String searchId, + bool isLoading, + bool generatingAIOverview, + )? onServerItems, + required void Function( + List summaries, + String searchId, + bool isLoading, + bool generatingAIOverview, + )? onSummaries, + required void Function( + List items, + String searchId, + )? onLocalItems, + required void Function(String searchId)? onFinished, + }) { + _onServerItems = onServerItems; + _onSummaries = onSummaries; + _onLocalItems = onLocalItems; + _onFinished = onFinished; } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart b/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart index 74b8316a4b..a17b5741bc 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart @@ -3,20 +3,13 @@ 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/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.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'; -const List _customParsers = [ - MathEquationNodeParser(), - CalloutNodeParser(), - ToggleListNodeParser(), - CustomImageNodeParser(), -]; - enum DocumentExportType { json, markdown, @@ -32,12 +25,13 @@ class DocumentExporter { final ViewPB view; Future> export( - DocumentExportType type, - ) async { + DocumentExportType type, { + String? path, + }) async { final documentService = DocumentService(); final result = await documentService.openDocument(documentId: view.id); return result.fold( - (r) { + (r) async { final document = r.toDocument(); if (document == null) { return FlowyResult.failure( @@ -50,11 +44,14 @@ class DocumentExporter { case DocumentExportType.json: return FlowyResult.success(jsonEncode(document)); case DocumentExportType.markdown: - final markdown = documentToMarkdown( - document, - customParsers: _customParsers, - ); - return FlowyResult.success(markdown); + if (path != null) { + await customDocumentToMarkdown(document, path: path); + return FlowyResult.success(''); + } else { + return FlowyResult.success( + await customDocumentToMarkdown(document), + ); + } case DocumentExportType.text: throw UnimplementedError(); case DocumentExportType.html: diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart index 7b9e86e16b..546b9ba13d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart @@ -1,4 +1,5 @@ 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'; @@ -17,6 +18,7 @@ class FavoriteBloc extends Bloc { final _service = FavoriteService(); final _listener = FavoriteListener(); + bool isReordering = false; @override Future close() async { @@ -32,36 +34,67 @@ class FavoriteBloc extends Bloc { _listener.start( favoritesUpdated: _onFavoritesUpdated, ); - final result = await _service.readFavorites(); - emit( - result.fold( - (view) => state.copyWith( - views: view.items, - ), - (error) => state.copyWith( - views: [], - ), - ), - ); + add(const FavoriteEvent.fetchFavorites()); }, fetchFavorites: () async { final result = await _service.readFavorites(); emit( result.fold( - (view) => state.copyWith( - views: view.items, - ), + (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 { - await _service.toggleFavorite( - view.id, - !view.isFavorite, - ); + 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); + }, + pin: (view) async { + await _service.pinFavorite(view); + add(const FavoriteEvent.fetchFavorites()); + }, + unpin: (view) async { + await _service.unpinFavorite(view); + add(const FavoriteEvent.fetchFavorites()); + }, + reorder: (oldIndex, newIndex) async { + /// TODO: this is a workaround to reorder the favorite views + isReordering = true; + final pinnedViews = state.pinnedViews.toList(); + if (oldIndex < newIndex) newIndex -= 1; + final target = pinnedViews.removeAt(oldIndex); + pinnedViews.insert(newIndex, target); + emit(state.copyWith(pinnedViews: pinnedViews)); + for (final view in pinnedViews) { + await _service.toggleFavorite(view.item.id); + await _service.toggleFavorite(view.item.id); + } + if (!isClosed) { + add(const FavoriteEvent.fetchFavorites()); + } + isReordering = false; }, ); }, @@ -72,27 +105,39 @@ class FavoriteBloc extends Bloc { FlowyResult favoriteOrFailed, bool didFavorite, ) { - favoriteOrFailed.fold( - (favorite) => add(const FetchFavorites()), - (error) => Log.error(error), - ); + if (!isReordering) { + 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; + + const factory FavoriteEvent.reorder(int oldIndex, int newIndex) = + ReorderFavorite; } @freezed class FavoriteState with _$FavoriteState { const factory FavoriteState({ - required List views, + @Default([]) List views, + @Default([]) List pinnedViews, + @Default([]) List unpinnedViews, + @Default(true) bool isLoading, }) = _FavoriteState; - factory FavoriteState.initial() => const FavoriteState( - views: [], - ); + factory FavoriteState.initial() => const FavoriteState(); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart index d9343e2ee3..7f0f844dda 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart @@ -1,18 +1,62 @@ +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() { - return FolderEventReadFavorites().send(); + 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 { + Future> toggleFavorite(String viewId) 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/home/home_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart index 2df1c95c1f..531e797ff5 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart @@ -1,20 +1,22 @@ import 'package:appflowy/user/application/user_listener.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-folder/workspace.pb.dart' - show WorkspaceSettingPB; + show WorkspaceLatestPB; 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 = UserWorkspaceListener(), + HomeBloc(WorkspaceLatestPB workspaceSetting) + : _workspaceListener = FolderListener(), super(HomeState.initial(workspaceSetting)) { _dispatch(workspaceSetting); } - final UserWorkspaceListener _workspaceListener; + final FolderListener _workspaceListener; @override Future close() async { @@ -22,7 +24,7 @@ class HomeBloc extends Bloc { return super.close(); } - void _dispatch(WorkspaceSettingPB workspaceSetting) { + void _dispatch(WorkspaceLatestPB workspaceSetting) { on( (event, emit) async { await event.map( @@ -34,10 +36,9 @@ class HomeBloc extends Bloc { }); _workspaceListener.start( - onSettingUpdated: (result) { + onLatestUpdated: (result) { result.fold( - (setting) => - add(HomeEvent.didReceiveWorkspaceSetting(setting)), + (latest) => add(HomeEvent.didReceiveWorkspaceSetting(latest)), (r) => Log.error(r), ); }, @@ -47,10 +48,17 @@ class HomeBloc extends Bloc { emit(state.copyWith(isLoading: e.isLoading)); }, didReceiveWorkspaceSetting: (_DidReceiveWorkspaceSetting value) { + // the latest view is shared across all the members of the workspace. + final latestView = value.setting.hasLatestView() ? value.setting.latestView : state.latestView; + if (latestView != null && latestView.isSpace) { + // If the latest view is a space, we don't need to open it. + return; + } + emit( state.copyWith( workspaceSetting: value.setting, @@ -69,7 +77,7 @@ class HomeEvent with _$HomeEvent { const factory HomeEvent.initial() = _Initial; const factory HomeEvent.showLoading(bool isLoading) = _ShowLoading; const factory HomeEvent.didReceiveWorkspaceSetting( - WorkspaceSettingPB setting, + WorkspaceLatestPB setting, ) = _DidReceiveWorkspaceSetting; } @@ -77,11 +85,11 @@ class HomeEvent with _$HomeEvent { class HomeState with _$HomeState { const factory HomeState({ required bool isLoading, - required WorkspaceSettingPB workspaceSetting, + required WorkspaceLatestPB workspaceSetting, ViewPB? latestView, }) = _HomeState; - factory HomeState.initial(WorkspaceSettingPB workspaceSetting) => HomeState( + factory HomeState.initial(WorkspaceLatestPB 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 index 171bf634a7..cde67045b9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart @@ -2,7 +2,7 @@ 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; + show WorkspaceLatestPB; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/time/duration.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -12,10 +12,10 @@ part 'home_setting_bloc.freezed.dart'; class HomeSettingBloc extends Bloc { HomeSettingBloc( - WorkspaceSettingPB workspaceSetting, + WorkspaceLatestPB workspaceSetting, AppearanceSettingsCubit appearanceSettingsCubit, double screenWidthPx, - ) : _listener = UserWorkspaceListener(), + ) : _listener = FolderListener(), _appearanceSettingsCubit = appearanceSettingsCubit, super( HomeSettingState.initial( @@ -27,7 +27,7 @@ class HomeSettingBloc extends Bloc { _dispatch(); } - final UserWorkspaceListener _listener; + final FolderListener _listener; final AppearanceSettingsCubit _appearanceSettingsCubit; @override @@ -86,7 +86,7 @@ class HomeSettingBloc extends Bloc { }, editPanelResized: (_EditPanelResized e) { final newPosition = - (e.offset + state.resizeStart).clamp(-50, 200).toDouble(); + (state.resizeStart + e.offset).clamp(0, 200).toDouble(); if (state.resizeOffset != newPosition) { emit(state.copyWith(resizeOffset: newPosition)); } @@ -124,7 +124,7 @@ class HomeSettingEvent with _$HomeSettingEvent { _ShowEditPanel; const factory HomeSettingEvent.dismissEditPanel() = _DismissEditPanel; const factory HomeSettingEvent.didReceiveWorkspaceSetting( - WorkspaceSettingPB setting, + WorkspaceLatestPB setting, ) = _DidReceiveWorkspaceSetting; const factory HomeSettingEvent.collapseMenu() = _CollapseMenu; const factory HomeSettingEvent.checkScreenSize(double screenWidthPx) = @@ -139,7 +139,7 @@ class HomeSettingEvent with _$HomeSettingEvent { class HomeSettingState with _$HomeSettingState { const factory HomeSettingState({ required EditPanelContext? panelContext, - required WorkspaceSettingPB workspaceSetting, + required WorkspaceLatestPB workspaceSetting, required bool unauthorized, required bool isMenuCollapsed, required bool keepMenuCollapsed, @@ -150,7 +150,7 @@ class HomeSettingState with _$HomeSettingState { }) = _HomeSettingState; factory HomeSettingState.initial( - WorkspaceSettingPB workspaceSetting, + WorkspaceLatestPB workspaceSetting, AppearanceSettingsState appearanceSettingsState, double screenWidthPx, ) { 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 index 249144096c..a9e4d28a3a 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_user_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_user_bloc.dart @@ -13,7 +13,7 @@ part 'menu_user_bloc.freezed.dart'; class MenuUserBloc extends Bloc { MenuUserBloc(this.userProfile) : _userListener = UserListener(userProfile: userProfile), - _userWorkspaceListener = UserWorkspaceListener(), + _userWorkspaceListener = FolderListener(), _userService = UserBackendService(userId: userProfile.id), super(MenuUserState.initial(userProfile)) { _dispatch(); @@ -21,7 +21,7 @@ class MenuUserBloc extends Bloc { final UserBackendService _userService; final UserListener _userListener; - final UserWorkspaceListener _userWorkspaceListener; + final FolderListener _userWorkspaceListener; final UserProfilePB userProfile; @override 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 index e38f4cf0da..d6a6a73578 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart @@ -54,9 +54,11 @@ class SidebarSectionsBloc _initial(userProfile, workspaceId); final sectionViews = await _getSectionViews(); if (sectionViews != null) { + final containsSpace = _containsSpace(sectionViews); emit( state.copyWith( section: sectionViews, + containsSpace: containsSpace, ), ); } @@ -65,18 +67,19 @@ class SidebarSectionsBloc _reset(userProfile, workspaceId); final sectionViews = await _getSectionViews(); if (sectionViews != null) { + final containsSpace = _containsSpace(sectionViews); emit( state.copyWith( section: sectionViews, + containsSpace: containsSpace, ), ); } }, - createRootViewInSection: (name, section, desc, index) async { + createRootViewInSection: (name, section, index) async { final result = await _workspaceService.createView( name: name, viewSection: section, - desc: desc, index: index, ); result.fold( @@ -102,6 +105,8 @@ class SidebarSectionsBloc case ViewSectionPB.Public: emit( state.copyWith( + containsSpace: state.containsSpace || + sectionViews.views.any((view) => view.isSpace), section: state.section.copyWith( publicViews: sectionViews.views, ), @@ -110,6 +115,8 @@ class SidebarSectionsBloc case ViewSectionPB.Private: emit( state.copyWith( + containsSpace: state.containsSpace || + sectionViews.views.any((view) => view.isSpace), section: state.section.copyWith( privateViews: sectionViews.views, ), @@ -160,9 +167,11 @@ class SidebarSectionsBloc _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 @@ -229,8 +238,16 @@ class SidebarSectionsBloc } } + 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); + _workspaceService = WorkspaceService( + workspaceId: workspaceId, + userId: userProfile.id, + ); _listener = WorkspaceSectionsListener( user: userProfile, @@ -268,7 +285,6 @@ class SidebarSectionsEvent with _$SidebarSectionsEvent { const factory SidebarSectionsEvent.createRootViewInSection({ required String name, required ViewSectionPB viewSection, - String? desc, int? index, }) = _CreateRootViewInSection; const factory SidebarSectionsEvent.moveRootView({ @@ -292,6 +308,7 @@ class SidebarSectionsState with _$SidebarSectionsState { required SidebarSection section, @Default(null) ViewPB? lastCreatedRootView, FlowyResult? createRootViewResult, + @Default(true) bool containsSpace, }) = _SidebarSectionsState; factory SidebarSectionsState.initial() => const SidebarSectionsState( diff --git a/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart b/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart index 5418eb2b1c..3f9657c5cf 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart @@ -1,8 +1,11 @@ import 'package:flutter/foundation.dart'; - import 'package:local_notifier/local_notifier.dart'; -const _appName = "AppFlowy"; +/// The app name used in the local notification. +/// +/// DO NOT Use i18n here, because the i18n plugin is not ready +/// before the local notification is initialized. +const _localNotifierAppName = 'AppFlowy'; /// Manages Local Notifications /// @@ -13,7 +16,11 @@ const _appName = "AppFlowy"; /// class NotificationService { static Future initialize() async { - await localNotifier.setup(appName: _appName); + await localNotifier.setup( + appName: _localNotifierAppName, + // Don't create a shortcut on Windows, because the setup.exe will create a shortcut + shortcutPolicy: ShortcutPolicy.requireNoCreate, + ); } } 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 index 8407ed841f..a5381ce17f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart @@ -1,13 +1,15 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; - +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. /// @@ -23,21 +25,23 @@ class CachedRecentService { Completer _completer = Completer(); - ValueNotifier> notifier = ValueNotifier(const []); + ValueNotifier> notifier = ValueNotifier(const []); - List get _recentViews => notifier.value; - set _recentViews(List value) => notifier.value = value; + List get _recentViews => notifier.value; + set _recentViews(List value) => notifier.value = value; final _listener = RecentViewsListener(); - Future> recentViews() async { - if (_isInitialized) return _recentViews; + Future> recentViews() async { + if (_isInitialized || _completer.isCompleted) return _recentViews; _isInitialized = true; _listener.start(recentViewsUpdated: _recentViewsUpdated); - final result = await _readRecentViews(); - _recentViews = result.toNullable()?.items ?? const []; + _recentViews = await _readRecentViews().fold( + (s) => s.items.unique((e) => e.item.id), + (_) => [], + ); _completer.complete(); return _recentViews; @@ -48,13 +52,40 @@ class CachedRecentService { 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: viewIds, addInRecent: addInRecent), + UpdateRecentViewPayloadPB( + viewIds: addInRecent ? viewIds : duplicatedViewIds, + addInRecent: addInRecent, + ), ).send(); } - Future> _readRecentViews() => - FolderEventReadRecentViews().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; @@ -72,11 +103,12 @@ class CachedRecentService { void _recentViewsUpdated( FlowyResult result, - ) { + ) async { final viewIds = result.toNullable(); if (viewIds != null) { - _readRecentViews().then( - (views) => _recentViews = views.toNullable()?.items ?? const [], + _recentViews = await _readRecentViews().fold( + (s) => s.items.unique((e) => e.item.id), + (_) => [], ); } } 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 index b43edfa2b1..d67c24e854 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/recent/recent_views_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/recent/recent_views_bloc.dart @@ -35,7 +35,12 @@ class RecentViewsBloc extends Bloc { await _service.updateRecentViews(e.viewIds, false); }, fetchRecentViews: (e) async { - emit(state.copyWith(views: await _service.recentViews())); + emit( + state.copyWith( + isLoading: false, + views: await _service.recentViews(), + ), + ); }, resetRecentViews: (e) async { await _service.reset(); @@ -63,8 +68,10 @@ class RecentViewsEvent with _$RecentViewsEvent { @freezed class RecentViewsState with _$RecentViewsState { - const factory RecentViewsState({required List views}) = - _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/local_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart new file mode 100644 index 0000000000..a90f319a94 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +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_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'local_llm_listener.dart'; + +part 'local_ai_bloc.freezed.dart'; + +class LocalAiPluginBloc extends Bloc { + LocalAiPluginBloc() : super(const LoadingLocalAiPluginState()) { + on(_handleEvent); + _startListening(); + _getLocalAiState(); + } + + final listener = LocalAIStateListener(); + + @override + Future close() async { + await listener.stop(); + return super.close(); + } + + Future _handleEvent( + LocalAiPluginEvent event, + Emitter emit, + ) async { + await event.when( + didReceiveAiState: (aiState) { + emit( + LocalAiPluginState.ready( + isEnabled: aiState.enabled, + version: aiState.pluginVersion, + runningState: aiState.state, + lackOfResource: + aiState.hasLackOfResource() ? aiState.lackOfResource : null, + ), + ); + }, + didReceiveLackOfResources: (resources) { + state.maybeMap( + ready: (readyState) { + emit(readyState.copyWith(lackOfResource: resources)); + }, + orElse: () {}, + ); + }, + toggle: () async { + emit(LocalAiPluginState.loading()); + await AIEventToggleLocalAI().send().fold( + (aiState) { + add(LocalAiPluginEvent.didReceiveAiState(aiState)); + }, + Log.error, + ); + }, + restart: () async { + emit(LocalAiPluginState.loading()); + await AIEventRestartLocalAI().send(); + }, + ); + } + + void _startListening() { + listener.start( + stateCallback: (pluginState) { + add(LocalAiPluginEvent.didReceiveAiState(pluginState)); + }, + resourceCallback: (data) { + add(LocalAiPluginEvent.didReceiveLackOfResources(data)); + }, + ); + } + + void _getLocalAiState() { + AIEventGetLocalAIState().send().fold( + (aiState) { + add(LocalAiPluginEvent.didReceiveAiState(aiState)); + }, + Log.error, + ); + } +} + +@freezed +class LocalAiPluginEvent with _$LocalAiPluginEvent { + const factory LocalAiPluginEvent.didReceiveAiState(LocalAIPB aiState) = + _DidReceiveAiState; + const factory LocalAiPluginEvent.didReceiveLackOfResources( + LackOfAIResourcePB resources, + ) = _DidReceiveLackOfResources; + const factory LocalAiPluginEvent.toggle() = _Toggle; + const factory LocalAiPluginEvent.restart() = _Restart; +} + +@freezed +class LocalAiPluginState with _$LocalAiPluginState { + const LocalAiPluginState._(); + + const factory LocalAiPluginState.ready({ + required bool isEnabled, + required String version, + required RunningStatePB runningState, + required LackOfAIResourcePB? lackOfResource, + }) = ReadyLocalAiPluginState; + + const factory LocalAiPluginState.loading() = LoadingLocalAiPluginState; + + bool get isEnabled { + return maybeWhen( + ready: (isEnabled, _, __, ___) => isEnabled, + orElse: () => false, + ); + } + + bool get showIndicator { + return maybeWhen( + ready: (isEnabled, _, runningState, lackOfResource) => + runningState != RunningStatePB.Running || lackOfResource != null, + orElse: () => false, + ); + } +} 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 0000000000..3bb26a182b --- /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(); + } + + void _onPaymentSuccessful() { + 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 0000000000..99c90faeb5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_llm_listener.dart @@ -0,0 +1,59 @@ +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(LocalAIPB state); +typedef PluginResourceCallback = void Function(LackOfAIResourcePB data); + +class LocalAIStateListener { + LocalAIStateListener() { + _parser = + ChatNotificationParser(id: "appflowy_ai_plugin", callback: _callback); + _subscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + StreamSubscription? _subscription; + ChatNotificationParser? _parser; + + PluginStateCallback? stateCallback; + PluginResourceCallback? resourceCallback; + + void start({ + PluginStateCallback? stateCallback, + PluginResourceCallback? resourceCallback, + }) { + this.stateCallback = stateCallback; + this.resourceCallback = resourceCallback; + } + + void _callback( + ChatNotification ty, + FlowyResult result, + ) { + result.map((r) { + switch (ty) { + case ChatNotification.UpdateLocalAIState: + stateCallback?.call(LocalAIPB.fromBuffer(r)); + break; + case ChatNotification.LocalAIResourceUpdated: + resourceCallback?.call(LackOfAIResourcePB.fromBuffer(r)); + break; + default: + break; + } + }); + } + + Future stop() async { + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart new file mode 100644 index 0000000000..f5c4209028 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart @@ -0,0 +1,220 @@ +import 'dart:async'; + +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_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:equatable/equatable.dart'; + +part 'ollama_setting_bloc.freezed.dart'; + +class OllamaSettingBloc extends Bloc { + OllamaSettingBloc() : super(const OllamaSettingState()) { + on(_handleEvent); + } + + Future _handleEvent( + OllamaSettingEvent event, + Emitter emit, + ) async { + event.when( + started: () { + AIEventGetLocalAISetting().send().fold( + (setting) { + if (!isClosed) { + add(OllamaSettingEvent.didLoadSetting(setting)); + } + }, + Log.error, + ); + }, + didLoadSetting: (setting) => _updateSetting(setting, emit), + updateSetting: (setting) => _updateSetting(setting, emit), + onEdit: (content, settingType) { + final updatedSubmittedItems = state.submittedItems + .map( + (item) => item.settingType == settingType + ? SubmittedItem( + content: content, + settingType: item.settingType, + ) + : item, + ) + .toList(); + + // Convert both lists to maps: {settingType: content} + final updatedMap = { + for (final item in updatedSubmittedItems) + item.settingType: item.content, + }; + + final inputMap = { + for (final item in state.inputItems) item.settingType: item.content, + }; + + // Compare maps instead of lists + final isEdited = !const MapEquality() + .equals(updatedMap, inputMap); + + emit( + state.copyWith( + submittedItems: updatedSubmittedItems, + isEdited: isEdited, + ), + ); + }, + submit: () { + final setting = LocalAISettingPB(); + final settingUpdaters = { + SettingType.serverUrl: (value) => setting.serverUrl = value, + SettingType.chatModel: (value) => setting.chatModelName = value, + SettingType.embeddingModel: (value) => + setting.embeddingModelName = value, + }; + + for (final item in state.submittedItems) { + settingUpdaters[item.settingType]?.call(item.content); + } + add(OllamaSettingEvent.updateSetting(setting)); + AIEventUpdateLocalAISetting(setting).send().fold( + (_) => Log.info('AI setting updated successfully'), + (err) => Log.error("update ai setting failed: $err"), + ); + }, + ); + } + + void _updateSetting( + LocalAISettingPB setting, + Emitter emit, + ) { + emit( + state.copyWith( + setting: setting, + inputItems: _createInputItems(setting), + submittedItems: _createSubmittedItems(setting), + isEdited: false, // Reset to false when the setting is loaded/updated. + ), + ); + } + + List _createInputItems(LocalAISettingPB setting) => [ + SettingItem( + content: setting.serverUrl, + hintText: 'http://localhost:11434', + settingType: SettingType.serverUrl, + ), + SettingItem( + content: setting.chatModelName, + hintText: 'llama3.1', + settingType: SettingType.chatModel, + ), + SettingItem( + content: setting.embeddingModelName, + hintText: 'nomic-embed-text', + settingType: SettingType.embeddingModel, + ), + ]; + + List _createSubmittedItems(LocalAISettingPB setting) => [ + SubmittedItem( + content: setting.serverUrl, + settingType: SettingType.serverUrl, + ), + SubmittedItem( + content: setting.chatModelName, + settingType: SettingType.chatModel, + ), + SubmittedItem( + content: setting.embeddingModelName, + settingType: SettingType.embeddingModel, + ), + ]; +} + +// Create an enum for setting type. +enum SettingType { + serverUrl, + chatModel, + embeddingModel; // semicolon needed after the enum values + + String get title { + switch (this) { + case SettingType.serverUrl: + return 'Ollama server url'; + case SettingType.chatModel: + return 'Chat model name'; + case SettingType.embeddingModel: + return 'Embedding model name'; + } + } +} + +class SettingItem extends Equatable { + const SettingItem({ + required this.content, + required this.hintText, + required this.settingType, + }); + final String content; + final String hintText; + final SettingType settingType; + @override + List get props => [content, settingType]; +} + +class SubmittedItem extends Equatable { + const SubmittedItem({ + required this.content, + required this.settingType, + }); + final String content; + final SettingType settingType; + + @override + List get props => [content, settingType]; +} + +@freezed +class OllamaSettingEvent with _$OllamaSettingEvent { + const factory OllamaSettingEvent.started() = _Started; + const factory OllamaSettingEvent.didLoadSetting(LocalAISettingPB setting) = + _DidLoadSetting; + const factory OllamaSettingEvent.updateSetting(LocalAISettingPB setting) = + _UpdateSetting; + const factory OllamaSettingEvent.onEdit( + String content, + SettingType settingType, + ) = _OnEdit; + const factory OllamaSettingEvent.submit() = _OnSubmit; +} + +@freezed +class OllamaSettingState with _$OllamaSettingState { + const factory OllamaSettingState({ + LocalAISettingPB? setting, + @Default([ + SettingItem( + content: 'http://localhost:11434', + hintText: 'http://localhost:11434', + settingType: SettingType.serverUrl, + ), + SettingItem( + content: 'llama3.1', + hintText: 'llama3.1', + settingType: SettingType.chatModel, + ), + SettingItem( + content: 'nomic-embed-text', + hintText: 'nomic-embed-text', + settingType: SettingType.embeddingModel, + ), + ]) + List inputItems, + @Default([]) List submittedItems, + @Default(false) bool isEdited, + }) = _PluginStateState; +} 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 0000000000..0141283765 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart @@ -0,0 +1,190 @@ +import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart'; +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-ai/entities.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:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'settings_ai_bloc.freezed.dart'; + +const String aiModelsGlobalActiveModel = "ai_models_global_active_model"; + +class SettingsAIBloc extends Bloc { + SettingsAIBloc( + this.userProfile, + this.workspaceId, + ) : _userListener = UserListener(userProfile: userProfile), + _aiModelSwitchListener = + AIModelSwitchListener(objectId: aiModelsGlobalActiveModel), + super( + SettingsAIState( + userProfile: userProfile, + ), + ) { + _aiModelSwitchListener.start( + onUpdateSelectedModel: (model) { + if (!isClosed) { + _loadModelList(); + } + }, + ); + _dispatch(); + } + + final UserListener _userListener; + final UserProfilePB userProfile; + final String workspaceId; + final AIModelSwitchListener _aiModelSwitchListener; + + @override + Future close() async { + await _userListener.stop(); + await _aiModelSwitchListener.stop(); + return super.close(); + } + + void _dispatch() { + on((event, emit) async { + await event.when( + started: () { + _userListener.start( + onProfileUpdated: _onProfileUpdated, + onUserWorkspaceSettingUpdated: (settings) { + if (!isClosed) { + add(SettingsAIEvent.didLoadWorkspaceSetting(settings)); + } + }, + ); + _loadModelList(); + _loadUserWorkspaceSetting(); + }, + didReceiveUserProfile: (userProfile) { + emit(state.copyWith(userProfile: userProfile)); + }, + toggleAISearch: () { + emit( + state.copyWith(enableSearchIndexing: !state.enableSearchIndexing), + ); + _updateUserWorkspaceSetting( + disableSearchIndexing: + !(state.aiSettings?.disableSearchIndexing ?? false), + ); + }, + selectModel: (AIModelPB model) async { + if (!model.isLocal) { + await _updateUserWorkspaceSetting(model: model.name); + } + await AIEventUpdateSelectedModel( + UpdateSelectedModelPB( + source: aiModelsGlobalActiveModel, + selectedModel: model, + ), + ).send(); + }, + didLoadWorkspaceSetting: (WorkspaceSettingsPB settings) { + emit( + state.copyWith( + aiSettings: settings, + enableSearchIndexing: !settings.disableSearchIndexing, + ), + ); + }, + didLoadAvailableModels: (AvailableModelsPB models) { + emit( + state.copyWith( + availableModels: models, + ), + ); + }, + ); + }); + } + + Future> _updateUserWorkspaceSetting({ + bool? disableSearchIndexing, + String? model, + }) async { + final payload = UpdateUserWorkspaceSettingPB( + workspaceId: workspaceId, + ); + if (disableSearchIndexing != null) { + payload.disableSearchIndexing = disableSearchIndexing; + } + if (model != null) { + payload.aiModel = model; + } + final result = await UserEventUpdateWorkspaceSetting(payload).send(); + result.fold( + (ok) => Log.info('Update workspace setting success'), + (err) => Log.error('Update workspace setting failed: $err'), + ); + return result; + } + + void _onProfileUpdated( + FlowyResult userProfileOrFailed, + ) => + userProfileOrFailed.fold( + (profile) => add(SettingsAIEvent.didReceiveUserProfile(profile)), + (err) => Log.error(err), + ); + + void _loadModelList() { + AIEventGetServerAvailableModels().send().then((result) { + result.fold((models) { + if (!isClosed) { + add(SettingsAIEvent.didLoadAvailableModels(models)); + } + }, (err) { + Log.error(err); + }); + }); + } + + void _loadUserWorkspaceSetting() { + final payload = UserWorkspaceIdPB(workspaceId: workspaceId); + UserEventGetWorkspaceSetting(payload).send().then((result) { + result.fold((settings) { + if (!isClosed) { + add(SettingsAIEvent.didLoadWorkspaceSetting(settings)); + } + }, (err) { + Log.error(err); + }); + }); + } +} + +@freezed +class SettingsAIEvent with _$SettingsAIEvent { + const factory SettingsAIEvent.started() = _Started; + const factory SettingsAIEvent.didLoadWorkspaceSetting( + WorkspaceSettingsPB settings, + ) = _DidLoadWorkspaceSetting; + + const factory SettingsAIEvent.toggleAISearch() = _toggleAISearch; + + const factory SettingsAIEvent.selectModel(AIModelPB model) = _SelectAIModel; + + const factory SettingsAIEvent.didReceiveUserProfile( + UserProfilePB newUserProfile, + ) = _DidReceiveUserProfile; + + const factory SettingsAIEvent.didLoadAvailableModels( + AvailableModelsPB models, + ) = _DidLoadAvailableModels; +} + +@freezed +class SettingsAIState with _$SettingsAIState { + const factory SettingsAIState({ + required UserProfilePB userProfile, + WorkspaceSettingsPB? aiSettings, + AvailableModelsPB? availableModels, + @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 index fa9b571478..99b9eaa2c9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart @@ -2,11 +2,13 @@ import 'dart:async'; 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: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/workspace/presentation/widgets/dialogs.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'; @@ -17,6 +19,7 @@ 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'; +import 'package:universal_platform/universal_platform.dart'; part 'appearance_cubit.freezed.dart'; @@ -41,7 +44,6 @@ class AppearanceSettingsCubit extends Cubit { appTheme, appearanceSettings.themeMode, appearanceSettings.font, - appearanceSettings.monospaceFont, appearanceSettings.layoutDirection, appearanceSettings.textDirection, appearanceSettings.enableRtlToolbarItems, @@ -98,7 +100,19 @@ class AppearanceSettingsCubit extends Cubit { Future setTheme(String themeName) async { _appearanceSettings.theme = themeName; unawaited(_saveAppearanceSettings()); - emit(state.copyWith(appTheme: await AppTheme.fromName(themeName))); + try { + final theme = await AppTheme.fromName(themeName); + emit(state.copyWith(appTheme: theme)); + } catch (e) { + Log.error("Error setting theme: $e"); + if (UniversalPlatform.isMacOS) { + showToastNotification( + message: + LocaleKeys.settings_workspacePage_theme_failedToLoadThemes.tr(), + type: ToastificationType.error, + ); + } + } } /// Reset the current user selected theme back to the default @@ -130,9 +144,8 @@ class AppearanceSettingsCubit extends Cubit { emit(state.copyWith(layoutDirection: layoutDirection)); } - void setTextDirection(AppFlowyTextDirection? textDirection) { - _appearanceSettings.textDirection = - textDirection?.toTextDirectionPB() ?? TextDirectionPB.FALLBACK; + void setTextDirection(AppFlowyTextDirection textDirection) { + _appearanceSettings.textDirection = textDirection.toTextDirectionPB(); _saveAppearanceSettings(); emit(state.copyWith(textDirection: textDirection)); } @@ -311,7 +324,6 @@ ThemeModePB _themeModeToPB(ThemeMode themeMode) { case ThemeMode.dark: return ThemeModePB.Dark; case ThemeMode.system: - default: return ThemeModePB.System; } } @@ -337,7 +349,7 @@ enum AppFlowyTextDirection { rtl, auto; - static AppFlowyTextDirection? fromTextDirectionPB( + static AppFlowyTextDirection fromTextDirectionPB( TextDirectionPB? textDirectionPB, ) { switch (textDirectionPB) { @@ -348,7 +360,7 @@ enum AppFlowyTextDirection { case TextDirectionPB.AUTO: return AppFlowyTextDirection.auto; default: - return null; + return AppFlowyTextDirection.ltr; } } @@ -360,8 +372,6 @@ enum AppFlowyTextDirection { return TextDirectionPB.RTL; case AppFlowyTextDirection.auto: return TextDirectionPB.AUTO; - default: - return TextDirectionPB.FALLBACK; } } } @@ -374,9 +384,8 @@ class AppearanceSettingsState with _$AppearanceSettingsState { required AppTheme appTheme, required ThemeMode themeMode, required String font, - required String monospaceFont, required LayoutDirection layoutDirection, - required AppFlowyTextDirection? textDirection, + required AppFlowyTextDirection textDirection, required bool enableRtlToolbarItems, required Locale locale, required bool isMenuCollapsed, @@ -393,7 +402,6 @@ class AppearanceSettingsState with _$AppearanceSettingsState { AppTheme appTheme, ThemeModePB themeModePB, String font, - String monospaceFont, LayoutDirectionPB layoutDirectionPB, TextDirectionPB? textDirectionPB, bool enableRtlToolbarItems, @@ -410,7 +418,6 @@ class AppearanceSettingsState with _$AppearanceSettingsState { return AppearanceSettingsState( appTheme: appTheme, font: font, - monospaceFont: monospaceFont, layoutDirection: LayoutDirection.fromLayoutDirectionPB(layoutDirectionPB), textDirection: AppFlowyTextDirection.fromTextDirectionPB(textDirectionPB), enableRtlToolbarItems: enableRtlToolbarItems, @@ -428,6 +435,7 @@ class AppearanceSettingsState with _$AppearanceSettingsState { } ThemeData get lightTheme => _getThemeData(Brightness.light); + ThemeData get darkTheme => _getThemeData(Brightness.dark); ThemeData _getThemeData(Brightness brightness) { @@ -435,7 +443,7 @@ class AppearanceSettingsState with _$AppearanceSettingsState { appTheme, brightness, font, - monospaceFont, + 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 index f2c6141407..6be53cf158 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart @@ -1,5 +1,4 @@ import 'package:appflowy/shared/google_fonts_extension.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flutter/material.dart'; @@ -11,18 +10,15 @@ import 'package:flutter/material.dart'; // Desktop: Based on the OS const defaultFontFamily = ''; -// the Poppins font is embedded in the app, so we can use it without GoogleFonts -// TODO(Lucas): after releasing version 0.5.6, remove it. -const fallbackFontFamily = 'Poppins'; const builtInCodeFontFamily = 'RobotoMono'; abstract class BaseAppearance { final white = const Color(0xFFFFFFFF); - final Set scrollbarInteractiveStates = { - MaterialState.pressed, - MaterialState.hovered, - MaterialState.dragged, + final Set scrollbarInteractiveStates = { + WidgetState.pressed, + WidgetState.hovered, + WidgetState.dragged, }; TextStyle getFontStyle({ @@ -33,9 +29,8 @@ abstract class BaseAppearance { double? letterSpacing, double? lineHeight, }) { - fontSize = fontSize ?? FontSizes.s12; - fontWeight = fontWeight ?? - (PlatformExtension.isDesktopOrWeb ? FontWeight.w500 : FontWeight.w400); + fontSize = fontSize ?? FontSizes.s14; + fontWeight = fontWeight ?? FontWeight.w400; letterSpacing = fontSize * (letterSpacing ?? 0.005); final textStyle = TextStyle( @@ -47,7 +42,7 @@ abstract class BaseAppearance { height: lineHeight, ); - if (fontFamily == defaultFontFamily || fontFamily == fallbackFontFamily) { + if (fontFamily == defaultFontFamily) { return textStyle; } 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 index 001de8af4e..c1e539cf58 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart @@ -14,9 +14,10 @@ class DesktopAppearance extends BaseAppearance { ) { assert(codeFontFamily.isNotEmpty); - final theme = brightness == Brightness.light - ? appTheme.lightTheme - : appTheme.darkTheme; + fontFamily = fontFamily.isEmpty ? defaultFontFamily : fontFamily; + + final isLight = brightness == Brightness.light; + final theme = isLight ? appTheme.lightTheme : appTheme.darkTheme; final colorScheme = ColorScheme( brightness: brightness, @@ -34,8 +35,6 @@ class DesktopAppearance extends BaseAppearance { // Editor: toolbarColor onTertiary: theme.toolbarColor, tertiaryContainer: theme.questionBubbleBG, - background: theme.surface, - onBackground: theme.text, surface: theme.surface, // text&icon color when it is hovered onSurface: theme.hoverFG, @@ -44,12 +43,13 @@ class DesktopAppearance extends BaseAppearance { onError: theme.onPrimary, error: theme.red, outline: theme.shader4, - surfaceVariant: theme.sidebarBg, + 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, @@ -57,6 +57,11 @@ class DesktopAppearance extends BaseAppearance { fontFamily: fontFamily, fontColor: theme.text, ), + textButtonTheme: const TextButtonThemeData( + style: ButtonStyle( + minimumSize: WidgetStatePropertyAll(Size.zero), + ), + ), textSelectionTheme: TextSelectionThemeData( cursorColor: theme.main2, selectionHandleColor: theme.main2, @@ -76,18 +81,12 @@ class DesktopAppearance extends BaseAppearance { contentTextStyle: TextStyle(color: colorScheme.onSurface), ), scrollbarTheme: ScrollbarThemeData( - thumbColor: MaterialStateProperty.resolveWith((states) { - if (states.any(scrollbarInteractiveStates.contains)) { - return theme.shader7; - } - return theme.shader5; - }), - thickness: MaterialStateProperty.resolveWith((states) { - if (states.any(scrollbarInteractiveStates.contains)) { - return 4; - } - return 3.0; - }), + 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, @@ -104,6 +103,7 @@ class DesktopAppearance extends BaseAppearance { indicatorColor: theme.main1, cardColor: theme.input, colorScheme: colorScheme, + extensions: [ AFThemeExtension( warning: theme.yellow, @@ -119,6 +119,7 @@ class DesktopAppearance extends BaseAppearance { tint9: theme.tint9, textColor: theme.text, secondaryTextColor: theme.secondaryText, + strongText: theme.strongText, greyHover: theme.hoverBG1, greySelect: theme.bg3, lightGreyHover: theme.hoverBG3, @@ -144,6 +145,13 @@ class DesktopAppearance extends BaseAppearance { fontWeight: FontWeight.w400, fontColor: theme.hint, ), + onBackground: theme.text, + background: theme.surface, + borderColor: theme.borderColor, + scrollbarColor: theme.scrollbarColor, + scrollbarHoverColor: theme.scrollbarHoverColor, + lightIconColor: theme.lightIconColor, + toolbarHoverColor: theme.toolbarHoverColor, ), ], ); 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 index 19cd87f4f4..46eddd53ab 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart @@ -1,11 +1,10 @@ -import 'package:flutter/material.dart'; - // 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 @@ -29,13 +28,12 @@ class MobileAppearance extends BaseAppearance { fontWeight: FontWeight.w400, ); + final isLight = brightness == Brightness.light; final codeFontStyle = getFontStyle(fontFamily: codeFontFamily); - final theme = brightness == Brightness.light - ? appTheme.lightTheme - : appTheme.darkTheme; + final theme = isLight ? appTheme.lightTheme : appTheme.darkTheme; - final colorTheme = brightness == Brightness.light + final colorTheme = isLight ? ColorScheme( brightness: brightness, primary: _primaryColor, @@ -49,14 +47,12 @@ class MobileAppearance extends BaseAppearance { tertiary: const Color(0xff858585), // for light text error: const Color(0xffFB006D), onError: const Color(0xffFB006D), - background: Colors.white, - onBackground: _onBackgroundColor, outline: const Color(0xffe3e3e3), - outlineVariant: const Color(0xffCBD5E0).withOpacity(0.24), + outlineVariant: const Color(0xffCBD5E0).withValues(alpha: 0.24), //Snack bar surface: Colors.white, onSurface: _onSurfaceColor, // text/body color - surfaceVariant: const Color.fromARGB(255, 216, 216, 216), + surfaceContainerHighest: theme.sidebarBg, ) : ColorScheme( brightness: brightness, @@ -67,17 +63,16 @@ class MobileAppearance extends BaseAppearance { tertiary: const Color(0xff858585), // temp error: const Color(0xffFB006D), onError: const Color(0xffFB006D), - background: const Color(0xff121212), // temp - onBackground: Colors.white, 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 hintColor = isLight ? const Color(0x991F2329) : _hintColorInDarkMode; + final onBackground = isLight ? _onBackgroundColor : Colors.white; + final background = isLight ? Colors.white : const Color(0xff121212); return ThemeData( useMaterial3: false, @@ -86,14 +81,14 @@ class MobileAppearance extends BaseAppearance { dividerColor: colorTheme.outline, //caption hintColor: hintColor, disabledColor: colorTheme.outline, - scaffoldBackgroundColor: colorTheme.background, + scaffoldBackgroundColor: background, appBarTheme: AppBarTheme( toolbarHeight: 44.0, - foregroundColor: colorTheme.onBackground, - backgroundColor: colorTheme.background, + foregroundColor: onBackground, + backgroundColor: background, centerTitle: false, titleTextStyle: TextStyle( - color: colorTheme.onBackground, + color: onBackground, fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: 0.05, @@ -101,8 +96,8 @@ class MobileAppearance extends BaseAppearance { shadowColor: colorTheme.outlineVariant, ), radioTheme: RadioThemeData( - fillColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { return colorTheme.primary; } return colorTheme.outline; @@ -111,20 +106,20 @@ class MobileAppearance extends BaseAppearance { // button elevatedButtonTheme: ElevatedButtonThemeData( style: ButtonStyle( - fixedSize: MaterialStateProperty.all(const Size.fromHeight(48)), - elevation: MaterialStateProperty.all(0), - textStyle: MaterialStateProperty.all( + fixedSize: WidgetStateProperty.all(const Size.fromHeight(48)), + elevation: WidgetStateProperty.all(0), + textStyle: WidgetStateProperty.all( TextStyle( fontSize: 14, fontFamily: fontStyle.fontFamily, fontWeight: FontWeight.w600, ), ), - shadowColor: MaterialStateProperty.all(null), - foregroundColor: MaterialStateProperty.all(Colors.white), - backgroundColor: MaterialStateProperty.resolveWith( - (Set states) { - if (states.contains(MaterialState.disabled)) { + shadowColor: WidgetStateProperty.all(null), + foregroundColor: WidgetStateProperty.all(Colors.white), + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { return _primaryColor; } return colorTheme.primary; @@ -134,29 +129,29 @@ class MobileAppearance extends BaseAppearance { ), outlinedButtonTheme: OutlinedButtonThemeData( style: ButtonStyle( - textStyle: MaterialStateProperty.all( + textStyle: WidgetStateProperty.all( TextStyle( fontSize: 14, fontFamily: fontStyle.fontFamily, fontWeight: FontWeight.w500, ), ), - foregroundColor: MaterialStateProperty.all(colorTheme.onBackground), - backgroundColor: MaterialStateProperty.all(colorTheme.background), - shape: MaterialStateProperty.all( + foregroundColor: WidgetStateProperty.all(onBackground), + backgroundColor: WidgetStateProperty.all(background), + shape: WidgetStateProperty.all( RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), ), - side: MaterialStateProperty.all( + side: WidgetStateProperty.all( BorderSide(color: colorTheme.outline, width: 0.5), ), - padding: MaterialStateProperty.all( + padding: WidgetStateProperty.all( const EdgeInsets.symmetric(horizontal: 8, vertical: 12), ), ), ), textButtonTheme: TextButtonThemeData( style: ButtonStyle( - textStyle: MaterialStateProperty.all(fontStyle), + textStyle: WidgetStateProperty.all(fontStyle), ), ), // text @@ -170,7 +165,7 @@ class MobileAppearance extends BaseAppearance { letterSpacing: 0.16, ), displayMedium: fontStyle.copyWith( - color: colorTheme.onBackground, + color: onBackground, fontSize: 32, fontWeight: FontWeight.w600, height: 1.20, @@ -178,33 +173,33 @@ class MobileAppearance extends BaseAppearance { ), // H1 Semi 26 displaySmall: fontStyle.copyWith( - color: colorTheme.onBackground, + color: onBackground, fontWeight: FontWeight.w600, height: 1.10, letterSpacing: 0.13, ), // body2 14 Regular bodyMedium: fontStyle.copyWith( - color: colorTheme.onBackground, + color: onBackground, fontWeight: FontWeight.w400, letterSpacing: 0.07, ), // Trash empty title labelLarge: fontStyle.copyWith( - color: colorTheme.onBackground, + color: onBackground, fontSize: 22, fontWeight: FontWeight.w600, letterSpacing: -0.3, ), // setting item title labelMedium: fontStyle.copyWith( - color: colorTheme.onSurface, + color: onBackground, fontSize: 18, fontWeight: FontWeight.w500, ), // setting group title labelSmall: fontStyle.copyWith( - color: colorTheme.onBackground, + color: onBackground, fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.05, @@ -251,6 +246,7 @@ class MobileAppearance extends BaseAppearance { tint9: theme.tint9, textColor: theme.text, secondaryTextColor: theme.secondaryText, + strongText: theme.strongText, greyHover: theme.hoverBG1, greySelect: theme.bg3, lightGreyHover: theme.hoverBG3, @@ -273,6 +269,13 @@ class MobileAppearance extends BaseAppearance { fontWeight: FontWeight.w400, color: theme.hint, ), + onBackground: onBackground, + background: background, + borderColor: theme.borderColor, + scrollbarColor: theme.scrollbarColor, + scrollbarHoverColor: theme.scrollbarHoverColor, + lightIconColor: theme.lightIconColor, + toolbarHoverColor: theme.toolbarHoverColor, ), 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 index fdb53f4e43..febb89727a 100644 --- 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 @@ -1,3 +1,4 @@ +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'; @@ -14,7 +15,7 @@ class AppFlowyCloudSettingBloc extends Bloc { AppFlowyCloudSettingBloc(CloudSettingPB setting) : _listener = UserCloudConfigListener(), - super(AppFlowyCloudSettingState.initial(setting)) { + super(AppFlowyCloudSettingState.initial(setting, false)) { _dispatch(); } @@ -31,6 +32,10 @@ class AppFlowyCloudSettingBloc (event, emit) async { await event.when( initial: () async { + await getSyncLogEnabled().then((value) { + emit(state.copyWith(isSyncLogEnabled: value)); + }); + _listener.start( onSettingChanged: (result) { if (isClosed) { @@ -48,6 +53,10 @@ class AppFlowyCloudSettingBloc 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( @@ -67,6 +76,8 @@ 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; @@ -77,12 +88,17 @@ class AppFlowyCloudSettingState with _$AppFlowyCloudSettingState { const factory AppFlowyCloudSettingState({ required CloudSettingPB setting, required bool showRestartHint, + required bool isSyncLogEnabled, }) = _AppFlowyCloudSettingState; - factory AppFlowyCloudSettingState.initial(CloudSettingPB setting) => + factory AppFlowyCloudSettingState.initial( + CloudSettingPB setting, + bool isSyncLogEnabled, + ) => AppFlowyCloudSettingState( setting: setting, showRestartHint: setting.serverUrl.isNotEmpty, + isSyncLogEnabled: isSyncLogEnabled, ); } 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 index 998e6d632f..5652904180 100644 --- 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 @@ -24,6 +24,15 @@ class AppFlowyCloudURLsBloc ), ); }, + updateBaseWebDomain: (url) { + emit( + state.copyWith( + updatedBaseWebDomain: url, + urlError: null, + showRestartHint: url.isNotEmpty, + ), + ); + }, confirmUpdate: () async { if (state.updatedServerUrl.isEmpty) { emit( @@ -35,13 +44,27 @@ class AppFlowyCloudURLsBloc ), ); } else { - validateUrl(state.updatedServerUrl).fold( + bool isSuccess = false; + + await validateUrl(state.updatedServerUrl).fold( (url) async { await useSelfHostedAppFlowyCloudWithURL(url); - add(const AppFlowyCloudURLsEvent.didSaveConfig()); + isSuccess = true; }, - (err) => emit(state.copyWith(urlError: err)), + (err) async => emit(state.copyWith(urlError: err)), ); + + await validateUrl(state.updatedBaseWebDomain).fold( + (url) async { + await useBaseWebDomain(url); + isSuccess = true; + }, + (err) async => emit(state.copyWith(urlError: err)), + ); + + if (isSuccess) { + add(const AppFlowyCloudURLsEvent.didSaveConfig()); + } } }, didSaveConfig: () { @@ -62,6 +85,8 @@ class AppFlowyCloudURLsEvent with _$AppFlowyCloudURLsEvent { const factory AppFlowyCloudURLsEvent.initial() = _Initial; const factory AppFlowyCloudURLsEvent.updateServerUrl(String text) = _ServerUrl; + const factory AppFlowyCloudURLsEvent.updateBaseWebDomain(String text) = + _UpdateBaseWebDomain; const factory AppFlowyCloudURLsEvent.confirmUpdate() = _UpdateConfig; const factory AppFlowyCloudURLsEvent.didSaveConfig() = _DidSaveConfig; } @@ -71,6 +96,7 @@ class AppFlowyCloudURLsState with _$AppFlowyCloudURLsState { const factory AppFlowyCloudURLsState({ required AppFlowyCloudConfiguration config, required String updatedServerUrl, + required String updatedBaseWebDomain, required String? urlError, required bool restartApp, required bool showRestartHint, @@ -81,6 +107,8 @@ class AppFlowyCloudURLsState with _$AppFlowyCloudURLsState { urlError: null, updatedServerUrl: getIt().appflowyCloudConfig.base_url, + updatedBaseWebDomain: + getIt().appflowyCloudConfig.base_web_domain, showRestartHint: getIt() .appflowyCloudConfig .base_url 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 0000000000..df880891e9 --- /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, userId: userId); + _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/date_time/date_format_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart index 863482cca8..76fec2ecfc 100644 --- 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 @@ -1,14 +1,21 @@ import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; -const _localFmt = 'M/d/y'; -const _usFmt = 'y/M/d'; -const _isoFmt = 'y-M-d'; -const _friendlyFmt = 'MMM d, y'; -const _dmyFmt = 'd/M/y'; +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); + DateFormat get toFormat { + try { + return DateFormat(_toFormat[this] ?? _friendlyFmt); + } catch (_) { + // fallback to en-US + return DateFormat(_toFormat[this] ?? _friendlyFmt, 'en-US'); + } + } String formatDate( DateTime date, 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 0000000000..0dfa2807b9 --- /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 0000000000..58560bae03 --- /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 index aae0a63609..ea6b3b6f01 100644 --- 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 @@ -1,5 +1,8 @@ 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'; @@ -10,23 +13,30 @@ part 'notification_settings_cubit.freezed.dart'; class NotificationSettingsCubit extends Cubit { NotificationSettingsCubit() : super(NotificationSettingsState.initial()) { - UserSettingsBackendService() - .getNotificationSettings() - .then((notificationSettings) { - _notificationSettings = notificationSettings; - emit( - state.copyWith( - isNotificationsEnabled: _notificationSettings.notificationsEnabled, - ), - ); - _initCompleter.complete(); - }); + _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; @@ -41,9 +51,24 @@ class NotificationSettingsCubit extends Cubit { 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( @@ -59,8 +84,12 @@ class NotificationSettingsState with _$NotificationSettingsState { const factory NotificationSettingsState({ required bool isNotificationsEnabled, + required bool isShowNotificationsIconEnabled, }) = _NotificationSettingsState; factory NotificationSettingsState.initial() => - const NotificationSettingsState(isNotificationsEnabled: true); + 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 0000000000..26975b00ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart @@ -0,0 +1,238 @@ +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, + userId: userId, + ); + _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) { + 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 0000000000..9d91ade4d3 --- /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 0000000000..ddaca15f5c --- /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/setting_file_importer_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/setting_file_importer_bloc.dart index 517b943b35..7a1d3efc45 100644 --- 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 @@ -1,4 +1,7 @@ +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'; @@ -19,9 +22,17 @@ class SettingFileImportBloc 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 = "appflowy_import_$formattedDate"; + ..importContainerName = "import_$formattedDate"; + + if (spaceId != null) { + payload.parentViewId = spaceId; + } + emit( state.copyWith(loadingState: const LoadingState.loading()), ); 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 index 906ccb78f2..83588f0079 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -1,8 +1,11 @@ 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/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'; @@ -11,27 +14,33 @@ part 'settings_dialog_bloc.freezed.dart'; enum SettingsPage { // NEW account, + workspace, + manageData, + shortcuts, + ai, + plan, + billing, + sites, // OLD - appearance, - language, - files, - // user, notifications, cloud, - shortcuts, member, featureFlags, } class SettingsDialogBloc extends Bloc { - SettingsDialogBloc(this.userProfile) - : _userListener = UserListener(userProfile: userProfile), - super(SettingsDialogState.initial(userProfile)) { + 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 @@ -46,6 +55,14 @@ class SettingsDialogBloc 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)); @@ -67,6 +84,42 @@ class SettingsDialogBloc (err) => Log.error(err), ); } + + Future _isBillingEnabled( + UserProfilePB userProfile, [ + AFRolePB? currentWorkspaceMemberRole, + ]) async { + if ([ + AuthTypePB.Local, + ].contains(userProfile.workspaceAuthType)) { + 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 @@ -83,14 +136,17 @@ class SettingsDialogEvent with _$SettingsDialogEvent { class SettingsDialogState with _$SettingsDialogState { const factory SettingsDialogState({ required UserProfilePB userProfile, - required FlowyResult successOrFailure, required SettingsPage page, + required bool isBillingEnabled, }) = _SettingsDialogState; - factory SettingsDialogState.initial(UserProfilePB userProfile) => + factory SettingsDialogState.initial( + UserProfilePB userProfile, + SettingsPage? page, + ) => SettingsDialogState( userProfile: userProfile, - successOrFailure: FlowyResult.success(null), - page: SettingsPage.account, + page: page ?? SettingsPage.account, + isBillingEnabled: false, ); } 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 index 7cf81b3bfb..e890959949 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/share/export_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/share/export_service.dart @@ -12,4 +12,12 @@ class BackendExportService { 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 index 34ea16e52f..205a61f7e3 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/share/import_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/share/import_service.dart @@ -1,37 +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/import.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; -class ImportBackendService { - static Future> importData( - List data, - String name, - String parentViewId, - ImportTypePB importType, - ) async { - final payload = ImportPB.create() - ..data = data - ..parentViewId = parentViewId - ..viewLayout = importType.toLayout() - ..name = name - ..importType = importType; - return FolderEventImportData(payload).send(); - } +class ImportPayload { + ImportPayload({ + required this.name, + required this.data, + required this.layout, + }); + + final String name; + final List data; + final ViewLayoutPB layout; } -extension on ImportTypePB { - ViewLayoutPB toLayout() { - switch (this) { - case ImportTypePB.HistoryDocument: - return ViewLayoutPB.Document; - case ImportTypePB.HistoryDatabase || - ImportTypePB.CSV || - ImportTypePB.RawDatabase: - return ViewLayoutPB.Grid; - default: - throw UnimplementedError('Unsupported import type $this'); +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 index 790375fc20..569b4a4ea4 100644 --- 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 @@ -1,5 +1,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_page.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'; @@ -18,7 +18,27 @@ class ShortcutsState with _$ShortcutsState { }) = _ShortcutsState; } -enum ShortcutsStatus { initial, updating, success, failure } +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()); @@ -32,6 +52,7 @@ class ShortcutsCubit extends Cubit { error: '', ), ); + try { final customizeShortcuts = await service.getCustomizeShortcuts(); await service.updateCommandShortcuts( @@ -40,7 +61,9 @@ class ShortcutsCubit extends Cubit { ); //sort the shortcuts - commandShortcutEvents.sort((a, b) => a.key.compareTo(b.key)); + commandShortcutEvents.sort( + (a, b) => a.key.toLowerCase().compareTo(b.key.toLowerCase()), + ); emit( state.copyWith( @@ -53,44 +76,31 @@ class ShortcutsCubit extends Cubit { emit( state.copyWith( status: ShortcutsStatus.failure, - error: LocaleKeys.settings_shortcuts_couldNotLoadErrorMsg.tr(), + error: LocaleKeys.settings_shortcutsPage_couldNotLoadErrorMsg.tr(), ), ); } } Future updateAllShortcuts() async { - emit( - state.copyWith( - status: ShortcutsStatus.updating, - error: '', - ), - ); + emit(state.copyWith(status: ShortcutsStatus.updating, error: '')); + try { await service.saveAllShortcuts(state.commandShortcutEvents); - emit( - state.copyWith( - status: ShortcutsStatus.success, - error: '', - ), - ); + emit(state.copyWith(status: ShortcutsStatus.success, error: '')); } catch (e) { emit( state.copyWith( status: ShortcutsStatus.failure, - error: LocaleKeys.settings_shortcuts_couldNotSaveErrorMsg.tr(), + error: LocaleKeys.settings_shortcutsPage_couldNotSaveErrorMsg.tr(), ), ); } } Future resetToDefault() async { - emit( - state.copyWith( - status: ShortcutsStatus.updating, - error: '', - ), - ); + emit(state.copyWith(status: ShortcutsStatus.updating, error: '')); + try { await service.saveAllShortcuts(defaultCommandShortcutEvents); await fetchShortcuts(); @@ -98,25 +108,31 @@ class ShortcutsCubit extends Cubit { emit( state.copyWith( status: ShortcutsStatus.failure, - error: LocaleKeys.settings_shortcuts_couldNotSaveErrorMsg.tr(), + 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. - String getConflict(CommandShortcutEvent currentShortcut, String command) { - //check if currentShortcut is a codeblock shortcut. + /// 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 e in state.commandShortcutEvents) { - if (e.command == command && e.isCodeBlockCommand == isCodeBlockCommand) { - return e.key; + for (final shortcut in state.commandShortcutEvents) { + final keybindings = shortcut.command.split(','); + if (keybindings.contains(command) && + shortcut.isCodeBlockCommand == isCodeBlockCommand) { + return shortcut; } } - return ''; + + return null; } } 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 index 35809ac585..af95d5af5a 100644 --- 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 @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +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'; @@ -53,8 +53,8 @@ class SettingsShortcutService { } } - /// 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. + // 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; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart deleted file mode 100644 index 9308a06a98..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:appflowy/env/backend_env.dart'; -import 'package:appflowy/env/cloud_env.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-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 'cloud_setting_listener.dart'; - -part 'supabase_cloud_setting_bloc.freezed.dart'; - -class SupabaseCloudSettingBloc - extends Bloc { - SupabaseCloudSettingBloc({ - required CloudSettingPB setting, - }) : _listener = UserCloudConfigListener(), - super(SupabaseCloudSettingState.initial(setting)) { - _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 { - _listener.start( - onSettingChanged: (result) { - if (isClosed) { - return; - } - result.fold( - (setting) => - add(SupabaseCloudSettingEvent.didReceiveSetting(setting)), - (error) => Log.error(error), - ); - }, - ); - }, - enableSync: (bool enable) async { - final update = UpdateCloudConfigPB.create()..enableSync = enable; - await updateCloudConfig(update); - }, - didReceiveSetting: (CloudSettingPB setting) { - emit( - state.copyWith( - setting: setting, - loadingState: LoadingState.finish(FlowyResult.success(null)), - ), - ); - }, - enableEncrypt: (bool enable) { - final update = UpdateCloudConfigPB.create()..enableEncrypt = enable; - updateCloudConfig(update); - emit(state.copyWith(loadingState: const LoadingState.loading())); - }, - ); - }, - ); - } - - Future updateCloudConfig(UpdateCloudConfigPB setting) async { - await UserEventSetCloudConfig(setting).send(); - } -} - -@freezed -class SupabaseCloudSettingEvent with _$SupabaseCloudSettingEvent { - const factory SupabaseCloudSettingEvent.initial() = _Initial; - const factory SupabaseCloudSettingEvent.didReceiveSetting( - CloudSettingPB setting, - ) = _DidSyncSupabaseConfig; - const factory SupabaseCloudSettingEvent.enableSync(bool enable) = _EnableSync; - const factory SupabaseCloudSettingEvent.enableEncrypt(bool enable) = - _EnableEncrypt; -} - -@freezed -class SupabaseCloudSettingState with _$SupabaseCloudSettingState { - const factory SupabaseCloudSettingState({ - required LoadingState loadingState, - required SupabaseConfiguration config, - required CloudSettingPB setting, - }) = _SupabaseCloudSettingState; - - factory SupabaseCloudSettingState.initial(CloudSettingPB setting) => - SupabaseCloudSettingState( - loadingState: LoadingState.finish(FlowyResult.success(null)), - setting: setting, - config: getIt().supabaseConfig, - ); -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_urls_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_urls_bloc.dart deleted file mode 100644 index fdd4cbef21..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_urls_bloc.dart +++ /dev/null @@ -1,128 +0,0 @@ -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_backend/dispatch/dispatch.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 'package:freezed_annotation/freezed_annotation.dart'; - -import 'appflowy_cloud_setting_bloc.dart'; - -part 'supabase_cloud_urls_bloc.freezed.dart'; - -class SupabaseCloudURLsBloc - extends Bloc { - SupabaseCloudURLsBloc() : super(SupabaseCloudURLsState.initial()) { - on((event, emit) async { - await event.when( - updateUrl: (String url) { - emit( - state.copyWith( - updatedUrl: url, - showRestartHint: url.isNotEmpty && state.upatedAnonKey.isNotEmpty, - urlError: null, - ), - ); - }, - updateAnonKey: (String anonKey) { - emit( - state.copyWith( - upatedAnonKey: anonKey, - showRestartHint: - anonKey.isNotEmpty && state.updatedUrl.isNotEmpty, - anonKeyError: null, - ), - ); - }, - confirmUpdate: () async { - if (state.updatedUrl.isEmpty) { - emit( - state.copyWith( - urlError: - LocaleKeys.settings_menu_cloudSupabaseUrlCanNotBeEmpty.tr(), - anonKeyError: null, - restartApp: false, - ), - ); - return; - } - - if (state.upatedAnonKey.isEmpty) { - emit( - state.copyWith( - urlError: null, - anonKeyError: LocaleKeys - .settings_menu_cloudSupabaseAnonKeyCanNotBeEmpty - .tr(), - restartApp: false, - ), - ); - return; - } - - validateUrl(state.updatedUrl).fold( - (_) async { - await useSupabaseCloud( - url: state.updatedUrl, - anonKey: state.upatedAnonKey, - ); - - add(const SupabaseCloudURLsEvent.didSaveConfig()); - }, - (error) => emit(state.copyWith(urlError: error)), - ); - }, - didSaveConfig: () { - emit( - state.copyWith( - urlError: null, - anonKeyError: null, - restartApp: true, - ), - ); - }, - ); - }); - } - - Future updateCloudConfig(UpdateCloudConfigPB setting) async { - await UserEventSetCloudConfig(setting).send(); - } -} - -@freezed -class SupabaseCloudURLsEvent with _$SupabaseCloudURLsEvent { - const factory SupabaseCloudURLsEvent.updateUrl(String text) = _UpdateUrl; - const factory SupabaseCloudURLsEvent.updateAnonKey(String text) = - _UpdateAnonKey; - const factory SupabaseCloudURLsEvent.confirmUpdate() = _UpdateConfig; - const factory SupabaseCloudURLsEvent.didSaveConfig() = _DidSaveConfig; -} - -@freezed -class SupabaseCloudURLsState with _$SupabaseCloudURLsState { - const factory SupabaseCloudURLsState({ - required SupabaseConfiguration config, - required String updatedUrl, - required String upatedAnonKey, - required String? urlError, - required String? anonKeyError, - required bool restartApp, - required bool showRestartHint, - }) = _SupabaseCloudURLsState; - - factory SupabaseCloudURLsState.initial() { - final config = getIt().supabaseConfig; - return SupabaseCloudURLsState( - updatedUrl: config.url, - upatedAnonKey: config.anon_key, - urlError: null, - anonKeyError: null, - restartApp: false, - showRestartHint: config.url.isNotEmpty && config.anon_key.isNotEmpty, - config: config, - ); - } -} 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 0000000000..b8081cb2d5 --- /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 0000000000..56d6ae8cc8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart @@ -0,0 +1,245 @@ +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/workspace/application/workspace/workspace_service.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(); + }, + ); + } + + Future _checkWorkspaceUsage() async { + if (state.workspaceId == null || state.userProfile == null) { + return; + } + + await WorkspaceService( + workspaceId: state.workspaceId!, + userId: state.userProfile!.id, + ).getWorkspaceUsage().then((result) { + result.fold( + (usage) { + if (!isClosed && usage != null) { + add(SidebarPlanEvent.updateWorkspaceUsage(usage)); + } + }, + (error) => Log.error("Failed to get workspace usage: $error"), + ); + }); + } +} + +@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 index 4dd934f60b..609b9ce0ae 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart @@ -9,18 +9,20 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'folder_bloc.freezed.dart'; -enum FolderCategoryType { +enum FolderSpaceType { favorite, private, - public; + public, + unknown; ViewSectionPB get toViewSectionPB { switch (this) { - case FolderCategoryType.private: + case FolderSpaceType.private: return ViewSectionPB.Private; - case FolderCategoryType.public: + case FolderSpaceType.public: return ViewSectionPB.Public; - case FolderCategoryType.favorite: + case FolderSpaceType.favorite: + case FolderSpaceType.unknown: throw UnimplementedError(); } } @@ -28,7 +30,7 @@ enum FolderCategoryType { class FolderBloc extends Bloc { FolderBloc({ - required FolderCategoryType type, + required FolderSpaceType type, }) : super(FolderState.initial(type)) { on((event, emit) async { await event.map( @@ -84,12 +86,12 @@ class FolderEvent with _$FolderEvent { @freezed class FolderState with _$FolderState { const factory FolderState({ - required FolderCategoryType type, + required FolderSpaceType type, required bool isExpanded, }) = _FolderState; factory FolderState.initial( - FolderCategoryType type, + FolderSpaceType type, ) => FolderState( type: type, 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 0000000000..6d6ce05051 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -0,0 +1,811 @@ +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); + + 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) { + _workspaceService = WorkspaceService( + workspaceId: workspaceId, + userId: userProfile.id, + ); + + this.userProfile = userProfile; + this.workspaceId = workspaceId; + + _listener = WorkspaceSectionsListener( + user: userProfile, + workspaceId: workspaceId, + )..start( + sectionChanged: (result) async { + 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 0000000000..04e3ad7896 --- /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 0000000000..0cf436630f --- /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 index 36d64e6989..f27539cddd 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart @@ -1,19 +1,24 @@ -import 'package:flutter/foundation.dart'; +import 'dart:convert'; +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy/util/expand_views.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/home_stack.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_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'tabs_bloc.freezed.dart'; -part 'tabs_event.dart'; -part 'tabs_state.dart'; class TabsBloc extends Bloc { TabsBloc() : super(TabsState()) { @@ -37,27 +42,166 @@ class TabsBloc extends Bloc { if (index != state.currentIndex && index >= 0 && index < state.pages) { - emit(state.copyWith(newIndex: index)); + 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) { - emit(state.openView(plugin, view)); + state.currentPageManager + ..hideSecondaryPlugin() + ..setSecondaryPlugin(BlankPagePlugin()); + emit(state.openView(plugin)); _setLatestOpenView(view); }, openPlugin: (Plugin plugin, ViewPB? view, bool setLatest) { + state.currentPageManager + ..hideSecondaryPlugin() + ..setSecondaryPlugin(BlankPagePlugin()); emit(state.openPlugin(plugin: plugin, setLatest: setLatest)); if (setLatest) { + // the space view should be filtered out. + if (view != null && view.isSpace) { + return; + } _setLatestOpenView(view); + if (view != null) _expandAncestors(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) + ..showSecondaryPlugin(); + }, + closeSecondaryPlugin: () { + final pageManager = state.currentPageManager; + pageManager.hideSecondaryPlugin(); + }, + expandSecondaryPlugin: () { + final pageManager = state.currentPageManager; + pageManager + ..hideSecondaryPlugin() + ..expandSecondaryPlugin(); + _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)); } }, ); @@ -71,12 +215,55 @@ class TabsBloc extends Bloc { } else { final pageManager = state.currentPageManager; final notifier = pageManager.plugin.notifier; - if (notifier is ViewPluginNotifier) { + if (notifier is ViewPluginNotifier && + menuSharedState.latestOpenView?.id != notifier.view.id) { menuSharedState.latestOpenView = notifier.view; } } } + Future _expandAncestors(ViewPB view) async { + final viewExpanderRegistry = getIt.get(); + if (viewExpanderRegistry.isViewExpanded(view.parentViewId)) return; + final value = await getIt().get(KVKeys.expandedViews); + try { + final Map expandedViews = value == null ? {} : jsonDecode(value); + final List ancestors = + await ViewBackendService.getViewAncestors(view.id) + .fold((s) => s.items.map((e) => e.id).toList(), (f) => []); + ViewExpander? viewExpander; + for (final id in ancestors) { + expandedViews[id] = true; + final expander = viewExpanderRegistry.getExpander(id); + if (expander == null) continue; + if (!expander.isViewExpanded && viewExpander == null) { + viewExpander = expander; + } + } + await getIt() + .set(KVKeys.expandedViews, jsonEncode(expandedViews)); + viewExpander?.expand(); + } catch (e) { + Log.error('expandAncestors error', e); + } + } + + 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)); @@ -92,8 +279,157 @@ class TabsBloc extends Bloc { view: view, ), ); - - // Update recent views - getIt().updateRecentViews([view.id], true); + } +} + +@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/tabs/tabs_event.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart deleted file mode 100644 index 335c383f2e..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart +++ /dev/null @@ -1,18 +0,0 @@ -part of 'tabs_bloc.dart'; - -@freezed -class TabsEvent with _$TabsEvent { - const factory TabsEvent.moveTab() = _MoveTab; - const factory TabsEvent.closeTab(String pluginId) = _CloseTab; - const factory TabsEvent.closeCurrentTab() = _CloseCurrentTab; - const factory TabsEvent.selectTab(int index) = _SelectTab; - 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; -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart deleted file mode 100644 index cf4092eaaa..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart +++ /dev/null @@ -1,107 +0,0 @@ -part of 'tabs_bloc.dart'; - -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; - - /// This opens a new tab given a [Plugin] and a [View]. - /// - /// If the [Plugin.id] is already associated with an open tab, - /// then it selects that tab. - /// - TabsState openView(Plugin plugin, ViewPB view) { - final selectExistingPlugin = _selectPluginIfOpen(plugin.id); - - if (selectExistingPlugin == null) { - _pageManagers.add(PageManager()..setPlugin(plugin, true)); - - return copyWith(newIndex: 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( - newIndex: 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(newIndex: index); - } - - TabsState copyWith({ - int? newIndex, - List? pageManagers, - }) => - TabsState( - currentIndex: newIndex ?? currentIndex, - pageManagers: pageManagers ?? _pageManagers, - ); - - void dispose() { - for (final manager in pageManagers) { - manager.dispose(); - } - } -} 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 index 59127bdab0..2f62177661 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart @@ -54,26 +54,17 @@ class SettingsUserViewBloc extends Bloc { ); }); }, - removeUserIcon: () { - // Empty Icon URL = No icon - _userService.updateUserProfile(iconUrl: "").then((result) { + updateUserEmail: (String email) { + _userService.updateUserProfile(email: email).then((result) { result.fold( (l) => null, (err) => Log.error(err), ); }); }, - updateUserOpenAIKey: (openAIKey) { - _userService.updateUserProfile(openAIKey: openAIKey).then((result) { - result.fold( - (l) => null, - (err) => Log.error(err), - ); - }); - }, - updateUserStabilityAIKey: (stabilityAIKey) { + updateUserPassword: (String oldPassword, String newPassword) { _userService - .updateUserProfile(stabilityAiKey: stabilityAIKey) + .updateUserProfile(password: newPassword) .then((result) { result.fold( (l) => null, @@ -81,8 +72,9 @@ class SettingsUserViewBloc extends Bloc { ); }); }, - updateUserEmail: (String email) { - _userService.updateUserProfile(email: email).then((result) { + removeUserIcon: () { + // Empty Icon URL = No icon + _userService.updateUserProfile(iconUrl: "").then((result) { result.fold( (l) => null, (err) => Log.error(err), @@ -122,16 +114,20 @@ class SettingsUserViewBloc extends Bloc { @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.updateUserName({ + required String name, + }) = _UpdateUserName; + const factory SettingsUserEvent.updateUserEmail({ + required String email, + }) = _UpdateEmail; + const factory SettingsUserEvent.updateUserIcon({ + required String iconUrl, + }) = _UpdateUserIcon; + const factory SettingsUserEvent.updateUserPassword({ + required String oldPassword, + required String newPassword, + }) = _UpdateUserPassword; const factory SettingsUserEvent.removeUserIcon() = _RemoveUserIcon; - const factory SettingsUserEvent.updateUserOpenAIKey(String openAIKey) = - _UpdateUserOpenaiKey; - const factory SettingsUserEvent.updateUserStabilityAIKey( - String stabilityAIKey, - ) = _UpdateUserStabilityAIKey; const factory SettingsUserEvent.didReceiveUserProfile( UserProfilePB newUserProfile, ) = _DidReceiveUserProfile; 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 index a85cd3ca34..d14f258462 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -27,17 +27,24 @@ class UserWorkspaceBloc extends Bloc { (event, emit) async { await event.when( initial: () async { - _listener - ..didUpdateUserWorkspaces = (workspaces) { - add(UserWorkspaceEvent.updateWorkspaces(workspaces)); - } - ..start(); + _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 && + userProfile.userAuthType == AuthTypePB.Server && FeatureFlag.collaborativeWorkspace.isOn; Log.info( 'init workspace, current workspace: ${currentWorkspace?.workspaceId}, ' @@ -45,8 +52,12 @@ class UserWorkspaceBloc extends Bloc { ); if (currentWorkspace != null && result.$3 == true) { Log.info('init open workspace: ${currentWorkspace.workspaceId}'); - await _userService.openWorkspace(currentWorkspace.workspaceId); + await _userService.openWorkspace( + currentWorkspace.workspaceId, + currentWorkspace.workspaceAuthType, + ); } + emit( state.copyWith( currentWorkspace: currentWorkspace, @@ -67,12 +78,26 @@ class UserWorkspaceBloc extends Bloc { emit( state.copyWith( - currentWorkspace: currentWorkspace, 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, + currentWorkspace.workspaceAuthType, + ), + ); + } }, - createWorkspace: (name) async { + createWorkspace: (name, authType) async { emit( state.copyWith( actionResult: const UserWorkspaceActionResult( @@ -82,7 +107,10 @@ class UserWorkspaceBloc extends Bloc { ), ), ); - final result = await _userService.createUserWorkspace(name); + final result = await _userService.createUserWorkspace( + name, + authType, + ); final workspaces = result.fold( (s) => [...state.workspaces, s], (e) => state.workspaces, @@ -101,7 +129,12 @@ class UserWorkspaceBloc extends Bloc { result ..onSuccess((s) { Log.info('create workspace success: $s'); - add(OpenWorkspace(s.workspaceId)); + add( + OpenWorkspace( + s.workspaceId, + s.workspaceAuthType, + ), + ); }) ..onFailure((f) { Log.error('create workspace error: $f'); @@ -143,24 +176,37 @@ class UserWorkspaceBloc extends Bloc { } final result = await _userService.deleteWorkspaceById(workspaceId); - final workspaces = result.fold( - // remove the deleted workspace from the list instead of fetching - // the workspaces again - (s) => state.workspaces - .where((e) => e.workspaceId != workspaceId) - .toList(), - (e) => state.workspaces, + // 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)); + add( + OpenWorkspace( + workspaces.first.workspaceId, + workspaces.first.workspaceAuthType, + ), + ); } }) ..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, + workspaces.first.workspaceAuthType, + ), + ); + } }); emit( state.copyWith( @@ -173,7 +219,7 @@ class UserWorkspaceBloc extends Bloc { ), ); }, - openWorkspace: (workspaceId) async { + openWorkspace: (workspaceId, authType) async { emit( state.copyWith( actionResult: const UserWorkspaceActionResult( @@ -183,7 +229,10 @@ class UserWorkspaceBloc extends Bloc { ), ), ); - final result = await _userService.openWorkspace(workspaceId); + final result = await _userService.openWorkspace( + workspaceId, + authType, + ); final currentWorkspace = result.fold( (s) => state.workspaces.firstWhereOrNull( (e) => e.workspaceId == workspaceId, @@ -254,6 +303,14 @@ class UserWorkspaceBloc extends Bloc { ); }, 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, @@ -309,7 +366,12 @@ class UserWorkspaceBloc extends Bloc { 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)); + add( + OpenWorkspace( + workspaces.first.workspaceId, + workspaces.first.workspaceAuthType, + ), + ); } }) ..onFailure((f) { @@ -337,6 +399,25 @@ class UserWorkspaceBloc extends Bloc { ), ); }, + 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), + ), + ), + ); + }, ); }, ); @@ -360,7 +441,7 @@ class UserWorkspaceBloc extends Bloc { )> _fetchWorkspaces() async { try { final currentWorkspace = - await _userService.getCurrentWorkspace().getOrThrow(); + await UserBackendService.getCurrentWorkspace().getOrThrow(); final workspaces = await _userService.getWorkspaces().getOrThrow(); if (workspaces.isEmpty) { workspaces.add(convertWorkspacePBToUserWorkspace(currentWorkspace)); @@ -394,12 +475,16 @@ class UserWorkspaceBloc extends Bloc { class UserWorkspaceEvent with _$UserWorkspaceEvent { const factory UserWorkspaceEvent.initial() = Initial; const factory UserWorkspaceEvent.fetchWorkspaces() = FetchWorkspaces; - const factory UserWorkspaceEvent.createWorkspace(String name) = - CreateWorkspace; + const factory UserWorkspaceEvent.createWorkspace( + String name, + AuthTypePB authType, + ) = CreateWorkspace; const factory UserWorkspaceEvent.deleteWorkspace(String workspaceId) = DeleteWorkspace; - const factory UserWorkspaceEvent.openWorkspace(String workspaceId) = - OpenWorkspace; + const factory UserWorkspaceEvent.openWorkspace( + String workspaceId, + AuthTypePB authType, + ) = OpenWorkspace; const factory UserWorkspaceEvent.renameWorkspace( String workspaceId, String name, @@ -413,6 +498,9 @@ class UserWorkspaceEvent with _$UserWorkspaceEvent { const factory UserWorkspaceEvent.updateWorkspaces( RepeatedUserWorkspacePB workspaces, ) = UpdateWorkspaces; + const factory UserWorkspaceEvent.updateCurrentWorkspace( + UserWorkspacePB workspace, + ) = UpdateCurrentWorkspace; } enum UserWorkspaceActionType { @@ -436,6 +524,11 @@ class UserWorkspaceActionResult { final UserWorkspaceActionType actionType; final bool isLoading; final FlowyResult? result; + + @override + String toString() { + return 'UserWorkspaceActionResult(actionType: $actionType, isLoading: $isLoading, result: $result)'; + } } @freezed diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart index 1cfb89c8f9..7c2a4d9b64 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart @@ -1,17 +1,23 @@ +import 'dart:async'; 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/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/expand_views.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:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; @@ -19,23 +25,39 @@ import 'package:protobuf/protobuf.dart'; part 'view_bloc.freezed.dart'; class ViewBloc extends Bloc { - ViewBloc({required this.view}) - : viewBackendSvc = ViewBackendService(), + ViewBloc({ + required this.view, + this.shouldLoadChildViews = true, + this.engagedInExpanding = false, + }) : viewBackendSvc = ViewBackendService(), listener = ViewListener(viewId: view.id), favoriteListener = FavoriteListener(), super(ViewState.init(view)) { _dispatch(); + if (engagedInExpanding) { + expander = ViewExpander( + () => state.isExpanded, + () => add(const ViewEvent.setIsExpanded(true)), + ); + getIt().register(view.id, expander); + } } final ViewPB view; final ViewBackendService viewBackendSvc; final ViewListener listener; final FavoriteListener favoriteListener; + final bool shouldLoadChildViews; + final bool engagedInExpanding; + late ViewExpander expander; @override Future close() async { await listener.stop(); await favoriteListener.stop(); + if (engagedInExpanding) { + getIt().unregister(view.id, expander); + } return super.close(); } @@ -74,8 +96,10 @@ class ViewBloc extends Bloc { }, ); final isExpanded = await _getViewIsExpanded(view); - emit(state.copyWith(isExpanded: isExpanded)); - await _loadViewsWhenExpanded(emit, isExpanded); + emit(state.copyWith(isExpanded: isExpanded, view: view)); + if (shouldLoadChildViews) { + await _loadChildViews(emit); + } }, setIsEditing: (e) { emit(state.copyWith(isEditing: e.isEditing)); @@ -123,23 +147,35 @@ class ViewBloc extends Bloc { 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) => state.copyWith( - successOrFailure: FlowyResult.failure(error), - ), + (error) { + Log.error('rename view failed: $error'); + return state.copyWith( + successOrFailure: FlowyResult.failure(error), + ); + }, ), ); }, delete: (e) async { - final result = await ViewBackendService.delete(viewId: view.id); + // 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) => - state.copyWith(successOrFailure: FlowyResult.success(null)), + (l) { + return state.copyWith( + successOrFailure: FlowyResult.success(null), + isDeleted: true, + ); + }, (error) => state.copyWith( successOrFailure: FlowyResult.failure(error), ), @@ -151,7 +187,13 @@ class ViewBloc extends Bloc { ); }, duplicate: (e) async { - final result = await ViewBackendService.duplicate(view: view); + final result = await ViewBackendService.duplicate( + view: view, + openAfterDuplicate: true, + syncAfterDuplicate: true, + includeChildren: true, + suffix: ' (${LocaleKeys.menuAppHeader_pageNameSuffix.tr()})', + ); emit( result.fold( (l) => @@ -172,8 +214,11 @@ class ViewBloc extends Bloc { ); emit( result.fold( - (l) => - state.copyWith(successOrFailure: FlowyResult.success(null)), + (l) { + return state.copyWith( + successOrFailure: FlowyResult.success(null), + ); + }, (error) => state.copyWith( successOrFailure: FlowyResult.failure(error), ), @@ -184,7 +229,6 @@ class ViewBloc extends Bloc { final result = await ViewBackendService.createView( parentViewId: view.id, name: e.name, - desc: '', layoutType: e.layoutType, ext: {}, openAfterCreate: e.openAfterCreated, @@ -218,10 +262,23 @@ class ViewBloc extends Bloc { }, updateIcon: (value) async { await ViewBackendService.updateViewIcon( - viewId: view.id, - viewIcon: value.icon ?? '', + view: view, + 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)); + } + }, ); }, ); @@ -270,6 +327,33 @@ class ViewBloc extends Bloc { ); } + 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; @@ -342,6 +426,20 @@ class ViewBloc extends Bloc { 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); } @@ -359,11 +457,17 @@ class ViewBloc extends Bloc { @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, @@ -371,6 +475,7 @@ class ViewEvent with _$ViewEvent { ViewSectionPB? fromSection, ViewSectionPB? toSection, ) = Move; + const factory ViewEvent.createView( String name, ViewLayoutPB layoutType, { @@ -378,16 +483,25 @@ class ViewEvent with _$ViewEvent { @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 @@ -397,6 +511,7 @@ class ViewState with _$ViewState { required bool isEditing, required bool isExpanded, required FlowyResult successOrFailure, + @Default(false) bool isDeleted, @Default(true) bool isLoading, @Default(null) ViewPB? lastCreatedView, }) = _ViewState; diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index 49ba3cc4c2..fcd991fcf9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -1,21 +1,28 @@ 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_backend/protobuf/flowy-folder/view.pb.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 { @@ -32,17 +39,54 @@ class ViewExtKeys { 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 { - Widget defaultIcon() => FlowySvg( + String get nameOrDefault => + name.isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : name; + + bool get isDocument => pluginType == PluginType.document; + bool get isDatabase => [ + PluginType.grid, + PluginType.board, + PluginType.calendar, + ].contains(pluginType); + + Widget defaultIcon({Size? size}) => FlowySvg( switch (layout) { - ViewLayoutPB.Board => FlowySvgs.board_s, - ViewLayoutPB.Calendar => FlowySvgs.date_s, - ViewLayoutPB.Grid => FlowySvgs.grid_s, - ViewLayoutPB.Document => FlowySvgs.document_s, - _ => FlowySvgs.document_s, + 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) { @@ -50,6 +94,7 @@ extension ViewExtension on ViewPB { ViewLayoutPB.Calendar => PluginType.calendar, ViewLayoutPB.Document => PluginType.document, ViewLayoutPB.Grid => PluginType.grid, + ViewLayoutPB.Chat => PluginType.chat, _ => throw UnimplementedError(), }; @@ -70,12 +115,16 @@ extension ViewExtension on ViewPB { 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; } @@ -96,10 +145,117 @@ extension ViewExtension on ViewPB { 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] ?? {}; @@ -144,10 +300,21 @@ extension ViewExtension on ViewPB { extension ViewLayoutExtension on ViewLayoutPB { FlowySvgData get icon => switch (this) { - ViewLayoutPB.Grid => FlowySvgs.grid_s, - ViewLayoutPB.Board => FlowySvgs.board_s, - ViewLayoutPB.Calendar => FlowySvgs.date_s, - ViewLayoutPB.Document => FlowySvgs.document_s, + 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'), }; @@ -156,9 +323,27 @@ extension ViewLayoutExtension on ViewLayoutPB { ViewLayoutPB.Board || ViewLayoutPB.Calendar => true, - ViewLayoutPB.Document => false, + 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, + ViewLayoutPB.Board => 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 { diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_lock_status_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_lock_status_bloc.dart new file mode 100644 index 0000000000..251131d849 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_lock_status_bloc.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +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/protobuf.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 'package:protobuf/protobuf.dart'; + +part 'view_lock_status_bloc.freezed.dart'; + +class ViewLockStatusBloc + extends Bloc { + ViewLockStatusBloc({ + required this.view, + }) : viewBackendSvc = ViewBackendService(), + listener = ViewListener(viewId: view.id), + super(ViewLockStatusState.init(view)) { + on( + (event, emit) async { + await event.when( + initial: () async { + listener.start( + onViewUpdated: (view) async { + add(ViewLockStatusEvent.updateLockStatus(view.isLocked)); + }, + ); + + final result = await ViewBackendService.getView(view.id); + final latestView = result.fold( + (view) => view, + (_) => view, + ); + emit( + state.copyWith( + view: latestView, + isLocked: latestView.isLocked, + isLoadingLockStatus: false, + ), + ); + }, + lock: () async { + final result = await ViewBackendService.lockView(view.id); + final isLocked = result.fold( + (_) => true, + (_) => false, + ); + add( + ViewLockStatusEvent.updateLockStatus( + isLocked, + ), + ); + }, + unlock: () async { + final result = await ViewBackendService.unlockView(view.id); + final isLocked = result.fold( + (_) => false, + (_) => true, + ); + add( + ViewLockStatusEvent.updateLockStatus( + isLocked, + lockCounter: state.lockCounter + 1, + ), + ); + }, + updateLockStatus: (isLocked, lockCounter) { + state.view.freeze(); + final updatedView = state.view.rebuild( + (update) => update.isLocked = isLocked, + ); + emit( + state.copyWith( + view: updatedView, + isLocked: isLocked, + lockCounter: lockCounter ?? state.lockCounter, + ), + ); + }, + ); + }, + ); + } + + final ViewPB view; + final ViewBackendService viewBackendSvc; + final ViewListener listener; + + @override + Future close() async { + await listener.stop(); + + return super.close(); + } +} + +@freezed +class ViewLockStatusEvent with _$ViewLockStatusEvent { + const factory ViewLockStatusEvent.initial() = Initial; + const factory ViewLockStatusEvent.lock() = Lock; + const factory ViewLockStatusEvent.unlock() = Unlock; + const factory ViewLockStatusEvent.updateLockStatus( + bool isLocked, { + int? lockCounter, + }) = UpdateLockStatus; +} + +@freezed +class ViewLockStatusState with _$ViewLockStatusState { + const factory ViewLockStatusState({ + required ViewPB view, + required bool isLocked, + required int lockCounter, + @Default(true) bool isLoadingLockStatus, + }) = _ViewLockStatusState; + + factory ViewLockStatusState.init(ViewPB view) => ViewLockStatusState( + view: view, + isLocked: false, + lockCounter: 0, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart index b85467d41f..709515f1b3 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart @@ -1,9 +1,15 @@ 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/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/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; class ViewBackendService { static Future> createView({ @@ -15,7 +21,6 @@ class ViewBackendService { /// The [name] is the name of the view. required String name, - String? desc, /// 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 @@ -38,11 +43,11 @@ class ViewBackendService { /// 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 - ..desc = desc ?? "" ..layout = layoutType ..setAsCurrent = openAfterCreate ..initialData = initialDataBytes ?? []; @@ -51,10 +56,6 @@ class ViewBackendService { payload.meta.addAll(ext); } - if (desc != null) { - payload.desc = desc; - } - if (index != null) { payload.index = index; } @@ -63,6 +64,10 @@ class ViewBackendService { payload.section = section; } + if (viewId != null) { + payload.viewId = viewId; + } + return FolderEventCreateView(payload).send(); } @@ -82,7 +87,6 @@ class ViewBackendService { final payload = CreateOrphanViewPayloadPB.create() ..viewId = viewId ..name = name - ..desc = desc ?? "" ..layout = layoutType ..initialData = initialDataBytes ?? []; @@ -117,13 +121,6 @@ class ViewBackendService { }); } - static Future> delete({ - required String viewId, - }) { - final request = RepeatedViewIdPB.create()..items.add(viewId); - return FolderEventDeleteView(request).send(); - } - static Future> deleteView({ required String viewId, }) { @@ -131,10 +128,37 @@ class ViewBackendService { return FolderEventDeleteView(request).send(); } - static Future> duplicate({ - required ViewPB view, + static Future> deleteViews({ + required List viewIds, }) { - return FolderEventDuplicateView(view).send(); + 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({ @@ -168,17 +192,25 @@ class ViewBackendService { } static Future> updateViewIcon({ - required String viewId, - required String viewIcon, - ViewIconTypePB iconType = ViewIconTypePB.Emoji, + required ViewPB view, + required EmojiIconData viewIcon, }) { - final icon = ViewIconPB() - ..ty = iconType - ..value = viewIcon; + final viewId = view.id; + final oldIcon = view.icon.toEmojiIconData(); + final icon = viewIcon.toViewIcon(); final payload = UpdateViewIconPayloadPB.create() ..viewId = viewId ..icon = icon; - + if (oldIcon.type == FlowyIconType.custom && + viewIcon.emoji != oldIcon.emoji) { + DocumentEventDeleteFile( + DeleteFilePB(url: oldIcon.emoji), + ).send().onFailure((e) { + Log.error( + 'updateViewIcon error while deleting :${oldIcon.emoji}, error: ${e.msg}, ${e.code}', + ); + }); + } return FolderEventUpdateViewIcon(payload).send(); } @@ -234,6 +266,33 @@ class ViewBackendService { 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 { @@ -266,4 +325,94 @@ class ViewBackendService { ); 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); + } + + static Future> lockView(String viewId) async { + final payload = ViewIdPB()..value = viewId; + return FolderEventLockView(payload).send(); + } + + static Future> unlockView(String viewId) async { + final payload = ViewIdPB()..value = viewId; + return FolderEventUnlockView(payload).send(); + } } 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 index b24c4f6a9a..27540622ba 100644 --- 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 @@ -1,4 +1,5 @@ import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:bloc/bloc.dart'; @@ -11,7 +12,12 @@ class ViewInfoBloc extends Bloc { on((event, emit) { event.when( started: () { - emit(state.copyWith(createdAt: view.createTime.toDateTime())); + emit( + state.copyWith( + createdAt: view.createTime.toDateTime(), + titleCounters: view.name.getCounter(), + ), + ); }, unregisterEditorState: () { _clearWordCountService(); @@ -36,6 +42,13 @@ class ViewInfoBloc extends Bloc { ), ); }, + titleChanged: (s) { + emit( + state.copyWith( + titleCounters: s.getCounter(), + ), + ); + }, ); }); } @@ -71,17 +84,21 @@ class ViewInfoEvent with _$ViewInfoEvent { }) = _RegisterEditorState; const factory ViewInfoEvent.wordCountChanged() = _WordCountChanged; + + const factory ViewInfoEvent.titleChanged(String title) = _TitleChanged; } @freezed class ViewInfoState with _$ViewInfoState { const factory ViewInfoState({ required Counters? documentCounters, + required Counters? titleCounters, required DateTime? createdAt, }) = _ViewInfoState; factory ViewInfoState.initial() => const ViewInfoState( documentCounters: null, + titleCounters: 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 0000000000..1530c96d32 --- /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 0000000000..fdb9dc9321 --- /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/workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart index d8d5db45b4..ed06f16c8f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart @@ -2,6 +2,7 @@ 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/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -64,7 +65,8 @@ class WorkspaceBloc extends Bloc { String desc, Emitter emit, ) async { - final result = await userService.createWorkspace(name, desc); + final result = + await userService.createUserWorkspace(name, AuthTypePB.Server); emit( result.fold( (workspace) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart index 6e42b744f6..ae6220994e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart @@ -1,36 +1,50 @@ import 'dart:async'; +import 'package:appflowy/shared/af_role_pb_extension.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/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:fixnum/fixnum.dart' as fixnum; class WorkspaceService { - WorkspaceService({required this.workspaceId}); + WorkspaceService({required this.workspaceId, required this.userId}); final String workspaceId; + final fixnum.Int64 userId; Future> createView({ required String name, required ViewSectionPB viewSection, - String? desc, int? index, + ViewLayoutPB? layout, + bool? setAsCurrent, + String? viewId, + String? extra, }) { final payload = CreateViewPayloadPB.create() ..parentViewId = workspaceId ..name = name - // only allow document layout for the top-level views - ..layout = ViewLayoutPB.Document + ..layout = layout ?? ViewLayoutPB.Document ..section = viewSection; - if (desc != null) { - payload.desc = desc; - } - 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(); } @@ -70,4 +84,24 @@ class WorkspaceService { return FolderEventMoveView(payload).send(); } + + Future> getWorkspaceUsage() async { + final request = WorkspaceMemberIdPB()..uid = userId; + final result = await UserEventGetMemberInfo(request).send(); + final isOwner = result.fold( + (member) => member.role.isOwner, + (_) => false, + ); + + if (!isOwner) { + return FlowyResult.success(null); + } + + 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 index 187cbaf544..648712bd15 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart @@ -1,16 +1,14 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - 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: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:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; class CommandPalette extends InheritedWidget { CommandPalette({ @@ -57,7 +55,7 @@ class _CommandPaletteController extends StatefulWidget { } class _CommandPaletteControllerState extends State<_CommandPaletteController> { - late final ValueNotifier _toggleNotifier = widget.notifier; + late ValueNotifier _toggleNotifier = widget.notifier; bool _isOpen = false; @override @@ -72,6 +70,16 @@ class _CommandPaletteControllerState extends State<_CommandPaletteController> { 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; @@ -105,7 +113,7 @@ class _CommandPaletteControllerState extends State<_CommandPaletteController> { }, shortcuts: { LogicalKeySet( - PlatformExtension.isMacOS + UniversalPlatform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.keyP, @@ -126,15 +134,18 @@ class CommandPaletteModal extends StatelessWidget { builder: (context, state) => FlowyDialog( alignment: Alignment.topCenter, insetPadding: const EdgeInsets.only(top: 100), - constraints: const BoxConstraints(maxHeight: 420, maxWidth: 510), + constraints: const BoxConstraints( + maxHeight: 600, + maxWidth: 800, + minHeight: 600, + ), expandHeight: false, child: shortcutBuilder( + // Change mainAxisSize to max so Expanded works correctly. Column( - mainAxisSize: MainAxisSize.min, children: [ - SearchField(query: state.query, isLoading: state.isLoading), - if ((state.query?.isEmpty ?? true) || - state.isLoading && state.results.isEmpty) ...[ + SearchField(query: state.query, isLoading: state.searching), + if (state.query?.isEmpty ?? true) ...[ const Divider(height: 0), Flexible( child: RecentViewsList( @@ -142,19 +153,26 @@ class CommandPaletteModal extends StatelessWidget { ), ), ], - if (state.results.isNotEmpty) ...[ + if (state.combinedResponseItems.isNotEmpty && + (state.query?.isNotEmpty ?? false)) ...[ const Divider(height: 0), Flexible( - child: SearchResultsList( + child: SearchResultList( trash: state.trash, - results: state.results, + resultItems: state.combinedResponseItems.values.toList(), + resultSummaries: state.resultSummaries, ), ), + ] + // When there are no results and the query is not empty and not loading, + // show the no results message, centered in the available space. + else if ((state.query?.isNotEmpty ?? false) && + !state.searching) ...[ + const Divider(height: 0), + Expanded( + child: const _NoResultsHint(), + ), ], - _CommandPaletteFooter( - shouldShow: state.results.isNotEmpty && - (state.query?.isNotEmpty ?? false), - ), ], ), ), @@ -163,35 +181,16 @@ class CommandPaletteModal extends StatelessWidget { } } -class _CommandPaletteFooter extends StatelessWidget { - const _CommandPaletteFooter({required this.shouldShow}); - - final bool shouldShow; +/// Updated _NoResultsHint now centers its content. +class _NoResultsHint extends StatelessWidget { + const _NoResultsHint(); @override Widget build(BuildContext context) { - if (!shouldShow) { - return const SizedBox.shrink(); - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - decoration: BoxDecoration( - 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), - ], + return Center( + child: FlowyText.regular( + LocaleKeys.commandPalette_noResultsHint.tr(), + textAlign: TextAlign.center, ), ); } 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 deleted file mode 100644 index 713fe5bd14..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart +++ /dev/null @@ -1,46 +0,0 @@ -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(4), - FlowyText(view.name), - ], - ), - focusColor: Theme.of(context).colorScheme.primary.withOpacity(0.5), - hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.5), - 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 index 2087d1e476..3bc160ee81 100644 --- 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 @@ -1,13 +1,14 @@ -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/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/workspace/presentation/command_palette/widgets/search_recent_view_cell.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 { @@ -24,7 +25,7 @@ class RecentViewsList extends StatelessWidget { builder: (context, state) { // We remove duplicates by converting the list to a set first final List recentViews = - state.views.reversed.toSet().toList(); + state.views.map((e) => e.item).toSet().toList(); return ListView.separated( shrinkWrap: true, @@ -45,14 +46,14 @@ class RecentViewsList extends StatelessWidget { final view = recentViews[index - 1]; final icon = view.icon.value.isNotEmpty - ? Text( - view.icon.value, - style: const TextStyle(fontSize: 18.0), + ? EmojiIconWidget( + emoji: view.icon.toEmojiIconData(), + emojiSize: 18.0, ) : FlowySvg(view.iconData, size: const Size.square(20)); - return RecentViewTile( - icon: icon, + return SearchRecentViewCell( + icon: SizedBox(width: 24, child: icon), view: view, onSelected: onSelected, ); 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 index d171123e7d..1586ab0a7e 100644 --- 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 @@ -1,4 +1,5 @@ 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'; @@ -6,32 +7,134 @@ import 'package:appflowy/workspace/application/command_palette/command_palette_b 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 StatelessWidget { +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 TextEditingController controller; + + @override + void initState() { + super.initState(); + controller = TextEditingController(text: widget.query); + focusNode = FocusNode(onKeyEvent: _handleKeyEvent); + focusNode.requestFocus(); + // Update the text selection after the first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); + }); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (node.hasFocus && + event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.arrowDown) { + node.nextFocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + void dispose() { + focusNode.dispose(); + controller.dispose(); + super.dispose(); + } + + Widget _buildSuffixIcon(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + final hasText = value.text.trim().isNotEmpty; + final clearIcon = 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), + ), + ); + return AnimatedOpacity( + opacity: hasText ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: hasText + ? FlowyTooltip( + message: LocaleKeys.commandPalette_clearSearchTooltip.tr(), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: _clearSearch, + child: clearIcon, + ), + ), + ) + : clearIcon, + ); + }, + ); + } + @override Widget build(BuildContext context) { + // Cache theme and text styles + final theme = Theme.of(context); + final textStyle = theme.textTheme.bodySmall?.copyWith(fontSize: 14); + final hintStyle = theme.textTheme.bodySmall?.copyWith( + fontSize: 14, + color: theme.hintColor, + ); + + // Choose the leading icon based on loading state + final Widget leadingIcon = widget.isLoading + ? FlowyTooltip( + message: LocaleKeys.commandPalette_loadingTooltip.tr(), + child: const SizedBox( + width: 20, + height: 20, + child: Padding( + padding: EdgeInsets.all(3.0), + child: CircularProgressIndicator(strokeWidth: 2.0), + ), + ), + ) + : SizedBox( + width: 20, + height: 20, + child: FlowySvg( + FlowySvgs.search_m, + color: theme.hintColor, + ), + ); + return Row( children: [ const HSpace(12), - FlowySvg( - FlowySvgs.search_m, - color: Theme.of(context).hintColor, - ), + leadingIcon, Expanded( child: FlowyTextField( - controller: TextEditingController(text: query), - textStyle: - Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 14), + focusNode: focusNode, + controller: controller, + textStyle: textStyle, decoration: InputDecoration( constraints: const BoxConstraints(maxHeight: 48), contentPadding: const EdgeInsets.symmetric(horizontal: 12), @@ -41,42 +144,24 @@ class SearchField extends StatelessWidget { ), 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), - // TODO(Mathias): Remove beta when support document/database search - suffix: FlowyTooltip( - message: LocaleKeys.commandPalette_betaTooltip.tr(), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 5, - vertical: 1, - ), - decoration: BoxDecoration( - color: AFThemeExtension.of(context).lightGreyHover, - borderRadius: BorderRadius.circular(4), - ), - child: FlowyText.semibold( - LocaleKeys.commandPalette_betaLabel.tr(), - fontSize: 10, - ), - ), + hintStyle: hintStyle, + errorStyle: theme.textTheme.bodySmall! + .copyWith(color: theme.colorScheme.error), + suffix: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSuffixIcon(context), + const HSpace(8), + ], ), counterText: "", focusedBorder: const OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent), borderRadius: Corners.s8Border, + borderSide: BorderSide(color: Colors.transparent), ), errorBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.error, - ), borderRadius: Corners.s8Border, + borderSide: BorderSide(color: theme.colorScheme.error), ), ), onChanged: (value) => context @@ -84,19 +169,14 @@ class SearchField extends StatelessWidget { .add(CommandPaletteEvent.searchChanged(search: value)), ), ), - if (isLoading) ...[ - const HSpace(12), - 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_recent_view_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart new file mode 100644 index 0000000000..a803f9b44c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart @@ -0,0 +1,51 @@ +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/view_ext.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'; +import 'package:flutter/material.dart'; + +class SearchRecentViewCell extends StatelessWidget { + const SearchRecentViewCell({ + 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), + Expanded( + child: FlowyText( + view.nameOrDefault, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + focusColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 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/search_result_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart new file mode 100644 index 0000000000..2485da4a69 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart @@ -0,0 +1,235 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.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'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SearchResultCell extends StatefulWidget { + const SearchResultCell({ + super.key, + required this.item, + this.isTrashed = false, + this.isHovered = false, + }); + + final SearchResultItem item; + final bool isTrashed; + final bool isHovered; + + @override + State createState() => _SearchResultCellState(); +} + +class _SearchResultCellState extends State { + bool _hasFocus = false; + final focusNode = FocusNode(); + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + + /// Helper to handle the selection action. + void _handleSelection() { + context.read().add( + SearchResultListEvent.openPage(pageId: widget.item.id), + ); + } + + /// Helper to clean up preview text. + String _cleanPreview(String preview) { + return preview.replaceAll('\n', ' ').trim(); + } + + @override + Widget build(BuildContext context) { + final title = widget.item.displayName.orDefault( + LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + ); + final icon = widget.item.icon.getIcon(); + final cleanedPreview = _cleanPreview(widget.item.content); + final hasPreview = cleanedPreview.isNotEmpty; + final trashHintText = + widget.isTrashed ? LocaleKeys.commandPalette_fromTrashHint.tr() : null; + + // Build the tile content based on preview availability. + Widget tileContent; + if (hasPreview) { + tileContent = 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( + trashHintText!, + color: AFThemeExtension.of(context) + .textColor + .withAlpha(175), + fontSize: 10, + ), + FlowyText( + title, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + const VSpace(4), + _DocumentPreview(preview: cleanedPreview), + ], + ); + } else { + tileContent = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + SizedBox(width: 24, child: icon), + const HSpace(6), + ], + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.isTrashed) + FlowyText( + trashHintText!, + color: + AFThemeExtension.of(context).textColor.withAlpha(175), + fontSize: 10, + ), + FlowyText( + title, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleSelection, + child: Focus( + focusNode: focusNode, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.enter) { + _handleSelection(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + onFocusChange: (hasFocus) { + setState(() { + context.read().add( + SearchResultListEvent.onHoverResult( + item: widget.item, + userHovered: true, + ), + ); + _hasFocus = hasFocus; + }); + }, + child: FlowyHover( + onHover: (value) { + context.read().add( + SearchResultListEvent.onHoverResult( + item: widget.item, + userHovered: true, + ), + ); + }, + isSelected: () => _hasFocus || widget.isHovered, + style: HoverStyle( + borderRadius: BorderRadius.circular(8), + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + foregroundColorOnHover: AFThemeExtension.of(context).textColor, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 30), + child: tileContent, + ), + ), + ), + ), + ); + } +} + +class _DocumentPreview extends StatelessWidget { + const _DocumentPreview({required this.preview}); + + final String preview; + + @override + Widget build(BuildContext context) { + // Combine the horizontal padding for clarity: + return Padding( + padding: const EdgeInsets.fromLTRB(30, 0, 16, 0), + child: FlowyText.regular( + preview, + color: Theme.of(context).hintColor, + fontSize: 12, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ); + } +} + +class SearchResultPreview extends StatelessWidget { + const SearchResultPreview({ + super.key, + required this.data, + }); + + final SearchResultItem data; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Opacity( + opacity: 0.5, + child: FlowyText( + LocaleKeys.commandPalette_pagePreview.tr(), + fontSize: 12, + ), + ), + const VSpace(6), + Expanded( + child: FlowyText( + data.content, + maxLines: 30, + ), + ), + ], + ); + } +} 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 deleted file mode 100644 index a21d1823b3..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.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/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; - -class SearchResultTile extends StatelessWidget { - const SearchResultTile({ - super.key, - required this.result, - required this.onSelected, - this.isTrashed = false, - }); - - final SearchResultPB result; - final VoidCallback onSelected; - final bool isTrashed; - - @override - Widget build(BuildContext context) { - final icon = result.getIcon(); - - return ListTile( - dense: true, - title: Row( - children: [ - if (icon != null) ...[icon, const HSpace(6)], - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isTrashed) ...[ - FlowyText( - LocaleKeys.commandPalette_fromTrashHint.tr(), - color: AFThemeExtension.of(context).textColor.withAlpha(175), - fontSize: 10, - ), - ], - FlowyText(result.data), - ], - ), - ], - ), - focusColor: Theme.of(context).colorScheme.primary.withOpacity(0.5), - hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.5), - onTap: () { - onSelected(); - - getIt().add( - ActionNavigationEvent.performAction( - action: NavigationAction(objectId: result.viewId), - ), - ); - }, - ); - } -} 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 index ed9becf29e..d90888e3e9 100644 --- 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 @@ -1,47 +1,278 @@ +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/command_palette/search_result_list_bloc.dart'; 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'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_animate/flutter_animate.dart'; -class SearchResultsList extends StatelessWidget { - const SearchResultsList({ - super.key, +import 'search_result_cell.dart'; +import 'search_summary_cell.dart'; + +class SearchResultList extends StatefulWidget { + const SearchResultList({ required this.trash, - required this.results, + required this.resultItems, + required this.resultSummaries, + super.key, }); final List trash; - final List results; + final List resultItems; + final List resultSummaries; + + @override + State createState() => _SearchResultListState(); +} + +class _SearchResultListState extends State { + late final SearchResultListBloc bloc; + + @override + void initState() { + super.initState(); + bloc = SearchResultListBloc(); + } + + @override + void dispose() { + bloc.close(); + super.dispose(); + } + + Widget _buildSectionHeader(String title) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8) + + const EdgeInsets.only(left: 8), + child: Opacity( + opacity: 0.6, + child: FlowyText(title, fontSize: 12), + ), + ); + + Widget _buildAIOverviewSection(BuildContext context) { + final state = context.read().state; + + if (state.generatingAIOverview) { + return Row( + children: [ + _buildSectionHeader(LocaleKeys.commandPalette_aiOverview.tr()), + const HSpace(10), + const AIOverviewIndicator(), + ], + ); + } + + if (widget.resultSummaries.isNotEmpty) { + if (!bloc.state.userHovered) { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + bloc.add( + SearchResultListEvent.onHoverSummary( + summary: widget.resultSummaries[0], + userHovered: false, + ), + ); + }, + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(LocaleKeys.commandPalette_aiOverview.tr()), + ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: widget.resultSummaries.length, + separatorBuilder: (_, __) => const Divider(height: 0), + itemBuilder: (_, index) => SearchSummaryCell( + summary: widget.resultSummaries[index], + isHovered: bloc.state.hoveredSummary != null, + ), + ), + ], + ); + } + + return const SizedBox.shrink(); + } + + Widget _buildResultsSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + _buildSectionHeader(LocaleKeys.commandPalette_bestMatches.tr()), + ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: widget.resultItems.length, + separatorBuilder: (_, __) => const Divider(height: 0), + itemBuilder: (_, index) { + final item = widget.resultItems[index]; + return SearchResultCell( + item: item, + isTrashed: widget.trash.any((t) => t.id == item.id), + isHovered: bloc.state.hoveredResult?.id == item.id, + ); + }, + ), + ], + ); + } @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(), - ), - ); - } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: BlocProvider.value( + value: bloc, + child: BlocListener( + listener: (context, state) { + if (state.openPageId != null) { + FlowyOverlay.pop(context); + getIt().add( + ActionNavigationEvent.performAction( + action: NavigationAction(objectId: state.openPageId!), + ), + ); + } + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + flex: 7, + child: BlocBuilder( + buildWhen: (previous, current) => + previous.hoveredResult != current.hoveredResult || + previous.hoveredSummary != current.hoveredSummary, + builder: (context, state) { + return ListView( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + _buildAIOverviewSection(context), + const VSpace(10), + if (widget.resultItems.isNotEmpty) + _buildResultsSection(context), + ], + ); + }, + ), + ), + const HSpace(10), + if (widget.resultItems + .any((item) => item.content.isNotEmpty)) ...[ + const VerticalDivider( + thickness: 1.0, + ), + Flexible( + flex: 3, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 16, + ), + child: const SearchCellPreview(), + ), + ), + ], + ], + ), + ), + ), + ); + } +} - final result = results[index - 1]; - return SearchResultTile( - result: result, - onSelected: () => FlowyOverlay.pop(context), - isTrashed: trash.any((t) => t.id == result.viewId), - ); +class SearchCellPreview extends StatelessWidget { + const SearchCellPreview({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.hoveredSummary != null) { + return SearchSummaryPreview(summary: state.hoveredSummary!); + } else if (state.hoveredResult != null) { + return SearchResultPreview(data: state.hoveredResult!); + } + return const SizedBox.shrink(); }, ); } } + +class AIOverviewIndicator extends StatelessWidget { + const AIOverviewIndicator({ + super.key, + this.duration = const Duration(seconds: 1), + }); + + final Duration duration; + + @override + Widget build(BuildContext context) { + final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5); + return SelectionContainer.disabled( + child: SizedBox( + height: 20, + width: 100, + child: SeparatedRow( + separatorBuilder: () => const HSpace(4), + children: [ + 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/workspace/presentation/command_palette/widgets/search_summary_cell.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart new file mode 100644 index 0000000000..84b8f6646b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart @@ -0,0 +1,137 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.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:flutter/material.dart'; + +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SearchSummaryCell extends StatelessWidget { + const SearchSummaryCell({ + required this.summary, + required this.isHovered, + super.key, + }); + + final SearchSummaryPB summary; + final bool isHovered; + + @override + Widget build(BuildContext context) { + return FlowyHover( + isSelected: () => isHovered, + onHover: (value) { + context.read().add( + SearchResultListEvent.onHoverSummary( + summary: summary, + userHovered: true, + ), + ); + }, + style: HoverStyle( + borderRadius: BorderRadius.circular(8), + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + foregroundColorOnHover: AFThemeExtension.of(context).textColor, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: FlowyText( + summary.content, + maxLines: 20, + ), + ), + ); + } +} + +class SearchSummaryPreview extends StatelessWidget { + const SearchSummaryPreview({ + required this.summary, + super.key, + }); + + final SearchSummaryPB summary; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (summary.highlights.isNotEmpty) ...[ + Opacity( + opacity: 0.5, + child: FlowyText( + LocaleKeys.commandPalette_aiOverviewMoreDetails.tr(), + fontSize: 12, + ), + ), + const VSpace(6), + SearchSummaryHighlight(text: summary.highlights), + const VSpace(36), + ], + + Opacity( + opacity: 0.5, + child: FlowyText( + LocaleKeys.commandPalette_aiOverviewSource.tr(), + fontSize: 12, + ), + ), + // Sources + const VSpace(6), + ...summary.sources.map((e) => SearchSummarySource(source: e)), + ], + ); + } +} + +class SearchSummaryHighlight extends StatelessWidget { + const SearchSummaryHighlight({ + required this.text, + super.key, + }); + + final String text; + + @override + Widget build(BuildContext context) { + return AIMarkdownText(markdown: text); + } +} + +class SearchSummarySource extends StatelessWidget { + const SearchSummarySource({ + required this.source, + super.key, + }); + + final SearchSourcePB source; + + @override + Widget build(BuildContext context) { + final icon = source.icon.getIcon(); + return FlowyTooltip( + message: LocaleKeys.commandPalette_clickToOpenPage.tr(), + child: SizedBox( + height: 30, + child: FlowyButton( + leftIcon: icon, + hoverColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + text: FlowyText(source.displayName), + onTap: () { + context.read().add( + SearchResultListEvent.openPage(pageId: source.id), + ); + }, + ), + ), + ); + } +} 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 0000000000..17d09b6821 --- /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 index efc79f0d59..619ee4e229 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart @@ -1,6 +1,3 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; - import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; @@ -15,6 +12,7 @@ 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'; @@ -26,12 +24,14 @@ 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'; @@ -52,10 +52,11 @@ class DesktopHomeScreen extends StatelessWidget { return _buildLoading(); } - final workspaceSetting = snapshots.data?[0].fold( - (workspaceSettingPB) => workspaceSettingPB as WorkspaceSettingPB, + final workspaceLatest = snapshots.data?[0].fold( + (workspaceLatestPB) => workspaceLatestPB as WorkspaceLatestPB, (error) => null, ); + final userProfile = snapshots.data?[1].fold( (userProfilePB) => userProfilePB as UserProfilePB, (error) => null, @@ -63,33 +64,42 @@ class DesktopHomeScreen extends StatelessWidget { // 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) { + if (workspaceLatest == null || userProfile == null) { return const WorkspaceFailedScreen(); } - return MultiBlocProvider( - key: ValueKey(userProfile.id), - providers: [ - BlocProvider.value(value: getIt()), - BlocProvider.value(value: getIt()), - BlocProvider( - create: (_) => - HomeBloc(workspaceSetting)..add(const HomeEvent.initial()), + Sentry.configureScope( + (scope) => scope.setUser( + SentryUser( + id: userProfile.id.toString(), ), - BlocProvider( - create: (_) => HomeSettingBloc( - workspaceSetting, - context.read(), - context.widthPx, - )..add(const HomeSettingEvent.initial()), - ), - BlocProvider( - create: (context) => - FavoriteBloc()..add(const FavoriteEvent.initial()), - ), - ], - child: HomeHotKeys( - userProfile: userProfile, + ), + ); + + return AFFocusManager( + child: MultiBlocProvider( + key: ValueKey(userProfile.id), + providers: [ + BlocProvider.value( + value: getIt(), + ), + BlocProvider.value(value: getIt()), + BlocProvider( + create: (_) => + HomeBloc(workspaceLatest)..add(const HomeEvent.initial()), + ), + BlocProvider( + create: (_) => HomeSettingBloc( + workspaceLatest, + context.read(), + context.widthPx, + )..add(const HomeSettingEvent.initial()), + ), + BlocProvider( + create: (context) => + FavoriteBloc()..add(const FavoriteEvent.initial()), + ), + ], child: Scaffold( floatingActionButton: enableMemoryLeakDetect ? const FloatingActionButton( @@ -119,12 +129,17 @@ class DesktopHomeScreen extends StatelessWidget { buildWhen: (previous, current) => previous != current, builder: (context, state) => BlocProvider( create: (_) => UserWorkspaceBloc(userProfile: userProfile) - ..add( - const UserWorkspaceEvent.initial(), + ..add(const UserWorkspaceEvent.initial()), + child: HomeHotKeys( + userProfile: userProfile, + child: FlowyContainer( + Theme.of(context).colorScheme.surface, + child: _buildBody( + context, + userProfile, + workspaceLatest, + ), ), - child: FlowyContainer( - Theme.of(context).colorScheme.surface, - child: _buildBody(context, userProfile, workspaceSetting), ), ), ), @@ -142,26 +157,29 @@ class DesktopHomeScreen extends StatelessWidget { Widget _buildBody( BuildContext context, UserProfilePB userProfile, - WorkspaceSettingPB workspaceSetting, + WorkspaceLatestPB workspaceSetting, ) { final layout = HomeLayout(context); final homeStack = HomeStack( layout: layout, delegate: DesktopHomeScreenStackAdaptor(context), + userProfile: userProfile, ); - final menu = _buildHomeSidebar( + final sidebar = _buildHomeSidebar( context, layout: layout, userProfile: userProfile, workspaceSetting: workspaceSetting, ); - final homeMenuResizer = _buildHomeMenuResizer(context, layout: layout); + + final homeMenuResizer = + layout.showMenu ? const SidebarResizer() : const SizedBox.shrink(); final editPanel = _buildEditPanel(context, layout: layout); return _layoutWidgets( layout: layout, homeStack: homeStack, - homeMenu: menu, + sidebar: sidebar, editPanel: editPanel, bubble: const QuestionBubble(), homeMenuResizer: homeMenuResizer, @@ -172,7 +190,7 @@ class DesktopHomeScreen extends StatelessWidget { BuildContext context, { required HomeLayout layout, required UserProfilePB userProfile, - required WorkspaceSettingPB workspaceSetting, + required WorkspaceLatestPB workspaceSetting, }) { final homeMenu = HomeSideBar( userProfile: userProfile, @@ -209,42 +227,9 @@ class DesktopHomeScreen extends StatelessWidget { ); } - Widget _buildHomeMenuResizer( - BuildContext context, { - required HomeLayout layout, - }) { - if (!layout.showMenu) { - return const SizedBox.shrink(); - } - - return MouseRegion( - cursor: SystemMouseCursors.resizeLeftRight, - child: GestureDetector( - dragStartBehavior: DragStartBehavior.down, - onHorizontalDragStart: (details) => context - .read() - .add(const HomeSettingEvent.editPanelResizeStart()), - onHorizontalDragUpdate: (details) => context - .read() - .add(HomeSettingEvent.editPanelResized(details.localPosition.dx)), - onHorizontalDragEnd: (details) => context - .read() - .add(const HomeSettingEvent.editPanelResizeEnd()), - onHorizontalDragCancel: () => context - .read() - .add(const HomeSettingEvent.editPanelResizeEnd()), - behavior: HitTestBehavior.translucent, - child: SizedBox( - width: 10, - height: MediaQuery.of(context).size.height, - ), - ), - ); - } - Widget _layoutWidgets({ required HomeLayout layout, - required Widget homeMenu, + required Widget sidebar, required Widget homeStack, required Widget editPanel, required Widget bubble, @@ -278,7 +263,7 @@ class DesktopHomeScreen extends StatelessWidget { bottom: 0, width: layout.editPanelWidth, ), - homeMenu + sidebar .animatedPanelX( closeX: -layout.menuWidth, isClosed: !layout.showMenu, @@ -287,7 +272,7 @@ class DesktopHomeScreen extends StatelessWidget { ) .positioned(left: 0, top: 0, width: layout.menuWidth, bottom: 0), homeMenuResizer - .positioned(left: layout.menuWidth - 5) + .positioned(left: layout.menuWidth) .animate(layout.animDuration, Curves.easeOutQuad), ], ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart index 1da2ca32fa..98139f1db7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart @@ -1,4 +1,5 @@ import 'dart:io' show Platform; +import 'dart:math'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:flowy_infra/size.dart'; @@ -13,8 +14,11 @@ class HomeLayout { HomeLayout(BuildContext context) { final homeSetting = context.read().state; showEditPanel = homeSetting.panelContext != null; - menuWidth = Sizes.sideBarWidth; - menuWidth += homeSetting.resizeOffset; + + menuWidth = max( + HomeSizes.minimumSidebarWidth + homeSetting.resizeOffset, + HomeSizes.minimumSidebarWidth, + ); final screenWidthPx = context.widthPx; context diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart index fd15cf8d23..18d76057a2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart @@ -1,12 +1,28 @@ class HomeSizes { static const double menuAddButtonHeight = 60; - static const double topBarHeight = 60; + static const double topBarHeight = 44; static const double editPanelTopBarHeight = 60; static const double editPanelWidth = 400; - static const double tabBarHeigth = 40; + 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 topBarTitlePadding = 12; + 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 index 517dede7c5..ae3b92a702 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -1,10 +1,16 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; 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/util/theme_extension.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'; @@ -12,10 +18,16 @@ 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/material.dart'; +import 'package:flutter/scheduler.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'; @@ -25,45 +37,134 @@ abstract class HomeStackDelegate { void didDeleteStackWidget(ViewPB view, int? index); } -class HomeStack extends StatelessWidget { +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) { - final pageController = PageController(); - return BlocProvider.value( value: getIt(), child: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - Padding( - padding: EdgeInsets.only(left: layout.menuSpacing), - child: TabsManager(pageController: pageController), + builder: (context, state) => Column( + children: [ + if (UniversalPlatform.isWindows) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + WindowTitleBar( + leftChildren: [_buildToggleMenuButton(context)], + ), + ], ), - state.currentPageManager.stackTopBar(layout: layout), - Expanded( - child: PageView( - physics: const NeverScrollableScrollPhysics(), - controller: pageController, - children: state.pageManagers - .map( - (pm) => PageStack(pageManager: pm, delegate: delegate), - ) - .toList(), - ), + 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), + ), + ), + ), ), ); } @@ -74,11 +175,12 @@ class PageStack extends StatefulWidget { super.key, required this.pageManager, required this.delegate, + required this.userProfile, }); final PageManager pageManager; - final HomeStackDelegate delegate; + final UserProfilePB userProfile; @override State createState() => _PageStackState(); @@ -94,6 +196,7 @@ class _PageStackState extends State color: Theme.of(context).colorScheme.surface, child: FocusTraversalGroup( child: widget.pageManager.stackWidget( + userProfile: widget.userProfile, onDeleted: (view, index) { widget.delegate.didDeleteStackWidget(view, index); }, @@ -106,6 +209,349 @@ class _PageStackState extends State 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 { + final overlayController = OverlayPortalController(); + final layerLink = LayerLink(); + + late final ValueNotifier widthNotifier; + + late final AnimationController animationController; + late Animation widthAnimation; + late final Animation offsetAnimation; + + late bool hasSecondaryView; + + 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); + + widget.pageManager.secondaryNotifier.addListener(onSecondaryViewChanged); + onSecondaryViewChanged(); + + overlayController.show(); + } + + @override + void dispose() { + widget.pageManager.showSecondaryPluginNotifier + .removeListener(onShowSecondaryChanged); + widget.pageManager.secondaryNotifier.removeListener(onSecondaryViewChanged); + widthNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final isLightMode = Theme.of(context).isLightMode; + return OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return ValueListenableBuilder( + valueListenable: widget.pageManager.showSecondaryPluginNotifier, + builder: (context, isShowing, child) { + return CompositedTransformFollower( + link: layerLink, + followerAnchor: Alignment.topRight, + offset: const Offset(0.0, 120.0), + child: Align( + alignment: AlignmentDirectional.topEnd, + child: AnimatedSwitcher( + duration: 150.milliseconds, + transitionBuilder: (child, animation) { + return NonClippingSizeTransition( + sizeFactor: animation, + axis: Axis.horizontal, + axisAlignment: -1, + child: child, + ); + }, + child: isShowing || !hasSecondaryView + ? const SizedBox.shrink() + : GestureDetector( + onTap: () => widget.pageManager + .showSecondaryPluginNotifier.value = true, + child: Container( + height: 36, + width: 36, + decoration: BoxDecoration( + borderRadius: getBorderRadius(), + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + offset: const Offset(0, 4), + blurRadius: 20, + color: isLightMode + ? const Color(0x1F1F2329) + : Theme.of(context) + .shadowColor + .withValues(alpha: 0.08), + ), + ], + ), + child: FlowyHover( + style: HoverStyle( + borderRadius: getBorderRadius(), + border: getBorder(context), + ), + child: const Center( + child: FlowySvg( + FlowySvgs.rename_s, + size: Size.square(16.0), + ), + ), + ), + ), + ), + ), + ), + ); + }, + ); + }, + child: CompositedTransformTarget( + link: layerLink, + child: 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), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ), + ), + ), + ); + } + + BoxBorder getBorder(BuildContext context) { + final isLightMode = Theme.of(context).isLightMode; + final borderSide = BorderSide( + color: isLightMode + ? const Color(0x141F2329) + : Theme.of(context).dividerColor, + ); + + return Border( + left: borderSide, + top: borderSide, + bottom: borderSide, + ); + } + + BorderRadius getBorderRadius() { + return const BorderRadius.only( + topLeft: Radius.circular(12.0), + bottomLeft: Radius.circular(12.0), + ); + } + + void onSecondaryViewChanged() { + hasSecondaryView = widget.pageManager.secondaryNotifier.plugin.pluginType != + PluginType.blank; + } + + 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 + 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, @@ -146,18 +592,17 @@ class FadingIndexedStackState extends State { return TweenAnimationBuilder( duration: _targetOpacity > 0 ? widget.duration : 0.milliseconds, tween: Tween(begin: 0, end: _targetOpacity), - builder: (_, value, child) { - return Opacity(opacity: value, child: child); - }, + 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); + Widget tabBarItem(String pluginId, [bool shortForm = false]); NavigationCallback get action => (id) => throw UnimplementedError(); } @@ -170,14 +615,20 @@ class PageNotifier extends ChangeNotifier { Widget get titleWidget => _plugin.widgetBuilder.leftBarItem; - Widget tabBarWidget(String pluginId) => - _plugin.widgetBuilder.tabBarItem(pluginId); + 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) { - _plugin.dispose(); - newPlugin.init(); + void setPlugin( + Plugin newPlugin, { + required bool setLatest, + bool disposeExisting = true, + }) { + if (newPlugin.id != plugin.id && disposeExisting) { + _plugin.dispose(); + } // Set the plugin view as the latest view. if (setLatest) { @@ -196,42 +647,70 @@ class PageManager { PageManager(); final PageNotifier _notifier = PageNotifier(); + final PageNotifier _secondaryNotifier = PageNotifier(); PageNotifier get notifier => _notifier; + PageNotifier get secondaryNotifier => _secondaryNotifier; - Widget title() { - return _notifier.plugin.widgetBuilder.leftBarItem; - } + bool isPinned = false; + + final showSecondaryPluginNotifier = ValueNotifier(false); Plugin get plugin => _notifier.plugin; - void setPlugin(Plugin newPlugin, bool setLatest) { - _notifier.setPlugin(newPlugin, setLatest); + void setPlugin(Plugin newPlugin, bool setLatest, [bool init = true]) { + if (init) { + newPlugin.init(); + } + _notifier.setPlugin(newPlugin, setLatest: setLatest); } - void setStackWithId(String id) { - // Navigate to the page with id + void setSecondaryPlugin(Plugin newPlugin) { + newPlugin.init(); + _secondaryNotifier.setPlugin(newPlugin, setLatest: false); + } + + void expandSecondaryPlugin() { + _notifier.setPlugin(_secondaryNotifier.plugin, setLatest: true); + _secondaryNotifier.setPlugin( + BlankPagePlugin(), + setLatest: false, + disposeExisting: false, + ); + } + + void showSecondaryPlugin() { + showSecondaryPluginNotifier.value = true; + } + + void hideSecondaryPlugin() { + showSecondaryPluginNotifier.value = false; } Widget stackTopBar({required HomeLayout layout}) { - return MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: _notifier), - ], + return ChangeNotifierProvider.value( + value: _notifier, child: Selector( selector: (context, notifier) => notifier.titleWidget, - builder: (context, widget, child) { - return MoveWindowDetector(child: HomeTopBar(layout: layout)); - }, + builder: (_, __, child) => MoveWindowDetector( + child: HomeTopBar(layout: layout), + ), ), ); } - Widget stackWidget({required Function(ViewPB, int?) onDeleted}) { - return MultiProvider( - providers: [ChangeNotifierProvider.value(value: _notifier)], - child: Consumer( - builder: (_, PageNotifier notifier, __) { + Widget stackWidget({ + required UserProfilePB userProfile, + required Function(ViewPB, int?) onDeleted, + }) { + return ChangeNotifierProvider.value( + value: _notifier, + child: Consumer( + builder: (_, notifier, __) { + if (notifier.plugin.pluginType == PluginType.blank) { + return const BlankPage(); + } + return FadingIndexedStack( index: getIt().indexOf(notifier.plugin.pluginType), children: getIt().supportPluginTypes.map( @@ -239,11 +718,13 @@ class PageManager { if (pluginType == notifier.plugin.pluginType) { final builder = notifier.plugin.widgetBuilder; final pluginWidget = builder.buildWidget( - context: PluginContext(onDeleted: onDeleted), + context: PluginContext( + onDeleted: onDeleted, + userProfile: userProfile, + ), shrinkWrap: false, ); - // TODO(Xazin): Board should fill up full width return Padding( padding: builder.contentPadding, child: pluginWidget, @@ -259,33 +740,90 @@ class PageManager { ); } + 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 StatelessWidget { +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.onSecondaryContainer, - border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), - ), + color: Theme.of(context).colorScheme.surface, ), - height: HomeSizes.topBarHeight, + height: HomeSizes.topBarHeight + HomeInsets.topBarTitleVerticalPadding, child: Padding( padding: const EdgeInsets.symmetric( - horizontal: HomeInsets.topBarTitlePadding, + horizontal: HomeInsets.topBarTitleHorizontalPadding, + vertical: HomeInsets.topBarTitleVerticalPadding, ), child: Row( children: [ - HSpace(layout.menuSpacing), + HSpace(widget.layout.menuSpacing), const FlowyNavigation(), const HSpace(16), ChangeNotifierProvider.value( @@ -301,4 +839,191 @@ class HomeTopBar extends StatelessWidget { ), ); } + + @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.surfaceContainerHighest, + ), + height: HomeSizes.topBarHeight + HomeInsets.topBarTitleVerticalPadding, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: HomeInsets.topBarTitleHorizontalPadding, + vertical: HomeInsets.topBarTitleVerticalPadding, + ), + child: Row( + children: [ + FlowyIconButton( + width: 24, + tooltipText: LocaleKeys.sideBar_closeSidebar.tr(), + 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, + tooltipText: LocaleKeys.sideBar_expandSidebar.tr(), + 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(), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// A version of Flutter's built in SizeTransition widget that clips the child +/// more sparingly than the original. +class NonClippingSizeTransition extends AnimatedWidget { + const NonClippingSizeTransition({ + super.key, + this.axis = Axis.vertical, + required Animation sizeFactor, + this.axisAlignment = 0.0, + this.fixedCrossAxisSizeFactor, + this.child, + }) : assert( + fixedCrossAxisSizeFactor == null || fixedCrossAxisSizeFactor >= 0.0, + ), + super(listenable: sizeFactor); + + /// [Axis.horizontal] if [sizeFactor] modifies the width, otherwise + /// [Axis.vertical]. + final Axis axis; + + /// The animation that controls the (clipped) size of the child. + /// + /// The width or height (depending on the [axis] value) of this widget will be + /// its intrinsic width or height multiplied by [sizeFactor]'s value at the + /// current point in the animation. + /// + /// If the value of [sizeFactor] is less than one, the child will be clipped + /// in the appropriate axis. + Animation get sizeFactor => listenable as Animation; + + /// Describes how to align the child along the axis that [sizeFactor] is + /// modifying. + /// + /// A value of -1.0 indicates the top when [axis] is [Axis.vertical], and the + /// start when [axis] is [Axis.horizontal]. The start is on the left when the + /// text direction in effect is [TextDirection.ltr] and on the right when it + /// is [TextDirection.rtl]. + /// + /// A value of 1.0 indicates the bottom or end, depending upon the [axis]. + /// + /// A value of 0.0 (the default) indicates the center for either [axis] value. + final double axisAlignment; + + /// The factor by which to multiply the cross axis size of the child. + /// + /// If the value of [fixedCrossAxisSizeFactor] is less than one, the child + /// will be clipped along the appropriate axis. + /// + /// If `null` (the default), the cross axis size is as large as the parent. + final double? fixedCrossAxisSizeFactor; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + @override + Widget build(BuildContext context) { + final AlignmentDirectional alignment; + final Edge edge; + if (axis == Axis.vertical) { + alignment = AlignmentDirectional(-1.0, axisAlignment); + edge = switch (axisAlignment) { -1.0 => Edge.bottom, _ => Edge.top }; + } else { + alignment = AlignmentDirectional(axisAlignment, -1.0); + edge = switch (axisAlignment) { -1.0 => Edge.right, _ => Edge.left }; + } + return ClipRect( + clipper: EdgeRectClipper(edge: edge, margin: 20), + child: Align( + alignment: alignment, + heightFactor: axis == Axis.vertical + ? max(sizeFactor.value, 0.0) + : fixedCrossAxisSizeFactor, + widthFactor: axis == Axis.horizontal + ? max(sizeFactor.value, 0.0) + : fixedCrossAxisSizeFactor, + child: child, + ), + ); + } +} + +class EdgeRectClipper extends CustomClipper { + const EdgeRectClipper({ + required this.edge, + required this.margin, + }); + + final Edge edge; + final double margin; + + @override + Rect getClip(Size size) { + return switch (edge) { + Edge.left => + Rect.fromLTRB(0.0, -margin, size.width + margin, size.height + margin), + Edge.right => + Rect.fromLTRB(-margin, -margin, size.width, size.height + margin), + Edge.top => + Rect.fromLTRB(-margin, 0.0, size.width + margin, size.height + margin), + Edge.bottom => Rect.fromLTRB(-margin, -margin, size.width, size.height), + }; + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; +} + +enum Edge { + left, + top, + right, + bottom; + + bool get isHorizontal => switch (this) { + left || right => true, + _ => false, + }; + + bool get isVertical => !isHorizontal; } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart index 6f120ced1d..af3db13d53 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart @@ -1,19 +1,35 @@ import 'dart:io'; -import 'package:flutter/material.dart'; - 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/sidebar_setting.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. /// @@ -49,14 +65,27 @@ class HomeHotKeys extends StatefulWidget { } class _HomeHotKeysState extends State { + final windowSizeManager = WindowSizeManager(); + late final items = [ - // Collapse sidebar menu + // Collapse sidebar menu (using slash) HotKeyItem( hotKey: HotKey( - Platform.isMacOS ? KeyCode.period : KeyCode.backslash, + KeyCode.backslash, modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], - // Set hotkey scope (default is HotKeyScope.system) - scope: HotKeyScope.inapp, // Set as inapp-wide hotkey. + 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() @@ -118,6 +147,68 @@ class _HomeHotKeysState extends State { 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), ]; @@ -125,14 +216,12 @@ class _HomeHotKeysState extends State { @override void initState() { super.initState(); - _registerHotKeys(context); } @override void didChangeDependencies() { super.didChangeDependencies(); - _registerHotKeys(context); } @@ -149,4 +238,29 @@ class _HomeHotKeysState extends State { 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 index 535c89be4e..8c9d800470 100644 --- 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 @@ -2,7 +2,9 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; class MenuSharedState { - MenuSharedState({ViewPB? view}) { + MenuSharedState({ + ViewPB? view, + }) { _latestOpenView.value = view; } @@ -17,13 +19,8 @@ class MenuSharedState { } } - VoidCallback addLatestViewListener(void Function(ViewPB?) callback) { - void listener() { - callback(_latestOpenView.value); - } - + void addLatestViewListener(VoidCallback listener) { _latestOpenView.addListener(listener); - return listener; } void removeLatestViewListener(VoidCallback 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 0000000000..ca0773bf72 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart @@ -0,0 +1,219 @@ +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/application/view/view_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()), + ), + buildReorderListView(context, state), + if (state.isExpanded) ...[ + // more button + const VSpace(2), + const FavoriteMoreButton(), + ], + ], + ), + ); + }, + ), + ); + } + + Widget buildReorderListView( + BuildContext context, + FolderState state, + ) { + if (!state.isExpanded) return const SizedBox.shrink(); + + final favoriteBloc = context.read(); + final pinnedViews = + favoriteBloc.state.pinnedViews.map((e) => e.item).toList(); + + if (pinnedViews.isEmpty) return const SizedBox.shrink(); + if (pinnedViews.length == 1) { + return buildViewItem(pinnedViews.first); + } + + return Theme( + data: Theme.of(context).copyWith( + canvasColor: Colors.transparent, + shadowColor: Colors.transparent, + ), + child: ReorderableListView.builder( + shrinkWrap: true, + buildDefaultDragHandles: false, + itemCount: pinnedViews.length, + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, i) { + final view = pinnedViews[i]; + return ReorderableDragStartListener( + key: ValueKey(view.id), + index: i, + child: DecoratedBox( + decoration: const BoxDecoration(color: Colors.transparent), + child: buildViewItem(view), + ), + ); + }, + onReorder: (oldIndex, newIndex) { + favoriteBloc.add(FavoriteEvent.reorder(oldIndex, newIndex)); + }, + ), + ); + } + + Widget buildViewItem(ViewPB view) { + return 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) => [ + Listener( + child: FavoriteMoreActions(view: view), + onPointerDown: (e) { + context.read().add(const ViewEvent.setIsEditing(true)); + }, + ), + const HSpace(8.0), + Listener( + child: FavoritePinAction(view: view), + onPointerDown: (e) { + context.read().add(const ViewEvent.setIsEditing(true)); + }, + ), + 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 0000000000..1bf6635037 --- /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 0000000000..443e8a9840 --- /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 0000000000..09b8a44842 --- /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 0000000000..3bd2ffe67f --- /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 0000000000..e4a335e34b --- /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/_favorite_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_favorite_folder.dart deleted file mode 100644 index 364e12644c..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_favorite_folder.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.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/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/theme_extension.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class FavoriteFolder extends StatelessWidget { - const FavoriteFolder({ - super.key, - required this.views, - }); - - final List views; - - @override - Widget build(BuildContext context) { - if (views.isEmpty) { - return const SizedBox.shrink(); - } - - return BlocProvider( - create: (context) => FolderBloc(type: FolderCategoryType.favorite) - ..add( - const FolderEvent.initial(), - ), - child: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - FavoriteHeader( - onPressed: () => context - .read() - .add(const FolderEvent.expandOrUnExpand()), - onAdded: () => context - .read() - .add(const FolderEvent.expandOrUnExpand(isExpanded: true)), - ), - if (state.isExpanded) - ...views.map( - (view) => ViewItem( - key: ValueKey( - '${FolderCategoryType.favorite.name} ${view.id}', - ), - categoryType: FolderCategoryType.favorite, - isDraggable: false, - isFirstChild: view.id == views.first.id, - isFeedback: false, - view: view, - level: 0, - onSelected: (view, _) { - if (HardwareKeyboard.instance.isControlPressed) { - context.read().openTab(view); - } - - context.read().openPlugin(view); - }, - onTertiarySelected: (view, _) => - context.read().openTab(view), - ), - ), - ], - ); - }, - ), - ); - } -} - -class FavoriteHeader extends StatefulWidget { - const FavoriteHeader({ - super.key, - required this.onPressed, - required this.onAdded, - }); - - final VoidCallback onPressed; - final VoidCallback onAdded; - - @override - State createState() => _FavoriteHeaderState(); -} - -class _FavoriteHeaderState extends State { - bool onHover = false; - - @override - Widget build(BuildContext context) { - const iconSize = 26.0; - return MouseRegion( - onEnter: (event) => setState(() => onHover = true), - onExit: (event) => setState(() => onHover = false), - child: Row( - children: [ - FlowyTextButton( - LocaleKeys.sideBar_favorites.tr(), - fontColor: AFThemeExtension.of(context).textColor, - fontHoverColor: Theme.of(context).colorScheme.onSurface, - tooltip: LocaleKeys.sideBar_clickToHideFavorites.tr(), - constraints: const BoxConstraints(maxHeight: iconSize), - padding: const EdgeInsets.all(4), - fillColor: Colors.transparent, - onPressed: widget.onPressed, - ), - ], - ), - ); - } -} 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 index 422003fdd9..d73060d0b3 100644 --- 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 @@ -1,8 +1,8 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:flowy_infra/theme_extension.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({ @@ -12,6 +12,7 @@ class FolderHeader extends StatefulWidget { required this.addButtonTooltip, required this.onPressed, required this.onAdded, + required this.isExpanded, }); final String title; @@ -19,48 +20,59 @@ class FolderHeader extends StatefulWidget { final String addButtonTooltip; final VoidCallback onPressed; final VoidCallback onAdded; + final bool isExpanded; @override State createState() => _FolderHeaderState(); } class _FolderHeaderState extends State { - bool onHover = false; + final isHovered = ValueNotifier(false); + + @override + void dispose() { + isHovered.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - const iconSize = 26.0; - const textPadding = 4.0; - return MouseRegion( - onEnter: (event) => setState(() => onHover = true), - onExit: (event) => setState(() => onHover = false), - child: Row( - children: [ - FlowyTextButton( - widget.title, - tooltip: widget.expandButtonTooltip, - constraints: const BoxConstraints( - minHeight: iconSize + textPadding * 2, - ), - fontColor: AFThemeExtension.of(context).textColor, - fontHoverColor: Theme.of(context).colorScheme.onSurface, - padding: const EdgeInsets.all(textPadding), - fillColor: Colors.transparent, - onPressed: widget.onPressed, - ), - if (onHover) ...[ - const Spacer(), - FlowyIconButton( + 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, - hoverColor: Theme.of(context).colorScheme.secondaryContainer, - iconPadding: const EdgeInsets.all(2), - height: iconSize, - width: iconSize, - icon: const FlowySvg(FlowySvgs.add_s), + 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 index ecd7b5f9de..a8717e28bc 100644 --- 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 @@ -1,22 +1,21 @@ -import 'package:appflowy/generated/locale_keys.g.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/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/sidebar/rename_view_dialog.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 SectionFolder extends StatelessWidget { +class SectionFolder extends StatefulWidget { const SectionFolder({ super.key, required this.title, - required this.categoryType, + required this.spaceType, required this.views, this.isHoverEnabled = true, required this.expandButtonTooltip, @@ -24,101 +23,123 @@ class SectionFolder extends StatelessWidget { }); final String title; - final FolderCategoryType categoryType; + 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 BlocProvider( - create: (context) => FolderBloc(type: categoryType) - ..add( - const FolderEvent.initial(), - ), - child: BlocBuilder( - builder: (context, state) { - return Column( + 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: [ - FolderHeader( - title: title, - expandButtonTooltip: expandButtonTooltip, - addButtonTooltip: addButtonTooltip, - onPressed: () => context - .read() - .add(const FolderEvent.expandOrUnExpand()), - onAdded: () { - createViewAndShowRenameDialogIfNeeded( - context, - LocaleKeys.newPageText.tr(), - (viewName, _) { - if (viewName.isNotEmpty) { - context.read().add( - SidebarSectionsEvent.createRootViewInSection( - name: viewName, - index: 0, - viewSection: categoryType.toViewSectionPB, - ), - ); - - context.read().add( - const FolderEvent.expandOrUnExpand( - isExpanded: true, - ), - ); - } - }, - ); - }, - ), - if (state.isExpanded) - ...views.map( - (view) => ViewItem( - key: ValueKey( - '${categoryType.name} ${view.id}', - ), - categoryType: categoryType, - isFirstChild: view.id == views.first.id, - view: view, - level: 0, - leftPadding: 16, - isFeedback: false, - onSelected: (view, viewContext) { - if (HardwareKeyboard.instance.isControlPressed) { - context.read().openTab(view); - } - - context.read().openPlugin(view); - }, - onTertiarySelected: (view, viewContext) => - context.read().openTab(view), - isHoverEnabled: isHoverEnabled, - ), - ), - if (views.isEmpty) - ViewItem( - categoryType: categoryType, - view: ViewPB( - parentViewId: context - .read() - .state - .currentWorkspace - ?.workspaceId ?? - '', - ), - level: 0, - leftPadding: 16, - isFeedback: false, - onSelected: (_, __) {}, - onTertiarySelected: (_, __) {}, - isHoverEnabled: isHoverEnabled, - isPlaceholder: true, - ), + _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, + engagedInExpanding: true, + 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 0000000000..f8c3a30488 --- /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.com/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 0000000000..cbb969d191 --- /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 0000000000..05e6d46957 --- /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: userProfile, + userWorkspaceBloc: userWorkspaceBloc, + initPage: 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).withValues(alpha: .1), + const Color(0xFFEF35FF).withValues(alpha: .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/footer/sidebar_upgarde_application_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_upgarde_application_button.dart new file mode 100644 index 0000000000..abe3ffd354 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_upgarde_application_button.dart @@ -0,0 +1,110 @@ +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/material.dart'; + +class SidebarUpgradeApplicationButton extends StatelessWidget { + const SidebarUpgradeApplicationButton({ + super.key, + required this.onUpdateButtonTap, + required this.onCloseButtonTap, + }); + + final VoidCallback onUpdateButtonTap; + final VoidCallback onCloseButtonTap; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.sidebarUpgradeButtonBackground, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // title + _buildTitle(), + const VSpace(2), + // description + _buildDescription(), + const VSpace(10), + // update button + _buildUpdateButton(), + ], + ), + ); + } + + Widget _buildTitle() { + return Row( + children: [ + const FlowySvg( + FlowySvgs.sidebar_upgrade_version_s, + blendMode: null, + ), + const HSpace(6), + FlowyText.medium( + LocaleKeys.autoUpdate_bannerUpdateTitle.tr(), + fontSize: 14, + figmaLineHeight: 18, + ), + const Spacer(), + FlowyButton( + useIntrinsicWidth: true, + text: const FlowySvg(FlowySvgs.upgrade_close_s), + onTap: onCloseButtonTap, + ), + ], + ); + } + + Widget _buildDescription() { + return Opacity( + opacity: 0.7, + child: FlowyText( + LocaleKeys.autoUpdate_bannerUpdateDescription.tr(), + fontSize: 13, + figmaLineHeight: 16, + maxLines: null, + ), + ); + } + + Widget _buildUpdateButton() { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onUpdateButtonTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: ShapeDecoration( + color: const Color(0xFFA44AFD), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + ), + child: FlowyText.medium( + LocaleKeys.autoUpdate_settingsUpdateButton.tr(), + color: Colors.white, + fontSize: 12.0, + figmaLineHeight: 15.0, + ), + ), + ), + ); + } +} + +extension on BuildContext { + Color get sidebarUpgradeButtonBackground => Theme.of(this).isLightMode + ? const Color(0xB2EBE4FF) + : const Color(0xB239275B); +} 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 0000000000..67930c336a --- /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.app_logo_with_text_dark_xl + : FlowySvgs.app_logo_with_text_light_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/sidebar_user.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_user.dart similarity index 81% rename from frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_user.dart index dc089e27a2..524934aa82 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_user.dart @@ -1,8 +1,6 @@ -import 'package:flutter/material.dart'; - 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/sidebar_setting.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' @@ -10,6 +8,7 @@ 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: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) @@ -29,15 +28,26 @@ class SidebarUser extends StatelessWidget { 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(4), + const HSpace(8.0), const NotificationButton(), + const HSpace(10.0), ], ), ), @@ -50,6 +60,7 @@ class SidebarUser extends StatelessWidget { name, overflow: TextOverflow.ellipsis, color: Theme.of(context).colorScheme.tertiary, + fontSize: 15.0, ); } 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 index 32a86dbb86..716002e917 100644 --- 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 @@ -1,19 +1,19 @@ 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:appflowy_editor/appflowy_editor.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:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; @@ -67,10 +67,12 @@ class ImportPanel extends StatefulWidget { class _ImportPanelState extends State { final flowyContainerFocusNode = FocusNode(); + final ValueNotifier showLoading = ValueNotifier(false); @override void dispose() { flowyContainerFocusNode.dispose(); + showLoading.dispose(); super.dispose(); } @@ -87,37 +89,52 @@ class _ImportPanelState extends State { FlowyOverlay.pop(context); } }, - child: 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, + 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); + } + }, + ), ), - onTap: () async { - await _importFile(widget.parentViewId, e); - if (context.mounted) { - FlowyOverlay.pop(context); - } - }, - ), - ), - ) - .toList(), - ), + ) + .toList(), + ), + ), + ValueListenableBuilder( + valueListenable: showLoading, + builder: (context, showLoading, child) { + if (!showLoading) { + return const SizedBox.shrink(); + } + return const Center( + child: CircularProgressIndicator(), + ); + }, + ), + ], ), ); } @@ -132,68 +149,84 @@ class _ImportPanelState extends State { return; } + showLoading.value = true; + + final importValues = []; for (final file in result.files) { final path = file.path; if (path == null) { continue; } - final data = await File(path).readAsString(); final name = p.basenameWithoutExtension(path); switch (importType) { - case ImportType.markdownOrText: + 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) { - await ImportBackendService.importData( - bytes, - name, - parentViewId, - ImportTypePB.HistoryDocument, + importValues.add( + ImportItemPayloadPB.create() + ..name = name + ..data = bytes + ..viewLayout = ViewLayoutPB.Document + ..importType = ImportTypePB.Markdown, ); } break; - case ImportType.historyDatabase: - await ImportBackendService.importData( - utf8.encode(data), - name, - parentViewId, - ImportTypePB.HistoryDatabase, + 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.databaseRawData: - await ImportBackendService.importData( - utf8.encode(data), - name, - parentViewId, - ImportTypePB.RawDatabase, + 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; - case ImportType.databaseCSV: - await ImportBackendService.importData( - utf8.encode(data), - name, - parentViewId, - ImportTypePB.CSV, - ); - break; - default: - assert(false, 'Unsupported Type $importType'); } } + 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.markdownOrText: - final document = markdownToDocument(data); - return DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(); 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 index 3728cbee7b..5c7c297327 100644 --- 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 @@ -8,8 +8,8 @@ enum ImportType { historyDocument, historyDatabase, markdownOrText, - databaseCSV, - databaseRawData; + csv, + afDatabase; @override String toString() { @@ -20,9 +20,9 @@ enum ImportType { return LocaleKeys.importPanel_databaseFromV010.tr(); case ImportType.markdownOrText: return LocaleKeys.importPanel_textAndMarkdown.tr(); - case ImportType.databaseCSV: + case ImportType.csv: return LocaleKeys.importPanel_csv.tr(); - case ImportType.databaseRawData: + case ImportType.afDatabase: return LocaleKeys.importPanel_database.tr(); } } @@ -33,8 +33,8 @@ enum ImportType { case ImportType.historyDatabase: svg = FlowySvgs.document_s; case ImportType.historyDocument: - case ImportType.databaseCSV: - case ImportType.databaseRawData: + case ImportType.csv: + case ImportType.afDatabase: svg = FlowySvgs.board_s; case ImportType.markdownOrText: svg = FlowySvgs.text_s; @@ -48,7 +48,9 @@ enum ImportType { bool get enableOnRelease { switch (this) { - case ImportType.databaseRawData: + case ImportType.historyDatabase: + case ImportType.historyDocument: + case ImportType.afDatabase: return kDebugMode; default: return true; @@ -60,11 +62,11 @@ enum ImportType { case ImportType.historyDocument: return ['afdoc']; case ImportType.historyDatabase: - case ImportType.databaseRawData: + case ImportType.afDatabase: return ['afdb']; case ImportType.markdownOrText: return ['md', 'txt']; - case ImportType.databaseCSV: + case ImportType.csv: return ['csv']; } } @@ -72,9 +74,9 @@ enum ImportType { bool get allowMultiSelect { switch (this) { case ImportType.historyDocument: - case ImportType.databaseCSV: - case ImportType.databaseRawData: 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 0000000000..631e20f14a --- /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/rename_view_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart deleted file mode 100644 index bf18df1a98..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart +++ /dev/null @@ -1,35 +0,0 @@ -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:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -/// Creates a new view and shows the rename dialog if needed. -/// -/// If the user has enabled the setting to show the rename dialog when creating a new view, -/// this function will show the rename dialog. -/// -/// Otherwise, it will just create the view with default name. -Future createViewAndShowRenameDialogIfNeeded( - BuildContext context, - String dialogTitle, - void Function(String viewName, BuildContext context) createView, -) async { - final value = await getIt().getWithFormat( - KVKeys.showRenameDialogWhenCreatingNewFile, - (value) => bool.parse(value), - ); - final showRenameDialog = value ?? false; - if (context.mounted && showRenameDialog) { - await NavigatorTextFieldDialog( - title: dialogTitle, - value: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), - autoSelectAllText: true, - onConfirm: createView, - ).show(context); - } else if (context.mounted) { - createView(LocaleKeys.menuAppHeader_defaultNewPageName.tr(), context); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart similarity index 87% rename from frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart index dbbf3f0d0e..c27f259b68 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; @@ -7,11 +5,12 @@ 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/folder/_favorite_folder.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 { @@ -26,20 +25,21 @@ class SidebarFolder extends StatelessWidget { @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 Padding( - padding: const EdgeInsets.only(bottom: 10), - child: FavoriteFolder(views: state.views), + return FavoriteFolder( + views: state.views.map((e) => e.item).toList(), ); }, ), @@ -55,18 +55,18 @@ class SidebarFolder extends StatelessWidget { children: isCollaborativeWorkspace ? [ // public - const VSpace(10), + const VSpace(sectionPadding), PublicSectionFolder(views: state.section.publicViews), // private - const VSpace(10), + const VSpace(sectionPadding), PrivateSectionFolder( views: state.section.privateViews, ), ] : [ // personal - const VSpace(10), + const VSpace(sectionPadding), PersonalSectionFolder( views: state.section.publicViews, ), @@ -74,6 +74,7 @@ class SidebarFolder extends StatelessWidget { ); }, ), + const VSpace(200), ], ); }, @@ -85,7 +86,7 @@ class PrivateSectionFolder extends SectionFolder { PrivateSectionFolder({super.key, required super.views}) : super( title: LocaleKeys.sideBar_private.tr(), - categoryType: FolderCategoryType.private, + spaceType: FolderSpaceType.private, expandButtonTooltip: LocaleKeys.sideBar_clickToHidePrivate.tr(), addButtonTooltip: LocaleKeys.sideBar_addAPageToPrivate.tr(), ); @@ -95,7 +96,7 @@ class PublicSectionFolder extends SectionFolder { PublicSectionFolder({super.key, required super.views}) : super( title: LocaleKeys.sideBar_workspace.tr(), - categoryType: FolderCategoryType.public, + spaceType: FolderSpaceType.public, expandButtonTooltip: LocaleKeys.sideBar_clickToHideWorkspace.tr(), addButtonTooltip: LocaleKeys.sideBar_addAPageToWorkspace.tr(), ); @@ -105,7 +106,7 @@ class PersonalSectionFolder extends SectionFolder { PersonalSectionFolder({super.key, required super.views}) : super( title: LocaleKeys.sideBar_personal.tr(), - categoryType: FolderCategoryType.public, + 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 0000000000..d35c4cd148 --- /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 0000000000..0bd5dafe91 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart @@ -0,0 +1,168 @@ +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/user/application/password/password_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/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: 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; + late PasswordBloc _passwordBloc; + + @override + void initState() { + super.initState(); + + _userWorkspaceBloc = context.read(); + _passwordBloc = PasswordBloc(widget.userProfile) + ..add(PasswordEvent.init()) + ..add(PasswordEvent.checkHasPassword()); + } + + @override + void didChangeDependencies() { + _userWorkspaceBloc = context.read(); + + super.didChangeDependencies(); + } + + @override + void dispose() { + _passwordBloc.close(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 24.0, + child: FlowyTooltip( + message: LocaleKeys.settings_menu_open.tr(), + child: BlocProvider.value( + value: _passwordBloc, + child: FlowyButton( + onTap: () => showSettingsDialog( + context, + userProfile: widget.userProfile, + userWorkspaceBloc: _userWorkspaceBloc, + passwordBloc: _passwordBloc, + ), + margin: EdgeInsets.zero, + text: FlowySvg( + FlowySvgs.settings_s, + color: widget.isHover + ? Theme.of(context).colorScheme.onSurface + : null, + opacity: 0.7, + ), + ), + ), + ), + ); + } +} + +void showSettingsDialog( + BuildContext context, { + required UserProfilePB userProfile, + UserWorkspaceBloc? userWorkspaceBloc, + PasswordBloc? passwordBloc, + SettingsPage? initPage, +}) { + AFFocusManager.maybeOf(context)?.notifyLoseFocus(); + showDialog( + context: context, + builder: (dialogContext) => MultiBlocProvider( + key: _settingsDialogKey, + providers: [ + passwordBloc != null + ? BlocProvider.value( + value: passwordBloc, + ) + : BlocProvider( + create: (context) => PasswordBloc(userProfile) + ..add(PasswordEvent.init()) + ..add(PasswordEvent.checkHasPassword()), + ), + BlocProvider.value( + value: BlocProvider.of(dialogContext), + ), + BlocProvider.value( + value: userWorkspaceBloc ?? 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 index 8d798ef853..9c19184217 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -1,11 +1,15 @@ import 'dart:async'; - -import 'package:flutter/material.dart'; +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_notification.dart'; import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/shared/loading.dart'; +import 'package:appflowy/shared/version_checker/version_checker.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/device_info_task.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'; @@ -13,26 +17,33 @@ 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/menu/sidebar/sidebar_folder.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_trash.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_user.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_workspace.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/footer/sidebar_upgarde_application_button.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/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.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: @@ -49,7 +60,7 @@ class HomeSideBar extends StatelessWidget { final UserProfilePB userProfile; - final WorkspaceSettingPB workspaceSetting; + final WorkspaceLatestPB workspaceSetting; @override Widget build(BuildContext context) { @@ -68,85 +79,140 @@ class HomeSideBar extends StatelessWidget { // +-- Public Or Private Section: control the sections of the workspace // | // +-- Trash Section - return BlocConsumer( - listenWhen: (previous, current) => - previous.currentWorkspace?.workspaceId != - current.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, - ), - ); - } - - // 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(); - } - return MultiBlocProvider( - providers: [ - BlocProvider(create: (_) => getIt()), - BlocProvider( - create: (_) => SidebarSectionsBloc() - ..add( - SidebarSectionsEvent.initial( - userProfile, - state.currentWorkspace?.workspaceId ?? - workspaceSetting.workspaceId, + 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, ), - ), - ), - ], - 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: (_, 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) { - context.read().add( - SidebarSectionsEvent.reload( - userProfile, - state.currentWorkspace?.workspaceId ?? - workspaceSetting.workspaceId, - ), - ); - context.read().add( - const FavoriteEvent.fetchFavorites(), - ); - } - }, + 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: _Sidebar(userProfile: userProfile), - ), - ); - }, + 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), + ), + ); + }, + ), ); } @@ -166,6 +232,11 @@ class HomeSideBar extends StatelessWidget { ); } + 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; @@ -189,7 +260,12 @@ class _Sidebar extends StatefulWidget { class _SidebarState extends State<_Sidebar> { final _scrollController = ScrollController(); Timer? _scrollDebounce; - bool isScrolling = false; + bool _isScrolling = false; + final _isHovered = ValueNotifier(false); + final _scrollOffset = ValueNotifier(0); + + // mute the update button during the current application lifecycle. + final _muteUpdateButton = ValueNotifier(false); @override void initState() { @@ -202,43 +278,118 @@ class _SidebarState extends State<_Sidebar> { _scrollDebounce?.cancel(); _scrollController.removeListener(_onScrollChanged); _scrollController.dispose(); + _scrollOffset.dispose(); + _isHovered.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - const menuHorizontalInset = EdgeInsets.symmetric(horizontal: 12); - final userState = context.read().state; - return DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, - border: Border( - right: BorderSide(color: Theme.of(context).dividerColor), + 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 - const Padding(padding: menuHorizontalInset, child: SidebarTopMenu()), - // user or workspace, setting - Padding( - padding: menuHorizontalInset, - child: + 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 - userState.isCollabWorkspaceOn && userState.workspaces.isNotEmpty + child: state.isCollabWorkspaceOn && state.workspaces.isNotEmpty ? SidebarWorkspace(userProfile: widget.userProfile) : SidebarUser(userProfile: widget.userProfile), - ), - if (FeatureFlag.search.isOn) ...[ - const VSpace(8), - const Padding( - padding: menuHorizontalInset, - child: _SidebarSearchButton(), + ), ), + 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), + _buildUpgradeApplicationButton(menuHorizontalInset), + + const VSpace(8), + Padding( + padding: menuHorizontalInset + + const EdgeInsets.symmetric(horizontal: 4.0), + child: const SidebarFooter(), + ), + const VSpace(14), ], - // scrollable document list - Expanded( + ), + ), + ); + } + + 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( @@ -247,36 +398,96 @@ class _SidebarState extends State<_Sidebar> { physics: const ClampingScrollPhysics(), child: SidebarFolder( userProfile: widget.userProfile, - isHoverEnabled: !isScrolling, + isHoverEnabled: !_isScrolling, ), ), ), - ), - const VSpace(10), - // trash - const Padding( - padding: menuHorizontalInset, - child: SidebarTrashButton(), - ), - const VSpace(10), - // new page button - const SidebarNewPageButton(), - ], - ), + ) + : 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(), + ); + } + + Widget _buildUpgradeApplicationButton(EdgeInsets menuHorizontalInset) { + return ValueListenableBuilder( + valueListenable: _muteUpdateButton, + builder: (_, mute, child) { + if (mute) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: ApplicationInfo.latestVersionNotifier, + builder: (_, latestVersion, child) { + if (!ApplicationInfo.isUpdateAvailable) { + return const SizedBox.shrink(); + } + + return Padding( + padding: menuHorizontalInset + + const EdgeInsets.only( + left: 4.0, + right: 4.0, + ), + child: SidebarUpgradeApplicationButton( + onUpdateButtonTap: () { + versionChecker.checkForUpdate(); + }, + onCloseButtonTap: () { + _muteUpdateButton.value = true; + }, + ), + ); + }, + ); + }, ); } void _onScrollChanged() { - setState(() => isScrolling = true); + setState(() => _isScrolling = true); _scrollDebounce?.cancel(); _scrollDebounce = Timer(const Duration(milliseconds: 300), _setScrollStopped); + + _scrollOffset.value = _scrollController.offset; } void _setScrollStopped() { if (mounted) { - setState(() => isScrolling = false); + setState(() => _isScrolling = false); } } } @@ -286,10 +497,32 @@ class _SidebarSearchButton extends StatelessWidget { @override Widget build(BuildContext context) { - return FlowyButton( - onTap: () => CommandPalette.of(context).toggle(), - leftIcon: const FlowySvg(FlowySvgs.search_s), - text: FlowyText(LocaleKeys.search_label.tr()), + 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: () { + // exit editing mode when doing search to avoid the toolbar showing up + EditorNotification.exitEditing().post(); + 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/sidebar_new_page_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart deleted file mode 100644 index eac80118b4..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart +++ /dev/null @@ -1,70 +0,0 @@ -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/user/user_workspace_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/rename_view_dialog.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/button.dart'; -import 'package:flowy_infra_ui/style_widget/extension.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class SidebarNewPageButton extends StatelessWidget { - const SidebarNewPageButton({ - super.key, - }); - - @override - Widget build(BuildContext context) { - final child = FlowyTextButton( - LocaleKeys.newPageText.tr(), - fillColor: Colors.transparent, - hoverColor: Colors.transparent, - fontColor: Theme.of(context).colorScheme.tertiary, - onPressed: () async => createViewAndShowRenameDialogIfNeeded( - context, - LocaleKeys.newPageText.tr(), - (viewName, _) { - if (viewName.isNotEmpty) { - // if the workspace is collaborative, create the view in the private section by default. - final section = - context.read().state.isCollabWorkspaceOn - ? ViewSectionPB.Private - : ViewSectionPB.Public; - context.read().add( - SidebarSectionsEvent.createRootViewInSection( - name: viewName, - viewSection: section, - ), - ); - } - }, - ), - heading: Container( - width: 16, - height: 16, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.surface, - ), - child: FlowySvg( - FlowySvgs.new_app_s, - color: Theme.of(context).colorScheme.primary, - ), - ), - padding: const EdgeInsets.all(0), - ); - - return SizedBox( - height: 60, - child: TopBorder( - color: Theme.of(context).dividerColor, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18), - child: child, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_setting.dart deleted file mode 100644 index 45698ebabf..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_setting.dart +++ /dev/null @@ -1,91 +0,0 @@ -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_appearance_cubit.dart'; -import 'package:appflowy/startup/startup.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:appflowy_editor/appflowy_editor.dart' hide Log; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hotkey_manager/hotkey_manager.dart'; - -final GlobalKey _settingsDialogKey = GlobalKey(); - -HotKeyItem openSettingsHotKey( - BuildContext context, - UserProfilePB userProfile, -) => - HotKeyItem( - hotKey: HotKey( - KeyCode.comma, - scope: HotKeyScope.inapp, - modifiers: [ - PlatformExtension.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 StatelessWidget { - const UserSettingButton({required this.userProfile, super.key}); - - final UserProfilePB userProfile; - - @override - Widget build(BuildContext context) { - return FlowyTooltip( - message: LocaleKeys.settings_menu_open.tr(), - child: IconButton( - onPressed: () => showSettingsDialog(context, userProfile), - icon: SizedBox.square( - dimension: 20, - child: FlowySvg( - FlowySvgs.settings_m, - color: Theme.of(context).colorScheme.tertiary, - ), - ), - ), - ); - } -} - -void showSettingsDialog(BuildContext context, UserProfilePB userProfile) => - showDialog( - context: context, - builder: (dialogContext) => BlocProvider.value( - key: _settingsDialogKey, - value: BlocProvider.of(dialogContext), - child: SettingsDialog( - userProfile, - 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_top_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart deleted file mode 100644 index e4d5f2fa3e..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart +++ /dev/null @@ -1,87 +0,0 @@ -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/icon_button.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.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, - }); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return SizedBox( - height: HomeSizes.topBarHeight, - 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 FlowySvg( - svgData, - size: const Size(92, 17), - blendMode: null, - ); - } - - Widget _buildCollapseMenuButton(BuildContext context) { - final textSpan = TextSpan( - children: [ - TextSpan( - text: '${LocaleKeys.sideBar_closeSidebar.tr()}\n', - ), - TextSpan( - text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\', - ), - ], - ); - return FlowyTooltip( - richMessage: textSpan, - child: FlowyIconButton( - width: 28, - hoverColor: Colors.transparent, - onPressed: () => context - .read() - .add(const HomeSettingEvent.collapseMenu()), - iconPadding: const EdgeInsets.fromLTRB(4, 4, 4, 4), - icon: const FlowySvg( - FlowySvgs.hide_menu_m, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_trash.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_trash.dart deleted file mode 100644 index b4a6eb344a..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_trash.dart +++ /dev/null @@ -1,63 +0,0 @@ -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/application/tabs/tabs_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.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'; -import 'package:flutter/material.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; - -class SidebarTrashButton extends StatelessWidget { - const SidebarTrashButton({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: getIt().notifier, - builder: (context, value, child) { - return FlowyHover( - style: HoverStyle( - hoverColor: AFThemeExtension.of(context).greySelect, - ), - isSelected: () => getIt().latestOpenView == null, - child: SizedBox( - height: 26, - child: InkWell( - onTap: () { - getIt().latestOpenView = null; - getIt().add( - TabsEvent.openPlugin( - plugin: makePlugin(pluginType: PluginType.trash), - ), - ); - }, - child: _buildTextButton(context), - ), - ), - ); - }, - ); - } - - Widget _buildTextButton(BuildContext context) { - return Row( - children: [ - const HSpace(6), - const FlowySvg( - FlowySvgs.trash_m, - size: Size(16, 16), - ), - const HSpace(6), - FlowyText.medium( - LocaleKeys.trash_text.tr(), - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart deleted file mode 100644 index 115968796c..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart +++ /dev/null @@ -1,216 +0,0 @@ -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/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/home/toast.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:appflowy_popover/appflowy_popover.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; - - @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 Row( - children: [ - Expanded( - child: SidebarSwitchWorkspaceButton( - userProfile: widget.userProfile, - currentWorkspace: currentWorkspace, - ), - ), - UserSettingButton(userProfile: widget.userProfile), - const HSpace(4), - const NotificationButton(), - ], - ); - }, - ); - } - - 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) { - showSnackBarMessage(context, message); - } - } -} - -class SidebarSwitchWorkspaceButton extends StatelessWidget { - const SidebarSwitchWorkspaceButton({ - super.key, - required this.userProfile, - required this.currentWorkspace, - }); - - final UserWorkspacePB currentWorkspace; - final UserProfilePB userProfile; - - @override - Widget build(BuildContext context) { - return AppFlowyPopover( - direction: PopoverDirection.bottomWithCenterAligned, - offset: const Offset(0, 10), - constraints: const BoxConstraints(maxWidth: 260, maxHeight: 600), - 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: userProfile, - currentWorkspace: currentWorkspace, - workspaces: workspaces, - ); - }, - ), - ); - }, - child: FlowyButton( - margin: const EdgeInsets.symmetric(vertical: 8), - text: Row( - children: [ - const HSpace(2.0), - SizedBox.square( - dimension: 30.0, - child: WorkspaceIcon( - workspace: currentWorkspace, - iconSize: 20, - enableEdit: false, - ), - ), - const HSpace(6), - Expanded( - child: FlowyText.medium( - currentWorkspace.name, - overflow: TextOverflow.ellipsis, - withTooltip: true, - ), - ), - const FlowySvg(FlowySvgs.drop_menu_show_m), - ], - ), - ), - ); - } -} 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 0000000000..40f20f098a --- /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 0000000000..e3ce26e835 --- /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 0000000000..eb8c54025d --- /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 0000000000..d06016dfb8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart @@ -0,0 +1,698 @@ +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).withValues(alpha: 0.8); + } + return const Color(0xFFffffff).withValues(alpha: 0.8); + } + + static Color descriptionColor(BuildContext context) { + if (Theme.of(context).isLightMode) { + return const Color(0xFF171717).withValues(alpha: 0.7); + } + return const Color(0xFFffffff).withValues(alpha: 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, + this.showCloseButton = true, + this.enableKeyboardListener = 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; + + /// Show close button. + /// Defaults to true. + /// + final bool showCloseButton; + + /// Enable keyboard listener. + /// Defaults to true. + /// + final bool enableKeyboardListener; + + @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 (widget.enableKeyboardListener) { + 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), + if (widget.showCloseButton) ...[ + 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 0000000000..e4be64d5b9 --- /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 0000000000..cf4a2aa5b1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart @@ -0,0 +1,263 @@ +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: + if (data is SelectedEmojiIconResult) { + if (data.type == FlowyIconType.icon) { + try { + final iconsData = IconsData.fromJson(jsonDecode(data.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 0000000000..f4d910700d --- /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 0000000000..be0eadd8ed --- /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 0000000000..ad9e5e8f0a --- /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 0000000000..82410b387e --- /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 0000000000..10ef94ba01 --- /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 0000000000..4b13062c3e --- /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 0000000000..f8b17c23c1 --- /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 index 39301799d6..44f558fc17 100644 --- 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 @@ -6,7 +6,6 @@ import 'package:appflowy/workspace/presentation/settings/widgets/members/workspa 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:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -16,15 +15,26 @@ enum WorkspaceMoreAction { rename, delete, leave, + divider, } -class WorkspaceMoreActionList extends StatelessWidget { +class WorkspaceMoreActionList extends StatefulWidget { const WorkspaceMoreActionList({ super.key, required this.workspace, + required this.popoverMutex, }); final UserWorkspacePB workspace; + final PopoverMutex popoverMutex; + + @override + State createState() => + _WorkspaceMoreActionListState(); +} + +class _WorkspaceMoreActionListState extends State { + bool isPopoverOpen = false; @override Widget build(BuildContext context) { @@ -32,6 +42,7 @@ class WorkspaceMoreActionList extends StatelessWidget { 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); @@ -40,20 +51,39 @@ class WorkspaceMoreActionList extends StatelessWidget { return const SizedBox.shrink(); } return PopoverActionList<_WorkspaceMoreActionWrapper>( - direction: PopoverDirection.bottomWithCenterAligned, + direction: PopoverDirection.bottomWithLeftAligned, actions: actions - .map((e) => _WorkspaceMoreActionWrapper(e, workspace)) + .map( + (action) => _WorkspaceMoreActionWrapper( + action, + widget.workspace, + () => PopoverContainer.of(context).closeAll(), + ), + ) .toList(), + mutex: widget.popoverMutex, + constraints: const BoxConstraints(minWidth: 220), + animationDuration: Durations.short3, + slideDistance: 2, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + onClosed: () => isPopoverOpen = false, + asBarrier: true, buildChild: (controller) { - return FlowyButton( - margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - useIntrinsicWidth: true, - text: const FlowySvg( - FlowySvgs.three_dots_vertical_s, + 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 (!isPopoverOpen) { + controller.show(); + isPopoverOpen = true; + } + }, ), - onTap: () { - controller.show(); - }, ); }, onSelected: (action, controller) {}, @@ -62,38 +92,69 @@ class WorkspaceMoreActionList extends StatelessWidget { } class _WorkspaceMoreActionWrapper extends CustomActionCell { - _WorkspaceMoreActionWrapper(this.inner, this.workspace); + _WorkspaceMoreActionWrapper( + this.inner, + this.workspace, + this.closeWorkspaceMenu, + ); final WorkspaceMoreAction inner; final UserWorkspacePB workspace; + final VoidCallback closeWorkspaceMenu; @override - Widget buildWithContext(BuildContext context) { - return FlowyButton( - text: FlowyText( + 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, - color: inner == WorkspaceMoreAction.delete + fontSize: 14.0, + figmaLineHeight: 18.0, + color: [WorkspaceMoreAction.delete, WorkspaceMoreAction.leave] + .contains(inner) && + onHover ? Theme.of(context).colorScheme.error : null, ), - margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + margin: const EdgeInsets.all(6), onTap: () async { PopoverContainer.of(context).closeAll(); + closeWorkspaceMenu(); final workspaceBloc = context.read(); switch (inner) { + case WorkspaceMoreAction.divider: + break; case WorkspaceMoreAction.delete: - await NavigatorAlertDialog( - title: LocaleKeys.workspace_deleteWorkspaceHintText.tr(), - confirm: () { + await showConfirmDeletionDialog( + context: context, + name: workspace.name, + description: LocaleKeys.workspace_deleteWorkspaceHintText.tr(), + onConfirm: () { workspaceBloc.add( UserWorkspaceEvent.deleteWorkspace(workspace.workspaceId), ); }, - ).show(context); + ); case WorkspaceMoreAction.rename: await NavigatorTextFieldDialog( - title: LocaleKeys.workspace_create.tr(), + title: LocaleKeys.workspace_renameWorkspace.tr(), value: workspace.name, hintText: '', autoSelectAllText: true, @@ -107,17 +168,17 @@ class _WorkspaceMoreActionWrapper extends CustomActionCell { }, ).show(context); case WorkspaceMoreAction.leave: - await showDialog( + await showConfirmDialog( context: context, - builder: (_) => NavigatorOkCancelDialog( - message: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(), - onOkPressed: () { - workspaceBloc.add( - UserWorkspaceEvent.leaveWorkspace(workspace.workspaceId), - ); - }, - okTitle: LocaleKeys.button_yes.tr(), - ), + title: LocaleKeys.workspace_leaveCurrentWorkspace.tr(), + description: + LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(), + confirmLabel: LocaleKeys.button_yes.tr(), + onConfirm: () { + workspaceBloc.add( + UserWorkspaceEvent.leaveWorkspace(workspace.workspaceId), + ); + }, ); } }, @@ -132,6 +193,27 @@ class _WorkspaceMoreActionWrapper extends CustomActionCell { 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 index ffc5083db8..1f9f4b03b8 100644 --- 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 @@ -1,25 +1,41 @@ -import 'dart:math'; - -import 'package:appflowy/plugins/base/icon/icon_picker.dart'; +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/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:appflowy_popover/appflowy_popover.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 '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.workspace, + 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(); @@ -30,57 +46,85 @@ class _WorkspaceIconState extends State { @override Widget build(BuildContext context) { + final color = ColorGenerator(widget.workspace.name).randomColor(); Widget child = widget.workspace.icon.isNotEmpty - ? Container( - width: widget.iconSize, - alignment: Alignment.center, - child: FlowyText( - widget.workspace.icon, - fontSize: widget.iconSize, - ), + ? FlowyText.emoji( + widget.workspace.icon, + fontSize: widget.emojiSize, + figmaLineHeight: widget.figmaLineHeight, + optimizeEmojiAlign: true, ) - : Container( - alignment: Alignment.center, - width: widget.iconSize, - height: max(widget.iconSize, 26), - decoration: BoxDecoration( - color: ColorGenerator(widget.workspace.name).toColor(), - borderRadius: BorderRadius.circular(4), - ), - child: FlowyText( - widget.workspace.name.isEmpty - ? '' - : widget.workspace.name.substring(0, 1), - fontSize: 16, - color: Colors.black, - ), + : 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 = AppFlowyPopover( + 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(360, 380)), + constraints: BoxConstraints.loose(const Size(364, 356)), clickHandler: PopoverClickHandler.gestureDetector, - popupBuilder: (BuildContext popoverContext) { - return FlowyIconPicker( - onSelected: (result) { - context.read().add( - UserWorkspaceEvent.updateWorkspaceIcon( - widget.workspace.workspaceId, - result.emoji, - ), - ); - controller.close(); - }, - ); - }, + margin: const EdgeInsets.all(0), + popupBuilder: (_) => FlowyIconEmojiPicker( + tabs: const [PickerTabType.emoji], + onSelectedEmoji: (r) { + widget.onSelected(r.data); + if (!r.keepOpen) controller.close(); + }, + ), child: MouseRegion( cursor: SystemMouseCursors.click, child: child, ), ); } - return 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 index 65830eddd7..4ff5ccbf67 100644 --- 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 @@ -1,5 +1,9 @@ +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'; @@ -7,17 +11,22 @@ import 'package:appflowy/workspace/presentation/settings/widgets/members/workspa 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: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/flowy_infra_ui.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'; + +import '_sidebar_import_notion.dart'; @visibleForTesting const createWorkspaceButtonKey = ValueKey('createWorkspaceButton'); -class WorkspacesMenu extends StatelessWidget { +@visibleForTesting +const importNotionButtonKey = ValueKey('importNotinoButton'); + +class WorkspacesMenu extends StatefulWidget { const WorkspacesMenu({ super.key, required this.userProfile, @@ -29,6 +38,13 @@ class WorkspacesMenu extends StatelessWidget { final UserWorkspacePB currentWorkspace; final List workspaces; + @override + State createState() => _WorkspacesMenuState(); +} + +class _WorkspacesMenuState extends State { + final popoverMutex = PopoverMutex(); + @override Widget build(BuildContext context) { return Column( @@ -37,7 +53,7 @@ class WorkspacesMenu extends StatelessWidget { children: [ // user email Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + padding: const EdgeInsets.only(left: 10.0, top: 6.0, right: 10.0), child: Row( children: [ Expanded( @@ -49,93 +65,128 @@ class WorkspacesMenu extends StatelessWidget { ), ), const HSpace(4.0), - FlowyButton( - key: createWorkspaceButtonKey, - useIntrinsicWidth: true, - text: const FlowySvg(FlowySvgs.add_m), - onTap: () { - _showCreateWorkspaceDialog(context); - PopoverContainer.of(context).closeAll(); - }, + WorkspaceMoreButton( + popoverMutex: popoverMutex, ), + const HSpace(8.0), ], ), ), - for (final workspace in workspaces) ...[ - WorkspaceMenuItem( - key: ValueKey(workspace.workspaceId), - workspace: workspace, - userProfile: userProfile, - isSelected: workspace.workspaceId == currentWorkspace.workspaceId, + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 6.0), + child: Divider(height: 1.0), + ), + // workspace list + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + 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, + popoverMutex: popoverMutex, + ), + const VSpace(6.0), + ], + ], + ), + ), + ), + // add new workspace + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6.0), + child: _CreateWorkspaceButton(), + ), + + if (UniversalPlatform.isDesktop) ...[ + const Padding( + padding: EdgeInsets.only(left: 6.0, top: 6.0, right: 6.0), + child: _ImportNotionButton(), ), - const VSpace(4.0), ], + + const VSpace(6.0), ], ); } String _getUserInfo() { - if (userProfile.email.isNotEmpty) { - return userProfile.email; + if (widget.userProfile.email.isNotEmpty) { + return widget.userProfile.email; } - if (userProfile.name.isNotEmpty) { - return userProfile.name; + if (widget.userProfile.name.isNotEmpty) { + return widget.userProfile.name; } return LocaleKeys.defaultUsername.tr(); } - - Future _showCreateWorkspaceDialog(BuildContext context) async { - if (context.mounted) { - final workspaceBloc = context.read(); - await CreateWorkspaceDialog( - onConfirm: (name) { - workspaceBloc.add(UserWorkspaceEvent.createWorkspace(name)); - }, - ).show(context); - } - } } -class WorkspaceMenuItem extends StatelessWidget { +class WorkspaceMenuItem extends StatefulWidget { const WorkspaceMenuItem({ super.key, required this.workspace, required this.userProfile, required this.isSelected, + required this.popoverMutex, }); final UserProfilePB userProfile; final UserWorkspacePB workspace; final bool isSelected; + final PopoverMutex popoverMutex; + + @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: userProfile, workspace: workspace) - ..add(const WorkspaceMemberEvent.initial()), + 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: 52, - child: Stack( - alignment: Alignment.center, - children: [ - _WorkspaceInfo( - isSelected: isSelected, - workspace: workspace, - ), - Positioned(left: 8, child: _buildLeftIcon(context)), - Positioned( - right: 12.0, - child: Align(child: _buildRightIcon(context)), - ), - ], + 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)), + ), + ], + ), ), ); }, @@ -144,32 +195,59 @@ class WorkspaceMenuItem extends StatelessWidget { } Widget _buildLeftIcon(BuildContext context) { - return SizedBox.square( - dimension: 32, - child: FlowyTooltip( - message: LocaleKeys.document_plugins_cover_changeIcon.tr(), - child: WorkspaceIcon( - workspace: workspace, - iconSize: 26, - enableEdit: true, - ), + 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) { - // only the owner can update or delete workspace. - // only show the more action button when the workspace is selected. - if (!isSelected || context.read().state.isLoading) { - return const SizedBox.shrink(); - } - + Widget _buildRightIcon(BuildContext context, ValueNotifier isHovered) { return Row( children: [ - WorkspaceMoreActionList(workspace: workspace), - const FlowySvg( - FlowySvgs.blue_check_s, - ), + // 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, + popoverMutex: widget.popoverMutex, + ), + ), + 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), + ], ], ); } @@ -186,52 +264,55 @@ class _WorkspaceInfo extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final members = state.members; - return FlowyButton( - onTap: () => _openWorkspace(context), - iconPadding: 10.0, - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - leftIconSize: const Size.square(32), - leftIcon: const SizedBox.square(dimension: 32), - rightIcon: const HSpace(42.0), - text: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // workspace name - FlowyText.medium( - workspace.name, - fontSize: 14.0, - overflow: TextOverflow.ellipsis, - withTooltip: true, - ), - // workspace members count - FlowyText( - state.isLoading - ? '' - : LocaleKeys.settings_appearance_members_membersCount - .plural( - members.length, - ), - fontSize: 10.0, - color: Theme.of(context).hintColor, - ), - ], + 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, + workspace.workspaceAuthType, ), ); + PopoverContainer.of(context).closeAll(); } } @@ -256,3 +337,191 @@ class CreateWorkspaceDialog extends StatelessWidget { ); } } + +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).withValues(alpha: 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, + AuthTypePB.Server, + ), + ); + }, + ).show(context); + } + } +} + +class _ImportNotionButton extends StatelessWidget { + const _ImportNotionButton(); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40, + child: 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(), + ), + ], + ), + rightIcon: 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).withValues(alpha: 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'); + } + } +} + +@visibleForTesting +class WorkspaceMoreButton extends StatelessWidget { + const WorkspaceMoreButton({ + super.key, + required this.popoverMutex, + }); + + final PopoverMutex popoverMutex; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 6), + mutex: popoverMutex, + asBarrier: true, + 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 0000000000..50ea9d83c7 --- /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/shared/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( + 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), + margin: EdgeInsets.zero, + 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 index 658d60bfe7..c604fae432 100644 --- 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 @@ -3,10 +3,10 @@ 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:appflowy_editor/appflowy_editor.dart' hide Log; 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, @@ -15,6 +15,8 @@ enum DraggableHoverPosition { bottom, } +const kDraggableViewItemDividerHeight = 2.0; + class DraggableViewItem extends StatefulWidget { const DraggableViewItem({ super.key, @@ -45,15 +47,14 @@ class DraggableViewItem extends StatefulWidget { class _DraggableViewItemState extends State { DraggableHoverPosition position = DraggableHoverPosition.none; - - final _dividerHeight = 2.0; + 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 = PlatformExtension.isMobile + final child = UniversalPlatform.isMobile ? _buildMobileDraggableItem() : _buildDesktopDraggableItem(); @@ -64,6 +65,11 @@ class _DraggableViewItemState extends State { 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; @@ -75,13 +81,8 @@ class _DraggableViewItemState extends State { ), onAcceptWithDetails: (details) { final data = details.data; - _move( - data, - widget.view, - ); - _updatePosition( - DraggableHoverPosition.none, - ); + _move(data, widget.view); + _updatePosition(DraggableHoverPosition.none); }, feedback: IntrinsicWidth( child: Opacity( @@ -100,11 +101,10 @@ class _DraggableViewItemState extends State { // only show the top border when the draggable item is the first child if (widget.isFirstChild) Divider( - height: _dividerHeight, - thickness: _dividerHeight, + height: kDraggableViewItemDividerHeight, + thickness: kDraggableViewItemDividerHeight, color: position == DraggableHoverPosition.top - ? widget.topHighlightColor ?? - Theme.of(context).colorScheme.secondary + ? widget.topHighlightColor ?? hoverColor : Colors.transparent, ), DecoratedBox( @@ -112,17 +112,16 @@ class _DraggableViewItemState extends State { borderRadius: BorderRadius.circular(6.0), color: position == DraggableHoverPosition.center ? widget.centerHighlightColor ?? - Theme.of(context).colorScheme.secondary.withOpacity(0.5) + hoverColor.withValues(alpha: 0.5) : Colors.transparent, ), child: widget.child, ), Divider( - height: _dividerHeight, - thickness: _dividerHeight, + height: kDraggableViewItemDividerHeight, + thickness: kDraggableViewItemDividerHeight, color: position == DraggableHoverPosition.bottom - ? widget.bottomHighlightColor ?? - Theme.of(context).colorScheme.secondary + ? widget.bottomHighlightColor ?? hoverColor : Colors.transparent, ), ], @@ -137,10 +136,10 @@ class _DraggableViewItemState extends State { top: 0, left: 0, right: 0, - height: _dividerHeight, + height: kDraggableViewItemDividerHeight, child: Divider( - height: _dividerHeight, - thickness: _dividerHeight, + height: kDraggableViewItemDividerHeight, + thickness: kDraggableViewItemDividerHeight, color: position == DraggableHoverPosition.top ? widget.topHighlightColor ?? Theme.of(context).colorScheme.secondary @@ -152,7 +151,10 @@ class _DraggableViewItemState extends State { borderRadius: BorderRadius.circular(4.0), color: position == DraggableHoverPosition.center ? widget.centerHighlightColor ?? - Theme.of(context).colorScheme.secondary.withOpacity(0.5) + Theme.of(context) + .colorScheme + .secondary + .withValues(alpha: 0.5) : Colors.transparent, ), child: widget.child, @@ -161,10 +163,10 @@ class _DraggableViewItemState extends State { bottom: 0, left: 0, right: 0, - height: _dividerHeight, + height: kDraggableViewItemDividerHeight, child: Divider( - height: _dividerHeight, - thickness: _dividerHeight, + height: kDraggableViewItemDividerHeight, + thickness: kDraggableViewItemDividerHeight, color: position == DraggableHoverPosition.bottom ? widget.bottomHighlightColor ?? Theme.of(context).colorScheme.secondary @@ -176,12 +178,10 @@ class _DraggableViewItemState extends State { } void _updatePosition(DraggableHoverPosition position) { - if (PlatformExtension.isMobile && position != this.position) { + if (UniversalPlatform.isMobile && position != this.position) { HapticFeedback.mediumImpact(); } - setState( - () => this.position = position, - ); + setState(() => this.position = position); } void _move(ViewPB from, ViewPB to) { 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 index 9fda07d7d2..d4f91b67d9 100644 --- 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 @@ -10,8 +10,21 @@ enum ViewMoreActionType { duplicate, copyLink, // not supported yet. rename, - moveTo, // not supported yet. + moveTo, openInNewTab, + changeIcon, + collapseAllPages, // including sub pages + divider, + lastModified, + created, + lockPage; + + static const disableInLockedView = [ + delete, + rename, + moveTo, + changeIcon, + ]; } extension ViewMoreActionTypeExtension on ViewMoreActionType { @@ -33,27 +46,66 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType { 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.lockPage: + return LocaleKeys.disclosureAction_lockPage.tr(); + case ViewMoreActionType.divider: + case ViewMoreActionType.lastModified: + case ViewMoreActionType.created: + return ''; } } - Widget icon(Color iconColor) { + FlowySvgData get leftIconSvg { switch (this) { case ViewMoreActionType.delete: - return const FlowySvg(FlowySvgs.delete_s); + return FlowySvgs.trash_s; case ViewMoreActionType.favorite: - return const FlowySvg(FlowySvgs.unfavorite_s); + return FlowySvgs.favorite_s; case ViewMoreActionType.unFavorite: - return const FlowySvg(FlowySvgs.favorite_s); + return FlowySvgs.unfavorite_s; case ViewMoreActionType.duplicate: - return const FlowySvg(FlowySvgs.copy_s); - case ViewMoreActionType.copyLink: - return const Icon(Icons.copy); + return FlowySvgs.duplicate_s; case ViewMoreActionType.rename: - return const FlowySvg(FlowySvgs.edit_s); + return FlowySvgs.view_item_rename_s; case ViewMoreActionType.moveTo: - return const Icon(Icons.move_to_inbox); + return FlowySvgs.move_to_s; case ViewMoreActionType.openInNewTab: - return const FlowySvg(FlowySvgs.full_view_s); + 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.lockPage: + return FlowySvgs.lock_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: + case ViewMoreActionType.lockPage: + 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 index e020751e0b..d0b99e2a5d 100644 --- 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 @@ -1,15 +1,13 @@ 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:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.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:easy_localization/easy_localization.dart'; class ViewAddButton extends StatelessWidget { const ViewAddButton({ @@ -17,6 +15,7 @@ class ViewAddButton extends StatelessWidget { required this.parentViewId, required this.onEditing, required this.onSelected, + this.isHovered = false, }); final String parentViewId; @@ -28,6 +27,7 @@ class ViewAddButton extends StatelessWidget { bool openAfterCreated, bool createNewView, ) onSelected; + final bool isHovered; List get _actions { return [ @@ -52,12 +52,16 @@ class ViewAddButton extends StatelessWidget { direction: PopoverDirection.bottomWithLeftAligned, actions: _actions, offset: const Offset(0, 8), + constraints: const BoxConstraints( + minWidth: 200, + ), buildChild: (popover) { return FlowyIconButton( - hoverColor: Colors.transparent, - iconPadding: const EdgeInsets.all(2), - width: 26, - icon: const FlowySvg(FlowySvgs.add_s), + width: 24, + icon: FlowySvg( + FlowySvgs.view_item_add_s, + color: isHovered ? Theme.of(context).colorScheme.onSurface : null, + ), onPressed: () { onEditing(true); popover.show(); @@ -108,7 +112,10 @@ class ViewAddButtonActionWrapper extends ActionCell { final PluginBuilder pluginBuilder; @override - Widget? leftIcon(Color iconColor) => FlowySvg(pluginBuilder.icon); + Widget? leftIcon(Color iconColor) => FlowySvg( + pluginBuilder.icon, + size: const Size.square(16), + ); @override String get name => pluginBuilder.menuName; @@ -124,7 +131,7 @@ class ViewImportActionWrapper extends ActionCell { final DocumentPluginBuilder pluginBuilder; @override - Widget? leftIcon(Color iconColor) => const FlowySvg(FlowySvgs.import_s); + 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 index 057b3d8f99..22182f7429 100644 --- 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 @@ -1,41 +1,57 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; -import 'package:appflowy/plugins/base/icon/icon_picker.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/tab.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/sidebar/rename_view_dialog.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/more_view_actions/widgets/lock_page_action.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_popover/appflowy_popover.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/flowy_tooltip.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -typedef ViewItemOnSelected = void Function(ViewPB, BuildContext); +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.categoryType, + required this.spaceType, required this.level, this.leftPadding = 10, required this.onSelected, @@ -43,15 +59,26 @@ class ViewItem extends StatelessWidget { this.isFirstChild = false, this.isDraggable = true, required this.isFeedback, - this.height = 28.0, - this.isHoverEnabled = true, + 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.engagedInExpanding = false, + this.enableRightClickContext = false, }); final ViewPB view; final ViewPB? parentView; - final FolderCategoryType categoryType; + final FolderSpaceType spaceType; // indicate the level of the view item // used to calculate the left padding @@ -85,10 +112,44 @@ class ViewItem extends StatelessWidget { // 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; + + /// to record the ViewBlock which is expanded or collapsed + final bool engagedInExpanding; + @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), + create: (_) => ViewBloc( + view: view, + shouldLoadChildViews: shouldLoadChildViews, + engagedInExpanding: engagedInExpanding, + )..add(const ViewEvent.initial()), child: BlocConsumer( listenWhen: (p, c) => c.lastCreatedView != null && @@ -96,15 +157,25 @@ class ViewItem extends StatelessWidget { listener: (context, state) => context.read().openPlugin(state.lastCreatedView!), builder: (context, state) { - return InnerViewItem( + // 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: state.view.childViews, - categoryType: categoryType, + childViews: childViews, + spaceType: spaceType, level: level, leftPadding: leftPadding, showActions: state.isEditing, + enableRightClickContext: enableRightClickContext, isExpanded: state.isExpanded, + disableSelectedStatus: disableSelectedStatus, onSelected: onSelected, onTertiarySelected: onTertiarySelected, isFirstChild: isFirstChild, @@ -113,27 +184,52 @@ class ViewItem extends StatelessWidget { height: height, isHoverEnabled: isHoverEnabled, isPlaceholder: isPlaceholder, + isHovered: isHovered, + shouldRenderChildren: shouldRenderChildren, + leftIconBuilder: leftIconBuilder, + rightIconsBuilder: rightIconsBuilder, + isExpandedNotifier: isExpandedNotifier, + extendBuilder: extendBuilder, + shouldIgnoreView: shouldIgnoreView, + engagedInExpanding: engagedInExpanding, ); + + 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 StatelessWidget { +class InnerViewItem extends StatefulWidget { const InnerViewItem({ super.key, required this.view, required this.parentView, required this.childViews, - required this.categoryType, + 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, @@ -141,16 +237,26 @@ class InnerViewItem extends StatelessWidget { 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, + this.engagedInExpanding = false, + required this.shouldIgnoreView, }); final ViewPB view; final ViewPB? parentView; final List childViews; - final FolderCategoryType categoryType; + 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; @@ -158,112 +264,157 @@ class InnerViewItem extends StatelessWidget { 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; + final bool engagedInExpanding; + + @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 = SingleInnerViewItem( - view: view, - parentView: parentView, - level: level, - showActions: showActions, - categoryType: categoryType, - onSelected: onSelected, - onTertiarySelected: onTertiarySelected, - isExpanded: isExpanded, - isDraggable: isDraggable, - leftPadding: leftPadding, - isFeedback: isFeedback, - height: height, - isPlaceholder: isPlaceholder, + 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 (isExpanded) { - if (childViews.isNotEmpty) { - final children = childViews.map((childView) { - return ViewItem( - key: ValueKey('${categoryType.name} ${childView.id}'), - parentView: view, - categoryType: categoryType, - isFirstChild: childView.id == childViews.first.id, - view: childView, - level: level + 1, - onSelected: onSelected, - onTertiarySelected: onTertiarySelected, - isDraggable: isDraggable, - leftPadding: leftPadding, - isFeedback: isFeedback, - isPlaceholder: isPlaceholder, - ); - }).toList(); + 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, + engagedInExpanding: widget.engagedInExpanding, + ); + }).toList(); - child = Column( - mainAxisSize: MainAxisSize.min, - children: [ - child, - ...children, - ], - ); - } else { - child = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - child, - Container( - height: height, - alignment: Alignment.centerLeft, - child: Padding( - // add 2px to make the text align with the view item - padding: EdgeInsets.only(left: (level + 1) * leftPadding + 2), - child: FlowyText.medium( - LocaleKeys.noPagesInside.tr(), - color: Theme.of(context).hintColor, - ), - ), - ), - ], - ); - } + child = Column( + mainAxisSize: MainAxisSize.min, + children: [child, ...children], + ); } // wrap the child with DraggableItem if isDraggable is true - if ((isDraggable || isPlaceholder) && - !isReferencedDatabaseView(view, parentView)) { + if ((widget.isDraggable || widget.isPlaceholder) && + !isReferencedDatabaseView(widget.view, widget.parentView)) { child = DraggableViewItem( - isFirstChild: isFirstChild, - view: view, - onDragging: (isDragging) { - _isDragging = isDragging; - }, - onMove: isPlaceholder - ? (from, to) => _moveViewCrossSection(context, from, to) + 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) { - return ViewItem( - view: view, - parentView: parentView, - categoryType: categoryType, - level: level, - onSelected: onSelected, - onTertiarySelected: onTertiarySelected, + 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: leftPadding, + 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: 2.0), + padding: const EdgeInsets.only(top: kDraggableViewItemDividerHeight), child: child, ); } @@ -271,35 +422,10 @@ class InnerViewItem extends StatelessWidget { return child; } - void _moveViewCrossSection( - BuildContext context, - ViewPB from, - ViewPB to, - ) { - if (isReferencedDatabaseView(view, parentView)) { - return; + void _collapseAllPages() { + if (widget.isExpandedNotifier?.value == true) { + context.read().add(const ViewEvent.collapseAllPages()); } - final fromSection = categoryType == FolderCategoryType.public - ? ViewSectionPB.Private - : ViewSectionPB.Public; - final toSection = categoryType == FolderCategoryType.public - ? ViewSectionPB.Public - : ViewSectionPB.Private; - context.read().add( - ViewEvent.move( - from, - to.parentViewId, - null, - fromSection, - toSection, - ), - ); - context.read().add( - ViewEvent.updateViewVisibility( - from, - categoryType == FolderCategoryType.public, - ), - ); } } @@ -312,19 +438,28 @@ class SingleInnerViewItem extends StatefulWidget { required this.level, required this.leftPadding, this.isDraggable = true, - required this.categoryType, + 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; @@ -333,13 +468,22 @@ class SingleInnerViewItem extends StatefulWidget { final bool isDraggable; final bool showActions; + final bool enableRightClickContext; final ViewItemOnSelected onSelected; final ViewItemOnSelected? onTertiarySelected; - final FolderCategoryType categoryType; + 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(); @@ -347,18 +491,20 @@ class SingleInnerViewItem extends StatefulWidget { class _SingleInnerViewItemState extends State { final controller = PopoverController(); + final viewMoreActionController = PopoverController(); + bool isIconPickerOpened = false; @override Widget build(BuildContext context) { - final isSelected = - getIt().latestOpenView?.id == widget.view.id; + bool isSelected = widget.isSelected; + + if (widget.disableSelectedStatus == true) { + isSelected = false; + } if (widget.isPlaceholder) { - return const SizedBox( - height: 4, - width: double.infinity, - ); + return const SizedBox(height: 4, width: double.infinity); } if (widget.isFeedback || !widget.isHoverEnabled) { @@ -369,55 +515,94 @@ class _SingleInnerViewItemState extends State { } return FlowyHover( - style: HoverStyle( - hoverColor: Theme.of(context).colorScheme.secondary, - ), + style: HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary), resetHoverOnRebuild: widget.showActions || !isIconPickerOpened, buildWhenOnHover: () => !widget.showActions && !_isDragging && !isIconPickerOpened, - builder: (_, onHover) => _buildViewItem(onHover, isSelected), 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 = [ - // expand icon - _buildLeftIcon(), + const HSpace(2), + // expand icon or placeholder + widget.leftIconBuilder?.call(context, widget.view) ?? _buildLeftIcon(), + const HSpace(2), // icon _buildViewIconButton(), - const HSpace(5), + const HSpace(6), // title Expanded( - child: FlowyText.regular( - widget.view.name, - overflow: TextOverflow.ellipsis, - ), + child: widget.extendBuilder != null + ? Row( + children: [ + Flexible(child: name), + ...widget.extendBuilder!(widget.view), + ], + ) + : name, ), ]; // hover action if (widget.showActions || onHover) { - // ··· more action button - children.add(_buildViewMoreActionButton(context)); - // only support add button for document layout - if (widget.view.layout == ViewLayoutPB.Document) { - // + button - children.add(_buildViewAddButton(context)); + 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(widget.view, context), + onTap: () => widget.onSelected(context, widget.view), onTertiaryTapDown: (_) => - widget.onTertiarySelected?.call(widget.view, context), + widget.onTertiarySelected?.call(context, widget.view), child: SizedBox( height: widget.height, child: Padding( padding: EdgeInsets.only(left: widget.level * widget.leftPadding), - child: Row( - children: children, + 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), ), ), ), @@ -431,9 +616,9 @@ class _SingleInnerViewItemState extends State { offset: const Offset(0, 5), direction: PopoverDirection.bottomWithLeftAligned, popupBuilder: (_) => RenameViewPopover( - viewId: widget.view.id, + view: widget.view, name: widget.view.name, - emoji: widget.view.icon.value, + emoji: widget.view.icon.toEmojiIconData(), popoverController: popoverController, showIconChanger: false, ), @@ -445,118 +630,131 @@ class _SingleInnerViewItemState extends State { } Widget _buildViewIconButton() { - final icon = widget.view.icon.value.isNotEmpty - ? EmojiText( - emoji: widget.view.icon.value, - fontSize: 18.0, + final iconData = widget.view.icon.toEmojiIconData(); + final icon = iconData.isNotEmpty + ? RawEmojiIconWidget( + emoji: iconData, + emojiSize: 16.0, + lineHeight: 18.0 / 16.0, ) - : SizedBox.square( - dimension: 20.0, - child: widget.view.defaultIcon(), - ); - return AppFlowyPopover( + : Opacity(opacity: 0.6, child: widget.view.defaultIcon()); + + final Widget child = AppFlowyPopover( offset: const Offset(20, 0), controller: controller, direction: PopoverDirection.rightWithCenterAligned, - constraints: BoxConstraints.loose(const Size(360, 380)), + 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: icon, + child: SizedBox(width: 16.0, child: icon), ), ), popupBuilder: (context) { isIconPickerOpened = true; - return FlowyIconPicker( - onSelected: (result) { + return FlowyIconEmojiPicker( + initialType: iconData.type.toPickerTabType(), + tabs: const [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ], + documentId: widget.view.id, + onSelectedEmoji: (r) { ViewBackendService.updateViewIcon( - viewId: widget.view.id, - viewIcon: result.emoji, - iconType: result.type.toProto(), + view: widget.view, + viewIcon: r.data, ); - controller.close(); + if (!r.keepOpen) controller.close(); }, ); }, ); + + if (widget.view.isLocked) { + return LockPageButtonWrapper( + child: child, + ); + } + + return child; } // > button or · button // show > if the view is expandable. // show · if the view can't contain child views. Widget _buildLeftIcon() { - if (isReferencedDatabaseView(widget.view, widget.parentView)) { - return const _DotIconWidget(); - } - - final svg = widget.isExpanded - ? FlowySvgs.drop_menu_show_m - : FlowySvgs.drop_menu_hide_m; - return GestureDetector( - child: FlowySvg( - svg, - size: const Size.square(16.0), - ), - onTap: () => context - .read() - .add(ViewEvent.setIsExpanded(!widget.isExpanded)), + return ViewItemDefaultLeftIcon( + view: widget.view, + parentView: widget.parentView, + isExpanded: widget.isExpanded, + leftPadding: widget.leftPadding, + isHovered: widget.isHovered, ); } // + button Widget _buildViewAddButton(BuildContext context) { - final viewBloc = context.read(); return FlowyTooltip( message: LocaleKeys.menuAppHeader_addPageTooltip.tr(), child: ViewAddButton( parentViewId: widget.view.id, onEditing: (value) => context.read().add(ViewEvent.setIsEditing(value)), - onSelected: ( - pluginBuilder, - name, - initialDataBytes, - openAfterCreated, - createNewView, - ) { - if (createNewView) { - createViewAndShowRenameDialogIfNeeded( - context, - _convertLayoutToHintText(pluginBuilder.layoutType!), - (viewName, _) { - if (viewName.isNotEmpty) { - viewBloc.add( - ViewEvent.createView( - viewName, - pluginBuilder.layoutType!, - openAfterCreated: openAfterCreated, - section: widget.categoryType.toViewSectionPB, - ), - ); - } - }, - ); - } - viewBloc.add( - const ViewEvent.setIsExpanded(true), - ); - }, + 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) { - return FlowyTooltip( - message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(), - child: ViewMoreActionButton( + 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)), - onAction: (action) { + buildChild: buildChild, + onAction: (action, data) async { switch (action) { case ViewMoreActionType.favorite: case ViewMoreActionType.unFavorite: @@ -565,18 +763,33 @@ class _SingleInnerViewItemState extends State { .add(FavoriteEvent.toggle(widget.view)); break; case ViewMoreActionType.rename: - NavigatorTextFieldDialog( - title: LocaleKeys.disclosureAction_rename.tr(), - autoSelectAllText: true, - value: widget.view.name, - maxLength: 256, - onConfirm: (newValue, _) { - context.read().add(ViewEvent.rename(newValue)); - }, - ).show(context); + 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: - context.read().add(const ViewEvent.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()); @@ -584,6 +797,34 @@ class _SingleInnerViewItemState extends State { 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! SelectedEmojiIconResult) { + return; + } + await ViewBackendService.updateViewIcon( + view: widget.view, + viewIcon: data.data, + ); + 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'); } @@ -591,20 +832,6 @@ class _SingleInnerViewItemState extends State { ), ); } - - String _convertLayoutToHintText(ViewLayoutPB layout) { - switch (layout) { - case ViewLayoutPB.Document: - return LocaleKeys.newDocumentText.tr(); - case ViewLayoutPB.Grid: - return LocaleKeys.newGridText.tr(); - case ViewLayoutPB.Board: - return LocaleKeys.newBoardText.tr(); - case ViewLayoutPB.Calendar: - return LocaleKeys.newCalendarText.tr(); - } - return LocaleKeys.newPageText.tr(); - } } class _DotIconWidget extends StatelessWidget { @@ -633,3 +860,85 @@ bool isReferencedDatabaseView(ViewPB view, ViewPB? parentView) { } 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 index 0cefde700e..5b531c2f28 100644 --- 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 @@ -1,72 +1,313 @@ import 'package:appflowy/generated/flowy_svgs.g.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/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_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/material.dart'; - +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.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/icon_button.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 ViewMoreActionButton extends StatelessWidget { - const ViewMoreActionButton({ +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) onAction; + 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 supportedActionTypes = [ - ViewMoreActionType.rename, - ViewMoreActionType.delete, - ViewMoreActionType.duplicate, - ViewMoreActionType.openInNewTab, - view.isFavorite - ? ViewMoreActionType.unFavorite - : ViewMoreActionType.favorite, - ]; + final wrappers = _buildActionTypeWrappers(); return PopoverActionList( - direction: PopoverDirection.bottomWithCenterAligned, + controller: controller, + direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 8), - actions: supportedActionTypes - .map((e) => ViewMoreActionTypeWrapper(e)) - .toList(), - buildChild: (popover) { - return FlowyIconButton( - hoverColor: Colors.transparent, - iconPadding: const EdgeInsets.all(2), - width: 26, - icon: const FlowySvg(FlowySvgs.details_s), - onPressed: () { - onEditing(true); - popover.show(); - }, - ); - }, - onSelected: (action, popover) { - onEditing(false); - onAction(action.inner); - popover.close(); - }, + 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) { + final actionWrapper = + ViewMoreActionTypeWrapper(e, view, (controller, data) { + onEditing(false); + onAction(e, data); + bool enableClose = true; + if (data is SelectedEmojiIconResult) { + if (data.keepOpen) enableClose = false; + } + if (enableClose) controller.close(); + }); + + return actionWrapper; + }, + ).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 ActionCell { - ViewMoreActionTypeWrapper(this.inner); +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? leftIcon(Color iconColor) => inner.icon(iconColor); + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + Widget child; - @override - String get name => inner.name; + if (inner == ViewMoreActionType.divider) { + child = _buildDivider(); + } else if (inner == ViewMoreActionType.lastModified) { + child = _buildLastModified(context); + } else if (inner == ViewMoreActionType.created) { + child = _buildCreated(context); + } else if (inner == ViewMoreActionType.changeIcon) { + child = _buildEmojiActionButton(context, controller); + } else if (inner == ViewMoreActionType.moveTo) { + child = _buildMoveToActionButton(context, controller); + } else { + child = _buildNormalActionButton(context, controller); + } + + if (ViewMoreActionType.disableInLockedView.contains(inner) && + sourceView.isLocked) { + child = LockPageButtonWrapper( + child: child, + ); + } + + return child; + } + + 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( + tabs: const [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ], + documentId: sourceView.id, + initialType: sourceView.icon.toEmojiIconData().type.toPickerTabType(), + 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.workspaceAuthType != AuthTypePB.Server) { + 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 index 8a81377c1c..d588e512b0 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart @@ -1,7 +1,5 @@ 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/workspace/application/home/home_setting_bloc.dart'; @@ -11,9 +9,11 @@ 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}); @@ -63,24 +63,37 @@ class FlowyNavigation extends StatelessWidget { return BlocBuilder( buildWhen: (p, c) => p.isMenuCollapsed != c.isMenuCollapsed, builder: (context, state) { - if (state.isMenuCollapsed) { - return RotationTransition( - turns: const AlwaysStoppedAnimation(180 / 360), - child: FlowyTooltip( - richMessage: sidebarTooltipTextSpan( - context, - LocaleKeys.sideBar_openSidebar.tr(), + if (!UniversalPlatform.isWindows && state.isMenuCollapsed) { + final textSpan = TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.sideBar_openSidebar.tr()}\n', + style: context.tooltipTextStyle(), ), - child: FlowyIconButton( - width: 24, - hoverColor: Colors.transparent, - onPressed: () => context - .read() - .add(const HomeSettingEvent.collapseMenu()), - iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2), - icon: FlowySvg( - FlowySvgs.hide_menu_m, - color: Theme.of(context).iconTheme.color, + 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), + ), ), ), ), @@ -149,13 +162,13 @@ class EllipsisNaviItem extends NavigationItem { final List items; @override - Widget get leftBarItem => FlowyText.medium( - '...', - fontSize: FontSizes.s16, - ); + String? get viewName => null; @override - Widget tabBarItem(String pluginId) => leftBarItem; + Widget get leftBarItem => FlowyText.medium('...', fontSize: FontSizes.s16); + + @override + Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem; @override NavigationCallback get action => (id) {}; 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 index 81601cbfd4..7e4a5f8df1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart @@ -1,10 +1,15 @@ 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:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.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 { @@ -12,56 +17,107 @@ class FlowyTab extends StatefulWidget { 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 { - bool _isHovering = false; + final controller = PopoverController(); @override Widget build(BuildContext context) { - return GestureDetector( - onTertiaryTapUp: _closeTab, - child: MouseRegion( - onEnter: (_) => _setHovering(true), - onExit: (_) => _setHovering(), - child: Container( - width: HomeSizes.tabBarWidth, - height: HomeSizes.tabBarHeigth, - decoration: BoxDecoration( - color: _getBackgroundColor(), + 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, ), - child: ChangeNotifierProvider.value( - value: widget.pageManager.notifier, - child: Consumer( - builder: (context, value, child) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - children: [ - Expanded( - child: widget.pageManager.notifier - .tabBarWidget(widget.pageManager.plugin.id), - ), - Visibility( - visible: _isHovering, - child: FlowyIconButton( - onPressed: _closeTab, - hoverColor: Theme.of(context).hoverColor, - iconColorOnHover: - Theme.of(context).colorScheme.onSurface, - icon: const FlowySvg( - FlowySvgs.close_s, - size: Size.fromWidth(16), + 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), + ), + ), + ), + ), + ], + ], ), ), ), - ], + ), ), ), ), @@ -71,25 +127,111 @@ class _FlowyTabState extends State { ); } - void _setHovering([bool isHovering = false]) { - if (mounted) { - setState(() => _isHovering = isHovering); - } - } - - Color _getBackgroundColor() { - if (widget.isCurrent) { - return Theme.of(context).colorScheme.onSecondaryContainer; - } - - if (_isHovering) { - return AFThemeExtension.of(context).lightGreyHover; - } - - return Theme.of(context).colorScheme.surfaceVariant; - } - - void _closeTab([TapUpDetails? details]) => context + 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 index 436e58f1bf..38ede2421e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart @@ -1,100 +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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class TabsManager extends StatefulWidget { - const TabsManager({super.key, required this.pageController}); +class TabsManager extends StatelessWidget { + const TabsManager({super.key, required this.onIndexChanged}); - final PageController pageController; - - @override - State createState() => _TabsManagerState(); -} - -class _TabsManagerState extends State - with TickerProviderStateMixin { - late TabController _controller; - - @override - void initState() { - super.initState(); - _controller = TabController(vsync: this, length: 1); - } + final void Function(int) onIndexChanged; @override Widget build(BuildContext context) { - return BlocProvider.value( - value: BlocProvider.of(context), - child: BlocListener( - listener: (context, state) { - if (_controller.length != state.pages) { - _controller.dispose(); - _controller = TabController( - vsync: this, - initialIndex: state.currentIndex, - length: state.pages, - ); - } + 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(); + } - if (state.currentIndex != widget.pageController.page) { - // Unfocus editor to hide selection toolbar - FocusScope.of(context).unfocus(); + final isAllPinned = state.isAllPinned; - widget.pageController.animateToPage( - state.currentIndex, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - child: BlocBuilder( - builder: (context, state) { - if (_controller.length == 1) { - return const SizedBox.shrink(); - } - - return Container( - alignment: Alignment.bottomLeft, - height: HomeSizes.tabBarHeigth, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, - ), - - /// TODO(Xazin): Custom Reorderable TabBar - child: TabBar( - padding: EdgeInsets.zero, - labelPadding: EdgeInsets.zero, - indicator: BoxDecoration( - border: Border.all(width: 0, color: Colors.transparent), - ), - indicatorWeight: 0, - dividerColor: Colors.transparent, - isScrollable: true, - controller: _controller, - onTap: (newIndex) => - context.read().add(TabsEvent.selectTab(newIndex)), - tabs: state.pageManagers - .map( - (pm) => FlowyTab( - key: UniqueKey(), - pageManager: pm, - isCurrent: state.currentPageManager == pm, - ), - ) - .toList(), - ), - ); - }, - ), - ), + 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(), + ), + ), + ); + }, ); } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart index 6e9d575dce..22906ce724 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart @@ -1,11 +1,11 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy_editor/appflowy_editor.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}); @@ -57,7 +57,7 @@ void showSnackBarMessage( }) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, duration: duration, action: !showCancel ? null @@ -71,7 +71,7 @@ void showSnackBarMessage( content: FlowyText( message, maxLines: 2, - fontSize: PlatformExtension.isDesktop ? 14 : 12, + 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 index 1394758fe1..7a14b6b1c5 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart @@ -28,25 +28,25 @@ class NotificationDialog extends StatefulWidget { class _NotificationDialogState extends State with SingleTickerProviderStateMixin { - late final TabController _controller = TabController(length: 2, vsync: this); - final PopoverMutex _mutex = PopoverMutex(); - final ReminderBloc _reminderBloc = getIt(); + 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); + reminderBloc.add(const ReminderEvent.started()); + controller.addListener(updateState); } - void _updateState() => setState(() {}); + void updateState() => setState(() {}); @override void dispose() { - _mutex.close(); - _controller.removeListener(_updateState); - _controller.dispose(); + mutex.dispose(); + controller.removeListener(updateState); + controller.dispose(); super.dispose(); } @@ -54,7 +54,7 @@ class _NotificationDialogState extends State Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider.value(value: _reminderBloc), + BlocProvider.value(value: reminderBloc), BlocProvider( create: (_) => NotificationFilterBloc(), ), @@ -63,41 +63,43 @@ class _NotificationDialogState extends State builder: (context, filterState) => BlocBuilder( builder: (context, state) { - final List pastReminders = state.pastReminders - .where((r) => filterState.showUnreadsOnly ? !r.isRead : true) - .sortByScheduledAt(); + List pastReminders = + state.pastReminders.sortByScheduledAt(); + if (filterState.showUnreadsOnly) { + pastReminders = pastReminders.where((r) => !r.isRead).toList(); + } - final List upcomingReminders = + 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), + NotificationTabBar(tabController: controller), Expanded( child: TabBarView( - controller: _controller, + controller: controller, children: [ NotificationsView( shownReminders: pastReminders, - reminderBloc: _reminderBloc, + reminderBloc: reminderBloc, views: widget.views, - onDelete: _onDelete, - onAction: _onAction, + onAction: onAction, onReadChanged: _onReadChanged, actionBar: InboxActionBar( - hasUnreads: state.hasUnreads, + hasUnreads: hasUnreads, showUnreadsOnly: filterState.showUnreadsOnly, ), ), NotificationsView( shownReminders: upcomingReminders, - reminderBloc: _reminderBloc, + reminderBloc: reminderBloc, views: widget.views, isUpcoming: true, - onAction: _onAction, + onAction: onAction, ), ], ), @@ -110,20 +112,16 @@ class _NotificationDialogState extends State ); } - void _onAction(ReminderPB reminder, int? path, ViewPB? view) { - _reminderBloc.add( + void onAction(ReminderPB reminder, int? path, ViewPB? view) { + reminderBloc.add( ReminderEvent.pressReminder(reminderId: reminder.id, path: path), ); widget.mutex.close(); } - void _onDelete(ReminderPB reminder) { - _reminderBloc.add(ReminderEvent.remove(reminderId: reminder.id)); - } - void _onReadChanged(ReminderPB reminder, bool isRead) { - _reminderBloc.add( + reminderBloc.add( ReminderEvent.update(ReminderUpdate(id: reminder.id, isRead: isRead)), ); } 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 index 93b696c67f..87d839d71a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/flowy_tab.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/flowy_tab.dart @@ -1,7 +1,7 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; 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({ @@ -22,9 +22,9 @@ class FlowyTabItem extends StatelessWidget { @override Widget build(BuildContext context) { return Tab( - height: PlatformExtension.isMobile ? mobileHeight : desktopHeight, + height: UniversalPlatform.isMobile ? mobileHeight : desktopHeight, child: Padding( - padding: PlatformExtension.isMobile ? mobilePadding : desktopPadding, + padding: UniversalPlatform.isMobile ? mobilePadding : desktopPadding, child: FlowyText.regular( label, color: isSelected 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 index 8ee7a0f102..988ca40fca 100644 --- 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 @@ -81,30 +81,30 @@ class _ToggleUnreadsButtonState extends State<_ToggleUnreadsButton> { showSelectedIcon: false, style: ButtonStyle( tapTargetSize: MaterialTapTargetSize.shrinkWrap, - side: MaterialStatePropertyAll( + side: WidgetStatePropertyAll( BorderSide(color: Theme.of(context).dividerColor), ), - shape: const MaterialStatePropertyAll( + shape: const WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: Corners.s6Border, ), ), - foregroundColor: MaterialStateProperty.resolveWith( + foregroundColor: WidgetStateProperty.resolveWith( (state) { - if (state.contains(MaterialState.selected)) { + if (state.contains(WidgetState.selected)) { return Theme.of(context).colorScheme.onPrimary; } return AFThemeExtension.of(context).textColor; }, ), - backgroundColor: MaterialStateProperty.resolveWith( + backgroundColor: WidgetStateProperty.resolveWith( (state) { - if (state.contains(MaterialState.selected)) { + if (state.contains(WidgetState.selected)) { return Theme.of(context).colorScheme.primary; } - if (state.contains(MaterialState.hovered)) { + if (state.contains(WidgetState.hovered)) { return AFThemeExtension.of(context).lightGreyHover; } 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 index a7925dc3f7..6f29c8e2aa 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart @@ -1,46 +1,83 @@ 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:appflowy_popover/appflowy_popover.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/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class NotificationButton extends StatelessWidget { +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; - final mutex = PopoverMutex(); return BlocProvider.value( value: getIt(), - child: BlocBuilder( - builder: (context, state) => FlowyTooltip( - message: LocaleKeys.notificationHub_title.tr(), - child: MouseRegion( - cursor: SystemMouseCursors.click, - 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: _buildNotificationIcon(context, state.hasUnreads), - ), - ), - ), + 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(); + }, + ); + }, ), ); } @@ -48,21 +85,20 @@ class NotificationButton extends StatelessWidget { Widget _buildNotificationIcon(BuildContext context, bool hasUnreads) { return Stack( children: [ - FlowySvg( - FlowySvgs.clock_alarm_s, - size: const Size.square(24), - color: Theme.of(context).colorScheme.tertiary, + Center( + child: FlowySvg( + FlowySvgs.notification_s, + color: + widget.isHover ? Theme.of(context).colorScheme.onSurface : null, + opacity: 0.7, + ), ), if (hasUnreads) - Positioned( - bottom: 2, - right: 2, - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AFThemeExtension.of(context).warning, - ), - child: const SizedBox(height: 8, width: 8), + const Positioned( + top: 4, + right: 6, + child: NotificationRedDot( + size: 5, ), ), ], 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 index 0f69d0e679..3d12a6afb7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart @@ -1,12 +1,11 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; -import 'package:appflowy/plugins/document/presentation/editor_style.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:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra/size.dart'; @@ -14,11 +13,12 @@ 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.reminderId, + required this.reminder, required this.title, required this.scheduled, required this.body, @@ -27,12 +27,11 @@ class NotificationItem extends StatefulWidget { this.includeTime = false, this.readOnly = false, this.onAction, - this.onDelete, this.onReadChanged, this.view, }); - final String reminderId; + final ReminderPB reminder; final String title; final Int64 scheduled; final String body; @@ -50,7 +49,6 @@ class NotificationItem extends StatefulWidget { final bool readOnly; final void Function(int? path)? onAction; - final VoidCallback? onDelete; final void Function(bool isRead)? onReadChanged; @override @@ -71,6 +69,12 @@ class _NotificationItemState extends State { infoString = _buildInfoString(); } + @override + void dispose() { + mutex.dispose(); + super.dispose(); + } + String _buildInfoString() { String scheduledString = _scheduledString(widget.scheduled, widget.includeTime); @@ -98,7 +102,7 @@ class _NotificationItemState extends State { child: DecoratedBox( decoration: BoxDecoration( border: Border( - bottom: PlatformExtension.isMobile + bottom: UniversalPlatform.isMobile ? BorderSide( color: AFThemeExtension.of(context).calloutBGColor, ) @@ -116,7 +120,7 @@ class _NotificationItemState extends State { ? null : Border( left: BorderSide( - width: PlatformExtension.isMobile ? 4 : 2, + width: UniversalPlatform.isMobile ? 4 : 2, color: Theme.of(context).colorScheme.primary, ), ), @@ -132,7 +136,7 @@ class _NotificationItemState extends State { FlowySvg( FlowySvgs.time_s, size: Size.square( - PlatformExtension.isMobile ? 24 : 20, + UniversalPlatform.isMobile ? 24 : 20, ), color: AFThemeExtension.of(context).textColor, ), @@ -144,14 +148,13 @@ class _NotificationItemState extends State { FlowyText.semibold( widget.title, fontSize: - PlatformExtension.isMobile ? 16 : 14, + UniversalPlatform.isMobile ? 16 : 14, color: AFThemeExtension.of(context).textColor, ), - // TODO(Xazin): Relative time FlowyText.regular( infoString, fontSize: - PlatformExtension.isMobile ? 12 : 10, + UniversalPlatform.isMobile ? 12 : 10, ), const VSpace(5), Container( @@ -163,6 +166,7 @@ class _NotificationItemState extends State { ), child: _NotificationContent( block: widget.block, + reminder: widget.reminder, body: widget.body, ), ), @@ -177,14 +181,13 @@ class _NotificationItemState extends State { ), ), ), - if (PlatformExtension.isMobile && !widget.readOnly || + if (UniversalPlatform.isMobile && !widget.readOnly || _isHovering && !widget.readOnly) Positioned( - right: PlatformExtension.isMobile ? 8 : 4, - top: PlatformExtension.isMobile ? 8 : 4, + right: UniversalPlatform.isMobile ? 8 : 4, + top: UniversalPlatform.isMobile ? 8 : 4, child: NotificationItemActions( isRead: widget.isRead, - onDelete: widget.onDelete, onReadChanged: widget.onReadChanged, ), ), @@ -208,10 +211,12 @@ class _NotificationItemState extends State { 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 @@ -223,29 +228,10 @@ class _NotificationContent extends StatelessWidget { return FlowyText.regular(body, maxLines: 4); } - final editorState = EditorState( - document: Document(root: snapshot.data!), - ); - - final styleCustomizer = EditorStyleCustomizer( - context: context, - padding: EdgeInsets.zero, - ); - - return Transform.scale( - scale: .9, - alignment: Alignment.centerLeft, - child: AppFlowyEditor( - editorState: editorState, - editorStyle: styleCustomizer.style(), - editable: false, - shrinkWrap: true, - blockComponentBuilders: getEditorBuilderMap( - context: context, - editorState: editorState, - styleCustomizer: styleCustomizer, - editable: false, - ), + return IntrinsicHeight( + child: NotificationDocumentContent( + nodes: [snapshot.data!], + reminder: reminder, ), ); }, @@ -257,17 +243,15 @@ class NotificationItemActions extends StatelessWidget { const NotificationItemActions({ super.key, required this.isRead, - this.onDelete, this.onReadChanged, }); final bool isRead; - final VoidCallback? onDelete; final void Function(bool isRead)? onReadChanged; @override Widget build(BuildContext context) { - final double size = PlatformExtension.isMobile ? 40.0 : 30.0; + final double size = UniversalPlatform.isMobile ? 40.0 : 30.0; return Container( height: size, @@ -285,6 +269,7 @@ class NotificationItemActions extends StatelessWidget { FlowyIconButton( height: size, width: size, + radius: BorderRadius.circular(4), tooltipText: LocaleKeys.reminderNotification_tooltipMarkUnread.tr(), icon: const FlowySvg(FlowySvgs.restore_s), @@ -295,6 +280,7 @@ class NotificationItemActions extends StatelessWidget { FlowyIconButton( height: size, width: size, + radius: BorderRadius.circular(4), tooltipText: LocaleKeys.reminderNotification_tooltipMarkRead.tr(), iconColorOnHover: Theme.of(context).colorScheme.onSurface, @@ -302,23 +288,6 @@ class NotificationItemActions extends StatelessWidget { onPressed: () => onReadChanged?.call(true), ), ], - VerticalDivider( - width: 3, - thickness: 1, - indent: 2, - endIndent: 2, - color: PlatformExtension.isMobile - ? Theme.of(context).colorScheme.outline - : Theme.of(context).dividerColor, - ), - FlowyIconButton( - height: size, - width: size, - tooltipText: LocaleKeys.reminderNotification_tooltipDelete.tr(), - icon: const FlowySvg(FlowySvgs.delete_s), - iconColorOnHover: Theme.of(context).colorScheme.onSurface, - onPressed: onDelete, - ), ], ), ), 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 index 42e5d50bfd..0465256f60 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart @@ -26,7 +26,6 @@ class NotificationsView extends StatelessWidget { required this.views, this.isUpcoming = false, this.onAction, - this.onDelete, this.onReadChanged, this.actionBar, }); @@ -36,7 +35,6 @@ class NotificationsView extends StatelessWidget { final List views; final bool isUpcoming; final Function(ReminderPB reminder, int? path, ViewPB? view)? onAction; - final Function(ReminderPB reminder)? onDelete; final Function(ReminderPB reminder, bool isRead)? onReadChanged; final Widget? actionBar; @@ -76,7 +74,7 @@ class NotificationsView extends StatelessWidget { final view = views.findView(reminder.objectId); return NotificationItem( - reminderId: reminder.id, + reminder: reminder, key: ValueKey(reminder.id), title: reminder.title, scheduled: reminder.scheduledAt, @@ -87,7 +85,6 @@ class NotificationsView extends StatelessWidget { readOnly: isUpcoming, onReadChanged: (isRead) => onReadChanged?.call(reminder, isRead), - onDelete: () => onDelete?.call(reminder), onAction: (path) => onAction?.call(reminder, path, view), view: view, ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/about/app_version.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/about/app_version.dart new file mode 100644 index 0000000000..2125ea4b66 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/about/app_version.dart @@ -0,0 +1,153 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/version_checker/version_checker.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class SettingsAppVersion extends StatelessWidget { + const SettingsAppVersion({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return ApplicationInfo.isUpdateAvailable + ? const _UpdateAppSection() + : _buildIsUpToDate(context); + } + + Widget _buildIsUpToDate(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.settings_accountPage_isUpToDate.tr(), + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + const VSpace(4), + Text( + LocaleKeys.settings_accountPage_officialVersion.tr( + namedArgs: { + 'version': ApplicationInfo.applicationVersion, + }, + ), + style: theme.textStyle.caption.standard( + color: theme.textColorScheme.secondary, + ), + ), + ], + ); + } +} + +class _UpdateAppSection extends StatelessWidget { + const _UpdateAppSection(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded(child: _buildDescription(context)), + _buildUpdateButton(), + ], + ); + } + + Widget _buildUpdateButton() { + return PrimaryRoundedButton( + text: LocaleKeys.autoUpdate_settingsUpdateButton.tr(), + margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + fontWeight: FontWeight.w500, + radius: 8.0, + onTap: () { + Log.info('[AutoUpdater] Checking for updates'); + versionChecker.checkForUpdate(); + }, + ); + } + + Widget _buildDescription(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _buildRedDot(), + const HSpace(6), + Flexible( + child: FlowyText.medium( + LocaleKeys.autoUpdate_settingsUpdateTitle.tr( + namedArgs: { + 'newVersion': ApplicationInfo.latestVersion, + }, + ), + figmaLineHeight: 17, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const VSpace(4), + _buildCurrentVersionAndLatestVersion(context), + ], + ); + } + + Widget _buildCurrentVersionAndLatestVersion(BuildContext context) { + return Row( + children: [ + Flexible( + child: Opacity( + opacity: 0.7, + child: FlowyText.regular( + LocaleKeys.autoUpdate_settingsUpdateDescription.tr( + namedArgs: { + 'currentVersion': ApplicationInfo.applicationVersion, + 'newVersion': ApplicationInfo.latestVersion, + }, + ), + fontSize: 12, + figmaLineHeight: 13, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const HSpace(6), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + afLaunchUrlString('https://www.appflowy.io/what-is-new'); + }, + child: FlowyText.regular( + LocaleKeys.autoUpdate_settingsUpdateWhatsNew.tr(), + decoration: TextDecoration.underline, + color: Theme.of(context).colorScheme.primary, + fontSize: 12, + figmaLineHeight: 13, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ); + } + + Widget _buildRedDot() { + return Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFFB006D), + shape: BoxShape.circle, + ), + ); + } +} 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 0000000000..7b337d8d78 --- /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 0000000000..04d078ec0d --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart @@ -0,0 +1,246 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/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:appflowy_ui/appflowy_ui.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'; + +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 theme = AppFlowyTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.button_deleteAccount.tr(), + style: theme.textStyle.heading4.enhanced( + color: theme.textColorScheme.primary, + ), + ), + const VSpace(8), + Row( + children: [ + Expanded( + child: Text( + LocaleKeys.newSettings_myAccount_deleteAccount_description.tr(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textStyle.caption.standard( + color: theme.textColorScheme.secondary, + ), + ), + ), + AFOutlinedTextButton.destructive( + text: LocaleKeys.button_deleteAccount.tr(), + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.error, + weight: FontWeight.w400, + ), + onTap: () { + 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: + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(), + 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) || + text == LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(); +} + +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( + type: ToastificationType.warning, + bottomPadding: bottomPadding, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_checkToConfirmError + .tr(), + ); + return; + } + if (!context.mounted) { + return; + } + + if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) { + showToastNotification( + 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( + 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( + 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 0000000000..78f1aaf16e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart @@ -0,0 +1,309 @@ +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/password/password_bloc.dart'; +import 'package:appflowy/user/application/prelude.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart'; +import 'package:appflowy/util/navigator_context_extension.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/change_password.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/setup_password.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:appflowy_ui/appflowy_ui.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 AccountSignInOutSection extends StatelessWidget { + const AccountSignInOutSection({ + 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) { + final theme = AppFlowyTheme.of(context); + return Row( + children: [ + Text( + LocaleKeys.settings_accountPage_login_title.tr(), + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + const Spacer(), + AccountSignInOutButton( + userProfile: userProfile, + onAction: onAction, + signIn: signIn, + ), + ], + ); + } +} + +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 AFFilledTextButton.primary( + text: signIn + ? LocaleKeys.settings_accountPage_login_loginLabel.tr() + : LocaleKeys.settings_accountPage_login_logoutLabel.tr(), + onTap: () => + signIn ? _showSignInDialog(context) : _showLogoutDialog(context), + ); + } + + void _showLogoutDialog(BuildContext context) { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), + description: 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 ChangePasswordSection extends StatelessWidget { + const ChangePasswordSection({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return BlocBuilder( + builder: (context, state) { + return Row( + children: [ + Text( + LocaleKeys.newSettings_myAccount_password_title.tr(), + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + const Spacer(), + state.hasPassword + ? AFFilledTextButton.primary( + text: LocaleKeys + .newSettings_myAccount_password_changePassword + .tr(), + onTap: () => _showChangePasswordDialog(context), + ) + : AFFilledTextButton.primary( + text: LocaleKeys + .newSettings_myAccount_password_setupPassword + .tr(), + onTap: () => _showSetPasswordDialog(context), + ), + ], + ); + }, + ); + } + + Future _showChangePasswordDialog(BuildContext context) async { + final theme = AppFlowyTheme.of(context); + await showDialog( + context: context, + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider.value( + value: getIt(), + ), + ], + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(theme.borderRadius.xl), + ), + child: ChangePasswordDialogContent( + userProfile: userProfile, + ), + ), + ), + ); + } + + Future _showSetPasswordDialog(BuildContext context) async { + await showDialog( + context: context, + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider.value( + value: getIt(), + ), + ], + child: Dialog( + child: SetupPasswordDialogContent( + userProfile: userProfile, + ), + ), + ), + ); + } +} + +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 ContinueWithEmailAndPassword(), + 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 0000000000..62a6232c4a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart @@ -0,0 +1,185 @@ +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:appflowy_ui/appflowy_ui.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() { + final theme = AppFlowyTheme.of(context); + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.name, + overflow: TextOverflow.ellipsis, + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + ), + ), + const HSpace(4), + AFGhostButton.normal( + size: AFButtonSize.s, + padding: EdgeInsets.all(theme.spacing.xs), + onTap: () => setState(() => isEditing = true), + builder: (context, isHovering, disabled) => FlowySvg( + FlowySvgs.toolbar_link_edit_m, + size: const Size.square(20), + ), + ), + ], + ), + ); + } + + 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/account/email/email_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/email/email_section.dart new file mode 100644 index 0000000000..d606f870ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/email/email_section.dart @@ -0,0 +1,38 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/widgets.dart'; + +class SettingsEmailSection extends StatelessWidget { + const SettingsEmailSection({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.settings_accountPage_email_title.tr(), + style: theme.textStyle.body.enhanced( + color: theme.textColorScheme.primary, + ), + ), + VSpace(theme.spacing.s), + Text( + userProfile.email, + style: theme.textStyle.body.standard( + color: theme.textColorScheme.secondary, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/change_password.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/change_password.dart new file mode 100644 index 0000000000..194254c869 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/change_password.dart @@ -0,0 +1,330 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/password/password_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.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 ChangePasswordDialogContent extends StatefulWidget { + const ChangePasswordDialogContent({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + State createState() => + _ChangePasswordDialogContentState(); +} + +class _ChangePasswordDialogContentState + extends State { + final currentPasswordTextFieldKey = GlobalKey(); + final newPasswordTextFieldKey = GlobalKey(); + final confirmPasswordTextFieldKey = GlobalKey(); + + final currentPasswordController = TextEditingController(); + final newPasswordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + + final iconSize = 20.0; + + @override + void dispose() { + currentPasswordController.dispose(); + newPasswordController.dispose(); + confirmPasswordController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return BlocListener( + listener: _onPasswordStateChanged, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + constraints: const BoxConstraints(maxWidth: 400), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(theme.borderRadius.xl), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(context), + VSpace(theme.spacing.l), + ..._buildCurrentPasswordFields(context), + VSpace(theme.spacing.l), + ..._buildNewPasswordFields(context), + VSpace(theme.spacing.l), + ..._buildConfirmPasswordFields(context), + VSpace(theme.spacing.l), + _buildSubmitButton(context), + ], + ), + ), + ); + } + + Widget _buildTitle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Change password', + style: theme.textStyle.heading4.prominent( + color: theme.textColorScheme.primary, + ), + ), + const Spacer(), + AFGhostButton.normal( + size: AFButtonSize.s, + padding: EdgeInsets.all(theme.spacing.xs), + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) => FlowySvg( + FlowySvgs.password_close_m, + size: const Size.square(20), + ), + ), + ], + ); + } + + List _buildCurrentPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + LocaleKeys.newSettings_myAccount_password_currentPassword.tr(), + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: currentPasswordTextFieldKey, + controller: currentPasswordController, + hintText: LocaleKeys + .newSettings_myAccount_password_hint_enterYourCurrentPassword + .tr(), + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + currentPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + List _buildNewPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + LocaleKeys.newSettings_myAccount_password_newPassword.tr(), + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: newPasswordTextFieldKey, + controller: newPasswordController, + hintText: LocaleKeys + .newSettings_myAccount_password_hint_enterYourNewPassword + .tr(), + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + newPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + List _buildConfirmPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + LocaleKeys.newSettings_myAccount_password_confirmNewPassword.tr(), + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: confirmPasswordTextFieldKey, + controller: confirmPasswordController, + hintText: LocaleKeys + .newSettings_myAccount_password_hint_confirmYourNewPassword + .tr(), + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + confirmPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + Widget _buildSubmitButton(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AFOutlinedTextButton.normal( + text: LocaleKeys.button_cancel.tr(), + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + weight: FontWeight.w400, + ), + onTap: () => Navigator.of(context).pop(), + ), + const HSpace(16), + AFFilledTextButton.primary( + text: LocaleKeys.button_save.tr(), + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.onFill, + weight: FontWeight.w400, + ), + onTap: () => _save(context), + ), + ], + ); + } + + void _save(BuildContext context) async { + _resetError(); + + final currentPassword = currentPasswordController.text; + final newPassword = newPasswordController.text; + final confirmPassword = confirmPasswordController.text; + + if (newPassword.isEmpty) { + newPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_newPasswordIsRequired + .tr(), + ); + return; + } + + if (confirmPassword.isEmpty) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_confirmPasswordIsRequired + .tr(), + ); + return; + } + + if (newPassword != confirmPassword) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_passwordsDoNotMatch + .tr(), + ); + return; + } + + if (newPassword == currentPassword) { + newPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_newPasswordIsSameAsCurrent + .tr(), + ); + return; + } + + // all the verification passed, save the new password + context.read().add( + PasswordEvent.changePassword( + oldPassword: currentPassword, + newPassword: newPassword, + ), + ); + } + + void _resetError() { + currentPasswordTextFieldKey.currentState?.clearError(); + newPasswordTextFieldKey.currentState?.clearError(); + confirmPasswordTextFieldKey.currentState?.clearError(); + } + + void _onPasswordStateChanged(BuildContext context, PasswordState state) { + bool hasError = false; + String message = ''; + String description = ''; + + final changePasswordResult = state.changePasswordResult; + final setPasswordResult = state.setupPasswordResult; + + if (changePasswordResult != null) { + changePasswordResult.fold( + (success) { + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordUpdatedSuccessfully + .tr(); + }, + (error) { + hasError = true; + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordUpdatedFailed + .tr(); + description = error.msg; + }, + ); + } else if (setPasswordResult != null) { + setPasswordResult.fold( + (success) { + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordSetupSuccessfully + .tr(); + }, + (error) { + hasError = true; + message = LocaleKeys + .newSettings_myAccount_password_toast_passwordSetupFailed + .tr(); + description = error.msg; + }, + ); + } + + if (!state.isSubmitting && message.isNotEmpty) { + showToastNotification( + message: message, + description: description, + type: hasError ? ToastificationType.error : ToastificationType.success, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart new file mode 100644 index 0000000000..5417b1a0eb --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart @@ -0,0 +1,30 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class PasswordSuffixIcon extends StatelessWidget { + const PasswordSuffixIcon({ + super.key, + required this.isObscured, + required this.onTap, + }); + + final bool isObscured; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Padding( + padding: EdgeInsets.only(right: theme.spacing.m), + child: GestureDetector( + onTap: onTap, + child: FlowySvg( + isObscured ? FlowySvgs.show_s : FlowySvgs.hide_s, + color: theme.textColorScheme.secondary, + size: const Size.square(20), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/setup_password.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/setup_password.dart new file mode 100644 index 0000000000..2fdfd8b934 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/setup_password.dart @@ -0,0 +1,254 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/password/password_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.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 SetupPasswordDialogContent extends StatefulWidget { + const SetupPasswordDialogContent({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + State createState() => + _SetupPasswordDialogContentState(); +} + +class _SetupPasswordDialogContentState + extends State { + final passwordTextFieldKey = GlobalKey(); + final confirmPasswordTextFieldKey = GlobalKey(); + + final passwordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + + final iconSize = 20.0; + + @override + void dispose() { + passwordController.dispose(); + confirmPasswordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return BlocListener( + listener: _onPasswordStateChanged, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(context), + VSpace(theme.spacing.l), + ..._buildPasswordFields(context), + VSpace(theme.spacing.l), + ..._buildConfirmPasswordFields(context), + VSpace(theme.spacing.l), + _buildSubmitButton(context), + ], + ), + ), + ); + } + + Widget _buildTitle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + LocaleKeys.newSettings_myAccount_password_setupPassword.tr(), + style: theme.textStyle.heading4.prominent( + color: theme.textColorScheme.primary, + ), + ), + const Spacer(), + AFGhostButton.normal( + size: AFButtonSize.s, + padding: EdgeInsets.all(theme.spacing.xs), + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) => FlowySvg( + FlowySvgs.password_close_m, + size: const Size.square(20), + ), + ), + ], + ); + } + + List _buildPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + 'Password', + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: passwordTextFieldKey, + controller: passwordController, + hintText: 'Enter your password', + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + passwordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + List _buildConfirmPasswordFields(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return [ + Text( + 'Confirm password', + style: theme.textStyle.caption.enhanced( + color: theme.textColorScheme.secondary, + ), + ), + VSpace(theme.spacing.xs), + AFTextField( + key: confirmPasswordTextFieldKey, + controller: confirmPasswordController, + hintText: 'Confirm your password', + keyboardType: TextInputType.visiblePassword, + obscureText: true, + suffixIconConstraints: BoxConstraints.tightFor( + width: iconSize + theme.spacing.m, + height: iconSize, + ), + suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( + isObscured: isObscured, + onTap: () { + confirmPasswordTextFieldKey.currentState?.syncObscured(!isObscured); + }, + ), + ), + ]; + } + + Widget _buildSubmitButton(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AFOutlinedTextButton.normal( + text: 'Cancel', + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + weight: FontWeight.w400, + ), + onTap: () => Navigator.of(context).pop(), + ), + const HSpace(16), + AFFilledTextButton.primary( + text: 'Save', + textStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.onFill, + weight: FontWeight.w400, + ), + onTap: () => _save(context), + ), + ], + ); + } + + void _save(BuildContext context) async { + _resetError(); + + final password = passwordController.text; + final confirmPassword = confirmPasswordController.text; + + if (password.isEmpty) { + passwordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_newPasswordIsRequired + .tr(), + ); + return; + } + + if (confirmPassword.isEmpty) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_confirmPasswordIsRequired + .tr(), + ); + return; + } + + if (password != confirmPassword) { + confirmPasswordTextFieldKey.currentState?.syncError( + errorText: LocaleKeys + .newSettings_myAccount_password_error_passwordsDoNotMatch + .tr(), + ); + return; + } + + // all the verification passed, save the password + context.read().add( + PasswordEvent.setupPassword( + newPassword: password, + ), + ); + } + + void _resetError() { + passwordTextFieldKey.currentState?.clearError(); + confirmPasswordTextFieldKey.currentState?.clearError(); + } + + void _onPasswordStateChanged(BuildContext context, PasswordState state) { + bool hasError = false; + String message = ''; + String description = ''; + + final setPasswordResult = state.setupPasswordResult; + + if (setPasswordResult != null) { + setPasswordResult.fold( + (success) { + message = 'Password set'; + description = 'Your password has been set'; + }, + (error) { + hasError = true; + message = 'Failed to set password'; + description = error.msg; + }, + ); + } + + if (!state.isSubmitting && message.isNotEmpty) { + showToastNotification( + message: message, + description: description, + type: hasError ? ToastificationType.error : ToastificationType.success, + ); + } + } +} 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 0000000000..2cecb25b30 --- /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/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 0000000000..b836f15b03 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart @@ -0,0 +1,150 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.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/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'ollama_setting.dart'; +import 'plugin_status_indicator.dart'; + +class LocalAISetting extends StatefulWidget { + const LocalAISetting({super.key}); + + @override + State createState() => _LocalAISettingState(); +} + +class _LocalAISettingState extends State { + final expandableController = ExpandableController(initialExpanded: false); + + @override + void dispose() { + expandableController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => LocalAiPluginBloc(), + child: BlocConsumer( + listener: (context, state) { + expandableController.value = state.isEnabled; + }, + builder: (context, state) { + return ExpandablePanel( + controller: expandableController, + theme: ExpandableThemeData( + tapBodyToCollapse: false, + hasIcon: false, + tapBodyToExpand: false, + tapHeaderToExpand: false, + ), + header: LocalAiSettingHeader( + isEnabled: state.isEnabled, + isToggleable: state is ReadyLocalAiPluginState, + ), + collapsed: const SizedBox.shrink(), + expanded: Padding( + padding: EdgeInsets.only(top: 12), + child: LocalAISettingPanel(), + ), + ); + }, + ), + ); + } +} + +class LocalAiSettingHeader extends StatelessWidget { + const LocalAiSettingHeader({ + super.key, + required this.isEnabled, + required this.isToggleable, + }); + + final bool isEnabled; + final bool isToggleable; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium( + LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), + ), + const VSpace(4), + FlowyText( + LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(), + maxLines: 3, + fontSize: 12, + ), + ], + ), + ), + IgnorePointer( + ignoring: !isToggleable, + child: Opacity( + opacity: isToggleable ? 1 : 0.5, + child: Toggle( + value: isEnabled, + onChanged: (_) => _onToggleChanged(context), + ), + ), + ), + ], + ); + } + + void _onToggleChanged(BuildContext context) { + 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 LocalAiPluginEvent.toggle()); + }, + ); + } else { + context.read().add(const LocalAiPluginEvent.toggle()); + } + } +} + +class LocalAISettingPanel extends StatelessWidget { + const LocalAISettingPanel({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is! ReadyLocalAiPluginState) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const LocalAIStatusIndicator(), + const VSpace(10), + OllamaSettingPage(), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_settings_ai_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_settings_ai_view.dart new file mode 100644 index 0000000000..e90c42444f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_settings_ai_view.dart @@ -0,0 +1,34 @@ +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/pages/setting_ai_view/local_ai_setting.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.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 LocalSettingsAIView extends StatelessWidget { + const LocalSettingsAIView({ + super.key, + required this.userProfile, + required this.workspaceId, + }); + + final UserProfilePB userProfile; + final String workspaceId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SettingsAIBloc(userProfile, workspaceId) + ..add(const SettingsAIEvent.started()), + child: SettingsBody( + title: LocaleKeys.settings_aiPage_title.tr(), + description: "", + children: [ + const LocalAISetting(), + ], + ), + ); + } +} 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 0000000000..7357c2951c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart @@ -0,0 +1,74 @@ +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +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: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}); + static const double height = 49; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.availableModels != current.availableModels, + builder: (context, state) { + final models = state.availableModels?.models; + if (models == null) { + return const SizedBox( + // Using same height as SettingsDropdown to avoid layout shift + height: height, + ); + } + + final localModels = models.where((model) => model.isLocal).toList(); + final cloudModels = models.where((model) => !model.isLocal).toList(); + final selectedModel = state.availableModels!.selectedModel; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: FlowyText.medium( + LocaleKeys.settings_aiPage_keys_llmModelType.tr(), + overflow: TextOverflow.ellipsis, + ), + ), + Flexible( + child: SettingsDropdown( + key: const Key('_AIModelSelection'), + onChanged: (model) => context + .read() + .add(SettingsAIEvent.selectModel(model)), + selectedOption: selectedModel, + selectOptionCompare: (left, right) => + left?.name == right?.name, + options: [...localModels, ...cloudModels] + .map( + (model) => buildDropdownMenuEntry( + context, + value: model, + label: + model.isLocal ? "${model.i18n} 🔐" : model.i18n, + subLabel: model.desc, + maximumHeight: height, + ), + ) + .toList(), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart new file mode 100644 index 0000000000..6f38043927 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart @@ -0,0 +1,115 @@ +import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.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/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class OllamaSettingPage extends StatelessWidget { + const OllamaSettingPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + OllamaSettingBloc()..add(const OllamaSettingEvent.started()), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.inputItems != current.inputItems || + previous.isEdited != current.isEdited, + builder: (context, state) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + padding: EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + for (final item in state.inputItems) + _SettingItemWidget(item: item), + _SaveButton(isEdited: state.isEdited), + ], + ), + ); + }, + ), + ); + } +} + +class _SettingItemWidget extends StatelessWidget { + const _SettingItemWidget({required this.item}); + + final SettingItem item; + + @override + Widget build(BuildContext context) { + return Column( + key: ValueKey(item.content + item.settingType.title), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + item.settingType.title, + fontSize: 12, + figmaLineHeight: 16, + ), + const VSpace(4), + SizedBox( + height: 32, + child: FlowyTextField( + autoFocus: false, + hintText: item.hintText, + text: item.content, + onChanged: (content) { + context.read().add( + OllamaSettingEvent.onEdit(content, item.settingType), + ); + }, + ), + ), + ], + ); + } +} + +class _SaveButton extends StatelessWidget { + const _SaveButton({required this.isEdited}); + + final bool isEdited; + + @override + Widget build(BuildContext context) { + return Align( + alignment: AlignmentDirectional.centerEnd, + child: FlowyTooltip( + message: isEdited ? null : 'No changes', + child: SizedBox( + child: FlowyButton( + text: FlowyText( + 'Apply', + figmaLineHeight: 20, + color: Theme.of(context).colorScheme.onPrimary, + ), + disable: !isEdited, + expandText: false, + margin: EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withAlpha(200), + onTap: () { + if (isEdited) { + context + .read() + .add(const OllamaSettingEvent.submit()); + } + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart new file mode 100644 index 0000000000..a280cf0644 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart @@ -0,0 +1,363 @@ +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/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.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:flutter_bloc/flutter_bloc.dart'; + +class LocalAIStatusIndicator extends StatelessWidget { + const LocalAIStatusIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + ready: (_, version, runningState, lackOfResource) { + if (lackOfResource != null) { + return _LackOfResource(resource: lackOfResource); + } + + return switch (runningState) { + RunningStatePB.ReadyToRun => const _ReadyToRun(), + RunningStatePB.Connecting || + RunningStatePB.Connected => + _Initializing(), + RunningStatePB.Running => _LocalAIRunning(version: version), + RunningStatePB.Stopped => const _RestartPluginButton(), + _ => const SizedBox.shrink(), + }; + }, + orElse: () => const SizedBox.shrink(), + ); + }, + ); + } +} + +class _ReadyToRun extends StatelessWidget { + const _ReadyToRun(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + const SizedBox.square( + dimension: 20.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + strokeAlign: BorderSide.strokeAlignInside, + ), + ), + const HSpace(8.0), + Expanded( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIStart.tr(), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _Initializing extends StatelessWidget { + const _Initializing(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + const SizedBox.square( + dimension: 20.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + strokeAlign: BorderSide.strokeAlignInside, + ), + ), + HSpace(8), + Expanded( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _RestartPluginButton extends StatelessWidget { + const _RestartPluginButton(); + + @override + Widget build(BuildContext context) { + final textStyle = + Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? const Color(0x80FFE7EE) + : const Color(0x80591734), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.toast_error_filled_s, + size: Size.square(20.0), + blendMode: null, + ), + const HSpace(8), + Expanded( + child: RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: + LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_restartLocalAI.tr(), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + context + .read() + .add(const LocalAiPluginEvent.restart()); + }, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _LocalAIRunning extends StatelessWidget { + const _LocalAIRunning({ + required this.version, + }); + + final String version; + + @override + Widget build(BuildContext context) { + final runningText = LocaleKeys.settings_aiPage_keys_localAIRunning.tr(); + final text = version.isEmpty ? runningText : "$runningText ($version)"; + + return Container( + decoration: const BoxDecoration( + color: Color(0xFFEDF7ED), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.download_success_s, + color: Color(0xFF2E7D32), + ), + const HSpace(6), + Expanded( + child: FlowyText( + text, + color: const Color(0xFF1E4620), + maxLines: 3, + ), + ), + ], + ), + ); + } +} + +class _LackOfResource extends StatelessWidget { + const _LackOfResource({required this.resource}); + + final LackOfAIResourcePB resource; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? const Color(0x80FFE7EE) + : const Color(0x80591734), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + FlowySvg( + FlowySvgs.toast_error_filled_s, + size: const Size.square(20.0), + blendMode: null, + ), + const HSpace(8), + Expanded( + child: switch (resource.resourceType) { + LackOfAIResourceTypePB.PluginExecutableNotReady => + _buildNoLAI(context), + LackOfAIResourceTypePB.OllamaServerNotReady => + _buildNoOllama(context), + LackOfAIResourceTypePB.MissingModel => + _buildNoModel(context, resource.missingModelNames), + _ => const SizedBox.shrink(), + }, + ), + ], + ), + ); + } + + TextStyle? _textStyle(BuildContext context) { + return Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); + } + + Widget _buildNoLAI(BuildContext context) { + final textStyle = _textStyle(context); + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_laiNotReady.tr(), + style: textStyle, + ), + TextSpan(text: ' ', style: _textStyle(context)), + ..._downloadInstructions(textStyle), + ], + ), + ); + } + + Widget _buildNoOllama(BuildContext context) { + final textStyle = _textStyle(context); + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_ollamaNotReady.tr(), + style: textStyle, + ), + TextSpan(text: ' ', style: textStyle), + ..._downloadInstructions(textStyle), + ], + ), + ); + } + + Widget _buildNoModel(BuildContext context, List modelNames) { + final textStyle = _textStyle(context); + + return RichText( + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(), + style: textStyle, + ), + TextSpan( + text: modelNames.join(', '), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_instructions.tr(), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString( + "https://appflowy.com/guide/appflowy-local-ai-ollama", + ); + }, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_downloadModel.tr(), + style: textStyle, + ), + ], + ), + ); + } + + List _downloadInstructions(TextStyle? textStyle) { + return [ + TextSpan( + text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(), + style: textStyle, + ), + TextSpan( + text: ' ', + style: textStyle, + ), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_instructions.tr(), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString( + "https://appflowy.com/guide/appflowy-local-ai-ollama", + ); + }, + ), + TextSpan(text: ' ', style: textStyle), + TextSpan( + text: LocaleKeys.settings_aiPage_keys_installOllamaLai.tr(), + style: textStyle, + ), + ]; + } +} 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 0000000000..c2e75ff2f2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart @@ -0,0 +1,84 @@ +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/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/widgets/toggle/toggle.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/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +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) + ..add(const SettingsAIEvent.started()), + child: SettingsBody( + title: LocaleKeys.settings_aiPage_title.tr(), + description: LocaleKeys.settings_aiPage_keys_aiSettingsDescription.tr(), + children: [ + const AIModelSelection(), + const _AISearchToggle(value: false), + const LocalAISetting(), + ], + ), + ); + } +} + +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()), + ); + } + }, + ), + ], + ), + ], + ); + } +} 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 index 5eae1ef34c..d7afb03e87 100644 --- 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 @@ -1,29 +1,16 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/about/app_version.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/email/email_section.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_header.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_input_field.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy/workspace/presentation/widgets/user_avatar.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:easy_localization/easy_localization.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/widget/flowy_tooltip.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsAccountView extends StatefulWidget { @@ -58,15 +45,13 @@ class _SettingsAccountViewState extends State { child: BlocBuilder( builder: (context, state) { return SettingsBody( + title: LocaleKeys.newSettings_myAccount_title.tr(), children: [ - SettingsHeader( - title: LocaleKeys.settings_accountPage_title.tr(), - description: LocaleKeys.settings_accountPage_description.tr(), - ), + // user profile SettingsCategory( - title: LocaleKeys.settings_accountPage_general_title.tr(), + title: LocaleKeys.newSettings_myAccount_myProfile.tr(), children: [ - UserProfileSetting( + AccountUserProfile( name: userName, iconUrl: state.userProfile.iconUrl, onSave: (newName) { @@ -76,136 +61,67 @@ class _SettingsAccountViewState extends State { setState(() => userName = newName); context .read() - .add(SettingsUserEvent.updateUserName(newName)); + .add(SettingsUserEvent.updateUserName(name: newName)); }, ), ], ), - // Enable when/if we need change email feature - // // Only show change email if the user is authenticated and not using local auth - // if (isAuthEnabled && - // state.userProfile.authenticator != AuthenticatorPB.Local) ...[ - // const SettingsCategorySpacer(), - // SettingsCategory( - // title: LocaleKeys.settings_accountPage_email_title.tr(), - // children: [ - // SingleSettingAction( - // label: state.userProfile.email, - // buttonLabel: LocaleKeys - // .settings_accountPage_email_actions_change - // .tr(), - // onPressed: () => SettingsAlertDialog( - // title: LocaleKeys - // .settings_accountPage_email_actions_change - // .tr(), - // confirmLabel: LocaleKeys.button_save.tr(), - // confirm: () { - // context.read().add( - // SettingsUserEvent.updateUserEmail( - // _emailController.text, - // ), - // ); - // Navigator.of(context).pop(); - // }, - // children: [ - // SettingsInputField( - // label: LocaleKeys.settings_accountPage_email_title - // .tr(), - // value: state.userProfile.email, - // hideActions: true, - // textController: _emailController, - // ), - // ], - // ).show(context), - // ), - // ], - // ), - // ], + // user email + // Only show email if the user is authenticated and not using local auth + if (isAuthEnabled && + state.userProfile.workspaceAuthType != AuthTypePB.Local) ...[ + SettingsCategory( + title: LocaleKeys.newSettings_myAccount_myAccount.tr(), + children: [ + SettingsEmailSection( + userProfile: state.userProfile, + ), + ChangePasswordSection( + userProfile: state.userProfile, + ), + AccountSignInOutSection( + userProfile: state.userProfile, + onAction: state.userProfile.workspaceAuthType == + AuthTypePB.Local + ? widget.didLogin + : widget.didLogout, + signIn: state.userProfile.workspaceAuthType == + AuthTypePB.Local, + ), + ], + ), + ], - /// Enable when we have change password feature and 2FA - // const SettingsCategorySpacer(), - // SettingsCategory( - // title: 'Account & security', - // children: [ - // SingleSettingAction( - // label: '**********', - // buttonLabel: 'Change password', - // onPressed: () {}, - // ), - // SingleSettingAction( - // label: '2-step authentication', - // buttonLabel: 'Enable 2FA', - // onPressed: () {}, - // ), - // ], - // ), - const SettingsCategorySpacer(), + if (isAuthEnabled && + state.userProfile.workspaceAuthType == AuthTypePB.Local) ...[ + SettingsCategory( + title: LocaleKeys.settings_accountPage_login_title.tr(), + children: [ + AccountSignInOutSection( + userProfile: state.userProfile, + onAction: state.userProfile.workspaceAuthType == + AuthTypePB.Local + ? widget.didLogin + : widget.didLogout, + signIn: state.userProfile.workspaceAuthType == + AuthTypePB.Local, + ), + ], + ), + ], + + // App version SettingsCategory( - title: LocaleKeys.settings_accountPage_keys_title.tr(), - children: [ - SettingsInputField( - label: - LocaleKeys.settings_accountPage_keys_openAILabel.tr(), - tooltip: - LocaleKeys.settings_accountPage_keys_openAITooltip.tr(), - placeholder: - LocaleKeys.settings_accountPage_keys_openAIHint.tr(), - value: state.userProfile.openaiKey, - obscureText: true, - onSave: (key) => context - .read() - .add(SettingsUserEvent.updateUserOpenAIKey(key)), - ), - SettingsInputField( - label: LocaleKeys.settings_accountPage_keys_stabilityAILabel - .tr(), - tooltip: LocaleKeys - .settings_accountPage_keys_stabilityAITooltip - .tr(), - placeholder: LocaleKeys - .settings_accountPage_keys_stabilityAIHint - .tr(), - value: state.userProfile.stabilityAiKey, - obscureText: true, - onSave: (key) => context - .read() - .add(SettingsUserEvent.updateUserStabilityAIKey(key)), - ), - ], - ), - const SettingsCategorySpacer(), - SettingsCategory( - title: LocaleKeys.settings_accountPage_login_title.tr(), - children: [ - SignInOutButton( - userProfile: state.userProfile, - onAction: - state.userProfile.authenticator == AuthenticatorPB.Local - ? widget.didLogin - : widget.didLogout, - signIn: state.userProfile.authenticator == - AuthenticatorPB.Local, - ), + title: LocaleKeys.newSettings_myAccount_aboutAppFlowy.tr(), + children: const [ + SettingsAppVersion(), ], ), - /// Enable when we can delete accounts - // const SettingsCategorySpacer(), - // SettingsSubcategory( - // title: 'Delete account', - // children: [ - // SingleSettingAction( - // label: - // 'Permanently delete your account and remove access from all teamspaces.', - // labelMaxLines: 4, - // onPressed: () {}, - // buttonLabel: 'Delete my account', - // isDangerous: true, - // fontSize: 12, - // ), - // ], - // ), + // user deletion + if (widget.userProfile.workspaceAuthType == AuthTypePB.Server) + const AccountDeletionButton(), ], ); }, @@ -213,212 +129,3 @@ class _SettingsAccountViewState extends State { ); } } - -@visibleForTesting -class SignInOutButton extends StatelessWidget { - const SignInOutButton({ - 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 Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 48, - child: FlowyTextButton( - signIn - ? LocaleKeys.settings_accountPage_login_loginLabel.tr() - : LocaleKeys.settings_accountPage_login_logoutLabel.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: signIn - ? LocaleKeys.settings_accountPage_login_loginLabel.tr() - : LocaleKeys.settings_accountPage_login_logoutLabel.tr(), - subtitle: signIn - ? null - : switch (userProfile.encryptionType) { - EncryptionTypePB.Symmetric => LocaleKeys - .settings_menu_selfEncryptionLogoutPrompt - .tr(), - _ => LocaleKeys.settings_menu_logoutPrompt.tr(), - }, - implyLeading: signIn, - confirm: !signIn - ? () async { - await getIt().signOut(); - onAction(); - } - : null, - children: - signIn ? [SettingThirdPartyLogin(didLogin: onAction)] : null, - ).show(context), - ), - ), - ], - ); - } -} - -@visibleForTesting -class UserProfileSetting extends StatefulWidget { - const UserProfileSetting({ - super.key, - required this.name, - required this.iconUrl, - this.onSave, - }); - - final String name; - final String iconUrl; - final void Function(String)? onSave; - - @override - State createState() => _UserProfileSettingState(); -} - -class _UserProfileSettingState extends State { - late final _nameController = TextEditingController(text: widget.name); - late final FocusNode focusNode; - bool isEditing = false; - bool isHovering = false; - - @override - void initState() { - super.initState(); - focusNode = FocusNode( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.escape && - isEditing && - mounted) { - setState(() => isEditing = false); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - )..addListener(() { - if (!focusNode.hasFocus && isEditing && mounted) { - widget.onSave?.call(_nameController.text); - setState(() => isEditing = false); - } - }); - } - - @override - void dispose() { - focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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, - isLarge: true, - isHovering: isHovering, - ), - ), - ), - ), - const HSpace(16), - if (!isEditing) ...[ - Padding( - padding: const EdgeInsets.only(top: 20), - 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), - ), - ), - ), - ], - ), - ), - ] else ...[ - Flexible( - child: SettingsInputField( - textController: _nameController, - value: widget.name, - focusNode: focusNode..requestFocus(), - onCancel: () => setState(() => isEditing = false), - onSave: (val) { - widget.onSave?.call(val); - setState(() => isEditing = false); - }, - ), - ), - ], - ], - ); - } - - Future _showIconPickerDialog(BuildContext context) { - return showDialog( - context: context, - builder: (dialogContext) => SimpleDialog( - children: [ - Container( - height: 380, - width: 360, - margin: const EdgeInsets.symmetric(horizontal: 12), - child: FlowyIconPicker( - onSelected: (result) { - context.read().add( - SettingsUserEvent.updateUserIcon(iconUrl: result.emoji), - ); - Navigator.of(dialogContext).pop(); - }, - ), - ), - ], - ), - ); - } -} 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 0000000000..77c1116319 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart @@ -0,0 +1,578 @@ +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/shared/loading.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'; + +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(), + ], + ), + ], + ); + }, + ); + }, + ), + ); + } + + 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 0000000000..a2d911ea40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart @@ -0,0 +1,456 @@ +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( + 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 0000000000..420daa8698 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart @@ -0,0 +1,766 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/shared/loading.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'; + +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_itemSeven.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_itemSeven.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_itemSeven.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 0000000000..21896ead0e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart @@ -0,0 +1,924 @@ +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/shared/loading.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'; + +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), + ], + ), + ], + ), + ); + }, + ), + ); + } +} + +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), () {}); + }, + ), + ], + ], + ), + ], + ); + } +} + +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).withValues( + alpha: 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).withValues(alpha: 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 0000000000..0d3716c7dc --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart @@ -0,0 +1,756 @@ +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/math_equation/math_equation_shortcut.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart'; +import 'package:appflowy/shared/error_page/error_page.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/home/menu/sidebar/space/shared_widget.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: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: () { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_shortcutsPage_resetDialog_title + .tr(), + description: LocaleKeys + .settings_shortcutsPage_resetDialog_description + .tr(), + confirmLabel: LocaleKeys + .settings_shortcutsPage_resetDialog_buttonLabel + .tr(), + onConfirm: () { + context.read().resetToDefault(); + Navigator.of(context).pop(); + }, + style: ConfirmPopupStyle.cancelAndOk, + ); + }, + ), + ], + ), + 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.withValues(alpha: 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 == insertInlineMathEquationCommand.key) { + label = LocaleKeys + .settings_shortcutsPage_keybindings_insertInlineMathEquation + .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 0000000000..78ffd34eef --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart @@ -0,0 +1,1376 @@ +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.workspaceAuthType != AuthTypePB.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.workspaceAuthType != AuthTypePB.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; + + 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 + .withValues(alpha: 0.12), + selectedTileColor: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 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 0000000000..2f03fc052c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart @@ -0,0 +1,59 @@ +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( + message: LocaleKeys.message_copy_success.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 0000000000..23a1bb2d7f --- /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 0000000000..b1d9b9cdae --- /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: [ + Flexible( + child: 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.withValues(alpha: 0.9), + onTap: () { + if (isOwner) { + showToastNotification( + message: + LocaleKeys.settings_sites_namespace_redirectToPayment.tr(), + type: ToastificationType.info, + ); + + context.read().add( + const SettingsSitesEvent.upgradeSubscription(), + ); + } else { + showToastNotification( + 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 0000000000..9c506b22ff --- /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 0000000000..9617f2c8d6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart @@ -0,0 +1,243 @@ +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( + 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( + 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 0000000000..f1236c1024 --- /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 0000000000..3ba2c7e75e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/publish_info_view_item.dart @@ -0,0 +1,61 @@ +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/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.toEmojiIconData(); + return icon.isNotEmpty + ? RawEmojiIconWidget(emoji: icon, emojiSize: 16.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 0000000000..99c310c901 --- /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 0000000000..34fefb4cd8 --- /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 0000000000..6c7b17a70b --- /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 0000000000..ad37bae866 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.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/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( + message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ); + Navigator.of(context).pop(); + }, + (f) { + Log.error('update path name failed: $f'); + + showToastNotification( + 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 0000000000..e03eed3f46 --- /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 0000000000..f3845b0896 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart @@ -0,0 +1,230 @@ +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( + message: + LocaleKeys.settings_sites_error_failedToGeneratePaymentLink.tr(), + type: ToastificationType.error, + ); + }); + } else if (type == SettingsSitesActionType.unpublishView && + result != null) { + result.fold((_) { + showToastNotification( + message: LocaleKeys.publish_unpublishSuccessfully.tr(), + ); + }, (f) { + Log.error('Failed to unpublish view: ${f.msg}'); + + showToastNotification( + message: LocaleKeys.publish_unpublishFailed.tr(), + type: ToastificationType.error, + description: f.msg, + ); + }); + } else if (type == SettingsSitesActionType.setHomePage && result != null) { + result.fold((s) { + showToastNotification( + message: LocaleKeys.settings_sites_success_setHomepageSuccess.tr(), + ); + }, (f) { + Log.error('Failed to set homepage: ${f.msg}'); + + showToastNotification( + message: LocaleKeys.settings_sites_error_setHomepageFailed.tr(), + type: ToastificationType.error, + ); + }); + } else if (type == SettingsSitesActionType.removeHomePage && + result != null) { + result.fold((s) { + showToastNotification( + message: LocaleKeys.settings_sites_success_removeHomePageSuccess.tr(), + ); + }, (f) { + Log.error('Failed to remove homepage: ${f.msg}'); + + showToastNotification( + 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 index a3b34d524f..cd33c62090 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -1,43 +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/plugins/shared/share/constants.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_appearance_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_system_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.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_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/web_url_hint_widget.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 'pages/setting_ai_view/local_settings_ai_view.dart'; import 'widgets/setting_cloud.dart'; +@visibleForTesting +const kSelfHostedTextInputFieldKey = + ValueKey('self_hosted_url_input_text_field'); +@visibleForTesting +const kSelfHostedWebTextInputFieldKey = + ValueKey('self_hosted_web_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; - final UserProfilePB user; @override Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width * 0.6; return BlocProvider( - create: (context) => getIt(param1: user) - ..add(const SettingsDialogEvent.initial()), + create: (context) => SettingsDialogBloc( + user, + context.read().state.currentWorkspace?.role, + initPage: initPage, + )..add(const SettingsDialogEvent.initial()), child: BlocBuilder( builder: (context, state) => FlowyDialog( - width: MediaQuery.of(context).size.width * 0.7, + width: width, + constraints: const BoxConstraints(minWidth: 564), child: ScaffoldMessenger( child: Scaffold( backgroundColor: Colors.transparent, @@ -53,12 +85,23 @@ class SettingsDialog extends StatelessWidget { .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, ), ), ], @@ -70,7 +113,12 @@ class SettingsDialog extends StatelessWidget { ); } - Widget getSettingsView(SettingsPage page, UserProfilePB user) { + Widget getSettingsView( + String workspaceId, + SettingsPage page, + UserProfilePB user, + AFRolePB? currentWorkspaceMemberRole, + ) { switch (page) { case SettingsPage.account: return SettingsAccountView( @@ -78,24 +126,471 @@ class SettingsDialog extends StatelessWidget { didLogout: didLogout, didLogin: dismissDialog, ); - case SettingsPage.appearance: - return const SettingsAppearanceView(); - case SettingsPage.language: - return const SettingsLanguageView(); - case SettingsPage.files: - return const SettingsFileSystemView(); + 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.workspaceAuthType == AuthTypePB.Server) { + return SettingsAIView( + key: ValueKey(workspaceId), + userProfile: user, + currentWorkspaceMemberRole: currentWorkspaceMemberRole, + workspaceId: workspaceId, + ); + } else { + return LocalSettingsAIView( + key: ValueKey(workspaceId), + userProfile: user, + workspaceId: workspaceId, + ); + } case SettingsPage.member: - return WorkspaceMembersPage(userProfile: user); + 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 cloudUrlTextController = TextEditingController(); + final webUrlTextController = TextEditingController(); + + AuthenticatorType type = AuthenticatorType.appflowyCloud; + + @override + void initState() { + super.initState(); + + _fetchUrls(); + } + + @override + void dispose() { + cloudUrlTextController.dispose(); + webUrlTextController.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 Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SelfHostUrlField( + textFieldKey: kSelfHostedTextInputFieldKey, + textController: cloudUrlTextController, + title: LocaleKeys.settings_menu_cloudURL.tr(), + hintText: LocaleKeys.settings_menu_cloudURLHint.tr(), + onSave: (url) => _saveUrl( + cloudUrl: url, + webUrl: webUrlTextController.text, + type: AuthenticatorType.appflowyCloudSelfHost, + ), + ), + const VSpace(12.0), + _SelfHostUrlField( + textFieldKey: kSelfHostedWebTextInputFieldKey, + textController: webUrlTextController, + title: LocaleKeys.settings_menu_webURL.tr(), + hintText: LocaleKeys.settings_menu_webURLHint.tr(), + hintBuilder: (context) => const WebUrlHintWidget(), + onSave: (url) => _saveUrl( + cloudUrl: cloudUrlTextController.text, + webUrl: url, + type: AuthenticatorType.appflowyCloudSelfHost, + ), + ), + const VSpace(12.0), + _buildSaveButton(), + ], + ); + } + + Widget _buildSaveButton() { + return Container( + height: 36, + constraints: const BoxConstraints(minWidth: 78), + child: OutlinedRoundedButton( + text: LocaleKeys.button_save.tr(), + onTap: () => _saveUrl( + cloudUrl: cloudUrlTextController.text, + webUrl: webUrlTextController.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) { + cloudUrlTextController.text = kAppflowyCloudUrl; + webUrlTextController.text = ShareConstants.defaultBaseWebDomain; + _saveUrl( + cloudUrl: kAppflowyCloudUrl, + webUrl: ShareConstants.defaultBaseWebDomain, + type: type, + ); + } + } + + Future _saveUrl({ + required String cloudUrl, + required String webUrl, + required AuthenticatorType type, + }) async { + if (cloudUrl.isEmpty || webUrl.isEmpty) { + showToastNotification( + message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), + type: ToastificationType.error, + ); + return; + } + + final isValid = await _validateUrl(cloudUrl) && await _validateUrl(webUrl); + + if (mounted) { + if (isValid) { + showToastNotification( + message: LocaleKeys.settings_menu_changeUrl.tr(args: [cloudUrl]), + ); + + Navigator.of(context).pop(); + + await useBaseWebDomain(webUrl); + await useAppFlowyBetaCloudWithURL(cloudUrl, type); + + await runAppFlowy(); + } else { + showToastNotification( + message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), + type: ToastificationType.error, + ); + } + } + } + + Future _validateUrl(String url) async { + return await validateUrl(url).fold( + (url) async { + return true; + }, + (err) { + Log.error(err); + return false; + }, + ); + } + + Future _fetchUrls() async { + await Future.wait([ + getAppFlowyCloudUrl(), + getAppFlowyShareDomain(), + ]).then((values) { + if (values.length != 2) { + return; + } + + cloudUrlTextController.text = values[0]; + webUrlTextController.text = values[1]; + + if (kAppflowyCloudUrl != values[0]) { + setState(() { + type = AuthenticatorType.appflowyCloudSelfHost; + }); + } + }); + } +} + +@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( + message: LocaleKeys + .settings_manageDataPage_cache_dialog_successHint + .tr(), + ); + } + }, + ), + ), + ], + ), + ], + ); + } +} + +class _SelfHostUrlField extends StatelessWidget { + const _SelfHostUrlField({ + required this.textController, + required this.title, + required this.hintText, + required this.onSave, + this.textFieldKey, + this.hintBuilder, + }); + + final TextEditingController textController; + final String title; + final String hintText; + final ValueChanged onSave; + final Key? textFieldKey; + final WidgetBuilder? hintBuilder; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHintWidget(context), + const VSpace(6.0), + SizedBox( + height: 36, + child: FlowyTextField( + key: textFieldKey, + controller: textController, + autoFocus: false, + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + ), + hintText: hintText, + onEditingComplete: () => onSave(textController.text), + ), + ), + ], + ); + } + + Widget _buildHintWidget(BuildContext context) { + return Row( + children: [ + FlowyText( + title, + overflow: TextOverflow.ellipsis, + ), + hintBuilder?.call(context) ?? const SizedBox.shrink(), + ], + ); + } +} 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 0000000000..720f7793f2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart @@ -0,0 +1,73 @@ +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, + String subLabel = '', + T? selectedValue, + Widget? leadingWidget, + Widget? trailingWidget, + String? fontFamily, + double maximumHeight = 29, +}) { + final fontFamilyUsed = fontFamily != null + ? getGoogleFontSafely(fontFamily).fontFamily ?? defaultFontFamily + : defaultFontFamily; + Widget? labelWidget; + if (subLabel.isNotEmpty) { + labelWidget = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + label, + fontSize: 14, + ), + const VSpace(4), + FlowyText.regular( + subLabel, + fontSize: 10, + ), + ], + ); + } else { + labelWidget = FlowyText.regular( + label, + fontSize: 14, + textAlign: TextAlign.start, + fontFamily: fontFamilyUsed, + ); + } + + 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: WidgetStatePropertyAll(Size(double.infinity, maximumHeight)), + ), + value: value, + label: label, + leadingIcon: leadingWidget, + labelWidget: labelWidget, + 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 0000000000..9892fd18a8 --- /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 0000000000..68556f8294 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart @@ -0,0 +1,87 @@ +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.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.withValues(alpha: 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 0000000000..e4551d1c2c --- /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 0000000000..9c1f0f4fc4 --- /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 0000000000..6c8eeb9ae4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart @@ -0,0 +1,59 @@ +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, + this.boxConstraints, + this.margin = const EdgeInsets.all(6), + }); + + final String currentValue; + final Key? popoverKey; + final Widget Function(BuildContext) popupBuilder; + final void Function()? onClose; + final Widget? child; + final PopoverController? popoverController; + final Offset? offset; + final BoxConstraints? boxConstraints; + final EdgeInsets margin; + + @override + State createState() => _SettingValueDropDownState(); +} + +class _SettingValueDropDownState extends State { + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + key: widget.popoverKey, + controller: widget.popoverController, + direction: PopoverDirection.bottomWithCenterAligned, + margin: widget.margin, + popupBuilder: widget.popupBuilder, + constraints: widget.boxConstraints ?? + 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 0000000000..6f92696f28 --- /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 index 17d60359cd..c56b46eae0 100644 --- 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 @@ -1,15 +1,15 @@ -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/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; +import 'package:flutter/material.dart'; class SettingsAlertDialog extends StatefulWidget { const SettingsAlertDialog({ super.key, + this.icon, required this.title, this.subtitle, this.children, @@ -19,8 +19,10 @@ class SettingsAlertDialog extends StatefulWidget { this.hideCancelButton = false, this.isDangerous = false, this.implyLeading = false, + this.enableConfirmNotifier, }); + final Widget? icon; final String title; final String? subtitle; final List? children; @@ -29,6 +31,7 @@ class SettingsAlertDialog extends StatefulWidget { 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; @@ -38,6 +41,37 @@ class SettingsAlertDialog extends StatefulWidget { } 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( @@ -86,6 +120,10 @@ class _SettingsAlertDialogState extends State { ), ], ), + if (widget.icon != null) ...[ + widget.icon!, + const VSpace(16), + ], Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -129,6 +167,7 @@ class _SettingsAlertDialogState extends State { cancel: widget.cancel, confirm: widget.confirm, isDangerous: widget.isDangerous, + enableConfirm: enableConfirm, ), ], ), @@ -143,6 +182,7 @@ class _Actions extends StatelessWidget { this.cancel, this.confirm, this.isDangerous = false, + this.enableConfirm = true, }); final bool hideCancelButton; @@ -150,6 +190,7 @@ class _Actions extends StatelessWidget { final VoidCallback? cancel; final VoidCallback? confirm; final bool isDangerous; + final bool enableConfirm; @override Widget build(BuildContext context) { @@ -158,17 +199,16 @@ class _Actions extends StatelessWidget { children: [ if (!hideCancelButton) ...[ SizedBox( - height: 24, - child: FlowyTextButton( - LocaleKeys.button_cancel.tr(), - padding: const EdgeInsets.symmetric( + height: 48, + child: PrimaryRoundedButton( + text: LocaleKeys.button_cancel.tr(), + margin: const EdgeInsets.symmetric( horizontal: 24, vertical: 12, ), - fontColor: AFThemeExtension.of(context).textColor, - fillColor: Colors.transparent, - hoverColor: Colors.transparent, - onPressed: () { + fontWeight: FontWeight.w600, + radius: 12.0, + onTap: () { cancel?.call(); Navigator.of(context).pop(); }, @@ -187,15 +227,20 @@ class _Actions extends StatelessWidget { horizontal: 24, vertical: 12, ), + radius: Corners.s12Border, fontColor: isDangerous ? Colors.white : null, - fontHoverColor: Colors.white, - fillColor: isDangerous - ? Theme.of(context).colorScheme.error - : Theme.of(context).colorScheme.primary, - hoverColor: isDangerous - ? Theme.of(context).colorScheme.error - : const Color(0xFF005483), - onPressed: confirm, + 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 index b1ade9c118..8091a72684 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart @@ -1,11 +1,21 @@ 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 @@ -14,8 +24,21 @@ class SettingsBody extends StatelessWidget { physics: const ClampingScrollPhysics(), padding: const EdgeInsets.all(24), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: children, + 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 index 34f25dd41e..33c81b99e8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart @@ -1,8 +1,7 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; /// Renders a simple category taking a title and the list /// of children (settings) to be rendered. @@ -12,6 +11,7 @@ class SettingsCategory extends StatelessWidget { super.key, required this.title, this.description, + this.descriptionColor, this.tooltip, this.actions, required this.children, @@ -19,21 +19,25 @@ class SettingsCategory extends StatelessWidget { final String title; final String? description; + final Color? descriptionColor; final String? tooltip; final List? actions; final List children; @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - FlowyText.semibold( + Text( title, + style: theme.textStyle.heading4.enhanced( + color: theme.textColorScheme.primary, + ), maxLines: 2, - fontSize: 16, overflow: TextOverflow.ellipsis, ), if (tooltip != null) ...[ @@ -47,13 +51,14 @@ class SettingsCategory extends StatelessWidget { if (actions != null) ...actions!, ], ), - const VSpace(8), + const VSpace(16), if (description?.isNotEmpty ?? false) ...[ FlowyText.regular( description!, maxLines: 4, fontSize: 12, overflow: TextOverflow.ellipsis, + color: descriptionColor, ), const VSpace(8), ], 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 index 5637fdd20c..deec09c1d8 100644 --- 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 @@ -1,3 +1,4 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; /// This is used to create a uniform space and divider @@ -7,6 +8,11 @@ class SettingsCategorySpacer extends StatelessWidget { const SettingsCategorySpacer({super.key}); @override - Widget build(BuildContext context) => - const Divider(height: 32, color: Color(0xFFF2F2F2)); + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Divider( + height: 32, + color: theme.borderColorScheme.greyPrimary, + ); + } } 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 0000000000..6a8405dc56 --- /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 0000000000..e392ed91f0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart @@ -0,0 +1,121 @@ +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, + this.selectOptionCompare, + }); + + final T selectedOption; + final CompareFunction? selectOptionCompare; + 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, + selectOptionCompare: widget.selectOptionCompare, + 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.withValues(alpha: 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 index c028e6886d..7409070ba9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; - +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; /// Renders a simple header for the settings view /// @@ -13,10 +13,16 @@ class SettingsHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlowyText.semibold(title, fontSize: 24), + Text( + title, + style: theme.textStyle.heading2.enhanced( + color: theme.textColorScheme.primary, + ), + ), if (description?.isNotEmpty == true) ...[ const VSpace(8), FlowyText( 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 index de0526b717..d5a81655a5 100644 --- 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 @@ -5,7 +5,6 @@ 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'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; /// This is used to describe a settings input field /// @@ -27,6 +26,7 @@ class SettingsInputField extends StatefulWidget { this.onSave, this.onCancel, this.hideActions = false, + this.onChanged, }); final String? label; @@ -47,14 +47,16 @@ class SettingsInputField extends StatefulWidget { /// final bool hideActions; - final Function(String)? onSave; + 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 Function()? onCancel; + final void Function()? onCancel; + + final void Function(String)? onChanged; @override State createState() => _SettingsInputFieldState(); @@ -100,7 +102,8 @@ class _SettingsInputFieldState extends State { ], ], ), - const VSpace(8), + if (widget.label?.isNotEmpty ?? false || widget.tooltip != null) + const VSpace(8), SizedBox( height: 48, child: FlowyTextField( @@ -126,7 +129,10 @@ class _SettingsInputFieldState extends State { ), ), onSubmitted: widget.onSave, - onChanged: (_) => setState(() {}), + onChanged: (_) { + widget.onChanged?.call(controller.text); + setState(() {}); + }, ), ), if (!widget.hideActions && 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 0000000000..91d780ceda --- /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/single_setting_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart index 7d51ea370c..6b0c920a04 100644 --- 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 @@ -1,9 +1,19 @@ -import 'package:flutter/material.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/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 /// @@ -17,15 +27,18 @@ class SingleSettingAction extends StatelessWidget { const SingleSettingAction({ super.key, required this.label, + this.description, this.labelMaxLines, required this.buttonLabel, this.onPressed, - this.isDangerous = false, + 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; @@ -35,45 +48,120 @@ class SingleSettingAction extends StatelessWidget { /// final VoidCallback? onPressed; - /// If isDangerous is true, the button will be rendered as a dangerous - /// action, with a red outline. - /// - final bool isDangerous; + final SingleSettingsButtonType buttonType; final double fontSize; final FontWeight fontWeight; + final double? minWidth; @override Widget build(BuildContext context) { return Row( children: [ Expanded( - child: FlowyText( - label, - fontSize: fontSize, - fontWeight: fontWeight, - maxLines: labelMaxLines, - overflow: TextOverflow.ellipsis, - color: AFThemeExtension.of(context).secondaryTextColor, + 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), - SizedBox( - height: 32, + ConstrainedBox( + constraints: BoxConstraints( + minWidth: minWidth ?? 0.0, + maxHeight: 32, + minHeight: 32, + ), child: FlowyTextButton( buttonLabel, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), - fillColor: - isDangerous ? null : Theme.of(context).colorScheme.primary, - hoverColor: isDangerous ? null : const Color(0xFF005483), - fontColor: isDangerous ? Theme.of(context).colorScheme.error : null, - fontHoverColor: Colors.white, + fillColor: fillColor(context), + radius: Corners.s8Border, + hoverColor: hoverColor(context), + fontColor: fontColor(context), + fontHoverColor: fontHoverColor(context), + borderColor: borderColor(context), fontSize: 12, - isDangerous: isDangerous, + 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.withValues(alpha: 0.1); + } + + if (buttonType.isPrimary) { + return Theme.of(context).colorScheme.primary.withValues(alpha: 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 index 17f1582c90..d8aa15a944 100644 --- 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 @@ -1,9 +1,9 @@ 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:appflowy_editor/appflowy_editor.dart' show PlatformExtension; 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({ @@ -17,7 +17,7 @@ class RestartButton extends StatelessWidget { @override Widget build(BuildContext context) { - final List children = [_buildRestartButton()]; + final List children = [_buildRestartButton(context)]; if (showRestartHint) { children.add( Padding( @@ -33,28 +33,42 @@ class RestartButton extends StatelessWidget { return Column(children: children); } - Widget _buildRestartButton() { - if (PlatformExtension.isDesktopOrWeb) { + Widget _buildRestartButton(BuildContext context) { + if (UniversalPlatform.isDesktopOrWeb) { return Row( children: [ - FlowyButton( - isSelected: true, - useIntrinsicWidth: true, - margin: const EdgeInsets.symmetric( - horizontal: 30, - vertical: 10, + 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, ), - text: FlowyText( - LocaleKeys.settings_menu_restartApp.tr(), - ), - onTap: onClick, ), - const Spacer(), ], ); + // 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 MobileSignInOrLogoutButton( - labelText: LocaleKeys.settings_menu_restartApp.tr(), + 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 0000000000..e606292572 --- /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 deleted file mode 100644 index f57c25b351..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart +++ /dev/null @@ -1,122 +0,0 @@ -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'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.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() { - HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent); - super.initState(); - } - - bool _handleGlobalKeyEvent(KeyEvent event) { - if (event.logicalKey == LogicalKeyboardKey.escape && - event is KeyDownEvent) { - //triggers on esc - widget.onExit(); - return true; - } else { - return false; - } - } - - @override - void deactivate() { - HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); - super.deactivate(); - } - - @override - void dispose() { - super.dispose(); - } - - @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 index a369cc6b87..6e1f6e239f 100644 --- 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 @@ -1,4 +1,3 @@ -export 'emoji_menu_item.dart'; export 'emoji_shortcut_event.dart'; export 'src/emji_picker_config.dart'; export 'src/emoji_picker.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 index 078cf64963..6959f69788 100644 --- 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 @@ -1,5 +1,7 @@ -import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; +import 'package:appflowy/plugins/emoji/emoji_actions_command.dart'; +import 'package:appflowy/plugins/emoji/emoji_menu.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; final CommandShortcutEvent emojiShortcutEvent = CommandShortcutEvent( @@ -15,73 +17,16 @@ CommandShortcutEventHandler _emojiShortcutHandler = (editorState) { if (selection == null) { return KeyEventResult.ignored; } - final context = editorState.getNodeAtPath(selection.start.path)?.context; - if (context == null) { + final node = editorState.getNodeAtPath(selection.start.path); + final context = node?.context; + if (node == null || + context == null || + node.delta == null || + node.type == CodeBlockKeys.type) { 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, - ); - + emojiMenuService = EmojiMenu(editorState: editorState, overlay: container); + emojiMenuService?.show(''); 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 index f2bdbf4faa..9a4690f240 100644 --- 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 @@ -1,6 +1,8 @@ +import 'package:flutter/material.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 'emoji_picker.dart'; import 'emoji_picker_builder.dart'; import 'models/emoji_category_models.dart'; @@ -29,8 +31,10 @@ class DefaultEmojiPickerViewState extends State @override void initState() { - var initCategory = widget.state.emojiCategoryGroupList.indexWhere( - (element) => element.category == widget.config.initCategory, + super.initState(); + + int initCategory = widget.state.emojiCategoryGroupList.indexWhere( + (el) => el.category == widget.config.initCategory, ); if (initCategory == -1) { initCategory = 0; @@ -42,31 +46,12 @@ class DefaultEmojiPickerViewState extends State ); _pageController = PageController(initialPage: initCategory); _emojiFocusNode.requestFocus(); - - _emojiController.addListener(() { - 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) { - return item.name.toLowerCase().contains(query); - }).toList(), - ); - } - } - setState(() {}); - }); - super.initState(); + _emojiController.addListener(_onEmojiChanged); } @override void dispose() { + _emojiController.removeListener(_onEmojiChanged); _emojiController.dispose(); _emojiFocusNode.dispose(); _pageController?.dispose(); @@ -75,31 +60,41 @@ class DefaultEmojiPickerViewState extends State 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!(); - }, + icon: Icon(Icons.backspace, color: widget.config.backspaceColor), + onPressed: () => widget.state.onBackspacePressed!(), ), ); } + return const SizedBox.shrink(); } - bool isEmojiSearching() { - final bool result = - searchEmojiList.emoji.isNotEmpty || _emojiController.text.isNotEmpty; - - return result; - } + bool isEmojiSearching() => + searchEmojiList.emoji.isNotEmpty || _emojiController.text.isNotEmpty; @override Widget build(BuildContext context) { @@ -213,15 +208,9 @@ class DefaultEmojiPickerViewState extends State required Widget child, }) { if (widget.config.buttonMode == ButtonMode.MATERIAL) { - return InkWell( - onTap: onPressed, - child: child, - ); + return InkWell(onTap: onPressed, child: child); } - return GestureDetector( - onTap: onPressed, - child: child, - ); + return GestureDetector(onTap: onPressed, child: child); } Widget _buildPage(double emojiSize, EmojiCategoryGroup emojiCategoryGroup) { @@ -275,9 +264,7 @@ class DefaultEmojiPickerViewState extends State child: FittedBox( child: Text( emoji.emoji, - style: TextStyle( - fontSize: emojiSize, - ), + style: TextStyle(fontSize: emojiSize), ), ), ), 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 index 22d2bbe034..f329e9dd1c 100644 --- 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 @@ -3,8 +3,8 @@ 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'; +import 'models/emoji_category_models.dart'; part 'emji_picker_config.freezed.dart'; @@ -87,8 +87,6 @@ class EmojiPickerConfig with _$EmojiPickerConfig { return emojiCategoryIcons.flagIcon; case EmojiCategory.SEARCH: return emojiCategoryIcons.searchIcon; - default: - throw Exception('Unsupported EmojiCategory'); } } } 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 index 066d63f380..4ef6e00994 100644 --- 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 @@ -29,6 +29,6 @@ EmojiPickerConfig buildFlowyEmojiPickerConfig(BuildContext context) { noRecentsText: LocaleKeys.emoji_noRecent.tr(), noRecentsStyle: style.textTheme.bodyMedium, noEmojiFoundText: LocaleKeys.emoji_noEmojiFound.tr(), - scrollBarHandleColor: style.colorScheme.onBackground, + scrollBarHandleColor: style.colorScheme.onSurface, ); } 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 index a1fb8257ec..90ce4d6f9b 100644 --- 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 @@ -3,8 +3,6 @@ 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: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 FeatureFlagsPage extends StatelessWidget { @@ -15,15 +13,14 @@ class FeatureFlagsPage extends StatelessWidget { @override Widget build(BuildContext context) { return SettingsBody( + title: 'Feature flags', children: [ - const SettingsHeader(title: 'Feature flags'), SeparatedColumn( children: FeatureFlag.data.entries .where((e) => e.key != FeatureFlag.unknown) .map((e) => _FeatureFlagItem(featureFlag: e.key)) .toList(), ), - const SettingsCategorySpacer(), FlowyTextButton( 'Restart the app to apply changes', fontSize: 16.0, @@ -53,7 +50,10 @@ class _FeatureFlagItemState extends State<_FeatureFlagItem> { subtitle: FlowyText.small(widget.featureFlag.description, maxLines: 3), trailing: Switch.adaptive( value: widget.featureFlag.isOn, - onChanged: (value) => setState(() => widget.featureFlag.update(value)), + onChanged: (value) async { + await widget.featureFlag.update(value); + setState(() {}); + }, ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/setting_file_import_appflowy_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/setting_file_import_appflowy_data_view.dart deleted file mode 100644 index 20498752b9..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/setting_file_import_appflowy_data_view.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; - -import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/application/settings/setting_file_importer_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/toast.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_bloc/flutter_bloc.dart'; -import 'package:fluttertoast/fluttertoast.dart'; - -class ImportAppFlowyData extends StatefulWidget { - const ImportAppFlowyData({super.key}); - - @override - State createState() => _ImportAppFlowyDataState(); -} - -class _ImportAppFlowyDataState extends State { - final _fToast = FToast(); - @override - void initState() { - super.initState(); - _fToast.init(context); - } - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => SettingFileImportBloc(), - child: BlocListener( - listener: (context, state) { - state.successOrFail?.fold( - (_) { - _showToast(LocaleKeys.settings_menu_importSuccess.tr()); - }, - (_) { - _showToast(LocaleKeys.settings_menu_importFailed.tr()); - }, - ); - }, - child: BlocBuilder( - builder: (context, state) { - final List children = [ - const ImportAppFlowyDataButton(), - const VSpace(6), - ]; - - if (state.loadingState.isLoading()) { - children.add(const AppFlowyDataImportingTip()); - } else { - children.add(const AppFlowyDataImportTip()); - } - - return Column(children: children); - }, - ), - ), - ); - } - - void _showToast(String message) { - _fToast.showToast( - child: FlowyMessageToast(message: message), - gravity: ToastGravity.CENTER, - ); - } -} - -class AppFlowyDataImportTip extends StatelessWidget { - const AppFlowyDataImportTip({super.key}); - - final url = "https://docs.appflowy.io/docs/appflowy/product/data-storage"; - - @override - Widget build(BuildContext context) { - return Opacity( - opacity: 0.6, - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: LocaleKeys.settings_menu_importAppFlowyDataDescription.tr(), - style: Theme.of(context).textTheme.bodySmall!, - ), - TextSpan( - text: " ${LocaleKeys.settings_menu_importGuide.tr()} ", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => afLaunchUrlString(url), - ), - ], - ), - ), - ); - } -} - -class ImportAppFlowyDataButton extends StatefulWidget { - const ImportAppFlowyDataButton({super.key}); - - @override - State createState() => - _ImportAppFlowyDataButtonState(); -} - -class _ImportAppFlowyDataButtonState extends State { - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return Column( - children: [ - SizedBox( - height: 40, - child: FlowyButton( - disable: state.loadingState.isLoading(), - text: - FlowyText(LocaleKeys.settings_menu_importAppFlowyData.tr()), - onTap: () async { - final path = - await getIt().getDirectoryPath(); - if (path == null || !context.mounted) { - return; - } - - context.read().add( - SettingFileImportEvent.importAppFlowyDataFolder(path), - ); - }, - ), - ), - if (state.loadingState.isLoading()) - const LinearProgressIndicator(minHeight: 1), - ], - ); - }, - ); - } -} - -class AppFlowyDataImportingTip extends StatelessWidget { - const AppFlowyDataImportingTip({super.key}); - - @override - Widget build(BuildContext context) { - return Opacity( - opacity: 0.6, - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: LocaleKeys.settings_menu_importingAppFlowyDataTip.tr(), - style: Theme.of(context).textTheme.bodySmall!, - ), - ], - ), - ), - ); - } -} 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 index 1c6441e90b..ed6c8949b7 100644 --- 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 @@ -1,16 +1,15 @@ +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:flutter/material.dart'; import 'package:styled_widget/styled_widget.dart'; import '../../../../../generated/locale_keys.g.dart'; class SettingsExportFileWidget extends StatefulWidget { - const SettingsExportFileWidget({ - super.key, - }); + const SettingsExportFileWidget({super.key}); @override State createState() => diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_cache_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_cache_widget.dart deleted file mode 100644 index 011b7ece9f..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_cache_widget.dart +++ /dev/null @@ -1,80 +0,0 @@ -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/workspace/presentation/home/toast.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'; - -class SettingsFileCacheWidget extends StatelessWidget { - const SettingsFileCacheWidget({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 5.0), - child: FlowyText.medium( - LocaleKeys.settings_files_clearCache.tr(), - fontSize: 13, - overflow: TextOverflow.ellipsis, - ), - ), - const VSpace(8), - Opacity( - opacity: 0.6, - child: FlowyText( - LocaleKeys.settings_files_clearCacheDesc.tr(), - fontSize: 10, - maxLines: 3, - ), - ), - ], - ), - ), - const _ClearCacheButton(), - ], - ); - } -} - -class _ClearCacheButton extends StatelessWidget { - const _ClearCacheButton(); - - @override - Widget build(BuildContext context) { - return FlowyIconButton( - hoverColor: Theme.of(context).colorScheme.secondaryContainer, - tooltipText: LocaleKeys.settings_files_clearCache.tr(), - icon: FlowySvg( - FlowySvgs.delete_s, - size: const Size.square(18), - color: Theme.of(context).iconTheme.color, - ), - onPressed: () { - NavigatorAlertDialog( - title: LocaleKeys.settings_files_areYouSureToClearCache.tr(), - confirm: () async { - await getIt().clearAllCache(); - if (context.mounted) { - showSnackBarMessage( - context, - LocaleKeys.settings_files_clearCacheSuccess.tr(), - ); - } - }, - ).show(context); - }, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_customize_location_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_customize_location_view.dart deleted file mode 100644 index e7cc9fff0d..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_customize_location_view.dart +++ /dev/null @@ -1,285 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/workspace/application/settings/settings_location_cubit.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:flowy_infra_ui/widget/buttons/secondary_button.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:styled_widget/styled_widget.dart'; - -import '../../../../../generated/locale_keys.g.dart'; -import '../../../../../startup/startup.dart'; -import '../../../../../startup/tasks/prelude.dart'; - -class SettingsFileLocationCustomizer extends StatefulWidget { - const SettingsFileLocationCustomizer({super.key}); - - @override - State createState() => - SettingsFileLocationCustomizerState(); -} - -@visibleForTesting -class SettingsFileLocationCustomizerState - extends State { - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => SettingsLocationCubit(), - child: BlocBuilder( - builder: (context, state) { - return state.when( - initial: () => const Center( - child: CircularProgressIndicator(), - ), - didReceivedPath: (path) { - return Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - // display file paths. - _path(path), - - // display the icons - _buttons(path), - ], - ), - const VSpace(10), - IntrinsicHeight( - child: Opacity( - opacity: 0.6, - child: FlowyText.medium( - LocaleKeys.settings_menu_customPathPrompt.tr(), - maxLines: 13, - ), - ), - ), - ], - ); - }, - ); - }, - ), - ); - } - - Widget _path(String path) { - return Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText.medium( - LocaleKeys.settings_files_defaultLocation.tr(), - fontSize: 13, - overflow: TextOverflow.visible, - ).padding(horizontal: 5), - const VSpace(5), - _CopyableText( - usingPath: path, - ), - ], - ), - ); - } - - Widget _buttons(String path) { - final List children = []; - children.addAll([ - Flexible( - child: _ChangeStoragePathButton( - usingPath: path, - ), - ), - const HSpace(10), - ]); - - children.add( - _OpenStorageButton( - usingPath: path, - ), - ); - - children.add( - _RecoverDefaultStorageButton( - usingPath: path, - ), - ); - - return Flexible( - child: Row(mainAxisAlignment: MainAxisAlignment.end, children: children), - ); - } -} - -class _CopyableText extends StatelessWidget { - const _CopyableText({ - required this.usingPath, - }); - - final String usingPath; - - @override - Widget build(BuildContext context) { - return FlowyHover( - builder: (_, onHover) { - return GestureDetector( - onTap: () { - Clipboard.setData(ClipboardData(text: usingPath)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: FlowyText( - LocaleKeys.settings_files_pathCopiedSnackbar.tr(), - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ); - }, - child: Container( - height: 20, - padding: const EdgeInsets.symmetric(horizontal: 5), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: FlowyText.regular( - usingPath, - fontSize: 12, - overflow: TextOverflow.ellipsis, - ), - ), - if (onHover) ...[ - const HSpace(5), - FlowyText.regular( - LocaleKeys.settings_files_copy.tr(), - fontSize: 12, - color: Theme.of(context).colorScheme.primary, - ), - ], - ], - ), - ), - ); - }, - ); - } -} - -class _ChangeStoragePathButton extends StatefulWidget { - const _ChangeStoragePathButton({ - required this.usingPath, - }); - - final String usingPath; - - @override - State<_ChangeStoragePathButton> createState() => - _ChangeStoragePathButtonState(); -} - -class _ChangeStoragePathButtonState extends State<_ChangeStoragePathButton> { - @override - Widget build(BuildContext context) { - return FlowyTooltip( - message: LocaleKeys.settings_files_changeLocationTooltips.tr(), - child: SecondaryTextButton( - LocaleKeys.settings_files_change.tr(), - mode: TextButtonMode.small, - onPressed: () async { - // pick the new directory and reload app - final path = await getIt().getDirectoryPath(); - if (path == null || widget.usingPath == path) { - return; - } - if (!context.mounted) { - return; - } - await context.read().setCustomPath(path); - await runAppFlowy(isAnon: true); - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), - ); - } -} - -class _OpenStorageButton extends StatelessWidget { - const _OpenStorageButton({ - required this.usingPath, - }); - - final String usingPath; - - @override - Widget build(BuildContext context) { - return FlowyIconButton( - hoverColor: Theme.of(context).colorScheme.secondaryContainer, - tooltipText: LocaleKeys.settings_files_openCurrentDataFolder.tr(), - icon: FlowySvg( - FlowySvgs.open_folder_lg, - color: Theme.of(context).iconTheme.color, - ), - onPressed: () async { - final uri = Directory(usingPath).uri; - await afLaunchUrl(uri, context: context); - }, - ); - } -} - -class _RecoverDefaultStorageButton extends StatefulWidget { - const _RecoverDefaultStorageButton({ - required this.usingPath, - }); - - final String usingPath; - - @override - State<_RecoverDefaultStorageButton> createState() => - _RecoverDefaultStorageButtonState(); -} - -class _RecoverDefaultStorageButtonState - extends State<_RecoverDefaultStorageButton> { - @override - Widget build(BuildContext context) { - return FlowyIconButton( - hoverColor: Theme.of(context).colorScheme.secondaryContainer, - tooltipText: LocaleKeys.settings_files_recoverLocationTooltips.tr(), - icon: const FlowySvg( - FlowySvgs.restore_s, - size: Size.square(20), - ), - onPressed: () async { - // reset to the default directory and reload app - final directory = await appFlowyApplicationDataDirectory(); - final path = directory.path; - if (widget.usingPath == path) { - return; - } - if (!context.mounted) { - return; - } - await context - .read() - .resetDataStoragePathToApplicationDefault(); - await runAppFlowy(isAnon: true); - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ); - } -} 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 index 751599def3..7c8e128ec6 100644 --- 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 @@ -1,8 +1,7 @@ import 'dart:io'; -import 'package:flutter/material.dart'; - 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'; @@ -16,7 +15,8 @@ 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' hide WidgetBuilder; +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; @@ -126,16 +126,14 @@ class _FileExporterWidgetState extends State { ); } } - } else { + } else if (mounted) { showSnackBarMessage( context, LocaleKeys.settings_files_exportFileFail.tr(), ); } if (mounted) { - Navigator.of(context).popUntil( - (router) => router.settings.name == '/', - ); + context.popToHome(); } }); }, 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 index 7b9dc4354b..c9fcb34204 100644 --- 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 @@ -1,3 +1,7 @@ +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'; @@ -24,13 +28,14 @@ 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(); + await _setCurrentWorkspaceId(workspaceId); final result = await _userBackendService.getWorkspaceMembers( _workspaceId, @@ -40,6 +45,10 @@ class WorkspaceMemberBloc (e) => [], ); final myRole = _getMyRole(members); + + if (myRole.isOwner) { + unawaited(_fetchWorkspaceSubscriptionInfo()); + } emit( state.copyWith( members: members, @@ -135,9 +144,7 @@ class WorkspaceMemberBloc (s) => state.members.map((e) { if (e.email == email) { e.freeze(); - return e.rebuild((p0) { - p0.role = role; - }); + return e.rebuild((p0) => p0.role = role); } return e; }).toList(), @@ -153,6 +160,26 @@ class WorkspaceMemberBloc ), ); }, + 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), + ); + } + }, ); }); } @@ -178,9 +205,11 @@ class WorkspaceMemberBloc return role; } - Future _setCurrentWorkspaceId() async { + 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) { @@ -192,6 +221,22 @@ class WorkspaceMemberBloc }); } } + + // 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 @@ -209,6 +254,11 @@ class WorkspaceMemberEvent with _$WorkspaceMemberEvent { String email, AFRolePB role, ) = UpdateWorkspaceMember; + const factory WorkspaceMemberEvent.updateSubscriptionInfo( + WorkspaceSubscriptionInfoPB subscriptionInfo, + ) = UpdateSubscriptionInfo; + + const factory WorkspaceMemberEvent.upgradePlan() = UpgradePlan; } enum WorkspaceMemberActionType { @@ -241,6 +291,7 @@ class WorkspaceMemberState with _$WorkspaceMemberState { @Default(AFRolePB.Guest) AFRolePB myRole, @Default(null) WorkspaceMemberActionResult? actionResult, @Default(true) bool isLoading, + @Default(null) WorkspaceSubscriptionInfoPB? subscriptionInfo, }) = _WorkspaceMemberState; factory WorkspaceMemberState.initial() => const WorkspaceMemberState(); @@ -255,6 +306,7 @@ class WorkspaceMemberState with _$WorkspaceMemberState { 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 index 64f5914e7c..bf33ab9d72 100644 --- 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 @@ -1,29 +1,32 @@ +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/shared/settings_header.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:appflowy_popover/appflowy_popover.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/flowy_tooltip.dart'; -import 'package:flowy_infra_ui/widget/rounded_button.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}); + const WorkspaceMembersPage({ + super.key, + required this.userProfile, + required this.workspaceId, + }); final UserProfilePB userProfile; + final String workspaceId; @override Widget build(BuildContext context) { @@ -34,14 +37,17 @@ class WorkspaceMembersPage extends StatelessWidget { listener: _showResultDialog, builder: (context, state) { return SettingsBody( + title: LocaleKeys.settings_appearance_members_title.tr(), + autoSeparate: false, children: [ - // title - SettingsHeader( - title: LocaleKeys.settings_appearance_members_title.tr(), - ), - if (state.myRole.canInvite) const _InviteMember(), - if (state.myRole.canInvite && state.members.isNotEmpty) + 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, @@ -55,6 +61,105 @@ class WorkspaceMembersPage extends StatelessWidget { ); } + 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) { @@ -95,22 +200,21 @@ class WorkspaceMembersPage extends StatelessWidget { (f) { Log.error('invite workspace member failed: $f'); final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded - ? LocaleKeys.settings_appearance_members_memberLimitExceeded.tr() + ? LocaleKeys.settings_appearance_members_inviteFailedMemberLimit + .tr() : LocaleKeys.settings_appearance_members_failedToInviteMember .tr(); - showDialog( + showConfirmDialog( context: context, - builder: (context) => NavigatorOkCancelDialog(message: message), + title: LocaleKeys + .settings_appearance_members_inviteFailedDialogTitle + .tr(), + description: message, + confirmLabel: LocaleKeys.button_ok.tr(), ); }, ); } - - result.onFailure((f) { - Log.error( - '[Member] Failed to perform ${actionType.toString()} action: $f', - ); - }); } } @@ -149,6 +253,8 @@ class _InviteMemberState extends State<_InviteMember> { height: 48.0, ), child: FlowyTextField( + hintText: + LocaleKeys.settings_appearance_members_inviteHint.tr(), controller: _emailController, onEditingComplete: _inviteMember, ), @@ -158,11 +264,13 @@ class _InviteMemberState extends State<_InviteMember> { SizedBox( height: 48.0, child: IntrinsicWidth( - child: RoundedTextButton( - title: LocaleKeys.settings_appearance_members_sendInvite.tr(), - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - onPressed: _inviteMember, + child: PrimaryRoundedButton( + text: LocaleKeys.settings_appearance_members_sendInvite.tr(), + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + onTap: _inviteMember, ), ), ), 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 index 6805633f74..5f158f4ae1 100644 --- 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 @@ -1,22 +1,28 @@ -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/plugins/shared/share/constants.dart'; +import 'package:appflowy/shared/error_page/error_page.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/settings/widgets/web_url_hint_widget.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/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class AppFlowyCloudViewSetting extends StatelessWidget { @@ -42,11 +48,11 @@ class AppFlowyCloudViewSetting extends StatelessWidget { (setting) => _renderContent(context, setting), (err) => FlowyErrorPage.message(err.toString(), howToFix: ""), ); - } else { - return const Center( - child: CircularProgressIndicator(), - ); } + + return const Center( + child: CircularProgressIndicator(), + ); }, ); } @@ -62,13 +68,19 @@ class AppFlowyCloudViewSetting extends StatelessWidget { builder: (context, state) { return Column( children: [ + const VSpace(8), const AppFlowyCloudEnableSync(), + const VSpace(6), + // const AppFlowyCloudSyncLogEnabled(), const VSpace(12), RestartButton( onClick: () { NavigatorAlertDialog( title: LocaleKeys.settings_menu_restartAppTip.tr(), confirm: () async { + await useBaseWebDomain( + ShareConstants.defaultBaseWebDomain, + ); await useAppFlowyBetaCloudWithURL( serverURL, authenticatorType, @@ -118,6 +130,7 @@ class CustomAppFlowyCloudView extends StatelessWidget { final List children = []; children.addAll([ const AppFlowyCloudEnableSync(), + // const AppFlowyCloudSyncLogEnabled(), const VSpace(40), ]); @@ -141,6 +154,7 @@ class CustomAppFlowyCloudView extends StatelessWidget { create: (context) => AppFlowyCloudSettingBloc(setting) ..add(const AppFlowyCloudSettingEvent.initial()), child: Column( + mainAxisSize: MainAxisSize.min, children: children, ), ); @@ -166,8 +180,10 @@ class AppFlowyCloudURLs extends StatelessWidget { child: BlocBuilder( builder: (context, state) { return Column( + mainAxisSize: MainAxisSize.min, children: [ - const AppFlowySelfhostTip(), + const AppFlowySelfHostTip(), + const VSpace(12), CloudURLInput( title: LocaleKeys.settings_menu_cloudURL.tr(), url: state.config.base_url, @@ -181,6 +197,20 @@ class AppFlowyCloudURLs extends StatelessWidget { }, ), const VSpace(8), + CloudURLInput( + title: LocaleKeys.settings_menu_webURL.tr(), + url: state.config.base_web_domain, + hint: LocaleKeys.settings_menu_webURLHint.tr(), + hintBuilder: (context) => const WebUrlHintWidget(), + onChanged: (text) { + context.read().add( + AppFlowyCloudURLsEvent.updateBaseWebDomain( + text, + ), + ); + }, + ), + const VSpace(12), RestartButton( onClick: () { NavigatorAlertDialog( @@ -203,8 +233,8 @@ class AppFlowyCloudURLs extends StatelessWidget { } } -class AppFlowySelfhostTip extends StatelessWidget { - const AppFlowySelfhostTip({super.key}); +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"; @@ -249,12 +279,14 @@ class CloudURLInput extends StatefulWidget { required this.url, required this.hint, required this.onChanged, + this.hintBuilder, }); final String title; final String url; final String hint; - final Function(String) onChanged; + final ValueChanged onChanged; + final WidgetBuilder? hintBuilder; @override CloudURLInputState createState() => CloudURLInputState(); @@ -277,27 +309,55 @@ class CloudURLInputState extends State { @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: Theme.of(context).colorScheme.onBackground), + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHint(context), + SizedBox( + height: 28, + child: TextField( + controller: _controller, + style: Theme.of(context).textTheme.titleMedium!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w400, + ), + decoration: InputDecoration( + 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, + ), ), - focusedBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), - ), - hintText: widget.hint, - errorText: context.read().state.urlError, + ], + ); + } + + Widget _buildHint(BuildContext context) { + final children = [ + FlowyText( + widget.title, + fontSize: 12, ), - onChanged: widget.onChanged, + ]; + + if (widget.hintBuilder != null) { + children.add(widget.hintBuilder!(context)); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: children, ); } } @@ -313,14 +373,11 @@ class AppFlowyCloudEnableSync extends StatelessWidget { children: [ FlowyText.medium(LocaleKeys.settings_menu_enableSync.tr()), const Spacer(), - Switch.adaptive( - onChanged: (bool value) { - context.read().add( - AppFlowyCloudSettingEvent.enableSync(value), - ); - }, - activeColor: Theme.of(context).colorScheme.primary, + Toggle( value: state.setting.enableSync, + onChanged: (value) => context + .read() + .add(AppFlowyCloudSettingEvent.enableSync(value)), ), ], ); @@ -328,3 +385,92 @@ class AppFlowyCloudEnableSync extends StatelessWidget { ); } } + +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 index 8d6b48976e..692be99baa 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart @@ -1,5 +1,3 @@ -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'; @@ -8,23 +6,26 @@ 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_header.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: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/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:universal_platform/universal_platform.dart'; import 'setting_appflowy_cloud.dart'; -import 'setting_supabase_cloud.dart'; class SettingCloud extends StatelessWidget { - const SettingCloud({required this.restartAppFlowy, super.key}); + const SettingCloud({ + super.key, + required this.restartAppFlowy, + }); final VoidCallback restartAppFlowy; @@ -41,31 +42,11 @@ class SettingCloud extends StatelessWidget { child: BlocBuilder( builder: (context, state) { return SettingsBody( + title: LocaleKeys.settings_menu_cloudSettings.tr(), + autoSeparate: false, children: [ - SettingsHeader( - title: LocaleKeys.settings_menu_cloudSettings.tr(), - ), if (Env.enableCustomCloud) - Row( - children: [ - Expanded( - child: FlowyText.medium( - LocaleKeys.settings_menu_cloudServerType.tr(), - ), - ), - CloudTypeSwitcher( - cloudType: state.cloudType, - onSelected: (newCloudType) { - context.read().add( - CloudSettingEvent.updateCloudType( - newCloudType, - ), - ); - }, - ), - ], - ), - const VSpace(8), + _CloudServerSwitcher(cloudType: state.cloudType), _viewFromCloudType(state.cloudType), ], ); @@ -73,9 +54,7 @@ class SettingCloud extends StatelessWidget { ), ); } else { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } }, ); @@ -84,21 +63,11 @@ class SettingCloud extends StatelessWidget { Widget _viewFromCloudType(AuthenticatorType cloudType) { switch (cloudType) { case AuthenticatorType.local: - return SettingLocalCloud( - restartAppFlowy: restartAppFlowy, - ); - case AuthenticatorType.supabase: - return SettingSupabaseCloudView( - restartAppFlowy: restartAppFlowy, - ); + return SettingLocalCloud(restartAppFlowy: restartAppFlowy); case AuthenticatorType.appflowyCloud: - return AppFlowyCloudViewSetting( - restartAppFlowy: restartAppFlowy, - ); + return AppFlowyCloudViewSetting(restartAppFlowy: restartAppFlowy); case AuthenticatorType.appflowyCloudSelfHost: - return CustomAppFlowyCloudView( - restartAppFlowy: restartAppFlowy, - ); + return CustomAppFlowyCloudView(restartAppFlowy: restartAppFlowy); case AuthenticatorType.appflowyCloudDevelop: return AppFlowyCloudViewSetting( serverURL: "http://localhost", @@ -124,36 +93,31 @@ class CloudTypeSwitcher extends StatelessWidget { final isDevelopMode = integrationMode().isDevelop; // Only show the appflowyCloudDevelop in develop mode final values = AuthenticatorType.values.where((element) { - // Supabase will going to be removed in the future - if (element == AuthenticatorType.supabase) { - return false; - } - return isDevelopMode || element != AuthenticatorType.appflowyCloudDevelop; }).toList(); - return PlatformExtension.isDesktopOrWeb - ? AppFlowyPopover( - direction: PopoverDirection.bottomWithRightAligned, - child: FlowyTextButton( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), - titleFromCloudType(cloudType), - fontColor: Theme.of(context).colorScheme.onBackground, - fillColor: Colors.transparent, - onPressed: () {}, - ), - popupBuilder: (BuildContext context) { - return ListView.builder( - shrinkWrap: true, - itemBuilder: (context, index) { - return CloudTypeItem( - cloudType: values[index], - currentCloudtype: cloudType, - onSelected: onSelected, - ); - }, - itemCount: values.length, - ); + 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( @@ -163,33 +127,28 @@ class CloudTypeSwitcher extends StatelessWidget { rightIcon: const Icon( Icons.chevron_right, ), - onTap: () { - showMobileBottomSheet( - context, - showHeader: true, - showDragHandle: true, - showDivider: false, - showCloseButton: false, - title: LocaleKeys.settings_menu_cloudServerType.tr(), - builder: (context) { - return 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(), - ); - }, - ); - }, + 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(), + ), + ), ); } } @@ -198,12 +157,12 @@ class CloudTypeItem extends StatelessWidget { const CloudTypeItem({ super.key, required this.cloudType, - required this.currentCloudtype, + required this.currentCloudType, required this.onSelected, }); final AuthenticatorType cloudType; - final AuthenticatorType currentCloudtype; + final AuthenticatorType currentCloudType; final Function(AuthenticatorType) onSelected; @override @@ -214,11 +173,11 @@ class CloudTypeItem extends StatelessWidget { text: FlowyText.medium( titleFromCloudType(cloudType), ), - rightIcon: currentCloudtype == cloudType + rightIcon: currentCloudType == cloudType ? const FlowySvg(FlowySvgs.check_s) : null, onTap: () { - if (currentCloudtype != cloudType) { + if (currentCloudType != cloudType) { NavigatorAlertDialog( title: LocaleKeys.settings_menu_changeServerTip.tr(), confirm: () async { @@ -234,12 +193,54 @@ class CloudTypeItem extends StatelessWidget { } } +class _CloudServerSwitcher extends StatelessWidget { + const _CloudServerSwitcher({ + required this.cloudType, + }); + + final AuthenticatorType cloudType; + + @override + Widget build(BuildContext context) { + return UniversalPlatform.isDesktopOrWeb + ? Row( + children: [ + Expanded( + child: FlowyText.medium( + LocaleKeys.settings_menu_cloudServerType.tr(), + ), + ), + Flexible( + child: CloudTypeSwitcher( + cloudType: cloudType, + onSelected: (type) => context + .read() + .add(CloudSettingEvent.updateCloudType(type)), + ), + ), + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FlowyText.medium( + LocaleKeys.settings_menu_cloudServerType.tr(), + ), + CloudTypeSwitcher( + cloudType: cloudType, + onSelected: (type) => context + .read() + .add(CloudSettingEvent.updateCloudType(type)), + ), + ], + ); + } +} + String titleFromCloudType(AuthenticatorType cloudType) { switch (cloudType) { case AuthenticatorType.local: return LocaleKeys.settings_menu_cloudLocal.tr(); - case AuthenticatorType.supabase: - return LocaleKeys.settings_menu_cloudSupabase.tr(); case AuthenticatorType.appflowyCloud: return LocaleKeys.settings_menu_cloudAppFlowy.tr(); case AuthenticatorType.appflowyCloudSelfHost: diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart deleted file mode 100644 index c014cdf516..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart +++ /dev/null @@ -1,345 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/supabase_cloud_setting_bloc.dart'; -import 'package:appflowy/workspace/application/settings/supabase_cloud_urls_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_button.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.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_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_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class SettingSupabaseCloudView extends StatelessWidget { - const SettingSupabaseCloudView({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) { - return BlocProvider( - create: (context) => SupabaseCloudSettingBloc( - setting: setting, - )..add(const SupabaseCloudSettingEvent.initial()), - child: Column( - children: [ - BlocBuilder( - builder: (context, state) { - return const Column( - children: [ - SupabaseEnableSync(), - EnableEncrypt(), - ], - ); - }, - ), - const VSpace(40), - const SupabaseSelfhostTip(), - SupabaseCloudURLs( - didUpdateUrls: restartAppFlowy, - ), - ], - ), - ); - }, - (err) { - return FlowyErrorPage.message(err.toString(), howToFix: ""); - }, - ); - } else { - return const Center( - child: CircularProgressIndicator(), - ); - } - }, - ); - } -} - -class SupabaseCloudURLs extends StatelessWidget { - const SupabaseCloudURLs({super.key, required this.didUpdateUrls}); - - final VoidCallback didUpdateUrls; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => SupabaseCloudURLsBloc(), - child: BlocListener( - listener: (context, state) async { - if (state.restartApp) { - didUpdateUrls(); - } - }, - child: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - SupabaseInput( - title: LocaleKeys.settings_menu_cloudSupabaseUrl.tr(), - url: state.config.url, - hint: LocaleKeys.settings_menu_cloudURLHint.tr(), - onChanged: (text) { - context - .read() - .add(SupabaseCloudURLsEvent.updateUrl(text)); - }, - error: state.urlError, - ), - SupabaseInput( - title: LocaleKeys.settings_menu_cloudSupabaseAnonKey.tr(), - url: state.config.anon_key, - hint: LocaleKeys.settings_menu_cloudURLHint.tr(), - onChanged: (text) { - context - .read() - .add(SupabaseCloudURLsEvent.updateAnonKey(text)); - }, - error: state.anonKeyError, - ), - const VSpace(20), - RestartButton( - onClick: () => _restartApp(context), - showRestartHint: state.showRestartHint, - ), - ], - ); - }, - ), - ), - ); - } - - void _restartApp(BuildContext context) { - NavigatorAlertDialog( - title: LocaleKeys.settings_menu_restartAppTip.tr(), - confirm: () => context - .read() - .add(const SupabaseCloudURLsEvent.confirmUpdate()), - ).show(context); - } -} - -class EnableEncrypt extends StatelessWidget { - const EnableEncrypt({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final indicator = state.loadingState.when( - loading: () => const CircularProgressIndicator.adaptive(), - finish: (successOrFail) => const SizedBox.shrink(), - idle: () => const SizedBox.shrink(), - ); - - return Column( - children: [ - Row( - children: [ - FlowyText.medium(LocaleKeys.settings_menu_enableEncrypt.tr()), - const Spacer(), - indicator, - const HSpace(3), - Switch.adaptive( - activeColor: Theme.of(context).colorScheme.primary, - onChanged: state.setting.enableEncrypt - ? null - : (bool value) { - context.read().add( - SupabaseCloudSettingEvent.enableEncrypt(value), - ); - }, - value: state.setting.enableEncrypt, - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - IntrinsicHeight( - child: Opacity( - opacity: 0.6, - child: FlowyText.medium( - LocaleKeys.settings_menu_enableEncryptPrompt.tr(), - maxLines: 13, - ), - ), - ), - const VSpace(6), - SizedBox( - height: 40, - child: FlowyTooltip( - message: LocaleKeys.settings_menu_clickToCopySecret.tr(), - child: FlowyButton( - disable: !state.setting.enableEncrypt, - decoration: BoxDecoration( - borderRadius: Corners.s5Border, - border: Border.all( - color: Theme.of(context).colorScheme.secondary, - ), - ), - text: FlowyText.medium(state.setting.encryptSecret), - onTap: () async { - await Clipboard.setData( - ClipboardData(text: state.setting.encryptSecret), - ); - showMessageToast(LocaleKeys.message_copy_success.tr()); - }, - ), - ), - ), - ], - ), - ], - ); - }, - ); - } -} - -class SupabaseEnableSync extends StatelessWidget { - const SupabaseEnableSync({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return Row( - children: [ - FlowyText.medium(LocaleKeys.settings_menu_enableSync.tr()), - const Spacer(), - Switch.adaptive( - activeColor: Theme.of(context).colorScheme.primary, - onChanged: (bool value) { - context.read().add( - SupabaseCloudSettingEvent.enableSync(value), - ); - }, - value: state.setting.enableSync, - ), - ], - ); - }, - ); - } -} - -@visibleForTesting -class SupabaseInput extends StatefulWidget { - const SupabaseInput({ - super.key, - required this.title, - required this.url, - required this.hint, - required this.error, - required this.onChanged, - }); - - final String title; - final String url; - final String hint; - final String? error; - final Function(String) onChanged; - - @override - SupabaseInputState createState() => SupabaseInputState(); -} - -class SupabaseInputState 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: Theme.of(context).colorScheme.onBackground), - ), - focusedBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), - ), - hintText: widget.hint, - errorText: widget.error, - ), - onChanged: widget.onChanged, - ); - } -} - -class SupabaseSelfhostTip extends StatelessWidget { - const SupabaseSelfhostTip({super.key}); - - final url = - "https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy-using-supabase"; - - @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!, - ), - ], - ), - ), - ); - } -} 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 index e623652f8b..8a85377efe 100644 --- 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 @@ -1,10 +1,7 @@ -import 'package:flutter/material.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/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'; @@ -12,10 +9,14 @@ 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({required this.didLogin, super.key}); + const SettingThirdPartyLogin({ + super.key, + required this.didLogin, + }); final VoidCallback didLogin; @@ -62,12 +63,8 @@ class SettingThirdPartyLogin extends StatelessWidget { ) async { result.fold( (user) async { - if (user.encryptionType == EncryptionTypePB.Symmetric) { - getIt().pushEncryptionScreen(context, user); - } else { - didLogin(); - await runAppFlowy(); - } + didLogin(); + await runAppFlowy(); }, (error) => showSnapBar(context, error.msg), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart deleted file mode 100644 index aaa7004014..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'dart:io'; - -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/util/theme_mode_extension.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy_popover/appflowy_popover.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 'theme_setting_entry_template.dart'; - -class BrightnessSetting extends StatelessWidget { - const BrightnessSetting({required this.currentThemeMode, super.key}); - - final ThemeMode currentThemeMode; - - @override - Widget build(BuildContext context) { - return FlowySettingListTile( - label: LocaleKeys.settings_appearance_themeMode_label.tr(), - hint: hintText, - onResetRequested: context.read().resetThemeMode, - trailing: [ - FlowySettingValueDropDown( - currentValue: currentThemeMode.labelText, - popupBuilder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _themeModeItemButton(context, ThemeMode.light), - _themeModeItemButton(context, ThemeMode.dark), - _themeModeItemButton(context, ThemeMode.system), - ], - ), - ), - ], - ); - } - - String get hintText => - '${LocaleKeys.settings_files_change.tr()} ${LocaleKeys.settings_appearance_themeMode_label.tr()} : ${Platform.isMacOS ? '⌘+Shift+L' : 'Ctrl+Shift+L'}'; - - Widget _themeModeItemButton( - BuildContext context, - ThemeMode themeMode, - ) { - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium(themeMode.labelText), - rightIcon: currentThemeMode == themeMode - ? const FlowySvg( - FlowySvgs.check_s, - ) - : null, - onTap: () { - if (currentThemeMode != themeMode) { - context.read().setThemeMode(themeMode); - } - PopoverContainer.of(context).close(); - }, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart deleted file mode 100644 index bf91f92097..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart +++ /dev/null @@ -1,174 +0,0 @@ -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/presentation/home/toast.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; -import 'package:appflowy_popover/appflowy_popover.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/plugins/bloc/dynamic_plugin_state.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class ColorSchemeSetting extends StatelessWidget { - const ColorSchemeSetting({ - super.key, - required this.currentTheme, - required this.bloc, - }); - - final String currentTheme; - final DynamicPluginBloc bloc; - - @override - Widget build(BuildContext context) { - return FlowySettingListTile( - label: LocaleKeys.settings_appearance_theme.tr(), - onResetRequested: context.read().resetTheme, - trailing: [ - ColorSchemeUploadPopover(currentTheme: currentTheme, bloc: bloc), - ColorSchemeUploadOverlayButton(bloc: bloc), - ], - ); - } -} - -class ColorSchemeUploadOverlayButton extends StatelessWidget { - const ColorSchemeUploadOverlayButton({super.key, required this.bloc}); - - final DynamicPluginBloc bloc; - - @override - Widget build(BuildContext context) { - return FlowyIconButton( - width: 24, - icon: FlowySvg( - FlowySvgs.folder_m, - size: const Size.square(16), - color: Theme.of(context).iconTheme.color, - ), - iconColorOnHover: Theme.of(context).colorScheme.onPrimary, - hoverColor: Theme.of(context).colorScheme.secondaryContainer, - tooltipText: LocaleKeys.settings_appearance_themeUpload_uploadTheme.tr(), - onPressed: () => Dialogs.show( - context, - child: BlocProvider.value( - value: bloc, - child: const FlowyDialog( - constraints: BoxConstraints(maxHeight: 300), - child: ThemeUploadWidget(), - ), - ), - ).then((value) { - if (value == null) return; - showSnackBarMessage( - context, - LocaleKeys.settings_appearance_themeUpload_uploadSuccess.tr(), - ); - }), - ); - } -} - -class ColorSchemeUploadPopover extends StatelessWidget { - const ColorSchemeUploadPopover({ - super.key, - required this.currentTheme, - required this.bloc, - }); - - final String currentTheme; - final DynamicPluginBloc bloc; - - @override - Widget build(BuildContext context) { - return AppFlowyPopover( - direction: PopoverDirection.bottomWithRightAligned, - child: FlowyTextButton( - currentTheme, - fontColor: Theme.of(context).colorScheme.onBackground, - fillColor: Colors.transparent, - onPressed: () {}, - ), - popupBuilder: (BuildContext context) { - return IntrinsicWidth( - child: BlocBuilder( - bloc: bloc..add(DynamicPluginEvent.load()), - buildWhen: (previous, current) => current is Ready, - builder: (context, state) { - return state.maybeWhen( - ready: (plugins) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - ...AppTheme.builtins.map( - (theme) => _themeItemButton(context, theme.themeName), - ), - if (plugins.isNotEmpty) ...[ - const Divider(), - ...plugins - .map((plugin) => plugin.theme) - .whereType() - .map( - (theme) => _themeItemButton( - context, - theme.themeName, - false, - ), - ), - ], - ], - ), - orElse: () => const SizedBox.shrink(), - ); - }, - ), - ); - }, - ); - } - - Widget _themeItemButton( - BuildContext context, - String theme, [ - bool isBuiltin = true, - ]) { - return SizedBox( - height: 32, - child: Row( - children: [ - Expanded( - child: FlowyButton( - text: FlowyText.medium(theme), - rightIcon: currentTheme == theme - ? const FlowySvg( - FlowySvgs.check_s, - ) - : null, - onTap: () { - if (currentTheme != theme) { - context.read().setTheme(theme); - } - PopoverContainer.of(context).close(); - }, - ), - ), - // when the custom theme is not the current theme, show the remove button - if (!isBuiltin && currentTheme != theme) - FlowyIconButton( - icon: const FlowySvg( - FlowySvgs.close_s, - ), - width: 20, - onPressed: () => - bloc.add(DynamicPluginEvent.removePlugin(name: theme)), - ), - ], - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart deleted file mode 100644 index d45bc44b27..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -bool _prevSetting = false; - -class CreateFileSettings extends StatelessWidget { - CreateFileSettings({ - super.key, - }); - - final cubit = CreateFileSettingsCubit(_prevSetting); - - @override - Widget build(BuildContext context) { - return FlowySettingListTile( - label: - LocaleKeys.settings_appearance_showNamingDialogWhenCreatingPage.tr(), - trailing: [ - BlocProvider.value( - value: cubit, - child: BlocBuilder( - builder: (context, state) { - _prevSetting = state; - return Switch( - value: state, - splashRadius: 0, - activeColor: Theme.of(context).colorScheme.primary, - onChanged: (value) { - cubit.toggle(value: value); - _prevSetting = value; - }, - ); - }, - ), - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart deleted file mode 100644 index 3f78e35478..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart +++ /dev/null @@ -1,72 +0,0 @@ -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_backend/protobuf/flowy-user/date_time.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 'theme_setting_entry_template.dart'; - -class DateFormatSetting extends StatelessWidget { - const DateFormatSetting({ - super.key, - required this.currentFormat, - }); - - final UserDateFormatPB currentFormat; - - @override - Widget build(BuildContext context) => FlowySettingListTile( - label: LocaleKeys.settings_appearance_dateFormat_label.tr(), - trailing: [ - FlowySettingValueDropDown( - currentValue: _formatLabel(currentFormat), - popupBuilder: (_) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _formatItem(context, UserDateFormatPB.Locally), - _formatItem(context, UserDateFormatPB.US), - _formatItem(context, UserDateFormatPB.ISO), - _formatItem(context, UserDateFormatPB.Friendly), - _formatItem(context, UserDateFormatPB.DayMonthYear), - ], - ), - ), - ], - ); - - Widget _formatItem(BuildContext context, UserDateFormatPB format) { - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium(_formatLabel(format)), - rightIcon: - currentFormat == format ? const FlowySvg(FlowySvgs.check_s) : null, - onTap: () { - if (currentFormat != format) { - context.read().setDateFormat(format); - } - }, - ), - ); - } - - String _formatLabel(UserDateFormatPB format) { - switch (format) { - case (UserDateFormatPB.Locally): - return LocaleKeys.settings_appearance_dateFormat_local.tr(); - case (UserDateFormatPB.US): - return LocaleKeys.settings_appearance_dateFormat_us.tr(); - case (UserDateFormatPB.ISO): - return LocaleKeys.settings_appearance_dateFormat_iso.tr(); - case (UserDateFormatPB.Friendly): - return LocaleKeys.settings_appearance_dateFormat_friendly.tr(); - case (UserDateFormatPB.DayMonthYear): - return LocaleKeys.settings_appearance_dateFormat_dmy.tr(); - default: - return ""; - } - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart deleted file mode 100644 index 1f21cfa4bb..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart +++ /dev/null @@ -1,173 +0,0 @@ -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/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy_popover/appflowy_popover.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 'theme_setting_entry_template.dart'; - -class LayoutDirectionSetting extends StatelessWidget { - const LayoutDirectionSetting({ - super.key, - required this.currentLayoutDirection, - }); - - final LayoutDirection currentLayoutDirection; - - @override - Widget build(BuildContext context) { - return FlowySettingListTile( - label: LocaleKeys.settings_appearance_layoutDirection_label.tr(), - hint: LocaleKeys.settings_appearance_layoutDirection_hint.tr(), - trailing: [ - FlowySettingValueDropDown( - currentValue: _layoutDirectionLabelText(currentLayoutDirection), - popupBuilder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _layoutDirectionItemButton(context, LayoutDirection.ltrLayout), - _layoutDirectionItemButton(context, LayoutDirection.rtlLayout), - ], - ), - ), - ], - ); - } - - Widget _layoutDirectionItemButton( - BuildContext context, - LayoutDirection direction, - ) { - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium(_layoutDirectionLabelText(direction)), - rightIcon: currentLayoutDirection == direction - ? const FlowySvg(FlowySvgs.check_s) - : null, - onTap: () { - if (currentLayoutDirection != direction) { - context - .read() - .setLayoutDirection(direction); - } - PopoverContainer.of(context).close(); - }, - ), - ); - } - - String _layoutDirectionLabelText(LayoutDirection direction) { - switch (direction) { - case (LayoutDirection.ltrLayout): - return LocaleKeys.settings_appearance_layoutDirection_ltr.tr(); - case (LayoutDirection.rtlLayout): - return LocaleKeys.settings_appearance_layoutDirection_rtl.tr(); - default: - return ''; - } - } -} - -class TextDirectionSetting extends StatelessWidget { - const TextDirectionSetting({ - super.key, - required this.currentTextDirection, - }); - - final AppFlowyTextDirection? currentTextDirection; - - @override - Widget build(BuildContext context) => FlowySettingListTile( - label: LocaleKeys.settings_appearance_textDirection_label.tr(), - hint: LocaleKeys.settings_appearance_textDirection_hint.tr(), - trailing: [ - FlowySettingValueDropDown( - currentValue: _textDirectionLabelText(currentTextDirection), - popupBuilder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _textDirectionItemButton(context, null), - _textDirectionItemButton(context, AppFlowyTextDirection.ltr), - _textDirectionItemButton(context, AppFlowyTextDirection.rtl), - _textDirectionItemButton(context, AppFlowyTextDirection.auto), - ], - ), - ), - ], - ); - - Widget _textDirectionItemButton( - BuildContext context, - AppFlowyTextDirection? textDirection, - ) { - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium(_textDirectionLabelText(textDirection)), - rightIcon: currentTextDirection == textDirection - ? const FlowySvg(FlowySvgs.check_s) - : null, - onTap: () { - if (currentTextDirection != textDirection) { - context - .read() - .setTextDirection(textDirection); - context - .read() - .syncDefaultTextDirection(textDirection?.name); - } - PopoverContainer.of(context).close(); - }, - ), - ); - } - - String _textDirectionLabelText(AppFlowyTextDirection? textDirection) { - switch (textDirection) { - case (AppFlowyTextDirection.ltr): - return LocaleKeys.settings_appearance_textDirection_ltr.tr(); - case (AppFlowyTextDirection.rtl): - return LocaleKeys.settings_appearance_textDirection_rtl.tr(); - case (AppFlowyTextDirection.auto): - return LocaleKeys.settings_appearance_textDirection_auto.tr(); - default: - return LocaleKeys.settings_appearance_textDirection_fallback.tr(); - } - } -} - -class EnableRTLToolbarItemsSetting extends StatelessWidget { - const EnableRTLToolbarItemsSetting({ - super.key, - }); - - static const enableRTLSwitchKey = ValueKey('enable_rtl_toolbar_items_switch'); - - @override - Widget build(BuildContext context) { - return FlowySettingListTile( - label: LocaleKeys.settings_appearance_enableRTLToolbarItems.tr(), - trailing: [ - Switch( - key: enableRTLSwitchKey, - value: context - .read() - .state - .enableRtlToolbarItems, - splashRadius: 0, - activeColor: Theme.of(context).colorScheme.primary, - onChanged: (value) { - context - .read() - .setEnableRTLToolbarItems(value); - }, - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart deleted file mode 100644 index 203d42e1f5..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart +++ /dev/null @@ -1,266 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/util/color_to_hex_string.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; -import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flutter/material.dart'; - -class DocumentColorSettingButton extends StatelessWidget { - 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 - Widget build(BuildContext context) { - return FlowyButton( - margin: const EdgeInsets.all(8), - text: previewWidgetBuilder.call(currentColor), - hoverColor: Theme.of(context).colorScheme.secondaryContainer, - expandText: false, - onTap: () => Dialogs.show( - context, - child: _DocumentColorSettingDialog( - currentColor: currentColor, - previewWidgetBuilder: previewWidgetBuilder, - dialogTitle: dialogTitle, - onApply: onApply, - ), - ), - ); - } -} - -class _DocumentColorSettingDialog extends StatefulWidget { - const _DocumentColorSettingDialog({ - required this.currentColor, - required this.previewWidgetBuilder, - required this.dialogTitle, - required this.onApply, - }); - - final Color currentColor; - - final Widget Function(Color?) previewWidgetBuilder; - - final String dialogTitle; - - final void Function(Color selectedColorOnDialog) onApply; - - @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; - final _formKey = GlobalKey(debugLabel: 'colorSettingForm'); - - void updateSelectedColor() { - if (_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!); - }); - } - } - - @override - void initState() { - super.initState(); - selectedColorOnDialog = widget.currentColor; - currentColorHexString = 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 FlowyDialog( - constraints: const BoxConstraints(maxWidth: 360, maxHeight: 320), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - const Spacer(), - FlowyText(widget.dialogTitle), - const VSpace(8), - SizedBox( - width: 100, - height: 40, - child: Center( - child: widget.previewWidgetBuilder( - selectedColorOnDialog, - ), - ), - ), - const VSpace(8), - SizedBox( - height: 160, - child: Form( - key: _formKey, - child: Column( - children: [ - _ColorSettingTextField( - controller: hexController, - labelText: LocaleKeys.editor_hexValue.tr(), - hintText: '6fc9e7', - onFieldSubmitted: (_) => updateSelectedColor(), - validator: (hexValue) => validateHexValue( - hexValue, - opacityController.text, - ), - ), - const VSpace(8), - _ColorSettingTextField( - controller: opacityController, - labelText: LocaleKeys.editor_opacity.tr(), - hintText: '50', - onFieldSubmitted: (_) => updateSelectedColor(), - validator: (value) => validateOpacityValue(value), - ), - ], - ), - ), - ), - const VSpace(8), - RoundedTextButton( - title: LocaleKeys.settings_appearance_documentSettings_apply.tr(), - width: 100, - height: 30, - onPressed: () { - if (_formKey.currentState!.validate()) { - if (selectedColorOnDialog != null && - selectedColorOnDialog != widget.currentColor) { - widget.onApply.call(selectedColorOnDialog!); - } - } else { - // error message will be shown below the text field - return; - } - Navigator.of(context).pop(); - }, - ), - ], - ), - ), - ); - } -} - -class _ColorSettingTextField extends StatelessWidget { - const _ColorSettingTextField({ - required this.controller, - required this.labelText, - required this.hintText, - required this.onFieldSubmitted, - required this.validator, - }); - - final TextEditingController controller; - final String labelText; - final String hintText; - - final void Function(String) onFieldSubmitted; - 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, - border: OutlineInputBorder( - borderSide: BorderSide( - color: style.colorScheme.outline, - ), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: style.colorScheme.outline, - ), - ), - ), - style: style.textTheme.bodyMedium, - 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; -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart deleted file mode 100644 index c0eb42bf4a..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart +++ /dev/null @@ -1,83 +0,0 @@ -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/workspace/application/appearance_defaults.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.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 DocumentCursorColorSetting extends StatelessWidget { - const DocumentCursorColorSetting({ - super.key, - required this.currentCursorColor, - }); - - final Color currentCursorColor; - - @override - Widget build(BuildContext context) { - final label = - LocaleKeys.settings_appearance_documentSettings_cursorColor.tr(); - return FlowySettingListTile( - label: label, - resetButtonKey: const Key('DocumentCursorColorResetButton'), - onResetRequested: () { - context.read().resetDocumentCursorColor(); - context.read().syncCursorColor(null); - }, - trailing: [ - DocumentColorSettingButton( - key: const Key('DocumentCursorColorSettingButton'), - currentColor: currentCursorColor, - previewWidgetBuilder: (color) => _CursorColorValueWidget( - cursorColor: color ?? - DefaultAppearanceSettings.getDefaultDocumentCursorColor( - context, - ), - ), - dialogTitle: label, - onApply: (selectedColorOnDialog) { - context - .read() - .setDocumentCursorColor(selectedColorOnDialog); - // update the state of document appearance cubit with latest cursor color - context - .read() - .syncCursorColor(selectedColorOnDialog); - }, - ), - ], - ); - } -} - -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: Theme.of(context).colorScheme.onBackground, - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart deleted file mode 100644 index 89e3e18a63..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart +++ /dev/null @@ -1,86 +0,0 @@ -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/workspace/application/appearance_defaults.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.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 DocumentSelectionColorSetting extends StatelessWidget { - const DocumentSelectionColorSetting({ - super.key, - required this.currentSelectionColor, - }); - - final Color currentSelectionColor; - - @override - Widget build(BuildContext context) { - final label = - LocaleKeys.settings_appearance_documentSettings_selectionColor.tr(); - - return FlowySettingListTile( - label: label, - resetButtonKey: const Key('DocumentSelectionColorResetButton'), - onResetRequested: () { - context.read().resetDocumentSelectionColor(); - context.read().syncSelectionColor(null); - }, - trailing: [ - DocumentColorSettingButton( - currentColor: currentSelectionColor, - previewWidgetBuilder: (color) => _SelectionColorValueWidget( - selectionColor: color ?? - DefaultAppearanceSettings.getDefaultDocumentSelectionColor( - context, - ), - ), - dialogTitle: label, - onApply: (selectedColorOnDialog) { - context - .read() - .setDocumentSelectionColor(selectedColorOnDialog); - // update the state of document appearance cubit with latest selection color - context - .read() - .syncSelectionColor(selectedColorOnDialog); - }, - ), - ], - ); - } -} - -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 = Theme.of(context).colorScheme.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, - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart deleted file mode 100644 index 48a761da75..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart +++ /dev/null @@ -1,247 +0,0 @@ -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/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_popover/appflowy_popover.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:google_fonts/google_fonts.dart'; - -import 'levenshtein.dart'; -import 'theme_setting_entry_template.dart'; - -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 FlowySettingListTile( - 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.showResetButton = false, - 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 bool showResetButton; - 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 FlowySettingValueDropDown( - 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: [ - if (widget.showResetButton) - SliverPersistentHeader( - delegate: _ResetFontButton( - onPressed: widget.onResetFont, - ), - pinned: true, - ), - 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(); - }, - ), - ), - ); - } -} - -class _ResetFontButton extends SliverPersistentHeaderDelegate { - _ResetFontButton({this.onPressed}); - - final VoidCallback? onPressed; - - @override - Widget build( - BuildContext context, - double shrinkOffset, - bool overlapsContent, - ) { - return Padding( - padding: const EdgeInsets.only(right: 8, bottom: 8.0), - child: FlowyTextButton( - LocaleKeys.document_toolbar_resetToDefaultFont.tr(), - fontColor: AFThemeExtension.of(context).textColor, - fontHoverColor: Theme.of(context).colorScheme.onSurface, - fontSize: 12, - onPressed: onPressed, - ), - ); - } - - @override - double get maxExtent => 35; - - @override - double get minExtent => 35; - - @override - bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => - true; -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart deleted file mode 100644 index d4385631d6..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart +++ /dev/null @@ -1,6 +0,0 @@ -export 'brightness_setting.dart'; -export 'font_family_setting.dart'; -export 'color_scheme.dart'; -export 'direction_setting.dart'; -export 'document_cursor_color_setting.dart'; -export 'document_selection_color_setting.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart deleted file mode 100644 index 2a4a76cdf9..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.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/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; - -class FlowySettingListTile extends StatelessWidget { - const FlowySettingListTile({ - super.key, - this.resetTooltipText, - this.resetButtonKey, - required this.label, - this.hint, - this.trailing, - this.onResetRequested, - }); - - final String label; - final String? hint; - final String? resetTooltipText; - final Key? resetButtonKey; - final List? trailing; - final void Function()? 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 (trailing != null) ...trailing!, - if (onResetRequested != null) - FlowyIconButton( - hoverColor: Theme.of(context).colorScheme.secondaryContainer, - key: resetButtonKey, - width: 24, - icon: FlowySvg( - FlowySvgs.restore_s, - color: Theme.of(context).iconTheme.color, - ), - iconColorOnHover: Theme.of(context).colorScheme.onPrimary, - tooltipText: resetTooltipText ?? - LocaleKeys.settings_appearance_resetSetting.tr(), - onPressed: onResetRequested, - ), - ], - ); - } -} - -class FlowySettingValueDropDown extends StatefulWidget { - const FlowySettingValueDropDown({ - 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() => - _FlowySettingValueDropDownState(); -} - -class _FlowySettingValueDropDownState 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: Theme.of(context).colorScheme.onBackground, - fillColor: Colors.transparent, - onPressed: () {}, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart deleted file mode 100644 index e4ffff7461..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart +++ /dev/null @@ -1,63 +0,0 @@ -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_backend/protobuf/flowy-user/date_time.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 'theme_setting_entry_template.dart'; - -class TimeFormatSetting extends StatelessWidget { - const TimeFormatSetting({ - super.key, - required this.currentFormat, - }); - - final UserTimeFormatPB currentFormat; - - @override - Widget build(BuildContext context) => FlowySettingListTile( - label: LocaleKeys.settings_appearance_timeFormat_label.tr(), - trailing: [ - FlowySettingValueDropDown( - currentValue: _formatLabel(currentFormat), - popupBuilder: (_) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _formatItem(context, UserTimeFormatPB.TwentyFourHour), - _formatItem(context, UserTimeFormatPB.TwelveHour), - ], - ), - ), - ], - ); - - Widget _formatItem(BuildContext context, UserTimeFormatPB format) { - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium(_formatLabel(format)), - rightIcon: - currentFormat == format ? const FlowySvg(FlowySvgs.check_s) : null, - onTap: () { - if (currentFormat != format) { - context.read().setTimeFormat(format); - } - }, - ), - ); - } - - String _formatLabel(UserTimeFormatPB format) { - switch (format) { - case (UserTimeFormatPB.TwentyFourHour): - return LocaleKeys.settings_appearance_timeFormat_twentyFourHour.tr(); - case (UserTimeFormatPB.TwelveHour): - return LocaleKeys.settings_appearance_timeFormat_twelveHour.tr(); - default: - return ""; - } - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart deleted file mode 100644 index c41e5704f0..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/appearance_defaults.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'settings_appearance/settings_appearance.dart'; - -class SettingsAppearanceView extends StatelessWidget { - const SettingsAppearanceView({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => DynamicPluginBloc(), - child: BlocBuilder( - builder: (context, state) { - return SettingsBody( - children: [ - SettingsHeader(title: LocaleKeys.settings_menu_appearance.tr()), - ColorSchemeSetting( - currentTheme: state.appTheme.themeName, - bloc: context.read(), - ), - BrightnessSetting( - currentThemeMode: state.themeMode, - ), - const Divider(), - ThemeFontFamilySetting( - currentFontFamily: state.font, - ), - const Divider(), - DocumentCursorColorSetting( - currentCursorColor: state.documentCursorColor ?? - DefaultAppearanceSettings.getDefaultDocumentCursorColor( - context, - ), - ), - DocumentSelectionColorSetting( - currentSelectionColor: state.documentSelectionColor ?? - DefaultAppearanceSettings.getDefaultDocumentSelectionColor( - context, - ), - ), - const Divider(), - LayoutDirectionSetting( - currentLayoutDirection: state.layoutDirection, - ), - TextDirectionSetting( - currentTextDirection: state.textDirection, - ), - const EnableRTLToolbarItemsSetting(), - const Divider(), - DateFormatSetting( - currentFormat: state.dateFormat, - ), - TimeFormatSetting( - currentFormat: state.timeFormat, - ), - const Divider(), - CreateFileSettings(), - ], - ); - }, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart deleted file mode 100644 index 00383e547d..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.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_body.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.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_bloc/flutter_bloc.dart'; - -class SettingsShortcutsView extends StatelessWidget { - const SettingsShortcutsView({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => - ShortcutsCubit(SettingsShortcutService())..fetchShortcuts(), - child: SettingsBody( - children: [ - SettingsHeader( - title: LocaleKeys.settings_shortcuts_shortcutsLabel.tr(), - ), - BlocBuilder( - builder: (_, state) => switch (state.status) { - ShortcutsStatus.initial || - ShortcutsStatus.updating => - const Center(child: CircularProgressIndicator()), - ShortcutsStatus.success => - ShortcutsListView(shortcuts: state.commandShortcutEvents), - ShortcutsStatus.failure => - ShortcutsErrorView(errorMessage: state.error), - }, - ), - ], - ), - ); - } -} - -class ShortcutsListView extends StatelessWidget { - const ShortcutsListView({super.key, required this.shortcuts}); - - final List shortcuts; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: FlowyText.semibold( - LocaleKeys.settings_shortcuts_command.tr(), - overflow: TextOverflow.ellipsis, - ), - ), - FlowyText.semibold( - LocaleKeys.settings_shortcuts_keyBinding.tr(), - overflow: TextOverflow.ellipsis, - ), - ], - ), - const VSpace(10), - ...shortcuts.map((e) => ShortcutsListTile(shortcutEvent: e)), - const VSpace(10), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const Spacer(), - FlowyTextButton( - LocaleKeys.settings_shortcuts_resetToDefault.tr(), - fontColor: AFThemeExtension.of(context).textColor, - onPressed: () => context.read().resetToDefault(), - ), - ], - ), - const VSpace(10), - ], - ); - } -} - -class ShortcutsListTile extends StatelessWidget { - const ShortcutsListTile({ - super.key, - required this.shortcutEvent, - }); - - final CommandShortcutEvent shortcutEvent; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Row( - children: [ - Expanded( - child: FlowyText.medium( - key: Key(shortcutEvent.key), - shortcutEvent.description!.capitalize(), - overflow: TextOverflow.ellipsis, - ), - ), - FlowyTextButton( - shortcutEvent.command, - fillColor: Colors.transparent, - fontColor: AFThemeExtension.of(context).textColor, - onPressed: () => showKeyListenerDialog(context), - ), - ], - ), - Divider( - color: Theme.of(context).dividerColor, - ), - ], - ); - } - - void showKeyListenerDialog(BuildContext widgetContext) { - final controller = TextEditingController(text: shortcutEvent.command); - showDialog( - context: widgetContext, - builder: (builderContext) { - final formKey = GlobalKey(); - return AlertDialog( - title: Text(LocaleKeys.settings_shortcuts_updateShortcutStep.tr()), - content: KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: (key) { - if (key.logicalKey == LogicalKeyboardKey.enter && - !HardwareKeyboard.instance.isShiftPressed) { - if (controller.text == shortcutEvent.command) { - _dismiss(builderContext); - } - if (formKey.currentState!.validate()) { - _updateKey(widgetContext, controller.text); - _dismiss(builderContext); - } - } else if (key.logicalKey == LogicalKeyboardKey.escape) { - _dismiss(builderContext); - } else { - //extract the keybinding command from the key event. - controller.text = key.convertToCommand; - } - }, - child: Form( - key: formKey, - child: TextFormField( - autofocus: true, - controller: controller, - readOnly: true, - maxLines: null, - decoration: const InputDecoration( - border: OutlineInputBorder(), - ), - validator: (_) => _validateForConflicts( - widgetContext, - controller.text, - ), - ), - ), - ), - ); - }, - ).then((_) => controller.dispose()); - } - - String? _validateForConflicts(BuildContext context, String command) { - final conflict = BlocProvider.of(context).getConflict( - shortcutEvent, - command, - ); - if (conflict.isEmpty) return null; - - return LocaleKeys.settings_shortcuts_shortcutIsAlreadyUsed.tr( - namedArgs: {'conflict': conflict}, - ); - } - - void _updateKey(BuildContext context, String command) { - shortcutEvent.updateCommand(command: command); - BlocProvider.of(context).updateAllShortcuts(); - } - - void _dismiss(BuildContext context) => Navigator.of(context).pop(); -} - -extension on KeyEvent { - String get convertToCommand { - String command = ''; - if (HardwareKeyboard.instance.isAltPressed) { - command += 'alt+'; - } - if (HardwareKeyboard.instance.isControlPressed) { - command += 'ctrl+'; - } - if (HardwareKeyboard.instance.isShiftPressed) { - command += 'shift+'; - } - if (HardwareKeyboard.instance.isMetaPressed) { - command += 'meta+'; - } - - final keyPressed = keyToCodeMapping.keys.firstWhere( - (k) => keyToCodeMapping[k] == logicalKey.keyId, - orElse: () => '', - ); - - return command += keyPressed; - } -} - -class ShortcutsErrorView extends StatelessWidget { - const ShortcutsErrorView({super.key, required this.errorMessage}); - - final String errorMessage; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: FlowyText.medium( - errorMessage, - overflow: TextOverflow.ellipsis, - ), - ), - FlowyIconButton( - icon: const Icon(Icons.replay_outlined), - onPressed: () => context.read().fetchShortcuts(), - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart deleted file mode 100644 index 047cfa62cb..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/locale_keys.g.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/shared/settings_header.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/files/setting_file_import_appflowy_data_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_file_cache_widget.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_file_customize_location_view.dart'; -import 'package:easy_localization/easy_localization.dart'; - -class SettingsFileSystemView extends StatelessWidget { - const SettingsFileSystemView({super.key}); - - @override - Widget build(BuildContext context) { - return SettingsBody( - children: [ - SettingsHeader(title: LocaleKeys.settings_menu_files.tr()), - const SettingsFileLocationCustomizer(), - const SettingsCategorySpacer(), - if (kDebugMode) ...[ - const SettingsExportFileWidget(), - ], - const ImportAppFlowyData(), - const SettingsCategorySpacer(), - const SettingsFileCacheWidget(), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_language_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_language_view.dart deleted file mode 100644 index 32ab4db5f3..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_language_view.dart +++ /dev/null @@ -1,119 +0,0 @@ -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/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; -import 'package:appflowy_popover/appflowy_popover.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_bloc/flutter_bloc.dart'; - -class SettingsLanguageView extends StatelessWidget { - const SettingsLanguageView({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) => SettingsBody( - children: [ - SettingsHeader(title: LocaleKeys.settings_menu_language.tr()), - Row( - children: [ - Expanded( - child: FlowyText.medium( - LocaleKeys.settings_menu_language.tr(), - ), - ), - LanguageSelector(currentLocale: state.locale), - ], - ), - ], - ), - ); - } -} - -class LanguageSelector extends StatelessWidget { - const LanguageSelector({super.key, required this.currentLocale}); - - final Locale currentLocale; - - @override - Widget build(BuildContext context) { - return AppFlowyPopover( - direction: PopoverDirection.bottomWithRightAligned, - child: FlowyTextButton( - languageFromLocale(currentLocale), - fontColor: Theme.of(context).colorScheme.onBackground, - fillColor: Colors.transparent, - onPressed: () {}, - ), - popupBuilder: (BuildContext context) { - final allLocales = EasyLocalization.of(context)!.supportedLocales; - return LanguageItemsListView(allLocales: allLocales); - }, - ); - } -} - -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( - itemBuilder: (context, index) { - final locale = allLocales[index]; - return LanguageItem( - locale: locale, - currentLocale: state.locale, - ); - }, - itemCount: allLocales.length, - ), - ); - } -} - -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(); - }, - ), - ); - } -} 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 index c1b26df9b2..979f19fbde 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -1,6 +1,3 @@ -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/shared/feature_flags.dart'; @@ -9,6 +6,8 @@ import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_e 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({ @@ -16,11 +15,13 @@ class SettingsMenu extends StatelessWidget { 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) { @@ -32,10 +33,10 @@ class SettingsMenu extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 8) + const EdgeInsets.only(left: 8, right: 4), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(8), - bottomLeft: Radius.circular(8), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadiusDirectional.only( + topStart: Radius.circular(8), + bottomStart: Radius.circular(8), ), ), child: SingleChildScrollView( @@ -55,24 +56,26 @@ class SettingsMenu extends StatelessWidget { changeSelectedPage: changeSelectedPage, ), SettingsMenuElement( - page: SettingsPage.appearance, + page: SettingsPage.workspace, selectedPage: currentPage, - label: LocaleKeys.settings_menu_appearance.tr(), - icon: const Icon(Icons.brightness_4), + label: LocaleKeys.settings_workspacePage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_workplace_m), changeSelectedPage: changeSelectedPage, ), + if (FeatureFlag.membersSettings.isOn && + userProfile.workspaceAuthType == AuthTypePB.Server) + SettingsMenuElement( + page: SettingsPage.member, + selectedPage: currentPage, + label: LocaleKeys.settings_appearance_members_label.tr(), + icon: const Icon(Icons.people), + changeSelectedPage: changeSelectedPage, + ), SettingsMenuElement( - page: SettingsPage.language, + page: SettingsPage.manageData, selectedPage: currentPage, - label: LocaleKeys.settings_menu_language.tr(), - icon: const Icon(Icons.translate), - changeSelectedPage: changeSelectedPage, - ), - SettingsMenuElement( - page: SettingsPage.files, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_files.tr(), - icon: const Icon(Icons.file_present_outlined), + label: LocaleKeys.settings_manageDataPage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_data_m), changeSelectedPage: changeSelectedPage, ), SettingsMenuElement( @@ -92,20 +95,44 @@ class SettingsMenu extends StatelessWidget { SettingsMenuElement( page: SettingsPage.shortcuts, selectedPage: currentPage, - label: LocaleKeys.settings_shortcuts_shortcutsLabel.tr(), - icon: const Icon(Icons.cut), + label: LocaleKeys.settings_shortcutsPage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_shortcuts_m), changeSelectedPage: changeSelectedPage, ), - if (FeatureFlag.membersSettings.isOn && - userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud) + 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.workspaceAuthType == AuthTypePB.Server) SettingsMenuElement( - page: SettingsPage.member, + page: SettingsPage.sites, selectedPage: currentPage, - label: LocaleKeys.settings_appearance_members_label.tr(), - icon: const Icon(Icons.people), + 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 @@ -124,3 +151,56 @@ class SettingsMenu extends StatelessWidget { ); } } + +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 index b8cafe87a6..b1bef7cceb 100644 --- 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 @@ -1,10 +1,9 @@ -import 'package:flutter/material.dart'; - 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({ @@ -28,7 +27,7 @@ class SettingsMenuElement extends StatelessWidget { isSelected: () => page == selectedPage, resetHoverOnRebuild: false, style: HoverStyle( - hoverColor: AFThemeExtension.of(context).greySelect, + hoverColor: AFThemeExtension.of(context).greyHover, borderRadius: BorderRadius.circular(4), ), builder: (_, isHovering) => ListTile( 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 index a930b55edf..29ba2baf5c 100644 --- 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 @@ -2,9 +2,9 @@ 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/settings/shared/settings_header.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -16,24 +16,37 @@ class SettingsNotificationsView extends StatelessWidget { return BlocBuilder( builder: (context, state) { return SettingsBody( + title: LocaleKeys.settings_menu_notifications.tr(), children: [ - SettingsHeader(title: LocaleKeys.settings_menu_notifications.tr()), - FlowySettingListTile( + SettingListTile( label: LocaleKeys.settings_notifications_enableNotifications_label .tr(), hint: LocaleKeys.settings_notifications_enableNotifications_hint .tr(), trailing: [ - Switch( + Toggle( value: state.isNotificationsEnabled, - splashRadius: 0, - activeColor: Theme.of(context).colorScheme.primary, - onChanged: (value) => context + 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_upload_decoration.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_decoration.dart index 260918c7de..ea8ebfe36b 100644 --- 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 @@ -1,4 +1,5 @@ import 'package:dotted_border/dotted_border.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'theme_upload_view.dart'; @@ -15,8 +16,8 @@ class ThemeUploadDecoration extends StatelessWidget { borderRadius: BorderRadius.circular(ThemeUploadWidget.borderRadius), color: Theme.of(context).colorScheme.surface, border: Border.all( - color: Theme.of(context).colorScheme.onBackground.withOpacity( - ThemeUploadWidget.fadeOpacity, + color: AFThemeExtension.of(context).onBackground.withValues( + alpha: ThemeUploadWidget.fadeOpacity, ), ), ), @@ -26,8 +27,8 @@ class ThemeUploadDecoration extends StatelessWidget { dashPattern: const [6, 6], color: Theme.of(context) .colorScheme - .onBackground - .withOpacity(ThemeUploadWidget.fadeOpacity), + .onSurface + .withValues(alpha: ThemeUploadWidget.fadeOpacity), radius: const Radius.circular(ThemeUploadWidget.borderRadius), child: ClipRRect( borderRadius: BorderRadius.circular(ThemeUploadWidget.borderRadius), 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 index 379c78acd5..a7286bee48 100644 --- 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 @@ -1,5 +1,6 @@ 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'; @@ -14,7 +15,7 @@ class ThemeUploadFailureWidget extends StatelessWidget { color: Theme.of(context) .colorScheme .error - .withOpacity(ThemeUploadWidget.fadeOpacity), + .withValues(alpha: ThemeUploadWidget.fadeOpacity), constraints: const BoxConstraints.expand(), padding: ThemeUploadWidget.padding, child: Column( @@ -24,7 +25,7 @@ class ThemeUploadFailureWidget extends StatelessWidget { FlowySvg( FlowySvgs.close_m, size: ThemeUploadWidget.iconSize, - color: Theme.of(context).colorScheme.onBackground, + color: AFThemeExtension.of(context).onBackground, ), FlowyText.medium( errorMessage, 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 index 628232bd71..bdc5ef0546 100644 --- 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 @@ -1,13 +1,13 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/error_page/error_page.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}); @@ -21,7 +21,7 @@ class ThemeUploadLearnMoreButton extends StatelessWidget { height: ThemeUploadWidget.buttonSize.height, child: IntrinsicWidth( child: SecondaryButton( - outlineColor: Theme.of(context).colorScheme.onBackground, + outlineColor: AFThemeExtension.of(context).onBackground, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: FlowyText.medium( @@ -31,7 +31,7 @@ class ThemeUploadLearnMoreButton extends StatelessWidget { ), onPressed: () async { final uri = Uri.parse(learnMoreURL); - await afLaunchUrl( + await afLaunchUri( uri, context: context, onFailure: (_) async { 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 index 643189a38f..1d3e7ab0f8 100644 --- 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 @@ -13,8 +13,8 @@ class ThemeUploadLoadingWidget extends StatelessWidget { padding: ThemeUploadWidget.padding, color: Theme.of(context) .colorScheme - .background - .withOpacity(ThemeUploadWidget.fadeOpacity), + .surface + .withValues(alpha: ThemeUploadWidget.fadeOpacity), constraints: const BoxConstraints.expand(), child: Column( mainAxisAlignment: MainAxisAlignment.center, 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 index f9705843d1..1b22dba659 100644 --- 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 @@ -1,6 +1,7 @@ +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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'theme_upload_decoration.dart'; @@ -57,8 +58,9 @@ class _ThemeUploadWidgetState extends State { }); } - Widget child = - const UploadNewThemeWidget(key: Key('upload_new_theme_widget')); + Widget child = const UploadNewThemeWidget( + key: Key('upload_new_theme_widget'), + ); @override Widget build(BuildContext context) { 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 index 967f5b0b0f..02a7c8e7ab 100644 --- 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 @@ -2,6 +2,7 @@ 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'; @@ -13,8 +14,8 @@ class UploadNewThemeWidget extends StatelessWidget { return Container( color: Theme.of(context) .colorScheme - .background - .withOpacity(ThemeUploadWidget.fadeOpacity), + .surface + .withValues(alpha: ThemeUploadWidget.fadeOpacity), padding: ThemeUploadWidget.padding, child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -23,7 +24,7 @@ class UploadNewThemeWidget extends StatelessWidget { FlowySvg( FlowySvgs.folder_m, size: ThemeUploadWidget.iconSize, - color: Theme.of(context).colorScheme.onBackground, + color: AFThemeExtension.of(context).onBackground, ), FlowyText.medium( LocaleKeys.settings_appearance_themeUpload_description.tr(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/web_url_hint_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/web_url_hint_widget.dart new file mode 100644 index 0000000000..ecf3cc7ef7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/web_url_hint_widget.dart @@ -0,0 +1,34 @@ +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: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'; + +class WebUrlHintWidget extends StatelessWidget { + const WebUrlHintWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 2), + child: FlowyTooltip( + message: LocaleKeys.workspace_learnMore.tr(), + preferBelow: false, + child: FlowyIconButton( + width: 24, + height: 24, + icon: const FlowySvg( + FlowySvgs.information_s, + ), + onPressed: () { + afLaunchUrlString( + 'https://appflowy.com/docs/self-host-appflowy-run-appflowy-web', + ); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart deleted file mode 100644 index 9fa92446b4..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart +++ /dev/null @@ -1,283 +0,0 @@ -import 'package:flutter/material.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:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker.dart'; -import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/end_text_field.dart'; -import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart'; -import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; -import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/start_text_field.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; - -class OptionGroup { - OptionGroup({required this.options}); - - final List options; -} - -typedef DaySelectedCallback = Function(DateTime, DateTime); -typedef RangeSelectedCallback = Function(DateTime?, DateTime?, DateTime); -typedef IncludeTimeChangedCallback = Function(bool); -typedef TimeChangedCallback = Function(String); - -class AppFlowyDatePicker extends StatefulWidget { - const AppFlowyDatePicker({ - super.key, - required this.includeTime, - required this.onIncludeTimeChanged, - this.rebuildOnDaySelected = true, - this.enableRanges = true, - this.isRange = false, - this.onIsRangeChanged, - required this.dateFormat, - required this.timeFormat, - this.selectedDay, - this.focusedDay, - this.firstDay, - this.lastDay, - this.startDay, - this.endDay, - this.timeStr, - this.endTimeStr, - this.timeHintText, - this.parseEndTimeError, - this.parseTimeError, - this.popoverMutex, - this.selectedReminderOption = ReminderOption.none, - this.onStartTimeSubmitted, - this.onEndTimeSubmitted, - this.onDaySelected, - this.onRangeSelected, - this.onReminderSelected, - this.options, - this.allowFormatChanges = false, - this.onDateFormatChanged, - this.onTimeFormatChanged, - this.onClearDate, - this.onCalendarCreated, - this.onPageChanged, - }); - - final bool includeTime; - final Function(bool) onIncludeTimeChanged; - - final bool enableRanges; - final bool isRange; - final Function(bool)? onIsRangeChanged; - - final bool rebuildOnDaySelected; - - final DateFormatPB dateFormat; - final TimeFormatPB timeFormat; - - final DateTime? selectedDay; - final DateTime? focusedDay; - final DateTime? firstDay; - final DateTime? lastDay; - - /// Start date in selected range - final DateTime? startDay; - - /// End date in selected range - final DateTime? endDay; - - final String? timeStr; - final String? endTimeStr; - final String? timeHintText; - final String? parseEndTimeError; - final String? parseTimeError; - final PopoverMutex? popoverMutex; - final ReminderOption selectedReminderOption; - - final TimeChangedCallback? onStartTimeSubmitted; - final TimeChangedCallback? onEndTimeSubmitted; - final DaySelectedCallback? onDaySelected; - final RangeSelectedCallback? onRangeSelected; - final OnReminderSelected? onReminderSelected; - - /// A list of [OptionGroup] that will be rendered with proper - /// separators, each group can contain multiple options. - /// - /// __Supported on Desktop & Web__ - /// - final List? options; - - /// If this value is true, then [onTimeFormatChanged] and [onDateFormatChanged] - /// cannot be null - /// - final bool allowFormatChanges; - - /// If [allowFormatChanges] is true, this must be provided - /// - final Function(DateFormatPB)? onDateFormatChanged; - - /// If [allowFormatChanges] is true, this must be provided - /// - final Function(TimeFormatPB)? onTimeFormatChanged; - - /// If provided, the ClearDate button will be shown - /// Otherwise it will be hidden - /// - final VoidCallback? onClearDate; - - final void Function(PageController pageController)? onCalendarCreated; - - final void Function(DateTime focusedDay)? onPageChanged; - - @override - State createState() => _AppFlowyDatePickerState(); -} - -class _AppFlowyDatePickerState extends State { - late DateTime? _selectedDay = widget.selectedDay; - late ReminderOption _selectedReminderOption = widget.selectedReminderOption; - - @override - void didUpdateWidget(covariant AppFlowyDatePicker oldWidget) { - _selectedDay = oldWidget.selectedDay != widget.selectedDay - ? widget.selectedDay - : _selectedDay; - _selectedReminderOption = - oldWidget.selectedReminderOption != widget.selectedReminderOption - ? widget.selectedReminderOption - : _selectedReminderOption; - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) => - PlatformExtension.isMobile ? buildMobilePicker() : buildDesktopPicker(); - - Widget buildMobilePicker() { - return DatePicker( - isRange: widget.isRange, - onDaySelected: (selectedDay, focusedDay) { - widget.onDaySelected?.call(selectedDay, focusedDay); - - if (widget.rebuildOnDaySelected) { - setState(() => _selectedDay = selectedDay); - } - }, - onRangeSelected: widget.onRangeSelected, - selectedDay: - widget.rebuildOnDaySelected ? _selectedDay : widget.selectedDay, - firstDay: widget.firstDay, - lastDay: widget.lastDay, - startDay: widget.startDay, - endDay: widget.endDay, - onCalendarCreated: widget.onCalendarCreated, - onPageChanged: widget.onPageChanged, - ); - } - - Widget buildDesktopPicker() { - // 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: [ - StartTextField( - includeTime: widget.includeTime, - timeFormat: widget.timeFormat, - timeHintText: widget.timeHintText, - parseEndTimeError: widget.parseEndTimeError, - parseTimeError: widget.parseTimeError, - timeStr: widget.timeStr, - popoverMutex: widget.popoverMutex, - onSubmitted: widget.onStartTimeSubmitted, - ), - EndTextField( - includeTime: widget.includeTime, - timeFormat: widget.timeFormat, - isRange: widget.isRange, - endTimeStr: widget.endTimeStr, - popoverMutex: widget.popoverMutex, - onSubmitted: widget.onEndTimeSubmitted, - ), - DatePicker( - isRange: widget.isRange, - onDaySelected: (selectedDay, focusedDay) { - widget.onDaySelected?.call(selectedDay, focusedDay); - - if (widget.rebuildOnDaySelected) { - setState(() => _selectedDay = selectedDay); - } - }, - onRangeSelected: widget.onRangeSelected, - selectedDay: widget.rebuildOnDaySelected - ? _selectedDay - : widget.selectedDay, - firstDay: widget.firstDay, - lastDay: widget.lastDay, - startDay: widget.startDay, - endDay: widget.endDay, - onCalendarCreated: widget.onCalendarCreated, - onPageChanged: widget.onPageChanged, - ), - const TypeOptionSeparator(spacing: 12.0), - if (widget.enableRanges && widget.onIsRangeChanged != null) ...[ - EndTimeButton( - isRange: widget.isRange, - onChanged: widget.onIsRangeChanged!, - ), - const VSpace(4.0), - ], - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: IncludeTimeButton( - value: widget.includeTime, - onChanged: widget.onIncludeTimeChanged, - ), - ), - const _GroupSeparator(), - ReminderSelector( - mutex: widget.popoverMutex, - hasTime: widget.includeTime, - timeFormat: widget.timeFormat, - selectedOption: _selectedReminderOption, - onOptionSelected: (option) { - setState(() => _selectedReminderOption = option); - widget.onReminderSelected?.call(option); - }, - ), - if (widget.options?.isNotEmpty ?? false) ...[ - const _GroupSeparator(), - ListView.separated( - shrinkWrap: true, - itemCount: widget.options!.length, - separatorBuilder: (_, __) => const _GroupSeparator(), - itemBuilder: (_, index) => - _renderGroupOptions(widget.options![index].options), - ), - ], - ], - ), - ), - ); - } - - Widget _renderGroupOptions(List options) => ListView.separated( - shrinkWrap: true, - itemCount: options.length, - separatorBuilder: (_, __) => const VSpace(4), - itemBuilder: (_, index) => options[index], - ); -} - -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), - ); -} 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 0000000000..d965670f77 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart @@ -0,0 +1,334 @@ +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, + this.enableDidUpdate = true, + }); + + 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; + final bool enableDidUpdate; +} + +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(); + initData(); + + focusedDateTime = widget.dateTime ?? DateTime.now(); + } + + @override + void didUpdateWidget(covariant oldWidget) { + if (widget.enableDidUpdate) { + initData(); + } + if (oldWidget.reminderOption != widget.reminderOption) { + reminderOption = widget.reminderOption; + } + super.didUpdateWidget(oldWidget); + } + + void initData() { + dateTime = widget.dateTime; + startDateTime = widget.isRange ? widget.dateTime : null; + endDateTime = widget.isRange ? widget.endDateTime : null; + includeTime = widget.includeTime; + isRange = widget.isRange; + reminderOption = widget.reminderOption; + } + + 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 0000000000..fada23e994 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart @@ -0,0 +1,307 @@ +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, + super.enableDidUpdate, + 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_appflowy_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart deleted file mode 100644 index 12714e04df..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart +++ /dev/null @@ -1,498 +0,0 @@ -import 'package:flutter/cupertino.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/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/workspace/presentation/widgets/date_picker/appflowy_date_picker.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/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:go_router/go_router.dart'; - -class MobileAppFlowyDatePicker extends StatefulWidget { - const MobileAppFlowyDatePicker({ - super.key, - this.selectedDay, - this.startDay, - this.endDay, - this.dateStr, - this.endDateStr, - this.timeStr, - this.endTimeStr, - this.enableRanges = false, - this.isRange = false, - this.rebuildOnDaySelected = false, - this.rebuildOnTimeChanged = false, - required this.includeTime, - required this.use24hFormat, - required this.timeFormat, - this.selectedReminderOption, - required this.onStartTimeChanged, - this.onEndTimeChanged, - required this.onIncludeTimeChanged, - this.onRangeChanged, - this.onDaySelected, - this.onRangeSelected, - this.onClearDate, - this.liveDateFormatter, - this.onReminderSelected, - }); - - final DateTime? selectedDay; - final DateTime? startDay; - final DateTime? endDay; - - final String? dateStr; - final String? endDateStr; - final String? timeStr; - final String? endTimeStr; - - final bool enableRanges; - final bool isRange; - final bool includeTime; - final bool rebuildOnDaySelected; - final bool rebuildOnTimeChanged; - final bool use24hFormat; - - final TimeFormatPB timeFormat; - - final ReminderOption? selectedReminderOption; - - final Function(String? time) onStartTimeChanged; - final Function(String? time)? onEndTimeChanged; - final Function(bool) onIncludeTimeChanged; - final Function(bool)? onRangeChanged; - - final DaySelectedCallback? onDaySelected; - final RangeSelectedCallback? onRangeSelected; - final VoidCallback? onClearDate; - final OnReminderSelected? onReminderSelected; - - final String Function(DateTime)? liveDateFormatter; - - @override - State createState() => - _MobileAppFlowyDatePickerState(); -} - -class _MobileAppFlowyDatePickerState extends State { - late bool _includeTime = widget.includeTime; - late String? _dateStr = widget.dateStr; - late ReminderOption _reminderOption = - widget.selectedReminderOption ?? ReminderOption.none; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - FlowyOptionDecorateBox( - showTopBorder: false, - child: _IncludeTimePicker( - dateStr: - widget.liveDateFormatter != null ? _dateStr : widget.dateStr, - endDateStr: widget.endDateStr, - timeStr: widget.timeStr, - endTimeStr: widget.endTimeStr, - includeTime: _includeTime, - use24hFormat: widget.use24hFormat, - onStartTimeChanged: widget.onStartTimeChanged, - onEndTimeChanged: widget.onEndTimeChanged, - rebuildOnTimeChanged: widget.rebuildOnTimeChanged, - ), - ), - const _Divider(), - FlowyOptionDecorateBox( - child: MobileDatePicker( - isRange: widget.isRange, - selectedDay: widget.selectedDay, - startDay: widget.startDay, - endDay: widget.endDay, - onDaySelected: (selected, focused) { - widget.onDaySelected?.call(selected, focused); - - if (widget.liveDateFormatter != null) { - setState(() => _dateStr = widget.liveDateFormatter!(selected)); - } - }, - onRangeSelected: widget.onRangeSelected, - rebuildOnDaySelected: widget.rebuildOnDaySelected, - ), - ), - const _Divider(), - if (widget.enableRanges && widget.onRangeChanged != null) - _EndDateSwitch( - isRange: widget.isRange, - onRangeChanged: widget.onRangeChanged!, - ), - _IncludeTimeSwitch( - showTopBorder: !widget.enableRanges || widget.onRangeChanged == null, - includeTime: _includeTime, - onIncludeTimeChanged: (includeTime) { - widget.onIncludeTimeChanged(includeTime); - setState(() => _includeTime = includeTime); - }, - ), - 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!), - ], - 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 _IncludeTimePicker extends StatefulWidget { - const _IncludeTimePicker({ - required this.includeTime, - this.dateStr, - this.endDateStr, - this.timeStr, - this.endTimeStr, - this.rebuildOnTimeChanged = false, - required this.use24hFormat, - required this.onStartTimeChanged, - required this.onEndTimeChanged, - }); - - final bool includeTime; - - final String? dateStr; - final String? endDateStr; - - final String? timeStr; - final String? endTimeStr; - - final bool rebuildOnTimeChanged; - - final bool use24hFormat; - - final Function(String? time) onStartTimeChanged; - final Function(String? time)? onEndTimeChanged; - - @override - State<_IncludeTimePicker> createState() => _IncludeTimePickerState(); -} - -class _IncludeTimePickerState extends State<_IncludeTimePicker> { - late String? _timeStr = widget.timeStr; - late String? _endTimeStr = widget.endTimeStr; - - @override - Widget build(BuildContext context) { - if (widget.dateStr == null || widget.dateStr!.isEmpty) { - return const Divider(height: 1); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTime( - context, - widget.includeTime, - widget.use24hFormat, - true, - widget.dateStr, - widget.rebuildOnTimeChanged ? _timeStr : widget.timeStr, - ), - VSpace(8.0, color: Theme.of(context).colorScheme.surface), - _buildTime( - context, - widget.includeTime, - widget.use24hFormat, - false, - widget.endDateStr, - widget.rebuildOnTimeChanged ? _endTimeStr : widget.endTimeStr, - ), - ], - ), - ); - } - - Widget _buildTime( - BuildContext context, - bool isIncludeTime, - bool use24hFormat, - bool isStartDay, - String? dateStr, - String? timeStr, - ) { - if (dateStr == null) { - return const SizedBox.shrink(); - } - - final List children = []; - - if (!isIncludeTime) { - children.addAll([ - const HSpace(12.0), - FlowyText(dateStr), - ]); - } else { - children.addAll([ - Expanded(child: FlowyText(dateStr, textAlign: TextAlign.center)), - Container(width: 1, height: 16, color: Colors.grey), - Expanded(child: FlowyText(timeStr ?? '', textAlign: TextAlign.center)), - ]); - } - - return GestureDetector( - onTap: !isIncludeTime - ? null - : () async { - await showMobileBottomSheet( - context, - builder: (context) => ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 300), - child: CupertinoDatePicker( - mode: CupertinoDatePickerMode.time, - use24hFormat: use24hFormat, - onDateTimeChanged: (dateTime) { - final selectedTime = use24hFormat - ? DateFormat('HH:mm').format(dateTime) - : DateFormat('hh:mm a').format(dateTime); - - if (isStartDay) { - widget.onStartTimeChanged(selectedTime); - - if (widget.rebuildOnTimeChanged && mounted) { - setState(() => _timeStr = selectedTime); - } - } else { - widget.onEndTimeChanged?.call(selectedTime); - - if (widget.rebuildOnTimeChanged && mounted) { - setState(() => _endTimeStr = selectedTime); - } - } - }, - ), - ), - ); - }, - child: 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), - ), - ); - } -} - -class _EndDateSwitch extends StatelessWidget { - const _EndDateSwitch({ - 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/mobile_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_date_picker.dart new file mode 100644 index 0000000000..e9f3262cc3 --- /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/widgets/clear_date_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart index c3157c0933..3eaa674df8 100644 --- 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 @@ -22,7 +22,7 @@ class ClearDateButton extends StatelessWidget { child: SizedBox( height: DatePickerSize.itemHeight, child: FlowyButton( - text: FlowyText.medium(LocaleKeys.datePicker_clearDate.tr()), + 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 index 843852e70e..5cbdc2bc43 100644 --- 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 @@ -1,10 +1,8 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; 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); @@ -17,8 +15,7 @@ class DatePicker extends StatefulWidget { this.startDay, this.endDay, this.selectedDay, - this.firstDay, - this.lastDay, + required this.focusedDay, this.onDaySelected, this.onRangeSelected, this.onCalendarCreated, @@ -32,20 +29,14 @@ class DatePicker extends StatefulWidget { final DateTime? endDay; final DateTime? selectedDay; - /// If not provided, defaults to 1st January 1970 - /// - final DateTime? firstDay; + final DateTime focusedDay; - /// If not provided, defaults to 1st January 2100 - /// - final DateTime? lastDay; - - final Function( + final void Function( DateTime selectedDay, DateTime focusedDay, )? onDaySelected; - final Function( + final void Function( DateTime? start, DateTime? end, DateTime focusedDay, @@ -60,7 +51,6 @@ class DatePicker extends StatefulWidget { } class _DatePickerState extends State { - late DateTime _focusedDay = widget.selectedDay ?? DateTime.now(); late CalendarFormat _calendarFormat = widget.calendarFormat; @override @@ -71,7 +61,7 @@ class _DatePickerState extends State { shape: BoxShape.circle, ); - final calendarStyle = PlatformExtension.isMobile + final calendarStyle = UniversalPlatform.isMobile ? _CalendarStyle.mobile( dowTextStyle: textStyle.copyWith( color: Theme.of(context).hintColor, @@ -79,8 +69,6 @@ class _DatePickerState extends State { ), ) : _CalendarStyle.desktop( - textStyle: textStyle, - iconColor: Theme.of(context).iconTheme.color, dowTextStyle: AFThemeExtension.of(context).caption, selectedColor: Theme.of(context).colorScheme.primary, ); @@ -88,9 +76,9 @@ class _DatePickerState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: TableCalendar( - firstDay: widget.firstDay ?? kFirstDay, - lastDay: widget.lastDay ?? kLastDay, - focusedDay: _focusedDay, + firstDay: kFirstDay, + lastDay: kLastDay, + focusedDay: widget.focusedDay, rowHeight: calendarStyle.rowHeight, calendarFormat: _calendarFormat, daysOfWeekHeight: calendarStyle.dowHeight, @@ -157,7 +145,6 @@ class _DatePickerState extends State { setState(() => _calendarFormat = calendarFormat), onPageChanged: (focusedDay) { widget.onPageChanged?.call(focusedDay); - setState(() => _focusedDay = focusedDay); }, onDaySelected: widget.onDaySelected, onRangeSelected: widget.onRangeSelected, @@ -168,26 +155,13 @@ class _DatePickerState extends State { class _CalendarStyle { _CalendarStyle.desktop({ - required TextStyle textStyle, required this.selectedColor, required this.dowTextStyle, - Color? iconColor, }) : rowHeight = 33, dowHeight = 35, - headerVisible = true, - headerStyle = HeaderStyle( - formatButtonVisible: false, - titleCentered: true, - titleTextStyle: textStyle, - leftChevronMargin: EdgeInsets.zero, - leftChevronPadding: EdgeInsets.zero, - leftChevronIcon: FlowySvg(FlowySvgs.arrow_left_s, color: iconColor), - rightChevronPadding: EdgeInsets.zero, - rightChevronMargin: EdgeInsets.zero, - rightChevronIcon: FlowySvg(FlowySvgs.arrow_right_s, color: iconColor), - headerPadding: const EdgeInsets.only(bottom: 8.0), - ), - availableGestures = AvailableGestures.all; + headerVisible = false, + headerStyle = const HeaderStyle(), + availableGestures = AvailableGestures.horizontalSwipe; _CalendarStyle.mobile({required this.dowTextStyle}) : rowHeight = 48, 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 index 10eb499292..54fc2fac2a 100644 --- 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 @@ -1,4 +1,5 @@ -import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.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/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'; @@ -15,58 +16,42 @@ import 'package:flutter/services.dart'; class DatePickerOptions { DatePickerOptions({ DateTime? focusedDay, - this.popoverMutex, this.selectedDay, - this.firstDay, - this.lastDay, - this.timeStr, - this.endTimeStr, this.includeTime = false, this.isRange = false, - this.enableRanges = true, this.dateFormat = UserDateFormatPB.Friendly, this.timeFormat = UserTimeFormatPB.TwentyFourHour, this.selectedReminderOption, this.onDaySelected, - required this.onIncludeTimeChanged, - this.onStartTimeChanged, - this.onEndTimeChanged, + this.onIncludeTimeChanged, this.onRangeSelected, this.onIsRangeChanged, this.onReminderSelected, }) : focusedDay = focusedDay ?? DateTime.now(); final DateTime focusedDay; - final PopoverMutex? popoverMutex; final DateTime? selectedDay; - final DateTime? firstDay; - final DateTime? lastDay; - final String? timeStr; - final String? endTimeStr; final bool includeTime; final bool isRange; - final bool enableRanges; final UserDateFormatPB dateFormat; final UserTimeFormatPB timeFormat; final ReminderOption? selectedReminderOption; final DaySelectedCallback? onDaySelected; - final IncludeTimeChangedCallback onIncludeTimeChanged; - final TimeChangedCallback? onStartTimeChanged; - final TimeChangedCallback? onEndTimeChanged; final RangeSelectedCallback? onRangeSelected; - final Function(bool)? onIsRangeChanged; + 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 = 370; -const double _includeTimeHeight = 32; +const double _datePickerHeight = 404; const double _ySpacing = 15; class DatePickerMenu extends DatePickerService { @@ -74,6 +59,7 @@ class DatePickerMenu extends DatePickerService { final BuildContext context; final EditorState editorState; + PopoverMutex? popoverMutex; OverlayEntry? _menuEntry; @@ -81,6 +67,9 @@ class DatePickerMenu extends DatePickerService { void dismiss() { _menuEntry?.remove(); _menuEntry = null; + popoverMutex?.close(); + popoverMutex?.dispose(); + popoverMutex = null; } @override @@ -111,6 +100,7 @@ class DatePickerMenu extends DatePickerService { } } + popoverMutex = PopoverMutex(); _menuEntry = OverlayEntry( builder: (_) => Material( type: MaterialType.transparency, @@ -133,6 +123,7 @@ class DatePickerMenu extends DatePickerService { offset: Offset(offsetX, offsetY), showBelow: showBelow, options: options, + popoverMutex: popoverMutex, ), ], ), @@ -146,67 +137,47 @@ class DatePickerMenu extends DatePickerService { } } -class _AnimatedDatePicker extends StatefulWidget { +class _AnimatedDatePicker extends StatelessWidget { const _AnimatedDatePicker({ required this.offset, required this.showBelow, required this.options, + this.popoverMutex, }); final Offset offset; final bool showBelow; final DatePickerOptions options; - - @override - State<_AnimatedDatePicker> createState() => _AnimatedDatePickerState(); -} - -class _AnimatedDatePickerState extends State<_AnimatedDatePicker> { - late bool _includeTime = widget.options.includeTime; + final PopoverMutex? popoverMutex; @override Widget build(BuildContext context) { - double dy = widget.offset.dy; - if (!widget.showBelow && _includeTime) { - dy -= _includeTimeHeight; - } - - dy += (widget.showBelow ? _ySpacing : -_ySpacing); + final dy = offset.dy + (showBelow ? _ySpacing : -_ySpacing); return AnimatedPositioned( duration: const Duration(milliseconds: 200), top: dy, - left: widget.offset.dx, + 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: AppFlowyDatePicker( - includeTime: _includeTime, - onIncludeTimeChanged: (includeTime) { - widget.options.onIncludeTimeChanged.call(!includeTime); - setState(() => _includeTime = !includeTime); - }, - enableRanges: widget.options.enableRanges, - isRange: widget.options.isRange, - onIsRangeChanged: widget.options.onIsRangeChanged, - dateFormat: widget.options.dateFormat.simplified, - timeFormat: widget.options.timeFormat.simplified, - selectedDay: widget.options.selectedDay, - focusedDay: widget.options.focusedDay, - firstDay: widget.options.firstDay, - lastDay: widget.options.lastDay, - timeStr: widget.options.timeStr, - endTimeStr: widget.options.endTimeStr, - popoverMutex: widget.options.popoverMutex, - selectedReminderOption: - widget.options.selectedReminderOption ?? ReminderOption.none, - onStartTimeSubmitted: widget.options.onStartTimeChanged, - onDaySelected: widget.options.onDaySelected, - onRangeSelected: widget.options.onRangeSelected, - onReminderSelected: widget.options.onReminderSelected, + 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: popoverMutex, + reminderOption: options.selectedReminderOption ?? ReminderOption.none, + onDaySelected: options.onDaySelected, + onRangeSelected: options.onRangeSelected, + onReminderSelected: options.onReminderSelected, + enableDidUpdate: false, ), ), ); 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 index 0c9c6aaa8e..7447700fef 100644 --- 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 @@ -4,7 +4,6 @@ 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:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; class DateTimeSetting extends StatefulWidget { @@ -29,6 +28,12 @@ class _DateTimeSettingState extends State { final timeSettingPopoverMutex = PopoverMutex(); String? overlayIdentifier; + @override + void dispose() { + timeSettingPopoverMutex.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final List children = [ 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 0000000000..553ffb4c0d --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_text_field.dart @@ -0,0 +1,377 @@ +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, + maxLength: widget.timeFormat == TimeFormatPB.TwelveHour + ? 8 // 12:34 PM = 8 characters + : 5, // 12:34 = 5 characters + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp('[0-9:AaPpMm]'), + ), + ], + 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, + counterText: "", + 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 index b4224bb05a..9bb819a243 100644 --- 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 @@ -5,7 +5,6 @@ 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:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -39,7 +38,7 @@ class DateTypeOptionButton extends StatelessWidget { child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( - text: FlowyText.medium(title), + text: FlowyText(title), rightIcon: const FlowySvg(FlowySvgs.more_s), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/end_text_field.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/end_text_field.dart deleted file mode 100644 index fae8782af5..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/end_text_field.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/time_text_field.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; - -class EndTextField extends StatelessWidget { - const EndTextField({ - super.key, - required this.includeTime, - required this.isRange, - required this.timeFormat, - this.endTimeStr, - this.popoverMutex, - this.onSubmitted, - }); - - final bool includeTime; - final bool isRange; - final TimeFormatPB timeFormat; - final String? endTimeStr; - final PopoverMutex? popoverMutex; - final Function(String timeStr)? onSubmitted; - - @override - Widget build(BuildContext context) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: includeTime && isRange - ? Padding( - padding: const EdgeInsets.only(top: 8.0), - child: TimeTextField( - isEndTime: true, - timeFormat: timeFormat, - endTimeStr: endTimeStr, - popoverMutex: popoverMutex, - onSubmitted: onSubmitted, - ), - ) - : const SizedBox.shrink(), - ); - } -} 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 index 6f60257929..fdb24fb761 100644 --- 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 @@ -1,12 +1,12 @@ +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/workspace/presentation/widgets/toggle/toggle_style.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 EndTimeButton extends StatelessWidget { const EndTimeButton({ @@ -33,12 +33,11 @@ class EndTimeButton extends StatelessWidget { color: Theme.of(context).iconTheme.color, ), const HSpace(6), - FlowyText.medium(LocaleKeys.datePicker_isRange.tr()), + FlowyText(LocaleKeys.datePicker_isRange.tr()), const Spacer(), Toggle( value: isRange, onChanged: onChanged, - style: ToggleStyle.big, 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 index 60476712e6..4d8176ba5c 100644 --- 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 @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.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'; @@ -10,39 +9,32 @@ class MobileDatePicker extends StatefulWidget { const MobileDatePicker({ super.key, this.selectedDay, - required this.isRange, - this.onDaySelected, - this.rebuildOnDaySelected = false, - this.onRangeSelected, - this.firstDay, - this.lastDay, 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 DaySelectedCallback? onDaySelected; - - final bool rebuildOnDaySelected; - final RangeSelectedCallback? onRangeSelected; - - final DateTime? firstDay; - final DateTime? lastDay; - final DateTime? startDay; - final DateTime? endDay; + 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; - - late DateTime _focusedDay = widget.selectedDay ?? DateTime.now(); - late DateTime? _selectedDay = widget.selectedDay; + PageController? pageController; @override Widget build(BuildContext context) { @@ -60,60 +52,64 @@ class _MobileDatePickerState extends State { Widget _buildCalendar(BuildContext context) { return DatePicker( isRange: widget.isRange, - onDaySelected: (selectedDay, focusedDay) { - widget.onDaySelected?.call(selectedDay, focusedDay); - - if (widget.rebuildOnDaySelected) { - setState(() => _selectedDay = selectedDay); - } + onDaySelected: (selectedDay, _) { + widget.onDaySelected?.call(selectedDay); }, - onRangeSelected: widget.onRangeSelected, - selectedDay: - widget.rebuildOnDaySelected ? _selectedDay : widget.selectedDay, - firstDay: widget.firstDay, - lastDay: widget.lastDay, + focusedDay: widget.focusedDay, + onRangeSelected: (start, end, focusedDay) { + widget.onRangeSelected?.call(start, end); + }, + selectedDay: widget.selectedDay, startDay: widget.startDay, endDay: widget.endDay, - onCalendarCreated: (pageController) => _pageController = pageController, - onPageChanged: (focusedDay) => setState(() => _focusedDay = focusedDay), + onCalendarCreated: (pageController) { + this.pageController = pageController; + }, + onPageChanged: widget.onPageChanged, ); } Widget _buildHeader(BuildContext context) { - return Row( - children: [ - const HSpace(16.0), - FlowyText( - DateFormat.yMMMM().format(_focusedDay), - ), - const Spacer(), - FlowyButton( - useIntrinsicWidth: true, - text: FlowySvg( - FlowySvgs.arrow_left_s, - color: Theme.of(context).iconTheme.color, - size: const Size.square(24.0), + return Padding( + padding: const EdgeInsetsDirectional.only(start: 16, end: 8), + child: Row( + children: [ + Expanded( + child: FlowyText( + DateFormat.yMMMM().format(widget.focusedDay), + ), ), - onTap: () => _pageController?.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, + 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), + 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, + ); + }, ), - onTap: () => _pageController?.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ), - ), - const HSpace(8.0), - ], + ], + ), ); } } 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 index d7f88e8d12..d795e2ab7d 100644 --- 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 @@ -4,7 +4,6 @@ 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:appflowy_popover/appflowy_popover.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -51,7 +50,7 @@ class ReminderSelector extends StatelessWidget { return SizedBox( height: DatePickerSize.itemHeight, child: FlowyButton( - text: FlowyText.medium(label), + text: FlowyText(label), rightIcon: o == selectedOption ? const FlowySvg(FlowySvgs.check_s) : null, onTap: () { @@ -87,7 +86,7 @@ class ReminderSelector extends StatelessWidget { child: SizedBox( height: DatePickerSize.itemHeight, child: FlowyButton( - text: FlowyText.medium(LocaleKeys.datePicker_reminderLabel.tr()), + text: FlowyText(LocaleKeys.datePicker_reminderLabel.tr()), rightIcon: Row( children: [ FlowyText.regular(selectedOption.label), @@ -125,10 +124,14 @@ enum 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 => @@ -191,10 +194,11 @@ enum ReminderOption { _ => ReminderOption.custom, }; - DateTime fromDate(DateTime date) => switch (withoutTime) { - true => requiresNoTime + DateTime getNotificationDateTime(DateTime date) { + return withoutTime + ? requiresNoTime ? date.withoutTime.add(time) - : date.withoutTime.subtract(time), - _ => date.subtract(time), - }; + : date.withoutTime.subtract(time) + : date.subtract(time); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/start_text_field.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/start_text_field.dart deleted file mode 100644 index 77afa8f7e1..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/start_text_field.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/time_text_field.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; - -class StartTextField extends StatelessWidget { - const StartTextField({ - super.key, - required this.includeTime, - required this.timeFormat, - this.timeHintText, - this.parseEndTimeError, - this.parseTimeError, - this.timeStr, - this.endTimeStr, - this.popoverMutex, - this.onSubmitted, - }); - - final bool includeTime; - final TimeFormatPB timeFormat; - final String? timeHintText; - final String? parseEndTimeError; - final String? parseTimeError; - final String? timeStr; - final String? endTimeStr; - final PopoverMutex? popoverMutex; - final Function(String timeStr)? onSubmitted; - - @override - Widget build(BuildContext context) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: includeTime - ? TimeTextField( - isEndTime: false, - timeFormat: timeFormat, - timeHintText: timeHintText, - parseEndTimeError: parseEndTimeError, - parseTimeError: parseTimeError, - timeStr: timeStr, - endTimeStr: endTimeStr, - popoverMutex: popoverMutex, - onSubmitted: onSubmitted, - ) - : const SizedBox.shrink(), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/time_text_field.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/time_text_field.dart deleted file mode 100644 index c0f5cc8308..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/time_text_field.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra_ui/style_widget/text_field.dart'; -import 'package:intl/intl.dart'; - -const _maxLengthTwelveHour = 8; -const _maxLengthTwentyFourHour = 5; - -class TimeTextField extends StatefulWidget { - const TimeTextField({ - super.key, - required this.isEndTime, - required this.timeFormat, - this.timeHintText, - this.parseEndTimeError, - this.parseTimeError, - this.timeStr, - this.endTimeStr, - this.popoverMutex, - this.onSubmitted, - }); - - final bool isEndTime; - final TimeFormatPB timeFormat; - final String? timeHintText; - final String? parseEndTimeError; - final String? parseTimeError; - final String? timeStr; - final String? endTimeStr; - final PopoverMutex? popoverMutex; - final Function(String timeStr)? onSubmitted; - - @override - State createState() => _TimeTextFieldState(); -} - -class _TimeTextFieldState extends State { - final FocusNode _focusNode = FocusNode(); - late final TextEditingController _textController = TextEditingController() - ..text = widget.timeStr ?? ""; - String text = ""; - - @override - void initState() { - super.initState(); - - _textController.text = - (widget.isEndTime ? widget.endTimeStr : widget.timeStr) ?? ""; - - if (!widget.isEndTime && widget.timeStr != null) { - text = widget.timeStr!; - } else if (widget.endTimeStr != null) { - text = widget.endTimeStr!; - } - - if (widget.timeFormat == TimeFormatPB.TwelveHour) { - final twentyFourHourFormat = DateFormat('HH:mm'); - final twelveHourFormat = DateFormat('hh:mm a'); - final date = twentyFourHourFormat.parse(text); - text = twelveHourFormat.format(date); - } - - _focusNode.addListener(_focusNodeListener); - widget.popoverMutex?.listenOnPopoverChanged(_popoverListener); - } - - @override - void dispose() { - widget.popoverMutex?.removePopoverListener(_popoverListener); - _textController.dispose(); - _focusNode.removeListener(_focusNodeListener); - _focusNode.dispose(); - super.dispose(); - } - - void _focusNodeListener() { - if (_focusNode.hasFocus) { - widget.popoverMutex?.close(); - } - } - - void _popoverListener() { - if (_focusNode.hasFocus) { - _focusNode.unfocus(); - } - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: FlowyTextField( - text: text, - keyboardType: TextInputType.datetime, - focusNode: _focusNode, - autoFocus: false, - controller: _textController, - submitOnLeave: true, - hintText: widget.timeHintText, - errorText: - widget.isEndTime ? widget.parseEndTimeError : widget.parseTimeError, - maxLength: widget.timeFormat == TimeFormatPB.TwelveHour - ? _maxLengthTwelveHour - : _maxLengthTwentyFourHour, - showCounter: false, - inputFormatters: [ - if (widget.timeFormat == TimeFormatPB.TwelveHour) ...[ - // Allow for AM/PM if time format is 12-hour - FilteringTextInputFormatter.allow(RegExp('[0-9:aApPmM ]')), - ] else ...[ - // Default allow for hh:mm format - FilteringTextInputFormatter.allow(RegExp('[0-9:]')), - ], - TimeInputFormatter(widget.timeFormat), - ], - onSubmitted: widget.onSubmitted, - ), - ); - } -} - -class TimeInputFormatter extends TextInputFormatter { - TimeInputFormatter(this.timeFormat); - - final TimeFormatPB timeFormat; - static const int colonPosition = 2; - static const int spacePosition = 5; - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - final oldText = oldValue.text; - final newText = newValue.text; - - // If the user has typed enough for a time separator(:) and hasn't already typed - if (newText.length == colonPosition + 1 && - oldText.length == colonPosition && - !newText.contains(":")) { - return _formatText(newText, colonPosition, ':'); - } - - // If the user has typed enough for an AM/PM separator and hasn't already typed - if (timeFormat == TimeFormatPB.TwelveHour && - newText.length == spacePosition + 1 && - oldText.length == spacePosition && - newText[newText.length - 1] != ' ') { - return _formatText(newText, spacePosition, ' '); - } - - if (timeFormat == TimeFormatPB.TwentyFourHour && - newValue.text.length == 5) { - final prefix = newValue.text.substring(0, 3); - final suffix = newValue.text.length > 5 ? newValue.text.substring(6) : ''; - - final minutes = int.tryParse(newValue.text.substring(3, 5)); - if (minutes == null || minutes <= 0) { - return newValue.copyWith(text: '${prefix}00$suffix'.toUpperCase()); - } else if (minutes > 59) { - return newValue.copyWith(text: '${prefix}59$suffix'.toUpperCase()); - } - } - - return newValue.copyWith(text: newText.toUpperCase()); - } - - TextEditingValue _formatText(String text, int index, String separator) { - return TextEditingValue( - text: '${text.substring(0, index)}$separator${text.substring(index)}', - selection: TextSelection.collapsed(offset: text.length + 1), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialog_v2.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialog_v2.dart new file mode 100644 index 0000000000..43ab8897e1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialog_v2.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +typedef SimpleAFDialogAction = (String, void Function(BuildContext)?); + +/// A simple dialog with a title, content, and actions. +/// +/// The primary button is a filled button and colored using theme or destructive +/// color depending on the [isDestructive] parameter. The secondary button is an +/// outlined button. +/// +Future showSimpleAFDialog({ + required BuildContext context, + required String title, + required String content, + bool isDestructive = false, + required SimpleAFDialogAction primaryAction, + SimpleAFDialogAction? secondaryAction, + bool barrierDismissible = true, +}) { + final theme = AppFlowyTheme.of(context); + + return showDialog( + context: context, + barrierColor: theme.surfaceColorScheme.overlay, + barrierDismissible: barrierDismissible, + builder: (_) { + return AFModal( + constraints: BoxConstraints( + maxWidth: AFModalDimension.S, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AFModalHeader( + leading: Text( + title, + style: theme.textStyle.heading4.standard( + color: theme.textColorScheme.primary, + ), + ), + trailing: [ + AFGhostButton.normal( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return FlowySvg( + FlowySvgs.close_s, + size: Size.square(20), + ); + }, + ), + ], + ), + Flexible( + child: ConstrainedBox( + // AFModalDimension.dialogHeight - header - footer + constraints: BoxConstraints(minHeight: 108.0), + child: AFModalBody( + child: Text(content), + ), + ), + ), + AFModalFooter( + trailing: [ + if (secondaryAction != null) + AFOutlinedButton.normal( + onTap: () { + secondaryAction.$2?.call(context); + Navigator.of(context).pop(); + }, + builder: (context, isHovering, disabled) { + return Text(secondaryAction.$1); + }, + ), + isDestructive + ? AFFilledButton.destructive( + onTap: () { + primaryAction.$2?.call(context); + Navigator.of(context).pop(); + }, + builder: (context, isHovering, disabled) { + return Text( + primaryAction.$1, + style: TextStyle( + color: AppFlowyTheme.of(context) + .textColorScheme + .onFill, + ), + ); + }, + ) + : AFFilledButton.primary( + onTap: () { + primaryAction.$2?.call(context); + Navigator.of(context).pop(); + }, + builder: (context, isHovering, disabled) { + return Text(primaryAction.$1); + }, + ), + ], + ), + ], + ), + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 513f72b4ed..7e30c4fa55 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -1,7 +1,8 @@ -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/tasks/app_widget.dart'; +import 'package:appflowy/util/theme_extension.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'; @@ -10,8 +11,68 @@ 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({ @@ -94,6 +155,12 @@ class _NavigatorTextFieldDialogState extends State { VSpace(Insets.xl), OkCancelButton( onOkPressed: () { + if (newValue.isEmpty) { + showToastNotification( + message: LocaleKeys.space_spaceNameCannotBeEmpty.tr(), + ); + return; + } widget.onConfirm(newValue, context); Navigator.of(context).pop(); }, @@ -129,11 +196,6 @@ class NavigatorAlertDialog extends StatefulWidget { } class _CreateFlowyAlertDialog extends State { - @override - void initState() { - super.initState(); - } - @override Widget build(BuildContext context) { return StyledDialog( @@ -188,6 +250,8 @@ class NavigatorOkCancelDialog extends StatelessWidget { this.title, this.message, this.maxWidth, + this.titleUpperCase = true, + this.autoDismiss = true, }); final VoidCallback? onOkPressed; @@ -197,9 +261,19 @@ class NavigatorOkCancelDialog extends StatelessWidget { 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), @@ -208,13 +282,13 @@ class NavigatorOkCancelDialog extends StatelessWidget { children: [ if (title != null) ...[ FlowyText.medium( - title!.toUpperCase(), + titleUpperCase ? title!.toUpperCase() : title!, fontSize: FontSizes.s16, maxLines: 3, ), VSpace(Insets.sm * 1.5), Container( - color: Theme.of(context).colorScheme.surfaceVariant, + color: Theme.of(context).colorScheme.surfaceContainerHighest, height: 1, ), VSpace(Insets.m * 1.5), @@ -228,12 +302,11 @@ class NavigatorOkCancelDialog extends StatelessWidget { OkCancelButton( onOkPressed: () { onOkPressed?.call(); - Navigator.of(context).pop(); - }, - onCancelPressed: () { - onCancelPressed?.call(); - Navigator.of(context).pop(); + if (autoDismiss) { + Navigator.of(context).pop(); + } }, + onCancelPressed: onCancel, okTitle: okTitle?.toUpperCase(), cancelTitle: cancelTitle?.toUpperCase(), ), @@ -288,3 +361,378 @@ class OkCancelButton extends StatelessWidget { ); } } + +ToastificationItem showToastNotification({ + String? message, + TextSpan? richMessage, + String? description, + ToastificationType type = ToastificationType.success, + ToastificationCallbacks? callbacks, + double bottomPadding = 100, +}) { + assert( + (message == null) != (richMessage == null), + "Exactly one of message or richMessage must be non-null.", + ); + return toastification.showCustom( + alignment: Alignment.bottomCenter, + autoCloseDuration: const Duration(milliseconds: 3000), + callbacks: callbacks ?? const ToastificationCallbacks(), + builder: (_, item) { + return UniversalPlatform.isMobile + ? _MobileToast( + message: message, + type: type, + bottomPadding: bottomPadding, + description: description, + ) + : DesktopToast( + message: message, + richMessage: richMessage, + type: type, + onDismiss: () => toastification.dismiss(item), + ); + }, + ); +} + +class _MobileToast extends StatelessWidget { + const _MobileToast({ + 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) { + if (message == null) { + return const SizedBox.shrink(); + } + 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, + ], + ], + ), + ), + ); + } +} + +@visibleForTesting +class DesktopToast extends StatelessWidget { + const DesktopToast({ + super.key, + this.message, + this.richMessage, + required this.type, + this.onDismiss, + }); + + final String? message; + final TextSpan? richMessage; + final ToastificationType type; + final void Function()? onDismiss; + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 360.0), + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + margin: const EdgeInsets.only(bottom: 32.0), + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? const Color(0xFF333333) + : const Color(0xFF363D49), + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // icon + FlowySvg( + switch (type) { + ToastificationType.warning => FlowySvgs.toast_warning_filled_s, + ToastificationType.success => FlowySvgs.toast_checked_filled_s, + ToastificationType.error => FlowySvgs.toast_error_filled_s, + _ => throw UnimplementedError(), + }, + size: const Size.square(20.0), + blendMode: null, + ), + const HSpace(8.0), + // text + Flexible( + child: message != null + ? FlowyText( + message!, + maxLines: 2, + figmaLineHeight: 20.0, + overflow: TextOverflow.ellipsis, + color: const Color(0xFFFFFFFF), + ) + : RichText( + text: richMessage!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(16.0), + // close + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onDismiss, + child: const SizedBox.square( + dimension: 24.0, + child: Center( + child: FlowySvg( + FlowySvgs.toast_close_s, + size: Size.square(16.0), + color: Color(0xFFBDBDBD), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +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, + bool showCloseButton = true, + bool enableKeyboardListener = true, + bool barrierDismissible = true, +}) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + 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, + showCloseButton: showCloseButton, + enableKeyboardListener: enableKeyboardListener, + 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 index 7d245d0320..5b3962cd63 100644 --- 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 @@ -1,5 +1,10 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +/// This value is used to disable the auto scroll when dragging. +/// +/// It is used to prevent the auto scroll when dragging a view item to a document. +bool disableAutoScrollWhenDragging = false; class DraggableItem extends StatefulWidget { const DraggableItem({ @@ -67,7 +72,7 @@ class _DraggableItemState extends State> { childWhenDragging: widget.childWhenDragging ?? widget.child, child: widget.child, onDragUpdate: (details) { - if (widget.enableAutoScroll) { + if (widget.enableAutoScroll && !disableAutoScrollWhenDragging) { dragTarget = details.globalPosition & widget.hitTestSize; autoScroller?.startAutoScrollIfNecessary(dragTarget!); } @@ -88,7 +93,7 @@ class _DraggableItemState extends State> { } void initAutoScrollerIfNeeded(BuildContext context) { - if (!widget.enableAutoScroll) { + if (!widget.enableAutoScroll || disableAutoScrollWhenDragging) { return; } @@ -104,7 +109,7 @@ class _DraggableItemState extends State> { autoScroller = EdgeDraggingAutoScroller( scrollable!, onScrollViewScrolled: () { - if (dragTarget != null) { + if (dragTarget != null && !disableAutoScrollWhenDragging) { autoScroller!.startAutoScrollIfNecessary(dragTarget!); } }, @@ -146,7 +151,7 @@ class _Draggable extends StatelessWidget { @override Widget build(BuildContext context) { - return PlatformExtension.isMobile + return UniversalPlatform.isMobile ? LongPressDraggable( data: data, feedback: feedback, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/favorite_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/favorite_button.dart index 4c52e6c278..6e1c377277 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/favorite_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/favorite_button.dart @@ -1,13 +1,11 @@ -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/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/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_bloc/flutter_bloc.dart'; class ViewFavoriteButton extends StatelessWidget { @@ -22,7 +20,7 @@ class ViewFavoriteButton extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final isFavorite = state.views.any((v) => v.id == view.id); + final isFavorite = state.views.any((v) => v.item.id == view.id); return Listener( onPointerDown: (_) => context.read().add(FavoriteEvent.toggle(view)), @@ -35,9 +33,9 @@ class ViewFavoriteButton extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(6), child: FlowySvg( - isFavorite ? FlowySvgs.favorite_s : FlowySvgs.unfavorite_s, - size: const Size(18, 18), - color: AFThemeExtension.of(context).warning, + 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 index e72dfa098c..e3117c7f86 100644 --- 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 @@ -1,30 +1,26 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.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/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/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:package_info_plus/package_info_plus.dart'; -import 'package:styled_widget/styled_widget.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( - width: 30, - height: 30, + return const SizedBox.square( + dimension: 32.0, child: BubbleActionList(), ); } @@ -60,26 +56,64 @@ class _BubbleActionListState extends State { actions.addAll( BubbleAction.values.map((action) => BubbleActionWrapper(action)), ); - actions.add(FlowyVersionDescription()); + + 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 FlowyTextButton( - '?', - tooltip: LocaleKeys.questionBubble_help.tr(), - fontWeight: FontWeight.w600, - fontColor: fontColor, - fillColor: fillColor, - hoverColor: Theme.of(context).colorScheme.primary, - mainAxisAlignment: MainAxisAlignment.center, - radius: Corners.s10Border, - onPressed: () { - toggle(); - controller.show(); - }, + return FlowyTooltip( + message: LocaleKeys.questionBubble_getSupport.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, @@ -87,22 +121,22 @@ class _BubbleActionListState extends State { if (action is BubbleActionWrapper) { switch (action.inner) { case BubbleAction.whatsNews: - afLaunchUrlString("https://www.appflowy.io/what-is-new"); + afLaunchUrlString('https://www.appflowy.io/what-is-new'); break; - case BubbleAction.help: - afLaunchUrlString("https://discord.gg/9Q2xaN37tV"); + case BubbleAction.getSupport: + afLaunchUrlString('https://discord.gg/9Q2xaN37tV'); break; case BubbleAction.debug: _DebugToast().show(); break; case BubbleAction.shortcuts: afLaunchUrlString( - "https://docs.appflowy.io/docs/appflowy/product/shortcuts", + 'https://docs.appflowy.io/docs/appflowy/product/shortcuts', ); break; case BubbleAction.markdown: afLaunchUrlString( - "https://docs.appflowy.io/docs/appflowy/product/markdown", + 'https://docs.appflowy.io/docs/appflowy/product/markdown', ); break; case BubbleAction.github: @@ -110,6 +144,11 @@ class _BubbleActionListState extends State { 'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose', ); break; + case BubbleAction.helpAndDocumentation: + afLaunchUrlString( + 'https://appflowy.com/guide', + ); + break; } } @@ -121,7 +160,7 @@ class _BubbleActionListState extends State { class _DebugToast { void show() async { - String debugInfo = ""; + String debugInfo = ''; debugInfo += await _getDeviceInfo(); debugInfo += await _getDocumentPath(); await Clipboard.setData(ClipboardData(text: debugInfo)); @@ -134,71 +173,33 @@ class _DebugToast { final deviceInfo = await deviceInfoPlugin.deviceInfo; return deviceInfo.data.entries - .fold('', (prev, el) => "$prev${el.key}: ${el.value}\n"); + .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"; + return 'Document: $path\n'; }); } } -class FlowyVersionDescription extends CustomActionCell { - @override - Widget buildWithContext(BuildContext context) { - 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), - FlowyText( - "$appName $version", - color: Theme.of(context).hintColor, - ), - ], - ).padding( - horizontal: ActionListSizes.itemHPadding, - ), - ); - } else { - return const SizedBox(height: 30); - } - }, - ); - } +enum BubbleAction { + whatsNews, + helpAndDocumentation, + getSupport, + debug, + shortcuts, + markdown, + github, } -enum BubbleAction { whatsNews, help, debug, shortcuts, markdown, github } - class BubbleActionWrapper extends ActionCell { BubbleActionWrapper(this.inner); final BubbleAction inner; @override - Widget? leftIcon(Color iconColor) => inner.emoji; + Widget? leftIcon(Color iconColor) => inner.icons; @override String get name => inner.name; @@ -209,8 +210,10 @@ extension QuestionBubbleExtension on BubbleAction { switch (this) { case BubbleAction.whatsNews: return LocaleKeys.questionBubble_whatsNew.tr(); - case BubbleAction.help: - return LocaleKeys.questionBubble_help.tr(); + case BubbleAction.helpAndDocumentation: + return LocaleKeys.questionBubble_helpAndDocumentation.tr(); + case BubbleAction.getSupport: + return LocaleKeys.questionBubble_getSupport.tr(); case BubbleAction.debug: return LocaleKeys.questionBubble_debug_name.tr(); case BubbleAction.shortcuts: @@ -222,26 +225,25 @@ extension QuestionBubbleExtension on BubbleAction { } } - Widget get emoji { + Widget? get icons { switch (this) { case BubbleAction.whatsNews: - return const FlowyText.regular('🆕'); - case BubbleAction.help: - return const FlowyText.regular('👥'); - case BubbleAction.debug: - return const FlowyText.regular('🐛'); - case BubbleAction.shortcuts: - return const FlowyText.regular('📋'); - case BubbleAction.markdown: - return const FlowyText.regular('✨'); - case BubbleAction.github: - return const Padding( - padding: EdgeInsets.all(3.0), - child: FlowySvg( - FlowySvgs.archive_m, - size: Size.square(12), - ), + return const FlowySvg(FlowySvgs.star_s); + case BubbleAction.helpAndDocumentation: + return const FlowySvg( + FlowySvgs.help_and_documentation_s, + size: Size.square(16.0), ); + case BubbleAction.getSupport: + 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 0000000000..8b58557455 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart @@ -0,0 +1,101 @@ +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) { + final url = switch (action.inner) { + SocialMedia.reddit => 'https://www.reddit.com/r/AppFlowy/', + SocialMedia.twitter => 'https://x.com/appflowy', + SocialMedia.forum => 'https://forum.appflowy.com/', + }; + + afLaunchUrlString(url); + }, + ); + }, + ), + ); + + 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; + } + } + + 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 0000000000..f6a2caa5a2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart @@ -0,0 +1,76 @@ +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( + 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 0000000000..b69c56abf2 --- /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, + 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 0000000000..765a385b0b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart @@ -0,0 +1,337 @@ +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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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 0000000000..143c6b1ad3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart @@ -0,0 +1,240 @@ +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 == null + ? null + : () => 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 index 529807c4f4..62b3ccc8f3 100644 --- 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 @@ -1,55 +1,47 @@ -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/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/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_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/lock_page_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_popover/appflowy_popover.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:flowy_infra_ui/widget/flowy_tooltip.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, + this.customActions = const [], }); /// 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; + /// Custom actions to show in the popover, will be laid out at the top. + /// + final List customActions; @override State createState() => _MoreViewActionsState(); } class _MoreViewActionsState extends State { - late final List viewActions; final popoverMutex = PopoverMutex(); - @override - void initState() { - super.initState(); - viewActions = ViewActionType.values - .map( - (type) => ViewAction( - type: type, - view: widget.view, - mutex: popoverMutex, - ), - ) - .toList(); - } - @override void dispose() { popoverMutex.dispose(); @@ -58,64 +50,152 @@ class _MoreViewActionsState extends State { @override Widget build(BuildContext context) { - final appearanceSettings = context.watch().state; - final dateFormat = appearanceSettings.dateFormat; - final timeFormat = appearanceSettings.timeFormat; - return BlocBuilder( builder: (context, state) { return AppFlowyPopover( mutex: popoverMutex, - constraints: BoxConstraints.loose(const Size(215, 400)), - offset: const Offset(0, 30), - popupBuilder: (_) { - final actions = [ - if (widget.isDocument) ...[ - const FontSizeAction(), - const Divider(height: 4), - ], - ...viewActions, - if (state.documentCounters != null || - state.createdAt != null) ...[ - const Divider(height: 4), - ViewMetaInfo( - dateFormat: dateFormat, - timeFormat: timeFormat, - documentCounters: state.documentCounters, - createdAt: state.createdAt, - ), - ], - ]; - - return ListView.separated( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: actions.length, - separatorBuilder: (_, __) => const VSpace(4), - physics: StyledScrollPhysics(), - itemBuilder: (_, index) => actions[index], - ); - }, - child: 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_vertical_s, - size: const Size.square(16), - color: isHovering - ? Theme.of(context).colorScheme.onSecondary - : Theme.of(context).iconTheme.color, - ), - ), - ), - ), + constraints: const BoxConstraints(maxWidth: 245), + direction: PopoverDirection.bottomWithRightAligned, + offset: const Offset(0, 12), + popupBuilder: (_) => _buildPopup(state), + child: const _ThreeDots(), ); }, ); } + + Widget _buildPopup(ViewInfoState viewInfoState) { + final userWorkspaceBloc = context.read(); + final userProfile = userWorkspaceBloc.userProfile; + final workspaceId = + userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? ''; + + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => ViewBloc(view: widget.view) + ..add( + const ViewEvent.initial(), + ), + ), + BlocProvider( + create: (_) => ViewLockStatusBloc(view: widget.view) + ..add(ViewLockStatusEvent.initial()), + ), + BlocProvider( + create: (context) => SpaceBloc( + userProfile: userProfile, + workspaceId: workspaceId, + )..add( + const SpaceEvent.initial(openFirstPage: false), + ), + ), + ], + child: BlocBuilder( + builder: (context, viewState) { + return BlocBuilder( + builder: (context, state) { + if (state.spaces.isEmpty && + userProfile.workspaceAuthType == AuthTypePB.Server) { + return const SizedBox.shrink(); + } + + final actions = _buildActions( + context, + viewInfoState, + ); + return ListView.builder( + key: ValueKey(state.spaces.hashCode), + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: actions.length, + physics: StyledScrollPhysics(), + itemBuilder: (_, index) => actions[index], + ); + }, + ); + }, + ), + ); + } + + List _buildActions(BuildContext context, ViewInfoState state) { + final view = context.watch().state.view; + final appearanceSettings = context.watch().state; + final dateFormat = appearanceSettings.dateFormat; + final timeFormat = appearanceSettings.timeFormat; + + final viewMoreActionTypes = [ + if (widget.view.layout != ViewLayoutPB.Chat) ViewMoreActionType.duplicate, + ViewMoreActionType.moveTo, + ViewMoreActionType.delete, + ViewMoreActionType.divider, + ]; + + final actions = [ + ...widget.customActions, + if (widget.view.isDocument) ...[ + const FontSizeAction(), + ViewAction( + type: ViewMoreActionType.divider, + view: view, + mutex: popoverMutex, + ), + ], + if (widget.view.isDocument || widget.view.isDatabase) ...[ + LockPageAction( + view: view, + ), + ViewAction( + type: ViewMoreActionType.divider, + view: view, + mutex: popoverMutex, + ), + ], + ...viewMoreActionTypes.map( + (type) => ViewAction( + type: type, + view: view, + mutex: popoverMutex, + ), + ), + if (state.documentCounters != null || state.createdAt != null) ...[ + ViewMetaInfo( + dateFormat: dateFormat, + timeFormat: timeFormat, + documentCounters: state.documentCounters, + titleCounters: state.titleCounters, + 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 index 0ce56272b1..2ecec3244c 100644 --- 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 @@ -1,35 +1,19 @@ -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/startup.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:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; - -enum ViewActionType { - delete, - duplicate; - - String get label => switch (this) { - ViewActionType.delete => LocaleKeys.moreAction_deleteView.tr(), - ViewActionType.duplicate => LocaleKeys.moreAction_duplicateView.tr(), - }; - - FlowySvgData get icon => switch (this) { - ViewActionType.delete => FlowySvgs.delete_s, - ViewActionType.duplicate => FlowySvgs.m_duplicate_s, - }; - - ViewEvent get actionEvent => switch (this) { - ViewActionType.delete => const ViewEvent.delete(), - ViewActionType.duplicate => const ViewEvent.duplicate(), - }; -} +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class ViewAction extends StatelessWidget { const ViewAction({ @@ -39,28 +23,126 @@ class ViewAction extends StatelessWidget { this.mutex, }); - final ViewActionType type; + final ViewMoreActionType type; final ViewPB view; final PopoverMutex? mutex; @override Widget build(BuildContext context) { - return FlowyButton( - onTap: () { - getIt(param1: view).add(type.actionEvent); + final wrapper = ViewMoreActionTypeWrapper( + type, + view, + (controller, data) async { + await _onAction(context, data); mutex?.close(); }, - text: FlowyText.regular( - type.label, - color: AFThemeExtension.of(context).textColor, + 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(); + } + } +} + +class CustomViewAction extends StatelessWidget { + const CustomViewAction({ + super.key, + required this.view, + required this.leftIcon, + required this.label, + this.tooltipMessage, + this.disabled = false, + this.onTap, + this.mutex, + }); + + final ViewPB view; + final FlowySvgData leftIcon; + final String label; + final bool disabled; + final String? tooltipMessage; + final VoidCallback? onTap; + final PopoverMutex? mutex; + + @override + Widget build(BuildContext context) { + return Container( + height: 34, + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: FlowyTooltip( + message: tooltipMessage, + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 6), + disable: disabled, + onTap: onTap, + leftIcon: FlowySvg( + leftIcon, + size: const Size.square(16.0), + color: disabled ? Theme.of(context).disabledColor : null, + ), + iconPadding: 10.0, + text: FlowyText( + label, + figmaLineHeight: 18.0, + color: disabled ? Theme.of(context).disabledColor : null, + ), + ), ), - leftIcon: FlowySvg( - type.icon, - color: Theme.of(context).iconTheme.color, - size: const Size.square(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_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart index c56f93ee90..8e0fa8c43c 100644 --- 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 @@ -1,12 +1,10 @@ -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/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.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/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class FontSizeAction extends StatelessWidget { @@ -31,18 +29,25 @@ class FontSizeAction extends StatelessWidget { ), ); }, - child: FlowyButton( - text: FlowyText.regular( - LocaleKeys.moreAction_fontSize.tr(), - color: AFThemeExtension.of(context).textColor, + 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, ), - 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/lock_page_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart new file mode 100644 index 0000000000..202919b639 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart @@ -0,0 +1,119 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/view/view_lock_status_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:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class LockPageAction extends StatefulWidget { + const LockPageAction({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + State createState() => _LockPageActionState(); +} + +class _LockPageActionState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => ViewLockStatusBloc(view: widget.view) + ..add( + ViewLockStatusEvent.initial(), + ), + child: BlocBuilder( + builder: (context, state) { + return _buildTextButton(context); + }, + ), + ); + } + + Widget _buildTextButton( + BuildContext context, + ) { + return Container( + height: 34, + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: FlowyIconTextButton( + margin: const EdgeInsets.symmetric(horizontal: 6), + onTap: () => _toggle(context), + leftIconBuilder: (onHover) => FlowySvg( + FlowySvgs.lock_page_s, + size: const Size.square(16.0), + ), + iconPadding: 10.0, + textBuilder: (onHover) => FlowyText( + LocaleKeys.disclosureAction_lockPage.tr(), + figmaLineHeight: 18.0, + ), + rightIconBuilder: (_) => _buildSwitch( + context, + ), + ), + ); + } + + Widget _buildSwitch(BuildContext context) { + final lockState = context.read().state; + if (lockState.isLoadingLockStatus) { + return SizedBox.shrink(); + } + + return Container( + width: 30, + height: 20, + margin: const EdgeInsets.only(right: 6), + child: FittedBox( + fit: BoxFit.fill, + child: CupertinoSwitch( + value: lockState.isLocked, + activeTrackColor: Theme.of(context).colorScheme.primary, + onChanged: (_) => _toggle(context), + ), + ), + ); + } + + Future _toggle(BuildContext context) async { + final isLocked = context.read().state.isLocked; + + context.read().add( + isLocked ? ViewLockStatusEvent.unlock() : ViewLockStatusEvent.lock(), + ); + + Log.info('update page(${widget.view.id}) lock status: $isLocked'); + } +} + +class LockPageButtonWrapper extends StatelessWidget { + const LockPageButtonWrapper({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.lockPage_lockedOperationTooltip.tr(), + child: IgnorePointer( + child: Opacity( + opacity: 0.5, + child: child, + ), + ), + ); + } +} 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 index a5a72964af..27b96d39e9 100644 --- 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 @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - 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'; @@ -7,6 +5,7 @@ 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({ @@ -14,12 +13,14 @@ class ViewMetaInfo extends StatelessWidget { required this.dateFormat, required this.timeFormat, this.documentCounters, + this.titleCounters, this.createdAt, }); final UserDateFormatPB dateFormat; final UserTimeFormatPB timeFormat; final Counters? documentCounters; + final Counters? titleCounters; final DateTime? createdAt; @override @@ -32,34 +33,43 @@ class ViewMetaInfo extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (documentCounters != null) ...[ + if (documentCounters != null && titleCounters != null) ...[ FlowyText.regular( LocaleKeys.moreAction_wordCount.tr( args: [ - numberFormat.format(documentCounters!.wordCount).toString(), + numberFormat + .format( + documentCounters!.wordCount + titleCounters!.wordCount, + ) + .toString(), ], ), - fontSize: 11, + fontSize: 12, color: Theme.of(context).hintColor, ), const VSpace(2), FlowyText.regular( LocaleKeys.moreAction_charCount.tr( args: [ - numberFormat.format(documentCounters!.charCount).toString(), + numberFormat + .format( + documentCounters!.charCount + titleCounters!.charCount, + ) + .toString(), ], ), - fontSize: 11, + fontSize: 12, color: Theme.of(context).hintColor, ), ], if (createdAt != null) ...[ - if (documentCounters != null) const VSpace(2), + if (documentCounters != null && titleCounters != null) + const VSpace(2), FlowyText.regular( LocaleKeys.moreAction_createdAt.tr( args: [dateFormat.formatDate(createdAt!, true, timeFormat)], ), - fontSize: 11, + 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 index bb285a7917..fb39d73965 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart @@ -1,5 +1,4 @@ -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart' hide WidgetBuilder; +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'; @@ -7,6 +6,7 @@ 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, @@ -17,13 +17,21 @@ class PopoverActionList extends StatefulWidget { 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; @@ -35,6 +43,13 @@ class PopoverActionList extends StatefulWidget { 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(); @@ -42,20 +57,37 @@ class PopoverActionList extends StatefulWidget { class _PopoverActionListState extends State> { - late PopoverController popoverController; + late PopoverController popoverController = + widget.controller ?? PopoverController(); @override - void initState() { - popoverController = PopoverController(); - super.initState(); + 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, @@ -63,7 +95,8 @@ class _PopoverActionListState offset: widget.offset, triggerActions: PopoverTriggerFlags.none, onClose: widget.onClosed, - popupBuilder: (BuildContext popoverContext) { + showAtCursor: widget.showAtCursor, + popupBuilder: (_) { widget.onPopupBuilder?.call(); final List children = widget.actions.map((action) { if (action is ActionCell) { @@ -83,15 +116,17 @@ class _PopoverActionListState ); } else { final custom = action as CustomActionCell; - return custom.buildWithContext(context); + return custom.buildWithContext( + context, + popoverController, + widget.popoverMutex, + ); } }).toList(); return IntrinsicHeight( child: IntrinsicWidth( - child: Column( - children: children, - ), + child: Column(children: children), ), ); }, @@ -104,6 +139,9 @@ 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( @@ -121,7 +159,11 @@ abstract class PopoverActionCell extends PopoverAction { } abstract class CustomActionCell extends PopoverAction { - Widget buildWithContext(BuildContext context); + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ); } abstract class PopoverAction {} @@ -159,6 +201,7 @@ class ActionCellWidget extends StatelessWidget { leftIcon: leftIcon, rightIcon: rightIcon, name: actionCell.name, + textColor: actionCell.textColor(context), onTap: () => onSelected(action), ); } @@ -222,6 +265,7 @@ class HoverButton extends StatelessWidget { this.leftIcon, required this.name, this.rightIcon, + this.textColor, }); final VoidCallback onTap; @@ -229,6 +273,7 @@ class HoverButton extends StatelessWidget { final Widget? leftIcon; final Widget? rightIcon; final String name; + final Color? textColor; @override Widget build(BuildContext context) { @@ -245,9 +290,11 @@ class HoverButton extends StatelessWidget { HSpace(ActionListSizes.itemHPadding), ], Expanded( - child: FlowyText.medium( + child: FlowyText.regular( name, overflow: TextOverflow.visible, + lineHeight: 1.15, + color: textColor, ), ), if (rightIcon != null) ...[ 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 index b12ae6644a..954fc77603 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/rename_view_popover.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/rename_view_popover.dart @@ -1,28 +1,34 @@ -import 'package:flutter/widgets.dart'; - import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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.view, required this.name, required this.popoverController, required this.emoji, this.icon, this.showIconChanger = true, + this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); - final String viewId; + final ViewPB view; final String name; final PopoverController popoverController; - final String emoji; + final EmojiIconData emoji; final Widget? icon; final bool showIconChanger; + final List tabs; @override State createState() => _RenameViewPopoverState(); @@ -51,17 +57,22 @@ class _RenameViewPopoverState extends State { mainAxisSize: MainAxisSize.min, children: [ if (widget.showIconChanger) ...[ - EmojiPickerButton( - emoji: widget.emoji, - defaultIcon: widget.icon, - direction: PopoverDirection.bottomWithCenterAligned, - offset: const Offset(0, 18), - onSubmitted: _updateViewIcon, + SizedBox( + width: 30.0, + child: EmojiPickerButton( + emoji: widget.emoji, + defaultIcon: widget.icon, + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 18), + onSubmitted: _updateViewIcon, + documentId: widget.view.id, + tabs: widget.tabs, + ), ), const HSpace(6), ], SizedBox( - height: 36.0, + height: 32.0, width: 220, child: FlowyTextField( controller: _controller, @@ -78,18 +89,23 @@ class _RenameViewPopoverState extends State { Future _updateViewName(String name) async { if (name.isNotEmpty && name != widget.name) { await ViewBackendService.updateView( - viewId: widget.viewId, + viewId: widget.view.id, name: _controller.text, ); widget.popoverController.close(); } } - Future _updateViewIcon(String emoji, PopoverController? _) async { + Future _updateViewIcon( + SelectedEmojiIconResult r, + PopoverController? _, + ) async { await ViewBackendService.updateViewIcon( - viewId: widget.viewId, - viewIcon: emoji, + view: widget.view, + viewIcon: r.data, ); - widget.popoverController.close(); + if (!r.keepOpen) { + 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 0000000000..f229951e04 --- /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 index 1023553efb..88474b20b3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart @@ -1,12 +1,20 @@ +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_listener.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 ViewTabBarItem extends StatefulWidget { - const ViewTabBarItem({super.key, required this.view}); + const ViewTabBarItem({ + super.key, + required this.view, + this.shortForm = false, + }); final ViewPB view; + final bool shortForm; @override State createState() => _ViewTabBarItemState(); @@ -38,6 +46,25 @@ class _ViewTabBarItemState extends State { @override Widget build(BuildContext context) { - return FlowyText.medium(view.name); + return Row( + mainAxisAlignment: + widget.shortForm ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + if (widget.view.icon.value.isNotEmpty) + RawEmojiIconWidget( + emoji: widget.view.icon.toEmojiIconData(), + emojiSize: 16, + ), + 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 index c788eaeea3..5cb834cbf3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle.dart @@ -1,16 +1,43 @@ -import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.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, - required this.style, + this.style = const ToggleStyle.big(), this.thumbColor, this.activeBackgroundColor, this.inactiveBackgroundColor, + this.duration = const Duration(milliseconds: 150), this.padding = const EdgeInsets.all(8.0), }); @@ -21,14 +48,16 @@ class Toggle extends StatelessWidget { final Color? activeBackgroundColor; final Color? inactiveBackgroundColor; final EdgeInsets padding; + final Duration duration; @override Widget build(BuildContext context) { final backgroundColor = value ? activeBackgroundColor ?? Theme.of(context).colorScheme.primary - : activeBackgroundColor ?? AFThemeExtension.of(context).toggleOffFill; + : inactiveBackgroundColor ?? + AFThemeExtension.of(context).toggleButtonBGColor; return GestureDetector( - onTap: () => onChanged(value), + onTap: () => onChanged(!value), child: Padding( padding: padding, child: Stack( @@ -42,14 +71,14 @@ class Toggle extends StatelessWidget { ), ), AnimatedPositioned( - duration: const Duration(milliseconds: 150), + duration: duration, 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 ?? Theme.of(context).colorScheme.onPrimary, + color: thumbColor ?? Colors.white, borderRadius: BorderRadius.circular(style.thumbRadius / 2), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle_style.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle_style.dart deleted file mode 100644 index d11bb5e40e..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle_style.dart +++ /dev/null @@ -1,20 +0,0 @@ -class ToggleStyle { - ToggleStyle({ - required this.height, - required this.width, - required this.thumbRadius, - }); - - final double height; - final double width; - final double thumbRadius; - - static ToggleStyle get big => - ToggleStyle(height: 16, width: 27, thumbRadius: 14); - - static ToggleStyle get small => - ToggleStyle(height: 10, width: 16, thumbRadius: 8); - - static ToggleStyle get mobile => - ToggleStyle(height: 24, width: 42, thumbRadius: 18); -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart index 4b6708c151..347d95d01d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart @@ -1,90 +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:appflowy/plugins/base/emoji/emoji_text.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'; - -const double _smallSize = 28; -const double _largeSize = 64; +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, - this.isLarge = false, + required this.size, + required this.fontSize, this.isHovering = false, + this.decoration, }); final String iconUrl; final String name; - final bool isLarge; + 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) { - final size = isLarge ? _largeSize : _smallSize; - if (iconUrl.isEmpty) { - 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: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: isHovering - ? Border.all( - color: _darken(color), - width: 4, - ) - : null, - ), - child: FlowyText.semibold( - nameInitials, - color: Colors.black, - fontSize: isLarge - ? nameInitials.length == initialsCount - ? 20 - : 26 - : nameInitials.length == initialsCount - ? 12 - : 14, - ), - ); + 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: BoxDecoration( - shape: BoxShape.circle, - border: isHovering - ? Border.all( - color: Theme.of(context).colorScheme.primary, - width: 4, - ) - : null, + decoration: decoration ?? + BoxDecoration( + shape: BoxShape.circle, + border: isHovering + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 4, + ) + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(size / 2), + child: Image.network( + iconUrl, + width: size, + height: size, + 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( @@ -94,7 +127,7 @@ class UserAvatar extends StatelessWidget { FlowySvgData('emoji/$iconUrl'), blendMode: null, ) - : EmojiText(emoji: iconUrl, fontSize: isLarge ? 36 : 18), + : FlowyText.emoji(iconUrl, fontSize: fontSize), ), ), ), 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 index 9d65cc06a1..3be0973123 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart @@ -1,23 +1,27 @@ -import 'dart:math'; - -import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; -import 'package:appflowy/startup/tasks/app_window_size_manager.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.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/user/user_workspace_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/workspace/application/view/view_lock_status_bloc.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/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:appflowy_popover/appflowy_popover.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/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -// workspace name / ... / view_title -class ViewTitleBar extends StatefulWidget { +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, @@ -25,242 +29,279 @@ class ViewTitleBar extends StatefulWidget { final ViewPB view; - @override - State createState() => _ViewTitleBarState(); -} - -class _ViewTitleBarState extends State { - late Future> ancestors; - late String viewId; - - @override - void initState() { - super.initState(); - - viewId = widget.view.id; - _reloadAncestors(viewId); - } - - @override - void didUpdateWidget(covariant ViewTitleBar oldWidget) { - super.didUpdateWidget(oldWidget); - - if (oldWidget.view.id != widget.view.id) { - viewId = widget.view.id; - _reloadAncestors(viewId); - } - } - @override Widget build(BuildContext context) { - return FutureBuilder>( - future: ancestors, - builder: (context, snapshot) { - final ancestors = snapshot.data; - if (ancestors == null || - snapshot.connectionState != ConnectionState.done) { - return const SizedBox.shrink(); - } - const maxWidth = WindowSizeManager.minWindowWidth / 2.0; - final replacement = Row( - // refresh the view title bar when the ancestors changed - key: ValueKey(ancestors.hashCode), - children: _buildViewTitles(context, ancestors), - ); - return LayoutBuilder( - builder: (context, constraints) { - return Visibility( - visible: constraints.maxWidth < maxWidth, - replacement: replacement, - // if the width is too small, only show one view title bar without the ancestors - child: _ViewTitle( - key: ValueKey(ancestors.last), - view: ancestors.last, - maxTitleWidth: constraints.maxWidth, - onUpdated: () => setState(() => _reloadAncestors(viewId)), + return MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => ViewTitleBarBloc(view: view)), + BlocProvider( + create: (_) => ViewLockStatusBloc(view: view) + ..add(const ViewLockStatusEvent.initial()), + ), + ], + 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, + ), + _buildLockPageStatus(context), + ], ), - ); - }, - ); + ), + ); + }, + ), + ); + } + + Widget _buildLockPageStatus(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => + previous.isLoadingLockStatus == current.isLoadingLockStatus && + current.isLoadingLockStatus == false, + listener: (context, state) { + if (state.isLocked) { + showToastNotification( + message: LocaleKeys.lockPage_pageLockedToast.tr(), + ); + } + }, + builder: (context, state) { + if (state.isLocked) { + return LockedPageStatus(); + } else if (!state.isLocked && state.lockCounter > 0) { + return ReLockedPageStatus(); + } + return const SizedBox.shrink(); }, ); } - List _buildViewTitles(BuildContext context, List views) { + 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 = []; - for (var i = 0; i < views.length; i++) { + 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 >= 1 && i < views.length - 2) { + if (i >= lowerBound && i < upperBound) { if (!hasAddedEllipsis) { hasAddedEllipsis = true; - children.add( - const FlowyText.regular(' ... /'), - ); + children.addAll([ + const FlowyText.regular(' ... '), + const FlowySvg(FlowySvgs.title_bar_divider_s), + ]); } continue; } - Widget child; - if (i == 0) { - final currentWorkspace = - context.read().state.currentWorkspace; - final icon = currentWorkspace?.icon ?? ''; - final name = currentWorkspace?.name ?? view.name; - // the first one is the workspace name - child = FlowyTooltip( - message: name, - child: Row( - children: [ - EmojiText( - emoji: icon, - fontSize: 18.0, - ), - const HSpace(2.0), - FlowyText.regular(name), - const HSpace(4.0), - ], - ), - ); - } else { - child = FlowyTooltip( - 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: () => setState(() => _reloadAncestors(viewId)), - ), - ); - } + final child = FlowyTooltip( + key: ValueKey(view.id), + message: view.name, + child: ViewTitle( + view: view, + behavior: i == views.length - 1 && !view.isLocked + ? 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 FlowyText.regular('/')); + children.add(const FlowySvg(FlowySvgs.title_bar_divider_s)); } } return children; } - void _reloadAncestors(String viewId) { - ancestors = ViewBackendService.getViewAncestors(viewId) - .fold((s) => s.items, (f) => []); + 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()), + ), + ), + ]; } } -enum _ViewTitleBehavior { +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({ +class ViewTitle extends StatefulWidget { + const ViewTitle({ super.key, required this.view, - this.behavior = _ViewTitleBehavior.editable, - this.maxTitleWidth = 180, + this.behavior = ViewTitleBehavior.editable, required this.onUpdated, }); final ViewPB view; - final _ViewTitleBehavior behavior; - final double maxTitleWidth; + final ViewTitleBehavior behavior; final VoidCallback onUpdated; @override - State<_ViewTitle> createState() => _ViewTitleState(); + State createState() => _ViewTitleState(); } -class _ViewTitleState extends State<_ViewTitle> { +class _ViewTitleState extends State { final popoverController = PopoverController(); final textEditingController = TextEditingController(); - late final viewListener = ViewListener(viewId: widget.view.id); - - String name = ''; - String icon = ''; - String inputtingName = ''; - - @override - void initState() { - super.initState(); - - name = widget.view.name; - icon = widget.view.icon.value; - - _resetTextEditingController(); - viewListener.start( - onViewUpdated: (view) { - if (name != view.name || icon != view.icon.value) { - widget.onUpdated(); - } - setState(() { - name = view.name; - icon = view.icon.value; - _resetTextEditingController(); - }); - }, - ); - } @override void dispose() { textEditingController.dispose(); popoverController.close(); - viewListener.stop(); super.dispose(); } @override Widget build(BuildContext context) { - // root view - if (widget.view.parentViewId.isEmpty) { - return Row( - children: [ - FlowyText.regular(name), - const HSpace(4.0), - ], - ); - } + final isEditable = widget.behavior == ViewTitleBehavior.editable; - final child = SingleChildScrollView( - child: Row( - children: [ - EmojiText( - emoji: icon, - fontSize: 18.0, - ), - const HSpace(2.0), - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: max(0, widget.maxTitleWidth), - ), - child: FlowyText.regular( - name, - overflow: TextOverflow.ellipsis, - ), - ), - ], + 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); + } + }, ), ); + } - if (widget.behavior == _ViewTitleBehavior.uneditable) { - return Listener( - onPointerDown: (_) => context.read().openPlugin(widget.view), + 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, - onTap: () {}, - text: child, + 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, @@ -268,32 +309,167 @@ class _ViewTitleState extends State<_ViewTitle> { ), controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, - offset: const Offset(0, 18), + offset: const Offset(0, 6), popupBuilder: (context) { // icon + textfield - _resetTextEditingController(); + _resetTextEditingController(state); return RenameViewPopover( - viewId: widget.view.id, + view: widget.view, name: widget.view.name, popoverController: popoverController, icon: widget.view.defaultIcon(), - emoji: icon, + emoji: state.icon, + tabs: const [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ], ); }, - child: FlowyButton( - useIntrinsicWidth: true, - text: child, + child: SizedBox( + height: 32.0, + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric(horizontal: 6.0), + text: _buildIconAndName(context, state, true), + ), ), ); } - void _resetTextEditingController() { - inputtingName = name; + 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 = name + ..text = state.name ..selection = TextSelection( baseOffset: 0, - extentOffset: name.length, + extentOffset: state.name.length, ); } } + +class LockedPageStatus extends StatelessWidget { + const LockedPageStatus({super.key}); + + @override + Widget build(BuildContext context) { + final color = const Color(0xFFD95A0B); + return FlowyTooltip( + message: LocaleKeys.lockPage_lockTooltip.tr(), + child: DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide(color: color), + borderRadius: BorderRadius.circular(6), + ), + color: context.lockedPageButtonBackground, + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 4.0, + ), + iconPadding: 4.0, + text: FlowyText.regular( + LocaleKeys.lockPage_lockPage.tr(), + color: color, + fontSize: 12.0, + ), + hoverColor: color.withValues(alpha: 0.1), + leftIcon: FlowySvg( + FlowySvgs.lock_page_fill_s, + blendMode: null, + ), + onTap: () => context.read().add( + const ViewLockStatusEvent.unlock(), + ), + ), + ), + ); + } +} + +class ReLockedPageStatus extends StatelessWidget { + const ReLockedPageStatus({super.key}); + + @override + Widget build(BuildContext context) { + final iconColor = const Color(0xFF8F959E); + return DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide(color: iconColor), + borderRadius: BorderRadius.circular(6), + ), + color: context.lockedPageButtonBackground, + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 4.0, + ), + iconPadding: 4.0, + text: FlowyText.regular( + LocaleKeys.lockPage_reLockPage.tr(), + fontSize: 12.0, + ), + leftIcon: FlowySvg( + FlowySvgs.unlock_page_s, + color: iconColor, + blendMode: null, + ), + onTap: () => context.read().add( + const ViewLockStatusEvent.lock(), + ), + ), + ); + } +} + +extension on BuildContext { + Color get lockedPageButtonBackground { + if (Theme.of(this).brightness == Brightness.light) { + return Colors.white.withValues(alpha: 0.75); + } + return Color(0xB21B1A22); + } +} diff --git a/frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h b/frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h index 9d9128671c..78992141ca 100644 --- a/frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h +++ b/frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h @@ -3,7 +3,7 @@ #include #include -int64_t init_sdk(char *data); +int64_t init_sdk(int64_t port, char *data); void async_event(int64_t port, const uint8_t *input, uintptr_t len); @@ -11,6 +11,8 @@ 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); diff --git a/frontend/appflowy_flutter/linux/my_application.cc b/frontend/appflowy_flutter/linux/my_application.cc index 25b07c8d9c..2a3a02cac4 100644 --- a/frontend/appflowy_flutter/linux/my_application.cc +++ b/frontend/appflowy_flutter/linux/my_application.cc @@ -28,38 +28,7 @@ static void my_application_activate(GApplication *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, "AppFlowy"); - 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, "AppFlowy"); - } + gtk_window_set_title(window, "AppFlowy"); gtk_window_set_default_size(window, 1280, 720); gtk_widget_show(GTK_WIDGET(window)); diff --git a/frontend/appflowy_flutter/linux/packaging/assets/logo.png b/frontend/appflowy_flutter/linux/packaging/assets/logo.png new file mode 100644 index 0000000000..f34332a395 Binary files /dev/null and b/frontend/appflowy_flutter/linux/packaging/assets/logo.png differ diff --git a/frontend/appflowy_flutter/linux/packaging/deb/make_config.yaml b/frontend/appflowy_flutter/linux/packaging/deb/make_config.yaml new file mode 100644 index 0000000000..801a5dbc02 --- /dev/null +++ b/frontend/appflowy_flutter/linux/packaging/deb/make_config.yaml @@ -0,0 +1,36 @@ +display_name: AppFlowy +package_name: appflowy + +maintainer: + name: AppFlowy + email: support@appflowy.io + +keywords: + - AppFlowy + - Office + - Document + - Database + - Note + - Kanban + - Note + +installed_size: 100000 +icon: linux/packaging/assets/logo.png + +generic_name: AppFlowy + +categories: + - Office + - Productivity + +startup_notify: true +essential: false + +section: x11 +priority: optional + +supportedMimeType: x-scheme-handler/appflowy-flutter + +dependencies: + - libnotify-bin + - libkeybinder-3.0-0 diff --git a/frontend/appflowy_flutter/linux/packaging/rpm/make_config.yaml b/frontend/appflowy_flutter/linux/packaging/rpm/make_config.yaml new file mode 100644 index 0000000000..3fcdea03bc --- /dev/null +++ b/frontend/appflowy_flutter/linux/packaging/rpm/make_config.yaml @@ -0,0 +1,33 @@ +display_name: AppFlowy +icon: linux/packaging/assets/logo.png +group: Applications/Office +vendor: AppFlowy +packager: AppFlowy +packagerEmail: support@appflowy.io +license: APGL-3.0 +url: https://github.com/AppFlowy-IO/appflowy + +build_arch: x86_64 + +keywords: + - AppFlowy + - Office + - Document + - Database + - Note + - Kanban + - Note + +generic_name: AppFlowy + +categories: + - Office + - Productivity + +startup_notify: true + +supportedMimeType: x-scheme-handler/appflowy-flutter + +requires: + - libnotify + - keybinder diff --git a/frontend/appflowy_flutter/macos/.gitignore b/frontend/appflowy_flutter/macos/.gitignore index 9aad20e46d..d2fd377230 100644 --- a/frontend/appflowy_flutter/macos/.gitignore +++ b/frontend/appflowy_flutter/macos/.gitignore @@ -4,4 +4,3 @@ # Xcode-related **/xcuserdata/ -Podfile.lock \ No newline at end of file diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock new file mode 100644 index 0000000000..b4a1a3d20d --- /dev/null +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -0,0 +1,178 @@ +PODS: + - app_links (1.0.0): + - FlutterMacOS + - appflowy_backend (0.0.1): + - FlutterMacOS + - auto_updater_macos (0.0.1): + - FlutterMacOS + - Sparkle + - 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.1) + - 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.4) + - screen_retriever_macos (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 + - Sparkle (2.6.4) + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - super_native_extensions (0.0.1): + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - webview_flutter_wkwebview (0.0.1): + - Flutter + - 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`) + - auto_updater_macos (from `Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/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_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/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_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/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`) + - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) + - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) + +SPEC REPOS: + trunk: + - HotKey + - ReachabilitySwift + - Sentry + - Sparkle + +EXTERNAL SOURCES: + app_links: + :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + appflowy_backend: + :path: Flutter/ephemeral/.symlinks/plugins/appflowy_backend/macos + auto_updater_macos: + :path: Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/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_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/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_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/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 + webview_flutter_wkwebview: + :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin + window_manager: + :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos + +SPEC CHECKSUMS: + app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a + appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 + auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d + bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 + connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 + desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 + file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d + flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 + hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c + irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 + local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff + package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda + screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 + Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 + share_plus: 1fa619de8392a4398bfaf176d441853922614e89 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 + url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + +PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 + +COCOAPODS: 1.16.2 diff --git a/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj index 13448a56c9..88c451bdd9 100644 --- a/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj +++ b/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj @@ -28,6 +28,8 @@ 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 */; }; + FB54062C2D22665000223D60 /* liblzma.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = FB54062B2D22664200223D60 /* liblzma.tbd */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -75,6 +77,8 @@ 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; }; + FB54062B2D22664200223D60 /* liblzma.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = liblzma.tbd; path = usr/lib/liblzma.tbd; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -82,7 +86,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FB4E0E4F2CC9F3F900C57E87 /* libbz2.tbd in Frameworks */, 706E045829F286F600B789F4 /* libc++.tbd in Frameworks */, + FB54062C2D22665000223D60 /* liblzma.tbd in Frameworks */, D7360C6D6177708F7B2D3C9D /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -168,6 +174,8 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + FB54062B2D22664200223D60 /* liblzma.tbd */, + FB4E0E4E2CC9F3E900C57E87 /* libbz2.tbd */, 706E045729F286EC00B789F4 /* libc++.tbd */, 1CD81A6C7244B2318E0BA2E8 /* Pods_Runner.framework */, ); diff --git a/frontend/appflowy_flutter/macos/Runner/AppDelegate.swift b/frontend/appflowy_flutter/macos/Runner/AppDelegate.swift index d53ef64377..c7872aaec9 100644 --- a/frontend/appflowy_flutter/macos/Runner/AppDelegate.swift +++ b/frontend/appflowy_flutter/macos/Runner/AppDelegate.swift @@ -1,9 +1,23 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main 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 + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } } diff --git a/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig b/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig index da469610eb..d2b3d7e9b3 100644 --- a/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig +++ b/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -11,4 +11,4 @@ PRODUCT_NAME = AppFlowy PRODUCT_BUNDLE_IDENTIFIER = io.appflowy.appflowy // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2024 AppFlowy.IO. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2025 AppFlowy.IO. All rights reserved. diff --git a/frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements b/frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements index 71949adefe..4c829c7ab0 100644 --- a/frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements +++ b/frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements @@ -1,20 +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 - - / - - - + + 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 + + / + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/macos/Runner/Info.plist b/frontend/appflowy_flutter/macos/Runner/Info.plist index cd07887134..cb3d1127a0 100644 --- a/frontend/appflowy_flutter/macos/Runner/Info.plist +++ b/frontend/appflowy_flutter/macos/Runner/Info.plist @@ -1,57 +1,61 @@ - - 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 - + 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 + SUPublicEDKey + Bs++IOmOwYmNTjMMC2jMqLNldP+mndDp/LwujCg2/kw= + SUAllowsAutomaticUpdates + - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - + \ No newline at end of file diff --git a/frontend/appflowy_flutter/macos/Runner/MainFlutterWindow.swift b/frontend/appflowy_flutter/macos/Runner/MainFlutterWindow.swift index 8e357d7ca1..620ad5c9bc 100644 --- a/frontend/appflowy_flutter/macos/Runner/MainFlutterWindow.swift +++ b/frontend/appflowy_flutter/macos/Runner/MainFlutterWindow.swift @@ -1,7 +1,7 @@ import Cocoa import FlutterMacOS -private let kTrafficLightOffetTop = 22 +private let kTrafficLightOffetTop = 14 class MainFlutterWindow: NSWindow { func registerMethodChannel(flutterViewController: FlutterViewController) { @@ -17,7 +17,7 @@ class MainFlutterWindow: NSWindow { let nY = position[1] as! NSNumber let x = nX.doubleValue let y = nY.doubleValue - + self.setFrameOrigin(NSPoint(x: x, y: y)) result(nil) return @@ -30,7 +30,7 @@ class MainFlutterWindow: NSWindow { result(nil) return } - + result(FlutterMethodNotImplemented) }) } @@ -51,9 +51,9 @@ class MainFlutterWindow: NSWindow { 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) + 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() @@ -74,7 +74,13 @@ class MainFlutterWindow: NSWindow { self.titleVisibility = .hidden self.styleMask.insert(StyleMask.fullSizeContentView) self.isMovableByWindowBackground = true - self.isMovable = false + + // 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() diff --git a/frontend/appflowy_flutter/macos/Runner/Release.entitlements b/frontend/appflowy_flutter/macos/Runner/Release.entitlements index 71949adefe..4c829c7ab0 100644 --- a/frontend/appflowy_flutter/macos/Runner/Release.entitlements +++ b/frontend/appflowy_flutter/macos/Runner/Release.entitlements @@ -1,20 +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 - - / - - - + + 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 + + / + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/project/PROJECT@v11_mod=a7fbf46937053896f73cc7c7ec6baefb_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1-json b/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/project/PROJECT@v11_mod=a7fbf46937053896f73cc7c7ec6baefb_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1-json new file mode 100644 index 0000000000..87f89b3bbc --- /dev/null +++ b/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/project/PROJECT@v11_mod=a7fbf46937053896f73cc7c7ec6baefb_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1-json @@ -0,0 +1 @@ +{"appPreferencesBuildSettings":{},"buildConfigurations":[{"buildSettings":{"ALWAYS_SEARCH_USER_PATHS":"NO","CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED":"YES","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_ENABLE_OBJC_WEAK":"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_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_IMPLICIT_RETAIN_SELF":"YES","CLANG_WARN_OBJC_LITERAL_CONVERSION":"YES","CLANG_WARN_OBJC_ROOT_CLASS":"YES_ERROR","CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER":"YES","CLANG_WARN_RANGE_LOOP_ANALYSIS":"YES","CLANG_WARN_STRICT_PROTOTYPES":"YES","CLANG_WARN_SUSPICIOUS_MOVE":"YES","CLANG_WARN_UNGUARDED_AVAILABILITY":"YES_AGGRESSIVE","CLANG_WARN_UNREACHABLE_CODE":"YES","CLANG_WARN__DUPLICATE_METHOD_MATCH":"YES","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":"POD_CONFIGURATION_DEBUG=1 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":"INCLUDE_SOURCE","MTL_FAST_MATH":"YES","ONLY_ACTIVE_ARCH":"YES","PRODUCT_NAME":"$(TARGET_NAME)","STRIP_INSTALLED_PRODUCT":"NO","SWIFT_ACTIVE_COMPILATION_CONDITIONS":"DEBUG","SWIFT_OPTIMIZATION_LEVEL":"-Onone","SWIFT_VERSION":"5.0","SYMROOT":"${SRCROOT}/../build"},"guid":"bfdfe7dc352907fc980b868725387e98814b7e2c3bac55ee99d78eaa8d1ec61e","name":"Debug"},{"buildSettings":{"ALWAYS_SEARCH_USER_PATHS":"NO","CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED":"YES","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_ENABLE_OBJC_WEAK":"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_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_IMPLICIT_RETAIN_SELF":"YES","CLANG_WARN_OBJC_LITERAL_CONVERSION":"YES","CLANG_WARN_OBJC_ROOT_CLASS":"YES_ERROR","CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER":"YES","CLANG_WARN_RANGE_LOOP_ANALYSIS":"YES","CLANG_WARN_STRICT_PROTOTYPES":"YES","CLANG_WARN_SUSPICIOUS_MOVE":"YES","CLANG_WARN_UNGUARDED_AVAILABILITY":"YES_AGGRESSIVE","CLANG_WARN_UNREACHABLE_CODE":"YES","CLANG_WARN__DUPLICATE_METHOD_MATCH":"YES","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_PREPROCESSOR_DEFINITIONS":"POD_CONFIGURATION_PROFILE=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":"NO","MTL_FAST_MATH":"YES","PRODUCT_NAME":"$(TARGET_NAME)","STRIP_INSTALLED_PRODUCT":"NO","SWIFT_COMPILATION_MODE":"wholemodule","SWIFT_OPTIMIZATION_LEVEL":"-O","SWIFT_VERSION":"5.0","SYMROOT":"${SRCROOT}/../build"},"guid":"bfdfe7dc352907fc980b868725387e98c22f26ca3341c3062f2313dc737070d4","name":"Profile"},{"buildSettings":{"ALWAYS_SEARCH_USER_PATHS":"NO","CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED":"YES","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_ENABLE_OBJC_WEAK":"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_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_IMPLICIT_RETAIN_SELF":"YES","CLANG_WARN_OBJC_LITERAL_CONVERSION":"YES","CLANG_WARN_OBJC_ROOT_CLASS":"YES_ERROR","CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER":"YES","CLANG_WARN_RANGE_LOOP_ANALYSIS":"YES","CLANG_WARN_STRICT_PROTOTYPES":"YES","CLANG_WARN_SUSPICIOUS_MOVE":"YES","CLANG_WARN_UNGUARDED_AVAILABILITY":"YES_AGGRESSIVE","CLANG_WARN_UNREACHABLE_CODE":"YES","CLANG_WARN__DUPLICATE_METHOD_MATCH":"YES","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_PREPROCESSOR_DEFINITIONS":"POD_CONFIGURATION_RELEASE=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":"NO","MTL_FAST_MATH":"YES","PRODUCT_NAME":"$(TARGET_NAME)","STRIP_INSTALLED_PRODUCT":"NO","SWIFT_COMPILATION_MODE":"wholemodule","SWIFT_OPTIMIZATION_LEVEL":"-O","SWIFT_VERSION":"5.0","SYMROOT":"${SRCROOT}/../build"},"guid":"bfdfe7dc352907fc980b868725387e9828903703a9fe9e3707306e58aab67b51","name":"Release"}],"classPrefix":"","defaultConfigurationName":"Release","developmentRegion":"en","groupTree":{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98d0b25d39b515a574839e998df229c3cb","path":"../Podfile","sourceTree":"SOURCE_ROOT","type":"file"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f986fc29daf4cb723e5ecd0e77c9cc3a","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Classes/AppLinksPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983499ae993d0615bc4a25c6d23d299cd2","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Classes/AppLinksPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9878bdb70529628b051bfb14170b6ce281","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Classes/SwiftAppLinksPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98803a16064d6b26357b9fa2d9c81eb2c0","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e982bcf9ac9f04ccd93893e9ae54d152c11","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e980af336cd97bd48a5f76247c45af3ca5a","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d4c688735e4b7c4c9af25f0254256c5a","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980f47fa79356e5b78e85637df4eab1e8f","name":"app_links","path":"app_links","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9852951db13a823ccb98a790f496fab4e3","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988786e23fb077ded3affc0f7a37fc275c","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9874ef26f64fe74d7c02d1a94bcde1184c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989187ad07f57154eca7d638acb8377adb","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dc7eee9f7cdf2e623ebc316ac5388a1e","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982c3c6bda68e418bfa0a7d818fbfb0037","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a15f02f386a528bc8eb605ae37642455","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cbbda7998cd576c276e2258fb3bad5a5","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982b881f7fdcc02480bc57ddb51bd8d98f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98aa491e8e0f7d7e2c84ecbe06c2f4df77","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ae796e67846c10ae19147ab24013260d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ee7362a7cb62b913a6f351fa670031fb","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c70d2ff23f2582de73eade3b63ca6732","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cfdf5bb4bb8df40c98fd16d64963a3be","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9827cc495d5b0d43a98c370d69de3ff8ae","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/app_links.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98b1016bc412809827cc7f8ffd7be68d60","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e988f23ea9e443884f6d6a834c279bb42c4","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989d5bbbb2531f246d5e384ddd39c9ef94","path":"app_links.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985bbf5bd8a81b79ac8b94165aebec6742","path":"app_links-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98a0510b51afa43c66fac3cfa1b303b58d","path":"app_links-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988921fa59229e94f886b598374e0a0a6c","path":"app_links-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988a8ca854307b43eb2b99c9daa9a1cc96","path":"app_links-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986a31ee17a0bf46ce08a50fbab9d7e0d7","path":"app_links.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e981b9d938e8177ba8be7381344ebbf3f72","path":"app_links.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98faf0d598eef28587701cbceba0a82824","path":"ResourceBundle-app_links_ios_privacy-app_links-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9834af3cd6a85915e92c7086a5cef194bd","name":"Support Files","path":"../../../../Pods/Target Support Files/app_links","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985fe1a1ae80c7c29a049e0b0f5a968eb6","name":"app_links","path":"../.symlinks/plugins/app_links/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c9b1d2b694569060c7a89ce432ae59aa","path":"../../../../../../packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d2eb541b9cb3760f4d8a0de1aceac0cd","path":"../../../../../../packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98ff9d3e6fbf7f3ebdf3b2d98a4d52480e","path":"../../../../../../packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98082f3bd8728bb955d3b231cc205df22a","path":"../../../../../../packages/appflowy_backend/ios/Classes/binding.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981cf16cdf61f891cb9befc3392b13ff5f","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ba42de9b682ddbb7d9ecb3a91add63c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ce8a30ee373383f69298444d24489306","name":"appflowy_backend","path":"appflowy_backend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c86a47e2309e0b005ed79419dd093f0d","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98503452eddbee5272ed788d0cc749960e","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98055cce552f828e105a78ac03f0bdd805","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ca2c764817f57c4676d84d72558b3899","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f5501b2ed32cd958d3563e60f001d703","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b9c0603d95a0886bda3e008ea3e3501a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9877a3c2776a0dd48b2c60d087afb651a0","name":"..","path":"../../../../../packages/appflowy_backend/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"archive.ar","guid":"bfdfe7dc352907fc980b868725387e9836c8d2e47d81f8a6899c11848d60e876","path":"../../../../../packages/appflowy_backend/ios/libdart_ffi.a","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5592f7a99092768eeefc4cfc096cae8","name":"Frameworks","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98c2aceac4d9f7a88a7f2aef9ddb559c68","path":"../../../../../packages/appflowy_backend/ios/appflowy_backend.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e985e17269d373b81baa61bd43ba6a542e5","path":"../../../../../packages/appflowy_backend/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9850866a0df8df8e8e5c4a2fae74fa1e1f","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98e87c56b9467409c44163e34bc882ac5d","path":"appflowy_backend.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e158e9b03f83f13bbdf8668ff066134b","path":"appflowy_backend-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98eaf420deb60cd2d5a76ad867f2138dae","path":"appflowy_backend-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9805ed6ca977fdc6df3bcf5eed84819eb9","path":"appflowy_backend-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983bb1fa0b0f13951c6fa653a914b3fa2c","path":"appflowy_backend-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986cd74f83369370fbe7e403ef8053e619","path":"appflowy_backend.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98403ef0361dbe02708d9acfefd0464e16","path":"appflowy_backend.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a1e14bed5ed1f1fb81e768bd14f444fe","name":"Support Files","path":"../../../../Pods/Target Support Files/appflowy_backend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9835d1d3e8ff3e04e32db8f29bce41a67d","name":"appflowy_backend","path":"../.symlinks/plugins/appflowy_backend/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98624a11fdf6f56961a285723dfd21440e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ConnectivityPlusPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985824c2dcc3b51cb92cca4c0ee6b76711","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ConnectivityPlusPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e980ce7873e1e41d09d3e0e844e23814078","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ConnectivityProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98880b04837ff2e7c369e7e0eb127f9146","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/PathMonitorConnectivityProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986c4b5d0d184e7bdaae9f3f567209b6a8","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ReachabilityConnectivityProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982d898374f337072929ea1137cd9b0531","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/SwiftConnectivityPlusPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a0e254b7ac4017377847f08d93859c98","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98403bc21ffba3f9ff7918b7c9d3ed9571","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f8845f64df2ea83ab8319d64b1bbadde","name":"connectivity_plus","path":"connectivity_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989eb115a9dec07b1f45a9c5d5afae3331","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9811c71b5edc42403c378790dde71cd61e","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988ba6e533cf0750afe861cf587836d63c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98337f70e0b3205d77252416fe9d53ade5","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d3ba4a1d262c8736c1a3dd2bbee95feb","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98935b95f888c988c238c3ba19998f568c","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9815214da764f00e2aace6e3402b97ff27","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c4748e70a6f1ff450d8ad89293faf1ad","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ddf25614c5494e3e2e964d3362a7e13","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98652b1ddb86551418439feb3a3cde66c8","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98413da6f3e8f068df77eefa2354cc1260","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b18d54474e5f2acd3f7e93b85008ecaa","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ff9bc5085e73af685f00aee655d65cbd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ec4569066db3377ae3957a0893989f6a","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e981911c6f4ab7da1f59fb48cce8d267a76","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/connectivity_plus.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e982050d0252ea66aa742807a457832653a","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986076caee38a9da764b3dfa1e4e1a5d41","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e986c1079ccb9ad2a0dd836bbde26dfebf0","path":"connectivity_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6ddb885fad9a4542a6329a470fa2fe4","path":"connectivity_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98e592f2c2e44ef3deac3cd02784cc784d","path":"connectivity_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d207fb51488c7ea7ac1275e911a94150","path":"connectivity_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9898468587a6cab520cdcb8933d0b368ca","path":"connectivity_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9818c5888400978b2fa22d952059454061","path":"connectivity_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98d82fe1b7b4db7bc85ab18441f1e2c0ce","path":"connectivity_plus.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984b570fd876e00e3c1622ee7a13633dbe","name":"Support Files","path":"../../../../Pods/Target Support Files/connectivity_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a5f07478f75bc3b752964b6b533c114d","name":"connectivity_plus","path":"../.symlinks/plugins/connectivity_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba19832f3a1fec7b3b56e3071bf4c9f7","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/Classes/FPPDeviceInfoPlusPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98589f97f59a6ebf70206fa946d33c6a98","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/Classes/FPPDeviceInfoPlusPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9830307b7eb9685258ae248d413d28a51f","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b1c115a5b698e82de643427263904f6e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b72e875a2313b2fd273ee6413267e0b7","name":"device_info_plus","path":"device_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a016968d1537de5fee216f39b9b13a4b","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984163ac08562702d9111e13bc58eadee3","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863be3299f9535e1f37edeca566c56956","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ec71f83543e78b02bb8878f2bdaa1770","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983b57caee95bb0defd7fbea09e4630e95","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9866f39d80aa059eb566807578dd56d3e0","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d5774272732a1c387c19dcb20953a259","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9884b715eaf409b020791c09bd1a45ac40","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989064cf80f6cbf753a3f2f76769676869","name":"..","path":"..","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98b80404716ec61d84484617356e734544","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9853d1bd10368491358c120edf0879a1c1","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cd41f321f927f1501da3ce4c3e1705d3","name":"device_info_plus","path":"device_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c8a696a7575bdfd695b50552bda53e63","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986fad17d5ebb6a1b1e363579811099dac","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9896b7c1d7f56d6b335d559dde146c1187","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7caa4a8157f551fd5157e38236212ff","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eb0be9618fdb3c31fc76e6c391bb5a54","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d79e90f30e8071ea0755a235e7bbe47a","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f7e502581e9e563f0e716961fe919778","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ceb019bbffa37850d4328a3b5e80e961","name":"dev","path":"../dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d1e6ecf8a8f5e7866d4a3bf6a028da56","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803607fd62a02c7946a1d3477b61e1422","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984cfdc50cf761e86a346d06e7bbf2d3a1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e53458045fdd4d428512566882c92e7e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a82b79305f187eb48310a393f4bfe813","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e987f003eaf8659e7fff511891b3f2afc0d","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/device_info_plus.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e983862a4e9dba3759d8615b149cba8a0dc","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e982a7f5deb6a64ae5e5a8c21df8be555e2","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9800ef6f27822088506da77df111adaf81","path":"device_info_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d3729b6804108439237d0ad0b05ddce2","path":"device_info_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e984decbb29f8ccfc95e475df5348ee3959","path":"device_info_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98051abdaf3129a419e9bb3b8c7a83e64b","path":"device_info_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e913211adfbc9d70bfc43b4439f4332c","path":"device_info_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e984bb4748a26339d307020f1110f05e895","path":"device_info_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e981f4d52f92d8b0f360ee8bef71882f85a","path":"device_info_plus.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f354426d7c8f682caf04a755240d2fde","path":"ResourceBundle-device_info_plus_privacy-device_info_plus-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98623cfe9c4e66d55a221902e7792e5d43","name":"Support Files","path":"../../../../Pods/Target Support Files/device_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ba853655f8a54c79510e0b66012fac89","name":"device_info_plus","path":"../.symlinks/plugins/device_info_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e3b3f804d927f2bf6183213a182625c0","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileInfo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e16f08255892d0f684af6ee4b4380824","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileInfo.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f56ed42bf21b1a52ac8fcb9a82336a65","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FilePickerPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988e93662f755da0279387c09ed20fcb67","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FilePickerPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982542f63e48192694c2301b53dd8da392","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987347d2eccffbba5b92a6737ac7c7f11f","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileUtils.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984b1a288da1e6fe981b46f8315944e21d","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/ImageUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e04840a4cc59ee085ade0b92a852884e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/ImageUtils.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98ed38e9ec1e8cd7f7427a8e3752fa5961","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e9830eb81718ca8a4637d18322144cb97c2","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e980cdda343b1e3945e28451531e9c1a605","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9845180a5dc5686acdc4e9429fa972713e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9836c9feed71b513f9919701e702f5ef43","name":"file_picker","path":"file_picker","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5fd1b4108172e277e33c8a87d3fcd70","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880d01b963c8ef608a0ad04583a5fc312","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9805619a0f02043e6fdb7a2be76189cfff","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9849b67c3d88226e7c72c75fa15e3bbdc0","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e1d9d867fa533872b45a7339a773d9b3","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b0187227de80dc084fabcf302004870b","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9825d0e9fb8adf9bdf05bf7db5c5fc456d","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9800d40be9558dad2dacf5f93047196827","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98abd6b198ade79f4872ee96e030a87e36","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9862b34fd8ad582ae69aae1fce3d396520","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ffed80c94d5fea9d76f4fe35d4478984","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9852117d814a38fe3eba010475bafd632a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e72e01936d9a14e0e9b869c88ad45424","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9875e49cbfeae37a120af486d9eb2d93c5","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98e67d477f82d32d5c7c9d30d2e81d066f","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/file_picker.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e9808d1f81b0d4082e0417db2f18a2acfb6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98cbd123e02025168eb151b7d71e556d22","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98b0953817803e455c620122efd668eb5f","path":"file_picker.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9837a1507700518fa7f49a60aac4b7767c","path":"file_picker-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98d8e8a876a8ca0ce915464fbc27689c44","path":"file_picker-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98273092b724e2281270954671470d86c2","path":"file_picker-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98330b1e95388e708f5ad38ed7ce90e28c","path":"file_picker-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98893e38e9593d6a06c8522268bf23390a","path":"file_picker.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e981a76198b195c337f9587084c94a43437","path":"file_picker.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e982406cc43be2014b06d264ea164e97c61","path":"ResourceBundle-file_picker_ios_privacy-file_picker-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a98dabe6e24ef65809527fcab892dd86","name":"Support Files","path":"../../../../Pods/Target Support Files/file_picker","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f62c63ccec2525926d94e2db6b738061","name":"file_picker","path":"../.symlinks/plugins/file_picker/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982640e5da9d4da0b1c1284d679e8bfff4","path":"../../../../../../packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982c61e60ddf03cc04542492a7725f11f3","path":"../../../../../../packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a2f3125bf6a20381c479cb67f97aa968","path":"../../../../../../packages/flowy_infra_ui/ios/Classes/SwiftFlowyInfraUIPlugin.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e984ba1576a81594fe8f17aea9519f8b1fd","path":"../../../../../../../packages/flowy_infra_ui/ios/Classes/Event/KeyboardEventHandler.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5424bf8b4f526e22486e2a27f3268a5","name":"Event","path":"Event","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988d8845aeb0c1ed01cfe07a5f22cc0ec9","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803f83e7a1d900c14ec7e6905f0092826","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bf611f0f52fc18d7226f7e7f09c38245","name":"flowy_infra_ui","path":"flowy_infra_ui","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981fb46775118dd702b7316a553c95e90e","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98616f2715911b1a74d97ddaada4344fbc","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984cd1c794c9b9ad19f2e4f0ca3f026819","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b37b705e2925a02d2062aaafe5099089","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982add91273b39db2a9cf969fe86dca06a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983b3dd8aad5aabbc2496c663812f03cfd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d656d9598f5d14273b8b8981109e8b9e","name":"..","path":"../../../../../packages/flowy_infra_ui/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e989d6db7e7b482c3241d9a1acce4813ee8","path":"../../../../../packages/flowy_infra_ui/ios/flowy_infra_ui.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e989fd87faba1a0f13d4d7494191f208760","path":"../../../../../packages/flowy_infra_ui/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98145bf30753fca245e5d38777385d9388","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9893b48c5d4d9ce2656928a0ee3ff7944f","path":"flowy_infra_ui.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985398a052a839596d179b48db347a52c5","path":"flowy_infra_ui-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98c1829b249c76d5ac78b6563b1ee7fde3","path":"flowy_infra_ui-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985b5e2f476270884826aa113914031988","path":"flowy_infra_ui-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983e584fb8f2aede8db256844ee817b410","path":"flowy_infra_ui-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98892689e861aa6009551af6801e83c338","path":"flowy_infra_ui.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98f6af4fb94ad21206a16cbaf1e6453010","path":"flowy_infra_ui.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e982cc4e6d82b8278ce278988c26691765e","name":"Support Files","path":"../../../../Pods/Target Support Files/flowy_infra_ui","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f43591bd9ec58ad67ffe4453fafc4a1a","name":"flowy_infra_ui","path":"../.symlinks/plugins/flowy_infra_ui/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98fb18cc16183813fcd8641d3cadfad33b","path":"Flutter.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d37e3d81bea52e1b4801db4886715c89","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9812cdcc535f32a8a76cc3d8e883d2013f","path":"Flutter.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98048531a74a78f7613c93ba91f15c7ef8","path":"Flutter.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98152d9dfbdddbef1340635c08e31152c6","name":"Support Files","path":"../Pods/Target Support Files/Flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988b26d3d01bc6aaf3315f6d51c851186d","name":"Flutter","path":"../Flutter","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b785d8b1f51c643229e4fdd18985825e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/Classes/FluttertoastPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9877147b007839a2216fd57593e0f3ba5f","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/Classes/FluttertoastPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987efa3ca5a1d26929ed3b109a26806afa","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98792f001acf47168aaffa10afd959b5c4","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9812c06060cb56dec48c82c3b93bd2fdf2","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a85c4c34db915b09efc04ad74e71906","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98879342f1ae53be318ccc851c8fb5f741","name":"fluttertoast","path":"fluttertoast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986e74552a0b437b7d398a0e712dfea078","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cd6a60c3b14da76ce2370d2a8be6b094","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e29b7bd0bc91b1bd5e1a2b480473e1d7","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984dd71cdc0b7c0e396503163601d414ef","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9892ba1e9027f479ab7d1c9354ba3a2aee","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d4cad64b7e33d505b2ecf5d631c9d3e7","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987932e250d513fa720967e0dd955cb5c2","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9869aa178a8c06888c2f7f224a2918a492","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98316da1c1d27426250efb3febcdf6e95a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a6847ce3a370c3b3d860bf1b58a643e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9898b42eaa404b9ed45fdaf7f308bef4aa","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b9d4812f1ec13b817bb1728f24b63ef3","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9850c5079f1f3a336ba56135604b1311cd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ef2416841219f34ad24eb0617d29faa","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e982ae0f7d0422d2f75f6cdde13de9e63ab","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/fluttertoast.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e983d3e3470809b19b23bd82e0a81446281","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d1afec4a601b8594f84cb4f864f71af4","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e983d17b3300e70f7d2c18f92e0d276131b","path":"fluttertoast.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981f77f9386b6ff408a5830dc5ec967554","path":"fluttertoast-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f000b535e938ba4dfcadc2d08b7f9dcb","path":"fluttertoast-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980af04a50d29871944a910af21b08eb54","path":"fluttertoast-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d7bc0cf610c976df935aac7605febe19","path":"fluttertoast-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e984e7d38849f0179ae896bfe33295f4bcb","path":"fluttertoast.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98bfeefd631cf811ef5ce08bdcc7b5e371","path":"fluttertoast.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98740356b6d05058396c4af19281498c88","path":"ResourceBundle-fluttertoast_privacy-fluttertoast-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98781d0486e386be35e6ed9b7fe0e393a9","name":"Support Files","path":"../../../../Pods/Target Support Files/fluttertoast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988aa84318e78c044eca9d3adcb2083544","name":"fluttertoast","path":"../.symlinks/plugins/fluttertoast/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98d61d652dffc4f8948dc5e51433b55422","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984a37fbaf4a9886ee0471de51a32ce11c","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98593629a4be85977669ea4c059a1d10a3","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f1b4186d564806fb6d451c19a178c28c","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d5d0ee78f468d5980cfa1ea4ad6dc829","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985041391a222e0b4847d8b2a02427be61","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9875530726d790ea59a3b2315529b92cc3","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989ea0a989d0c2a93f9b189cf1469cea87","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98391dbed2b1e45fe5718c27685e1d7c67","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986721fa987b7c82532939c240860d8345","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fb63c7bf2a24aed477a61c0970de0960","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b2bbb2e48ebc4b126f757c2703266204","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9817ce3811d81e530356a343c80efe8ad4","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9849f3c66c3da79f5b15d867daabb3187b","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d3fa3916bdb222eafea4d7820d6eb9e8","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cd7d949543e498a3b27db6a1893718cd","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e7820fbe6a5f106704f492a88059c536","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerImageUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982369589aa7940b6e167faf588a3802a0","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerMetaDataUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983f29585ff52e44e910a275777a9f20c5","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPhotoAssetUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9859a809cba5c8a38df54a2381d8b2365c","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98de28739697251ea419e62df80ffef732","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTPHPickerSaveImageToPathOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98919c321c361ef3f5f9d9f7385fcfcbc8","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/messages.g.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9850445506038bd7f616867a4a123a3ac1","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios-umbrella.h","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9883e47689f14ae5fdf581d0fab5f920e0","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerImageUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9831e620cfa600d0005b2000f1d54a69f4","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerMetaDataUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987f45fcb71f8e934254d2b14ac634b539","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPhotoAssetUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fe75cfd0b755ea439e9f1406676fbfcb","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d0c46c12ac4b3b637f4201b418d7f26f","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPlugin_Test.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9808b4daa14bfdb0a669f7e77d74ae3f17","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTPHPickerSaveImageToPathOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985d17117714f9b0c4ea80392bb9bccf16","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/messages.g.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e982e942c8a9d3701363338a82b251071ac","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98502dd1df3bee9584d607bb1b1a902b26","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982056603c1983c7c0b15b4f9d4e839e1e","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9892285f318b670b27f7fbf2287b291843","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986a27670c0d2f4e5b9f45ade587b13d2c","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987b8f4a03e01f25f540c609ad50dc4076","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d97a722bba91079df06c440102468bba","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98af8e2dbb1e8185946e7becdd47b97f9e","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989ae881df58521cd150fc286fffe35603","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803a91fc4ede7e5b0c2afbd5fab18fee9","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981a39b88b7383525d9d473bd93d1e2f0b","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9811f7fa0b42b0c8fb654345ec4ebb8e66","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98623219eb0a2c578df94d5883bc419325","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981125f09590385ffd9ea5a9fe5aa60ba5","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d31ae9e961eabf22319ec6a1fca4f113","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9817eb7c23f31f300a1c251c4305fda8c1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ec140a00b7550fc8d6fbc0aaceaa7a89","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bfbf2e646d2c92073ff2e83bebd9af2d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f2692e38d39bd674e71f6bb22b891e4a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9829706d777976177d0bf097f898b5b58f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985a01f741d7f1ce665f3e325d6c6a57f8","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98646c57f09cb01b0c762a0d93ca3f7b78","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98221a992b68c7cffb1a8eb5cbfbb297e9","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9853e18fbd58f9c37e8a3e2681a7b69feb","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios.podspec","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9804dde312afcdb12eedb33fc8bd45d59c","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/ImagePickerPlugin.modulemap","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e988990923df59cdef8e5b1e19216f2a97a","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98aa0c16b2297382848b598f9592b0c3f0","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98a37a01b5033e0b3fe7a1dbb5d490ef35","path":"image_picker_ios.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fd29ed6fd89520fddcfbd7124ff888d8","path":"image_picker_ios-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e983d86dee1d916fb29b63d5b1bfcc7b5f9","path":"image_picker_ios-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9803f06581ec423d44280cf7868eea5e93","path":"image_picker_ios-prefix.pch","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9862b8fc1ccbc32fefecbae61cf5888907","path":"image_picker_ios.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e985530ede90a4a8c2c7507499317499697","path":"image_picker_ios.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98bd2861c7f94ca0a221c93e59dabe2757","path":"ResourceBundle-image_picker_ios_privacy-image_picker_ios-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9859b1bb772d76fea02e84d0016d099c3a","name":"Support Files","path":"../../../../Pods/Target Support Files/image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9830cadcc7a1263e98e67a0cf722e5d0ab","name":"image_picker_ios","path":"../.symlinks/plugins/image_picker_ios/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987800ae9687eb588b086e319ea6177659","path":"../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/FLTIntegrationTestRunner.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98edd6a3512846802c34d22c5afb07300b","path":"../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/IntegrationTestIosTest.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980fa8dc3bbeb73b3d252e726a70557124","path":"../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/IntegrationTestPlugin.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980db9a591d712346350116e799f0e85b9","path":"../../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/include/FLTIntegrationTestRunner.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984a0d38d682beee02329dbf3bf514cce1","path":"../../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/include/IntegrationTestIosTest.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ae12dfa62d0f539c9a3638bb6061375d","path":"../../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/include/IntegrationTestPlugin.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9823e5a3226da96ed3914b1c8fcf559df9","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bde8b1c5794795e3b29ba817afae8d21","name":"integration_test","path":"integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7f37a3e9ff7a757003a42461c25c365","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ede6ae88c67694e327de9c425a7f6132","name":"integration_test","path":"integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987f156cd8efcf8b450a0acccba337affe","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989dcf9316567af671febf7c01cbe9f506","name":"integration_test","path":"integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987cf80e4fed23235d2406a799ca33eeca","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9847a104d2bb397b64bd133d45453210d2","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989392c96daffc1a0e321f346049c20e7e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980176812f8f0fbbedf15c13f3c2ee1f26","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985aa0f0e528439e82d21295d31624797f","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db8efb64140c28021c5072d8734e4699","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986ba3dab5252afe70f25f2838a01bdd4b","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b67e71ad7e85fc1ac5853ac3428427de","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985f23f974cdcdf9412df80483d5680940","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9873283974ea9ea8d983cfb681aeeee604","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db229454a7e5041b4830de422e60e499","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a2ced9ad917530dc3e50756255a1e21","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b9379d3571941dbae7d47d880a7a296","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985055bbeb70e29da915730a29ed24173f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988070745f3c2f414eef05f58b72da6f5c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863c8c730e09674801ad705a3ac5f0bb5","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989899d04f24c9ff95f1314d4fabeea094","name":"..","path":"../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e980c15437a062ac65671b286006afd1f2d","path":"../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d94ca862be564f152946859a75277d34","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98201d61b0d848a49c65589ceb7506e08d","path":"integration_test.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98af0a4e5755a22cb43543a81c374a2d51","path":"integration_test-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98a8522fb10db2dfa210c208f905afd9f3","path":"integration_test-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c0b24b129604eddd217c08a7d0c062db","path":"integration_test-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9896f6fa393191b60c6487f245272c94e3","path":"integration_test-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98105bcf1dafac8dc8d85eaff566c35643","path":"integration_test.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986a9f0cec960a86a1eea7dbdb4c49fcc4","path":"integration_test.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981a27bfd3655d560ebee82c3ab7c104f3","name":"Support Files","path":"../../../../Pods/Target Support Files/integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98051dc09e6cf1b3ea30ba9f39d11035d5","name":"integration_test","path":"../.symlinks/plugins/integration_test/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8ee44916529381498405522e751f8b0","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios/Classes/EngineContextPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984c462c42948edabe677ebac748da5d96","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios/Classes/EngineContextPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fc9b7f6e025c71f553fe6444717223ed","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db377227d8253ffe98324018634bc2ba","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986f2a88ed98a92034ce863ada1219ab5f","name":"irondash_engine_context","path":"irondash_engine_context","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98da61f0e74006e8fbd784bf4a3b970e5f","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98635b7210c5b62e20e037994cad1bf111","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c64150b3d0970cb99fece561f5a8799c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d3fdbea6d8cb166625df79788fcad443","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cbeb45be878891b685575f6b25a8528b","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9847ce44e75f1621ef4e0a6a5ae85f248d","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b2ae8d52765d0e5d9bea0d19cd7aebc9","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c84ed3eae59cea51b25d355ea7faec4c","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9899ac6782e67161e5aecf52fb2c668a4c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98710388c0277b6c02dc2bfce47465f2f8","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c5e6f44e93118427f696de099ce587e1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c97afc961fd21222514666e42cbbfd8f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984e9cef67c7f53259a28a5a877e4a2131","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985b651d4fcaab066c39a7ebe7cff5daa9","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98b58b99914223d1b29b5e344e157a1987","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios/irondash_engine_context.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e9830fe1bed9eaa6250033f52ceaadf7d59","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986fa867be18f4a0a3be7766d74146209f","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98425d4148b72c406e4fd6672ecc362707","path":"irondash_engine_context.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d84855c0549724120cd6c3172b83cfa2","path":"irondash_engine_context-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98de048098637f376810dd3bb08c83895c","path":"irondash_engine_context-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b78febae09cf0c8f6fc7540345bee7a3","path":"irondash_engine_context-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899f4dc54067ed61c6ad87cabdfa23817","path":"irondash_engine_context-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9859e866190126bed1c816e3db8cf733a3","path":"irondash_engine_context.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9837cfa75eec5f21f87364f26642dcde07","path":"irondash_engine_context.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9832c8cd0ed43920be52e135a0de0ef859","name":"Support Files","path":"../../../../Pods/Target Support Files/irondash_engine_context","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98abf194361c2fa0e4ef4dbec1a81fd4cf","name":"irondash_engine_context","path":"../.symlinks/plugins/irondash_engine_context/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e987c73d9371bc96dd532ba087fb5fe818f","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/ios/Classes/KeyboardHeightPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98bc635b84f7af43f9a693a5e68a4759e0","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f0eadcb14d473ce9c1a7c484bf311b2e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cf80142a4ef1966c0c5c5b502225cf5a","name":"keyboard_height_plugin","path":"keyboard_height_plugin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989d420609dd708f812028e4f49f0e9d67","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9895cace1167e8f8a1209977ad38dfccfb","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98422a86793790ea0d327a52c60169ac60","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9830a1c8fc44b202d4661674a7fad9873c","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e85d16fef38a2779c13639b43f1e3726","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9842be4aab321e08d3762331c112720d13","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d8f5a70339a345d74b94ca7b959328bb","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986d3eca9fadfaabd2c455a69807986068","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d98f50f9676e73dee28b8bb61da9f8ff","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9807172992c96c01947c7f63e7875b1880","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982737307bd1600ca8956d8f22af636541","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98854bc6907e9bd1273078ea0de43aa17e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d53a1d0636736242ed1151c333b5fc9f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980c1dd450aea5e1194ba1fff003416312","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e980bfe355de52abf2bb6aedb754200dccc","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/ios/keyboard_height_plugin.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e985cbc6607d34630da0312e81033a0eac3","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fe47c46f6c2454354c85a993a081845d","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98619ee239792a4f0a0de0a4a0b0a159ed","path":"keyboard_height_plugin.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a419ee90c48f680450b1d3ead4ab82d1","path":"keyboard_height_plugin-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98b22dd52696b4067993e38f2551b63980","path":"keyboard_height_plugin-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ac24e13278278e052df0627e6f2cfe76","path":"keyboard_height_plugin-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988d785c658ed39de0a4c38be45dfbc1bb","path":"keyboard_height_plugin-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98b6b8fa71d08d5539059bc6a5868a679d","path":"keyboard_height_plugin.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e985de3091a8d3281d32c2891e218876b88","path":"keyboard_height_plugin.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98b6f518b56ca945c4e48ea2f80f2dde0e","name":"Support Files","path":"../../../../Pods/Target Support Files/keyboard_height_plugin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98de11b38cb40d0a466e962277af5c13cc","name":"keyboard_height_plugin","path":"../.symlinks/plugins/keyboard_height_plugin/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fa9794d4853209dd9b9dc8e446307a60","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios/Classes/OpenFilePlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fda7caf052d83850aa64439013c284eb","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios/Classes/OpenFilePlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987c5f0895df45e85ab2470a8073a4c8f0","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98edc66a76af5791867252ee9a4fb21910","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9895e2c6870f605df8c86b47a953028581","name":"open_filex","path":"open_filex","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980fa51cd5f979e99f137ab48d9cfe538c","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989c915eac28a038451586120b5e42a4dd","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9844dffd7af5aba7efc4912e98f2c99fa8","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982963e1dbef544728f2fbcde398f7ce6b","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9820c60640726eda7bd6b2af35bd76df61","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bb93350f484f56f7d755b99459ba03a1","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5b26b9b30e5f21dd0e61b63275d1293","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986605830136cc6fec7889ae13c84cbe94","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b903bd75146f71f5101abe9a847888e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9835c6d6f45f08bbdd25355fae03109fc9","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989b22dd54825d4836f9aeb5366f4d1942","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fc1763d383faa8bc59b0d7f9aeef3f0a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bc4e9c0a74a0677415efe8c07067e305","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9898e553f7a823f6dc70a807a2f2d230a6","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98ee0863552468cb4b187a007dc6d9a9b2","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9852adb17f9ae796569014283b63994f43","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios/open_filex.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9881839ee4b95080a8084a269cdb03d9cf","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e985fad692e094e1d0680167d0e9b810fb5","path":"open_filex.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9840ba9a41d5060da79af9271d16ff75b4","path":"open_filex-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98481c5fe7d76a4040e0ad917154c7e4d5","path":"open_filex-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983eb20923999fb0272a7d7981812ac419","path":"open_filex-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98045ac1ed0e248a91c7e040ef0eb573ac","path":"open_filex-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98d756e4a333f24f739d0261675ee1a4ba","path":"open_filex.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e989220364d0a7adb8651f9b9241f5c7291","path":"open_filex.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984048ca38c41c417f8623767606347b2e","name":"Support Files","path":"../../../../Pods/Target Support Files/open_filex","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d744b5aadcc662d9087e0ff8ecfa7db1","name":"open_filex","path":"../.symlinks/plugins/open_filex/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981d03ed1c16bd8f4f97657768978ceefc","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m","sourceTree":"","type":"file"},{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e989c46f3ac5eacaf84d06c89a1618d018a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources/package_info_plus/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b8b9ba080e191e0b180722bf92e5f6d0","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources/package_info_plus/include/package_info_plus/FPPPackageInfoPlusPlugin.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98434c664522ec8a949437da93121dc4ea","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98da4508512de7ed1cf62ba23f6e0a1782","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987977607c1ffbf525bd13f89c1d4d9af6","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a38ddce212e7e062f995ed0c81943891","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98931f5d8098ad1aa7c93eec26372ba236","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98285660b922695a353a0d34c11f224681","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9850cc5da0429ea6a6d2b70a2656fcab30","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ddc93fc8ade4d1b45b41d4e43f4567c","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98265c9ab7615abe783c697b6cd4393241","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d03678f78b9690ff2b539e1f76337f73","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ad2375b9e2190a33ef70bfb739550e5","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7ffa0b49f80dc8ef09e80c7b9ea10d9","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9870b15a53dd8bece6cba98fe4fe76a795","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c4af5d8efdfb0599410662c8d5fbef98","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9875f8bf110ce0646b5fb64007eacf9418","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c89cf11d4dd01c776076b9aba66f6de9","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9856bac1d729bbf1ed2a72d1315cebd361","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9891ba0e8cfe4277c3831c309656922ca3","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980531ebbd14b9b1db9cb595aee441e728","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989afbfb7501eabf645dfdd3b2d5371318","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987d5f78597ba1b1ca57b94d699cac9587","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e0fd6aabdc57252bbd2b8424bc907ff7","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9890c7045574c1805ae1374327a927bd10","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e987939da84758bc78ea493a94d1cea8578","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e981e6125d7e62187e31adc58e8a2ab9ea5","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a53ea27f341ed23389e7d2e47e722d47","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989676689969e25b7e2b922fdcc545264e","path":"package_info_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985313e6a90d7b731193c46689110da675","path":"package_info_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98ecaad6d841c680a103b7c06250152c10","path":"package_info_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987e968239394f15ed507902e17a5a5d3b","path":"package_info_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983b443c6a2f8b2d550ac2cdd9baa30e9c","path":"package_info_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9833b0ea6cf67df0b17b6edee85d07e536","path":"package_info_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98fe3643fe82f3241c56cd3e148f9bfcfb","path":"package_info_plus.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98e9bc7cd75e23035e788a0e7c25bf5266","path":"ResourceBundle-package_info_plus_privacy-package_info_plus-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98597a17b9183cd7dd4bf1dc3afc5c61ea","name":"Support Files","path":"../../../../Pods/Target Support Files/package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98aacab2d91d3b0af7ec103ba0eb959971","name":"package_info_plus","path":"../.symlinks/plugins/package_info_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98fa855961429fcf8f989cbc521f984b6b","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources/path_provider_foundation/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98b4c976140e1e1950199f10d209e74f5b","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eacafab16506b203843ef40c684430b1","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98afc251afea8ca3c7c50e9df03700de10","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e09b0557ec981a89a5d3cdcc6842f0c7","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98848154feecc038860b30933f28fcd571","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e4c4edafb8828d8853578114f39e78ab","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988219086c27550c56b47393a9c0e39e4c","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e704029916b2a9af5f095363d987f0fe","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98357eba85aa36d189cd46406d27f5211c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987a6b9a2268f0d1806de5375f49c7ad34","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f47342e61ef54b290a2f053ba08d9dca","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d0b8c28e93e04c0badf7323d031eae30","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9837afaa61f9ce1c7f4fd458c2cf5adf98","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ca9305ea66f0600f4817de50cde8db74","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9808e63fd1bbc402ab215a50d1d16dcd32","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989546965c4112992540058bf89b75515b","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources/path_provider_foundation/messages.g.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985086f0886ccfd52bf38850cbdf060b2f","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources/path_provider_foundation/PathProviderPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f3577bef4fc38afcab726db64a001531","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982b0a750596bd71fbc8cfcf1a9c72ca42","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c1402de7d04e960dca8c1c9a29277190","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b0db58f72b87ea1465b9bbf9fa254cd1","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d11257d51b57c6c8f8403335be4b346e","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b2fe29e812c42cd3759a2d5cda315ac9","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9866a118eb431e0fc65900b0d1ed264fc9","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c5612f9fa3a0fea2a9cb1e98a04ed834","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ba29a7b05ab240400ab764ea3778d63","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e6d60acafabcaba2c92afe3f89dccc00","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98957bcf5769cd6c1ddb7b1bacb4045c62","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9857a303219d5ef02905623f5642388e38","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d0517ff6d03f19c2b5d8f5ae77998f57","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c6054740313f5e60a651b62a3a9fddff","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bb43e5bcfafff3075b881ff2a9506820","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98952ef0fb2ed4fabd37dfc72628c7e3ba","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c173f0cea5f6ab214f5630fe12869f9b","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b3e481ae0756598ee53e01ba3ade5dfc","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9815a65875b9784344b15a908a00046200","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9827c3714a1d7d43b116f6f2b348cb54b5","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98600a8661b70b3bcc185abb7a4e9008f4","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98a8b04a5824c42b6f4f609567865faee6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e981bdde8f32acfbdc05bfd1e4b808852a6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d9cf578a3f3bd7e82b6d5e62241a3287","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9861842fd411d053b57aced9948ca56491","path":"path_provider_foundation.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cc5a067b653131ca99fde3ba1debf062","path":"path_provider_foundation-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e989dd8857730e4c3db203f2e8d33299b7f","path":"path_provider_foundation-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ba8eca3e056e4a5b65fa675f2756611","path":"path_provider_foundation-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a687ca6ea8584ff3e8089580a0210540","path":"path_provider_foundation-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e989a3ec1c60f383374577efaaaba58a590","path":"path_provider_foundation.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98c312fad1a73dfd52c70c16e3d25e37e6","path":"path_provider_foundation.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98e9d12e6cfc445534a2c16d64aa703cc3","path":"ResourceBundle-path_provider_foundation_privacy-path_provider_foundation-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f3324a2357137a370c65dc36aa758feb","name":"Support Files","path":"../../../../Pods/Target Support Files/path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986a2b2d2d35d31671cf8d3a967a7db258","name":"path_provider_foundation","path":"../.symlinks/plugins/path_provider_foundation/darwin","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e318c8a8d2c9dc308e66d48a374d44d9","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionHandlerEnums.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d3c436acf3f4f4e33e39114153074d49","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionHandlerPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987887890697cf58754f65bdc3473cc551","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionHandlerPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a74eea3ef4e2a60a3951ba0e47037853","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f80ba72d7ab8de1bbe80c04c71d79ce6","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionManager.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988f16b65cffacfc53fcc954ce586fc713","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AppTrackingTransparencyPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c853197e3241e98fb93603a21ed56089","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AppTrackingTransparencyPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ce0ef02ade9d0a1304458c836d16e93f","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AssistantPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98688a017d26dc611c7c96d1741b382970","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AssistantPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985e535aca2400f5f683c5e24da33fa945","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AudioVideoPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9822f423a57f83424ebc6bfefdee8b3991","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AudioVideoPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98393b75ed0ceeda793874088622fd76fd","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BackgroundRefreshStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98df055a47a4e10ed4075cbf80ce6ec767","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BackgroundRefreshStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98df5b697e834c60412ac60dfaf5b92739","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BluetoothPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b97a3c6c28463a93a507d1b992be86ea","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BluetoothPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981d3465f51e1a6151315cb6853ef86eb1","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/ContactPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b85c4c9dd82c2d504573d0304be09cbe","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/ContactPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f6cc08933266f6af7683b3abe5dc3ace","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/CriticalAlertsPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e1614978d545aec846c24669228cfa1c","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/CriticalAlertsPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988634e5ad5b489cfb8621854418e2b38e","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/EventPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980b79f1643454a89c62ad82f48b83100d","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/EventPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98874ffa55cffa9b07dd0dc545368418e8","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/LocationPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d0f2795f5c06163cd56c66c1973fa5e6","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/LocationPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9820710d8f569064b9b4711ed685cbb429","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/MediaLibraryPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98922dbc3046dcd8b009793d4a2382c211","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/MediaLibraryPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985b7826319f99ae1623d37b8cf2f4eba5","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/NotificationPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9805e45e592399c0af6ed4e7318ba5d2c2","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/NotificationPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a0d58fb5a1923e677273beb359f58ab9","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b1b544d0a39e91ce76da89768ba82cb7","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhonePermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985eb1cc6a74764d8f7285312afe97653b","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhonePermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984e680efc7f3195ff924eec54756ed68e","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhotoPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6c7b999fdb1f6499108be37ae374b63","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhotoPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987f8fe14bbd3ac1b56df04f30ce3befc7","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SensorPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98af1cc35431393d9bafe812d1459bbdf1","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SensorPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985c3d41a4498df42a0c6816c971e66b90","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SpeechPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f22ec1e8227f9be641e5842788c56cf3","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SpeechPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986e866dd7a325c4b0ac10a86655c960f4","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/StoragePermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988cf99d004f1e5c25193675ec100372ac","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/StoragePermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9812e94ec5545f5483160eaed2e319fd2c","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/UnknownPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984d86125607541fcdd295dcf2c90de152","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/UnknownPermissionStrategy.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98bb6db6682fe6132381afffdd0b60c949","name":"strategies","path":"strategies","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983970b54b6bc58e9c785411c2b48da98a","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/util/Codec.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9852af6ca8a2b5eb428a1585731297f789","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/util/Codec.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5b3b31059ef8de5598d516260c77291","name":"util","path":"util","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b21a21f20e31c9dffd8a301b181e7499","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98adf4c31a2e50275af41b8a579edc68e7","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e989ac6ab6d3c4d5bfd6ee635e5bfaf86a2","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986adbf3486d93b28d124272c05d31c535","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9873343d816dbbcd1b184b6d68e52b8f8b","name":"permission_handler_apple","path":"permission_handler_apple","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e29a61315e2ef4299defb08c834d8658","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9813dc0e9e99378f2e9402361650d4f3c8","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e01ef4d5c3bed4f78810b1761f0bfd2b","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982268db3e1a4139e97ecd2061e336e5bb","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985a0dfbbd565a647af8ec44544f29a21d","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98656f043d890499eb834029f3c937d260","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98de17afe181ca6321b521909130e6f662","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986adad874bc346fed5ccaed27e453b741","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ff2305908cc93c4a0725655e88bdc78","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9857ae62fcfbdc453c8d905748f5c3feee","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a7884dba25b1f761bcc93d0154f12971","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9828b593dc8b9a43bd8236b7d7a05a125d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9840d397c5e3d46385c36e81d47ddb4e1d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b5d7000c1c54eb9b83d1cdaabf7aa111","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98eedb8bb38abe4a753d3e5a4a50166ce0","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98bf01ad6eb9325fb5f924a7a939b9c3d5","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/permission_handler_apple.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981b1b5aec77e91493469b21f5697a5678","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9895519dd776dc6b6b6a0d5a99939759a6","path":"permission_handler_apple.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d3090640601ef537b4effff19f6956f9","path":"permission_handler_apple-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98693e4bbf87f0173ce83ad9dad9c4ec64","path":"permission_handler_apple-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98db4011adc2801eb0447df5d29a88d434","path":"permission_handler_apple-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9872ca1772cd655bc190d399786a965f06","path":"permission_handler_apple-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98f0f251835fe2b85ba8e20304af40d5bf","path":"permission_handler_apple.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98498a3b2ced2fb3f55d9af8975a87d769","path":"permission_handler_apple.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986ea3ec8648859708ff7049b01e77d7e6","path":"ResourceBundle-permission_handler_apple_privacy-permission_handler_apple-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981e5c39acd14afb0de574df88226e3506","name":"Support Files","path":"../../../../Pods/Target Support Files/permission_handler_apple","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98373a889c847b4c599507bdad83234fe7","name":"permission_handler_apple","path":"../.symlinks/plugins/permission_handler_apple/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9881b60c802c0c2009406bdc039b7f0068","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Classes/SaverGalleryPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98dcbc4e0c213bbd743d9703fa7e437dfc","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Classes/SaverGalleryPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98f7bbe1f859cede2224d42b729d4c355e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Classes/SwiftSaverGalleryPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5b49ba1d17a7857dc59f1175110cc13","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98c89b00fc416882d2d0d7c9b090fa7c9c","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9820ce2023299a06e421e4a78afc6b0b82","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9865ece6bf5cc7c6cce51ccbe39d149b83","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98541499aef6d248a8301980d2821058d4","name":"saver_gallery","path":"saver_gallery","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9822ec8e7cdcda0404b791382d92111f01","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9864d2f6413ca98b754c7ee8a5171ad1fa","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98230a7213b882ddae22c49a7606136e55","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985321a2bb5473af9a5a2bc5bc4f834a04","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dc31a6400e820a903a3adf3e0472b97d","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b76ca3af3e88ee3e30fcf7e21bc6d0e","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988541bb9a4c72550c30a45563b09a2e33","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e4d469fa76bd962f8bbbec14965af39f","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988ab99b3f5ad06e51078cf9e780a1d69a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98362dc58617351a4e100aac833e0a9775","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980dde8a02284f890277e1c2802444a83b","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863783b60069d614fac80a368d82f00e3","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989d4a0878fd359b487c9cea0fa7016714","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980cda4027ee69c106bb442ab08ef4d82b","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98187e4b7080377e470ceccd8012e8f22b","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98f1b23c2ea102d15d2d3364c334ab1150","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/saver_gallery.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f1988bfa026ce78ad365f9598fb48ccf","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e980dce72be9e98a0e7938fea67d5dec3f0","path":"ResourceBundle-saver_gallery-saver_gallery-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98f8441040a6b86dbd9daae56dbd15f227","path":"saver_gallery.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98bc00fdad72f5f3771416bb7da4f843ac","path":"saver_gallery-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98b3812fe968f19a56dff3b7b21717a930","path":"saver_gallery-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9851858f7e87dff1cd2d14bcfec05bf9e7","path":"saver_gallery-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fdb913e186672d6dd019c490a01906ce","path":"saver_gallery-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986375a6a5aae83e2825b2fa84412a7b38","path":"saver_gallery.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986cfaff9f48d79d9aac38b1a4200ab7b7","path":"saver_gallery.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9870bd2de048ce790265ba57145fada872","name":"Support Files","path":"../../../../Pods/Target Support Files/saver_gallery","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e9f8053722083a22097cca53b68ac915","name":"saver_gallery","path":"../.symlinks/plugins/saver_gallery/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98988b8f30525a0740a56a45f26ec9e5ee","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutter.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b3f62b7cd428533f32ccbe502d30579a","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutterPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9826cd8809b1744007cf2cdc49b9ee183e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutterPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9814197e8a8c3142283755e74bb1e1a542","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutterPluginApple.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98ed1bfbd13db96f1a5a9a509f62b23475","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988a2e2c7191f3342f69288dc5b6f24eac","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b57f5aa6fe19896cfd444c1d7e3374b","name":"sentry_flutter","path":"sentry_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e95123df781f7246754488867eecad12","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980c8dbba9160b89547d42fa282bc4d833","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98408185297f1419b37849ee4ef61dd14a","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db140395c21d264a6950ef09ad273630","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a317c3cc4f246a4d86dee21027be9f4b","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982c4e624eda92287d712d01e09bae857c","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988c938458c24c5a8a3de86b13a240257d","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9816b3e0cb5684faafbde3cd04104a8bf1","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982a1a4fb826ee2604d98a1124cda9655e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984814fee408387d1cb1ac9abf04007801","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98646b3421a4027d57df2335874d741e7a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d7d7eb47d8b13b56642d25993bad53b7","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9834206fc222aad06497489cefdfcadd01","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98472caaca9c22e04c9586a3fde6ae90f3","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e988942381687e6a83dfb7f7b51b7114eae","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98d7a35162a09b1bafe73fd4acf4dcd74e","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/sentry_flutter.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98c631af1f252c1a6185a574de8e401916","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98b2b1e766516fc1973912c9ed7d38fba5","path":"sentry_flutter.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b20710f0afc9a65fd11ac053ab5224c4","path":"sentry_flutter-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98fb88983eae4d4fb5041df897b5eb48b7","path":"sentry_flutter-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98140ec5a195fd3cd2e6672a247b3c50dc","path":"sentry_flutter-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980060be8e96d53611f0f35fc6b03e1144","path":"sentry_flutter-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98898ca6b789d6ebda968b7bf2c32c2927","path":"sentry_flutter.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98eb2509e05e4701c0e7caf39cb9f1a7ad","path":"sentry_flutter.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f6c519cd219bbe493b512e88f6691f63","name":"Support Files","path":"../../../../Pods/Target Support Files/sentry_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9890b2588f7d4f7005fbfcb636532e8266","name":"sentry_flutter","path":"../.symlinks/plugins/sentry_flutter/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fd63b0ee8c393f403cd165102963d89a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources/share_plus/FPPSharePlusPlugin.m","sourceTree":"","type":"file"},{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e988387a65c6b9b6cb60327ffbe6c59158a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources/share_plus/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cb436b045699de5c33251ecb6224be07","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources/share_plus/include/share_plus/FPPSharePlusPlugin.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e988aa5f3177cec22ad24112deb2bd5df88","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989b5ccde2486e01b9f408c52039e15189","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fb60a3fc698011e0520a294091444220","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c0adb9f898335bb561ebd6a92c8b5764","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987c61ae0ab682495e830ebfec4bb2d992","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fec752fcf63dc774ef6492dfb4088824","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98061408887ffc6320474b1b3b9e56e869","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981ca0473827b2355cd2af25d5d7d10627","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984fc5d20b622f4b201b585186291a8f67","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c1ceacb5959c41905f6d4367a79a62d4","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988d04b2310b73a2f1377c4bab4917fbb0","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9858dedb5230d6424239e49823b8eaf146","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a939846af6f55181341850124da10438","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987236a4c37717175b816032b6e16c35db","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eefdcc530f2e92f8f7f62696466d831b","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e999dc81b190f4772b868a75138aa75e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a1108d6d840a33f47b9c0fc3af3275d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988dae09aab63bdbe088b9c18b957886b4","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cdcb3d9a575e64374dc1a1db82409a54","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988352975c34e4d7ee8941ab48a3068611","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9864da257bade8dccae972a95edd8d4097","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981dc40745a0624f3b853ce39f5bd094cd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98253b8bf66bd8ccf4c977852ef0a7714b","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e981e6f67f07605eeaa639fc9e31c57db8a","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98851d3db594f8658c9786f56478fbb6a5","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98e42d81eea266b46708846d237e6b9514","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986288ba151af560df873b930937f73b07","path":"ResourceBundle-share_plus_privacy-share_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98f12cd395a2605f620e5facab18ef7617","path":"share_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982db5430b3897df3342086c41d0ab0cc2","path":"share_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98875a85a28fcc15b9fc0d81e86a96d256","path":"share_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e15cc77379f3a5d2d644bb59a33c227a","path":"share_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f3d67ef42ee4a797c3b450c5f684db3a","path":"share_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9874f3142bf7e3eb1d60d86ef7b8277153","path":"share_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986e07cf0736987a294d8f475ef4545d77","path":"share_plus.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a569546e20b60ea744093ab0ba2e1905","name":"Support Files","path":"../../../../Pods/Target Support Files/share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988982bbfb1ebde9b684d127cb40ed59ae","name":"share_plus","path":"../.symlinks/plugins/share_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98bbefd940fe64ae1bdba547246e327226","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources/shared_preferences_foundation/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e980424689f89b333dcef6b831656c09796","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d76e5d546741e7c1bff558bc1604fa29","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9833fd9e8a667e58c3eab9ad18ac331952","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a2f6b7afaf2afa6bd09f4e0f94068d4d","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fb40156ec424853fa943291f5b303d0f","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b1e3c36a262a1ed5ad3b185a0227eb67","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9831ab42991c30549603d3d8ca536224cf","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98508ae9e3aa5011f9d4942b2f89441d82","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985f334cf968485fb19584ceb692110236","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98138f3662de1a399453cb9534c664235e","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9826b71c8c69e21da06f07c82c6cf362d3","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9828e3c8782ecd686251f0f260cb28a2d7","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9806bae6027bc7a368204df9fc3614abdb","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5fd31bd7e3de1ebf8947d9a8ac4c2ca","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d049289db0016cf5f53369d79d91af15","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98c588eadd95143f0918dce747cd1a1e44","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources/shared_preferences_foundation/messages.g.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985fee1a88b88d89bbd0b0617cbca27ded","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources/shared_preferences_foundation/SharedPreferencesPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98939dcc4abff9273e20bbabe5b4fd6244","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98642d561628a9b022c32e461ac6978d8d","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c2f343ab24d82d39b3b8df75288becb2","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988e8c1d85da5ff6697e97a0b62ee47e2a","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984be81f35c0b61917ec47fbad62d32af3","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803adc645aa9991244006b1d25477fdab","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9830ff183ca4264558be2d7888f36dfe2d","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b46089f714cf1702ed485a5fc0b2746c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dd0a1e90245c099ea267ffdb9fe7727f","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9806173dfed9c5271631519792136dcb97","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9896acf7fc50fc7d46cf4fd58ba1a01a8e","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9877cd8eaf1b44ad950ab2c1de574d545e","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a4a438f84238468ae34a00396733c267","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9826990e339d1e6bfb92c43f2df4021253","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9839d6e5e860b74845e418690de949729d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980437a510b83f078e975ac6e022596220","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985a9c0f13a0e24494a94657ac6f06a09e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f45a8957e29b9d8d573538c7c2a6660e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989bc0f1da02a3a34375b7c9fb790aa6de","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9873814c430069fbcad3cc2b2068a2f2c2","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982a9fea2f1146b6aa8643c58308630eb8","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e987d0661ac6c8236d9bcf05223d8b5321c","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e988319841cde2f015576fce1144b7c103b","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98538b20e19bcb07bd154b1117ca2e7f62","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98bf7e5ed8e9ee51963dd29079b941aefc","path":"ResourceBundle-shared_preferences_foundation_privacy-shared_preferences_foundation-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98fe484d482b057065dd2bc365ef0b1cbd","path":"shared_preferences_foundation.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987cfa1a168d224a15e60944154b171acd","path":"shared_preferences_foundation-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e9882ef96f96deea78c69f4201bad0a0f36","path":"shared_preferences_foundation-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98321ebbc91006eb23c9f9908d066f0fb5","path":"shared_preferences_foundation-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988ce14ca997c1ee5ad8eed12f49b985ef","path":"shared_preferences_foundation-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9863033d3e663dd173908d510d04d9645f","path":"shared_preferences_foundation.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e983dff215e39548f9188a7b8c8d4c24c32","path":"shared_preferences_foundation.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9843a113f040e59beff6b2bad0d9500e2d","name":"Support Files","path":"../../../../Pods/Target Support Files/shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9815e66823fa48c84facba1c31c881a7f2","name":"shared_preferences_foundation","path":"../.symlinks/plugins/shared_preferences_foundation/darwin","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e989d8d7f21b58b6c6ea6cdbf54b4310500","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98ff6e5bd448ece42e55969cd7ee2dcd22","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986016d04cf905713b9d5b98ae9bb69fd9","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982743918f80430a5f6e3fb467f2d4e58a","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db5cfaa6497e017576049d2d766fe43d","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9887c4ce35bf05c88dedb678a8a05f7ddb","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fbfd6a91559f348dcd214ab1cffe04a8","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a41b93d2bbcfa5fa03eef8097eb619a2","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9835810861c4e0c37a8ee85af809b7fae6","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e622054adbb0796c1efd972eb720d1ed","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982499ce39bf8d8ed82a87b79418b6050f","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984cfa4ad175bfd7a9f0e02542ff45ab26","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986069180160f76ffe39b94ecee9268997","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98adeb13adbc566e980f9d252124dcea3f","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9820d6e1cf31e5551a3d68c121e504d8d1","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985f4b97fdfbc2f7c5f93d9c36aaac6a3a","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d7c1e03fcf1c567ec11990def452273d","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteCursor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9853d850559207c4384f4df37b5e85a414","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteCursor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aba81c10b979d23a5281707bb944aebd","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabase.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9868b319ba9a799ba0a2bc5271ebda13ef","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabase.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ca5fc8019efea2c0a2df87621e6bfd20","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseAdditions.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98eaab2d07055d05ca402daaf728b22722","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseAdditions.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f164259be96c4b6bb6608f7ce49cf3de","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseQueue.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985ad6eba05b61ecb94890b1ef3012502a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseQueue.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982b2273f60e8670b2f5db571354e1ea07","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDB.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98463be45d0e22ba2b4d01b4ebe090555a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinImport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9872cbb441a98d645c1ab53aa8198b5a91","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinResultSet.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980a984e700108dd111790049ae08ac1e0","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinResultSet.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984fee7f0226744b7cccabb95aa1a2a58d","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDatabase.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9813f225fcdcd4540ad3a526d2488cee7d","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDatabase.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987dca200d5c95cb57fe2d64a729f10e59","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteImport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9870809aee28a31c0ce2cfe6427a7a84ca","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a82a52d3958fd262c4d9754ada0841a1","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98174fc872150c14b5ae3ef8ed6f674bac","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqflitePlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fe7eed89bf2c775aa63c02aebf8e6b33","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqflitePlugin.m","sourceTree":"","type":"file"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c822bb9bb66315e186526fd41e667547","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/include/sqflite_darwin/SqfliteImportPublic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d179bdefff96b31664c33767a93f03d1","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/include/sqflite_darwin/SqflitePluginPublic.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9892e5814da7ab09018c72d77ff35a15cf","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a8b633bfa4915a0040bf5c00fcd27ba1","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7b37f7cc0762d9ca0708a155f22b8eb","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989eba8369512e5541562107b2d8b760a0","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e33f20f0d475d4d5413f81e2e96ca687","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987eef1cebbe3bb46644378bc4631fe02d","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ed219e0fa4be3681e4e695b0532eae32","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988972fd127184a48f0dc3e6aee302404e","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9877fca5587d318fba37442797f6f705b8","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983ae2fa1946bb36f5e91787fb46d589c5","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984f9f6b5fd902d714436e433e3f764dd9","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dd7c7199347a79274529e6303ba01ef7","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ca3a121f05226c04281b755cc833b917","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9893f425a7aea6b4482e64d0a5bc657629","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e49b35d1a40e4dde1b2357ef1cb8d669","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987e5e76ea8d51c8b8d79c16a34746f33e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9854e643717fa41262edb3c3f1d725d61a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ed6f9da6b7cdf2cb6886e30e6cdfbc8c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98de4bc8577c1a9bea6d84ede9bedadcbb","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987d9613248b511bc9325f7bcd3f3e4dd2","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988f60c32fce1cfc81f7648e0319f50cb0","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9897a924c3a55a4bbf49fa6473d66861c6","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c43b6316619d3de45f006103a22d7931","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e981d8665643615515f27809c8e1ce6963e","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/LICENSE","sourceTree":"","type":"file"},{"fileType":"net.daringfireball.markdown","guid":"bfdfe7dc352907fc980b868725387e9852fdd6f48c5deae81f51387413e1e002","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/README.md","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98856a830972b607e532cd4373e4bd4903","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fe65523c54c78b02ecd1fb77f3e46537","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f79a082bb47738f827607307a0e2452a","path":"ResourceBundle-sqflite_darwin_privacy-sqflite_darwin-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98c804c2a12db2f8031b9549edefec60e1","path":"sqflite_darwin.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987dd296579fd63723f2385fc3e309a485","path":"sqflite_darwin-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98dd8f3f1e4afb05338b44768a2eea883a","path":"sqflite_darwin-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9839943824b9f6edc4e040c5c8b2151f82","path":"sqflite_darwin-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9867c38360f7a8c57f89677b1b8dd0f63b","path":"sqflite_darwin-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e983c5c591d1e2590f943bcf841c7250c29","path":"sqflite_darwin.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ecc2b7f452e75bd86ca68cc787ffd0e6","path":"sqflite_darwin.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9863d40e6f29652cdc2796922cd0db2932","name":"Support Files","path":"../../../../Pods/Target Support Files/sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984e01895d459ef3578b9180bd22234ef2","name":"sqflite_darwin","path":"../.symlinks/plugins/sqflite_darwin/darwin","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ddf54a4994459d8d533e977b9d3701b4","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios/Classes/SuperNativeExtensionsPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98227f3bf9a2096f16c498e39e527dfd82","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios/Classes/SuperNativeExtensionsPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9812ef15f301a3279be9158ecc5e2ee578","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9821ac568b46c2e99c638c4468b42476d3","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98288b3abd52cc5524befa714a293f1d78","name":"super_native_extensions","path":"super_native_extensions","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9847c7f918c61997a2cee2375876483e92","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98038dcce1516b7703d28d4d5925b0e167","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e27492f09959f12010d60e1537d0abd4","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989b82d7b34f027e37120d28d733c9bc63","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984e2d0c4c003b18b59d642ab053cd2e85","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dac882f2bd6878262999058971045bb1","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98906dd99ca335afe3dd9a7c6cc05a2048","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9895327431cbeda356cd4093b0b03dde53","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b3779ad14c3af16a3d17c6cb4a8af745","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98413ff013f5792a21699c27aa303d4851","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98503a720642223ab224d97e9c5f9bbe9d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9823726b2431178718aa367a59ee6a520c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f023da7cab226ce319043a4e226d36dd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e550084af2ca7acaf14461ed1f151852","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e988cb8e2ab455bb20dc19f7a3a2246df30","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9896d5871a48fd625e7d723f390e6a98b6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios/super_native_extensions.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98eeb382127790a1793c33c9b12ba2e097","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98a18f45e0ed141c453cff3a3c4527ecbd","path":"super_native_extensions.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985c10bd2540461e46ce7f11028e018d75","path":"super_native_extensions-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98fb6de20b66e6b8dbd451f64bf906989c","path":"super_native_extensions-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fdcda392f627466e5edccd1508970413","path":"super_native_extensions-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c698b70fb576aa4fe26ace0b9b8afcdd","path":"super_native_extensions-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ecc61835f7006374d2df8ec0b73025d7","path":"super_native_extensions.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98960e413e1bdfa694f48dc871e7ef827a","path":"super_native_extensions.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f219566d0916d6a77685c33cd16653c8","name":"Support Files","path":"../../../../Pods/Target Support Files/super_native_extensions","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f569f35312eec5d67d042173116f99c8","name":"super_native_extensions","path":"../.symlinks/plugins/super_native_extensions/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e988380eb098928f2df260922e9d961ad9b","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98247df5322ea5db3a84214881c52ee25d","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9887c8fcbc6272382fe1137437a8cfd2aa","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ab2c88ca66fa6efb7003fcd0a5fecbd","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98aca3ef13270c3be3fdb24dff41e0219a","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988826612973646a4053217e2324d0644f","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980d552426f9b9a7580f9c3386dc024b81","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987cd73363ca2db27832a5adec82341972","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fefce717b106b030e9dc126dd095e251","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9899ed1fd19213f07006bd2b9430d7772b","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863db1d0c1fc2b855158b6fa51dde21fd","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988e2efb7aff436f31626c71ead15daeef","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c34f07f7b163887aa57af2d6c5172187","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f1d44c68f08f6c906df9811d7caf2ef3","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9832553b1b2fc5c507feabcb35c959e868","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984fb77cebbaaabf4e807dd6ebc12b73f9","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98490bb9f80fa5964176076d4f1e6394fa","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/Launcher.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98daf626000fc0d232b897ba1dda9c0f31","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/messages.g.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985162a45777def5ce41cdd4f46ff104a9","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/URLLauncherPlugin.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e983b3ce4248a360b7aac31c20aa78daf2e","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/URLLaunchSession.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987ea293b0366abc71ebb96acf17297ef7","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9888d93807e2dc1f667f941643f134e44c","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981798185d018f93fe0298b8aba5d63560","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981019612be8bea02a74431d38ce3b9cbd","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880eb5ac131bdc80edb26f033f8a72fce","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9851161dc629e1fb4d2d3fd8bd10ee4ca2","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989fc545117a2c4837792115a214e84a3e","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cae5b473355e5f3f56b441bc383d1a87","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98025d2d7ef7d681723bfdf777175c2ffd","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9834b9faca7fd3324a1c2164a4cf7e5313","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988303e4a8f1f82c7fb4ea2702bb5cb5dd","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984516cc538c760100f78348eb10326053","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98732e82463b828bce4818d57c1ea975ab","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880fbcdef07ddfda2f3153be29f969c0f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981e967b61253c26da24779f0e1571787a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983de9bb36b851937d715c96cb7d26dc7a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98611614762b8213684ec434869762ea22","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988cb0470925262a8de3be403c8ae31ff0","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9833562037aa4c75db5121f347c9e53091","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e70d73185437e27f145504e3dc9f1033","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ed5927b53a4d1ccd1f9684043eea4526","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98a5500bec173c13a85e52bd5fff791d8f","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9856b8b14381dcf0263199d3e9429dbbba","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98cceb9263cc3f6dd5b9212b964cfa7054","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98604688695bcf046b591ad88eaebd26fc","path":"ResourceBundle-url_launcher_ios_privacy-url_launcher_ios-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e987be471ca7fbb33b50a518eea5dcb87e8","path":"url_launcher_ios.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9871fa081d7f580f4936897eb46aae0878","path":"url_launcher_ios-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986f18d2a83f59c045caefbc68524cb3d3","path":"url_launcher_ios-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ad745689d7ee76ea50404b77fa12b6f0","path":"url_launcher_ios-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eec495e5daf9423acf950402315f3522","path":"url_launcher_ios-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98b3b3d080e11ff54026ba894393f62b3a","path":"url_launcher_ios.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e980bab71536c4bda286b310a48eedac214","path":"url_launcher_ios.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98938e093d7e3b952e819ce9ac1448e73b","name":"Support Files","path":"../../../../Pods/Target Support Files/url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9833186a359a29a050b940ca2dbdf952b9","name":"url_launcher_ios","path":"../.symlinks/plugins/url_launcher_ios/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98273c41ad57101f59fb22ee04a3de8ec9","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9877cdee8b856454ff8e4213d5b4b8e81e","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982e8fed289e7a93127c4e71b0a59be8c1","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989c9e91c05d6fcd60cecbf709fcfae9f8","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5bb761b833aa1c82ed6c79ade71f8d2","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f9d8e7c800ff72bd4f22b25217d72bd2","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c7188e90eaef7902fa09bbd3b6c34138","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980d69590ab194d9defd371868c962c163","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987e1eeba79f29d46cbd63b8f82bc2f113","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982e575334bd02bdf114450e05cddb31e6","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987403e6bff419acd09288ab9193a47d46","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983974551ef8940d0a77ce7e5dba345c15","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984bffff6dbf90752f811d1b628435611f","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980016f1720faf508de4c79265c93531f1","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985409f0ee94bca73500af103a1f36ec69","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98863ea06f124e08ce56a9dc6093f14a5e","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9898207ddf6fb048e55cbc82f1767304ad","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FLTWebViewFlutterPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c78e7356e2edc959fdb6dcf759d9ea32","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFDataConverters.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984555345bd3de9a732d0775aeaa250a8a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFGeneratedWebKitApis.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98dbff9619978e59ca5bd2bd9b78df76e8","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFHTTPCookieStoreHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983ed9e6db451958c4b973973bda4af232","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFInstanceManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9897e653c33b1ca27c845f24161d5df94b","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFNavigationDelegateHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d512e4282ea1aaea1f31ec22635e6a73","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFObjectHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9863ec51aa8f06ccdd336b2b840124b5bd","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFPreferencesHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9852d8f233325638788b9c883bef4c434e","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFScriptMessageHandlerHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9839386e23f7ec2e92ef671f9a65fe9467","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFScrollViewDelegateHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98554327c6feb943c123568d30776d18b5","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFScrollViewHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988abd8337fa8780181224b1c59e11080a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFUIDelegateHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98bbd5fcab06bd2b4eb52aea88b514bbf5","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFUIViewHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d8f2fa4ff0132b374c1eca3de40fe4ce","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLAuthenticationChallengeHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cdfda639596827302a7f54cea81404f7","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLCredentialHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982d6b13259c9952197ed43ec8ae880592","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984f11e46b828b92865af10c3b4c4c9d68","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLProtectionSpaceHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984b54bebd3894ad50c5db03675b718387","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFUserContentControllerHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e0160a9358f35f9d1c0b5a60a3d0f682","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebsiteDataStoreHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6eb829538253c8fd04f8ad89b11abf3","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebViewConfigurationHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a1bb5ceebb3ce6eafef856cbadc7f5d2","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebViewFlutterWKWebViewExternalAPI.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a83be649d9b3f6edd971f560f1bdc837","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebViewHostApi.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98dfb923094c09a8b2984e29f4ed12750a","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview-umbrella.h","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9854b0c1f62864b38122a6cd2adf415fc8","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FLTWebViewFlutterPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98dba23cc94d8fa0e09dc855fe286cf011","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFDataConverters.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba40413987e8fe9a668fab4d2f62e968","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFGeneratedWebKitApis.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b41a55f1ff6a49c7cb1bfc6754b0048a","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFHTTPCookieStoreHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aa71e1ea8eea7861996ce4eea6fa83c7","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFInstanceManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982f56450de8c5ce26b62883f42bd50aff","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFInstanceManager_Test.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bdc5d2d37bbc00acdd9d5e37557c61aa","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFNavigationDelegateHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9876af14ebe27607bf4d780619a0507e5a","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFObjectHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9812c7ccf1eea1c9c80d378418ff91f3ef","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFPreferencesHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ad06b47c00b9bddb782d0454f9545c23","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFScriptMessageHandlerHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989c04773599c925e49e8e9df79338c2cb","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFScrollViewDelegateHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982c3bc52c27128b5552e2f492ee8e9d38","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFScrollViewHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983638c2d3e148c4aff7f56dae69d5bd44","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFUIDelegateHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9852648a53c3238ae232bc1cf732368733","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFUIViewHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983ac2710f7f8419957a975656f4f11010","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLAuthenticationChallengeHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f29772b4f240416b82bfca4c7240cc52","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLCredentialHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98287942d628c319dfb58242b7a056e501","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984b2e4dc50da302b519989286271f986f","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLProtectionSpaceHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da68ab6d947fd76c2248fff2397f9474","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFUserContentControllerHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98db1f999a296f5d4b787f4f9b4b82b565","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebsiteDataStoreHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98635d8b53208d8a44fe1b27f33c27ad74","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebViewConfigurationHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98de08c86939fbb40b81087f28b271e5b5","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebViewFlutterWKWebViewExternalAPI.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98984ca0249b9df8b5209a6dcb1c869dfc","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebViewHostApi.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d786586788316e81a204e06a860d9b2d","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986d305bdf63057a110d9a04a946a6a0b3","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985af659c04f6cdf4a7c73cb8559d95a55","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e1e70587e6aba44c0bf6b4fa49bed627","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983b00ae6fdbc30a34dfacc53e5e678acb","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9865432e0f8cd5ef04acb4722323bfaef7","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981fe60fe58df7074f3f027dfb308c785b","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98299b41c4840e81d29fd9defe63f562c1","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a1a72cddbe31b7904461fb30eabe3f41","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9866cb0dd0dbb6d8ae7c42bf7e70385636","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982c9395928d2b28bc4832e7eaf6338061","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981cf382f136f655ee4ef53b31836e9976","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982695540da12cb3688e9cd89de55b4c12","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989cea8ab7b2bc9aafea94b071fcb36fe9","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987cb1234016d9a45a8e6ebfa24a1ca6b6","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981ba95ad9f21e528ba007f114552e8faa","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989c9004908f380362c00015c90fe116d5","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fe5984c239273e4752feee9c1cd0d417","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eb7ac77a35fceda15f40d2e6dfe455e1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98481664ecd25a98fbf478061e4214182c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984d34857cedf24e0ffba417d4ddb7a5a7","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f104ab4138053278b0c742e5da3a37b6","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986ba8636d82c00ef31adc8e2a02c78383","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98e4b951171365bdf1469e58e30bb095be","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/FlutterWebView.modulemap","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98f15216db275bd0a141b390a80247ad56","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98319f1ecfeb379cf849cfb0c9a15fbdf7","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d1afcef86fa9f9ff1253aefecce2db52","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e980dc040649dc9e2eab53c3da3c40c35b0","path":"ResourceBundle-webview_flutter_wkwebview_privacy-webview_flutter_wkwebview-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989d9a8732910c4132ba5e9ee6c49a6fd0","path":"webview_flutter_wkwebview.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985debbc2cab2d792fa92242781697aa86","path":"webview_flutter_wkwebview-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f10ce8a5ece3a15020d640ce3ed6466e","path":"webview_flutter_wkwebview-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9818b0186c11c004e985a36edb09183861","path":"webview_flutter_wkwebview-prefix.pch","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98e3c8f3c679654f0d8322c0bfe86cb48c","path":"webview_flutter_wkwebview.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98de306c3f6c2c14f312be69bcd973767d","path":"webview_flutter_wkwebview.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fb0231873e76e3fdd25c08c4f7142e58","name":"Support Files","path":"../../../../Pods/Target Support Files/webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98daffe5a2e168f85d5660f57f2cc4f3d1","name":"webview_flutter_wkwebview","path":"../.symlinks/plugins/webview_flutter_wkwebview/darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880465010b585dd5bc5b7af0a27d58067","name":"Development Pods","path":"","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e984bcd4feee9e1dfb73f639f9bac23b2e2","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/AVFoundation.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e987724d547599f4408ee3b3d56fa903a20","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/AVKit.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e98c18474134a48ed556f51c824bfca3246","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/CoreTelephony.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e9885770c7fb25aa0db0cfd09c5443f9797","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e9802c3ee0032b21aafbeccc5b7db734fb2","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/ImageIO.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e984497b71acede5531fd260c9ac8b3d9e1","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Photos.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e98d671fdb5a7bd1c3267d3e6f35907cbca","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/QuartzCore.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e9872b55968a8ae9b74a90d6bfa9882dd5a","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/SystemConfiguration.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e98d159ebe55fadf57df5691badabf215cd","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/UIKit.framework","sourceTree":"DEVELOPER_DIR","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9827102c11d594e53de5adfd4435cd953d","name":"iOS","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986f57df5597ed36b645cb934c885be56d","name":"Frameworks","path":"","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e980f256812a9858de27fb60bd1accdd32c","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupCellItemProtocol.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9837ba02f2eab7de1730b72c879d53f332","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailBaseCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981791c992ccf9564106a690e1d96420b8","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailCameraCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981149a1d26875e8b68800278ef1ef0dd2","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailImageCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98c8875336f053558b1f9ed7c43e262ddd","path":"Sources/DKImagePickerController/View/DKAssetGroupDetailVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98164873936b2ccd15bdc608cc60c2cc65","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailVideoCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a7cecd4700762c7a109513bfb52989bb","path":"Sources/DKImagePickerController/View/DKAssetGroupGridLayout.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989c224ca64eb47c09a922a3d927cf7f10","path":"Sources/DKImagePickerController/View/DKAssetGroupListVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986c20dce043f12383acfeb98f96470e02","path":"Sources/DKImagePickerController/DKImageAssetExporter.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9855672fea159780d4dd23a1f5a30a7837","path":"Sources/DKImagePickerController/DKImageExtensionController.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98261f313efe36b40a087a9d35206c9607","path":"Sources/DKImagePickerController/DKImagePickerController.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982975067e23a41db0b8f6979b4a4a3b55","path":"Sources/DKImagePickerController/DKImagePickerControllerBaseUIDelegate.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d331501c9a3bbd1cec21da6d1bed6a79","path":"Sources/DKImagePickerController/View/DKPermissionView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9851006020f4c7fc277370bdaceee539fd","path":"Sources/DKImagePickerController/DKPopoverViewController.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f1abbceda566cfe605dbb72a81ac8f8e","name":"Core","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e10cad38a7b8b791b5523321d2112ef0","path":"Sources/DKImageDataManager/Model/DKAsset.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985cdfbee7f0fa63af790094c590a82f83","path":"Sources/DKImageDataManager/Model/DKAsset+Export.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982173071859389679a6c45171b90987da","path":"Sources/DKImageDataManager/Model/DKAsset+Fetch.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a89a84fdf4f961d13d26db8520129232","path":"Sources/DKImageDataManager/Model/DKAssetGroup.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e418cf226491ab81be949451bec00d12","path":"Sources/DKImageDataManager/DKImageBaseManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9878488e740dd474df94f5eec1f4ebdef4","path":"Sources/DKImageDataManager/DKImageDataManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e01ac6181d083bc327440f955529b52b","path":"Sources/DKImageDataManager/DKImageGroupDataManager.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981e366accc57fcc9cc1a70ecd199c50e7","name":"ImageDataManager","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982a05c3bf490f4a4639be6133ce46fc50","path":"Sources/Extensions/DKImageExtensionGallery.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9878b271eaafac7e3a83dd4dcb67d7b63e","name":"PhotoGallery","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9892de1f95603b3309408b855b150222e8","path":"Sources/DKImagePickerController/Resource/DKImagePickerControllerResource.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e982cb145326ee0fe9b023de780deed78c8","path":"Sources/DKImagePickerController/Resource/Resources/ar.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98aa1b813ba9ab60abaff79984557a95ae","path":"Sources/DKImagePickerController/Resource/Resources/Base.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e980c9106c6deeaa489bece52872ddc8302","path":"Sources/DKImagePickerController/Resource/Resources/da.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98638a8ead3c4921071ce058b31e0789b1","path":"Sources/DKImagePickerController/Resource/Resources/de.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e9808c6c81d6544958ee5022314e9257bea","path":"Sources/DKImagePickerController/Resource/Resources/en.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e989d1190414344f8164f52c639a0b3923e","path":"Sources/DKImagePickerController/Resource/Resources/es.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98a3612de5bfe6103968b0ad0e24321c06","path":"Sources/DKImagePickerController/Resource/Resources/fr.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e9840393198352d2a493eca64a5e8366781","path":"Sources/DKImagePickerController/Resource/Resources/hu.lproj","sourceTree":"","type":"file"},{"fileType":"folder.assetcatalog","guid":"bfdfe7dc352907fc980b868725387e9866b7b2a063b357116fff6128faab6010","path":"Sources/DKImagePickerController/Resource/Resources/Images.xcassets","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98389b54d175b1ebff57cec1e50f863fcc","path":"Sources/DKImagePickerController/Resource/Resources/it.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98653c7ebf4d1cd10720ef59b098ac5602","path":"Sources/DKImagePickerController/Resource/Resources/ja.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98104b68b4ff0a25a5296d0b83fcc56453","path":"Sources/DKImagePickerController/Resource/Resources/ko.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98d31edcc28dfbbe3d8ce637b5afc30729","path":"Sources/DKImagePickerController/Resource/Resources/nb-NO.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e984482abef551eb7f8c2328ba24695640c","path":"Sources/DKImagePickerController/Resource/Resources/nl.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e987d2482c68c58caea8c964ddc88375bce","path":"Sources/DKImagePickerController/Resource/Resources/pt_BR.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98e82421c9939f56bd2bac60c90c0972a4","path":"Sources/DKImagePickerController/Resource/Resources/ru.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98641e96c7b9979000ba15756d8c4088c5","path":"Sources/DKImagePickerController/Resource/Resources/tr.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e987dbf20be783e65771c0b6b10146e0526","path":"Sources/DKImagePickerController/Resource/Resources/ur.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98496e8767ebc06f02f3a190467b76c669","path":"Sources/DKImagePickerController/Resource/Resources/vi.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e9851d4a9e151fbaba3b43b0c1c328e7692","path":"Sources/DKImagePickerController/Resource/Resources/zh-Hans.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e989af334eda3f4b74b226c99b858e3a8ee","path":"Sources/DKImagePickerController/Resource/Resources/zh-Hant.lproj","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9804dfbef9d9e61df9bb559e2872b5da24","name":"Resources","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d02568085c3a0b85378f96f4eb289f1a","name":"Resource","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e985e388c776c1b334623438d45d3541462","path":"DKImagePickerController.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982407d1e8008282cbe7def1a4ffcc63a5","path":"DKImagePickerController-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98ea7538dde57b4e0f1e4dd4e75d1c0d0c","path":"DKImagePickerController-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b27014ca8a98af66a8fbde29e88273ac","path":"DKImagePickerController-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989c1f453bb5d9dee5b1b66f4e8eb4eb4d","path":"DKImagePickerController-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9806dc5717c6a31d76aad6bd6ece1d0e8a","path":"DKImagePickerController.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e982c698840c90027623e3da75a6ca7c080","path":"DKImagePickerController.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987e62c3ecefb84e0040e723e52f3a31b8","path":"ResourceBundle-DKImagePickerController-DKImagePickerController-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e983adef97a715cd560344faddd2136ca7c","name":"Support Files","path":"../Target Support Files/DKImagePickerController","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986a64e2ff11892e14ae7fcb90015c6a37","name":"DKImagePickerController","path":"DKImagePickerController","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9863e802284c73bd79713e7ebbd9884a03","path":"DKPhotoGallery/DKPhotoGallery.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e980341a413639d3324d70cd433b477ad56","path":"DKPhotoGallery/DKPhotoGalleryContentVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989de872349f4afb6a3ec10554d20a0824","path":"DKPhotoGallery/Transition/DKPhotoGalleryInteractiveTransition.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9891931bbad7bcd3c15555c1ab3b5e9eda","path":"DKPhotoGallery/DKPhotoGalleryScrollView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989304b9d8408d53cfd4dc626445b7aa78","path":"DKPhotoGallery/Transition/DKPhotoGalleryTransitionController.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9851ce307e43c7abf9a92db60b62d92eed","path":"DKPhotoGallery/Transition/DKPhotoGalleryTransitionDismiss.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98bb524791f17b91cf8d8e69a04b9d6241","path":"DKPhotoGallery/Transition/DKPhotoGalleryTransitionPresent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9817d140dcad4deb3eb9494e9b79f6a54f","path":"DKPhotoGallery/DKPhotoIncrementalIndicator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b08fe598889329654b388a853ab11345","path":"DKPhotoGallery/DKPhotoPreviewFactory.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98abec4317337fcb2bdb4f60b9acc75558","name":"Core","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981fe52c027e9b931957c809c3731fe5e8","path":"DKPhotoGallery/DKPhotoGalleryItem.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98790064f3a8ebed4c57f29d168a40864a","name":"Model","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98c8c44b9bad588a8cbce8ae16059cc044","path":"DKPhotoGallery/Preview/PDFPreview/DKPDFView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989def67d6c082310eb82979d6e6048439","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoBaseImagePreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e64926604b2aa420c550979dce302c11","path":"DKPhotoGallery/Preview/DKPhotoBasePreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985f43f00297e493a6c1ff3f01846dd6ab","path":"DKPhotoGallery/Preview/DKPhotoContentAnimationView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982b1b24b667eeb0aa9df1641d1afbf02f","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImageDownloader.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b7c4392f573762b73d261ae2c1ad25f5","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImagePreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98036bb4411bb8d063d059db4e041547ed","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImageUtility.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b73272e18580fd7065bff4ab280cf9d1","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImageView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b16bf7005681538906dcee4437e1937d","path":"DKPhotoGallery/Preview/PDFPreview/DKPhotoPDFPreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989a757ab8e8a9fc630d1a57794cb67b77","path":"DKPhotoGallery/Preview/PlayerPreview/DKPhotoPlayerPreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988a849dc79b3d29aaf31294b33a51b25b","path":"DKPhotoGallery/Preview/DKPhotoProgressIndicator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a03d72595a015012babd53f62ce9fe7b","path":"DKPhotoGallery/Preview/DKPhotoProgressIndicatorProtocol.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9895fb6b2018a434a37a846c5e29fe4c20","path":"DKPhotoGallery/Preview/QRCode/DKPhotoQRCodeResultVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9806b510aa1c18127530918269f3b10ecd","path":"DKPhotoGallery/Preview/QRCode/DKPhotoWebVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988b5a7957a4c4d63e718fe2f2bfa1a142","path":"DKPhotoGallery/Preview/PlayerPreview/DKPlayerView.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a4f4a3f314a53e8278137c97d48a2a7d","name":"Preview","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982c920e4b6a548594014fc19d89752af0","path":"DKPhotoGallery/Resource/DKPhotoGalleryResource.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98322362f55daedd42cf2628aab1e7ee5a","path":"DKPhotoGallery/Resource/Resources/Base.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98257fc87c20dddd559c3a65cb29ef2a8c","path":"DKPhotoGallery/Resource/Resources/en.lproj","sourceTree":"","type":"file"},{"fileType":"folder.assetcatalog","guid":"bfdfe7dc352907fc980b868725387e98632a77eb6e8a5c194970ffbb837e3163","path":"DKPhotoGallery/Resource/Resources/Images.xcassets","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e981cdbb40ea77c1fcafa54f43ddd5ead0e","path":"DKPhotoGallery/Resource/Resources/zh-Hans.lproj","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9833f8b21cf1e493b32aa79c353bb36c59","name":"Resources","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987e4621e932260350f9fe18d776062789","name":"Resource","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98669eec05ea75fd44fb0e2666721dbfac","path":"DKPhotoGallery.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986ea1aeb64b4ee3b54e278e3a7146573c","path":"DKPhotoGallery-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e984490f039b0eb3468d94b0ab2e6a714c8","path":"DKPhotoGallery-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9827c39759d14ae68710c6e6f3caaa10e9","path":"DKPhotoGallery-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986024dcc5015a387313d8adc4bce7b36a","path":"DKPhotoGallery-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98b1d81c0ffe868a64db997ed9da144cf0","path":"DKPhotoGallery.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e988204bc98e0d67983074132a4f5665a6b","path":"DKPhotoGallery.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987ab489cc4f30435cd7ded82f0c6eae68","path":"ResourceBundle-DKPhotoGallery-DKPhotoGallery-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987ec59054c088e47e413206915ef9028f","name":"Support Files","path":"../Target Support Files/DKPhotoGallery","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986c674d50d3b10e235f4f35f7a35d5ae8","name":"DKPhotoGallery","path":"DKPhotoGallery","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9896448ec1f9cbf14857b3fe6b95caa011","path":"Sources/Reachability.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989bacf7eba26d0bb486de3cbfe5ea13c2","path":"ReachabilitySwift.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6244bc1a6a40e6115703cb3d00b8446","path":"ReachabilitySwift-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987b7b6b5547766ec62d38737cd24e68cd","path":"ReachabilitySwift-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98547e39e19bf28c52f14233768cc21bd9","path":"ReachabilitySwift-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988aeea5b451523377c4d21395c5903a7c","path":"ReachabilitySwift-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98901baae58940cfd27e962f9d5011362f","path":"ReachabilitySwift.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e989a874d2419abc53ad8b0cd3d2a5318ae","path":"ReachabilitySwift.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9896d1894bae44836917da92d3aab54f93","name":"Support Files","path":"../Target Support Files/ReachabilitySwift","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bebab75b96c37c53dc67122f6031b002","name":"ReachabilitySwift","path":"ReachabilitySwift","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cbbfeea198fc4bce239c47fc57d8a961","path":"SDWebImage/Private/NSBezierPath+SDRoundedCorners.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a4610931d9a303cf4a5413361d1ada97","path":"SDWebImage/Private/NSBezierPath+SDRoundedCorners.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980e908f1ceaaa33a36d5403f550e8b9aa","path":"SDWebImage/Core/NSButton+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9817f5ea7520f6058c0504b71e2865c594","path":"SDWebImage/Core/NSButton+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984f06f7f319f35e24d3947bb38a46b4b9","path":"SDWebImage/Core/NSData+ImageContentType.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987b53a8893f5f3cdd6faadb0d3bfc4d62","path":"SDWebImage/Core/NSData+ImageContentType.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9890edfb9455d8d050d856d19de294b76c","path":"SDWebImage/Core/NSImage+Compatibility.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987853d28d16fa30841c1a55b61c447d01","path":"SDWebImage/Core/NSImage+Compatibility.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c8d140d68f858c8de679df2d49e39ce1","path":"SDWebImage/Core/SDAnimatedImage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b0ebe90ca974f5cad05396d5c0c407cc","path":"SDWebImage/Core/SDAnimatedImage.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9809df879cdb20a2d85e42e0ec9467d874","path":"SDWebImage/Core/SDAnimatedImagePlayer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e2ed6b92aae530671fb6a85f4968036a","path":"SDWebImage/Core/SDAnimatedImagePlayer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f116af502c254813038fb0fa9b6d7e43","path":"SDWebImage/Core/SDAnimatedImageRep.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987a40b9365faa88cb247c44544207a3ce","path":"SDWebImage/Core/SDAnimatedImageRep.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985026f7f77e36035ebbdce01168f1c703","path":"SDWebImage/Core/SDAnimatedImageView.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d1c51ae1c8f49cab13fbed609b95490d","path":"SDWebImage/Core/SDAnimatedImageView.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9853c881de2f36eb982e91b6111edd2159","path":"SDWebImage/Core/SDAnimatedImageView+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98229f3dff826db691d58cbf3df3ae4b2c","path":"SDWebImage/Core/SDAnimatedImageView+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a7fb3d4eb5f455ce911e0e84b77ee5df","path":"SDWebImage/Private/SDAssociatedObject.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9889c0b922f6710e9bbaad47d90e458796","path":"SDWebImage/Private/SDAssociatedObject.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9870a50afed1d5bb331637a22e1b2cdab6","path":"SDWebImage/Private/SDAsyncBlockOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989a88b4352ddd2268b71af2cececb468f","path":"SDWebImage/Private/SDAsyncBlockOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9894060044116c365175a4d8c9d018b47b","path":"SDWebImage/Private/SDDeviceHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98379407cdd5ec5f0842afe1dfb96cddaf","path":"SDWebImage/Private/SDDeviceHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9820bbf68e6d4b7e185246c728464b69b4","path":"SDWebImage/Core/SDDiskCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98556c8c3201769e199eb76c9fc9d5a0de","path":"SDWebImage/Core/SDDiskCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9822703fb114a75f5c5f2f254bb3f6484c","path":"SDWebImage/Private/SDDisplayLink.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982e5995722797bf81f987a7b34e8d29db","path":"SDWebImage/Private/SDDisplayLink.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98688ef71815752ca7f6167d44ae846d81","path":"SDWebImage/Private/SDFileAttributeHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4442f69c6514bc98fae925092574979","path":"SDWebImage/Private/SDFileAttributeHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cc8572110877f8beb99b8973afcea556","path":"SDWebImage/Core/SDGraphicsImageRenderer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d990503ee20a63ae3a6ac274eb15e500","path":"SDWebImage/Core/SDGraphicsImageRenderer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a81bf725f34f12d4633981ff1a9be615","path":"SDWebImage/Core/SDImageAPNGCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a93d56dd2776df0d9323eaf235ac4573","path":"SDWebImage/Core/SDImageAPNGCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98063a7a83ef11a2830cff9aef0f246a46","path":"SDWebImage/Private/SDImageAssetManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9878a035a3947c0bfb2c74de160efc90f8","path":"SDWebImage/Private/SDImageAssetManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba45c50b7e93c71a1094293aeb130683","path":"SDWebImage/Core/SDImageAWebPCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988b1062c96ec96331da01cae06e95e9bb","path":"SDWebImage/Core/SDImageAWebPCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98adff88ced90105be82ec5c254de38c39","path":"SDWebImage/Core/SDImageCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b59fb4d17416bbf73623c9ca148d4781","path":"SDWebImage/Core/SDImageCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da4185ba0b8e0c270b9045f1c0ea7f34","path":"SDWebImage/Core/SDImageCacheConfig.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f690a5caa9ac98baa8bfaca0d8119a97","path":"SDWebImage/Core/SDImageCacheConfig.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984fc8806592e30baa20155c119711a319","path":"SDWebImage/Core/SDImageCacheDefine.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aa465b740f26e7c857d665d9e948f68f","path":"SDWebImage/Core/SDImageCacheDefine.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98025e98798e009017d38c6b8f9f98dd3b","path":"SDWebImage/Core/SDImageCachesManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989babcbb0bec790231c9ea03d45485895","path":"SDWebImage/Core/SDImageCachesManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9848a18db96f9cc4192f5855b2c6ec65fc","path":"SDWebImage/Private/SDImageCachesManagerOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988c4bfa1a1a41475204073a9c37cc4138","path":"SDWebImage/Private/SDImageCachesManagerOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9878c179b604ae00d511bcfe7b1c26229a","path":"SDWebImage/Core/SDImageCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e79c00adee724aeb4ee07faf25c36816","path":"SDWebImage/Core/SDImageCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899370db25d5ccec85c5bc3841d9150c3","path":"SDWebImage/Core/SDImageCoderHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9864cb7481f2ac08dacad300433c1e0e01","path":"SDWebImage/Core/SDImageCoderHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98466aa14d7abf59b152f093b8602bd34b","path":"SDWebImage/Core/SDImageCodersManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a351c522c640e5c3836b0b3b0ff15805","path":"SDWebImage/Core/SDImageCodersManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989938d9775335a6ffdff595f50234b88b","path":"SDWebImage/Core/SDImageFrame.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a8ca264bae9407c5ab570cb5c4eaa6e8","path":"SDWebImage/Core/SDImageFrame.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989a78ff5eec8840469dfa52c4df920d84","path":"SDWebImage/Core/SDImageGIFCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984d22bab198a56a739526a9f9fac69088","path":"SDWebImage/Core/SDImageGIFCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b137bd091f2422235254ba87eeeebb86","path":"SDWebImage/Core/SDImageGraphics.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987ec2e6e40d48d5e1c573c77074e16edf","path":"SDWebImage/Core/SDImageGraphics.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9817f302504ade0c532160f76082a200fc","path":"SDWebImage/Core/SDImageHEICCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cec361cb5581e3f861f0c9ebc7eb79b3","path":"SDWebImage/Core/SDImageHEICCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9863c1633045c8f4e6a7fefb974e14ca36","path":"SDWebImage/Core/SDImageIOAnimatedCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98728975404c77f683b561feae9c1d429f","path":"SDWebImage/Core/SDImageIOAnimatedCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9840158ea8f0a0e2c720c2f9a0c43bc29e","path":"SDWebImage/Private/SDImageIOAnimatedCoderInternal.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981a3f021a5e50a888a5ae1abc4e2e57fe","path":"SDWebImage/Core/SDImageIOCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9810ea71fbe3808e0193ef3ebfe97b71ed","path":"SDWebImage/Core/SDImageIOCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e3114679d79d3ddb667c3e6fb257d6fb","path":"SDWebImage/Core/SDImageLoader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d8bfeefcb91662f7491bdee227ce85b8","path":"SDWebImage/Core/SDImageLoader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987a235e20b10d513a95f2fe5a4faa9fb2","path":"SDWebImage/Core/SDImageLoadersManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ae292dc3f6734ffa8a30d1eb750b312a","path":"SDWebImage/Core/SDImageLoadersManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98555b647b7b68b3c59175202ef82bc955","path":"SDWebImage/Core/SDImageTransformer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98379f432747c66b556608cfcb163dd08a","path":"SDWebImage/Core/SDImageTransformer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984b63b84d1f79f14a6dc3a12cac2809ed","path":"SDWebImage/Private/SDInternalMacros.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aad7fdfe00389f16c787afd556ef9f8e","path":"SDWebImage/Private/SDInternalMacros.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98209eac57b1854edf591b16e3f80fec69","path":"SDWebImage/Core/SDMemoryCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982b6d0c6ce7e156d1ba21129855543471","path":"SDWebImage/Core/SDMemoryCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9898c0fd4390df5133c07e348bdbd55d9c","path":"SDWebImage/Private/SDmetamacros.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988e65ef0d735983c43262cb848c637768","path":"SDWebImage/Private/SDWeakProxy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c1a6e6955d89f3463af21ab50142f735","path":"SDWebImage/Private/SDWeakProxy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9802c2dd1c8e6745d6ad8dfa6ac15a50df","path":"WebImage/SDWebImage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98583254b6e10a610161162b2462c9a243","path":"SDWebImage/Core/SDWebImageCacheKeyFilter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d67745e1c95b9a3b0b6afc23ba0e53d1","path":"SDWebImage/Core/SDWebImageCacheKeyFilter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e6013c78eea89a230352d0a58977ee65","path":"SDWebImage/Core/SDWebImageCacheSerializer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c798c0650901d3f454e84b38b32b7c14","path":"SDWebImage/Core/SDWebImageCacheSerializer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987bcdc97ee957f7d3dcc46ccd3df087d6","path":"SDWebImage/Core/SDWebImageCompat.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98175d683f5876f4bef28a5c568a03ee0e","path":"SDWebImage/Core/SDWebImageCompat.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a1f302e052441cf2364ea9fdbb633665","path":"SDWebImage/Core/SDWebImageDefine.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9801960d18f6952523241592bfcf8df74f","path":"SDWebImage/Core/SDWebImageDefine.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da783a4512ad557f5bed33b07819428b","path":"SDWebImage/Core/SDWebImageDownloader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988bf2ba3046f2a1dc82c3ae3236bb5c29","path":"SDWebImage/Core/SDWebImageDownloader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987349aeb8db30aa64526eda7973f155b2","path":"SDWebImage/Core/SDWebImageDownloaderConfig.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987233e0726650facd69de48131fbfb887","path":"SDWebImage/Core/SDWebImageDownloaderConfig.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981f0f6fc0eca1c4df41989a0e01130390","path":"SDWebImage/Core/SDWebImageDownloaderDecryptor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b1e56d48b1b3c795cd043d008ac2d69f","path":"SDWebImage/Core/SDWebImageDownloaderDecryptor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986735d6c802483fa77c3db72c66398b93","path":"SDWebImage/Core/SDWebImageDownloaderOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986a5410248346eb3a5db888eeaf016ed3","path":"SDWebImage/Core/SDWebImageDownloaderOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980f2c0526259c08fb215f33283d062669","path":"SDWebImage/Core/SDWebImageDownloaderRequestModifier.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986771283dce17b4fb9bd4b5fe643ed3b9","path":"SDWebImage/Core/SDWebImageDownloaderRequestModifier.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ae78c144f7dd1ffa4151c834e1856244","path":"SDWebImage/Core/SDWebImageDownloaderResponseModifier.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cd959e9548caf1d3b10d572543028967","path":"SDWebImage/Core/SDWebImageDownloaderResponseModifier.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981f2f1b5fb7e7a0259d587693c70e17dc","path":"SDWebImage/Core/SDWebImageError.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d7b5236ded30e2d2a5ff450a32f7bd62","path":"SDWebImage/Core/SDWebImageError.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98db171e7f43254466428cd7d160d4bc66","path":"SDWebImage/Core/SDWebImageIndicator.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988059612109acc8adb78283f1ba5f6c8e","path":"SDWebImage/Core/SDWebImageIndicator.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989a4f6041648958d4aff810b46b83ec9d","path":"SDWebImage/Core/SDWebImageManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985ec38f9cb9f5d4978c0311d1008e04cc","path":"SDWebImage/Core/SDWebImageManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982c27b087e8cd840abc3ef59829b80efa","path":"SDWebImage/Core/SDWebImageOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989ab8963206759569d84f30727fa4cba2","path":"SDWebImage/Core/SDWebImageOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b72cfadd9fa624c843ceaca86f14fba7","path":"SDWebImage/Core/SDWebImageOptionsProcessor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b5e7d24f1e11dbac304e87bddaff0bab","path":"SDWebImage/Core/SDWebImageOptionsProcessor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982a13b238680429b9cb2b8bf0839d5f79","path":"SDWebImage/Core/SDWebImagePrefetcher.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985fdfabd035ec7a467b035a8ce350edd5","path":"SDWebImage/Core/SDWebImagePrefetcher.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987fbe1b7021e7099d47e3d29bb0d246b0","path":"SDWebImage/Core/SDWebImageTransition.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d898bb0c82f19d5c90770a13e1992bc4","path":"SDWebImage/Core/SDWebImageTransition.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e46d3f1b4958c657b86076222797d146","path":"SDWebImage/Private/SDWebImageTransitionInternal.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989b6a95fea066801733ba0d9eb7781a8d","path":"SDWebImage/Core/UIButton+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a7d08c8d1537b994f19071149f068bb7","path":"SDWebImage/Core/UIButton+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9845d7911932c79e171dd089fd4628b5b8","path":"SDWebImage/Private/UIColor+SDHexString.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98008deca9be811cf53f205b0ddcc4983a","path":"SDWebImage/Private/UIColor+SDHexString.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986bb62f370bbe234b6bd0d7c77da71782","path":"SDWebImage/Core/UIImage+ExtendedCacheData.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aa1127430a2307c0512ba13f3695fe9e","path":"SDWebImage/Core/UIImage+ExtendedCacheData.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ccd7d7396bf3843012af71140da2a49c","path":"SDWebImage/Core/UIImage+ForceDecode.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9863a4e0085b4eca2b418099edfec4340a","path":"SDWebImage/Core/UIImage+ForceDecode.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b5c221940cf71976a9792c00ded2a77c","path":"SDWebImage/Core/UIImage+GIF.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d05fd41d05faa2360aeaf46fb5065514","path":"SDWebImage/Core/UIImage+GIF.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98662eda270b9081b770cb1f2a02e387b2","path":"SDWebImage/Core/UIImage+MemoryCacheCost.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fd8dd526fbbf1c35d004173853ebc817","path":"SDWebImage/Core/UIImage+MemoryCacheCost.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986357ed01b566bb1d1be0954a632e35e0","path":"SDWebImage/Core/UIImage+Metadata.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986a263fb34dde4d01069f152b021094c7","path":"SDWebImage/Core/UIImage+Metadata.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98deca6db8fdc22a81efbb6cd75f7c9144","path":"SDWebImage/Core/UIImage+MultiFormat.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981f8ce8389cf620585da7cd0d5ae68c6d","path":"SDWebImage/Core/UIImage+MultiFormat.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f79a5a836cf1d081150335f28fe35761","path":"SDWebImage/Core/UIImage+Transform.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c1367cac0329507a751b00959f8b1fe9","path":"SDWebImage/Core/UIImage+Transform.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9836fad0c1818f3ebd7ea94ecdb2cda58b","path":"SDWebImage/Core/UIImageView+HighlightedWebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e869a02fe4a657b9ddb0f148af11b090","path":"SDWebImage/Core/UIImageView+HighlightedWebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fc1f6b2f5b528239f7eb374a16707dd2","path":"SDWebImage/Core/UIImageView+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987a0efb32908f89fd6eaf93a60386fe37","path":"SDWebImage/Core/UIImageView+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d835b0cd54e8e5c3eefa756ca6a8cf4f","path":"SDWebImage/Core/UIView+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98da978efa2c20cf80e6a593952a64f214","path":"SDWebImage/Core/UIView+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982999b5735c33706130ed303f0efe7372","path":"SDWebImage/Core/UIView+WebCacheOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983963a49bc38ac28ca13543440440738c","path":"SDWebImage/Core/UIView+WebCacheOperation.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986e6118052c889416524a66863865f2d2","name":"Core","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98b569f5963060c97f2f2ba38385fe0047","path":"SDWebImage.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e582145da4c7bad45fd0fea722620980","path":"SDWebImage-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f69556d85c791f9f7bb4864067304e9e","path":"SDWebImage-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a5d7cf445f11b8a851603791bb80dccd","path":"SDWebImage-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9849c916368f02890a1ae21d78a5082dce","path":"SDWebImage-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98cbf68d1f922bc4817004dd6a2700f369","path":"SDWebImage.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e982a978c9504e7f820a9a62749b4639eb7","path":"SDWebImage.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98c5c4f2160b2c64c56cc35e1563f69dfa","name":"Support Files","path":"../Target Support Files/SDWebImage","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989ab633a406a0ba749c8183ca48f70ca1","name":"SDWebImage","path":"SDWebImage","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98198cb7323cf38f49c099a19af424e38e","path":"Sources/Swift/Metrics/BucketsMetricsAggregator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9820b096789e7bd1bfa4b58fa13bc5b50f","path":"Sources/Swift/Metrics/CounterMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98853fa02d04d0430ad3f2d7b333874af4","path":"Sources/Swift/Metrics/DistributionMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98ac0780da839edf981809953008673060","path":"Sources/Swift/Metrics/EncodeMetrics.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982bd4f7e001b4dd1cf079308ca514554b","path":"Sources/Swift/Metrics/GaugeMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9824d6ee3483a76d2eb18b2c33833c5c8a","path":"Sources/Swift/Tools/HTTPHeaderSanitizer.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98382df2b05839d9ea3f03c148af3ae692","path":"Sources/Swift/Metrics/LocalMetricsAggregator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981b4b3945f043de125e7b8c8aaeacc7c3","path":"Sources/Swift/Metrics/Metric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9830b09011eccca71f62b2f66238867f76","path":"Sources/Swift/Metrics/MetricsAggregator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aa15c1b3d311498677dcd94b75280f47","path":"Sources/Sentry/include/NSArray+SentrySanitize.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982ad87d7afe24ffd2d6bd213296aea811","path":"Sources/Sentry/NSArray+SentrySanitize.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f4b859c713f2344c6fc1477b46cbafeb","path":"Sources/Sentry/include/NSLocale+Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987f7b4c8e450d6bc934891b572509dd5c","path":"Sources/Sentry/NSLocale+Sentry.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b9346543c1b98901f22f3c7e23aff838","path":"Sources/Swift/Extensions/NSLock.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989b00bcbca3893520fc4f39ff18d0b1f5","path":"Sources/Sentry/include/NSMutableDictionary+Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98902ebd6036a41aaacac357ee5ccce1b9","path":"Sources/Sentry/NSMutableDictionary+Sentry.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9885b82b46b9dd005b768f8ef3c751040f","path":"Sources/Swift/Extensions/NumberExtensions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a716e75ccb528744d3ffde348c74054d","path":"Sources/Sentry/include/HybridPublic/PrivateSentrySDKOnly.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9810ac796ebca563e59fec2d96f029829c","path":"Sources/Sentry/PrivateSentrySDKOnly.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ed4d648fc835701dd40045ac4f0eb42","path":"Sources/Sentry/include/HybridPublic/PrivatesHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9825d3d80b5d5f47d8c3577be50d208f17","path":"Sources/Sentry/Public/Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9881e78b26fce5be69601e8f39debf1815","path":"Sources/Sentry/include/SentryANRTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988933b4bc3332ec51f502704c510ed48f","path":"Sources/Sentry/SentryANRTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c4aec8c696347d8a5fd3da2c9ae3b34b","path":"Sources/Sentry/include/SentryANRTrackerV2.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98143f06533f29ffe1f4e62e249ee7c61a","path":"Sources/Sentry/SentryANRTrackerV2.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9805b65a9fd5a17f8804adb0010a2bb994","path":"Sources/Swift/Integrations/ANR/SentryANRTrackerV2Delegate.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a9377495765c6b05f4938e987beaecc4","path":"Sources/Sentry/include/SentryANRTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9803a9b0b910a8e69ce5d48707b2f7b0ff","path":"Sources/Sentry/SentryANRTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9853bb97ef1af68a2e8a6cd65845220a21","path":"Sources/Sentry/include/SentryANRTrackingIntegrationV2.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f85156689aeaf18e00084edae9890433","path":"Sources/Sentry/SentryANRTrackingIntegrationV2.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982f3eaf81b6a498c005395bd74ff9b40c","path":"Sources/Sentry/include/HybridPublic/SentryAppStartMeasurement.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98573cf1fba6ec7158ac9e5d30872d3358","path":"Sources/Sentry/SentryAppStartMeasurement.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da6d3a4aa155bc2b99c1c5776fa1d7ab","path":"Sources/Sentry/include/SentryAppStartTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9858a59bcb56abe22c97af586957612f8a","path":"Sources/Sentry/SentryAppStartTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9897bfe385980cf04d6052b9a9a48626bd","path":"Sources/Sentry/include/SentryAppStartTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984c7e16723e7d6c5950d220f08a0f3a1b","path":"Sources/Sentry/SentryAppStartTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98711c5fa9ee620eb44232227aea5b4980","path":"Sources/Sentry/include/SentryAppState.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e102ca54129581ea6ac1fd19ca2f900d","path":"Sources/Sentry/SentryAppState.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f4aff5850320b2892171d2f9050e54f0","path":"Sources/Sentry/include/SentryAppStateManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98945bba539ff28910dddfef800d776212","path":"Sources/Sentry/SentryAppStateManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c7abc40702780507f67178f200c91e33","path":"Sources/Sentry/include/SentryAsynchronousOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f2c93c0f5d5274544f463c3dd147b69e","path":"Sources/Sentry/SentryAsynchronousOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9848c4b2580de96341fc0bfc5e6780e93a","path":"Sources/Sentry/SentryAsyncSafeLog.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98503f56b704c70eeeaa87f393ca606f46","path":"Sources/Sentry/SentryAsyncSafeLog.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9839b12ad72defb9e70cd9c490eab0f56b","path":"Sources/Sentry/Public/SentryAttachment.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9817625a85b70840a0b0718c986516528e","path":"Sources/Sentry/SentryAttachment.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98999febd8a8da745b5ea4843c406440a6","path":"Sources/Sentry/include/SentryAttachment+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980c19c6b2f6ae9fa9cbfb42827276a528","path":"Sources/Sentry/include/SentryAutoBreadcrumbTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982375cf366eb9fa65b8bba986be274ede","path":"Sources/Sentry/SentryAutoBreadcrumbTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986e0e69f1089a15308ed825159b9e98f7","path":"Sources/Sentry/include/SentryAutoSessionTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a4037e5e9e7feba04419a743f2be779b","path":"Sources/Sentry/SentryAutoSessionTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e981de7f79c30c8772c5fa58d8ea6d1b1b6","path":"Sources/Sentry/SentryBacktrace.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9891ba718e9d3cff3ab8d9bfee8bd0462b","path":"Sources/Sentry/include/SentryBacktrace.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989f61aa00b8a1516208dca7f64cd6a75c","path":"Sources/Sentry/Public/SentryBaggage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b20f7baef1201383ed42eb1c42b1ef2a","path":"Sources/Sentry/SentryBaggage.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e983e4f391d969af2e5ce4e2e2dd4a41dfb","path":"Sources/Swift/Helper/SentryBaggageSerialization.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985a62059eff00244af5c87c6ddd480877","path":"Sources/Sentry/include/SentryBaseIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987a9f6073313425fbba5130148b9b3a65","path":"Sources/Sentry/SentryBaseIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ccba6be3c8cd08597e295bd9856dd7e3","path":"Sources/Sentry/include/HybridPublic/SentryBinaryImageCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98963cbdfa2428d743619bf4f2c4cdf206","path":"Sources/Sentry/SentryBinaryImageCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98963224f46996165eb8c62129394cfe81","path":"Sources/Sentry/Public/SentryBreadcrumb.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e6b4f1738c9e7b78c11959faf476bb67","path":"Sources/Sentry/SentryBreadcrumb.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98647433b4e086798b973e947311127f7c","path":"Sources/Sentry/include/HybridPublic/SentryBreadcrumb+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b337f47392c6e73cb95920efdda0b675","path":"Sources/Sentry/include/SentryBreadcrumbDelegate.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fd297ac79b8c53f88805194c653eb324","path":"Sources/Sentry/include/SentryBreadcrumbTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98917b84e096fb7a3cee094b304c0d1f1e","path":"Sources/Sentry/SentryBreadcrumbTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987869ca5f51cd67f10fb06cf6d2a072e1","path":"Sources/Sentry/include/SentryBuildAppStartSpans.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b16fdba02a928575106b9d1ac1686733","path":"Sources/Sentry/SentryBuildAppStartSpans.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899a99bf57fcbf0c333b505bdb2e414cc","path":"Sources/Sentry/include/SentryByteCountFormatter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e29dfdbdcbf120e148ffa7d4a4c248a4","path":"Sources/Sentry/SentryByteCountFormatter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98be06b9e44be8b3f68cdabea1eab410fb","path":"Sources/Sentry/Public/SentryClient.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981f87b3406b001d130316dbc8ada38a00","path":"Sources/Sentry/SentryClient.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987b01f905126992881fc2dce8d8608866","path":"Sources/Sentry/include/SentryClient+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987f499c7f3f52d80336b1b533a1394782","path":"Sources/Sentry/include/SentryClientReport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a4dc6d699fd7d93e90a761eda46c4f64","path":"Sources/Sentry/SentryClientReport.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9830ba7417bb270438177deb73683c5536","path":"Sources/Sentry/include/SentryCompiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985f16ffcf4dfbc88b10ce30fd3e633708","path":"Sources/Sentry/include/SentryConcurrentRateLimitsDictionary.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98869ae30e47af8e56237caf99320cf284","path":"Sources/Sentry/SentryConcurrentRateLimitsDictionary.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f66f182cfe191dbc0994d7db36647922","path":"Sources/Sentry/include/SentryContinuousProfiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98fe03e1800d75ed025acc7c9f643b8f04","path":"Sources/Sentry/Profiling/SentryContinuousProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9860162861b3210d454479d7e1ac71d658","path":"Sources/Sentry/include/SentryCoreDataSwizzling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9828efb6a05eb4ef1519bb559204168266","path":"Sources/Sentry/SentryCoreDataSwizzling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9813f1af6eada9adbb72a6632c8d2b2829","path":"Sources/Sentry/include/SentryCoreDataTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980e9d7ced294ae24069ba05792c403f21","path":"Sources/Sentry/SentryCoreDataTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98373aa59d4a768c9ebed68430e50f9fd4","path":"Sources/Sentry/include/SentryCoreDataTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d656120ee1d06ad362116f5cd2c4da69","path":"Sources/Sentry/SentryCoreDataTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98971753585da08da03da7583249217f27","path":"Sources/Sentry/include/SentryCPU.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e71ce65934ced440e8087b210d26e9dd","path":"Sources/SentryCrash/Recording/SentryCrash.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9810ab6f5523c4427b61de387291c7a269","path":"Sources/SentryCrash/Recording/SentryCrash.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e983e0adaf7be093e8acf73fd7ca3cd8020","path":"Sources/SentryCrash/Recording/SentryCrashBinaryImageCache.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986c59b016150adc6042a09462cf5f2949","path":"Sources/SentryCrash/Recording/SentryCrashBinaryImageCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba2cf898e20c56ff809039433c33cfd0","path":"Sources/Sentry/include/SentryCrashBinaryImageProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982f6e693d7a2699f92a735b00a3b748ec","path":"Sources/SentryCrash/Recording/SentryCrashC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988880cf5ad48383745a739a9820534574","path":"Sources/SentryCrash/Recording/SentryCrashC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e981c394d73a0449af8d0295e4f249392e4","path":"Sources/SentryCrash/Recording/SentryCrashCachedData.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984943986cd47c7fbf04e945d0f7ce23dc","path":"Sources/SentryCrash/Recording/SentryCrashCachedData.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e985af7183f551d475ec21df7099e52ba19","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9819e75d68bf8cdd116d83607aa0c87f45","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8b934d46e8ace1a43b18243a1180d46","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_Apple.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e986e6252e39b90f3ce76d83e5d6c3974b2","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_arm.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98a418c37e4b8f0cae47ee4e0ef8a63b52","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_arm64.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9854f64306da17dca29bd7ff5a1c475bbe","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_x86_32.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9858b9351f994445a2cced9788b61b0857","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_x86_64.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9815c5e7deb3496c383b03ad2cb8c63c54","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDate.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98920a4695f60aba5f7a2d55e19953a7c3","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDate.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982e916154b2f4a8ee629c521943af2be5","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDebug.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fa25002e2a2a304eb735465f026645f3","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDebug.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9862a562ca672b78057b80f0d6b1381746","path":"Sources/Sentry/include/SentryCrashDefaultBinaryImageProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986d3ae0f245877d1a34a61568db103431","path":"Sources/Sentry/SentryCrashDefaultBinaryImageProvider.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989f539a8dd7e053b0be13bac966b57726","path":"Sources/Sentry/include/SentryCrashDefaultMachineContextWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98834119e584475e9bbe8dde34e6124eed","path":"Sources/Sentry/SentryCrashDefaultMachineContextWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987c323ed9eabfe9ca185b337c9308d491","path":"Sources/SentryCrash/Recording/SentryCrashDoctor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e86a5d681f8907d2cfc0b4615cae7aa6","path":"Sources/SentryCrash/Recording/SentryCrashDoctor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98872202ab332a20c50d416271f9d2372a","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDynamicLinker.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba5fbfcc911db7d4ae22629456fd9144","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDynamicLinker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98328deee97d6c62d2e5b511634b98f69a","path":"Sources/Sentry/Public/SentryCrashExceptionApplication.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ced46e42add5fe763f983e5bb41852eb","path":"Sources/Sentry/SentryCrashExceptionApplication.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9851df5069bd644d351f378d5a24e99531","path":"Sources/SentryCrash/Recording/Tools/SentryCrashFileUtils.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ee672a6996837477d96028b68c1168d1","path":"Sources/SentryCrash/Recording/Tools/SentryCrashFileUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9884856fe388c064570227a322157015ee","path":"Sources/SentryCrash/Recording/Tools/SentryCrashID.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9895df3868e47b3489c27cba402f91a418","path":"Sources/SentryCrash/Recording/Tools/SentryCrashID.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eafd27653478c04bf42d5a09c9475684","path":"Sources/SentryCrash/Installations/SentryCrashInstallation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98785810b1ca7d160b372c70ed55188bdb","path":"Sources/SentryCrash/Installations/SentryCrashInstallation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98789ed62e82862651822eb9e968b4183b","path":"Sources/SentryCrash/Installations/SentryCrashInstallation+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987b19143bc7df2526be6323a84f13d0cd","path":"Sources/Sentry/include/SentryCrashInstallationReporter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9843bd4e1a8c710f4388f1611823aa8703","path":"Sources/Sentry/SentryCrashInstallationReporter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fa237d860d80a5137ce637c70f1d45ae","path":"Sources/Sentry/include/SentryCrashIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984f9135a1bacbd9b7d700d830329cfa18","path":"Sources/Sentry/SentryCrashIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ed40eb6a8415d9209bc656e3e9003d20","path":"Sources/Sentry/include/SentryCrashIsAppImage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e983ace9e6326466c6006d7903e835f2298","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodec.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984db0ba7ef8f9c62a4768e1244e0175a5","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodec.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980949e62d558d1222c68ac80103de072a","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodecObjC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fb551adfbd11861983f13d4ea4ac5672","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodecObjC.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ebe01a592e2d14b2852a1fb5fbd2aec6","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMach.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e63f4dd5624ba45cbd9550973bc7a64b","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMach.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ac0ec3dc837141d75865164b98357241","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMachineContext.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982d7b304e56d6074933295f9cbe624568","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMachineContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b5dcb1a00343dc6a7015edc3f087c60a","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMachineContext_Apple.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9890a37d86794ca14855675168cd3383ad","path":"Sources/Sentry/include/SentryCrashMachineContextWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982bc91af71f89cf0a1a1788830d8adeba","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMemory.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bad5c89792bfd17e382bb695ad8ed3f7","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMemory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98cb3e637b4b9c0fd20f7ea80210d8b3c8","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f6864c5d37412b769927b5d5c74fe602","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e981c4aa5985300ab845f08441631c136e0","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_AppState.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986db33830a858c9794d31e24b7b5cdb20","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_AppState.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e98928789958ea08294471edb78122163bf","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_CPPException.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d88503a787c4bef8d3f64a16f8ac7431","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_CPPException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e985631641a5bf876e0a4464325cd89f9f0","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_MachException.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f9cccecbb59fac96ef00765633935089","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_MachException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981f0f66e36982c6f19b8196b5549c1151","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_NSException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9854c3e488a79f2baa48bf1bde77c46cd4","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_NSException.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98fc0ee9ce543fb5029fa8d77a33a2a927","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_Signal.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ef5045f7ebe869b4e0ded07a2e0b854f","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_Signal.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986d521257b90b2beb37d3cbeb2b852f91","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_System.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989b790d6fb8d01f7933ddbca6b6c2e119","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_System.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b15723d60c434030589a9c72cc466027","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitorContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e984023b76db5ad50719b293030bedfa158","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitorType.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e0d4db171ade52ab9810b83e9e46027a","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitorType.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9858f76db325762543559f151e9cde7092","path":"Sources/SentryCrash/Recording/Tools/SentryCrashNSErrorUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98036351eb99bc61418fa7904beb886a4b","path":"Sources/SentryCrash/Recording/Tools/SentryCrashNSErrorUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ec8643356b925433575de2c29645e403","path":"Sources/SentryCrash/Recording/Tools/SentryCrashObjC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d43045f94dfb5f8084c33ff0795a4fb3","path":"Sources/SentryCrash/Recording/Tools/SentryCrashObjC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983ff24760fdf3ff3241a74acaab8abd66","path":"Sources/SentryCrash/Recording/Tools/SentryCrashObjCApple.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e52be506e681f88f6f6ca68d2f91cc4e","path":"Sources/SentryCrash/Recording/Tools/SentryCrashPlatformSpecificDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ebb3d6360d98f69e909523c8039f5805","path":"Sources/SentryCrash/Recording/SentryCrashReport.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9882eca2c2727b3da70dc9991523763b33","path":"Sources/SentryCrash/Recording/SentryCrashReport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9857e1affb989c53e044956e4452fdbcea","path":"Sources/Sentry/include/SentryCrashReportConverter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982deeb46c4817dcdcd9ac3ff4d08481d4","path":"Sources/Sentry/SentryCrashReportConverter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982aad48539d3cdacf06eba4d8e46a2584","path":"Sources/SentryCrash/Recording/SentryCrashReportFields.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981e9d85b65beaeba8501a1c62b8febe5b","path":"Sources/SentryCrash/Reporting/Filters/SentryCrashReportFilter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980d8271e53cbfcf85f6cd065e708f2de2","path":"Sources/SentryCrash/Reporting/Filters/SentryCrashReportFilterBasic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9840d849ddebfb811a2974576cbaeecf80","path":"Sources/SentryCrash/Reporting/Filters/SentryCrashReportFilterBasic.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98a9d5de5d3dfe92644037d71ee5190757","path":"Sources/SentryCrash/Recording/SentryCrashReportFixer.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f69b87a8cdb3abcbac109f20e35935f7","path":"Sources/SentryCrash/Recording/SentryCrashReportFixer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9875fc97b0b53f66ab6b047f69319bae1f","path":"Sources/Sentry/include/SentryCrashReportSink.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f0c689d1974688e39dbfb4912fa84a98","path":"Sources/Sentry/SentryCrashReportSink.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9896c2e55b5af11c135ee2e941bdd0d7f7","path":"Sources/SentryCrash/Recording/SentryCrashReportStore.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9837c90d38984770cab39b73a4ada41ded","path":"Sources/SentryCrash/Recording/SentryCrashReportStore.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f92815bb8d5e910d80c83c261ef2665a","path":"Sources/SentryCrash/Recording/SentryCrashReportVersion.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982b8d6627048f9dc97cf8b1ea4105f91b","path":"Sources/SentryCrash/Recording/SentryCrashReportWriter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d078af0694b1dfb7d9ccde999b44e4fb","path":"Sources/Sentry/include/SentryCrashScopeObserver.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98078b4b6983c2612f332996e0802c4c78","path":"Sources/Sentry/SentryCrashScopeObserver.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98fc0f9f2e8fb1eb0f1596f10d50e88f2f","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSignalInfo.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983af198c0f2730501f9ce1047fe9febf5","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSignalInfo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982f50e0ec35d87b0e7665d862c74ad244","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ab8a94243d149d379bbd54e70a367d2","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e989255de6ce3a4e6431e337169ea12941c","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_Backtrace.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98227f0ab7c55a7b2a8c57667847371c5c","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_Backtrace.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e981ded049e9550dae6bfea340e4044795f","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_MachineContext.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985481f9043d0f6eec88a8931509d27ca7","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_MachineContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98172b9a0d48d2f67d576708e2c15c5681","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_SelfThread.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980331a56274b42b435802d99adc0f0030","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_SelfThread.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98616e193ad92d875887b6acd5f702b997","path":"Sources/Sentry/include/SentryCrashStackEntryMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fcbe81bbd35a2c2ad89d2e20c97dcc80","path":"Sources/Sentry/SentryCrashStackEntryMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98cac8c3e4cc76215e854ea8e9fd0b4fbd","path":"Sources/SentryCrash/Recording/Tools/SentryCrashString.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982bc1b6754e56222800d995cd75f8c2bb","path":"Sources/SentryCrash/Recording/Tools/SentryCrashString.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98e542917dc6ed22f0dc8928e137282679","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSymbolicator.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9842aa558befabfc401dc6c66d86529e41","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSymbolicator.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9805e8aec9ac44b4f557bd1c2c0ed33d7e","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSysCtl.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987d038ee2ee6d13d961dcc8f8cef0e8f9","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSysCtl.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9814ac97981888d650562ae1699fbed050","path":"Sources/SentryCrash/Recording/Tools/SentryCrashThread.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983761e96361f55650fcff3ec415c306c0","path":"Sources/SentryCrash/Recording/Tools/SentryCrashThread.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98cf8824f371b9de403b95fafde5e63a58","path":"Sources/SentryCrash/Recording/Tools/SentryCrashUUIDConversion.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9824542f73d2e98c60530e9c276356c21d","path":"Sources/SentryCrash/Recording/Tools/SentryCrashUUIDConversion.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f8bc45a2584bffecf7c9393d70e3ac1b","path":"Sources/SentryCrash/Reporting/Filters/Tools/SentryCrashVarArgs.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d8def6fa7b0dff52e0288684681cd235","path":"Sources/Sentry/include/SentryCrashWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98844dfbd5d2a8a41ef38196b8c228710e","path":"Sources/Sentry/SentryCrashWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98beaf5cf161791d03e65fca4463133522","path":"Sources/Swift/Helper/SentryCurrentDateProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9820c8946792aadcff4c6b7911cd3509b1","path":"Sources/Sentry/include/SentryDataCategory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9897ecba504c62f71a990fd084fdd0aa08","path":"Sources/Sentry/include/SentryDataCategoryMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98993a135e0861037578f65e78f6f19fa5","path":"Sources/Sentry/SentryDataCategoryMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fb2c2fb737cef61e3aa32bc173200734","path":"Sources/Sentry/include/SentryDateUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980da3c3a8a5a8e75a2b495f25b864ffff","path":"Sources/Sentry/SentryDateUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982cfb6141f563ef3b224e37c23dfd6ee7","path":"Sources/Sentry/include/SentryDateUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981e501be4e9e4ce930febba0a67b92f83","path":"Sources/Sentry/SentryDateUtils.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986de643aa7e4d68dd257a682eb4ecf094","path":"Sources/Sentry/Public/SentryDebugImageProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988714d874b4c7d300f8820a9568aac3b9","path":"Sources/Sentry/SentryDebugImageProvider.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ccd67327f2cef046d47a060a50e2b033","path":"Sources/Sentry/Public/SentryDebugMeta.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d62e6ab2a2d7467600f6feb7e2671a52","path":"Sources/Sentry/SentryDebugMeta.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98057cb7e081eab9c13bc45a99e2046c64","path":"Sources/Sentry/include/SentryDefaultObjCRuntimeWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98755befdde596ed98608c0f49e465f2e9","path":"Sources/Sentry/SentryDefaultObjCRuntimeWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987fb8b09d68a8675e669ccaa2b3d70f8b","path":"Sources/Sentry/include/SentryDefaultRateLimits.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98284e598b5703764339c9c4dd2d39c047","path":"Sources/Sentry/SentryDefaultRateLimits.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8b66c1f8b8fe6afdb3bd5083ac798dd","path":"Sources/Sentry/Public/SentryDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982b9ddd57b7d448702427527f15078729","path":"Sources/Sentry/include/SentryDelayedFrame.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b80297ad046a9e2ee2fc01b38f886866","path":"Sources/Sentry/SentryDelayedFrame.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c933c980d9fd2d6b18ab7660be826a32","path":"Sources/Sentry/include/SentryDelayedFramesTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9883f0c58fdda69214b888d7a89386438d","path":"Sources/Sentry/SentryDelayedFramesTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988e3fca574186ef9a3fbf60c7936614dd","path":"Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988e075ecfc8e39865eebcf99287896107","path":"Sources/Sentry/SentryDependencyContainer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ab472310b921cb357b3403f16d6bcc91","path":"Sources/Sentry/include/SentryDevice.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98ce8faf244cb4f73da113703dbb0fd493","path":"Sources/Sentry/SentryDevice.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f496c08b830fbf3baf100455494f579e","path":"Sources/SentryCrash/Reporting/Filters/Tools/SentryDictionaryDeepSearch.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ed54e2f0091c3b6700b4882495308ca7","path":"Sources/SentryCrash/Reporting/Filters/Tools/SentryDictionaryDeepSearch.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c50f7a6b19aff7f4ca9db7611b185ffc","path":"Sources/Sentry/include/SentryDiscardedEvent.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982b532fff8e4b1e30be209e64ad5f2328","path":"Sources/Sentry/SentryDiscardedEvent.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989c9e6c345c44f79bfb78251b2dbb8c7b","path":"Sources/Sentry/include/SentryDiscardReason.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c183e6d53e8117f062c3f4b7cd156530","path":"Sources/Sentry/include/SentryDiscardReasonMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986c8a3f333ed0b95751e7fbf78d7c98b9","path":"Sources/Sentry/SentryDiscardReasonMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bba374cd8c0dc6a0b88cd9da50d55613","path":"Sources/Sentry/include/SentryDispatchFactory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b61addc447754e7e5ec64e70a8a8d043","path":"Sources/Sentry/SentryDispatchFactory.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98181ef37baebf7620fdb3ed512d9c5b2c","path":"Sources/Sentry/include/SentryDispatchQueueWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fdfd3d660615791f5b19cb7a270b3d33","path":"Sources/Sentry/SentryDispatchQueueWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98edef8192fe71dc5c3aa5e7605d8bc231","path":"Sources/Sentry/include/SentryDispatchSourceWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ad912bcb51aa320ac7d9ae0d6b1c29d0","path":"Sources/Sentry/SentryDispatchSourceWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985d9e7a648cec7e7fc0ad3cf75672ea68","path":"Sources/Sentry/include/SentryDisplayLinkWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cebef9f2b8583ad43ec280bc3f601765","path":"Sources/Sentry/include/SentryDisplayLinkWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eb4e19f7af34fa2f92906307734634ab","path":"Sources/Sentry/Public/SentryDsn.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98bc58b953a2bf19f0ba887c35a848c43d","path":"Sources/Sentry/SentryDsn.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98629100bfbc75470514774b17e16c6ed2","path":"Sources/Swift/Helper/SentryEnabledFeaturesBuilder.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f5d9dabdb733a787122fe8ba4ca78980","path":"Sources/Sentry/include/HybridPublic/SentryEnvelope.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989ef35f84679b5286380254cf3782d3fc","path":"Sources/Sentry/SentryEnvelope.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982792cab6ec953388de02a2ff6935ac57","path":"Sources/Sentry/include/SentryEnvelope+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f6cedb8fc664cf2a66556a10e0939328","path":"Sources/Sentry/include/SentryEnvelopeAttachmentHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987e289e99946da7963f37deadbe959fdd","path":"Sources/Sentry/SentryEnvelopeAttachmentHeader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98141370ca334e2965d382e9f5cee9eb40","path":"Sources/Sentry/Public/SentryEnvelopeItemHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987942c3b2f4ab61de1e45f7316786b84c","path":"Sources/Sentry/SentryEnvelopeItemHeader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98781a30887bd009417ad1a4112c2fb3fe","path":"Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9895aa5c265104e54b26110f7557073d7b","path":"Sources/Sentry/include/SentryEnvelopeRateLimit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d5a56c7cb96529d8791d765d11773537","path":"Sources/Sentry/SentryEnvelopeRateLimit.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bafc78f7024b690ecf9471384a9902b4","path":"Sources/Sentry/Public/SentryError.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98ca1115e3b61c2229c1c80bd36f06de1b","path":"Sources/Sentry/SentryError.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98413720c3decab33490a7c5172007d190","path":"Sources/Sentry/Public/SentryEvent.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983743f3d27289e139c0b954aaa4e0628a","path":"Sources/Sentry/SentryEvent.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8f47a674006a0bd05b6739dc62b4f4f","path":"Sources/Sentry/include/SentryEvent+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987159465a79e7a721e0255b49a250409e","path":"Sources/Sentry/Public/SentryException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9895825e81ff4e9350316aa59835550564","path":"Sources/Sentry/SentryException.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9862683b73d213711a5794050fbedb940e","path":"Sources/Swift/SentryExperimentalOptions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a207c835059cd1e43b5179b933097323","path":"Sources/Sentry/SentryExtraContextProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e15159df27cf33be5cf5b699238e167f","path":"Sources/Sentry/SentryExtraContextProvider.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e61558513e2bb03c67587242c1ae2320","path":"Sources/Swift/Helper/SentryFileContents.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9891743b33ff8051d960b0188178ff8d18","path":"Sources/Sentry/include/SentryFileIOTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9811f125cb4ea42b0ee92dca25f4b88671","path":"Sources/Sentry/SentryFileIOTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c2c1ff1a63d933d631b4428ce2e38740","path":"Sources/Sentry/include/SentryFileManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987dc757a9b6a393b594b0e7f493805852","path":"Sources/Sentry/SentryFileManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c2ef2bd076cd4dd059f7e298bf852014","path":"Sources/Sentry/include/HybridPublic/SentryFormatter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9852b362bca7cc0c9e4f0ac4357b8926c2","path":"Sources/Sentry/Public/SentryFrame.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cb0c8f2e66fc06982ca30a2cf3eb9bfc","path":"Sources/Sentry/SentryFrame.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981a64e72c9b8c3c97fd22013edcdae5ca","path":"Sources/Sentry/include/SentryFrameRemover.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9887e6e078e6301374b2fef621090a7c44","path":"Sources/Sentry/SentryFrameRemover.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98dba5b9e2f053ff10581a7a52a1336881","path":"Sources/Swift/Integrations/FramesTracking/SentryFramesDelayResult.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980aa82f1dc751a60b99dfdc678c1d417c","path":"Sources/Sentry/include/HybridPublic/SentryFramesTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9851e95210c8e69b336ec75546cb826a45","path":"Sources/Sentry/SentryFramesTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98efaa72ef40eaa86dff924769099fe63f","path":"Sources/Sentry/include/SentryFramesTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98732b4c467888ee6e063ecfd31db00fc1","path":"Sources/Sentry/SentryFramesTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9891ed9d2029d00cffa36a1f5fc06398a1","path":"Sources/Sentry/Public/SentryGeo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988d6d449e12ef4dade75902629ee9c5e5","path":"Sources/Sentry/SentryGeo.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ca8e4cce263f1f47c0f162e4c586c4f8","path":"Sources/Sentry/include/SentryGlobalEventProcessor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ed988e9fdf3783f895274a6a759ea0e0","path":"Sources/Sentry/SentryGlobalEventProcessor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986e3e378fc6f7cc204b68b3caa7236f27","path":"Sources/Sentry/include/SentryHttpDateParser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a9f385e05164f46553c2ee2cf481b7d4","path":"Sources/Sentry/SentryHttpDateParser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c3bfc35223f1b5b6943049d2fb6c4389","path":"Sources/Sentry/Public/SentryHttpStatusCodeRange.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98768627ada7e32a0631bc8af27cb1d1ff","path":"Sources/Sentry/SentryHttpStatusCodeRange.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cca6fefe9cb49705f068665278f8ac02","path":"Sources/Sentry/include/SentryHttpStatusCodeRange+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989de0a39761e8a1050f2257cbe8713a04","path":"Sources/Sentry/include/SentryHttpTransport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985ee2ceb1a7cef57f920d5af9053a5d61","path":"Sources/Sentry/SentryHttpTransport.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98836f5531819312d8b361c85d8e8b18a4","path":"Sources/Sentry/Public/SentryHub.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98252ac7b4e2a6feb7cd60fdd1a4f09dc4","path":"Sources/Sentry/SentryHub.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98638a129b104a232f59a5551902b1f3f4","path":"Sources/Sentry/include/SentryHub+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98118a67a7239494acc31abf04261df084","path":"Sources/Swift/Protocol/SentryId.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98070046bf0c41b80e5921e2353abd1714","path":"Sources/Sentry/include/SentryInAppLogic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c06ead7e0dbabb3e7c310df0e456432b","path":"Sources/Sentry/SentryInAppLogic.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e38ea514f750e6ef90ab207cb16028c2","path":"Sources/Sentry/include/SentryInstallation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981b188529a60da3e88d8e87a3cef822ea","path":"Sources/Sentry/SentryInstallation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98f1ecb074aa05d438550e8283fcf8840e","path":"Sources/Swift/Protocol/SentryIntegrationProtocol.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9880707aac6a0b662d864e53db8ec3e873","path":"Sources/Sentry/include/SentryInternalCDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b48ba94d494b08c87d61b93a678e3dd2","path":"Sources/Sentry/include/SentryInternalDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9827d2c767a3050b20c39203308cb87f8d","path":"Sources/Sentry/include/SentryInternalNotificationNames.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9849c7220f6bfe0eeb615b9a7accf8b533","path":"Sources/Sentry/include/SentryInternalSerializable.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98def9cc150a565d2550979ec5b69998b1","path":"Sources/Sentry/include/SentryLaunchProfiling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98911fdc6061a2135c748153f2e4a05aa3","path":"Sources/Sentry/Profiling/SentryLaunchProfiling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989df9dc056cea8fcf0f59af8f85cd8282","path":"Sources/Swift/Helper/Log/SentryLevel.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983c8f81c7f1106715ce45bc09d968a4cb","path":"Sources/Sentry/include/SentryLevelHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9827103d0e236a2917fac90b75c47f4fec","path":"Sources/Sentry/SentryLevelHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98844dd3ba156013068dbf7f35651a5957","path":"Sources/Sentry/include/SentryLevelMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986b5d8de5042f9b6c6debab299d006332","path":"Sources/Sentry/SentryLevelMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988d673b72b0119aacb08d60c172d07c94","path":"Sources/Sentry/include/SentryLog.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9828d4fd172c6650cc894dc077f2713654","path":"Sources/Swift/Tools/SentryLog.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c9238e73552f603c4ec002a1af34b21c","path":"Sources/Sentry/include/SentryLogC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980bbcb909a9d03a552f1277782e18761a","path":"Sources/Sentry/SentryLogC.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986b40c6c48b55db5046d6db511942fa38","path":"Sources/Swift/Tools/SentryLogOutput.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e9849d90f9a6a3c8d0dad49a3899b83cec1","path":"Sources/Sentry/SentryMachLogging.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9800ab9a3ecaf9fb561465a8384a1a4232","path":"Sources/Sentry/include/SentryMachLogging.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982d535deadc12989119b0615b9dd28f36","path":"Sources/Sentry/Public/SentryMeasurementUnit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d114189b8a76ae18f56bcfc4aaa7ea75","path":"Sources/Sentry/SentryMeasurementUnit.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ca01cfbb872cb4259cd1e35227961de","path":"Sources/Sentry/include/SentryMeasurementValue.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987666c44d8e4bd63cee7a88e33191a7e6","path":"Sources/Sentry/SentryMeasurementValue.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9851202d61adfe91c437af0f1c67e03d04","path":"Sources/Sentry/Public/SentryMechanism.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e451424b1cdbc8b40cd309ecd4bbbf52","path":"Sources/Sentry/SentryMechanism.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bab24acd4702a28a3c4c9063e4ef0dce","path":"Sources/Sentry/Public/SentryMechanismMeta.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982968cc739e708fdcb3738f171b8efd43","path":"Sources/Sentry/SentryMechanismMeta.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9830744347f699402adbc18f50caf4e2d7","path":"Sources/Sentry/Public/SentryMessage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981082286186bf8efb07f5f2ce82e71bfc","path":"Sources/Sentry/SentryMessage.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899ce478e31a9826614e13502b4400b26","path":"Sources/Sentry/include/SentryMeta.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980d00aea86836a212dca15301d0f6be80","path":"Sources/Sentry/SentryMeta.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ead309d87cf3d8ca9c2bc918c7485105","path":"Sources/Sentry/include/SentryMetricKitIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982f522d156c7a74cefb6fc138d7ed9eea","path":"Sources/Sentry/SentryMetricKitIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987cd2cb4adcd0f896a6a9aa250d475f99","path":"Sources/Sentry/include/SentryMetricProfiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9869f27a6b12c3ecdb44b5cf7f02588341","path":"Sources/Sentry/SentryMetricProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986ce9c2df801c62045926d8864e84a2a1","path":"Sources/Swift/Metrics/SentryMetricsAPI.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981e970cc41f9443b9aec9727b6001ea4c","path":"Sources/Swift/Metrics/SentryMetricsClient.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982f7d0a3333cb2dcfcbe9c5afd9ec20f5","path":"Sources/Sentry/include/SentryMigrateSessionInit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c0abc314e5dda9b6db74ec0670b9817b","path":"Sources/Sentry/SentryMigrateSessionInit.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984f295a6e7dc5aee192e374504ed7fe03","path":"Sources/Sentry/include/SentryMsgPackSerializer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9886c992067744c43e9998edd72ad5e859","path":"Sources/Sentry/SentryMsgPackSerializer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98571b885c8f8846fcc4fd4db493b7816d","path":"Sources/Swift/MetricKit/SentryMXCallStackTree.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e987aff5927f8aad996cb87fb760614dbfd","path":"Sources/Swift/MetricKit/SentryMXManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e6c11cbc922ee1e497e67191309e6292","path":"Sources/Sentry/include/SentryNetworkTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9845b3a6e7f24267ede88073dada9a10f1","path":"Sources/Sentry/SentryNetworkTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98940d6066dd7b5b655ab91100255b0082","path":"Sources/Sentry/include/SentryNetworkTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e7ed27e60fec75e818894a08259699a4","path":"Sources/Sentry/SentryNetworkTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983581727c987fa4cca0954306818b12b7","path":"Sources/Sentry/include/SentryNoOpSpan.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9805654afa1f82958dd293c561ac9ab271","path":"Sources/Sentry/SentryNoOpSpan.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9833e522abc1d5b99c6068adbf830967c2","path":"Sources/Sentry/include/SentryNSDataSwizzling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9833178f1b00328efc29f49ca21f6fa919","path":"Sources/Sentry/SentryNSDataSwizzling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a32f1dc5a94f948c465e56c4010cf7e2","path":"Sources/Sentry/include/SentryNSDataTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984d57188a0ae1e5a026a97e63d1e37dd4","path":"Sources/Sentry/SentryNSDataTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987e9ef0b68a6618bff633d8257bbe4807","path":"Sources/Sentry/include/SentryNSDataUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9846770a46bcf2b781d57ce3c925c8d341","path":"Sources/Sentry/SentryNSDataUtils.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981b65e1d892bcf2bcb3c5f20ec5972415","path":"Sources/Sentry/include/SentryNSDictionarySanitize.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986c8f20ef0729cf34cac9cba109e20051","path":"Sources/Sentry/SentryNSDictionarySanitize.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983337f11aeab3a289f5c8c19c3908b921","path":"Sources/Sentry/Public/SentryNSError.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984e9914a4b4293c692a90ba05b97965b5","path":"Sources/Sentry/SentryNSError.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9880ca6abb4fff5d7995dc2123497283ad","path":"Sources/Sentry/include/SentryNSNotificationCenterWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986f2b065fb1bca703459a4977185b8d05","path":"Sources/Sentry/SentryNSNotificationCenterWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98663c96b18dbadd2be6f08f5b8ea31c5e","path":"Sources/Sentry/include/SentryNSProcessInfoWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9894ae4b1a56d92b5ab6ed329af307db93","path":"Sources/Sentry/SentryNSProcessInfoWrapper.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9878f37430fba46c9dd937dbfac8cf691c","path":"Sources/Sentry/include/SentryNSTimerFactory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98692460fbec7a54719df44466b20bcd96","path":"Sources/Sentry/SentryNSTimerFactory.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98680d2dc7ef64f30c303c18b4117568fc","path":"Sources/Sentry/include/SentryNSURLRequest.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e288c4e7a426b08b04b780e40e1f743f","path":"Sources/Sentry/SentryNSURLRequest.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c1a99b0cf864330bb01057e2bffc60fc","path":"Sources/Sentry/include/SentryNSURLRequestBuilder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4217e23e64d40642d3a033d561f8d35","path":"Sources/Sentry/SentryNSURLRequestBuilder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989354f19ee7a39a29a3b6520194111f66","path":"Sources/Sentry/include/SentryNSURLSessionTaskSearch.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985cf2ec8edccb99946f64c83878c3c803","path":"Sources/Sentry/SentryNSURLSessionTaskSearch.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98537f34ec2613ecc57890ea95d1c7af35","path":"Sources/Sentry/include/SentryObjCRuntimeWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e987bcb2b2868ee46b3d212b5e9a35106d4","path":"Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989e50a47e4c378c019942525106400715","path":"Sources/Sentry/Public/SentryOptions.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98063d68f2c40f62f6810d0ed406bb382e","path":"Sources/Sentry/SentryOptions.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eeec8be2c9ebaa5285c5472c3b36bf2e","path":"Sources/Sentry/include/HybridPublic/SentryOptions+HybridSDKs.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984bba6bc40fc60e061c7795ed9d14710d","path":"Sources/Sentry/include/SentryOptions+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984efbed9b76890e16efc0580a6c7f5776","path":"Sources/Sentry/include/SentryPerformanceTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989a15e09aff4d797481eb78d448d68934","path":"Sources/Sentry/SentryPerformanceTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981abbc71e31b4628810dbd6cd1779a6ab","path":"Sources/Sentry/include/SentryPerformanceTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4b7a5dd39a2fec7b7ddc0ee48570ad5","path":"Sources/Sentry/SentryPerformanceTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988b84a4cbbf82db3ee0783089fc6f3bba","path":"Sources/Swift/Integrations/SessionReplay/SentryPixelBuffer.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9855c159eb37097297bb381315fef4af83","path":"Sources/Sentry/include/SentryPredicateDescriptor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4338a3e8437f3b1d1acce2c48ddc4fb","path":"Sources/Sentry/SentryPredicateDescriptor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cb932407441bd3d3ca9e06a6841edf04","path":"Sources/Sentry/include/SentryPrivate.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a5d56cb9cee92ba68a856453c30f353d","path":"Sources/Sentry/include/SentryProfiledTracerConcurrency.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e987291ecb593ccdba0e5fc6a98cbf5fdae","path":"Sources/Sentry/Profiling/SentryProfiledTracerConcurrency.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e989733858144eb7e903d25bb39b2aa1d69","path":"Sources/Sentry/SentryProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98023f426c062e3f41cac6b9ff2674eb7c","path":"Sources/Sentry/include/SentryProfiler+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981591d1e000f7e36a693757459cc651de","path":"Sources/Sentry/Profiling/SentryProfilerDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9834c19d0343e8b716ae4009d27cd5343f","path":"Sources/Sentry/include/SentryProfilerSerialization.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98e9464d6a322ac39a174f4614fab171f4","path":"Sources/Sentry/Profiling/SentryProfilerSerialization.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986d6c0901f1b81ea1582095356892c849","path":"Sources/Sentry/Profiling/SentryProfilerSerialization+Test.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9849170326f9e7324f758fad5c8fda7c26","path":"Sources/Sentry/include/SentryProfilerState.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e988624d29c0763de3bcc218c7c4acf5c0e","path":"Sources/Sentry/Profiling/SentryProfilerState.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c97c721f87e21f23b35f49ab3480ddd9","path":"Sources/Sentry/include/SentryProfilerState+ObjCpp.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9803b8fbdce4e2ed80e49b511afbfdf7ba","path":"Sources/Sentry/include/SentryProfilerTestHelpers.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9857b858be807d990227d8cc8a467799eb","path":"Sources/Sentry/Profiling/SentryProfilerTestHelpers.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d57d7ff8850a7f68bc5127a2de78e1c6","path":"Sources/Sentry/include/SentryProfileTimeseries.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98cf09f39b101036d9039a5e9fdd5d4910","path":"Sources/Sentry/SentryProfileTimeseries.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980fa47d49e2bb65216878780f3a37d5f1","path":"Sources/Sentry/Public/SentryProfilingConditionals.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98dbbe1e35eb18685486a5c8b6577bbbe9","path":"Sources/Sentry/SentryPropagationContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9888a2658ee08672b053eb8cc3642a4e76","path":"Sources/Sentry/SentryPropagationContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989bdf26ad09027d76fb370df8816c6eb9","path":"Sources/Sentry/include/SentryQueueableRequestManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c2d294613ba90e126869f812c1ab6325","path":"Sources/Sentry/SentryQueueableRequestManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983454e6f421a0345f33e633ea310f2406","path":"Sources/Sentry/include/SentryRandom.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980f3118093a755d081ed6850275016cf3","path":"Sources/Sentry/SentryRandom.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a60f227949bd23fe3b07ff7b9601fc22","path":"Sources/Sentry/include/SentryRateLimitParser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fe5ac5ab93a3da03e0e408b697c6a079","path":"Sources/Sentry/SentryRateLimitParser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989d3798a7794bb34165683bfa0463151d","path":"Sources/Sentry/include/SentryRateLimits.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986a01eacf4fd9c5cbbfa18985456424be","path":"Sources/Sentry/include/SentryReachability.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d3daca73881c4a4b4874c14922476273","path":"Sources/Sentry/SentryReachability.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b22a5ba1237fd87037d77d3641902363","path":"Sources/Swift/Protocol/SentryRedactOptions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9842a47bed079d580cd20989b9075294e3","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985726026a1a1c31e50325233986a3b382","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9805701d4fccb49684aa1b55a60f18fb3c","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayRecording.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989cc077d015a9f88874f161283b85d1af","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayType.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9857bc555ad325fec19f683147a099c7e1","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98de5de5142f58314b7d12a2e669c41266","path":"Sources/Sentry/Public/SentryRequest.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9871b012a3b5f1462b9d197c7012741e34","path":"Sources/Sentry/SentryRequest.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985df69af6f60d3c7f31989a95e3f3a9e8","path":"Sources/Sentry/include/SentryRequestManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987657341f336ac02acfcf382525f1509c","path":"Sources/Sentry/include/SentryRequestOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d263d257b6329d0a2dfee2e60a97fb5c","path":"Sources/Sentry/SentryRequestOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987942c8ac2f032c0940a6a38ab91bf734","path":"Sources/Sentry/include/SentryRetryAfterHeaderParser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ac9e44632d815a3c81252e6452f71035","path":"Sources/Sentry/SentryRetryAfterHeaderParser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98f50457f417b0f79a0e1b0de834cf9aca","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebBreadcrumbEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98cb2f125ee7e524b3c4b954ba30af5d66","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebCustomEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988d52ddcc81c8bca32b6adcfe81c6b681","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98078862f44140b3e99c42e7d1ab12699b","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebMetaEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9846929a8c54eb68f7ae47aba553065cd4","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebSpanEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98fbbaa747f79a3331a947c4a688bc013a","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebTouchEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98cb215688c9705038d49294cb6576f234","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebVideoEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cf48b342ad07ad2abb993872033b47d0","path":"Sources/Sentry/include/SentrySample.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983cab1af79491ca23a25ea7c74d671896","path":"Sources/Sentry/Profiling/SentrySample.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984d00b5598fb7c97a8153da8d65037dfa","path":"Sources/Sentry/Public/SentrySampleDecision.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986c69e66e55629c2c42ad5d27ff70b570","path":"Sources/Sentry/SentrySampleDecision.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981142043015590766f312d36cdd419e39","path":"Sources/Sentry/include/SentrySampleDecision+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987eb65cfe85a754fabdf367fc17bec2d2","path":"Sources/Sentry/include/SentrySamplerDecision.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9894d865a928ae3975ad2746f118b4468b","path":"Sources/Sentry/SentrySamplerDecision.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ea213eb10687e172d1d3aded3bd41ae7","path":"Sources/Sentry/include/SentrySampling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98662bd827119a1617f1274adce996caf7","path":"Sources/Sentry/SentrySampling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987d47f3af45086a117a1cc24b2df9365a","path":"Sources/Sentry/Public/SentrySamplingContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989ff077c3356757e30d11f375da1ea1eb","path":"Sources/Sentry/SentrySamplingContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e981a82de91e8f2e5e3932cf0c7756ee711","path":"Sources/Sentry/SentrySamplingProfiler.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e985cd5b426c8a926a07fa8d251d5dab624","path":"Sources/Sentry/include/SentrySamplingProfiler.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988185ba6a88b996493cdf38ca379baabb","path":"Sources/Sentry/Public/SentryScope.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a51dc90ee182f3f234e31a6c3f115461","path":"Sources/Sentry/SentryScope.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98918a5c81a8e0b096accec29a8a6d8bb3","path":"Sources/Sentry/include/SentryScope+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e441058a2a5fac727c3f9be0e75a7e8e","path":"Sources/Sentry/include/SentryScopeObserver.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ff6e8cbde607cb070aaf521c95fb961a","path":"Sources/Sentry/SentryScopeSyncC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9881d0f7b202c7e8a98e9936b29655c95d","path":"Sources/Sentry/include/SentryScopeSyncC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989a369f24a3d33c353bfec1e7f64b3ea6","path":"Sources/Sentry/include/HybridPublic/SentryScreenFrames.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e46fe301c2548cc68a7e96ce8366b59a","path":"Sources/Sentry/SentryScreenFrames.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d09812498914ddbf97a6798491555ab6","path":"Sources/Sentry/include/SentryScreenshot.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e04f95ec1867f216e77231ddd1dda094","path":"Sources/Sentry/SentryScreenshot.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b1ff561b93fc4c5e149438256f956387","path":"Sources/Sentry/include/SentryScreenshotIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989e9f3ce88297e823291af52c5ad53475","path":"Sources/Sentry/SentryScreenshotIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9826acbc0b304b2a0ad2003d840178f0ff","path":"Sources/Sentry/Public/SentrySDK.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980e9d69924caebf6884c96f9ce74bc334","path":"Sources/Sentry/SentrySDK.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c7d38ced97e72f78e89610670b1b3fb3","path":"Sources/Sentry/include/SentrySDK+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984d5ae9898138c992b2021f68ec8babf6","path":"Sources/Sentry/include/SentrySdkInfo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98faa847346efca28ffffb35376ea3a952","path":"Sources/Sentry/SentrySdkInfo.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cf616d9f6cd818fe3348d29c0c916aa2","path":"Sources/Sentry/Public/SentrySerializable.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e1dd28873beb5b2dcb1af5beb95d271b","path":"Sources/Sentry/include/SentrySerialization.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980d6f259ec16188737b09287bd283b367","path":"Sources/Sentry/SentrySerialization.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f0fafebc44b49d7ff8414673d3cef1c9","path":"Sources/Sentry/include/SentrySession.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980b8b57442bb9cab2603b9a999081af38","path":"Sources/Sentry/SentrySession.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982116f2e7bb2c8e2ac20df990e0f81236","path":"Sources/Sentry/include/SentrySession+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983c8c985666c5352b6af3c7433239a020","path":"Sources/Sentry/include/SentrySessionCrashedHandler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b12e7dd603ab333ae8345872f96182a8","path":"Sources/Sentry/SentrySessionCrashedHandler.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98928b60c2da7f7825875efa439d557e6b","path":"Sources/Swift/Protocol/SentrySessionListener.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9828b59ccaf8ff87aa8c8c271b9b5bf184","path":"Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aed94b002bc11a182b8826fa79b3c5e5","path":"Sources/Sentry/include/SentrySessionReplayIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fa40304186e2c99ee5ccbfc8d92800fc","path":"Sources/Sentry/SentrySessionReplayIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987cc66d7b02be2cb3d51c5934a525009a","path":"Sources/Sentry/include/SentrySessionReplayIntegration+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ada9c823043bb19207c45e2346ff5a6a","path":"Sources/Sentry/include/HybridPublic/SentrySessionReplayIntegration-Hybrid.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9894dee7e19fea06bda4dbaaf5c1d9d35b","path":"Sources/Sentry/SentrySessionReplaySyncC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9864e08ba6ae71bf1d1c7b269fd8a8fe0f","path":"Sources/Sentry/include/SentrySessionReplaySyncC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e28c0aa12b28173b3abf435b1fa19a34","path":"Sources/Sentry/include/SentrySessionTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c0c10fbaa1cb5e98abbbef20d36e7b41","path":"Sources/Sentry/SentrySessionTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c6bf18b7cbf57a94d6c0d96399d10e5c","path":"Sources/Sentry/include/SentrySpan.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d09bbbc9887ced326bec2d6d7246dce1","path":"Sources/Sentry/SentrySpan.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f21c40f0c349752b7f501e01c22fa48b","path":"Sources/Sentry/include/SentrySpan+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ecbbe5eadcd9001b4c2fc21e078f887a","path":"Sources/Sentry/Public/SentrySpanContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980572088b35705ae125400e5eaf1ca5c0","path":"Sources/Sentry/SentrySpanContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9853c456af0a27c58300581e4f45771983","path":"Sources/Sentry/include/SentrySpanContext+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c593d7d98c929c6a2b0f7d4d0589a1d4","path":"Sources/Sentry/Public/SentrySpanId.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9826f4fe2fca7fe75078c0e9e4e65a2d02","path":"Sources/Sentry/SentrySpanId.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980a3fbb743bd3ac9e28a18b2bd756f025","path":"Sources/Sentry/include/SentrySpanOperations.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9871bde8593bddf6bbbc9f5418fd2e33c4","path":"Sources/Sentry/Public/SentrySpanProtocol.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9878c897661c68cc4ff7868fa62493291e","path":"Sources/Sentry/Public/SentrySpanStatus.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c18eedf9441788560deccc91341f4ef1","path":"Sources/Sentry/SentrySpanStatus.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98786a47c67a0dc6ec0050a48c5a5e4e3d","path":"Sources/Sentry/include/SentrySpotlightTransport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98571366b1f9a87519f69133aa94ea1592","path":"Sources/Sentry/SentrySpotlightTransport.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985987b898ae3639d21a5bfb374115d528","path":"Sources/Swift/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverter.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e98a1f775d7fcfd5f7d435fe7abffb91fb3","path":"Sources/Sentry/include/SentryStackBounds.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e98ea3cc80c8121c0454193a096db3b0641","path":"Sources/Sentry/include/SentryStackFrame.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988ebb06d6eb20ea09e0d5cce625106b6e","path":"Sources/Sentry/Public/SentryStacktrace.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9803eaef84f8cf151765e8f2dacff23bae","path":"Sources/Sentry/SentryStacktrace.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9863ffcb1f013b44034ba0bfda59953036","path":"Sources/Sentry/include/SentryStacktraceBuilder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985614a6bc35ea979167d2101aaf82ca72","path":"Sources/Sentry/SentryStacktraceBuilder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e70d13d7772d677d98ce5f06ab6a2bc7","path":"Sources/Sentry/include/SentryStatsdClient.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988a1606defceb762419fa23b4bb125351","path":"Sources/Sentry/SentryStatsdClient.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e05ba937b597322372e39fc0358edb5f","path":"Sources/Sentry/include/SentrySubClassFinder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989c1b2a8ac0bcddb420b0941ef640d0eb","path":"Sources/Sentry/SentrySubClassFinder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984d4ee0056f4fbde7672fb570961a8583","path":"Sources/Sentry/include/SentrySwift.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980bdb04ca4987d86965932ea3417f118b","path":"Sources/Sentry/include/SentrySwiftAsyncIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a09e471bf53c0e8d38c894fb2f23deda","path":"Sources/Sentry/SentrySwiftAsyncIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9864929f6d55b4bf658809924b262e5263","path":"Sources/Sentry/include/HybridPublic/SentrySwizzle.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d97ee0c09e9e900424b1603d10be7375","path":"Sources/Sentry/SentrySwizzle.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9877c9f1b3633c0e198960f68aa0c0810f","path":"Sources/Sentry/include/SentrySwizzleWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98711558a1dd411bb478cd26d8f3f745f2","path":"Sources/Sentry/SentrySwizzleWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98993f9b39cd9919f8ec83374ff13b1328","path":"Sources/Sentry/include/SentrySysctl.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985deb0edc70afd30a8360f3dc8e856686","path":"Sources/Sentry/SentrySysctl.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ff36fbd967725834366ae2130fb2bed0","path":"Sources/Sentry/include/SentrySystemEventBreadcrumbs.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a94a3a2ea869ff4381e8499b0d762c37","path":"Sources/Sentry/SentrySystemEventBreadcrumbs.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988821b58f8dee309405f48b809d77c389","path":"Sources/Sentry/include/SentrySystemWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98e7b36496f0059bd09052fe0723a1482e","path":"Sources/Sentry/SentrySystemWrapper.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9846f8b71aa0d77e70aad6baf2780ede40","path":"Sources/Sentry/Public/SentryThread.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98333f641f00d2f2094339ae17ba284755","path":"Sources/Sentry/SentryThread.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e98f983405d6088b8487df98cbf1cf08289","path":"Sources/Sentry/SentryThreadHandle.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9804a7db4ceccb6811912f7206f78e9447","path":"Sources/Sentry/include/SentryThreadHandle.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9862bc8fb6ad96b53f2b26b863c155ac1e","path":"Sources/Sentry/include/SentryThreadInspector.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9880761a78208e56b6a4707d7016a455cc","path":"Sources/Sentry/SentryThreadInspector.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e98bebf1492e8f631b28d50d71933f1372b","path":"Sources/Sentry/SentryThreadMetadataCache.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9826a9f2e728b361a2543a40d1abedebd0","path":"Sources/Sentry/include/SentryThreadMetadataCache.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e98706ef18fd5b8c82cc56ff430d4229cb2","path":"Sources/Sentry/include/SentryThreadState.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980755b34b65dc8dfadeb1feee1a3cf958","path":"Sources/Sentry/include/SentryThreadWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980b6ae641edc0eb8755e4ed4a2488c062","path":"Sources/Sentry/SentryThreadWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da71b3b7edd671b3d5c2cd01db469838","path":"Sources/Sentry/include/SentryTime.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e989800de374e76adb4a26b0bcddeecc2fd","path":"Sources/Sentry/SentryTime.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980cb9271b7f81c1a2d6b0f37452893839","path":"Sources/Sentry/include/SentryTimeToDisplayTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c7ad08268e0d328f29de3b10cd63ddc2","path":"Sources/Sentry/SentryTimeToDisplayTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982a75db7baa0413a63ae20e85adb1d22b","path":"Sources/Swift/Integrations/SessionReplay/SentryTouchTracker.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a2a1ef82a610bc27d79d0c54c67af7f0","path":"Sources/Sentry/Public/SentryTraceContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9887cc63e57392bb0beca2e79621425813","path":"Sources/Sentry/SentryTraceContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9892f3522efd5a3f19de8e39f523c688d6","path":"Sources/Sentry/Public/SentryTraceHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98df898a62101ed695fd27677ba8483559","path":"Sources/Sentry/SentryTraceHeader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c6f2a07c2d050533d09cf4c390de1f8c","path":"Sources/Sentry/include/SentryTraceOrigins.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b7d38aba29d59ffea50820df6a97c3d1","path":"Sources/Sentry/include/SentryTraceProfiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98d882e4234aaaf8e0069c816fdd59f579","path":"Sources/Sentry/Profiling/SentryTraceProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981398c4eb84cdf497790fb1fe607e2e63","path":"Sources/Sentry/include/SentryTracer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982bf9fe4d6f2326a7fccee18a75faa388","path":"Sources/Sentry/SentryTracer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98872ffad4b49352b651b261a73dad0060","path":"Sources/Sentry/include/SentryTracer+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9810cd37e42899add31fcc33f4bb739aac","path":"Sources/Sentry/include/SentryTracerConfiguration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986e709c30b562ffcb2b1eda4a0ad426d4","path":"Sources/Sentry/SentryTracerConfiguration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9866a291b84196da3f36541b841f97bd0e","path":"Sources/Sentry/include/SentryTransaction.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98221eaf92859927540a5d4291e050542e","path":"Sources/Sentry/SentryTransaction.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da808eab441d9e75217ff3d80a5e32a4","path":"Sources/Sentry/Public/SentryTransactionContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9841ff7a58c398688fb8514f02d4c41c49","path":"Sources/Sentry/SentryTransactionContext.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cb1d9988a5b4400c251fe18f3b1dd801","path":"Sources/Sentry/include/SentryTransactionContext+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9836194029038246f4ec8bf1410be089cc","path":"Sources/Swift/Integrations/Performance/SentryTransactionNameSource.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e2f9ec322d655c0ea0950183b52221d8","path":"Sources/Sentry/include/SentryTransport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d6a32bf0e617b57a20d2c33c46eb7914","path":"Sources/Sentry/include/SentryTransportAdapter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984be035c6e10d36e66be434b1c21a073b","path":"Sources/Sentry/SentryTransportAdapter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98954fa85d06420d5ec52380486fd5cb33","path":"Sources/Sentry/include/SentryTransportFactory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b676ca7f4497f4d1fe4138d1f5e18cf7","path":"Sources/Sentry/SentryTransportFactory.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986a376fd26c1eb7097e69832b6f02467c","path":"Sources/Sentry/include/SentryUIApplication.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ad8cadda5d19b22d7d5e391f3f2e5773","path":"Sources/Sentry/SentryUIApplication.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9894a25cc56626f69559a458a15ac02efd","path":"Sources/Sentry/include/SentryUIDeviceWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983ef575e6594a639f646ed7e15b622f05","path":"Sources/Sentry/SentryUIDeviceWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d16e83a31b866dde2772c9ce54293036","path":"Sources/Sentry/include/SentryUIEventTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c7648303d42528f7384cd7e96635cb91","path":"Sources/Sentry/SentryUIEventTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98727154a288bb63516c37f191b7f989fc","path":"Sources/Sentry/include/SentryUIEventTrackerMode.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ec06dab8b33576c79bb7f8e7c442836d","path":"Sources/Sentry/include/SentryUIEventTrackerTransactionMode.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e4854f9e24894360225810635de170d4","path":"Sources/Sentry/SentryUIEventTrackerTransactionMode.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98521d00d65862188e87f09af5fdd2b143","path":"Sources/Sentry/include/SentryUIEventTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c9a689aa4dad67a6b1ca280f2f6ebea4","path":"Sources/Sentry/SentryUIEventTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980e3a99429d2074eae3ed0f01f406b861","path":"Sources/Sentry/include/SentryUIViewControllerPerformanceTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9845042217e88518e3e855ac6610a6bd28","path":"Sources/Sentry/SentryUIViewControllerPerformanceTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9889e3af4a95fcd5340aa42b761c61304c","path":"Sources/Sentry/include/SentryUIViewControllerSwizzling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983176fb557a25d7b21f81988edd0f03bb","path":"Sources/Sentry/SentryUIViewControllerSwizzling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9893fe513507e3ed2aee8e215050e2e0d7","path":"Sources/Sentry/Public/SentryUser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981599944e2dac3b6e693aef6d13475bc3","path":"Sources/Sentry/SentryUser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98306548f7ff648626ef748253421486b8","path":"Sources/Sentry/include/HybridPublic/SentryUser+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983d67248f1449eda03bef4a9ab05fe6a4","path":"Sources/Sentry/Public/SentryUserFeedback.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aea6546754779f273d8124b565989345","path":"Sources/Sentry/SentryUserFeedback.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9821d0b241774df222e3b7377348f85f63","path":"Sources/Swift/Integrations/SessionReplay/SentryVideoInfo.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980bb1557164f81c9e1bfbb7f8363a7867","path":"Sources/Sentry/include/SentryViewHierarchy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98299ac7ba68df1b240a6b268ff9a86b76","path":"Sources/Sentry/SentryViewHierarchy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980afc7a0cee689b8418b92fe22b0c3615","path":"Sources/Sentry/include/SentryViewHierarchyIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b53e903d92672c16fb54d8ff0231cef9","path":"Sources/Sentry/SentryViewHierarchyIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98817181aab5ecfaf90b5b85e17c49dec8","path":"Sources/Swift/Tools/SentryViewPhotographer.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9889f837ea12a895bd661390ab3127aa6d","path":"Sources/Swift/Tools/SentryViewScreenshotProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98831b777b6c8ddefb76a396274e08cf65","path":"Sources/Sentry/include/SentryWatchdogTerminationLogic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98018b75d110252e6219e0ee545362ac37","path":"Sources/Sentry/SentryWatchdogTerminationLogic.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981b4d68ef1663be58c2c7d79a11afa4df","path":"Sources/Sentry/include/SentryWatchdogTerminationScopeObserver.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98364193a20b48ba275110ac3d6d5e9cda","path":"Sources/Sentry/SentryWatchdogTerminationScopeObserver.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fd01427898ca74956b565fd99d02dc1c","path":"Sources/Sentry/include/SentryWatchdogTerminationTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987e91816c36c7362ce51491a1a85d80cd","path":"Sources/Sentry/SentryWatchdogTerminationTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98780c1b9a14bc63cecf76b3990a59877b","path":"Sources/Sentry/include/SentryWatchdogTerminationTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98491b83da9ba737ee9f38229ad6e420d5","path":"Sources/Sentry/SentryWatchdogTerminationTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ed78e714a1400e941f04e4a22e3d5b2e","path":"Sources/Sentry/Public/SentryWithoutUIKit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981fe228c1e3eb9e98e34f508e02afeb4d","path":"Sources/Swift/Metrics/SetMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98fa7dc020d34d966016e6329a66dd8e9f","path":"Sources/Swift/Extensions/StringExtensions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d9c9b6d5fc746b0b6e694918a128dda3","path":"Sources/Swift/SwiftDescriptor.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98cb607a81a62d906f3b39c57798758ac5","path":"Sources/Swift/Tools/UIImageHelper.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988bedd1aa102f8e0c2b086358fe8c4265","path":"Sources/Swift/Tools/UIRedactBuilder.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98199af03d7bbe972473ba296e49e9e28f","path":"Sources/Sentry/include/UIViewController+Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c67c9dd02046243d9509ed2cd4798160","path":"Sources/Sentry/UIViewController+Sentry.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a9bdf6e3af1025f342b74ed83538e99f","path":"Sources/Swift/Extensions/UIViewExtensions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9847561447b26da1538d79830f9f25d534","path":"Sources/Swift/Tools/UrlSanitized.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e0f7059daf81ae00dbaea4f94fd0940e","path":"Sources/Swift/Tools/URLSessionTaskHelper.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98720140f27bb1633c64c1b1da36ff7150","path":"Sources/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984e8e4b6b4cdf0c8e787fc62391dd92fc","name":"Resources","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fde7c7fa201b545fa7346c2dbc65d01e","name":"HybridSDK","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98ef1bcc008e87a9a25e802f910bd3b338","path":"ResourceBundle-Sentry-Sentry-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98379c05280a321bf688329fe3aef3105b","path":"Sentry.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98743f2bd1841f8f09decb589fed2636c8","path":"Sentry-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e983b019ef6e78b06a8a837bc6d70936ca6","path":"Sentry-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a244d1195fc83689d6ce3ffc5e36f6e2","path":"Sentry-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983938971700c25e06de2a31eeb4ea8038","path":"Sentry-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98008eb440e134dedec2875572805c0b95","path":"Sentry.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9802292b204c9bad4ac7f8961219de2b07","path":"Sentry.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986fb9ef72a20518718c75331fd4883cdd","name":"Support Files","path":"../Target Support Files/Sentry","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982359f49a15cf54f5a5642b64cc3eb2e3","name":"Sentry","path":"Sentry","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d16ccbe7206fb0e2ba6426ac4ec38dd9","path":"SwiftyGif/NSImage+SwiftyGif.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9889573bb4021c2391fbff0a825ae4178b","path":"SwiftyGif/NSImageView+SwiftyGif.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9835fe91271911430f451a39e60aa1986c","path":"SwiftyGif/ObjcAssociatedWeakObject.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ee06371f21d2935a5ee20d4d5bb4e44f","path":"SwiftyGif/SwiftyGif.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e7350beaaae8ffbf946713feea0a4648","path":"SwiftyGif/SwiftyGifManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98595dbe4b870633814d56f32dc4618746","path":"SwiftyGif/UIImage+SwiftyGif.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d770d4ce570ae939fbded8f93e00ff8e","path":"SwiftyGif/UIImageView+SwiftyGif.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9875483e8f93a2b38f9b1fe471ba33c3a7","path":"SwiftyGif.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c6490049da5fff353f3196233e5aa86b","path":"SwiftyGif-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e988ad7a0fe1ae6306e9a821e4ed13fff44","path":"SwiftyGif-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981915be642f44f92bbb0c663859b70dfe","path":"SwiftyGif-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980626803cd8030a41b813ca8f28fffea0","path":"SwiftyGif-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ed5df08130fde981e5066c74824ca0ad","path":"SwiftyGif.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98be3854286ad572fdc485c3f4251ca09f","path":"SwiftyGif.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98810193ceb3c555979788ed2a5c372602","name":"Support Files","path":"../Target Support Files/SwiftyGif","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ae2061b06cbc4928ef7854c13f38740","name":"SwiftyGif","path":"SwiftyGif","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98632304d423ab2bf91c956eac7a76a9c7","path":"Toast-Framework/Toast.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9890a726412a53fe90ba2295a50dfd22a2","path":"Toast/UIView+Toast.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989752157b2cd16354797296f760ae1aef","path":"Toast/UIView+Toast.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989938462c628741b000a566d13b66d51d","path":"Toast.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e3d5685abb47d94d856240e992947bc4","path":"Toast-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e989038dc03edeabc512af644ec374031bf","path":"Toast-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985032ba3070912dc548c2a9e4e7a902a9","path":"Toast-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c66dfb77b319009b7b9b119ff175df41","path":"Toast-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9863fe2c666a46f95fbbe4c0f7414d7d8b","path":"Toast.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ca2bbfca057495e362d7fe67afdfd6b9","path":"Toast.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987533233d249f8595cd310d0355eed423","name":"Support Files","path":"../Target Support Files/Toast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984ea7b243f231f45db36c593b6182c199","name":"Toast","path":"Toast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988cfe4b62ae2e73f7f7be4602344a0f59","name":"Pods","path":"","sourceTree":"","type":"group"},{"guid":"bfdfe7dc352907fc980b868725387e9879aabb07c832697b70c102efdf5309db","name":"Products","path":"","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98bc9b33687cf974a825db433d5a4ce66f","path":"Pods-Runner.modulemap","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e987edd064bed57826d8d017ae149a3d52e","path":"Pods-Runner-acknowledgements.markdown","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987fb378bbcd6c5ed58c790a44f8de1ad8","path":"Pods-Runner-acknowledgements.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d314b0e0b0622c4b6805a741f2686226","path":"Pods-Runner-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.script.sh","guid":"bfdfe7dc352907fc980b868725387e98c868b1c7c8b6f1d4f98ebdaeb296a675","path":"Pods-Runner-frameworks.sh","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986fd78883b66d5d9077a96975628773ce","path":"Pods-Runner-Info.plist","sourceTree":"","type":"file"},{"fileType":"text.script.sh","guid":"bfdfe7dc352907fc980b868725387e983a42af4f91479a2ff6a94702f9452725","path":"Pods-Runner-resources.sh","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9847c36cf1fe2165ccb62332bdeff26348","path":"Pods-Runner-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e985f6ec3891a0be6525111807007bbcf76","path":"Pods-Runner.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e988db807c74690161e03dc575ee7464945","path":"Pods-Runner.profile.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98778a51cd43710d278531fd4c4e5ed80f","path":"Pods-Runner.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9863ac39dbb3c5e6697e1e483c96fdcf12","name":"Pods-Runner","path":"Target Support Files/Pods-Runner","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d87df373aa945c7cffc56572b5b5c1d0","name":"Targets Support Files","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98677e601b37074db53aff90e47c8f96d1","name":"Pods","path":"","sourceTree":"","type":"group"},"guid":"bfdfe7dc352907fc980b868725387e98","path":"/Users/morn/dev/samples/AppFlowy/frontend/appflowy_flutter/ios/Pods/Pods.xcodeproj","projectDirectory":"/Users/morn/dev/samples/AppFlowy/frontend/appflowy_flutter/ios/Pods","targets":["TARGET@v11_hash=8afac9aa7f2f7ae8a47d13626813e422","TARGET@v11_hash=dba02e4185326fadc962f9bcc306afff","TARGET@v11_hash=9ed764e6a36ac0463803ed04679a098b","TARGET@v11_hash=343abb3a1787caba848689df913b48cc","TARGET@v11_hash=18cc54ce823da0aaace1f19b54169b79","TARGET@v11_hash=455e18a547e744c268f0ab0be67b484f","TARGET@v11_hash=ce18edbb47580206399cf4783eec7ed5","TARGET@v11_hash=50777e38e58c4490a53094c0b174a83e","TARGET@v11_hash=be4bfd549192ab886b16634e611a5cfb","TARGET@v11_hash=bc8a844879af7a4b46ae41879f040474","TARGET@v11_hash=41f53d07703b6cdfcf27ef7c9560cf8c","TARGET@v11_hash=fe89e7ef4549c341d03d86fb3e6322bd","TARGET@v11_hash=8fb782e24ce265c815ff1b320853f917","TARGET@v11_hash=532913e38c7e06e5eea62089d1193ee4","TARGET@v11_hash=3c05d2ce5e305ec83a99f3301a5236aa","TARGET@v11_hash=a588ecdf5bfe2f1ad6c5d9a6bb9940f1","TARGET@v11_hash=c433bd69b99230b9785c1be714ce0175","TARGET@v11_hash=2438ab2bbd7e02a80b06e65e631e1ee9","TARGET@v11_hash=559c3084339e631943ea8fbb0ff14658","TARGET@v11_hash=78c419d7e36f388dac9ad87ec6534e43","TARGET@v11_hash=72545b20ee6d4e64d463a237167e469f","TARGET@v11_hash=7931e16ef4631bcfa5b05077cd140cef","TARGET@v11_hash=91393af516387dfbbafa2eb5029109fc","TARGET@v11_hash=c51f85455c2588dcd567c74a4396fcbf","TARGET@v11_hash=a1a63d5178cbcd2daae2e0cba9b032e5","TARGET@v11_hash=03dea4a492a969d9433ed28b4b2a0aec","TARGET@v11_hash=1dcf7cc21e4184e0f28a9789b4c382c9","TARGET@v11_hash=eaabb77f2569c0713fe5909f5362b3fa","TARGET@v11_hash=817de712cb6fac2be24baa7ec42aaf97","TARGET@v11_hash=fbd6377f91e5f0cc1620995c99b99ff0","TARGET@v11_hash=b5817aa8a8a5b233abd08d304efe013d","TARGET@v11_hash=86aab23948c9cd257baaff836f7414a1","TARGET@v11_hash=29ec1227ae80fa5e85545dce343417e5","TARGET@v11_hash=ab8b0fc009ec3b369e9ae605936ce603","TARGET@v11_hash=64039072b063670902e1ef354134e49d","TARGET@v11_hash=5449496bc380a949b05257eb8db9d316","TARGET@v11_hash=3cca9ca389e095b67ce3af588be9188d","TARGET@v11_hash=f78ac38fdf215d89c0281e470c44b101","TARGET@v11_hash=692a56120a7530dd608fbaa413d3d410","TARGET@v11_hash=bd3c446e66dacbac35fda866591052c9","TARGET@v11_hash=7d8a079c75bc93528df1276ff6c1a06e","TARGET@v11_hash=22014fcd8061c49ec1a7011011fa29d2","TARGET@v11_hash=0c4ac04efd08ba24acda74bf403c30fe","TARGET@v11_hash=6d6f324e26347bf163f3e2dcaa278075","TARGET@v11_hash=36e48dc34e49b20eaf26c3a4d1213a82","TARGET@v11_hash=583d53cd439ec89e8ee070a321e88f4e","TARGET@v11_hash=65477760b7bea77bb4f50c90f24afed5","TARGET@v11_hash=40f8368e8026f113aa896b4bd218efee","TARGET@v11_hash=78da11ebe0789216e22d2e6aaa220c0a"]} \ No newline at end of file diff --git a/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/workspace/WORKSPACE@v11_hash=(null)_subobjects=9b6915bad2214bcc5eb58b855fe7b55a-json b/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/workspace/WORKSPACE@v11_hash=(null)_subobjects=9b6915bad2214bcc5eb58b855fe7b55a-json new file mode 100644 index 0000000000..0d35e5fa9a --- /dev/null +++ b/frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/workspace/WORKSPACE@v11_hash=(null)_subobjects=9b6915bad2214bcc5eb58b855fe7b55a-json @@ -0,0 +1 @@ +{"guid":"dc4b70c03e8043e50e38f2068887b1d4","name":"Pods","path":"/Users/morn/dev/samples/AppFlowy/frontend/appflowy_flutter/ios/Pods/Pods.xcodeproj/project.xcworkspace","projects":["PROJECT@v11_mod=a7fbf46937053896f73cc7c7ec6baefb_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1"]} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/lib/main.dart b/frontend/appflowy_flutter/packages/appflowy_backend/example/lib/main.dart index 39759cfbff..a3fb8967a5 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/example/lib/main.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/lib/main.dart @@ -1,7 +1,8 @@ -import 'package:flutter/material.dart'; import 'dart:async'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + import 'package:appflowy_backend/appflowy_backend.dart'; void main() { @@ -36,21 +37,15 @@ class _MyAppState extends State { // 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; - }); + 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'), - ), + appBar: AppBar(title: const Text('Plugin example app')), + body: Center(child: Text('Running on: $_platformVersion\n')), ), ); } diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile index 8c77835677..012a925033 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.13' +platform :osx, '10.14' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.pbxproj index ac3debdf8e..b6c5559634 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.pbxproj +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -26,11 +26,7 @@ 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 */; }; - 33D1A10422148B71006C7A3E /* FlutterMacOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */; }; - 33D1A10522148B93006C7A3E /* FlutterMacOS.framework in Bundle Framework */ = {isa = PBXBuildFile; fileRef = 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; B7C2E82907836001B5A6F548 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 49D7864808B727FDFB82A4C2 /* Pods_Runner.framework */; }; - D73912F022F37F9E000D13A0 /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D73912EF22F37F9E000D13A0 /* App.framework */; }; - D73912F222F3801D000D13A0 /* App.framework in Bundle Framework */ = {isa = PBXBuildFile; fileRef = D73912EF22F37F9E000D13A0 /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -50,8 +46,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - D73912F222F3801D000D13A0 /* App.framework in Bundle Framework */, - 33D1A10522148B93006C7A3E /* FlutterMacOS.framework in Bundle Framework */, ); name = "Bundle Framework"; runOnlyForDeploymentPostprocessing = 0; @@ -71,7 +65,6 @@ 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 = ""; }; - 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FlutterMacOS.framework; path = Flutter/ephemeral/FlutterMacOS.framework; sourceTree = SOURCE_ROOT; }; 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 = ""; }; @@ -80,7 +73,6 @@ 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 = ""; }; A82DF8E6F43DF0AD4D0653DC /* 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 = ""; }; - D73912EF22F37F9E000D13A0 /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/ephemeral/App.framework; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -88,8 +80,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D73912F022F37F9E000D13A0 /* App.framework in Frameworks */, - 33D1A10422148B71006C7A3E /* FlutterMacOS.framework in Frameworks */, B7C2E82907836001B5A6F548 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -145,8 +135,6 @@ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - D73912EF22F37F9E000D13A0 /* App.framework */, - 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */, ); path = Flutter; sourceTree = ""; @@ -215,7 +203,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 0930; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { @@ -268,6 +256,7 @@ /* Begin PBXShellScriptBuildPhase section */ 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -281,7 +270,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename\n"; + 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; @@ -414,7 +403,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -497,7 +486,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -544,7 +533,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3d8205cf56..758981e665 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart index 9c1a1de27c..f69fd16927 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart @@ -1,15 +1,18 @@ -export 'package:async/async.dart'; import 'dart:async'; import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/rust_stream.dart'; +import 'package:ffi/ffi.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'dart:ffi'; + import 'ffi.dart' as ffi; -import 'package:ffi/ffi.dart'; -import 'dart:isolate'; -import 'dart:io'; -import 'package:logger/logger.dart'; + +export 'package:async/async.dart'; enum ExceptionType { AppearanceSettingsIsEmpty, @@ -59,27 +62,15 @@ class RustLogStreamReceiver { 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 - printTime: false, // 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); + Log.info(decodedString); }); } diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart index 21ace45b28..12fdd60ccf 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart @@ -16,14 +16,14 @@ 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'; @@ -33,9 +33,10 @@ 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, @@ -71,7 +72,9 @@ Future> _extractPayload( case FFIStatusCode.Ok: return FlowySuccess(Uint8List.fromList(response.payload)); case FFIStatusCode.Err: - return FlowyFailure(Uint8List.fromList(response.payload)); + 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"); diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart index 4019f6723f..639945f102 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart @@ -1,4 +1,8 @@ +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; @@ -20,15 +24,13 @@ class FlowyInternalError { return "$_statusCode: $_error"; } - FlowyInternalError( - {required FFIStatusCode statusCode, required String 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 { @@ -48,3 +50,70 @@ class StackTraceError { 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/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart index 91c4149f64..a1eb8947df 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/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; diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart index dc79e655d1..ce0a4e2248 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart @@ -1,48 +1,85 @@ // 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 'package:talker/talker.dart'; import 'ffi.dart'; class Log { static final shared = Log(); - // ignore: unused_field - late Logger _logger; + + late Talker _logger; + + bool enableFlutterLog = true; + + // 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 - printTime: false, // Should each log print contain a timestamp - ), - level: kDebugMode ? Level.trace : Level.info, + _logger = Talker( + filter: LogLevelTalkerFilter(), ); } + // Generic internal logging function to reduce code duplication + static void _log( + LogLevel level, + int rustLevel, + dynamic msg, [ + dynamic error, + StackTrace? stackTrace, + ]) { + // only forward logs to flutter in debug mode, otherwise log to rust to + // persist logs in the file system + if (shared.enableFlutterLog && kDebugMode) { + shared._logger.log(msg, logLevel: level, stackTrace: stackTrace); + } else { + String formattedMessage = _formatMessageWithStackTrace(msg, stackTrace); + rust_log(rustLevel, toNativeUtf8(formattedMessage)); + } + } + static void info(dynamic msg, [dynamic error, StackTrace? stackTrace]) { - rust_log(0, toNativeUtf8(msg)); + if (shared.disableLog) { + return; + } + + _log(LogLevel.info, 0, msg, error, stackTrace); } static void debug(dynamic msg, [dynamic error, StackTrace? stackTrace]) { - rust_log(1, toNativeUtf8(msg)); + if (shared.disableLog) { + return; + } + + _log(LogLevel.debug, 1, msg, error, stackTrace); } static void warn(dynamic msg, [dynamic error, StackTrace? stackTrace]) { - rust_log(3, toNativeUtf8(msg)); + if (shared.disableLog) { + return; + } + + _log(LogLevel.warning, 3, msg, error, stackTrace); } static void trace(dynamic msg, [dynamic error, StackTrace? stackTrace]) { - rust_log(2, toNativeUtf8(msg)); + if (shared.disableLog) { + return; + } + + _log(LogLevel.verbose, 2, msg, error, stackTrace); } static void error(dynamic msg, [dynamic error, StackTrace? stackTrace]) { - rust_log(4, toNativeUtf8(msg)); + if (shared.disableLog) { + return; + } + + _log(LogLevel.error, 4, msg, error, stackTrace); } } @@ -50,6 +87,22 @@ 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(); +} + +class LogLevelTalkerFilter implements TalkerFilter { + @override + bool filter(TalkerData data) { + // filter out the debug logs in release mode + return kDebugMode ? true : data.logLevel != LogLevel.debug; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml index c014da75df..18aea4838b 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml @@ -14,20 +14,16 @@ dependencies: ffi: ^2.0.2 isolates: ^3.0.3+8 protobuf: ^3.1.0 - freezed_annotation: - logger: ^2.0.0 + talker: ^4.7.1 plugin_platform_interface: ^2.1.3 - json_annotation: ^4.7.0 appflowy_result: path: ../appflowy_result + fixnum: ^1.1.0 + async: ^2.11.0 dev_dependencies: flutter_test: sdk: flutter - build_runner: - freezed: - flutter_lints: ^3.0.1 - json_serializable: ^6.6.2 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_popover/analysis_options.yaml index a5744c1cfb..75b15a0f70 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/analysis_options.yaml +++ b/frontend/appflowy_flutter/packages/appflowy_popover/analysis_options.yaml @@ -1,4 +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/appflowy_flutter/packages/appflowy_popover/example/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_popover/example/analysis_options.yaml index 61b6c4de17..75b15a0f70 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/example/analysis_options.yaml +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/analysis_options.yaml @@ -7,8 +7,14 @@ # 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` @@ -22,8 +28,33 @@ linter: # `// 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 + - 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/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/AppFrameworkInfo.plist b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/AppFrameworkInfo.plist index 8d4492f977..7c56964006 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/AppFrameworkInfo.plist +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 12.0 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 index 6edd238e7c..2d5f6c6b7c 100644 --- 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 @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -127,7 +127,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -171,10 +171,12 @@ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -185,6 +187,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -272,7 +275,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -349,7 +352,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -398,7 +401,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; 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 index c87d15a335..5e31d3d342 100644 --- 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 @@ -1,6 +1,6 @@ CADisableMinimumFrameDurationOnPhone + UIApplicationSupportsIndirectInputEvents + 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 index fa9d3fe9aa..12bfd87ac3 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; class PopoverMenu extends StatefulWidget { const PopoverMenu({super.key}); @@ -14,43 +14,32 @@ class _PopoverMenuState extends State { @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: [ + 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.withValues(alpha: 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"), + child: const Text( + 'Popover', + style: TextStyle( + fontSize: 14, + color: Colors.black, + ), ), ), Popover( @@ -58,38 +47,62 @@ class _PopoverMenuState extends State { 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("Second"), + 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 { - final String label; - final Offset? offset; - final PopoverDirection? direction; - const ExampleButton({ super.key, required this.label, - this.direction, + 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 ?? PopoverDirection.rightWithTopAligned, - child: TextButton(child: Text(label), onPressed: () {}), + 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 index f4e017aa8f..80c4dc6f72 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/main.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/main.dart @@ -1,6 +1,7 @@ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; -import "./example_button.dart"; + +import './example_button.dart'; void main() { runApp(const MyApp()); @@ -9,21 +10,11 @@ void main() { class MyApp extends StatelessWidget { const MyApp({super.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'), @@ -34,15 +25,6 @@ class MyApp extends StatelessWidget { class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); - // 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 @@ -52,79 +34,82 @@ class MyHomePage extends StatefulWidget { 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: const Row( - children: [ - Column(children: [ - ExampleButton( - label: "Left top", - offset: Offset(0, 10), - direction: PopoverDirection.bottomWithLeftAligned, - ), - Expanded(child: SizedBox.shrink()), - ExampleButton( - label: "Left bottom", - offset: Offset(0, -10), - direction: PopoverDirection.topWithLeftAligned, - ), - ]), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + 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: "Top", + 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, ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ExampleButton( - label: "Central", - 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", + label: 'Bottom', offset: Offset(0, -10), direction: PopoverDirection.topWithCenterAligned, ), ], ), - ), - Column( - children: [ - ExampleButton( - label: "Right top", - offset: Offset(0, 10), - direction: PopoverDirection.bottomWithRightAligned, - ), - Expanded(child: SizedBox.shrink()), - ExampleButton( - label: "Right bottom", - offset: Offset(0, -10), - direction: PopoverDirection.topWithRightAligned, - ), - ], - ) - ], + 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/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.pbxproj index c84862c675..d9ae3f484a 100644 --- 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 @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -182,7 +182,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { @@ -235,6 +235,7 @@ /* Begin PBXShellScriptBuildPhase section */ 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -344,7 +345,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -423,7 +424,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -470,7 +471,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; 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 index fb7259e177..5b055a3a37 100644 --- 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 @@ -1,6 +1,6 @@ =2.17.0 <3.0.0" + 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 diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/appflowy_popover.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/appflowy_popover.dart index 925b9cad02..0b4208e6fc 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/appflowy_popover.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/appflowy_popover.dart @@ -1,5 +1,5 @@ /// AppFlowyBoard library -library appflowy_popover; +library; export 'src/mutex.dart'; export 'src/popover.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/follower.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/follower.dart index ff54eaac61..1a61851a71 100644 --- a/frontend/appflowy_flutter/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 index 85a6e326e2..f297e901c8 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/layout.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/layout.dart @@ -1,21 +1,34 @@ 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; - final EdgeInsets windowPadding; - 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) { @@ -52,262 +65,180 @@ class PopoverLayoutDelegate extends SingleChildLayoutDelegate { maxHeight: constraints.maxHeight - windowPadding.top - windowPadding.bottom, ); - // 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) { + final effectiveOffset = link.leaderOffset; + final leaderSize = link.leaderSize; + + if (effectiveOffset == null || 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(); + 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)), + 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)), + 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 { - final PopoverLink link; const PopoverTarget({ super.key, super.child, required this.link, }); + final PopoverLink link; + @override PopoverTargetRenderBox createRenderObject(BuildContext context) { return PopoverTargetRenderBox( @@ -317,14 +248,20 @@ class PopoverTarget extends SingleChildRenderObjectWidget { @override void updateRenderObject( - BuildContext context, PopoverTargetRenderBox renderObject) { + BuildContext context, + PopoverTargetRenderBox renderObject, + ) { renderObject.link = link; } } class PopoverTargetRenderBox extends RenderProxyBox { + PopoverTargetRenderBox({ + required this.link, + RenderBox? child, + }) : super(child); + PopoverLink link; - PopoverTargetRenderBox({required this.link, RenderBox? child}) : super(child); @override bool get alwaysNeedsCompositing => true; diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart index f68fe95445..8e740cb6d2 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart @@ -3,74 +3,93 @@ import 'dart:collection'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; -typedef EntryMap = LinkedHashMap; +typedef _EntryMap = LinkedHashMap; class RootOverlayEntry { - final EntryMap _entries = EntryMap(); - 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(entry, newState, asBarrier); + _entries[newState] = OverlayEntryContext( + id, + entry, + newState, + asBarrier, + animationController, + ); 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); + void removeEntry(PopoverState state) { + final removedEntry = _entries.remove(state); 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; + OverlayEntryContext? popEntry() { + if (isEmpty) { + return null; + } final lastEntry = _entries.values.last; _entries.remove(lastEntry.popoverState); - lastEntry.overlayEntry.remove(); - lastEntry.popoverState.widget.onClose?.call(); + lastEntry.animationController.reverse().then((_) { + lastEntry.overlayEntry.remove(); + lastEntry.popoverState.widget.onClose?.call(); + }); - if (lastEntry.asBarrier) { - return lastEntry.popoverState; - } else { - return popEntry(); + return lastEntry.asBarrier ? lastEntry : popEntry(); + } + + bool isLastEntryAsBarrier() { + if (isEmpty) { + return false; } + + return _entries.values.last.asBarrier; } } class OverlayEntryContext { - final bool asBarrier; - final PopoverState popoverState; - final OverlayEntry overlayEntry; - 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 { - final void Function() onTap; - final Decoration? decoration; + const PopoverMask({ + super.key, + required this.onTap, + this.decoration, + }); - const PopoverMask({super.key, required this.onTap, this.decoration}); + final VoidCallback onTap; + final Decoration? decoration; @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mutex.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mutex.dart index 8ab88e98e1..be20803eba 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mutex.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mutex.dart @@ -5,22 +5,18 @@ import 'popover.dart'; /// If multiple popovers are exclusive, /// pass the same mutex to them. class PopoverMutex { - final _PopoverStateNotifier _stateNotifier = _PopoverStateNotifier(); PopoverMutex(); + final _PopoverStateNotifier _stateNotifier = _PopoverStateNotifier(); + + void addPopoverListener(VoidCallback listener) { + _stateNotifier.addListener(listener); + } + void removePopoverListener(VoidCallback listener) { _stateNotifier.removeListener(listener); } - VoidCallback listenOnPopoverChanged(VoidCallback callback) { - listenerCallback() { - callback(); - } - - _stateNotifier.addListener(listenerCallback); - return listenerCallback; - } - void close() => _stateNotifier.state?.close(); PopoverState? get state => _stateNotifier.state; diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart index d9420944fb..7af2bdae48 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart @@ -1,27 +1,24 @@ +import 'package:appflowy_popover/src/layout.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:appflowy_popover/src/layout.dart'; - import 'mask.dart'; import 'mutex.dart'; class PopoverController { PopoverState? _state; - void close() { - _state?.close(); - } - - void show() { - _state?.showOverlay(); - } + 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 { @@ -55,6 +52,35 @@ enum PopoverClickHandler { } 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 @@ -94,115 +120,82 @@ class Popover extends StatefulWidget { 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; - const Popover({ - super.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.windowPadding, - this.onOpen, - this.onClose, - this.canClose, - this.asBarrier = false, - this.clickHandler = PopoverClickHandler.listener, - this.skipTraversal = false, - }); - @override State createState() => PopoverState(); } -class PopoverState extends State { - static final RootOverlayEntry _rootEntry = RootOverlayEntry(); +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; - } - - 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: () async { - if (!(await widget.canClose?.call() ?? true)) { - return; - } - _removeRootOverlay(); - }, - ), - ); - } - - children.add( - PopoverContainer( - direction: widget.direction, - popoverLink: popoverLink, - offset: widget.offset ?? Offset.zero, - windowPadding: widget.windowPadding ?? EdgeInsets.zero, - popupBuilder: widget.popupBuilder, - onClose: close, - onCloseAll: _removeRootOverlay, - skipTraversal: widget.skipTraversal, - ), - ); - - return CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.escape): _removeRootOverlay, - }, - child: FocusScope(child: Stack(children: children)), - ); - }); - _rootEntry.addEntry(context, this, newEntry, widget.asBarrier); - } - - void close({ - bool notify = true, - }) { - if (_rootEntry.contains(this)) { - _rootEntry.removeEntry(this); - if (notify) { - widget.onClose?.call(); - } - } - } - - void _removeRootOverlay() { - _rootEntry.popEntry(); - - if (widget.mutex?.state == this) { - widget.mutex?.removeState(); - } + _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( @@ -211,75 +204,324 @@ class PopoverState extends State { ); } - Widget _buildChild(BuildContext context) { - if (widget.triggerActions == 0) { - return widget.child; + @override + void reassemble() { + // clear the overlay + while (rootEntry.isNotEmpty) { + rootEntry.popEntry(); } - return MouseRegion( - onEnter: (event) { - if (widget.triggerActions & PopoverTriggerFlags.hover != 0) { - showOverlay(); - } - }, - child: _buildClickHandler( - widget.child, - () { - widget.onOpen?.call(); - if (widget.triggerActions & PopoverTriggerFlags.click != 0) { - showOverlay(); - } - }, - ), + 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) { - switch (widget.clickHandler) { - case PopoverClickHandler.listener: - return Listener( - onPointerDown: (_) => _callHandler(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, - ); - case PopoverClickHandler.gestureDetector: - return GestureDetector( - onTap: () => _callHandler(handler), + ), + 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)) { + 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 { - final Widget? Function(BuildContext context) popupBuilder; - final PopoverDirection direction; - final PopoverLink popoverLink; - final Offset offset; - final EdgeInsets windowPadding; - final void Function() onClose; - final void Function() onCloseAll; - final bool skipTraversal; - const PopoverContainer({ super.key, required this.popupBuilder, - required this.direction, - required this.popoverLink, - required this.offset, - required this.windowPadding, + 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(); @@ -287,9 +529,14 @@ class PopoverContainer extends StatefulWidget { if (context is StatefulElement && context.state is PopoverContainerState) { return context.state as PopoverContainerState; } - final PopoverContainerState? result = - context.findAncestorStateOfType(); - return result!; + return context.findAncestorStateOfType()!; + } + + static PopoverContainerState? maybeOf(BuildContext context) { + if (context is StatefulElement && context.state is PopoverContainerState) { + return context.state as PopoverContainerState; + } + return context.findAncestorStateOfType(); } } @@ -300,12 +547,7 @@ class PopoverContainerState extends State { autofocus: true, skipTraversal: widget.skipTraversal, child: CustomSingleChildLayout( - delegate: PopoverLayoutDelegate( - direction: widget.direction, - link: widget.popoverLink, - offset: widget.offset, - windowPadding: widget.windowPadding, - ), + delegate: widget.delegate, child: widget.popupBuilder(context), ), ); diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_popover/pubspec.yaml index 9dd11b27ac..5d6e335621 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/appflowy_popover/pubspec.yaml @@ -4,8 +4,8 @@ version: 0.0.1 homepage: https://appflowy.io environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.1" + flutter: ">=3.22.0" + sdk: ">=3.3.0 <4.0.0" dependencies: flutter: @@ -14,41 +14,4 @@ dependencies: dev_dependencies: flutter_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 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 + flutter_lints: ^4.0.0 diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart index 97b81cfe1a..d91c9e4954 100644 --- a/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart @@ -1,4 +1,5 @@ -library appflowy_result; +/// AppFlowyPopover library +library; export 'src/async_result.dart'; export 'src/result.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart index eca6726b9e..e8d3be8d90 100644 --- a/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart @@ -15,8 +15,8 @@ abstract class FlowyResult { S? toNullable(); - void onSuccess(void Function(S s) onSuccess); - void onFailure(void Function(F f) onFailure); + T? onSuccess(T? Function(S s) onSuccess); + T? onFailure(T? Function(F f) onFailure); S getOrElse(S Function(F failure) onFailure); S getOrThrow(); @@ -70,12 +70,14 @@ class FlowySuccess implements FlowyResult { } @override - void onSuccess(void Function(S success) onSuccess) { - onSuccess(_value); + T? onSuccess(T? Function(S success) onSuccess) { + return onSuccess(_value); } @override - void onFailure(void Function(F failure) onFailure) {} + T? onFailure(T? Function(F failure) onFailure) { + return null; + } @override S getOrElse(S Function(F failure) onFailure) { @@ -139,11 +141,13 @@ class FlowyFailure implements FlowyResult { } @override - void onSuccess(void Function(S success) onSuccess) {} + T? onSuccess(T? Function(S success) onSuccess) { + return null; + } @override - void onFailure(void Function(F failure) onFailure) { - onFailure(_value); + T? onFailure(T? Function(F failure) onFailure) { + return onFailure(_value); } @override diff --git a/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml index 241f437d9b..5d8f0d88c2 100644 --- a/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml @@ -1,54 +1,11 @@ name: appflowy_result description: "A new Flutter package project." version: 0.0.1 -homepage: +homepage: https://github.com/appflowy-io/appflowy environment: - sdk: '>=3.3.0 <4.0.0' + sdk: ">=3.3.0 <4.0.0" flutter: ">=1.17.0" -dependencies: - flutter: - sdk: flutter - dev_dependencies: - flutter_test: - sdk: flutter flutter_lints: ^3.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# 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/appflowy_flutter/packages/appflowy_ui/.gitignore b/frontend/appflowy_flutter/packages/appflowy_ui/.gitignore new file mode 100644 index 0000000000..da0bb7ce97 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/.metadata b/frontend/appflowy_flutter/packages/appflowy_ui/.metadata new file mode 100644 index 0000000000..79932b61d5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/.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: "d8a9f9a52e5af486f80d932e838ee93861ffd863" + channel: "[user-branch]" + +project_type: package diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/CHANGELOG.md b/frontend/appflowy_flutter/packages/appflowy_ui/CHANGELOG.md new file mode 100644 index 0000000000..41cc7d8192 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/LICENSE b/frontend/appflowy_flutter/packages/appflowy_ui/LICENSE new file mode 100644 index 0000000000..ba75c69f7f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/README.md b/frontend/appflowy_flutter/packages/appflowy_ui/README.md new file mode 100644 index 0000000000..953d3545f1 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/README.md @@ -0,0 +1,39 @@ +# AppFlowy UI + +AppFlowy UI is a Flutter package that provides a collection of reusable UI components following the AppFlowy design system. These components are designed to be consistent, accessible, and easy to use. + +## Features + +- **Design System Components**: Buttons, text fields, and more UI components that follow the AppFlowy design system +- **Theming**: Consistent theming across all components with light and dark mode support + +## Installation + +Add the following to your `pubspec.yaml` file: + +```yaml +dependencies: + appflowy_ui: ^1.0.0 +``` + +## Supported components + +- [x] Button +- [x] TextField +- [ ] Avatar +- [ ] Checkbox +- [ ] Grid +- [ ] Link +- [ ] Loading & Progress Indicator +- [ ] Menu +- [ ] Message Box +- [ ] Navigation Bar +- [ ] Popover +- [ ] Scroll Bar +- [ ] Tab Bar +- [ ] Toggle +- [ ] Tooltip + +## Reference + +Figma: https://www.figma.com/design/aphWa2OgkqyIragpatdk7a/Design-System diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/analysis_options.yaml new file mode 100644 index 0000000000..abba19b4fe --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/analysis_options.yaml @@ -0,0 +1,29 @@ +include: package:flutter_lints/flutter.yaml + +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/packages/appflowy_ui/example/.gitignore b/frontend/appflowy_flutter/packages/appflowy_ui/example/.gitignore new file mode 100644 index 0000000000..79c113f9b5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +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 +.pub-cache/ +.pub/ +/build/ + +# 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/appflowy_flutter/packages/appflowy_ui/example/.metadata b/frontend/appflowy_flutter/packages/appflowy_ui/example/.metadata new file mode 100644 index 0000000000..777c932a64 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/.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 and should not be manually edited. + +version: + revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + - platform: macos + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + + # 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/packages/appflowy_ui/example/README.md b/frontend/appflowy_flutter/packages/appflowy_ui/example/README.md new file mode 100644 index 0000000000..2ccc9e658d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/README.md @@ -0,0 +1,41 @@ +# AppFlowy UI Example + +This example demonstrates how to use the `appflowy_ui` package in a Flutter application. + +## Getting Started + +To run this example: + +1. Ensure you have Flutter installed and set up on your machine +2. Clone this repository +3. Navigate to the example directory: + ```bash + cd example + ``` +4. Get the dependencies: + ```bash + flutter pub get + ``` +5. Run the example: + ```bash + flutter run + ``` + +## Features Demonstrated + +- Basic app structure using AppFlowy UI components +- Material 3 design integration +- Responsive layout + +## Project Structure + +- `lib/main.dart`: The main application file +- `pubspec.yaml`: Project dependencies and configuration + +## Additional Resources + +For more information about the AppFlowy UI package, please refer to: + +- The main package documentation +- [AppFlowy Website](https://appflowy.io) +- [AppFlowy GitHub Repository](https://github.com/AppFlowy-IO/AppFlowy) diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/example/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart new file mode 100644 index 0000000000..0d23746ebd --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart @@ -0,0 +1,117 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +import 'src/buttons/buttons_page.dart'; +import 'src/modal/modal_page.dart'; +import 'src/textfield/textfield_page.dart'; + +enum ThemeMode { + light, + dark, +} + +final themeMode = ValueNotifier(ThemeMode.light); + +void main() { + runApp( + const MyApp(), + ); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: themeMode, + builder: (context, themeMode, child) { + final themeBuilder = AppFlowyDefaultTheme(); + final themeData = + themeMode == ThemeMode.light ? ThemeData.light() : ThemeData.dark(); + + return AnimatedAppFlowyTheme( + data: themeMode == ThemeMode.light + ? themeBuilder.light() + : themeBuilder.dark(), + child: MaterialApp( + debugShowCheckedModeBanner: false, + title: 'AppFlowy UI Example', + theme: themeData.copyWith( + visualDensity: VisualDensity.standard, + ), + home: const MyHomePage( + title: 'AppFlowy UI', + ), + ), + ); + }, + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({ + super.key, + required this.title, + }); + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final tabs = [ + Tab(text: 'Button'), + Tab(text: 'TextField'), + Tab(text: 'Modal'), + ]; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text( + widget.title, + style: theme.textStyle.title.enhanced( + color: theme.textColorScheme.primary, + ), + ), + actions: [ + IconButton( + icon: Icon( + Theme.of(context).brightness == Brightness.light + ? Icons.dark_mode + : Icons.light_mode, + ), + onPressed: _toggleTheme, + tooltip: 'Toggle theme', + ), + ], + ), + body: TabBarView( + children: [ + ButtonsPage(), + TextFieldPage(), + ModalPage(), + ], + ), + bottomNavigationBar: TabBar( + tabs: tabs, + ), + floatingActionButton: null, + ), + ); + } + + void _toggleTheme() { + themeMode.value = + themeMode.value == ThemeMode.light ? ThemeMode.dark : ThemeMode.light; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart new file mode 100644 index 0000000000..0d0c018222 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart @@ -0,0 +1,287 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class ButtonsPage extends StatelessWidget { + const ButtonsPage({super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSection( + 'Filled Text Buttons', + [ + AFFilledTextButton.primary( + text: 'Primary Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFFilledTextButton.destructive( + text: 'Destructive Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFFilledTextButton.disabled( + text: 'Disabled Button', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Filled Icon Text Buttons', + [ + AFFilledButton.primary( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + const SizedBox(width: 8), + Text( + 'Primary Button', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFFilledButton.destructive( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.delete, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + const SizedBox(width: 8), + Text( + 'Destructive Button', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFFilledButton.disabled( + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.block, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + const SizedBox(width: 8), + Text( + 'Disabled Button', + style: TextStyle( + color: + AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Outlined Text Buttons', + [ + AFOutlinedTextButton.normal( + text: 'Normal Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFOutlinedTextButton.destructive( + text: 'Destructive Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFOutlinedTextButton.disabled( + text: 'Disabled Button', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Outlined Icon Text Buttons', + [ + AFOutlinedButton.normal( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Normal Button', + style: TextStyle( + color: + AppFlowyTheme.of(context).textColorScheme.primary, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFOutlinedButton.destructive( + onTap: () {}, + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.delete, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.error, + ), + const SizedBox(width: 8), + Text( + 'Destructive Button', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.error, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + AFOutlinedButton.disabled( + builder: (context, isHovering, disabled) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.block, + size: 20, + color: AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + const SizedBox(width: 8), + Text( + 'Disabled Button', + style: TextStyle( + color: + AppFlowyTheme.of(context).textColorScheme.tertiary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Ghost Buttons', + [ + AFGhostTextButton.primary( + text: 'Primary Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFGhostTextButton.disabled( + text: 'Disabled Button', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Button with alignment', + [ + SizedBox( + width: 200, + child: AFFilledTextButton.primary( + text: 'Left Button', + onTap: () {}, + alignment: Alignment.centerLeft, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 200, + child: AFFilledTextButton.primary( + text: 'Center Button', + onTap: () {}, + alignment: Alignment.center, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 200, + child: AFFilledTextButton.primary( + text: 'Right Button', + onTap: () {}, + alignment: Alignment.centerRight, + ), + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Button Sizes', + [ + AFFilledTextButton.primary( + text: 'Small Button', + onTap: () {}, + size: AFButtonSize.s, + ), + const SizedBox(width: 16), + AFFilledTextButton.primary( + text: 'Medium Button', + onTap: () {}, + ), + const SizedBox(width: 16), + AFFilledTextButton.primary( + text: 'Large Button', + onTap: () {}, + size: AFButtonSize.l, + ), + const SizedBox(width: 16), + AFFilledTextButton.primary( + text: 'Extra Large Button', + onTap: () {}, + size: AFButtonSize.xl, + ), + ], + ), + ], + ), + ); + } + + Widget _buildSection(String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: children, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/modal/modal_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/modal/modal_page.dart new file mode 100644 index 0000000000..4a9480d1b9 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/modal/modal_page.dart @@ -0,0 +1,153 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class ModalPage extends StatefulWidget { + const ModalPage({super.key}); + + @override + State createState() => _ModalPageState(); +} + +class _ModalPageState extends State { + double width = AFModalDimension.M; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Center( + child: Container( + constraints: BoxConstraints(maxWidth: 600), + padding: EdgeInsets.symmetric(horizontal: theme.spacing.xl), + child: Column( + spacing: theme.spacing.l, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + spacing: theme.spacing.m, + mainAxisSize: MainAxisSize.min, + children: [ + AFGhostButton.normal( + onTap: () => setState(() => width = AFModalDimension.S), + builder: (context, isHovering, disabled) { + return Text( + 'S', + style: TextStyle( + color: width == AFModalDimension.S + ? theme.textColorScheme.theme + : theme.textColorScheme.primary, + ), + ); + }, + ), + AFGhostButton.normal( + onTap: () => setState(() => width = AFModalDimension.M), + builder: (context, isHovering, disabled) { + return Text( + 'M', + style: TextStyle( + color: width == AFModalDimension.M + ? theme.textColorScheme.theme + : theme.textColorScheme.primary, + ), + ); + }, + ), + AFGhostButton.normal( + onTap: () => setState(() => width = AFModalDimension.L), + builder: (context, isHovering, disabled) { + return Text( + 'L', + style: TextStyle( + color: width == AFModalDimension.L + ? theme.textColorScheme.theme + : theme.textColorScheme.primary, + ), + ); + }, + ), + ], + ), + AFFilledButton.primary( + builder: (context, isHovering, disabled) { + return Text( + 'Show Modal', + style: TextStyle( + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + ); + }, + onTap: () { + showDialog( + context: context, + barrierColor: theme.surfaceColorScheme.overlay, + builder: (context) { + final theme = AppFlowyTheme.of(context); + + return Center( + child: AFModal( + constraints: BoxConstraints( + maxWidth: width, + maxHeight: AFModalDimension.dialogHeight, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AFModalHeader( + leading: Text( + 'Header', + style: theme.textStyle.heading4.standard( + color: theme.textColorScheme.primary, + ), + ), + trailing: [ + AFGhostButton.normal( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return const Icon(Icons.close); + }, + ) + ], + ), + Expanded( + child: AFModalBody( + child: Text( + 'A dialog briefly presents information or requests confirmation, allowing users to continue their workflow after interaction.'), + ), + ), + AFModalFooter( + trailing: [ + AFOutlinedButton.normal( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return const Text('Cancel'); + }, + ), + AFFilledButton.primary( + onTap: () => Navigator.of(context).pop(), + builder: (context, isHovering, disabled) { + return Text( + 'Apply', + style: TextStyle( + color: AppFlowyTheme.of(context) + .textColorScheme + .onFill, + ), + ); + }, + ), + ], + ) + ], + )), + ); + }, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart new file mode 100644 index 0000000000..9e3436ecd4 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart @@ -0,0 +1,90 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class TextFieldPage extends StatelessWidget { + const TextFieldPage({super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSection( + 'TextField Sizes', + [ + AFTextField( + hintText: 'Please enter your name', + size: AFTextFieldSize.m, + ), + AFTextField( + hintText: 'Please enter your name', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'TextField with hint text', + [ + AFTextField( + hintText: 'Please enter your name', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'TextField with initial text', + [ + AFTextField( + initialText: 'https://appflowy.com', + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'TextField with validator ', + [ + AFTextField( + validator: (controller) { + if (controller.text.isEmpty) { + return (true, 'This field is required'); + } + + final emailRegex = + RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); + if (!emailRegex.hasMatch(controller.text)) { + return (true, 'Please enter a valid email address'); + } + + return (false, ''); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildSection(String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: children, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/.gitignore b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..c2efd0b608 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..c2efd0b608 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..345181d730 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*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 */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 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 */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 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 */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 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_ui_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "appflowy_ui_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 */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 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 */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* appflowy_ui_example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + 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 */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 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 /* appflowy_ui_example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 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 */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 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 */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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 */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 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/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..04d5b736e6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/AppDelegate.swift b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..b3c1761412 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "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/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000..82b6f9d9a3 Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000..13b35eba55 Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000..0a3f5fa40f Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000..bdb57226d5 Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000000..f083318e09 Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000..326c0e72c9 Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000..2f1632cfdd Binary files /dev/null and b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Base.lproj/MainMenu.xib b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..80e867a4e0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/AppInfo.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..47821fa6d8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/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_ui_example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Warnings.xcconfig b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +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/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/DebugProfile.entitlements b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..dddb8a30c8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Info.plist b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + 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/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/MainFlutterWindow.swift b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..3cc05eb234 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Release.entitlements b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/RunnerTests/RunnerTests.swift b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..61f3bd1fc5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/example/pubspec.yaml new file mode 100644 index 0000000000..af361ecfab --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/pubspec.yaml @@ -0,0 +1,24 @@ +name: appflowy_ui_example +description: "Example app showcasing AppFlowy UI components and widgets" +publish_to: "none" + +version: 1.0.0+1 + +environment: + flutter: ">=3.27.4" + sdk: ">=3.3.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + appflowy_ui: + path: ../ + cupertino_icons: ^1.0.6 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/test/widget_test.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/test/widget_test.dart new file mode 100644 index 0000000000..423052a342 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/test/widget_test.dart @@ -0,0 +1,30 @@ +// 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. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:appflowy_ui_example/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart new file mode 100644 index 0000000000..974907f940 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart @@ -0,0 +1,2 @@ +export 'src/component/component.dart'; +export 'src/theme/theme.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart new file mode 100644 index 0000000000..39d5175af1 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart @@ -0,0 +1,54 @@ +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/widgets.dart'; + +enum AFButtonSize { + s, + m, + l, + xl; + + TextStyle buildTextStyle(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return switch (this) { + AFButtonSize.s => theme.textStyle.body.enhanced(), + AFButtonSize.m => theme.textStyle.body.enhanced(), + AFButtonSize.l => theme.textStyle.body.enhanced(), + AFButtonSize.xl => theme.textStyle.title.enhanced(), + }; + } + + EdgeInsetsGeometry buildPadding(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return switch (this) { + AFButtonSize.s => EdgeInsets.symmetric( + horizontal: theme.spacing.l, + vertical: theme.spacing.xs, + ), + AFButtonSize.m => EdgeInsets.symmetric( + horizontal: theme.spacing.xl, + vertical: theme.spacing.s, + ), + AFButtonSize.l => EdgeInsets.symmetric( + horizontal: theme.spacing.xl, + vertical: 10, // why? + ), + AFButtonSize.xl => EdgeInsets.symmetric( + horizontal: theme.spacing.xl, + vertical: 14, // why? + ), + }; + } + + double buildBorderRadius(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return switch (this) { + AFButtonSize.s => theme.borderRadius.m, + AFButtonSize.m => theme.borderRadius.m, + AFButtonSize.l => 10, // why? + AFButtonSize.xl => theme.borderRadius.xl, + }; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart new file mode 100644 index 0000000000..9bb36507e8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart @@ -0,0 +1,152 @@ +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFBaseButtonColorBuilder = Color Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +typedef AFBaseButtonBorderColorBuilder = Color Function( + BuildContext context, + bool isHovering, + bool disabled, + bool isFocused, +); + +class AFBaseButton extends StatefulWidget { + const AFBaseButton({ + super.key, + required this.onTap, + required this.builder, + required this.padding, + required this.borderRadius, + this.borderColor, + this.backgroundColor, + this.ringColor, + this.disabled = false, + }); + + final VoidCallback? onTap; + + final AFBaseButtonBorderColorBuilder? borderColor; + final AFBaseButtonBorderColorBuilder? ringColor; + final AFBaseButtonColorBuilder? backgroundColor; + + final EdgeInsetsGeometry padding; + final double borderRadius; + final bool disabled; + + final Widget Function( + BuildContext context, + bool isHovering, + bool disabled, + ) builder; + + @override + State createState() => _AFBaseButtonState(); +} + +class _AFBaseButtonState extends State { + final FocusNode focusNode = FocusNode(); + + bool isHovering = false; + bool isFocused = false; + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Color borderColor = _buildBorderColor(context); + final Color backgroundColor = _buildBackgroundColor(context); + final Color ringColor = _buildRingColor(context); + + return Actions( + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) { + if (!widget.disabled) { + widget.onTap?.call(); + } + return; + }, + ), + }, + child: Focus( + focusNode: focusNode, + onFocusChange: (isFocused) { + setState(() => this.isFocused = isFocused); + }, + child: MouseRegion( + cursor: widget.disabled + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: GestureDetector( + onTap: widget.disabled ? null : widget.onTap, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(widget.borderRadius), + border: isFocused + ? Border.all( + color: ringColor, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside, + ) + : null, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + child: Padding( + padding: widget.padding, + child: widget.builder( + context, + isHovering, + widget.disabled, + ), + ), + ), + ), + ), + ), + ), + ); + } + + Color _buildBorderColor(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return widget.borderColor + ?.call(context, isHovering, widget.disabled, isFocused) ?? + theme.borderColorScheme.greyTertiary; + } + + Color _buildBackgroundColor(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return widget.backgroundColor?.call(context, isHovering, widget.disabled) ?? + theme.fillColorScheme.transparent; + } + + Color _buildRingColor(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + if (widget.ringColor != null) { + return widget.ringColor! + .call(context, isHovering, widget.disabled, isFocused); + } + + if (isFocused) { + return theme.borderColorScheme.themeThick.withAlpha(128); + } + + return theme.borderColorScheme.transparent; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart new file mode 100644 index 0000000000..035307d10b --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart @@ -0,0 +1,55 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class AFBaseTextButton extends StatelessWidget { + const AFBaseTextButton({ + super.key, + required this.text, + required this.onTap, + this.disabled = false, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.textColor, + this.backgroundColor, + this.alignment, + this.textStyle, + }); + + /// The text of the button. + final String text; + + /// Whether the button is disabled. + final bool disabled; + + /// The callback when the button is tapped. + final VoidCallback onTap; + + /// The size of the button. + final AFButtonSize size; + + /// The padding of the button. + final EdgeInsetsGeometry? padding; + + /// The border radius of the button. + final double? borderRadius; + + /// The text color of the button. + final AFBaseButtonColorBuilder? textColor; + + /// The background color of the button. + final AFBaseButtonColorBuilder? backgroundColor; + + /// The alignment of the button. + /// + /// If it's null, the button size will be the size of the text with padding. + final Alignment? alignment; + + /// The text style of the button. + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + throw UnimplementedError(); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart new file mode 100644 index 0000000000..31a3a20b5f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart @@ -0,0 +1,16 @@ +// Base button +export 'base_button/base.dart'; +export 'base_button/base_button.dart'; +export 'base_button/base_text_button.dart'; +// Filled buttons +export 'filled_button/filled_button.dart'; +export 'filled_button/filled_icon_text_button.dart'; +export 'filled_button/filled_text_button.dart'; +// Ghost buttons +export 'ghost_button/ghost_button.dart'; +export 'ghost_button/ghost_icon_text_button.dart'; +export 'ghost_button/ghost_text_button.dart'; +// Outlined buttons +export 'outlined_button/outlined_button.dart'; +export 'outlined_button/outlined_icon_text_button.dart'; +export 'outlined_button/outlined_text_button.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart new file mode 100644 index 0000000000..e871626b59 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart @@ -0,0 +1,125 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFFilledButtonWidgetBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFFilledButton extends StatelessWidget { + const AFFilledButton._({ + super.key, + required this.builder, + required this.onTap, + required this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Primary text button. + factory AFFilledButton.primary({ + Key? key, + required AFFilledButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFFilledButton._( + key: key, + builder: builder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.themeThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.themeThick; + }, + ); + } + + /// Destructive text button. + factory AFFilledButton.destructive({ + Key? key, + required AFFilledButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFFilledButton._( + key: key, + builder: builder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.errorThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.errorThick; + }, + ); + } + + /// Disabled text button. + factory AFFilledButton.disabled({ + Key? key, + required AFFilledButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledButton._( + key: key, + builder: builder, + onTap: () {}, + size: size, + disabled: true, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5, + ); + } + + final VoidCallback onTap; + final bool disabled; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFBaseButtonColorBuilder? backgroundColor; + final AFFilledButtonWidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___, ____) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: builder, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_icon_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_icon_text_button.dart new file mode 100644 index 0000000000..04c49d0b01 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_icon_text_button.dart @@ -0,0 +1,199 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFFilledIconBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFFilledIconTextButton extends StatelessWidget { + const AFFilledIconTextButton._({ + super.key, + required this.text, + required this.onTap, + required this.iconBuilder, + this.textColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + }); + + /// Primary filled text button. + factory AFFilledIconTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + onTap: onTap, + iconBuilder: iconBuilder, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.tertiary; + } + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return theme.fillColorScheme.themeThick; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.onFill; + }, + ); + } + + /// Destructive filled text button. + factory AFFilledIconTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.tertiary; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.onFill; + }, + ); + } + + /// Disabled filled text button. + factory AFFilledIconTextButton.disabled({ + Key? key, + required String text, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.fillColorScheme.tertiary; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.onFill; + }, + ); + } + + /// Ghost filled text button with transparent background that shows color on hover. + factory AFFilledIconTextButton.ghost({ + Key? key, + required String text, + required VoidCallback onTap, + required AFFilledIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFFilledIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return Colors.transparent; + } + if (isHovering) { + return theme.fillColorScheme.themeThickHover; + } + return Colors.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + return theme.textColorScheme.primary; + }, + ); + } + + final String text; + final VoidCallback onTap; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFFilledIconBuilder iconBuilder; + + final AFBaseButtonColorBuilder? textColor; + final AFBaseButtonColorBuilder? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + backgroundColor: backgroundColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.onFill; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + iconBuilder(context, isHovering, disabled), + SizedBox(width: theme.spacing.s), + Text( + text, + style: size.buildTextStyle(context).copyWith( + color: textColor, + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart new file mode 100644 index 0000000000..d1b1d868d0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart @@ -0,0 +1,149 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +class AFFilledTextButton extends AFBaseTextButton { + const AFFilledTextButton({ + super.key, + required super.text, + required super.onTap, + required super.backgroundColor, + required super.textColor, + super.size = AFButtonSize.m, + super.padding, + super.borderRadius, + super.disabled = false, + super.alignment, + super.textStyle, + }); + + /// Primary text button. + factory AFFilledTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFFilledTextButton( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + textStyle: textStyle, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.onFill, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.themeThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.themeThick; + }, + ); + } + + /// Destructive text button. + factory AFFilledTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFFilledTextButton( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + textStyle: textStyle, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.onFill, + backgroundColor: (context, isHovering, disabled) { + if (disabled) { + return AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5; + } + if (isHovering) { + return AppFlowyTheme.of(context).fillColorScheme.errorThickHover; + } + return AppFlowyTheme.of(context).fillColorScheme.errorThick; + }, + ); + } + + /// Disabled text button. + factory AFFilledTextButton.disabled({ + Key? key, + required String text, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFFilledTextButton( + key: key, + text: text, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textStyle: textStyle, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.tertiary, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.primaryAlpha5, + ); + } + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___, ____) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + AppFlowyTheme.of(context).textColorScheme.onFill; + Widget child = Text( + text, + style: textStyle ?? + size.buildTextStyle(context).copyWith(color: textColor), + ); + + final alignment = this.alignment; + if (alignment != null) { + child = Align( + alignment: alignment, + child: child, + ); + } + + return child; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart new file mode 100644 index 0000000000..6300c6f5a8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart @@ -0,0 +1,96 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFGhostButtonWidgetBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFGhostButton extends StatelessWidget { + const AFGhostButton._({ + super.key, + required this.onTap, + required this.backgroundColor, + required this.builder, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Normal ghost button. + factory AFGhostButton.normal({ + Key? key, + required VoidCallback onTap, + required AFGhostButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFGhostButton._( + key: key, + builder: builder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + ); + } + + /// Disabled ghost button. + factory AFGhostButton.disabled({ + Key? key, + required AFGhostButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFGhostButton._( + key: key, + builder: builder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.transparent, + ); + } + + final VoidCallback onTap; + final bool disabled; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFBaseButtonColorBuilder? backgroundColor; + final AFGhostButtonWidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___, ____) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: builder, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart new file mode 100644 index 0000000000..af65599ea3 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart @@ -0,0 +1,141 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFGhostIconBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFGhostIconTextButton extends StatelessWidget { + const AFGhostIconTextButton({ + super.key, + required this.text, + required this.onTap, + required this.iconBuilder, + this.textColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Primary ghost text button. + factory AFGhostIconTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + required AFGhostIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFGhostIconTextButton( + key: key, + text: text, + onTap: onTap, + iconBuilder: iconBuilder, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return Colors.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return Colors.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Disabled ghost text button. + factory AFGhostIconTextButton.disabled({ + Key? key, + required String text, + required AFGhostIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFGhostIconTextButton( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + backgroundColor: (context, isHovering, disabled) { + return Colors.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return theme.textColorScheme.tertiary; + }, + ); + } + + final String text; + final bool disabled; + final VoidCallback onTap; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFGhostIconBuilder iconBuilder; + + final AFBaseButtonColorBuilder? textColor; + final AFBaseButtonColorBuilder? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (context, isHovering, disabled, isFocused) { + return Colors.transparent; + }, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + iconBuilder( + context, + isHovering, + disabled, + ), + SizedBox(width: theme.spacing.m), + Text( + text, + style: size.buildTextStyle(context).copyWith( + color: textColor, + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart new file mode 100644 index 0000000000..d154d67dbd --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart @@ -0,0 +1,116 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +class AFGhostTextButton extends AFBaseTextButton { + const AFGhostTextButton({ + super.key, + required super.text, + required super.onTap, + super.textColor, + super.backgroundColor, + super.size = AFButtonSize.m, + super.padding, + super.borderRadius, + super.disabled = false, + super.alignment, + }); + + /// Normal ghost text button. + factory AFGhostTextButton.primary({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + }) { + return AFGhostTextButton( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + if (isHovering) { + return theme.textColorScheme.primary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Disabled ghost text button. + factory AFGhostTextButton.disabled({ + Key? key, + required String text, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + Alignment? alignment, + }) { + return AFGhostTextButton( + key: key, + text: text, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).textColorScheme.tertiary, + backgroundColor: (context, isHovering, disabled) => + AppFlowyTheme.of(context).fillColorScheme.transparent, + ); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: (_, __, ___, ____) => Colors.transparent, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + + Widget child = Text( + text, + style: size.buildTextStyle(context).copyWith(color: textColor), + ); + + final alignment = this.alignment; + if (alignment != null) { + child = Align( + alignment: alignment, + child: child, + ); + } + + return child; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart new file mode 100644 index 0000000000..205d9931d6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart @@ -0,0 +1,168 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFOutlinedButtonWidgetBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFOutlinedButton extends StatelessWidget { + const AFOutlinedButton._({ + super.key, + required this.onTap, + required this.builder, + this.borderColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + }); + + /// Normal outlined button. + factory AFOutlinedButton.normal({ + Key? key, + required AFOutlinedButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFOutlinedButton._( + key: key, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + builder: builder, + ); + } + + /// Destructive outlined button. + factory AFOutlinedButton.destructive({ + Key? key, + required AFOutlinedButtonWidgetBuilder builder, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + }) { + return AFOutlinedButton._( + key: key, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorSelect; + } + return theme.fillColorScheme.transparent; + }, + builder: builder, + ); + } + + /// Disabled outlined text button. + factory AFOutlinedButton.disabled({ + Key? key, + required AFOutlinedButtonWidgetBuilder builder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + }) { + return AFOutlinedButton._( + key: key, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + builder: builder, + ); + } + + final VoidCallback onTap; + final bool disabled; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + + final AFBaseButtonBorderColorBuilder? borderColor; + final AFBaseButtonColorBuilder? backgroundColor; + + final AFOutlinedButtonWidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: borderColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: builder, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart new file mode 100644 index 0000000000..350594cd46 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart @@ -0,0 +1,226 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFOutlinedIconBuilder = Widget Function( + BuildContext context, + bool isHovering, + bool disabled, +); + +class AFOutlinedIconTextButton extends StatelessWidget { + const AFOutlinedIconTextButton._({ + super.key, + required this.text, + required this.onTap, + required this.iconBuilder, + this.borderColor, + this.textColor, + this.backgroundColor, + this.size = AFButtonSize.m, + this.padding, + this.borderRadius, + this.disabled = false, + this.alignment = MainAxisAlignment.center, + }); + + /// Normal outlined text button. + factory AFOutlinedIconTextButton.normal({ + Key? key, + required String text, + required VoidCallback onTap, + required AFOutlinedIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + MainAxisAlignment alignment = MainAxisAlignment.center, + }) { + return AFOutlinedIconTextButton._( + key: key, + text: text, + onTap: onTap, + iconBuilder: iconBuilder, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + if (isHovering) { + return theme.textColorScheme.primary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Destructive outlined text button. + factory AFOutlinedIconTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + required AFOutlinedIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + MainAxisAlignment alignment = MainAxisAlignment.center, + }) { + return AFOutlinedIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.error + : theme.textColorScheme.error; + }, + ); + } + + /// Disabled outlined text button. + factory AFOutlinedIconTextButton.disabled({ + Key? key, + required String text, + required AFOutlinedIconBuilder iconBuilder, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + MainAxisAlignment alignment = MainAxisAlignment.center, + }) { + return AFOutlinedIconTextButton._( + key: key, + text: text, + iconBuilder: iconBuilder, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.tertiary + : theme.textColorScheme.primary; + }, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + ); + } + + final String text; + final bool disabled; + final VoidCallback onTap; + final AFButtonSize size; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + final MainAxisAlignment alignment; + + final AFOutlinedIconBuilder iconBuilder; + + final AFBaseButtonColorBuilder? textColor; + final AFBaseButtonBorderColorBuilder? borderColor; + final AFBaseButtonColorBuilder? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + backgroundColor: backgroundColor, + borderColor: borderColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + disabled: disabled, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + return Row( + mainAxisAlignment: alignment, + children: [ + iconBuilder(context, isHovering, disabled), + SizedBox(width: theme.spacing.s), + Text( + text, + style: size.buildTextStyle(context).copyWith( + color: textColor, + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart new file mode 100644 index 0000000000..d809d981b0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart @@ -0,0 +1,212 @@ +import 'package:appflowy_ui/src/component/component.dart'; +import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; +import 'package:flutter/material.dart'; + +class AFOutlinedTextButton extends AFBaseTextButton { + const AFOutlinedTextButton._({ + super.key, + required super.text, + required super.onTap, + this.borderColor, + super.textStyle, + super.textColor, + super.backgroundColor, + super.size = AFButtonSize.m, + super.padding, + super.borderRadius, + super.disabled = false, + super.alignment, + }); + + /// Normal outlined text button. + factory AFOutlinedTextButton.normal({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFOutlinedTextButton._( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + textStyle: textStyle, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.textColorScheme.tertiary; + } + if (isHovering) { + return theme.textColorScheme.primary; + } + return theme.textColorScheme.primary; + }, + ); + } + + /// Destructive outlined text button. + factory AFOutlinedTextButton.destructive({ + Key? key, + required String text, + required VoidCallback onTap, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + bool disabled = false, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFOutlinedTextButton._( + key: key, + text: text, + onTap: onTap, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: disabled, + alignment: alignment, + textStyle: textStyle, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorThickHover; + } + return theme.fillColorScheme.errorThick; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.errorThick; + } + if (isHovering) { + return theme.fillColorScheme.errorSelect; + } + return theme.fillColorScheme.transparent; + }, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.error + : theme.textColorScheme.error; + }, + ); + } + + /// Disabled outlined text button. + factory AFOutlinedTextButton.disabled({ + Key? key, + required String text, + AFButtonSize size = AFButtonSize.m, + EdgeInsetsGeometry? padding, + double? borderRadius, + Alignment? alignment, + TextStyle? textStyle, + }) { + return AFOutlinedTextButton._( + key: key, + text: text, + onTap: () {}, + size: size, + padding: padding, + borderRadius: borderRadius, + disabled: true, + alignment: alignment, + textStyle: textStyle, + textColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + return disabled + ? theme.textColorScheme.tertiary + : theme.textColorScheme.primary; + }, + borderColor: (context, isHovering, disabled, isFocused) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.borderColorScheme.greyTertiary; + } + if (isHovering) { + return theme.borderColorScheme.greyTertiaryHover; + } + return theme.borderColorScheme.greyTertiary; + }, + backgroundColor: (context, isHovering, disabled) { + final theme = AppFlowyTheme.of(context); + if (disabled) { + return theme.fillColorScheme.transparent; + } + if (isHovering) { + return theme.fillColorScheme.primaryAlpha5; + } + return theme.fillColorScheme.transparent; + }, + ); + } + + final AFBaseButtonBorderColorBuilder? borderColor; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return AFBaseButton( + disabled: disabled, + backgroundColor: backgroundColor, + borderColor: borderColor, + padding: padding ?? size.buildPadding(context), + borderRadius: borderRadius ?? size.buildBorderRadius(context), + onTap: onTap, + builder: (context, isHovering, disabled) { + final textColor = this.textColor?.call(context, isHovering, disabled) ?? + theme.textColorScheme.primary; + + Widget child = Text( + text, + style: textStyle ?? + size.buildTextStyle(context).copyWith(color: textColor), + ); + + final alignment = this.alignment; + + if (alignment != null) { + child = Align( + alignment: alignment, + child: child, + ); + } + + return child; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart new file mode 100644 index 0000000000..584d50c07b --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart @@ -0,0 +1,3 @@ +export 'button/button.dart'; +export 'modal/modal.dart'; +export 'textfield/textfield.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/dimension.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/dimension.dart new file mode 100644 index 0000000000..72a7dbb5cf --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/dimension.dart @@ -0,0 +1,9 @@ +class AFModalDimension { + const AFModalDimension._(); + + static const double S = 400.0; + static const double M = 560.0; + static const double L = 720.0; + + static const double dialogHeight = 200.0; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/modal.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/modal.dart new file mode 100644 index 0000000000..4b40aebcbd --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/modal.dart @@ -0,0 +1,125 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +export 'dimension.dart'; + +class AFModal extends StatelessWidget { + const AFModal({ + super.key, + this.constraints = const BoxConstraints(), + required this.child, + }); + + final BoxConstraints constraints; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Center( + child: Padding( + padding: EdgeInsets.all(theme.spacing.xl), + child: ConstrainedBox( + constraints: constraints, + child: DecoratedBox( + decoration: BoxDecoration( + boxShadow: theme.shadow.medium, + borderRadius: BorderRadius.circular(theme.borderRadius.xl), + color: theme.surfaceColorScheme.primary, + ), + child: Material( + color: Colors.transparent, + child: child, + ), + ), + ), + ), + ); + } +} + +class AFModalHeader extends StatelessWidget { + const AFModalHeader({ + super.key, + required this.leading, + this.trailing = const [], + }); + + final Widget leading; + final List trailing; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Padding( + padding: EdgeInsets.only( + top: theme.spacing.xl, + left: theme.spacing.xxl, + right: theme.spacing.xxl, + ), + child: Row( + spacing: theme.spacing.s, + children: [ + Expanded(child: leading), + ...trailing, + ], + ), + ); + } +} + +class AFModalFooter extends StatelessWidget { + const AFModalFooter({ + super.key, + this.leading = const [], + this.trailing = const [], + }); + + final List leading; + final List trailing; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Padding( + padding: EdgeInsets.only( + bottom: theme.spacing.xl, + left: theme.spacing.xxl, + right: theme.spacing.xxl, + ), + child: Row( + spacing: theme.spacing.l, + children: [ + ...leading, + Spacer(), + ...trailing, + ], + ), + ); + } +} + +class AFModalBody extends StatelessWidget { + const AFModalBody({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Padding( + padding: EdgeInsets.symmetric( + vertical: theme.spacing.l, + horizontal: theme.spacing.xxl, + ), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart new file mode 100644 index 0000000000..3f5ad4cfed --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart @@ -0,0 +1,254 @@ +import 'package:appflowy_ui/src/theme/theme.dart'; +import 'package:flutter/material.dart'; + +typedef AFTextFieldValidator = (bool result, String errorText) Function( + TextEditingController controller, +); + +abstract class AFTextFieldState extends State { + // Error handler + void syncError({required String errorText}) {} + void clearError() {} + + /// Obscure the text. + void syncObscured(bool isObscured) {} +} + +class AFTextField extends StatefulWidget { + const AFTextField({ + super.key, + this.hintText, + this.initialText, + this.keyboardType, + this.size = AFTextFieldSize.l, + this.validator, + this.controller, + this.onChanged, + this.onSubmitted, + this.autoFocus, + this.obscureText = false, + this.suffixIconBuilder, + this.suffixIconConstraints, + }); + + /// The hint text to display when the text field is empty. + final String? hintText; + + /// The initial text to display in the text field. + final String? initialText; + + /// The type of keyboard to display. + final TextInputType? keyboardType; + + /// The size variant of the text field. + final AFTextFieldSize size; + + /// The validator to use for the text field. + final AFTextFieldValidator? validator; + + /// The controller to use for the text field. + /// + /// If it's not provided, the text field will use a new controller. + final TextEditingController? controller; + + /// The callback to call when the text field changes. + final void Function(String)? onChanged; + + /// The callback to call when the text field is submitted. + final void Function(String)? onSubmitted; + + /// Enable auto focus. + final bool? autoFocus; + + /// Obscure the text. + final bool obscureText; + + /// The trailing widget to display. + final Widget Function(BuildContext context, bool isObscured)? + suffixIconBuilder; + + /// The size of the suffix icon. + final BoxConstraints? suffixIconConstraints; + + @override + State createState() => _AFTextFieldState(); +} + +class _AFTextFieldState extends AFTextFieldState { + late final TextEditingController effectiveController; + + bool hasError = false; + String errorText = ''; + + bool isObscured = false; + + @override + void initState() { + super.initState(); + + effectiveController = widget.controller ?? TextEditingController(); + + final initialText = widget.initialText; + if (initialText != null) { + effectiveController.text = initialText; + } + + effectiveController.addListener(_validate); + + isObscured = widget.obscureText; + } + + @override + void dispose() { + effectiveController.removeListener(_validate); + if (widget.controller == null) { + effectiveController.dispose(); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + final borderRadius = widget.size.borderRadius(theme); + final contentPadding = widget.size.contentPadding(theme); + + final errorBorderColor = theme.borderColorScheme.errorThick; + final defaultBorderColor = theme.borderColorScheme.greyTertiary; + + Widget child = TextField( + controller: effectiveController, + keyboardType: widget.keyboardType, + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + obscureText: isObscured, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + autofocus: widget.autoFocus ?? false, + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.tertiary, + ), + isDense: true, + constraints: BoxConstraints(), + contentPadding: contentPadding, + border: OutlineInputBorder( + borderSide: BorderSide( + color: hasError ? errorBorderColor : defaultBorderColor, + ), + borderRadius: borderRadius, + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: hasError ? errorBorderColor : defaultBorderColor, + ), + borderRadius: borderRadius, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: hasError + ? errorBorderColor + : theme.borderColorScheme.themeThick, + ), + borderRadius: borderRadius, + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: errorBorderColor, + ), + borderRadius: borderRadius, + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: errorBorderColor, + ), + borderRadius: borderRadius, + ), + hoverColor: theme.borderColorScheme.greyTertiaryHover, + suffixIcon: widget.suffixIconBuilder?.call(context, isObscured), + suffixIconConstraints: widget.suffixIconConstraints, + ), + ); + + if (hasError && errorText.isNotEmpty) { + child = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + child, + SizedBox(height: theme.spacing.xs), + Text( + errorText, + style: theme.textStyle.caption.standard( + color: theme.textColorScheme.error, + ), + ), + ], + ); + } + + return child; + } + + void _validate() { + final validator = widget.validator; + if (validator != null) { + final result = validator(effectiveController); + setState(() { + hasError = result.$1; + errorText = result.$2; + }); + } + } + + @override + void syncError({ + required String errorText, + }) { + setState(() { + hasError = true; + this.errorText = errorText; + }); + } + + @override + void clearError() { + setState(() { + hasError = false; + errorText = ''; + }); + } + + @override + void syncObscured(bool isObscured) { + setState(() { + this.isObscured = isObscured; + }); + } +} + +enum AFTextFieldSize { + m, + l; + + EdgeInsetsGeometry contentPadding(AppFlowyThemeData theme) { + return EdgeInsets.symmetric( + vertical: switch (this) { + AFTextFieldSize.m => theme.spacing.s, + AFTextFieldSize.l => 10.0, + }, + horizontal: theme.spacing.m, + ); + } + + BorderRadius borderRadius(AppFlowyThemeData theme) { + return BorderRadius.circular( + switch (this) { + AFTextFieldSize.m => theme.borderRadius.m, + AFTextFieldSize.l => 10.0, + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart new file mode 100644 index 0000000000..26e45ca8f1 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart @@ -0,0 +1,152 @@ +import 'package:appflowy_ui/src/theme/theme.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class AppFlowyTheme extends StatelessWidget { + const AppFlowyTheme({ + super.key, + required this.data, + required this.child, + }); + + final AppFlowyThemeData data; + final Widget child; + + static AppFlowyThemeData of(BuildContext context, {bool listen = true}) { + final provider = maybeOf(context, listen: listen); + if (provider == null) { + throw FlutterError( + ''' + AppFlowyTheme.of() called with a context that does not contain a AppFlowyTheme.\n + No AppFlowyTheme ancestor could be found starting from the context that was passed to AppFlowyTheme.of(). + This can happen because you do not have a AppFlowyTheme widget (which introduces a AppFlowyTheme), + or it can happen if the context you use comes from a widget above this widget.\n + The context used was: $context''', + ); + } + return provider; + } + + static AppFlowyThemeData? maybeOf( + BuildContext context, { + bool listen = true, + }) { + if (listen) { + return context + .dependOnInheritedWidgetOfExactType() + ?.themeData; + } + final provider = context + .getElementForInheritedWidgetOfExactType() + ?.widget; + + return (provider as AppFlowyInheritedTheme?)?.themeData; + } + + @override + Widget build(BuildContext context) { + return AppFlowyInheritedTheme( + themeData: data, + child: child, + ); + } +} + +class AppFlowyInheritedTheme extends InheritedTheme { + const AppFlowyInheritedTheme({ + super.key, + required this.themeData, + required super.child, + }); + + final AppFlowyThemeData themeData; + + @override + Widget wrap(BuildContext context, Widget child) { + return AppFlowyTheme(data: themeData, child: child); + } + + @override + bool updateShouldNotify(AppFlowyInheritedTheme oldWidget) => + themeData != oldWidget.themeData; +} + +/// An interpolation between two [AppFlowyThemeData]s. +/// +/// This class specializes the interpolation of [Tween] to +/// call the [AppFlowyThemeData.lerp] method. +/// +/// See [Tween] for a discussion on how to use interpolation objects. +class AppFlowyThemeDataTween extends Tween { + /// Creates a [AppFlowyThemeData] tween. + /// + /// The [begin] and [end] properties must be non-null before the tween is + /// first used, but the arguments can be null if the values are going to be + /// filled in later. + AppFlowyThemeDataTween({super.begin, super.end}); + + @override + AppFlowyThemeData lerp(double t) => AppFlowyThemeData.lerp(begin!, end!, t); +} + +class AnimatedAppFlowyTheme extends ImplicitlyAnimatedWidget { + /// Creates an animated theme. + /// + /// By default, the theme transition uses a linear curve. + const AnimatedAppFlowyTheme({ + super.key, + required this.data, + super.curve, + super.duration = kThemeAnimationDuration, + super.onEnd, + required this.child, + }); + + /// Specifies the color and typography values for descendant widgets. + final AppFlowyThemeData data; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + @override + AnimatedWidgetBaseState createState() => + _AnimatedThemeState(); +} + +class _AnimatedThemeState + extends AnimatedWidgetBaseState { + AppFlowyThemeDataTween? data; + + @override + void forEachTween(TweenVisitor visitor) { + data = visitor( + data, + widget.data, + (dynamic value) => + AppFlowyThemeDataTween(begin: value as AppFlowyThemeData), + )! as AppFlowyThemeDataTween; + } + + @override + Widget build(BuildContext context) { + return AppFlowyTheme( + data: data!.evaluate(animation), + child: widget.child, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder description) { + super.debugFillProperties(description); + description.add( + DiagnosticsProperty( + 'data', + data, + showName: false, + defaultValue: null, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart new file mode 100644 index 0000000000..2bd6d619d8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart @@ -0,0 +1,658 @@ +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: 2025-04-19T13:45:56.076897 +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:flutter/material.dart'; + +class AppFlowyPrimitiveTokens { + AppFlowyPrimitiveTokens._(); + + /// #f8faff + static Color get neutral100 => Color(0xFFF8FAFF); + + /// #e4e8f5 + static Color get neutral200 => Color(0xFFE4E8F5); + + /// #ced3e6 + static Color get neutral300 => Color(0xFFCED3E6); + + /// #b5bbd3 + static Color get neutral400 => Color(0xFFB5BBD3); + + /// #989eb7 + static Color get neutral500 => Color(0xFF989EB7); + + /// #6f748c + static Color get neutral600 => Color(0xFF6F748C); + + /// #54596e + static Color get neutral700 => Color(0xFF54596E); + + /// #3c3f4e + static Color get neutral800 => Color(0xFF3C3F4E); + + /// #272930 + static Color get neutral900 => Color(0xFF272930); + + /// #21232a + static Color get neutral1000 => Color(0xFF21232A); + + /// #000000 + static Color get neutralBlack => Color(0xFF000000); + + /// #00000099 + static Color get neutralAlphaBlack60 => Color(0x99000000); + + /// #ffffff + static Color get neutralWhite => Color(0xFFFFFFFF); + + /// #ffffff00 + static Color get neutralAlphaWhite0 => Color(0x00FFFFFF); + + /// #ffffff33 + static Color get neutralAlphaWhite20 => Color(0x33FFFFFF); + + /// #ffffff4d + static Color get neutralAlphaWhite30 => Color(0x4DFFFFFF); + + /// #f9fafd0d + static Color get neutralAlphaGrey10005 => Color(0x0DF9FAFD); + + /// #f9fafd1a + static Color get neutralAlphaGrey10010 => Color(0x1AF9FAFD); + + /// #1f23290d + static Color get neutralAlphaGrey100005 => Color(0x0D1F2329); + + /// #1f23291a + static Color get neutralAlphaGrey100010 => Color(0x1A1F2329); + + /// #1f2329b2 + static Color get neutralAlphaGrey100070 => Color(0xB21F2329); + + /// #1f2329cc + static Color get neutralAlphaGrey100080 => Color(0xCC1F2329); + + /// #e3f6ff + static Color get blue100 => Color(0xFFE3F6FF); + + /// #a9e2ff + static Color get blue200 => Color(0xFFA9E2FF); + + /// #80d2ff + static Color get blue300 => Color(0xFF80D2FF); + + /// #4ec1ff + static Color get blue400 => Color(0xFF4EC1FF); + + /// #00b5ff + static Color get blue500 => Color(0xFF00B5FF); + + /// #0092d6 + static Color get blue600 => Color(0xFF0092D6); + + /// #0078c0 + static Color get blue700 => Color(0xFF0078C0); + + /// #0065a9 + static Color get blue800 => Color(0xFF0065A9); + + /// #00508f + static Color get blue900 => Color(0xFF00508F); + + /// #003c77 + static Color get blue1000 => Color(0xFF003C77); + + /// #00b5ff26 + static Color get blueAlphaBlue50015 => Color(0x2600B5FF); + + /// #ecf9f5 + static Color get green100 => Color(0xFFECF9F5); + + /// #c3e5d8 + static Color get green200 => Color(0xFFC3E5D8); + + /// #9ad1bc + static Color get green300 => Color(0xFF9AD1BC); + + /// #71bd9f + static Color get green400 => Color(0xFF71BD9F); + + /// #48a982 + static Color get green500 => Color(0xFF48A982); + + /// #248569 + static Color get green600 => Color(0xFF248569); + + /// #29725d + static Color get green700 => Color(0xFF29725D); + + /// #2e6050 + static Color get green800 => Color(0xFF2E6050); + + /// #305548 + static Color get green900 => Color(0xFF305548); + + /// #305244 + static Color get green1000 => Color(0xFF305244); + + /// #f1e0ff + static Color get purple100 => Color(0xFFF1E0FF); + + /// #e1b3ff + static Color get purple200 => Color(0xFFE1B3FF); + + /// #d185ff + static Color get purple300 => Color(0xFFD185FF); + + /// #bc58ff + static Color get purple400 => Color(0xFFBC58FF); + + /// #9327ff + static Color get purple500 => Color(0xFF9327FF); + + /// #7a1dcc + static Color get purple600 => Color(0xFF7A1DCC); + + /// #6617b3 + static Color get purple700 => Color(0xFF6617B3); + + /// #55138f + static Color get purple800 => Color(0xFF55138F); + + /// #470c72 + static Color get purple900 => Color(0xFF470C72); + + /// #380758 + static Color get purple1000 => Color(0xFF380758); + + /// #ffe5ef + static Color get magenta100 => Color(0xFFFFE5EF); + + /// #ffb8d1 + static Color get magenta200 => Color(0xFFFFB8D1); + + /// #ff8ab2 + static Color get magenta300 => Color(0xFFFF8AB2); + + /// #ff5c93 + static Color get magenta400 => Color(0xFFFF5C93); + + /// #fb006d + static Color get magenta500 => Color(0xFFFB006D); + + /// #d2005f + static Color get magenta600 => Color(0xFFD2005F); + + /// #d2005f + static Color get magenta700 => Color(0xFFD2005F); + + /// #850040 + static Color get magenta800 => Color(0xFF850040); + + /// #610031 + static Color get magenta900 => Color(0xFF610031); + + /// #400022 + static Color get magenta1000 => Color(0xFF400022); + + /// #ffd2dd + static Color get red100 => Color(0xFFFFD2DD); + + /// #ffa5b4 + static Color get red200 => Color(0xFFFFA5B4); + + /// #ff7d87 + static Color get red300 => Color(0xFFFF7D87); + + /// #ff5050 + static Color get red400 => Color(0xFFFF5050); + + /// #f33641 + static Color get red500 => Color(0xFFF33641); + + /// #e71d32 + static Color get red600 => Color(0xFFE71D32); + + /// #ad1625 + static Color get red700 => Color(0xFFAD1625); + + /// #8c101c + static Color get red800 => Color(0xFF8C101C); + + /// #6e0a1e + static Color get red900 => Color(0xFF6E0A1E); + + /// #4c0a17 + static Color get red1000 => Color(0xFF4C0A17); + + /// #f336411a + static Color get redAlphaRed50010 => Color(0x1AF33641); + + /// #fff3d5 + static Color get orange100 => Color(0xFFFFF3D5); + + /// #ffe4ab + static Color get orange200 => Color(0xFFFFE4AB); + + /// #ffd181 + static Color get orange300 => Color(0xFFFFD181); + + /// #ffbe62 + static Color get orange400 => Color(0xFFFFBE62); + + /// #ffa02e + static Color get orange500 => Color(0xFFFFA02E); + + /// #db7e21 + static Color get orange600 => Color(0xFFDB7E21); + + /// #b75f17 + static Color get orange700 => Color(0xFFB75F17); + + /// #93450e + static Color get orange800 => Color(0xFF93450E); + + /// #7a3108 + static Color get orange900 => Color(0xFF7A3108); + + /// #602706 + static Color get orange1000 => Color(0xFF602706); + + /// #fff9b2 + static Color get yellow100 => Color(0xFFFFF9B2); + + /// #ffec66 + static Color get yellow200 => Color(0xFFFFEC66); + + /// #ffdf1a + static Color get yellow300 => Color(0xFFFFDF1A); + + /// #ffcc00 + static Color get yellow400 => Color(0xFFFFCC00); + + /// #ffce00 + static Color get yellow500 => Color(0xFFFFCE00); + + /// #e6b800 + static Color get yellow600 => Color(0xFFE6B800); + + /// #cc9f00 + static Color get yellow700 => Color(0xFFCC9F00); + + /// #b38a00 + static Color get yellow800 => Color(0xFFB38A00); + + /// #9a7500 + static Color get yellow900 => Color(0xFF9A7500); + + /// #7f6200 + static Color get yellow1000 => Color(0xFF7F6200); + + /// #fcf2f2 + static Color get subtleColorRose100 => Color(0xFFFCF2F2); + + /// #fae3e3 + static Color get subtleColorRose200 => Color(0xFFFAE3E3); + + /// #fad9d9 + static Color get subtleColorRose300 => Color(0xFFFAD9D9); + + /// #edadad + static Color get subtleColorRose400 => Color(0xFFEDADAD); + + /// #cc4e4e + static Color get subtleColorRose500 => Color(0xFFCC4E4E); + + /// #702828 + static Color get subtleColorRose600 => Color(0xFF702828); + + /// #fcf4f0 + static Color get subtleColorPapaya100 => Color(0xFFFCF4F0); + + /// #fae8de + static Color get subtleColorPapaya200 => Color(0xFFFAE8DE); + + /// #fadfd2 + static Color get subtleColorPapaya300 => Color(0xFFFADFD2); + + /// #f0bda3 + static Color get subtleColorPapaya400 => Color(0xFFF0BDA3); + + /// #d67240 + static Color get subtleColorPapaya500 => Color(0xFFD67240); + + /// #6b3215 + static Color get subtleColorPapaya600 => Color(0xFF6B3215); + + /// #fff7ed + static Color get subtleColorTangerine100 => Color(0xFFFFF7ED); + + /// #fcedd9 + static Color get subtleColorTangerine200 => Color(0xFFFCEDD9); + + /// #fae5ca + static Color get subtleColorTangerine300 => Color(0xFFFAE5CA); + + /// #f2cb99 + static Color get subtleColorTangerine400 => Color(0xFFF2CB99); + + /// #db8f2c + static Color get subtleColorTangerine500 => Color(0xFFDB8F2C); + + /// #613b0a + static Color get subtleColorTangerine600 => Color(0xFF613B0A); + + /// #fff9ec + static Color get subtleColorMango100 => Color(0xFFFFF9EC); + + /// #fcf1d7 + static Color get subtleColorMango200 => Color(0xFFFCF1D7); + + /// #fae9c3 + static Color get subtleColorMango300 => Color(0xFFFAE9C3); + + /// #f5d68e + static Color get subtleColorMango400 => Color(0xFFF5D68E); + + /// #e0a416 + static Color get subtleColorMango500 => Color(0xFFE0A416); + + /// #5c4102 + static Color get subtleColorMango600 => Color(0xFF5C4102); + + /// #fffbe8 + static Color get subtleColorLemon100 => Color(0xFFFFFBE8); + + /// #fcf5cf + static Color get subtleColorLemon200 => Color(0xFFFCF5CF); + + /// #faefb9 + static Color get subtleColorLemon300 => Color(0xFFFAEFB9); + + /// #f5e282 + static Color get subtleColorLemon400 => Color(0xFFF5E282); + + /// #e0bb00 + static Color get subtleColorLemon500 => Color(0xFFE0BB00); + + /// #574800 + static Color get subtleColorLemon600 => Color(0xFF574800); + + /// #f9fae6 + static Color get subtleColorOlive100 => Color(0xFFF9FAE6); + + /// #f6f7d0 + static Color get subtleColorOlive200 => Color(0xFFF6F7D0); + + /// #f0f2b3 + static Color get subtleColorOlive300 => Color(0xFFF0F2B3); + + /// #dbde83 + static Color get subtleColorOlive400 => Color(0xFFDBDE83); + + /// #adb204 + static Color get subtleColorOlive500 => Color(0xFFADB204); + + /// #4a4c03 + static Color get subtleColorOlive600 => Color(0xFF4A4C03); + + /// #f6f9e6 + static Color get subtleColorLime100 => Color(0xFFF6F9E6); + + /// #eef5ce + static Color get subtleColorLime200 => Color(0xFFEEF5CE); + + /// #e7f0bb + static Color get subtleColorLime300 => Color(0xFFE7F0BB); + + /// #cfdb91 + static Color get subtleColorLime400 => Color(0xFFCFDB91); + + /// #92a822 + static Color get subtleColorLime500 => Color(0xFF92A822); + + /// #414d05 + static Color get subtleColorLime600 => Color(0xFF414D05); + + /// #f4faeb + static Color get subtleColorGrass100 => Color(0xFFF4FAEB); + + /// #e9f5d7 + static Color get subtleColorGrass200 => Color(0xFFE9F5D7); + + /// #def0c5 + static Color get subtleColorGrass300 => Color(0xFFDEF0C5); + + /// #bfd998 + static Color get subtleColorGrass400 => Color(0xFFBFD998); + + /// #75a828 + static Color get subtleColorGrass500 => Color(0xFF75A828); + + /// #334d0c + static Color get subtleColorGrass600 => Color(0xFF334D0C); + + /// #f1faf0 + static Color get subtleColorForest100 => Color(0xFFF1FAF0); + + /// #e2f5df + static Color get subtleColorForest200 => Color(0xFFE2F5DF); + + /// #d7f0d3 + static Color get subtleColorForest300 => Color(0xFFD7F0D3); + + /// #a8d6a1 + static Color get subtleColorForest400 => Color(0xFFA8D6A1); + + /// #49a33b + static Color get subtleColorForest500 => Color(0xFF49A33B); + + /// #1e4f16 + static Color get subtleColorForest600 => Color(0xFF1E4F16); + + /// #f0faf6 + static Color get subtleColorJade100 => Color(0xFFF0FAF6); + + /// #dff5eb + static Color get subtleColorJade200 => Color(0xFFDFF5EB); + + /// #cef0e1 + static Color get subtleColorJade300 => Color(0xFFCEF0E1); + + /// #90d1b5 + static Color get subtleColorJade400 => Color(0xFF90D1B5); + + /// #1c9963 + static Color get subtleColorJade500 => Color(0xFF1C9963); + + /// #075231 + static Color get subtleColorJade600 => Color(0xFF075231); + + /// #f0f9fa + static Color get subtleColorAqua100 => Color(0xFFF0F9FA); + + /// #dff3f5 + static Color get subtleColorAqua200 => Color(0xFFDFF3F5); + + /// #ccecf0 + static Color get subtleColorAqua300 => Color(0xFFCCECF0); + + /// #83ccd4 + static Color get subtleColorAqua400 => Color(0xFF83CCD4); + + /// #008e9e + static Color get subtleColorAqua500 => Color(0xFF008E9E); + + /// #004e57 + static Color get subtleColorAqua600 => Color(0xFF004E57); + + /// #f0f6fa + static Color get subtleColorAzure100 => Color(0xFFF0F6FA); + + /// #e1eef7 + static Color get subtleColorAzure200 => Color(0xFFE1EEF7); + + /// #d3e6f5 + static Color get subtleColorAzure300 => Color(0xFFD3E6F5); + + /// #88c0eb + static Color get subtleColorAzure400 => Color(0xFF88C0EB); + + /// #0877cc + static Color get subtleColorAzure500 => Color(0xFF0877CC); + + /// #154469 + static Color get subtleColorAzure600 => Color(0xFF154469); + + /// #f0f3fa + static Color get subtleColorDenim100 => Color(0xFFF0F3FA); + + /// #e3ebfa + static Color get subtleColorDenim200 => Color(0xFFE3EBFA); + + /// #d7e2f7 + static Color get subtleColorDenim300 => Color(0xFFD7E2F7); + + /// #9ab6ed + static Color get subtleColorDenim400 => Color(0xFF9AB6ED); + + /// #3267d1 + static Color get subtleColorDenim500 => Color(0xFF3267D1); + + /// #223c70 + static Color get subtleColorDenim600 => Color(0xFF223C70); + + /// #f2f2fc + static Color get subtleColorMauve100 => Color(0xFFF2F2FC); + + /// #e6e6fa + static Color get subtleColorMauve200 => Color(0xFFE6E6FA); + + /// #dcdcf7 + static Color get subtleColorMauve300 => Color(0xFFDCDCF7); + + /// #aeaef5 + static Color get subtleColorMauve400 => Color(0xFFAEAEF5); + + /// #5555e0 + static Color get subtleColorMauve500 => Color(0xFF5555E0); + + /// #36366b + static Color get subtleColorMauve600 => Color(0xFF36366B); + + /// #f6f3fc + static Color get subtleColorLavender100 => Color(0xFFF6F3FC); + + /// #ebe3fa + static Color get subtleColorLavender200 => Color(0xFFEBE3FA); + + /// #e4daf7 + static Color get subtleColorLavender300 => Color(0xFFE4DAF7); + + /// #c1aaf0 + static Color get subtleColorLavender400 => Color(0xFFC1AAF0); + + /// #8153db + static Color get subtleColorLavender500 => Color(0xFF8153DB); + + /// #462f75 + static Color get subtleColorLavender600 => Color(0xFF462F75); + + /// #f7f0fa + static Color get subtleColorLilac100 => Color(0xFFF7F0FA); + + /// #f0e1f7 + static Color get subtleColorLilac200 => Color(0xFFF0E1F7); + + /// #edd7f7 + static Color get subtleColorLilac300 => Color(0xFFEDD7F7); + + /// #d3a9e8 + static Color get subtleColorLilac400 => Color(0xFFD3A9E8); + + /// #9e4cc7 + static Color get subtleColorLilac500 => Color(0xFF9E4CC7); + + /// #562d6b + static Color get subtleColorLilac600 => Color(0xFF562D6B); + + /// #faf0fa + static Color get subtleColorMallow100 => Color(0xFFFAF0FA); + + /// #f5e1f4 + static Color get subtleColorMallow200 => Color(0xFFF5E1F4); + + /// #f5d7f4 + static Color get subtleColorMallow300 => Color(0xFFF5D7F4); + + /// #dea4dc + static Color get subtleColorMallow400 => Color(0xFFDEA4DC); + + /// #b240af + static Color get subtleColorMallow500 => Color(0xFFB240AF); + + /// #632861 + static Color get subtleColorMallow600 => Color(0xFF632861); + + /// #f9eff3 + static Color get subtleColorCamellia100 => Color(0xFFF9EFF3); + + /// #f7e1eb + static Color get subtleColorCamellia200 => Color(0xFFF7E1EB); + + /// #f7d7e5 + static Color get subtleColorCamellia300 => Color(0xFFF7D7E5); + + /// #e5a3c0 + static Color get subtleColorCamellia400 => Color(0xFFE5A3C0); + + /// #c24279 + static Color get subtleColorCamellia500 => Color(0xFFC24279); + + /// #6e2343 + static Color get subtleColorCamellia600 => Color(0xFF6E2343); + + /// #f5f5f5 + static Color get subtleColorSmoke100 => Color(0xFFF5F5F5); + + /// #e8e8e8 + static Color get subtleColorSmoke200 => Color(0xFFE8E8E8); + + /// #dedede + static Color get subtleColorSmoke300 => Color(0xFFDEDEDE); + + /// #b8b8b8 + static Color get subtleColorSmoke400 => Color(0xFFB8B8B8); + + /// #6e6e6e + static Color get subtleColorSmoke500 => Color(0xFF6E6E6E); + + /// #404040 + static Color get subtleColorSmoke600 => Color(0xFF404040); + + /// #f2f4f7 + static Color get subtleColorIron100 => Color(0xFFF2F4F7); + + /// #e6e9f0 + static Color get subtleColorIron200 => Color(0xFFE6E9F0); + + /// #dadee5 + static Color get subtleColorIron300 => Color(0xFFDADEE5); + + /// #b0b5bf + static Color get subtleColorIron400 => Color(0xFFB0B5BF); + + /// #666f80 + static Color get subtleColorIron500 => Color(0xFF666F80); + + /// #394152 + static Color get subtleColorIron600 => Color(0xFF394152); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart new file mode 100644 index 0000000000..fe774d3561 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart @@ -0,0 +1,326 @@ +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: 2025-04-19T13:45:56.089922 +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +import '../shared.dart'; +import 'primitive.dart'; + +class AppFlowyDefaultTheme implements AppFlowyThemeBuilder { + @override + AppFlowyThemeData light() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.light); + + final textColorScheme = AppFlowyTextColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + secondary: AppFlowyPrimitiveTokens.neutral600, + tertiary: AppFlowyPrimitiveTokens.neutral400, + quaternary: AppFlowyPrimitiveTokens.neutral200, + inverse: AppFlowyPrimitiveTokens.neutralWhite, + onFill: AppFlowyPrimitiveTokens.neutralWhite, + theme: AppFlowyPrimitiveTokens.blue500, + themeHover: AppFlowyPrimitiveTokens.blue600, + action: AppFlowyPrimitiveTokens.blue500, + actionHover: AppFlowyPrimitiveTokens.blue600, + info: AppFlowyPrimitiveTokens.blue500, + infoHover: AppFlowyPrimitiveTokens.blue600, + success: AppFlowyPrimitiveTokens.green600, + successHover: AppFlowyPrimitiveTokens.green700, + warning: AppFlowyPrimitiveTokens.orange600, + warningHover: AppFlowyPrimitiveTokens.orange700, + error: AppFlowyPrimitiveTokens.red600, + errorHover: AppFlowyPrimitiveTokens.red700, + purple: AppFlowyPrimitiveTokens.purple500, + purpleHover: AppFlowyPrimitiveTokens.purple600, + ); + + final iconColorScheme = AppFlowyIconColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + secondary: AppFlowyPrimitiveTokens.neutral600, + tertiary: AppFlowyPrimitiveTokens.neutral400, + quaternary: AppFlowyPrimitiveTokens.neutral200, + white: AppFlowyPrimitiveTokens.neutralWhite, + purpleThick: AppFlowyPrimitiveTokens.purple500, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + ); + + final borderColorScheme = AppFlowyBorderColorScheme( + greyPrimary: AppFlowyPrimitiveTokens.neutral1000, + greyPrimaryHover: AppFlowyPrimitiveTokens.neutral900, + greySecondary: AppFlowyPrimitiveTokens.neutral800, + greySecondaryHover: AppFlowyPrimitiveTokens.neutral700, + greyTertiary: AppFlowyPrimitiveTokens.neutral300, + greyTertiaryHover: AppFlowyPrimitiveTokens.neutral400, + greyQuaternary: AppFlowyPrimitiveTokens.neutral100, + greyQuaternaryHover: AppFlowyPrimitiveTokens.neutral200, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue600, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorThick: AppFlowyPrimitiveTokens.red600, + errorThickHover: AppFlowyPrimitiveTokens.red700, + purpleThick: AppFlowyPrimitiveTokens.purple500, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + ); + + final fillColorScheme = AppFlowyFillColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + primaryHover: AppFlowyPrimitiveTokens.neutral900, + secondary: AppFlowyPrimitiveTokens.neutral600, + secondaryHover: AppFlowyPrimitiveTokens.neutral500, + tertiary: AppFlowyPrimitiveTokens.neutral300, + tertiaryHover: AppFlowyPrimitiveTokens.neutral400, + quaternary: AppFlowyPrimitiveTokens.neutral100, + quaternaryHover: AppFlowyPrimitiveTokens.neutral200, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + primaryAlpha5: AppFlowyPrimitiveTokens.neutralAlphaGrey100005, + primaryAlpha5Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey100010, + primaryAlpha80: AppFlowyPrimitiveTokens.neutralAlphaGrey100080, + primaryAlpha80Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey100070, + white: AppFlowyPrimitiveTokens.neutralWhite, + whiteAlpha: AppFlowyPrimitiveTokens.neutralAlphaWhite20, + whiteAlphaHover: AppFlowyPrimitiveTokens.neutralAlphaWhite30, + black: AppFlowyPrimitiveTokens.neutralBlack, + themeLight: AppFlowyPrimitiveTokens.blue100, + themeLightHover: AppFlowyPrimitiveTokens.blue200, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue600, + themeSelect: AppFlowyPrimitiveTokens.blueAlphaBlue50015, + infoLight: AppFlowyPrimitiveTokens.blue100, + infoLightHover: AppFlowyPrimitiveTokens.blue200, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successLight: AppFlowyPrimitiveTokens.green100, + successLightHover: AppFlowyPrimitiveTokens.green200, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningLight: AppFlowyPrimitiveTokens.orange100, + warningLightHover: AppFlowyPrimitiveTokens.orange200, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorLight: AppFlowyPrimitiveTokens.red100, + errorLightHover: AppFlowyPrimitiveTokens.red200, + errorThick: AppFlowyPrimitiveTokens.red600, + errorThickHover: AppFlowyPrimitiveTokens.red700, + errorSelect: AppFlowyPrimitiveTokens.redAlphaRed50010, + purpleLight: AppFlowyPrimitiveTokens.purple100, + purpleLightHover: AppFlowyPrimitiveTokens.purple200, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + purpleThick: AppFlowyPrimitiveTokens.purple500, + ); + + final surfaceColorScheme = AppFlowySurfaceColorScheme( + primary: AppFlowyPrimitiveTokens.neutralWhite, + overlay: AppFlowyPrimitiveTokens.neutralAlphaBlack60, + ); + + final backgroundColorScheme = AppFlowyBackgroundColorScheme( + primary: AppFlowyPrimitiveTokens.neutralWhite, + secondary: AppFlowyPrimitiveTokens.neutral100, + tertiary: AppFlowyPrimitiveTokens.neutral200, + quaternary: AppFlowyPrimitiveTokens.neutral300, + ); + + final brandColorScheme = AppFlowyBrandColorScheme( + skyline: Color(0xFF00B5FF), + aqua: Color(0xFF00C8FF), + violet: Color(0xFF9327FF), + amethyst: Color(0xFF8427E0), + berry: Color(0xFFE3006D), + coral: Color(0xFFFB006D), + golden: Color(0xFFF7931E), + amber: Color(0xFFFFBD00), + lemon: Color(0xFFFFCE00), + ); + + final otherColorsColorScheme = AppFlowyOtherColorsColorScheme( + textHighlight: AppFlowyPrimitiveTokens.blue200, + ); + + return AppFlowyThemeData( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + } + + @override + AppFlowyThemeData dark() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.dark); + + final textColorScheme = AppFlowyTextColorScheme( + primary: AppFlowyPrimitiveTokens.neutral200, + secondary: AppFlowyPrimitiveTokens.neutral400, + tertiary: AppFlowyPrimitiveTokens.neutral600, + quaternary: AppFlowyPrimitiveTokens.neutral1000, + inverse: AppFlowyPrimitiveTokens.neutral1000, + onFill: AppFlowyPrimitiveTokens.neutralWhite, + theme: AppFlowyPrimitiveTokens.blue500, + themeHover: AppFlowyPrimitiveTokens.blue600, + action: AppFlowyPrimitiveTokens.blue500, + actionHover: AppFlowyPrimitiveTokens.blue600, + info: AppFlowyPrimitiveTokens.blue500, + infoHover: AppFlowyPrimitiveTokens.blue600, + success: AppFlowyPrimitiveTokens.green600, + successHover: AppFlowyPrimitiveTokens.green700, + warning: AppFlowyPrimitiveTokens.orange600, + warningHover: AppFlowyPrimitiveTokens.orange700, + error: AppFlowyPrimitiveTokens.red500, + errorHover: AppFlowyPrimitiveTokens.red400, + purple: AppFlowyPrimitiveTokens.purple500, + purpleHover: AppFlowyPrimitiveTokens.purple600, + ); + + final iconColorScheme = AppFlowyIconColorScheme( + primary: AppFlowyPrimitiveTokens.neutral200, + secondary: AppFlowyPrimitiveTokens.neutral400, + tertiary: AppFlowyPrimitiveTokens.neutral600, + quaternary: AppFlowyPrimitiveTokens.neutral1000, + white: AppFlowyPrimitiveTokens.neutralWhite, + purpleThick: Color(0xFFFFFFFF), + purpleThickHover: Color(0xFFFFFFFF), + ); + + final borderColorScheme = AppFlowyBorderColorScheme( + greyPrimary: AppFlowyPrimitiveTokens.neutral100, + greyPrimaryHover: AppFlowyPrimitiveTokens.neutral200, + greySecondary: AppFlowyPrimitiveTokens.neutral300, + greySecondaryHover: AppFlowyPrimitiveTokens.neutral400, + greyTertiary: AppFlowyPrimitiveTokens.neutral800, + greyTertiaryHover: AppFlowyPrimitiveTokens.neutral700, + greyQuaternary: AppFlowyPrimitiveTokens.neutral1000, + greyQuaternaryHover: AppFlowyPrimitiveTokens.neutral900, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue600, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorThick: AppFlowyPrimitiveTokens.red500, + errorThickHover: AppFlowyPrimitiveTokens.red400, + purpleThick: AppFlowyPrimitiveTokens.purple500, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + ); + + final fillColorScheme = AppFlowyFillColorScheme( + primary: AppFlowyPrimitiveTokens.neutral100, + primaryHover: AppFlowyPrimitiveTokens.neutral200, + secondary: AppFlowyPrimitiveTokens.neutral300, + secondaryHover: AppFlowyPrimitiveTokens.neutral400, + tertiary: AppFlowyPrimitiveTokens.neutral600, + tertiaryHover: AppFlowyPrimitiveTokens.neutral500, + quaternary: AppFlowyPrimitiveTokens.neutral1000, + quaternaryHover: AppFlowyPrimitiveTokens.neutral900, + transparent: AppFlowyPrimitiveTokens.neutralAlphaWhite0, + primaryAlpha5: AppFlowyPrimitiveTokens.neutralAlphaGrey10005, + primaryAlpha5Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey10010, + primaryAlpha80: AppFlowyPrimitiveTokens.neutralAlphaGrey100080, + primaryAlpha80Hover: AppFlowyPrimitiveTokens.neutralAlphaGrey100070, + white: AppFlowyPrimitiveTokens.neutralWhite, + whiteAlpha: AppFlowyPrimitiveTokens.neutralAlphaWhite20, + whiteAlphaHover: AppFlowyPrimitiveTokens.neutralAlphaWhite30, + black: AppFlowyPrimitiveTokens.neutralBlack, + themeLight: AppFlowyPrimitiveTokens.blue100, + themeLightHover: AppFlowyPrimitiveTokens.blue200, + themeThick: AppFlowyPrimitiveTokens.blue500, + themeThickHover: AppFlowyPrimitiveTokens.blue400, + themeSelect: AppFlowyPrimitiveTokens.blueAlphaBlue50015, + infoLight: AppFlowyPrimitiveTokens.blue100, + infoLightHover: AppFlowyPrimitiveTokens.blue200, + infoThick: AppFlowyPrimitiveTokens.blue500, + infoThickHover: AppFlowyPrimitiveTokens.blue600, + successLight: AppFlowyPrimitiveTokens.green100, + successLightHover: AppFlowyPrimitiveTokens.green200, + successThick: AppFlowyPrimitiveTokens.green600, + successThickHover: AppFlowyPrimitiveTokens.green700, + warningLight: AppFlowyPrimitiveTokens.orange100, + warningLightHover: AppFlowyPrimitiveTokens.orange200, + warningThick: AppFlowyPrimitiveTokens.orange600, + warningThickHover: AppFlowyPrimitiveTokens.orange700, + errorLight: AppFlowyPrimitiveTokens.red100, + errorLightHover: AppFlowyPrimitiveTokens.red200, + errorThick: AppFlowyPrimitiveTokens.red600, + errorThickHover: AppFlowyPrimitiveTokens.red500, + errorSelect: AppFlowyPrimitiveTokens.redAlphaRed50010, + purpleLight: AppFlowyPrimitiveTokens.purple100, + purpleLightHover: AppFlowyPrimitiveTokens.purple200, + purpleThickHover: AppFlowyPrimitiveTokens.purple600, + purpleThick: AppFlowyPrimitiveTokens.purple500, + ); + + final surfaceColorScheme = AppFlowySurfaceColorScheme( + primary: AppFlowyPrimitiveTokens.neutral900, + overlay: AppFlowyPrimitiveTokens.neutralAlphaBlack60, + ); + + final backgroundColorScheme = AppFlowyBackgroundColorScheme( + primary: AppFlowyPrimitiveTokens.neutral1000, + secondary: AppFlowyPrimitiveTokens.neutral900, + tertiary: AppFlowyPrimitiveTokens.neutral800, + quaternary: AppFlowyPrimitiveTokens.neutral700, + ); + + final brandColorScheme = AppFlowyBrandColorScheme( + skyline: Color(0xFF00B5FF), + aqua: Color(0xFF00C8FF), + violet: Color(0xFF9327FF), + amethyst: Color(0xFF8427E0), + berry: Color(0xFFE3006D), + coral: Color(0xFFFB006D), + golden: Color(0xFFF7931E), + amber: Color(0xFFFFBD00), + lemon: Color(0xFFFFCE00), + ); + + final otherColorsColorScheme = AppFlowyOtherColorsColorScheme( + textHighlight: AppFlowyPrimitiveTokens.blue200, + ); + + return AppFlowyThemeData( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/built_in_themes.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/built_in_themes.dart new file mode 100644 index 0000000000..2b29371433 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/built_in_themes.dart @@ -0,0 +1 @@ +export 'appflowy_default/semantic.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/custom/custom_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/custom/custom_theme.dart new file mode 100644 index 0000000000..6ef43076c5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/custom/custom_theme.dart @@ -0,0 +1,23 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; + +class CustomTheme implements AppFlowyThemeBuilder { + const CustomTheme({ + required this.lightThemeJson, + required this.darkThemeJson, + }); + + final Map lightThemeJson; + final Map darkThemeJson; + + @override + AppFlowyThemeData light() { + // TODO: implement light + throw UnimplementedError(); + } + + @override + AppFlowyThemeData dark() { + // TODO: implement dark + throw UnimplementedError(); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/shared.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/shared.dart new file mode 100644 index 0000000000..c9c3c3adb0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/shared.dart @@ -0,0 +1,87 @@ +import 'package:appflowy_ui/src/theme/definition/border_radius/border_radius.dart'; +import 'package:appflowy_ui/src/theme/definition/shadow/shadow.dart'; +import 'package:appflowy_ui/src/theme/definition/spacing/spacing.dart'; +import 'package:flutter/material.dart'; + +class AppFlowySpacingConstant { + static const double spacing100 = 4; + static const double spacing200 = 6; + static const double spacing300 = 8; + static const double spacing400 = 12; + static const double spacing500 = 16; + static const double spacing600 = 20; +} + +class AppFlowyBorderRadiusConstant { + static const double radius100 = 4; + static const double radius200 = 6; + static const double radius300 = 8; + static const double radius400 = 12; + static const double radius500 = 16; + static const double radius600 = 20; +} + +class AppFlowySharedTokens { + const AppFlowySharedTokens(); + + static AppFlowyBorderRadius buildBorderRadius() { + return AppFlowyBorderRadius( + xs: AppFlowyBorderRadiusConstant.radius100, + s: AppFlowyBorderRadiusConstant.radius200, + m: AppFlowyBorderRadiusConstant.radius300, + l: AppFlowyBorderRadiusConstant.radius400, + xl: AppFlowyBorderRadiusConstant.radius500, + xxl: AppFlowyBorderRadiusConstant.radius600, + ); + } + + static AppFlowySpacing buildSpacing() { + return AppFlowySpacing( + xs: AppFlowySpacingConstant.spacing100, + s: AppFlowySpacingConstant.spacing200, + m: AppFlowySpacingConstant.spacing300, + l: AppFlowySpacingConstant.spacing400, + xl: AppFlowySpacingConstant.spacing500, + xxl: AppFlowySpacingConstant.spacing600, + ); + } + + static AppFlowyShadow buildShadow( + Brightness brightness, + ) { + return switch (brightness) { + Brightness.light => AppFlowyShadow( + small: [ + BoxShadow( + offset: Offset(0, 2), + blurRadius: 16, + color: Color(0x1F000000), + ), + ], + medium: [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 32, + color: Color(0x1F000000), + ), + ], + ), + Brightness.dark => AppFlowyShadow( + small: [ + BoxShadow( + offset: Offset(0, 2), + blurRadius: 16, + color: Color(0x7A000000), + ), + ], + medium: [ + BoxShadow( + offset: Offset(0, 4), + blurRadius: 32, + color: Color(0x7A000000), + ), + ], + ), + }; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/border_radius/border_radius.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/border_radius/border_radius.dart new file mode 100644 index 0000000000..fb07a5fe64 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/border_radius/border_radius.dart @@ -0,0 +1,17 @@ +class AppFlowyBorderRadius { + const AppFlowyBorderRadius({ + required this.xs, + required this.s, + required this.m, + required this.l, + required this.xl, + required this.xxl, + }); + + final double xs; + final double s; + final double m; + final double l; + final double xl; + final double xxl; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart new file mode 100644 index 0000000000..c7324c34fe --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class AppFlowyBackgroundColorScheme { + const AppFlowyBackgroundColorScheme({ + required this.primary, + required this.secondary, + required this.tertiary, + required this.quaternary, + }); + + final Color primary; + final Color secondary; + final Color tertiary; + final Color quaternary; + + AppFlowyBackgroundColorScheme lerp( + AppFlowyBackgroundColorScheme other, + double t, + ) { + return AppFlowyBackgroundColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart new file mode 100644 index 0000000000..28eee5b145 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +class AppFlowyBorderColorScheme { + const AppFlowyBorderColorScheme({ + required this.greyPrimary, + required this.greyPrimaryHover, + required this.greySecondary, + required this.greySecondaryHover, + required this.greyTertiary, + required this.greyTertiaryHover, + required this.greyQuaternary, + required this.greyQuaternaryHover, + required this.transparent, + required this.themeThick, + required this.themeThickHover, + required this.infoThick, + required this.infoThickHover, + required this.successThick, + required this.successThickHover, + required this.warningThick, + required this.warningThickHover, + required this.errorThick, + required this.errorThickHover, + required this.purpleThick, + required this.purpleThickHover, + }); + + final Color greyPrimary; + final Color greyPrimaryHover; + final Color greySecondary; + final Color greySecondaryHover; + final Color greyTertiary; + final Color greyTertiaryHover; + final Color greyQuaternary; + final Color greyQuaternaryHover; + final Color transparent; + final Color themeThick; + final Color themeThickHover; + final Color infoThick; + final Color infoThickHover; + final Color successThick; + final Color successThickHover; + final Color warningThick; + final Color warningThickHover; + final Color errorThick; + final Color errorThickHover; + final Color purpleThick; + final Color purpleThickHover; + + AppFlowyBorderColorScheme lerp( + AppFlowyBorderColorScheme other, + double t, + ) { + return AppFlowyBorderColorScheme( + greyPrimary: Color.lerp(greyPrimary, other.greyPrimary, t)!, + greyPrimaryHover: + Color.lerp(greyPrimaryHover, other.greyPrimaryHover, t)!, + greySecondary: Color.lerp(greySecondary, other.greySecondary, t)!, + greySecondaryHover: + Color.lerp(greySecondaryHover, other.greySecondaryHover, t)!, + greyTertiary: Color.lerp(greyTertiary, other.greyTertiary, t)!, + greyTertiaryHover: + Color.lerp(greyTertiaryHover, other.greyTertiaryHover, t)!, + greyQuaternary: Color.lerp(greyQuaternary, other.greyQuaternary, t)!, + greyQuaternaryHover: + Color.lerp(greyQuaternaryHover, other.greyQuaternaryHover, t)!, + transparent: Color.lerp(transparent, other.transparent, t)!, + themeThick: Color.lerp(themeThick, other.themeThick, t)!, + themeThickHover: Color.lerp(themeThickHover, other.themeThickHover, t)!, + infoThick: Color.lerp(infoThick, other.infoThick, t)!, + infoThickHover: Color.lerp(infoThickHover, other.infoThickHover, t)!, + successThick: Color.lerp(successThick, other.successThick, t)!, + successThickHover: + Color.lerp(successThickHover, other.successThickHover, t)!, + warningThick: Color.lerp(warningThick, other.warningThick, t)!, + warningThickHover: + Color.lerp(warningThickHover, other.warningThickHover, t)!, + errorThick: Color.lerp(errorThick, other.errorThick, t)!, + errorThickHover: Color.lerp(errorThickHover, other.errorThickHover, t)!, + purpleThick: Color.lerp(purpleThick, other.purpleThick, t)!, + purpleThickHover: + Color.lerp(purpleThickHover, other.purpleThickHover, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart new file mode 100644 index 0000000000..4140f6924a --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class AppFlowyBrandColorScheme { + const AppFlowyBrandColorScheme({ + required this.skyline, + required this.aqua, + required this.violet, + required this.amethyst, + required this.berry, + required this.coral, + required this.golden, + required this.amber, + required this.lemon, + }); + + final Color skyline; + final Color aqua; + final Color violet; + final Color amethyst; + final Color berry; + final Color coral; + final Color golden; + final Color amber; + final Color lemon; + + AppFlowyBrandColorScheme lerp( + AppFlowyBrandColorScheme other, + double t, + ) { + return AppFlowyBrandColorScheme( + skyline: Color.lerp(skyline, other.skyline, t)!, + aqua: Color.lerp(aqua, other.aqua, t)!, + violet: Color.lerp(violet, other.violet, t)!, + amethyst: Color.lerp(amethyst, other.amethyst, t)!, + berry: Color.lerp(berry, other.berry, t)!, + coral: Color.lerp(coral, other.coral, t)!, + golden: Color.lerp(golden, other.golden, t)!, + amber: Color.lerp(amber, other.amber, t)!, + lemon: Color.lerp(lemon, other.lemon, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/color_scheme.dart new file mode 100644 index 0000000000..01952e1461 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/color_scheme.dart @@ -0,0 +1,8 @@ +export 'background_color_scheme.dart'; +export 'border_color_scheme.dart'; +export 'brand_color_scheme.dart'; +export 'fill_color_scheme.dart'; +export 'icon_color_scheme.dart'; +export 'other_color_scheme.dart'; +export 'surface_color_scheme.dart'; +export 'text_color_scheme.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart new file mode 100644 index 0000000000..3faac64dfc --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; + +class AppFlowyFillColorScheme { + const AppFlowyFillColorScheme({ + required this.primary, + required this.primaryHover, + required this.secondary, + required this.secondaryHover, + required this.tertiary, + required this.tertiaryHover, + required this.quaternary, + required this.quaternaryHover, + required this.transparent, + required this.primaryAlpha5, + required this.primaryAlpha5Hover, + required this.primaryAlpha80, + required this.primaryAlpha80Hover, + required this.white, + required this.whiteAlpha, + required this.whiteAlphaHover, + required this.black, + required this.themeLight, + required this.themeLightHover, + required this.themeThick, + required this.themeThickHover, + required this.themeSelect, + required this.infoLight, + required this.infoLightHover, + required this.infoThick, + required this.infoThickHover, + required this.successLight, + required this.successLightHover, + required this.successThick, + required this.successThickHover, + required this.warningLight, + required this.warningLightHover, + required this.warningThick, + required this.warningThickHover, + required this.errorLight, + required this.errorLightHover, + required this.errorThick, + required this.errorThickHover, + required this.errorSelect, + required this.purpleLight, + required this.purpleLightHover, + required this.purpleThick, + required this.purpleThickHover, + }); + + final Color primary; + final Color primaryHover; + final Color secondary; + final Color secondaryHover; + final Color tertiary; + final Color tertiaryHover; + final Color quaternary; + final Color quaternaryHover; + final Color transparent; + final Color primaryAlpha5; + final Color primaryAlpha5Hover; + final Color primaryAlpha80; + final Color primaryAlpha80Hover; + final Color white; + final Color whiteAlpha; + final Color whiteAlphaHover; + final Color black; + final Color themeLight; + final Color themeLightHover; + final Color themeThick; + final Color themeThickHover; + final Color themeSelect; + final Color infoLight; + final Color infoLightHover; + final Color infoThick; + final Color infoThickHover; + final Color successLight; + final Color successLightHover; + final Color successThick; + final Color successThickHover; + final Color warningLight; + final Color warningLightHover; + final Color warningThick; + final Color warningThickHover; + final Color errorLight; + final Color errorLightHover; + final Color errorThick; + final Color errorThickHover; + final Color errorSelect; + final Color purpleLight; + final Color purpleLightHover; + final Color purpleThick; + final Color purpleThickHover; + + AppFlowyFillColorScheme lerp( + AppFlowyFillColorScheme other, + double t, + ) { + return AppFlowyFillColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + primaryHover: Color.lerp(primaryHover, other.primaryHover, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + secondaryHover: Color.lerp(secondaryHover, other.secondaryHover, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + tertiaryHover: Color.lerp(tertiaryHover, other.tertiaryHover, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + quaternaryHover: Color.lerp(quaternaryHover, other.quaternaryHover, t)!, + transparent: Color.lerp(transparent, other.transparent, t)!, + primaryAlpha5: Color.lerp(primaryAlpha5, other.primaryAlpha5, t)!, + primaryAlpha5Hover: + Color.lerp(primaryAlpha5Hover, other.primaryAlpha5Hover, t)!, + primaryAlpha80: Color.lerp(primaryAlpha80, other.primaryAlpha80, t)!, + primaryAlpha80Hover: + Color.lerp(primaryAlpha80Hover, other.primaryAlpha80Hover, t)!, + white: Color.lerp(white, other.white, t)!, + whiteAlpha: Color.lerp(whiteAlpha, other.whiteAlpha, t)!, + whiteAlphaHover: Color.lerp(whiteAlphaHover, other.whiteAlphaHover, t)!, + black: Color.lerp(black, other.black, t)!, + themeLight: Color.lerp(themeLight, other.themeLight, t)!, + themeLightHover: Color.lerp(themeLightHover, other.themeLightHover, t)!, + themeThick: Color.lerp(themeThick, other.themeThick, t)!, + themeThickHover: Color.lerp(themeThickHover, other.themeThickHover, t)!, + themeSelect: Color.lerp(themeSelect, other.themeSelect, t)!, + infoLight: Color.lerp(infoLight, other.infoLight, t)!, + infoLightHover: Color.lerp(infoLightHover, other.infoLightHover, t)!, + infoThick: Color.lerp(infoThick, other.infoThick, t)!, + infoThickHover: Color.lerp(infoThickHover, other.infoThickHover, t)!, + successLight: Color.lerp(successLight, other.successLight, t)!, + successLightHover: + Color.lerp(successLightHover, other.successLightHover, t)!, + successThick: Color.lerp(successThick, other.successThick, t)!, + successThickHover: + Color.lerp(successThickHover, other.successThickHover, t)!, + warningLight: Color.lerp(warningLight, other.warningLight, t)!, + warningLightHover: + Color.lerp(warningLightHover, other.warningLightHover, t)!, + warningThick: Color.lerp(warningThick, other.warningThick, t)!, + warningThickHover: + Color.lerp(warningThickHover, other.warningThickHover, t)!, + errorLight: Color.lerp(errorLight, other.errorLight, t)!, + errorLightHover: Color.lerp(errorLightHover, other.errorLightHover, t)!, + errorThick: Color.lerp(errorThick, other.errorThick, t)!, + errorThickHover: Color.lerp(errorThickHover, other.errorThickHover, t)!, + errorSelect: Color.lerp(errorSelect, other.errorSelect, t)!, + purpleLight: Color.lerp(purpleLight, other.purpleLight, t)!, + purpleLightHover: + Color.lerp(purpleLightHover, other.purpleLightHover, t)!, + purpleThick: Color.lerp(purpleThick, other.purpleThick, t)!, + purpleThickHover: + Color.lerp(purpleThickHover, other.purpleThickHover, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart new file mode 100644 index 0000000000..efe59b8b99 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class AppFlowyIconColorScheme { + const AppFlowyIconColorScheme({ + required this.primary, + required this.secondary, + required this.tertiary, + required this.quaternary, + required this.white, + required this.purpleThick, + required this.purpleThickHover, + }); + + final Color primary; + final Color secondary; + final Color tertiary; + final Color quaternary; + final Color white; + final Color purpleThick; + final Color purpleThickHover; + + AppFlowyIconColorScheme lerp( + AppFlowyIconColorScheme other, + double t, + ) { + return AppFlowyIconColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + white: Color.lerp(white, other.white, t)!, + purpleThick: Color.lerp(purpleThick, other.purpleThick, t)!, + purpleThickHover: + Color.lerp(purpleThickHover, other.purpleThickHover, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart new file mode 100644 index 0000000000..9bb21e54e6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart @@ -0,0 +1,18 @@ +import 'dart:ui'; + +class AppFlowyOtherColorsColorScheme { + const AppFlowyOtherColorsColorScheme({ + required this.textHighlight, + }); + + final Color textHighlight; + + AppFlowyOtherColorsColorScheme lerp( + AppFlowyOtherColorsColorScheme other, + double t, + ) { + return AppFlowyOtherColorsColorScheme( + textHighlight: Color.lerp(textHighlight, other.textHighlight, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart new file mode 100644 index 0000000000..67be450a04 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class AppFlowySurfaceColorScheme { + const AppFlowySurfaceColorScheme({ + required this.primary, + required this.overlay, + }); + + final Color primary; + final Color overlay; + + AppFlowySurfaceColorScheme lerp( + AppFlowySurfaceColorScheme other, + double t, + ) { + return AppFlowySurfaceColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + overlay: Color.lerp(overlay, other.overlay, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart new file mode 100644 index 0000000000..17e1f057ce --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +class AppFlowyTextColorScheme { + const AppFlowyTextColorScheme({ + required this.primary, + required this.secondary, + required this.tertiary, + required this.quaternary, + required this.inverse, + required this.onFill, + required this.theme, + required this.themeHover, + required this.action, + required this.actionHover, + required this.info, + required this.infoHover, + required this.success, + required this.successHover, + required this.warning, + required this.warningHover, + required this.error, + required this.errorHover, + required this.purple, + required this.purpleHover, + }); + + final Color primary; + final Color secondary; + final Color tertiary; + final Color quaternary; + final Color inverse; + final Color onFill; + final Color theme; + final Color themeHover; + final Color action; + final Color actionHover; + final Color info; + final Color infoHover; + final Color success; + final Color successHover; + final Color warning; + final Color warningHover; + final Color error; + final Color errorHover; + final Color purple; + final Color purpleHover; + + AppFlowyTextColorScheme lerp( + AppFlowyTextColorScheme other, + double t, + ) { + return AppFlowyTextColorScheme( + primary: Color.lerp(primary, other.primary, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + tertiary: Color.lerp(tertiary, other.tertiary, t)!, + quaternary: Color.lerp(quaternary, other.quaternary, t)!, + inverse: Color.lerp(inverse, other.inverse, t)!, + onFill: Color.lerp(onFill, other.onFill, t)!, + theme: Color.lerp(theme, other.theme, t)!, + themeHover: Color.lerp(themeHover, other.themeHover, t)!, + action: Color.lerp(action, other.action, t)!, + actionHover: Color.lerp(actionHover, other.actionHover, t)!, + info: Color.lerp(info, other.info, t)!, + infoHover: Color.lerp(infoHover, other.infoHover, t)!, + success: Color.lerp(success, other.success, t)!, + successHover: Color.lerp(successHover, other.successHover, t)!, + warning: Color.lerp(warning, other.warning, t)!, + warningHover: Color.lerp(warningHover, other.warningHover, t)!, + error: Color.lerp(error, other.error, t)!, + errorHover: Color.lerp(errorHover, other.errorHover, t)!, + purple: Color.lerp(purple, other.purple, t)!, + purpleHover: Color.lerp(purpleHover, other.purpleHover, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/shadow/shadow.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/shadow/shadow.dart new file mode 100644 index 0000000000..457b86265e --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/shadow/shadow.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart'; + +class AppFlowyShadow { + AppFlowyShadow({ + required this.small, + required this.medium, + }); + + final List small; + final List medium; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/spacing/spacing.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/spacing/spacing.dart new file mode 100644 index 0000000000..ea90784db3 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/spacing/spacing.dart @@ -0,0 +1,17 @@ +class AppFlowySpacing { + const AppFlowySpacing({ + required this.xs, + required this.s, + required this.m, + required this.l, + required this.xl, + required this.xxl, + }); + + final double xs; + final double s; + final double m; + final double l; + final double xl; + final double xxl; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/base/default_text_style.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/base/default_text_style.dart new file mode 100644 index 0000000000..3cdf267fe0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/base/default_text_style.dart @@ -0,0 +1,517 @@ +import 'package:flutter/widgets.dart'; + +abstract class TextThemeType { + const TextThemeType(); + + TextStyle standard({ + String family = '', + Color? color, + FontWeight? weight, + }); + + TextStyle enhanced({ + String family = '', + Color? color, + FontWeight? weight, + }); + + TextStyle prominent({ + String family = '', + Color? color, + FontWeight? weight, + }); + + TextStyle underline({ + String family = '', + Color? color, + FontWeight? weight, + }); +} + +class TextThemeHeading1 extends TextThemeType { + const TextThemeHeading1(); + + @override + TextStyle standard({ + String family = '', + Color? color, + FontWeight? weight, + }) => + _defaultTextStyle( + family: family, + fontSize: 36, + height: 40 / 36, + color: color, + weight: weight ?? FontWeight.w400, + ); + + @override + TextStyle enhanced({ + String family = '', + Color? color, + FontWeight? weight, + }) => + _defaultTextStyle( + family: family, + fontSize: 36, + height: 40 / 36, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({ + String family = '', + Color? color, + FontWeight? weight, + }) => + _defaultTextStyle( + family: family, + fontSize: 36, + height: 40 / 36, + color: color, + weight: weight ?? FontWeight.w700, + ); + + @override + TextStyle underline({ + String family = '', + Color? color, + FontWeight? weight, + }) => + _defaultTextStyle( + family: family, + fontSize: 36, + height: 40 / 36, + color: color, + weight: weight ?? FontWeight.bold, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + required double fontSize, + required double height, + TextDecoration decoration = TextDecoration.none, + Color? color, + FontWeight weight = FontWeight.bold, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeading2 extends TextThemeType { + const TextThemeHeading2(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w700, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 24, + double height = 32 / 24, + TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.w400, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeading3 extends TextThemeType { + const TextThemeHeading3(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w700, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 20, + double height = 28 / 20, + TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.w400, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeading4 extends TextThemeType { + const TextThemeHeading4(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w700, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w400, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 16, + double height = 22 / 16, + TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.w400, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + color: color, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ); +} + +class TextThemeHeadline extends TextThemeType { + const TextThemeHeadline(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 24, + double height = 36 / 24, + TextDecoration decoration = TextDecoration.none, + FontWeight weight = FontWeight.normal, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} + +class TextThemeTitle extends TextThemeType { + const TextThemeTitle(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 20, + double height = 28 / 20, + FontWeight weight = FontWeight.normal, + TextDecoration decoration = TextDecoration.none, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} + +class TextThemeBody extends TextThemeType { + const TextThemeBody(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 14, + double height = 20 / 14, + FontWeight weight = FontWeight.normal, + TextDecoration decoration = TextDecoration.none, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} + +class TextThemeCaption extends TextThemeType { + const TextThemeCaption(); + + @override + TextStyle standard({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + ); + + @override + TextStyle enhanced({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.w600, + ); + + @override + TextStyle prominent({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.bold, + ); + + @override + TextStyle underline({String family = '', Color? color, FontWeight? weight}) => + _defaultTextStyle( + family: family, + color: color, + weight: weight ?? FontWeight.normal, + decoration: TextDecoration.underline, + ); + + static TextStyle _defaultTextStyle({ + required String family, + double fontSize = 12, + double height = 16 / 12, + FontWeight weight = FontWeight.normal, + TextDecoration decoration = TextDecoration.none, + Color? color, + }) => + TextStyle( + inherit: false, + fontSize: fontSize, + decoration: decoration, + fontStyle: FontStyle.normal, + fontWeight: weight, + height: height, + fontFamily: family, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + color: color, + ); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/text_style.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/text_style.dart new file mode 100644 index 0000000000..d96ca0f557 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/text_style.dart @@ -0,0 +1,23 @@ +import 'package:appflowy_ui/src/theme/definition/text_style/base/default_text_style.dart'; + +class AppFlowyBaseTextStyle { + const AppFlowyBaseTextStyle({ + this.heading1 = const TextThemeHeading1(), + this.heading2 = const TextThemeHeading2(), + this.heading3 = const TextThemeHeading3(), + this.heading4 = const TextThemeHeading4(), + this.headline = const TextThemeHeadline(), + this.title = const TextThemeTitle(), + this.body = const TextThemeBody(), + this.caption = const TextThemeCaption(), + }); + + final TextThemeType heading1; + final TextThemeType heading2; + final TextThemeType heading3; + final TextThemeType heading4; + final TextThemeType headline; + final TextThemeType title; + final TextThemeType body; + final TextThemeType caption; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/theme_data.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/theme_data.dart new file mode 100644 index 0000000000..515e6b2ecf --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/theme_data.dart @@ -0,0 +1,86 @@ +import 'border_radius/border_radius.dart'; +import 'color_scheme/color_scheme.dart'; +import 'shadow/shadow.dart'; +import 'spacing/spacing.dart'; +import 'text_style/text_style.dart'; + +/// [AppFlowyThemeData] defines the structure of the design system, and contains +/// the data that all child widgets will have access to. +class AppFlowyThemeData { + const AppFlowyThemeData({ + required this.textColorScheme, + required this.textStyle, + required this.iconColorScheme, + required this.borderColorScheme, + required this.backgroundColorScheme, + required this.fillColorScheme, + required this.surfaceColorScheme, + required this.borderRadius, + required this.spacing, + required this.shadow, + required this.brandColorScheme, + required this.otherColorsColorScheme, + }); + + final AppFlowyTextColorScheme textColorScheme; + + final AppFlowyBaseTextStyle textStyle; + + final AppFlowyIconColorScheme iconColorScheme; + + final AppFlowyBorderColorScheme borderColorScheme; + + final AppFlowyBackgroundColorScheme backgroundColorScheme; + + final AppFlowyFillColorScheme fillColorScheme; + + final AppFlowySurfaceColorScheme surfaceColorScheme; + + final AppFlowyBorderRadius borderRadius; + + final AppFlowySpacing spacing; + + final AppFlowyShadow shadow; + + final AppFlowyBrandColorScheme brandColorScheme; + + final AppFlowyOtherColorsColorScheme otherColorsColorScheme; + + static AppFlowyThemeData lerp( + AppFlowyThemeData begin, + AppFlowyThemeData end, + double t, + ) { + return AppFlowyThemeData( + textColorScheme: begin.textColorScheme.lerp(end.textColorScheme, t), + textStyle: end.textStyle, + iconColorScheme: begin.iconColorScheme.lerp(end.iconColorScheme, t), + borderColorScheme: begin.borderColorScheme.lerp(end.borderColorScheme, t), + backgroundColorScheme: + begin.backgroundColorScheme.lerp(end.backgroundColorScheme, t), + fillColorScheme: begin.fillColorScheme.lerp(end.fillColorScheme, t), + surfaceColorScheme: + begin.surfaceColorScheme.lerp(end.surfaceColorScheme, t), + borderRadius: end.borderRadius, + spacing: end.spacing, + shadow: end.shadow, + brandColorScheme: begin.brandColorScheme.lerp(end.brandColorScheme, t), + otherColorsColorScheme: + begin.otherColorsColorScheme.lerp(end.otherColorsColorScheme, t), + ); + } +} + +/// [AppFlowyThemeBuilder] is used to build the light and dark themes. Extend +/// this class to create a built-in theme, or use the [CustomTheme] class to +/// create a custom theme from JSON data. +/// +/// See also: +/// +/// - [AppFlowyThemeData] for the main theme data class. +abstract class AppFlowyThemeBuilder { + const AppFlowyThemeBuilder(); + + AppFlowyThemeData light(); + AppFlowyThemeData dark(); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart new file mode 100644 index 0000000000..000b7a0372 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart @@ -0,0 +1,8 @@ +export 'appflowy_theme.dart'; +export 'data/built_in_themes.dart'; +export 'definition/border_radius/border_radius.dart'; +export 'definition/color_scheme/color_scheme.dart'; +export 'definition/theme_data.dart'; +export 'definition/spacing/spacing.dart'; +export 'definition/shadow/shadow.dart'; +export 'definition/text_style/text_style.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_ui/pubspec.yaml new file mode 100644 index 0000000000..2f5633bb1e --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/pubspec.yaml @@ -0,0 +1,17 @@ +name: appflowy_ui +description: "A Flutter package for AppFlowy UI components and widgets" +version: 1.0.0 +homepage: https://github.com/appflowy-io/appflowy + +environment: + sdk: ^3.6.2 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_lints: ^5.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/Primitive.Mode 1.tokens.json b/frontend/appflowy_flutter/packages/appflowy_ui/script/Primitive.Mode 1.tokens.json new file mode 100644 index 0000000000..c46354b599 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/Primitive.Mode 1.tokens.json @@ -0,0 +1,984 @@ +{ + "Neutral": { + "100": { + "$type": "color", + "$value": "#f8faff" + }, + "200": { + "$type": "color", + "$value": "#e4e8f5" + }, + "300": { + "$type": "color", + "$value": "#ced3e6" + }, + "400": { + "$type": "color", + "$value": "#b5bbd3" + }, + "500": { + "$type": "color", + "$value": "#989eb7" + }, + "600": { + "$type": "color", + "$value": "#6f748c" + }, + "700": { + "$type": "color", + "$value": "#54596e" + }, + "800": { + "$type": "color", + "$value": "#3c3f4e" + }, + "900": { + "$type": "color", + "$value": "#272930" + }, + "1000": { + "$type": "color", + "$value": "#21232a" + }, + "black": { + "$type": "color", + "$value": "#000000" + }, + "alpha-black-60": { + "$type": "color", + "$value": "#00000099" + }, + "white": { + "$type": "color", + "$value": "#ffffff" + }, + "alpha-white-0": { + "$type": "color", + "$value": "#ffffff00" + }, + "alpha-white-20": { + "$type": "color", + "$value": "#ffffff33" + }, + "alpha-white-30": { + "$type": "color", + "$value": "#ffffff4d" + }, + "alpha-grey-100-05": { + "$type": "color", + "$value": "#f9fafd0d" + }, + "alpha-grey-100-10": { + "$type": "color", + "$value": "#f9fafd1a" + }, + "alpha-grey-1000-05": { + "$type": "color", + "$value": "#1f23290d" + }, + "alpha-grey-1000-10": { + "$type": "color", + "$value": "#1f23291a" + }, + "alpha-grey-1000-70": { + "$type": "color", + "$value": "#1f2329b2" + }, + "alpha-grey-1000-80": { + "$type": "color", + "$value": "#1f2329cc" + } + }, + "Blue": { + "100": { + "$type": "color", + "$value": "#e3f6ff" + }, + "200": { + "$type": "color", + "$value": "#a9e2ff" + }, + "300": { + "$type": "color", + "$value": "#80d2ff" + }, + "400": { + "$type": "color", + "$value": "#4ec1ff" + }, + "500": { + "$type": "color", + "$value": "#00b5ff" + }, + "600": { + "$type": "color", + "$value": "#0092d6" + }, + "700": { + "$type": "color", + "$value": "#0078c0" + }, + "800": { + "$type": "color", + "$value": "#0065a9" + }, + "900": { + "$type": "color", + "$value": "#00508f" + }, + "1000": { + "$type": "color", + "$value": "#003c77" + }, + "alpha-blue-500-15": { + "$type": "color", + "$value": "#00b5ff26" + } + }, + "Green": { + "100": { + "$type": "color", + "$value": "#ecf9f5" + }, + "200": { + "$type": "color", + "$value": "#c3e5d8" + }, + "300": { + "$type": "color", + "$value": "#9ad1bc" + }, + "400": { + "$type": "color", + "$value": "#71bd9f" + }, + "500": { + "$type": "color", + "$value": "#48a982" + }, + "600": { + "$type": "color", + "$value": "#248569" + }, + "700": { + "$type": "color", + "$value": "#29725d" + }, + "800": { + "$type": "color", + "$value": "#2e6050" + }, + "900": { + "$type": "color", + "$value": "#305548" + }, + "1000": { + "$type": "color", + "$value": "#305244" + } + }, + "Purple": { + "100": { + "$type": "color", + "$value": "#f1e0ff" + }, + "200": { + "$type": "color", + "$value": "#e1b3ff" + }, + "300": { + "$type": "color", + "$value": "#d185ff" + }, + "400": { + "$type": "color", + "$value": "#bc58ff" + }, + "500": { + "$type": "color", + "$value": "#9327ff" + }, + "600": { + "$type": "color", + "$value": "#7a1dcc" + }, + "700": { + "$type": "color", + "$value": "#6617b3" + }, + "800": { + "$type": "color", + "$value": "#55138f" + }, + "900": { + "$type": "color", + "$value": "#470c72" + }, + "1000": { + "$type": "color", + "$value": "#380758" + } + }, + "Magenta": { + "100": { + "$type": "color", + "$value": "#ffe5ef" + }, + "200": { + "$type": "color", + "$value": "#ffb8d1" + }, + "300": { + "$type": "color", + "$value": "#ff8ab2" + }, + "400": { + "$type": "color", + "$value": "#ff5c93" + }, + "500": { + "$type": "color", + "$value": "#fb006d" + }, + "600": { + "$type": "color", + "$value": "#d2005f" + }, + "700": { + "$type": "color", + "$value": "#d2005f" + }, + "800": { + "$type": "color", + "$value": "#850040" + }, + "900": { + "$type": "color", + "$value": "#610031" + }, + "1000": { + "$type": "color", + "$value": "#400022" + } + }, + "Red": { + "100": { + "$type": "color", + "$value": "#ffd2dd" + }, + "200": { + "$type": "color", + "$value": "#ffa5b4" + }, + "300": { + "$type": "color", + "$value": "#ff7d87" + }, + "400": { + "$type": "color", + "$value": "#ff5050" + }, + "500": { + "$type": "color", + "$value": "#f33641" + }, + "600": { + "$type": "color", + "$value": "#e71d32" + }, + "700": { + "$type": "color", + "$value": "#ad1625" + }, + "800": { + "$type": "color", + "$value": "#8c101c" + }, + "900": { + "$type": "color", + "$value": "#6e0a1e" + }, + "1000": { + "$type": "color", + "$value": "#4c0a17" + }, + "alpha-red-500-10": { + "$type": "color", + "$value": "#f336411a" + } + }, + "Orange": { + "100": { + "$type": "color", + "$value": "#fff3d5" + }, + "200": { + "$type": "color", + "$value": "#ffe4ab" + }, + "300": { + "$type": "color", + "$value": "#ffd181" + }, + "400": { + "$type": "color", + "$value": "#ffbe62" + }, + "500": { + "$type": "color", + "$value": "#ffa02e" + }, + "600": { + "$type": "color", + "$value": "#db7e21" + }, + "700": { + "$type": "color", + "$value": "#b75f17" + }, + "800": { + "$type": "color", + "$value": "#93450e" + }, + "900": { + "$type": "color", + "$value": "#7a3108" + }, + "1000": { + "$type": "color", + "$value": "#602706" + } + }, + "Yellow": { + "100": { + "$type": "color", + "$value": "#fff9b2" + }, + "200": { + "$type": "color", + "$value": "#ffec66" + }, + "300": { + "$type": "color", + "$value": "#ffdf1a" + }, + "400": { + "$type": "color", + "$value": "#ffcc00" + }, + "500": { + "$type": "color", + "$value": "#ffce00" + }, + "600": { + "$type": "color", + "$value": "#e6b800" + }, + "700": { + "$type": "color", + "$value": "#cc9f00" + }, + "800": { + "$type": "color", + "$value": "#b38a00" + }, + "900": { + "$type": "color", + "$value": "#9a7500" + }, + "1000": { + "$type": "color", + "$value": "#7f6200" + } + }, + "Subtle_Color": { + "Rose": { + "100": { + "$type": "color", + "$value": "#fcf2f2" + }, + "200": { + "$type": "color", + "$value": "#fae3e3" + }, + "300": { + "$type": "color", + "$value": "#fad9d9" + }, + "400": { + "$type": "color", + "$value": "#edadad" + }, + "500": { + "$type": "color", + "$value": "#cc4e4e" + }, + "600": { + "$type": "color", + "$value": "#702828" + } + }, + "Papaya": { + "100": { + "$type": "color", + "$value": "#fcf4f0" + }, + "200": { + "$type": "color", + "$value": "#fae8de" + }, + "300": { + "$type": "color", + "$value": "#fadfd2" + }, + "400": { + "$type": "color", + "$value": "#f0bda3" + }, + "500": { + "$type": "color", + "$value": "#d67240" + }, + "600": { + "$type": "color", + "$value": "#6b3215" + } + }, + "Tangerine": { + "100": { + "$type": "color", + "$value": "#fff7ed" + }, + "200": { + "$type": "color", + "$value": "#fcedd9" + }, + "300": { + "$type": "color", + "$value": "#fae5ca" + }, + "400": { + "$type": "color", + "$value": "#f2cb99" + }, + "500": { + "$type": "color", + "$value": "#db8f2c" + }, + "600": { + "$type": "color", + "$value": "#613b0a" + } + }, + "Mango": { + "100": { + "$type": "color", + "$value": "#fff9ec" + }, + "200": { + "$type": "color", + "$value": "#fcf1d7" + }, + "300": { + "$type": "color", + "$value": "#fae9c3" + }, + "400": { + "$type": "color", + "$value": "#f5d68e" + }, + "500": { + "$type": "color", + "$value": "#e0a416" + }, + "600": { + "$type": "color", + "$value": "#5c4102" + } + }, + "Lemon": { + "100": { + "$type": "color", + "$value": "#fffbe8" + }, + "200": { + "$type": "color", + "$value": "#fcf5cf" + }, + "300": { + "$type": "color", + "$value": "#faefb9" + }, + "400": { + "$type": "color", + "$value": "#f5e282" + }, + "500": { + "$type": "color", + "$value": "#e0bb00" + }, + "600": { + "$type": "color", + "$value": "#574800" + } + }, + "Olive": { + "100": { + "$type": "color", + "$value": "#f9fae6" + }, + "200": { + "$type": "color", + "$value": "#f6f7d0" + }, + "300": { + "$type": "color", + "$value": "#f0f2b3" + }, + "400": { + "$type": "color", + "$value": "#dbde83" + }, + "500": { + "$type": "color", + "$value": "#adb204" + }, + "600": { + "$type": "color", + "$value": "#4a4c03" + } + }, + "Lime": { + "100": { + "$type": "color", + "$value": "#f6f9e6" + }, + "200": { + "$type": "color", + "$value": "#eef5ce" + }, + "300": { + "$type": "color", + "$value": "#e7f0bb" + }, + "400": { + "$type": "color", + "$value": "#cfdb91" + }, + "500": { + "$type": "color", + "$value": "#92a822" + }, + "600": { + "$type": "color", + "$value": "#414d05" + } + }, + "Grass": { + "100": { + "$type": "color", + "$value": "#f4faeb" + }, + "200": { + "$type": "color", + "$value": "#e9f5d7" + }, + "300": { + "$type": "color", + "$value": "#def0c5" + }, + "400": { + "$type": "color", + "$value": "#bfd998" + }, + "500": { + "$type": "color", + "$value": "#75a828" + }, + "600": { + "$type": "color", + "$value": "#334d0c" + } + }, + "Forest": { + "100": { + "$type": "color", + "$value": "#f1faf0" + }, + "200": { + "$type": "color", + "$value": "#e2f5df" + }, + "300": { + "$type": "color", + "$value": "#d7f0d3" + }, + "400": { + "$type": "color", + "$value": "#a8d6a1" + }, + "500": { + "$type": "color", + "$value": "#49a33b" + }, + "600": { + "$type": "color", + "$value": "#1e4f16" + } + }, + "Jade": { + "100": { + "$type": "color", + "$value": "#f0faf6" + }, + "200": { + "$type": "color", + "$value": "#dff5eb" + }, + "300": { + "$type": "color", + "$value": "#cef0e1" + }, + "400": { + "$type": "color", + "$value": "#90d1b5" + }, + "500": { + "$type": "color", + "$value": "#1c9963" + }, + "600": { + "$type": "color", + "$value": "#075231" + } + }, + "Aqua": { + "100": { + "$type": "color", + "$value": "#f0f9fa" + }, + "200": { + "$type": "color", + "$value": "#dff3f5" + }, + "300": { + "$type": "color", + "$value": "#ccecf0" + }, + "400": { + "$type": "color", + "$value": "#83ccd4" + }, + "500": { + "$type": "color", + "$value": "#008e9e" + }, + "600": { + "$type": "color", + "$value": "#004e57" + } + }, + "Azure": { + "100": { + "$type": "color", + "$value": "#f0f6fa" + }, + "200": { + "$type": "color", + "$value": "#e1eef7" + }, + "300": { + "$type": "color", + "$value": "#d3e6f5" + }, + "400": { + "$type": "color", + "$value": "#88c0eb" + }, + "500": { + "$type": "color", + "$value": "#0877cc" + }, + "600": { + "$type": "color", + "$value": "#154469" + } + }, + "Denim": { + "100": { + "$type": "color", + "$value": "#f0f3fa" + }, + "200": { + "$type": "color", + "$value": "#e3ebfa" + }, + "300": { + "$type": "color", + "$value": "#d7e2f7" + }, + "400": { + "$type": "color", + "$value": "#9ab6ed" + }, + "500": { + "$type": "color", + "$value": "#3267d1" + }, + "600": { + "$type": "color", + "$value": "#223c70" + } + }, + "Mauve": { + "100": { + "$type": "color", + "$value": "#f2f2fc" + }, + "200": { + "$type": "color", + "$value": "#e6e6fa" + }, + "300": { + "$type": "color", + "$value": "#dcdcf7" + }, + "400": { + "$type": "color", + "$value": "#aeaef5" + }, + "500": { + "$type": "color", + "$value": "#5555e0" + }, + "600": { + "$type": "color", + "$value": "#36366b" + } + }, + "Lavender": { + "100": { + "$type": "color", + "$value": "#f6f3fc" + }, + "200": { + "$type": "color", + "$value": "#ebe3fa" + }, + "300": { + "$type": "color", + "$value": "#e4daf7" + }, + "400": { + "$type": "color", + "$value": "#c1aaf0" + }, + "500": { + "$type": "color", + "$value": "#8153db" + }, + "600": { + "$type": "color", + "$value": "#462f75" + } + }, + "Lilac": { + "100": { + "$type": "color", + "$value": "#f7f0fa" + }, + "200": { + "$type": "color", + "$value": "#f0e1f7" + }, + "300": { + "$type": "color", + "$value": "#edd7f7" + }, + "400": { + "$type": "color", + "$value": "#d3a9e8" + }, + "500": { + "$type": "color", + "$value": "#9e4cc7" + }, + "600": { + "$type": "color", + "$value": "#562d6b" + } + }, + "Mallow": { + "100": { + "$type": "color", + "$value": "#faf0fa" + }, + "200": { + "$type": "color", + "$value": "#f5e1f4" + }, + "300": { + "$type": "color", + "$value": "#f5d7f4" + }, + "400": { + "$type": "color", + "$value": "#dea4dc" + }, + "500": { + "$type": "color", + "$value": "#b240af" + }, + "600": { + "$type": "color", + "$value": "#632861" + } + }, + "Camellia": { + "100": { + "$type": "color", + "$value": "#f9eff3" + }, + "200": { + "$type": "color", + "$value": "#f7e1eb" + }, + "300": { + "$type": "color", + "$value": "#f7d7e5" + }, + "400": { + "$type": "color", + "$value": "#e5a3c0" + }, + "500": { + "$type": "color", + "$value": "#c24279" + }, + "600": { + "$type": "color", + "$value": "#6e2343" + } + }, + "Smoke": { + "100": { + "$type": "color", + "$value": "#f5f5f5" + }, + "200": { + "$type": "color", + "$value": "#e8e8e8" + }, + "300": { + "$type": "color", + "$value": "#dedede" + }, + "400": { + "$type": "color", + "$value": "#b8b8b8" + }, + "500": { + "$type": "color", + "$value": "#6e6e6e" + }, + "600": { + "$type": "color", + "$value": "#404040" + } + }, + "Iron": { + "100": { + "$type": "color", + "$value": "#f2f4f7" + }, + "200": { + "$type": "color", + "$value": "#e6e9f0" + }, + "300": { + "$type": "color", + "$value": "#dadee5" + }, + "400": { + "$type": "color", + "$value": "#b0b5bf" + }, + "500": { + "$type": "color", + "$value": "#666f80" + }, + "600": { + "$type": "color", + "$value": "#394152" + } + } + }, + "Spacing": { + "0": { + "$type": "dimension", + "$value": "0px" + }, + "100": { + "$type": "dimension", + "$value": "4px" + }, + "200": { + "$type": "dimension", + "$value": "6px" + }, + "300": { + "$type": "dimension", + "$value": "8px" + }, + "400": { + "$type": "dimension", + "$value": "12px" + }, + "500": { + "$type": "dimension", + "$value": "16px" + }, + "600": { + "$type": "dimension", + "$value": "20px" + }, + "1000": { + "$type": "dimension", + "$value": "1000px" + } + }, + "Border-Radius": { + "0": { + "$type": "dimension", + "$value": "0px" + }, + "100": { + "$type": "dimension", + "$value": "4px" + }, + "200": { + "$type": "dimension", + "$value": "6px" + }, + "300": { + "$type": "dimension", + "$value": "8px" + }, + "400": { + "$type": "dimension", + "$value": "12px" + }, + "500": { + "$type": "dimension", + "$value": "16px" + }, + "600": { + "$type": "dimension", + "$value": "20px" + }, + "1000": { + "$type": "dimension", + "$value": "1000px" + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Dark Mode.tokens.json b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Dark Mode.tokens.json new file mode 100644 index 0000000000..99d266c008 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Dark Mode.tokens.json @@ -0,0 +1,1039 @@ +{ + "Text": { + "primary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "inverse": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "on-fill": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "theme": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "action": { + "$type": "color", + "$value": "{Blue.500}" + }, + "action-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error": { + "$type": "color", + "$value": "{Red.500}" + }, + "error-hover": { + "$type": "color", + "$value": "{Red.400}" + }, + "purple": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Icon": { + "primary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "purple-thick": { + "$type": "color", + "$value": "#ffffff" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "#ffffff" + } + }, + "Border": { + "grey-primary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "grey-primary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "grey-secondary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "grey-secondary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "grey-tertiary": { + "$type": "color", + "$value": "{Neutral.800}" + }, + "grey-tertiary-hover": { + "$type": "color", + "$value": "{Neutral.700}" + }, + "grey-quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "grey-quaternary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.500}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.400}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Fill": { + "primary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "primary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "secondary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "tertiary-hover": { + "$type": "color", + "$value": "{Neutral.500}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "quaternary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "primary-alpha-5": { + "$type": "color", + "$value": "{Neutral.alpha-grey-100-05}", + "$description": "Used for hover state, eg. button, navigation item, menu item and grid item." + }, + "primary-alpha-5-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-100-10}" + }, + "primary-alpha-80": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-80}" + }, + "primary-alpha-80-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-70}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "white-alpha": { + "$type": "color", + "$value": "{Neutral.alpha-white-20}" + }, + "white-alpha-hover": { + "$type": "color", + "$value": "{Neutral.alpha-white-30}" + }, + "black": { + "$type": "color", + "$value": "{Neutral.black}" + }, + "theme-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "theme-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.400}" + }, + "theme-select": { + "$type": "color", + "$value": "{Blue.alpha-blue-500-15}" + }, + "info-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "info-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-light": { + "$type": "color", + "$value": "{Green.100}" + }, + "success-light-hover": { + "$type": "color", + "$value": "{Green.200}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-light": { + "$type": "color", + "$value": "{Orange.100}" + }, + "warning-light-hover": { + "$type": "color", + "$value": "{Orange.200}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-light": { + "$type": "color", + "$value": "{Red.100}" + }, + "error-light-hover": { + "$type": "color", + "$value": "{Red.200}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.500}" + }, + "error-select": { + "$type": "color", + "$value": "{Red.alpha-red-500-10}" + }, + "purple-light": { + "$type": "color", + "$value": "{Purple.100}" + }, + "purple-light-hover": { + "$type": "color", + "$value": "{Purple.200}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + } + }, + "Surface": { + "primary": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "overlay": { + "$type": "color", + "$value": "{Neutral.alpha-black-60}" + } + }, + "Background": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.800}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.700}" + } + }, + "Badge_Color": { + "Rose": { + "rose-light-1": { + "$type": "color", + "$value": "#fcf2f2" + }, + "rose-light-2": { + "$type": "color", + "$value": "#fae3e3" + }, + "rose-light-3": { + "$type": "color", + "$value": "#fad9d9" + }, + "rose-thick-1": { + "$type": "color", + "$value": "#edadad" + }, + "rose-thick-2": { + "$type": "color", + "$value": "#cc4e4e" + }, + "rose-thick-3": { + "$type": "color", + "$value": "#702828" + } + }, + "Papaya": { + "papaya-light-1": { + "$type": "color", + "$value": "#fcf4f0" + }, + "papaya-light-2": { + "$type": "color", + "$value": "#fae8de" + }, + "papaya-light-3": { + "$type": "color", + "$value": "#fadfd2" + }, + "papaya-thick-1": { + "$type": "color", + "$value": "#f0bda3" + }, + "papaya-thick-2": { + "$type": "color", + "$value": "#d67240" + }, + "papaya-thick-3": { + "$type": "color", + "$value": "#6b3215" + } + }, + "Tangerine": { + "tangerine-light-1": { + "$type": "color", + "$value": "#fff7ed" + }, + "tangerine-light-2": { + "$type": "color", + "$value": "#fcedd9" + }, + "tangerine-light-3": { + "$type": "color", + "$value": "#fae5ca" + }, + "tangerine-thick-1": { + "$type": "color", + "$value": "#f2cb99" + }, + "tangerine-thick-2": { + "$type": "color", + "$value": "#db8f2c" + }, + "tangerine-thick-3": { + "$type": "color", + "$value": "#613b0a" + } + }, + "Mango": { + "mango-light-1": { + "$type": "color", + "$value": "#fff9ec" + }, + "mango-light-2": { + "$type": "color", + "$value": "#fcf1d7" + }, + "mango-light-3": { + "$type": "color", + "$value": "#fae9c3" + }, + "mango-thick-1": { + "$type": "color", + "$value": "#f5d68e" + }, + "mango-thick-2": { + "$type": "color", + "$value": "#e0a416" + }, + "mango-thick-3": { + "$type": "color", + "$value": "#5c4102" + } + }, + "Lemon": { + "lemon-light-1": { + "$type": "color", + "$value": "#fffbe8" + }, + "lemon-light-2": { + "$type": "color", + "$value": "#fcf5cf" + }, + "lemon-light-3": { + "$type": "color", + "$value": "#faefb9" + }, + "lemon-thick-1": { + "$type": "color", + "$value": "#f5e282" + }, + "lemon-thick-2": { + "$type": "color", + "$value": "#e0bb00" + }, + "lemon-thick-3": { + "$type": "color", + "$value": "#574800" + } + }, + "Olive": { + "olive-light-1": { + "$type": "color", + "$value": "#f9fae6" + }, + "olive-light-2": { + "$type": "color", + "$value": "#f6f7d0" + }, + "olive-light-3": { + "$type": "color", + "$value": "#f0f2b3" + }, + "olive-thick-1": { + "$type": "color", + "$value": "#dbde83" + }, + "olive-thick-2": { + "$type": "color", + "$value": "#adb204" + }, + "olive-thick-3": { + "$type": "color", + "$value": "#4a4c03" + } + }, + "Lime": { + "lime-light-1": { + "$type": "color", + "$value": "#f6f9e6" + }, + "lime-light-2": { + "$type": "color", + "$value": "#eef5ce" + }, + "lime-light-3": { + "$type": "color", + "$value": "#e7f0bb" + }, + "lime-thick-1": { + "$type": "color", + "$value": "#cfdb91" + }, + "lime-thick-2": { + "$type": "color", + "$value": "#92a822" + }, + "lime-thick-3": { + "$type": "color", + "$value": "#414d05" + } + }, + "Grass": { + "grass-light-1": { + "$type": "color", + "$value": "#f4faeb" + }, + "grass-light-2": { + "$type": "color", + "$value": "#e9f5d7" + }, + "grass-light-3": { + "$type": "color", + "$value": "#def0c5" + }, + "grass-thick-1": { + "$type": "color", + "$value": "#bfd998" + }, + "grass-thick-2": { + "$type": "color", + "$value": "#75a828" + }, + "grass-thick-3": { + "$type": "color", + "$value": "#334d0c" + } + }, + "Forest": { + "forest-light-1": { + "$type": "color", + "$value": "#f1faf0" + }, + "forest-light-2": { + "$type": "color", + "$value": "#e2f5df" + }, + "forest-light-3": { + "$type": "color", + "$value": "#d7f0d3" + }, + "forest-thick-1": { + "$type": "color", + "$value": "#a8d6a1" + }, + "forest-thick-2": { + "$type": "color", + "$value": "#49a33b" + }, + "forest-thick-3": { + "$type": "color", + "$value": "#1e4f16" + } + }, + "Jade": { + "jade-light-1": { + "$type": "color", + "$value": "#f0faf6" + }, + "jade-light-2": { + "$type": "color", + "$value": "#dff5eb" + }, + "jade-light-3": { + "$type": "color", + "$value": "#cef0e1" + }, + "jade-thick-1": { + "$type": "color", + "$value": "#90d1b5" + }, + "jade-thick-2": { + "$type": "color", + "$value": "#1c9963" + }, + "jade-thick-3": { + "$type": "color", + "$value": "#075231" + } + }, + "Aqua": { + "aqua-light-1": { + "$type": "color", + "$value": "#f0f9fa" + }, + "aqua-light-2": { + "$type": "color", + "$value": "#dff3f5" + }, + "aqua-light-3": { + "$type": "color", + "$value": "#ccecf0" + }, + "aqua-thick-1": { + "$type": "color", + "$value": "#83ccd4" + }, + "aqua-thick-2": { + "$type": "color", + "$value": "#008e9e" + }, + "aqua-thick-3": { + "$type": "color", + "$value": "#004e57" + } + }, + "Azure": { + "azure-light-1": { + "$type": "color", + "$value": "#f0f6fa" + }, + "azure-light-2": { + "$type": "color", + "$value": "#e1eef7" + }, + "azure-light-3": { + "$type": "color", + "$value": "#d3e6f5" + }, + "azure-thick-1": { + "$type": "color", + "$value": "#88c0eb" + }, + "azure-thick-2": { + "$type": "color", + "$value": "#0877cc" + }, + "azure-thick-3": { + "$type": "color", + "$value": "#154469" + } + }, + "Denim": { + "denim-light-1": { + "$type": "color", + "$value": "#f0f3fa" + }, + "denim-light-2": { + "$type": "color", + "$value": "#e3ebfa" + }, + "denim-light-3": { + "$type": "color", + "$value": "#d7e2f7" + }, + "denim-thick-1": { + "$type": "color", + "$value": "#9ab6ed" + }, + "denim-thick-2": { + "$type": "color", + "$value": "#3267d1" + }, + "denim-thick-3": { + "$type": "color", + "$value": "#223c70" + } + }, + "Mauve": { + "mauve-light-1": { + "$type": "color", + "$value": "#f2f2fc" + }, + "mauve-thick-2": { + "$type": "color", + "$value": "#5555e0" + }, + "mauve-thick-3": { + "$type": "color", + "$value": "#36366b" + }, + "mauve-thick-1": { + "$type": "color", + "$value": "#aeaef5" + } + }, + "Lavender": { + "lavender-light-1": { + "$type": "color", + "$value": "#f6f3fc" + }, + "lavender-light-2": { + "$type": "color", + "$value": "#ebe3fa" + }, + "lavender-light-3": { + "$type": "color", + "$value": "#e4daf7" + }, + "lavender-thick-1": { + "$type": "color", + "$value": "#c1aaf0" + }, + "lavender-thick-2": { + "$type": "color", + "$value": "#8153db" + }, + "lavender-thick-3": { + "$type": "color", + "$value": "#462f75" + } + }, + "Lilac": { + "liliac-light-1": { + "$type": "color", + "$value": "#f7f0fa" + }, + "liliac-light-2": { + "$type": "color", + "$value": "#f0e1f7" + }, + "liliac-light-3": { + "$type": "color", + "$value": "#edd7f7" + }, + "liliac-thick-1": { + "$type": "color", + "$value": "#d3a9e8" + }, + "liliac-thick-2": { + "$type": "color", + "$value": "#9e4cc7" + }, + "liliac-thick-3": { + "$type": "color", + "$value": "#562d6b" + } + }, + "Mallow": { + "mallow-light-1": { + "$type": "color", + "$value": "#faf0fa" + }, + "mallow-light-2": { + "$type": "color", + "$value": "#f5e1f4" + }, + "mallow-light-3": { + "$type": "color", + "$value": "#f5d7f4" + }, + "mallow-thick-1": { + "$type": "color", + "$value": "#dea4dc" + }, + "mallow-thick-2": { + "$type": "color", + "$value": "#b240af" + }, + "mallow-thick-3": { + "$type": "color", + "$value": "#632861" + } + }, + "Camellia": { + "camellia-light-1": { + "$type": "color", + "$value": "#f9eff3" + }, + "camellia-light-2": { + "$type": "color", + "$value": "#f7e1eb" + }, + "camellia-light-3": { + "$type": "color", + "$value": "#f7d7e5" + }, + "camellia-thick-1": { + "$type": "color", + "$value": "#e5a3c0" + }, + "camellia-thick-2": { + "$type": "color", + "$value": "#c24279" + }, + "camellia-thick-3": { + "$type": "color", + "$value": "#6e2343" + } + }, + "Smoke": { + "smoke-light-1": { + "$type": "color", + "$value": "#f5f5f5" + }, + "smoke-light-2": { + "$type": "color", + "$value": "#e8e8e8" + }, + "smoke-light-3": { + "$type": "color", + "$value": "#dedede" + }, + "smoke-thick-1": { + "$type": "color", + "$value": "#b8b8b8" + }, + "smoke-thick-2": { + "$type": "color", + "$value": "#6e6e6e" + }, + "smoke-thick-3": { + "$type": "color", + "$value": "#404040" + } + }, + "Iron": { + "icon-light-1": { + "$type": "color", + "$value": "#f2f4f7" + }, + "icon-light-2": { + "$type": "color", + "$value": "#e6e9f0" + }, + "icon-light-3": { + "$type": "color", + "$value": "#dadee5" + }, + "icon-thick-1": { + "$type": "color", + "$value": "#b0b5bf" + }, + "icon-thick-2": { + "$type": "color", + "$value": "#666f80" + }, + "icon-thick-3": { + "$type": "color", + "$value": "#394152" + } + } + }, + "Shadow": { + "sm": { + "$type": "dimension", + "$value": "0px" + }, + "md": { + "$type": "dimension", + "$value": "0px" + } + }, + "Brand": { + "Skyline": { + "$type": "color", + "$value": "#00b5ff" + }, + "Aqua": { + "$type": "color", + "$value": "#00c8ff" + }, + "Violet": { + "$type": "color", + "$value": "#9327ff" + }, + "Amethyst": { + "$type": "color", + "$value": "#8427e0" + }, + "Berry": { + "$type": "color", + "$value": "#e3006d" + }, + "Coral": { + "$type": "color", + "$value": "#fb006d" + }, + "Golden": { + "$type": "color", + "$value": "#f7931e" + }, + "Amber": { + "$type": "color", + "$value": "#ffbd00" + }, + "Lemon": { + "$type": "color", + "$value": "#ffce00" + } + }, + "Other_Colors": { + "text-highlight": { + "$type": "color", + "$value": "{Blue.200}" + } + }, + "Spacing": { + "spacing-0": { + "$type": "dimension", + "$value": "{Spacing.0}" + }, + "spacing-xs": { + "$type": "dimension", + "$value": "{Spacing.100}" + }, + "spacing-s": { + "$type": "dimension", + "$value": "{Spacing.200}" + }, + "spacing-m": { + "$type": "dimension", + "$value": "{Spacing.300}" + }, + "spacing-l": { + "$type": "dimension", + "$value": "{Spacing.400}" + }, + "spacing-xl": { + "$type": "dimension", + "$value": "{Spacing.500}" + }, + "spacing-xxl": { + "$type": "dimension", + "$value": "{Spacing.600}" + }, + "spacing-full": { + "$type": "dimension", + "$value": "{Spacing.1000}" + } + }, + "Border_Radius": { + "border-radius-0": { + "$type": "dimension", + "$value": "{Border-Radius.0}" + }, + "border-radius-xs": { + "$type": "dimension", + "$value": "{Border-Radius.100}" + }, + "border-radius-s": { + "$type": "dimension", + "$value": "{Border-Radius.200}" + }, + "border-radius-m": { + "$type": "dimension", + "$value": "{Border-Radius.300}" + }, + "border-radius-l": { + "$type": "dimension", + "$value": "{Border-Radius.400}" + }, + "border-radius-xl": { + "$type": "dimension", + "$value": "{Border-Radius.500}" + }, + "border-radius-xxl": { + "$type": "dimension", + "$value": "{Border-Radius.600}" + }, + "border-radius-full": { + "$type": "dimension", + "$value": "{Border-Radius.1000}" + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Light Mode.tokens.json b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Light Mode.tokens.json new file mode 100644 index 0000000000..4e6b0543dc --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Light Mode.tokens.json @@ -0,0 +1,1039 @@ +{ + "Text": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "inverse": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "on-fill": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "theme": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "action": { + "$type": "color", + "$value": "{Blue.500}" + }, + "action-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-hover": { + "$type": "color", + "$value": "{Red.700}" + }, + "purple": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Icon": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Border": { + "grey-primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "grey-primary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "grey-secondary": { + "$type": "color", + "$value": "{Neutral.800}" + }, + "grey-secondary-hover": { + "$type": "color", + "$value": "{Neutral.700}" + }, + "grey-tertiary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "grey-tertiary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "grey-quaternary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "grey-quaternary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.700}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + } + }, + "Fill": { + "primary": { + "$type": "color", + "$value": "{Neutral.1000}" + }, + "primary-hover": { + "$type": "color", + "$value": "{Neutral.900}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.600}" + }, + "secondary-hover": { + "$type": "color", + "$value": "{Neutral.500}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.300}" + }, + "tertiary-hover": { + "$type": "color", + "$value": "{Neutral.400}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "quaternary-hover": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "transparent": { + "$type": "color", + "$value": "{Neutral.alpha-white-0}" + }, + "primary-alpha-5": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-05}", + "$description": "Used for hover state, eg. button, navigation item, menu item and grid item." + }, + "primary-alpha-5-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-10}" + }, + "primary-alpha-80": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-80}" + }, + "primary-alpha-80-hover": { + "$type": "color", + "$value": "{Neutral.alpha-grey-1000-70}" + }, + "white": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "white-alpha": { + "$type": "color", + "$value": "{Neutral.alpha-white-20}" + }, + "white-alpha-hover": { + "$type": "color", + "$value": "{Neutral.alpha-white-30}" + }, + "black": { + "$type": "color", + "$value": "{Neutral.black}" + }, + "theme-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "theme-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "theme-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "theme-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "theme-select": { + "$type": "color", + "$value": "{Blue.alpha-blue-500-15}" + }, + "info-light": { + "$type": "color", + "$value": "{Blue.100}" + }, + "info-light-hover": { + "$type": "color", + "$value": "{Blue.200}" + }, + "info-thick": { + "$type": "color", + "$value": "{Blue.500}" + }, + "info-thick-hover": { + "$type": "color", + "$value": "{Blue.600}" + }, + "success-light": { + "$type": "color", + "$value": "{Green.100}" + }, + "success-light-hover": { + "$type": "color", + "$value": "{Green.200}" + }, + "success-thick": { + "$type": "color", + "$value": "{Green.600}" + }, + "success-thick-hover": { + "$type": "color", + "$value": "{Green.700}" + }, + "warning-light": { + "$type": "color", + "$value": "{Orange.100}" + }, + "warning-light-hover": { + "$type": "color", + "$value": "{Orange.200}" + }, + "warning-thick": { + "$type": "color", + "$value": "{Orange.600}" + }, + "warning-thick-hover": { + "$type": "color", + "$value": "{Orange.700}" + }, + "error-light": { + "$type": "color", + "$value": "{Red.100}" + }, + "error-light-hover": { + "$type": "color", + "$value": "{Red.200}" + }, + "error-thick": { + "$type": "color", + "$value": "{Red.600}" + }, + "error-thick-hover": { + "$type": "color", + "$value": "{Red.700}" + }, + "error-select": { + "$type": "color", + "$value": "{Red.alpha-red-500-10}" + }, + "purple-light": { + "$type": "color", + "$value": "{Purple.100}" + }, + "purple-light-hover": { + "$type": "color", + "$value": "{Purple.200}" + }, + "purple-thick-hover": { + "$type": "color", + "$value": "{Purple.600}" + }, + "purple-thick": { + "$type": "color", + "$value": "{Purple.500}" + } + }, + "Surface": { + "primary": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "overlay": { + "$type": "color", + "$value": "{Neutral.alpha-black-60}" + } + }, + "Background": { + "primary": { + "$type": "color", + "$value": "{Neutral.white}" + }, + "secondary": { + "$type": "color", + "$value": "{Neutral.100}" + }, + "tertiary": { + "$type": "color", + "$value": "{Neutral.200}" + }, + "quaternary": { + "$type": "color", + "$value": "{Neutral.300}" + } + }, + "Badge_Color": { + "Rose": { + "rose-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Rose.100}" + }, + "rose-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Rose.200}" + }, + "rose-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Rose.300}" + }, + "rose-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Rose.400}" + }, + "rose-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Rose.500}" + }, + "rose-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Rose.600}" + } + }, + "Papaya": { + "papaya-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.100}" + }, + "papaya-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.200}" + }, + "papaya-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.300}" + }, + "papaya-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.400}" + }, + "papaya-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.500}" + }, + "papaya-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Papaya.600}" + } + }, + "Tangerine": { + "tangerine-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.100}" + }, + "tangerine-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.200}" + }, + "tangerine-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.300}" + }, + "tangerine-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.400}" + }, + "tangerine-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.500}" + }, + "tangerine-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Tangerine.600}" + } + }, + "Mango": { + "mango-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Mango.100}" + }, + "mango-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Mango.200}" + }, + "mango-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Mango.300}" + }, + "mango-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Mango.400}" + }, + "mango-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Mango.500}" + }, + "mango-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Mango.600}" + } + }, + "Lemon": { + "lemon-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.100}" + }, + "lemon-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.200}" + }, + "lemon-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.300}" + }, + "lemon-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.400}" + }, + "lemon-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.500}" + }, + "lemon-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lemon.600}" + } + }, + "Olive": { + "olive-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Olive.100}" + }, + "olive-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Olive.200}" + }, + "olive-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Olive.300}" + }, + "olive-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Olive.400}" + }, + "olive-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Olive.500}" + }, + "olive-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Olive.600}" + } + }, + "Lime": { + "lime-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lime.100}" + }, + "lime-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lime.200}" + }, + "lime-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lime.300}" + }, + "lime-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lime.400}" + }, + "lime-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lime.500}" + }, + "lime-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lime.600}" + } + }, + "Grass": { + "grass-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Grass.100}" + }, + "grass-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Grass.200}" + }, + "grass-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Grass.300}" + }, + "grass-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Grass.400}" + }, + "grass-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Grass.500}" + }, + "grass-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Grass.600}" + } + }, + "Forest": { + "forest-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Forest.100}" + }, + "forest-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Forest.200}" + }, + "forest-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Forest.300}" + }, + "forest-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Forest.400}" + }, + "forest-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Forest.500}" + }, + "forest-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Forest.600}" + } + }, + "Jade": { + "jade-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Jade.100}" + }, + "jade-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Jade.200}" + }, + "jade-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Jade.300}" + }, + "jade-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Jade.400}" + }, + "jade-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Jade.500}" + }, + "jade-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Jade.600}" + } + }, + "Aqua": { + "aqua-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.100}" + }, + "aqua-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.200}" + }, + "aqua-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.300}" + }, + "aqua-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.400}" + }, + "aqua-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.500}" + }, + "aqua-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Aqua.600}" + } + }, + "Azure": { + "azure-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Azure.100}" + }, + "azure-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Azure.200}" + }, + "azure-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Azure.300}" + }, + "azure-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Azure.400}" + }, + "azure-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Azure.500}" + }, + "azure-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Azure.600}" + } + }, + "Denim": { + "denim-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Denim.100}" + }, + "denim-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Denim.200}" + }, + "denim-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Denim.300}" + }, + "denim-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Denim.400}" + }, + "denim-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Denim.500}" + }, + "denim-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Denim.600}" + } + }, + "Mauve": { + "mauve-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.100}" + }, + "mauve-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.500}" + }, + "mauve-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.600}" + }, + "mauve-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Mauve.400}" + } + }, + "Lavender": { + "lavender-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.100}" + }, + "lavender-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.200}" + }, + "lavender-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.300}" + }, + "lavender-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.400}" + }, + "lavender-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.500}" + }, + "lavender-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lavender.600}" + } + }, + "Lilac": { + "liliac-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.100}" + }, + "liliac-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.200}" + }, + "liliac-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.300}" + }, + "liliac-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.400}" + }, + "liliac-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.500}" + }, + "liliac-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Lilac.600}" + } + }, + "Mallow": { + "mallow-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.100}" + }, + "mallow-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.200}" + }, + "mallow-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.300}" + }, + "mallow-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.400}" + }, + "mallow-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.500}" + }, + "mallow-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Mallow.600}" + } + }, + "Camellia": { + "camellia-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.100}" + }, + "camellia-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.200}" + }, + "camellia-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.300}" + }, + "camellia-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.400}" + }, + "camellia-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.500}" + }, + "camellia-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Camellia.600}" + } + }, + "Smoke": { + "smoke-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.100}" + }, + "smoke-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.200}" + }, + "smoke-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.300}" + }, + "smoke-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.400}" + }, + "smoke-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.500}" + }, + "smoke-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Smoke.600}" + } + }, + "Iron": { + "icon-light-1": { + "$type": "color", + "$value": "{Subtle_Color.Iron.100}" + }, + "icon-light-2": { + "$type": "color", + "$value": "{Subtle_Color.Iron.200}" + }, + "icon-light-3": { + "$type": "color", + "$value": "{Subtle_Color.Iron.300}" + }, + "icon-thick-1": { + "$type": "color", + "$value": "{Subtle_Color.Iron.400}" + }, + "icon-thick-2": { + "$type": "color", + "$value": "{Subtle_Color.Iron.500}" + }, + "icon-thick-3": { + "$type": "color", + "$value": "{Subtle_Color.Iron.600}" + } + } + }, + "Shadow": { + "sm": { + "$type": "dimension", + "$value": "0px" + }, + "md": { + "$type": "dimension", + "$value": "0px" + } + }, + "Brand": { + "Skyline": { + "$type": "color", + "$value": "#00b5ff" + }, + "Aqua": { + "$type": "color", + "$value": "#00c8ff" + }, + "Violet": { + "$type": "color", + "$value": "#9327ff" + }, + "Amethyst": { + "$type": "color", + "$value": "#8427e0" + }, + "Berry": { + "$type": "color", + "$value": "#e3006d" + }, + "Coral": { + "$type": "color", + "$value": "#fb006d" + }, + "Golden": { + "$type": "color", + "$value": "#f7931e" + }, + "Amber": { + "$type": "color", + "$value": "#ffbd00" + }, + "Lemon": { + "$type": "color", + "$value": "#ffce00" + } + }, + "Other_Colors": { + "text-highlight": { + "$type": "color", + "$value": "{Blue.200}" + } + }, + "Spacing": { + "spacing-0": { + "$type": "dimension", + "$value": "{Spacing.0}" + }, + "spacing-xs": { + "$type": "dimension", + "$value": "{Spacing.100}" + }, + "spacing-s": { + "$type": "dimension", + "$value": "{Spacing.200}" + }, + "spacing-m": { + "$type": "dimension", + "$value": "{Spacing.300}" + }, + "spacing-l": { + "$type": "dimension", + "$value": "{Spacing.400}" + }, + "spacing-xl": { + "$type": "dimension", + "$value": "{Spacing.500}" + }, + "spacing-xxl": { + "$type": "dimension", + "$value": "{Spacing.600}" + }, + "spacing-full": { + "$type": "dimension", + "$value": "{Spacing.1000}" + } + }, + "Border_Radius": { + "border-radius-0": { + "$type": "dimension", + "$value": "{Border-Radius.0}" + }, + "border-radius-xs": { + "$type": "dimension", + "$value": "{Border-Radius.100}" + }, + "border-radius-s": { + "$type": "dimension", + "$value": "{Border-Radius.200}" + }, + "border-radius-m": { + "$type": "dimension", + "$value": "{Border-Radius.300}" + }, + "border-radius-l": { + "$type": "dimension", + "$value": "{Border-Radius.400}" + }, + "border-radius-xl": { + "$type": "dimension", + "$value": "{Border-Radius.500}" + }, + "border-radius-xxl": { + "$type": "dimension", + "$value": "{Border-Radius.600}" + }, + "border-radius-full": { + "$type": "dimension", + "$value": "{Border-Radius.1000}" + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart b/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart new file mode 100644 index 0000000000..bddcdb4eae --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart @@ -0,0 +1,300 @@ +// ignore_for_file: avoid_print, depend_on_referenced_packages + +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; + +void main() { + generatePrimitive(); + generateSemantic(); +} + +void generatePrimitive() { + // 1. Load the JSON file. + final jsonString = + File('script/Primitive.Mode 1.tokens.json').readAsStringSync(); + final jsonData = jsonDecode(jsonString) as Map; + + // 2. Prepare the output code. + final buffer = StringBuffer(); + + buffer.writeln(''' +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: ${DateTime.now().toIso8601String()} +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:flutter/material.dart'; + +class AppFlowyPrimitiveTokens { + AppFlowyPrimitiveTokens._();'''); + + // 3. Process each color category. + jsonData.forEach((categoryName, categoryData) { + categoryData.forEach((tokenName, tokenData) { + processPrimitiveTokenData( + buffer, + tokenData, + '${categoryName}_$tokenName', + ); + }); + }); + + buffer.writeln('}'); + + // 4. Write the output to a Dart file. + final outputFile = File('lib/src/theme/data/appflowy_default/primitive.dart'); + outputFile.writeAsStringSync(buffer.toString()); + + print('Successfully generated ${outputFile.path}'); +} + +void processPrimitiveTokenData( + StringBuffer buffer, + Map tokenData, + final String currentTokenName, +) { + if (tokenData + case { + r'$type': 'color', + r'$value': final String colorValue, + }) { + final dartColorValue = convertColor(colorValue); + final dartTokenName = currentTokenName.replaceAll('-', '_').toCamelCase(); + + buffer.writeln(''' + + /// $colorValue + static Color get $dartTokenName => Color(0x$dartColorValue);'''); + } else { + tokenData.forEach((key, value) { + if (value is Map) { + processPrimitiveTokenData(buffer, value, '${currentTokenName}_$key'); + } + }); + } +} + +void generateSemantic() { + // 1. Load the JSON file. + final lightJsonString = + File('script/Semantic.Light Mode.tokens.json').readAsStringSync(); + final darkJsonString = + File('script/Semantic.Dark Mode.tokens.json').readAsStringSync(); + final lightJsonData = jsonDecode(lightJsonString) as Map; + final darkJsonData = jsonDecode(darkJsonString) as Map; + + // 2. Prepare the output code. + final buffer = StringBuffer(); + + buffer.writeln(''' +// ignore_for_file: constant_identifier_names, non_constant_identifier_names +// +// AUTO-GENERATED - DO NOT EDIT DIRECTLY +// +// This file is auto-generated by the generate_theme.dart script +// Generation time: ${DateTime.now().toIso8601String()} +// +// To modify these colors, edit the source JSON files and run the script: +// +// dart run script/generate_theme.dart +// +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +import '../shared.dart'; +import 'primitive.dart'; + +class AppFlowyDefaultTheme implements AppFlowyThemeBuilder {'''); + + // 3. Process light mode semantic tokens + buffer.writeln(''' + @override + AppFlowyThemeData light() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.light);'''); + + lightJsonData.forEach((categoryName, categoryData) { + if ([ + 'Spacing', + 'Border_Radius', + 'Shadow', + 'Badge_Color', + ].contains(categoryName)) { + return; + } + + final fullCategoryName = "${categoryName}_color_scheme".toCamelCase(); + final className = 'AppFlowy${fullCategoryName.toCapitalize()}'; + + buffer + ..writeln() + ..writeln(' final $fullCategoryName = $className('); + + categoryData.forEach((tokenName, tokenData) { + processSemanticTokenData(buffer, tokenData, tokenName); + }); + buffer.writeln(' );'); + }); + + buffer.writeln(); + buffer.writeln(''' + return AppFlowyThemeData( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + }'''); + + buffer.writeln(); + + buffer.writeln(''' + @override + AppFlowyThemeData dark() { + final textStyle = AppFlowyBaseTextStyle(); + final borderRadius = AppFlowySharedTokens.buildBorderRadius(); + final spacing = AppFlowySharedTokens.buildSpacing(); + final shadow = AppFlowySharedTokens.buildShadow(Brightness.dark);'''); + + darkJsonData.forEach((categoryName, categoryData) { + if ([ + 'Spacing', + 'Border_Radius', + 'Shadow', + 'Badge_Color', + ].contains(categoryName)) { + return; + } + + final fullCategoryName = "${categoryName}_color_scheme".toCamelCase(); + final className = 'AppFlowy${fullCategoryName.toCapitalize()}'; + + buffer + ..writeln() + ..writeln(' final $fullCategoryName = $className('); + + categoryData.forEach((tokenName, tokenData) { + if (tokenData is Map) { + processSemanticTokenData(buffer, tokenData, tokenName); + } + }); + buffer.writeln(' );'); + }); + + buffer.writeln(); + + buffer.writeln(''' + return AppFlowyThemeData( + textStyle: textStyle, + textColorScheme: textColorScheme, + borderColorScheme: borderColorScheme, + fillColorScheme: fillColorScheme, + surfaceColorScheme: surfaceColorScheme, + backgroundColorScheme: backgroundColorScheme, + iconColorScheme: iconColorScheme, + brandColorScheme: brandColorScheme, + otherColorsColorScheme: otherColorsColorScheme, + borderRadius: borderRadius, + spacing: spacing, + shadow: shadow, + ); + }'''); + + buffer.writeln('}'); + + // 4. Write the output to a Dart file. + final outputFile = File('lib/src/theme/data/appflowy_default/semantic.dart'); + outputFile.writeAsStringSync(buffer.toString()); + + print('Successfully generated ${outputFile.path}'); +} + +void processSemanticTokenData( + StringBuffer buffer, + Map json, + final String currentTokenName, +) { + if (json + case { + r'$type': 'color', + r'$value': final String value, + }) { + final semanticTokenName = + currentTokenName.replaceAll('-', '_').toCamelCase(); + + final String colorValueOrPrimitiveToken; + if (value.isColor) { + colorValueOrPrimitiveToken = 'Color(0x${convertColor(value)})'; + } else { + final primitiveToken = value + .replaceAll(RegExp(r'\{|\}'), '') + .replaceAll(RegExp(r'\.|-'), '_') + .toCamelCase(); + colorValueOrPrimitiveToken = 'AppFlowyPrimitiveTokens.$primitiveToken'; + } + + buffer.writeln(' $semanticTokenName: $colorValueOrPrimitiveToken,'); + } else { + json.forEach((key, value) { + if (value is Map) { + processSemanticTokenData( + buffer, + value, + '${currentTokenName}_$key', + ); + } + }); + } +} + +String convertColor(String hexColor) { + String color = hexColor.toUpperCase().replaceAll('#', ''); + if (color.length == 6) { + color = 'FF$color'; // Add missing alpha channel + } else if (color.length == 8) { + color = color.substring(6) + color.substring(0, 6); // Rearrange to ARGB + } + return color; +} + +extension on String { + String toCamelCase() { + return split('_').mapIndexed((index, part) { + if (index == 0) { + return part.toLowerCase(); + } else { + return part[0].toUpperCase() + part.substring(1).toLowerCase(); + } + }).join(); + } + + String toCapitalize() { + if (isEmpty) { + return this; + } + return '${this[0].toUpperCase()}${substring(1)}'; + } + + bool get isColor => + startsWith('#') || + (startsWith('0x') && length == 10) || + (startsWith('0xFF') && length == 12); +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart index c940f68451..4178edd294 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart @@ -1,7 +1,6 @@ -import 'package:flutter/material.dart'; - 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'; @@ -72,6 +71,7 @@ class FlowyColorScheme { required this.icon, required this.text, required this.secondaryText, + required this.strongText, required this.input, required this.hint, required this.primary, @@ -86,6 +86,11 @@ class FlowyColorScheme { required this.toggleButtonBGColor, required this.calendarWeekendBGColor, required this.gridRowCountColor, + required this.borderColor, + required this.scrollbarColor, + required this.scrollbarHoverColor, + required this.lightIconColor, + required this.toolbarHoverColor, }); final Color surface; @@ -123,6 +128,7 @@ class FlowyColorScheme { final Color icon; final Color text; final Color secondaryText; + final Color strongText; final Color input; final Color hint; final Color primary; @@ -143,8 +149,51 @@ class FlowyColorScheme { //grid bottom count color final Color gridRowCountColor; + final Color borderColor; + + final Color scrollbarColor; + final Color scrollbarHoverColor; + + final Color lightIconColor; + final Color toolbarHoverColor; + 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 index 7c3939e5fe..8d49b8dfa1 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart @@ -1,3 +1,4 @@ +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flutter/material.dart'; import 'colorscheme.dart'; @@ -64,6 +65,7 @@ class DandelionColorScheme extends FlowyColorScheme { icon: _lightShader1, text: _lightShader1, secondaryText: _lightShader1, + strongText: Colors.black, input: _white, hint: _lightShader3, primary: _lightDandelionYellow, @@ -80,6 +82,11 @@ class DandelionColorScheme extends FlowyColorScheme { toggleButtonBGColor: _lightDandelionYellow, calendarWeekendBGColor: const Color(0xFFFBFBFC), gridRowCountColor: _black, + borderColor: ColorSchemeConstants.lightBorderColor, + scrollbarColor: const Color(0x3F171717), + scrollbarHoverColor: const Color(0x7F171717), + lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: const Color(0xFFF2F4F7), ); const DandelionColorScheme.dark() @@ -119,6 +126,7 @@ class DandelionColorScheme extends FlowyColorScheme { icon: _darkShader5, text: _darkShader5, secondaryText: _darkShader5, + strongText: Colors.white, input: _darkInput, hint: _darkShader5, primary: _darkMain1, @@ -133,5 +141,10 @@ class DandelionColorScheme extends FlowyColorScheme { toggleButtonBGColor: _darkShader1, calendarWeekendBGColor: const Color(0xff121212), gridRowCountColor: _darkMain1, + borderColor: ColorSchemeConstants.darkBorderColor, + scrollbarColor: const Color(0x40FFFFFF), + scrollbarHoverColor: const Color(0x80FFFFFF), + lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: _lightShader6, ); } 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 index 9f0cc50c93..0e39de8fa8 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart @@ -2,43 +2,48 @@ import 'package:flutter/material.dart'; import 'colorscheme.dart'; -const _white = Color(0xFFFFFFFF); -const _lightHover = Color(0xFFe0f8FF); -const _lightSelector = Color(0xFFf2fcFF); -const _lightBg1 = Color(0xFFf7f8fc); -const _lightBg2 = Color(0xFFedeef2); -const _lightShader1 = Color(0xFF333333); -const _lightShader3 = Color(0xFF828282); -const _lightShader5 = Color(0xFFe0e0e0); -const _lightShader6 = Color(0xFFf2f2f2); -const _lightMain1 = Color(0xFF00bcf0); -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(0xFF00BCF0); -const _darkInput = Color(0xFF282E3A); +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: _white, - hover: _lightHover, - selector: _lightSelector, + surface: ColorSchemeConstants.white, + hover: ColorSchemeConstants.lightHover, + selector: ColorSchemeConstants.lightSelector, red: const Color(0xFFfb006d), yellow: const Color(0xFFFFd667), green: const Color(0xFF66cf80), - shader1: _lightShader1, + shader1: ColorSchemeConstants.lightShader1, shader2: const Color(0xFF4f4f4f), - shader3: _lightShader3, + shader3: ColorSchemeConstants.lightShader3, shader4: const Color(0xFFbdbdbd), - shader5: _lightShader5, - shader6: _lightShader6, - shader7: _lightShader1, - bg1: _lightBg1, - bg2: _lightBg2, + 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), @@ -49,82 +54,94 @@ class DefaultColorScheme extends FlowyColorScheme { tint6: const Color(0xFFf5FFdc), tint7: const Color(0xFFddFFd6), tint8: const Color(0xFFdeFFf1), - tint9: _lightTint9, - main1: _lightMain1, + tint9: ColorSchemeConstants.lightTint9, + main1: ColorSchemeConstants.lightMain1, main2: const Color(0xFF00b7ea), shadow: const Color.fromRGBO(0, 0, 0, 0.15), - sidebarBg: _lightBg1, - divider: _lightShader6, - topbarBg: _white, - icon: _lightShader1, - text: _lightShader1, + sidebarBg: ColorSchemeConstants.lightBg1, + divider: ColorSchemeConstants.lightShader6, + topbarBg: ColorSchemeConstants.white, + icon: ColorSchemeConstants.lightShader1, + text: ColorSchemeConstants.lightShader1, secondaryText: const Color(0xFF4f4f4f), - input: _white, - hint: _lightShader3, - primary: _lightMain1, - onPrimary: _white, - hoverBG1: _lightBg2, - hoverBG2: _lightHover, - hoverBG3: _lightShader6, - hoverFG: _lightShader1, - questionBubbleBG: _lightSelector, - progressBarBGColor: _lightTint9, - toolbarColor: _lightShader1, - toggleButtonBGColor: _lightShader5, + 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: _lightShader1, + gridRowCountColor: ColorSchemeConstants.lightShader1, + borderColor: ColorSchemeConstants.lightBorderColor, + scrollbarColor: const Color(0x3F171717), + scrollbarHoverColor: const Color(0x7F171717), + lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: const Color(0xFFF2F4F7), ); const DefaultColorScheme.dark() : super( - surface: _darkShader2, - hover: _darkMain1, - selector: _darkShader2, + surface: ColorSchemeConstants.darkShader2, + hover: ColorSchemeConstants.darkMain1, + selector: ColorSchemeConstants.darkShader2, red: const Color(0xFFfb006d), yellow: const Color(0xFFF7CF46), green: const Color(0xFF66CF80), - shader1: _darkShader1, - shader2: _darkShader2, - shader3: _darkShader3, + shader1: ColorSchemeConstants.darkShader1, + shader2: ColorSchemeConstants.darkShader2, + shader3: ColorSchemeConstants.darkShader3, shader4: const Color(0xFF505469), - shader5: _darkShader5, - shader6: _darkShader6, - shader7: _white, + shader5: ColorSchemeConstants.darkShader5, + shader6: ColorSchemeConstants.darkShader6, + shader7: ColorSchemeConstants.white, bg1: const Color(0xFF1A202C), bg2: const Color(0xFFEDEEF2), - bg3: _darkMain1, + bg3: ColorSchemeConstants.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, + 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: _darkShader3, - topbarBg: _darkShader1, - icon: _darkShader5, - text: _darkShader5, - secondaryText: _darkShader5, - input: _darkInput, + 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: _darkMain1, - onPrimary: _darkShader1, - hoverBG1: _darkMain1, - hoverBG2: _darkMain1, - hoverBG3: _darkShader3, - hoverFG: _darkShader1, - questionBubbleBG: _darkShader3, - progressBarBGColor: _darkShader3, - toolbarColor: _darkInput, - toggleButtonBGColor: _darkShader1, - calendarWeekendBGColor: _darkShader1, - gridRowCountColor: _darkShader5, + 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), + toolbarHoverColor: ColorSchemeConstants.lightShader6, ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart index 87fea48cdd..590d26db3e 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart @@ -1,3 +1,4 @@ +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flutter/material.dart'; import 'colorscheme.dart'; @@ -62,6 +63,7 @@ class LavenderColorScheme extends FlowyColorScheme { icon: _lightShader1, text: _lightShader1, secondaryText: _lightShader1, + strongText: Colors.black, input: _white, hint: _lightShader3, primary: _lightMain1, @@ -76,6 +78,11 @@ class LavenderColorScheme extends FlowyColorScheme { toggleButtonBGColor: _lightSelector, calendarWeekendBGColor: const Color(0xFFFBFBFC), gridRowCountColor: _black, + borderColor: ColorSchemeConstants.lightBorderColor, + scrollbarColor: const Color(0x3F171717), + scrollbarHoverColor: const Color(0x7F171717), + lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: const Color(0xFFF2F4F7), ); const LavenderColorScheme.dark() @@ -115,6 +122,7 @@ class LavenderColorScheme extends FlowyColorScheme { icon: _darkShader5, text: _darkShader5, secondaryText: _darkShader5, + strongText: Colors.white, input: _darkInput, hint: _darkShader5, primary: _darkMain1, @@ -129,5 +137,10 @@ class LavenderColorScheme extends FlowyColorScheme { toggleButtonBGColor: _darkShader1, calendarWeekendBGColor: const Color(0xff121212), gridRowCountColor: _darkMain1, + borderColor: ColorSchemeConstants.darkBorderColor, + scrollbarColor: const Color(0x40FFFFFF), + scrollbarHoverColor: const Color(0x80FFFFFF), + lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: _lightShader6, ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart index d2a80f7b92..3f39ae4c84 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart @@ -1,3 +1,4 @@ +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flutter/material.dart'; import 'colorscheme.dart'; @@ -66,6 +67,7 @@ class LemonadeColorScheme extends FlowyColorScheme { icon: _lightShader1, text: _lightShader1, secondaryText: _lightShader1, + strongText: Colors.black, input: _white, hint: _lightShader3, primary: _lightDandelionYellow, @@ -82,58 +84,68 @@ class LemonadeColorScheme extends FlowyColorScheme { toggleButtonBGColor: _lightDandelionYellow, calendarWeekendBGColor: const Color(0xFFFBFBFC), gridRowCountColor: _black, + borderColor: ColorSchemeConstants.lightBorderColor, + scrollbarColor: const Color(0x3F171717), + scrollbarHoverColor: const Color(0x7F171717), + lightIconColor: const Color(0xFF8F959E), + toolbarHoverColor: const Color(0xFFF2F4F7), ); 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, - 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, - ); + 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), + toolbarHoverColor: _lightShader6); } 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 index 2e4d082761..1e6f6a99e2 100644 --- 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 @@ -1,5 +1,7 @@ -import 'package:flowy_infra/file_picker/file_picker_service.dart'; +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 @@ -35,6 +37,11 @@ class FilePicker implements FilePickerService { 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, @@ -43,6 +50,7 @@ class FilePicker implements FilePickerService { FileType type = FileType.any, List? allowedExtensions, bool lockParentWindow = false, + Uint8List? bytes, }) async { final result = await fp.FilePicker.platform.saveFile( dialogTitle: dialogTitle, @@ -51,6 +59,7 @@ class FilePicker implements FilePickerService { type: type, allowedExtensions: allowedExtensions, lockParentWindow: lockParentWindow, + bytes: bytes, ); return result; diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart index f3a6869735..6f37058f00 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart @@ -48,6 +48,10 @@ String languageFromLocale(Locale locale) { default: return locale.languageCode; } + case "mr": + return "मराठी"; + case "he": + return "עברית"; case "hu": return "Magyar"; case "id": 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 0000000000..9e2085e3e4 --- /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 index ee1adee332..0dbfc84564 100644 --- 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 @@ -24,8 +24,11 @@ class DynamicPluginBloc extends Bloc { } Future onLoadRequested(Emitter emit) async { - emit(DynamicPluginState.ready( - plugins: await FlowyPluginService.instance.plugins)); + emit( + DynamicPluginState.ready( + plugins: await FlowyPluginService.instance.plugins, + ), + ); } Future addPlugin(Emitter emit) async { @@ -33,31 +36,41 @@ class DynamicPluginBloc extends Bloc { try { final plugin = await FlowyPluginService.pick(); if (plugin == null) { - emit(DynamicPluginState.ready( - plugins: await FlowyPluginService.instance.plugins)); - return; + 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)); + return emit( + DynamicPluginState.compilationFailure(errorMessage: exception.message), + ); } emit(const DynamicPluginState.compilationSuccess()); - emit(DynamicPluginState.ready( - plugins: await FlowyPluginService.instance.plugins)); + emit( + DynamicPluginState.ready( + plugins: await FlowyPluginService.instance.plugins, + ), + ); } Future removePlugin( - Emitter emit, String name) async { + Emitter emit, + String name, + ) async { emit(const DynamicPluginState.processing()); final plugin = await FlowyPluginService.instance.lookup(name: name); if (plugin == null) { - emit(DynamicPluginState.ready( - plugins: await FlowyPluginService.instance.plugins)); - return; + return emit( + DynamicPluginState.ready( + plugins: await FlowyPluginService.instance.plugins, + ), + ); } await FlowyPluginService.removePlugin(plugin); @@ -65,7 +78,8 @@ class DynamicPluginBloc extends Bloc { emit(const DynamicPluginState.deletionSuccess()); emit( DynamicPluginState.ready( - plugins: await FlowyPluginService.instance.plugins), + plugins: await FlowyPluginService.instance.plugins, + ), ); } } 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 index 93e972eeba..2f31ffbf1e 100644 --- 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 @@ -1,6 +1,8 @@ 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'; @@ -123,8 +125,9 @@ class FlowyDynamicPlugin { late final FlowyColorScheme darkTheme; try { - lightTheme = FlowyColorScheme.fromJson( - await jsonDecode(await light.readAsString())); + lightTheme = FlowyColorScheme.fromJsonSoft( + await jsonDecode(await light.readAsString()), + ); } catch (e) { throw PluginCompilationException( 'The light theme json file is not valid.', @@ -132,8 +135,10 @@ class FlowyDynamicPlugin { } try { - darkTheme = FlowyColorScheme.fromJson( - await jsonDecode(await dark.readAsString())); + darkTheme = FlowyColorScheme.fromJsonSoft( + await jsonDecode(await dark.readAsString()), + Brightness.dark, + ); } catch (e) { throw PluginCompilationException( 'The dark theme json file is not valid.', diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart index b66d836bb3..f58dad95b5 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart @@ -57,8 +57,6 @@ class Sizes { static double get hit => 40 * hitScale; static double get iconMed => 20; - - static double get sideBarWidth => 250 * hitScale; } class Corners { @@ -82,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_extension.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart index 2eeb901ef4..9ce1f0323d 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart @@ -5,6 +5,9 @@ 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, @@ -23,6 +26,7 @@ class AFThemeExtension extends ThemeExtension { required this.toggleOffFill, required this.textColor, required this.secondaryTextColor, + required this.strongText, required this.calloutBGColor, required this.tableCellBGColor, required this.calendarWeekendBGColor, @@ -32,6 +36,13 @@ class AFThemeExtension extends ThemeExtension { 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.toolbarHoverColor, + required this.lightIconColor, }); final Color? warning; @@ -49,6 +60,7 @@ class AFThemeExtension extends ThemeExtension { final Color textColor; final Color secondaryTextColor; + final Color strongText; final Color greyHover; final Color greySelect; final Color lightGreyHover; @@ -64,6 +76,20 @@ class AFThemeExtension extends ThemeExtension { 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 toolbarHoverColor; + final Color lightIconColor; + @override AFThemeExtension copyWith({ Color? warning, @@ -79,6 +105,7 @@ class AFThemeExtension extends ThemeExtension { Color? tint9, Color? textColor, Color? secondaryTextColor, + Color? strongText, Color? calloutBGColor, Color? tableCellBGColor, Color? greyHover, @@ -92,6 +119,13 @@ class AFThemeExtension extends ThemeExtension { TextStyle? code, TextStyle? callout, TextStyle? caption, + Color? background, + Color? onBackground, + Color? borderColor, + Color? scrollbarColor, + Color? scrollbarHoverColor, + Color? lightIconColor, + Color? toolbarHoverColor, }) => AFThemeExtension( warning: warning ?? this.warning, @@ -107,6 +141,7 @@ class AFThemeExtension extends ThemeExtension { 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, @@ -121,6 +156,13 @@ class AFThemeExtension extends ThemeExtension { 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, + toolbarHoverColor: toolbarHoverColor ?? this.toolbarHoverColor, ); @override @@ -147,6 +189,11 @@ class AFThemeExtension extends ThemeExtension { other.secondaryTextColor, t, )!, + strongText: Color.lerp( + strongText, + other.strongText, + t, + )!, calloutBGColor: Color.lerp(calloutBGColor, other.calloutBGColor, t)!, tableCellBGColor: Color.lerp(tableCellBGColor, other.tableCellBGColor, t)!, @@ -165,6 +212,15 @@ class AFThemeExtension extends ThemeExtension { 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)!, + toolbarHoverColor: + Color.lerp(toolbarHoverColor, other.toolbarHoverColor, t)!, ); } } @@ -198,16 +254,17 @@ enum FlowyTint { return null; } - Color color(BuildContext context) => switch (this) { - FlowyTint.tint1 => AFThemeExtension.of(context).tint1, - FlowyTint.tint2 => AFThemeExtension.of(context).tint2, - FlowyTint.tint3 => AFThemeExtension.of(context).tint3, - FlowyTint.tint4 => AFThemeExtension.of(context).tint4, - FlowyTint.tint5 => AFThemeExtension.of(context).tint5, - FlowyTint.tint6 => AFThemeExtension.of(context).tint6, - FlowyTint.tint7 => AFThemeExtension.of(context).tint7, - FlowyTint.tint8 => AFThemeExtension.of(context).tint8, - FlowyTint.tint9 => AFThemeExtension.of(context).tint9, + 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) { 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 index 19ca90d78f..4f80f81e62 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart @@ -13,5 +13,12 @@ class ColorConverter implements JsonConverter { } @override - String toJson(Color color) => "0x${color.value.toRadixString(16)}"; + String toJson(Color color) { + final alpha = (color.a * 255).toInt().toRadixString(16).padLeft(2, '0'); + final red = (color.r * 255).toInt().toRadixString(16).padLeft(2, '0'); + final green = (color.g * 255).toInt().toRadixString(16).padLeft(2, '0'); + final blue = (color.b * 255).toInt().toRadixString(16).padLeft(2, '0'); + + return '0x$alpha$red$green$blue'.toLowerCase(); + } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/uuid.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/uuid.dart index df8aee9dda..1f4c903625 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/uuid.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/uuid.dart @@ -1,5 +1,19 @@ +import 'package:uuid/data.dart'; +import 'package:uuid/rng.dart'; import 'package:uuid/uuid.dart'; +const _uuid = Uuid(); + String uuid() { - return const Uuid().v4(); + 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 index 0653aacaa5..9bf0245dc0 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml @@ -1,5 +1,5 @@ name: flowy_infra -description: A new Flutter package project. +description: AppFlowy Infra. version: 0.0.1 homepage: https://appflowy.io @@ -10,59 +10,19 @@ environment: dependencies: flutter: sdk: flutter - flutter_svg: ^2.0.2 json_annotation: ^4.7.0 path_provider: ^2.0.15 path: ^1.8.2 - textstyle_extensions: "2.0.0-nullsafety" time: ">=2.0.0" uuid: ">=2.2.2" - bloc: ^8.1.2 + bloc: ^9.0.0 freezed_annotation: ^2.1.0 file_picker: ^8.0.2 file: ^7.0.0 + analyzer: 6.11.0 dev_dependencies: - flutter_test: - sdk: flutter - build_runner: ^2.2.0 + 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/appflowy_flutter/packages/flowy_infra_ui/example/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/pubspec.yaml index 3cd07738f8..8c7793d7cc 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/example/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/pubspec.yaml @@ -4,7 +4,7 @@ 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.19.0" + flutter: ">=3.22.0" sdk: ">=3.1.5 <4.0.0" dependencies: 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 index f2e3eb8749..4a8ad910cb 100644 --- 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 @@ -1,7 +1,7 @@ name: flowy_infra_ui_platform_interface description: A new Flutter package project. version: 0.0.1 -homepage: +homepage: https://github.com/appflowy-io/appflowy environment: sdk: ">=2.12.0 <3.0.0" @@ -17,5 +17,3 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.1 - -flutter: \ No newline at end of file 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 index bbdac0d2e4..d4364a6400 100644 --- 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 @@ -1,7 +1,7 @@ name: flowy_infra_ui_web description: A new Flutter package project. version: 0.0.1 -homepage: +homepage: https://github.com/appflowy-io/appflowy publish_to: none environment: @@ -25,4 +25,4 @@ flutter: platforms: web: pluginClass: FlowyInfraUIPlugin - fileName: flowy_infra_ui_web.dart \ No newline at end of file + fileName: flowy_infra_ui_web.dart diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/basis.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/basis.dart index 59a2bc6533..7bbcbf0949 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/basis.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/basis.dart @@ -1,8 +1,3 @@ -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/appflowy_flutter/packages/flowy_infra_ui/lib/flowy_infra_ui.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/flowy_infra_ui.dart index cb147e2782..e1f58189b1 100644 --- 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 @@ -1,22 +1,23 @@ // Basis +export '/widget/flowy_tooltip.dart'; +export '/widget/separated_flex.dart'; +export '/widget/spacing.dart'; export 'basis.dart'; - -// Keyboard -export 'src/keyboard/keyboard_visibility_detector.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'; -export 'src/flowy_overlay/flowy_dialog.dart'; -export 'src/flowy_overlay/appflowy_popover.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'; - -export 'style_widget/button.dart'; -export 'style_widget/icon_button.dart'; -export 'style_widget/scrolling/styled_scroll_bar.dart'; -export '/widget/spacing.dart'; -export '/widget/separated_flex.dart'; -export 'style_widget/scrolling/styled_list.dart'; -export 'style_widget/color_picker.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 index 3014d393dd..6a154d4d48 100644 --- 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 @@ -1,35 +1,27 @@ import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra_ui/style_widget/decoration.dart'; +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flutter/material.dart'; +export 'package:appflowy_popover/appflowy_popover.dart'; + +class ShadowConstants { + ShadowConstants._(); + + static const List lightSmall = [ + BoxShadow(offset: Offset(0, 4), blurRadius: 20, color: Color(0x1A1F2329)), + ]; + static const List lightMedium = [ + BoxShadow(offset: Offset(0, 4), blurRadius: 32, color: Color(0x121F2225)), + ]; + static const List darkSmall = [ + BoxShadow(offset: Offset(0, 2), blurRadius: 16, color: Color(0x7A000000)), + ]; + static const List darkMedium = [ + BoxShadow(offset: Offset(0, 4), blurRadius: 32, color: Color(0x7A000000)), + ]; +} + 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 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 Decoration? decoration; - - /// 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; - const AppFlowyPopover({ super.key, required this.child, @@ -46,15 +38,75 @@ class AppFlowyPopover extends StatelessWidget { this.asBarrier = false, this.margin = const EdgeInsets.all(6), this.windowPadding = const EdgeInsets.all(8.0), - this.decoration, this.clickHandler = PopoverClickHandler.listener, this.skipTraversal = false, + this.decorationColor, + this.borderRadius, + this.popoverDecoration, + 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; + final Decoration? popoverDecoration; + + /// 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, @@ -66,14 +118,15 @@ class AppFlowyPopover extends StatelessWidget { offset: offset, clickHandler: clickHandler, skipTraversal: skipTraversal, - popupBuilder: (context) { - return _PopoverContainer( - constraints: constraints, - margin: margin, - decoration: decoration, - child: popupBuilder(context), - ); - }, + popupBuilder: (context) => _PopoverContainer( + constraints: constraints, + margin: margin, + decoration: popoverDecoration, + decorationColor: decorationColor, + borderRadius: borderRadius, + child: popupBuilder(context), + ), + showAtCursor: showAtCursor, child: child, ); } @@ -81,33 +134,65 @@ class AppFlowyPopover extends StatelessWidget { class _PopoverContainer extends StatelessWidget { const _PopoverContainer({ + this.decorationColor, + this.borderRadius, + this.decoration, required this.child, required this.margin, required this.constraints, - required this.decoration, }); final Widget child; final BoxConstraints constraints; final EdgeInsets margin; + final Color? decorationColor; + final BorderRadius? borderRadius; final Decoration? decoration; @override Widget build(BuildContext context) { - final decoration = this.decoration ?? - FlowyDecoration.decoration( - Theme.of(context).cardColor, - Theme.of(context).colorScheme.shadow, - ); - return Material( type: MaterialType.transparency, child: Container( padding: margin, - decoration: decoration, + decoration: decoration ?? + context.getPopoverDecoration( + color: decorationColor, + borderRadius: borderRadius, + ), constraints: constraints, child: child, ), ); } } + +extension PopoverDecoration 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 = Theme.of(this).brightness == Brightness.light + ? ShadowConstants.lightSmall + : ShadowConstants.darkSmall; + 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 index 8a2c504bc5..a5a51a16e6 100644 --- 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 @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; const _overlayContainerPadding = EdgeInsets.symmetric(vertical: 12); @@ -52,15 +50,13 @@ class FlowyDialog extends StatelessWidget { title: title, shape: shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - clipBehavior: Clip.hardEdge, + clipBehavior: Clip.antiAliasWithSaveLayer, children: [ Material( type: MaterialType.transparency, child: Container( height: expandHeight ? size.height : null, - width: width ?? - max(min(size.width, overlayContainerMaxWidth), - overlayContainerMinWidth), + width: width ?? size.width, constraints: constraints, child: child, ), diff --git a/frontend/appflowy_flutter/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 index 97d368eab6..ef03bbc3bd 100644 --- a/frontend/appflowy_flutter/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 @@ -2,10 +2,11 @@ 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 @@ -341,18 +342,17 @@ class FlowyOverlayState extends State { @override void initState() { - _keyboardShortcutBindings.addAll({ - LogicalKeySet(LogicalKeyboardKey.escape): (identifier) { - remove(identifier); - }, - }); super.initState(); + _keyboardShortcutBindings.addAll({ + LogicalKeySet(LogicalKeyboardKey.escape): (identifier) => + remove(identifier), + }); } @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(); @@ -390,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/appflowy_flutter/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 index 0d4bacde52..fb29bb0637 100644 --- a/frontend/appflowy_flutter/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 @@ -1,5 +1,7 @@ import 'dart:math' as math; + import 'package:flutter/material.dart'; + import 'flowy_overlay.dart'; class PopoverLayoutDelegate extends SingleChildLayoutDelegate { @@ -133,8 +135,6 @@ class PopoverLayoutDelegate extends SingleChildLayoutDelegate { case AnchorDirection.custom: childConstraints = constraints.loosen(); break; - default: - throw UnimplementedError(); } return childConstraints; } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart index 87dd63b715..84e7bb8ebd 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; + import 'package:flutter/material.dart'; + import 'flowy_overlay.dart'; class OverlayLayoutDelegate extends SingleChildLayoutDelegate { @@ -133,8 +135,6 @@ class OverlayLayoutDelegate extends SingleChildLayoutDelegate { case AnchorDirection.custom: childConstraints = constraints.loosen(); break; - default: - throw UnimplementedError(); } return childConstraints; } diff --git a/frontend/appflowy_flutter/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 index 2b61839ac3..d1964e0c83 100644 --- a/frontend/appflowy_flutter/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 @@ -128,7 +128,7 @@ class OverlayContainer extends StatelessWidget { padding: padding, decoration: FlowyDecoration.decoration( Theme.of(context).colorScheme.surface, - Theme.of(context).colorScheme.shadow.withOpacity(0.15), + Theme.of(context).colorScheme.shadow.withValues(alpha: 0.15), ), constraints: constraints, child: child, 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 index 832eca88e0..0273807980 100644 --- 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 @@ -1,19 +1,152 @@ 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_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, + border: borderColor == null ? null : Border.all(color: borderColor!), + ), + 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 EdgeInsets? margin; + final EdgeInsetsGeometry? margin; final Widget? leftIcon; final Widget? rightIcon; final Color? hoverColor; @@ -29,6 +162,9 @@ class FlowyButton extends StatelessWidget { final bool showDefaultBoxDecorationOnMobile; final double iconPadding; final bool expand; + final Color? borderColor; + final Color? backgroundColor; + final bool resetHoverOnRebuild; const FlowyButton({ super.key, @@ -52,6 +188,9 @@ class FlowyButton extends StatelessWidget { this.showDefaultBoxDecorationOnMobile = false, this.iconPadding = 6, this.expand = false, + this.borderColor, + this.backgroundColor, + this.resetHoverOnRebuild = true, }); @override @@ -62,6 +201,7 @@ class FlowyButton extends StatelessWidget { 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, @@ -74,11 +214,14 @@ class FlowyButton extends StatelessWidget { 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, + border: borderColor == null ? null : Border.all(color: borderColor!), + backgroundColor: backgroundColor ?? Colors.transparent, ), onHover: disable ? null : onHover, isSelected: () => isSelected, @@ -107,7 +250,7 @@ class FlowyButton extends StatelessWidget { } if (rightIcon != null) { - children.add(const HSpace(6)); + children.add(HSpace(iconPadding)); // No need to define the size of rightIcon. Just use its intrinsic width children.add(rightIcon!); } @@ -123,21 +266,41 @@ class FlowyButton extends StatelessWidget { child = IntrinsicWidth(child: child); } - final decoration = this.decoration ?? + var decoration = this.decoration; + + if (decoration == null && (showDefaultBoxDecorationOnMobile && - (Platform.isIOS || Platform.isAndroid) - ? BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.surfaceVariant, - width: 1.0, - )) - : null); + (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), + padding: margin ?? + const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), child: child, ), ); @@ -165,8 +328,41 @@ class FlowyTextButton extends StatelessWidget { 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; @@ -188,6 +384,8 @@ class FlowyTextButton extends StatelessWidget { final String? fontFamily; final bool isDangerous; + final Color? borderColor; + final double? lineHeight; @override Widget build(BuildContext context) { @@ -196,7 +394,11 @@ class FlowyTextButton extends StatelessWidget { children.add(heading!); children.add(const HSpace(8)); } - children.add(Text(text, overflow: overflow, textAlign: TextAlign.center)); + children.add(Text( + text, + overflow: overflow, + textAlign: TextAlign.center, + )); Widget child = Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -207,35 +409,38 @@ class FlowyTextButton extends StatelessWidget { child = ConstrainedBox( constraints: constraints, child: TextButton( - onPressed: onPressed ?? () {}, + onPressed: onPressed, focusNode: FocusNode(skipTraversal: onPressed == null), style: ButtonStyle( - overlayColor: const MaterialStatePropertyAll(Colors.transparent), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), splashFactory: NoSplash.splashFactory, tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: MaterialStateProperty.all(padding), - elevation: MaterialStateProperty.all(0), - shape: MaterialStateProperty.all( + padding: WidgetStateProperty.all(padding), + elevation: WidgetStateProperty.all(0), + shape: WidgetStateProperty.all( RoundedRectangleBorder( side: BorderSide( - color: isDangerous - ? Theme.of(context).colorScheme.error - : Colors.transparent, + color: borderColor ?? + (isDangerous + ? Theme.of(context).colorScheme.error + : Colors.transparent), ), borderRadius: radius ?? Corners.s6Border, ), ), - textStyle: MaterialStateProperty.all( - TextStyle( - fontWeight: fontWeight ?? FontWeight.w500, - fontSize: fontSize, - decoration: decoration, - fontFamily: fontFamily, - ), + 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: MaterialStateProperty.resolveWith( + backgroundColor: WidgetStateProperty.resolveWith( (states) { - if (states.contains(MaterialState.hovered)) { + if (states.contains(WidgetState.hovered)) { return hoverColor ?? (isDangerous ? Theme.of(context).colorScheme.error @@ -248,9 +453,9 @@ class FlowyTextButton extends StatelessWidget { : Theme.of(context).colorScheme.secondaryContainer); }, ), - foregroundColor: MaterialStateProperty.resolveWith( + foregroundColor: WidgetStateProperty.resolveWith( (states) { - if (states.contains(MaterialState.hovered)) { + if (states.contains(WidgetState.hovered)) { return fontHoverColor ?? (fontColor ?? Theme.of(context).colorScheme.onSurface); } @@ -328,7 +533,6 @@ class FlowyRichTextButton extends StatelessWidget { ); child = RawMaterialButton( - visualDensity: VisualDensity.compact, hoverElevation: 0, highlightElevation: 0, shape: RoundedRectangleBorder(borderRadius: radius ?? Corners.s6Border), 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 index afb4a787ed..1f8329e041 100644 --- 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 @@ -58,22 +58,18 @@ class FlowyColorPicker extends StatelessWidget { checkmark = const FlowySvg(FlowySvgData("grid/checkmark")); } - final colorIcon = SizedBox.square( - dimension: iconSize, - child: DecoratedBox( - decoration: BoxDecoration( - color: option.color, - shape: BoxShape.circle, - ), - ), + final colorIcon = ColorOptionIcon( + color: option.color, + iconSize: iconSize, ); return SizedBox( height: itemHeight, child: FlowyButton( - text: FlowyText.medium(option.i18n), + text: FlowyText(option.i18n), leftIcon: colorIcon, rightIcon: checkmark, + iconPadding: 10, onTap: () { onTap?.call(option, i); }, @@ -81,3 +77,30 @@ class FlowyColorPicker extends StatelessWidget { ); } } + +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/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/decoration.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/decoration.dart index 1a4b96ecb5..a3f80cb41c 100644 --- a/frontend/appflowy_flutter/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 0000000000..7f4b630386 --- /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/hover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart index 299eb76015..e734c4bb68 100644 --- 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 @@ -57,23 +57,21 @@ class _FlowyHoverState extends State { return MouseRegion( cursor: widget.cursor != null ? widget.cursor! : SystemMouseCursors.click, opaque: false, - onHover: (p) { - if (_onHover) return; - _setOnHover(true); - }, - onEnter: (p) { - if (_onHover) return; - _setOnHover(true); - }, - onExit: (p) { - if (!_onHover) return; - _setOnHover(false); - }, - child: renderWidget(), + 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) { @@ -81,30 +79,10 @@ class _FlowyHoverState extends State { } } } - - Widget renderWidget() { - bool showHover = _onHover; - if (!showHover && widget.isSelected != null) { - showHover = widget.isSelected!(); - } - - final child = widget.child ?? widget.builder!(context, _onHover); - final style = widget.style ?? - HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary); - if (showHover) { - return FlowyHoverContainer( - style: style, - child: child, - ); - } else { - return Container(color: style.backgroundColor, child: child); - } - } } class HoverStyle { - final Color borderColor; - final double borderWidth; + final BoxBorder? border; final Color? hoverColor; final Color? foregroundColorOnHover; final BorderRadius borderRadius; @@ -112,8 +90,7 @@ class HoverStyle { final Color backgroundColor; const HoverStyle({ - this.borderColor = Colors.transparent, - this.borderWidth = 0, + this.border, this.borderRadius = const BorderRadius.all(Radius.circular(6)), this.contentMargin = EdgeInsets.zero, this.backgroundColor = Colors.transparent, @@ -122,32 +99,28 @@ class HoverStyle { }); 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; + }) : hoverColor = Colors.transparent, + border = null; } 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; @@ -166,12 +139,14 @@ class FlowyHoverContainer extends StatelessWidget { return Container( margin: style.contentMargin, decoration: BoxDecoration( - border: hoverBorder, - color: style.hoverColor ?? Theme.of(context).colorScheme.secondary, + border: style.border, + color: applyStyle + ? style.hoverColor ?? Theme.of(context).colorScheme.secondary + : style.backgroundColor, borderRadius: style.borderRadius, ), child: Theme( - data: hoverTheme, + 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 index 116158b10b..cb3605fe7e 100644 --- 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 @@ -1,10 +1,11 @@ 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'; -import 'package:flutter/material.dart'; class FlowyIconButton extends StatelessWidget { final double width; @@ -62,11 +63,12 @@ class FlowyIconButton extends StatelessWidget { child = FlowyHover( isSelected: isSelected != null ? () => isSelected! : null, style: HoverStyle( - hoverColor: hoverColor, - foregroundColorOnHover: - iconColorOnHover ?? Theme.of(context).iconTheme.color, - //Do not set background here. Use [fillColor] instead. - ), + 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, ); @@ -82,9 +84,8 @@ class FlowyIconButton extends StatelessWidget { preferBelow: preferBelow, message: tooltipMessage, richMessage: richTooltipText, - showDuration: Duration.zero, child: RawMaterialButton( - visualDensity: VisualDensity.compact, + clipBehavior: Clip.antiAlias, hoverElevation: 0, highlightElevation: 0, shape: 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 0000000000..61f92fd073 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart @@ -0,0 +1,106 @@ +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, + overflow: TextOverflow.ellipsis, + ), + margin: margin ?? const EdgeInsets.symmetric(horizontal: 14.0), + backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.primary, + hoverColor: hoverColor ?? + Theme.of(context).colorScheme.primary.withValues(alpha: 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/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 0000000000..01111293ec --- /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/appflowy_flutter/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 index a3f262c070..92381cff44 100644 --- a/frontend/appflowy_flutter/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 @@ -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); @@ -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 index da22674152..ece1801098 100644 --- 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 @@ -1,12 +1,11 @@ import 'dart:async'; import 'dart:math'; -import 'package:flutter/material.dart'; - 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 { @@ -120,11 +119,6 @@ class ScrollbarState extends State { ? false : contentExtent > _viewExtent && contentExtent > 0; - // Handle color - var handleColor = widget.handleColor ?? - (Theme.of(context).brightness == Brightness.dark - ? AFThemeExtension.of(context).lightGreyHover - : AFThemeExtension.of(context).greyHover); // Track color var trackColor = widget.trackColor ?? (Theme.of(context).brightness == Brightness.dark @@ -161,18 +155,24 @@ class ScrollbarState extends State { 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, - ), - ), + 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, + ), + ); + }, ), ), ) 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 index 778aee0a74..c889496f17 100644 --- 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 @@ -9,7 +9,8 @@ void showSnapBar(BuildContext context, String title, {VoidCallback? onClosed}) { ScaffoldMessenger.of(context) .showSnackBar( SnackBar( - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, duration: const Duration(milliseconds: 8000), content: FlowyText( title, 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 index 65d0c19c59..360578a4a6 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -11,11 +13,21 @@ class FlowyText extends StatelessWidget { final int? maxLines; final Color? color; final TextDecoration? decoration; - final bool selectable; + final Color? decorationColor; + final double? decorationThickness; final String? fontFamily; final List? fallbackFontFamily; - final double? lineHeight; 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, { @@ -27,11 +39,17 @@ class FlowyText extends StatelessWidget { this.color, this.maxLines = 1, this.decoration, - this.selectable = false, + this.decorationColor, 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( @@ -42,11 +60,16 @@ class FlowyText extends StatelessWidget { this.textAlign, this.maxLines = 1, this.decoration, - this.selectable = false, + this.decorationColor, 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; @@ -59,11 +82,16 @@ class FlowyText extends StatelessWidget { this.textAlign, this.maxLines = 1, this.decoration, - this.selectable = false, + this.decorationColor, 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( @@ -75,11 +103,16 @@ class FlowyText extends StatelessWidget { this.textAlign, this.maxLines = 1, this.decoration, - this.selectable = false, + this.decorationColor, 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( @@ -91,11 +124,16 @@ class FlowyText extends StatelessWidget { this.textAlign, this.maxLines = 1, this.decoration, - this.selectable = false, + this.decorationColor, 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 @@ -105,55 +143,85 @@ class FlowyText extends StatelessWidget { this.fontSize, this.overflow, this.color, - this.textAlign, + this.textAlign = TextAlign.center, this.maxLines = 1, this.decoration, - this.selectable = false, + this.decorationColor, 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, - fontFamily = 'noto color emoji', fallbackFontFamily = null; @override Widget build(BuildContext context) { Widget child; - if (selectable) { - child = SelectableText( - text, - maxLines: maxLines, - textAlign: textAlign, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: fontSize, - fontWeight: fontWeight, - color: color, - decoration: decoration, - fontFamily: fontFamily, - fontFamilyFallback: fallbackFontFamily, - height: lineHeight, - ), - ); - } else { - child = Text( - text, - maxLines: maxLines, - textAlign: textAlign, - overflow: overflow ?? TextOverflow.clip, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: fontSize, - fontWeight: fontWeight, - color: color, - decoration: decoration, - fontFamily: fontFamily, - fontFamilyFallback: fallbackFontFamily, - height: lineHeight, - ), - ); + 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, + ); + + 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 = Tooltip( + child = FlowyTooltip( message: text, child: child, ); @@ -161,4 +229,14 @@ class FlowyText extends StatelessWidget { 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 index d26f353c72..c4f72f2261 100644 --- 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 @@ -1,10 +1,9 @@ import 'dart:async'; +import 'package:flowy_infra/size.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flowy_infra/size.dart'; - class FlowyTextField extends StatefulWidget { final String? hintText; final String? text; @@ -37,6 +36,11 @@ class FlowyTextField extends StatefulWidget { 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, @@ -71,6 +75,11 @@ class FlowyTextField extends StatefulWidget { this.inputFormatters, this.obscureText = false, this.isDense = true, + this.readOnly = false, + this.enableBorderColor, + this.borderRadius, + this.onTap, + this.onTapOutside, }); @override @@ -137,7 +146,6 @@ class FlowyTextFieldState extends State { void _onSubmitted(String text) { widget.onSubmitted?.call(text); if (widget.autoClearWhenDone) { - // using `controller.clear()` instead of `controller.text = ''` which will crash on Windows. controller.clear(); } } @@ -145,6 +153,7 @@ class FlowyTextFieldState extends State { @override Widget build(BuildContext context) { return TextField( + readOnly: widget.readOnly, controller: controller, focusNode: focusNode, onChanged: (text) { @@ -154,8 +163,10 @@ class FlowyTextFieldState extends State { _onChanged(text); } }, - onSubmitted: (text) => _onSubmitted(text), + onSubmitted: _onSubmitted, onEditingComplete: widget.onEditingComplete, + onTap: widget.onTap, + onTapOutside: widget.onTapOutside, minLines: 1, maxLines: widget.maxLines, maxLength: widget.maxLength, @@ -177,9 +188,10 @@ class FlowyTextFieldState extends State { (widget.maxLines == null || widget.maxLines! > 1) ? 12 : 0, ), enabledBorder: OutlineInputBorder( - borderRadius: Corners.s8Border, + borderRadius: widget.borderRadius ?? Corners.s8Border, borderSide: BorderSide( - color: Theme.of(context).colorScheme.outline, + color: widget.enableBorderColor ?? + Theme.of(context).colorScheme.outline, ), ), isDense: false, @@ -198,22 +210,25 @@ class FlowyTextFieldState extends State { suffixText: widget.showCounter ? _suffixText() : "", counterText: "", focusedBorder: OutlineInputBorder( - borderRadius: Corners.s8Border, + borderRadius: widget.borderRadius ?? Corners.s8Border, borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, + 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: Corners.s8Border, + borderRadius: widget.borderRadius ?? Corners.s8Border, ), focusedErrorBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.error, ), - borderRadius: Corners.s8Border, + borderRadius: widget.borderRadius ?? Corners.s8Border, ), prefixIcon: widget.prefixIcon, suffixIcon: widget.suffixIcon, 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 index 8e3bcc9bf5..95fd82363e 100644 --- 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 @@ -6,8 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class FlowyFormTextInput extends StatelessWidget { - static EdgeInsets kDefaultTextInputPadding = - EdgeInsets.only(bottom: Insets.sm, top: 4); + static EdgeInsets kDefaultTextInputPadding = const EdgeInsets.only(bottom: 2); final String? label; final bool? autoFocus; @@ -68,7 +67,7 @@ class FlowyFormTextInput extends StatelessWidget { hintStyle: Theme.of(context) .textTheme .bodyMedium! - .copyWith(color: Theme.of(context).hintColor.withOpacity(0.7)), + .copyWith(color: Theme.of(context).hintColor.withValues(alpha: 0.7)), isDense: true, inputBorder: const ThinUnderlineBorder( borderSide: BorderSide(width: 5, color: Colors.red), @@ -162,10 +161,12 @@ class StyledSearchTextInputState extends State { @override void initState() { + super.initState(); _controller = widget.controller ?? TextEditingController(text: widget.initialValue); _focusNode = FocusNode( - debugLabel: widget.label ?? '', + debugLabel: widget.label, + canRequestFocus: true, onKeyEvent: (node, event) { if (event.logicalKey == LogicalKeyboardKey.escape) { widget.onEditingCancel?.call(); @@ -173,23 +174,23 @@ class StyledSearchTextInputState extends State { } return KeyEventResult.ignored; }, - canRequestFocus: true, ); // Listen for focus out events - _focusNode - .addListener(() => widget.onFocusChanged?.call(_focusNode.hasFocus)); + _focusNode.addListener(_onFocusChanged); widget.onFocusCreated?.call(_focusNode); if (widget.autoFocus ?? false) { scheduleMicrotask(() => _focusNode.requestFocus()); } - super.initState(); } + void _onFocusChanged() => widget.onFocusChanged?.call(_focusNode.hasFocus); + @override void dispose() { if (widget.controller == null) { _controller.dispose(); } + _focusNode.removeListener(_onFocusChanged); _focusNode.dispose(); super.dispose(); } @@ -292,8 +293,10 @@ class ThinUnderlineBorder extends InputBorder { bool get isOutline => false; @override - UnderlineInputBorder copyWith( - {BorderSide? borderSide, BorderRadius? borderRadius}) { + UnderlineInputBorder copyWith({ + BorderSide? borderSide, + BorderRadius? borderRadius, + }) { return UnderlineInputBorder( borderSide: borderSide ?? this.borderSide, borderRadius: borderRadius ?? this.borderRadius, @@ -301,14 +304,12 @@ class ThinUnderlineBorder extends InputBorder { } @override - EdgeInsetsGeometry get dimensions { - return EdgeInsets.only(bottom: borderSide.width); - } + EdgeInsetsGeometry get dimensions => + EdgeInsets.only(bottom: borderSide.width); @override - UnderlineInputBorder scale(double t) { - return UnderlineInputBorder(borderSide: borderSide.scale(t)); - } + UnderlineInputBorder scale(double t) => + UnderlineInputBorder(borderSide: borderSide.scale(t)); @override Path getInnerPath(Rect rect, {TextDirection? textDirection}) { diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/toolbar_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/toolbar_button.dart new file mode 100644 index 0000000000..96a22a6f85 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/toolbar_button.dart @@ -0,0 +1,43 @@ +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; + +class FlowyToolbarButton extends StatelessWidget { + final Widget child; + final VoidCallback? onPressed; + final EdgeInsets padding; + final String? tooltip; + + const FlowyToolbarButton({ + super.key, + this.onPressed, + this.tooltip, + this.padding = const EdgeInsets.symmetric(vertical: 8, horizontal: 6), + required this.child, + }); + + @override + Widget build(BuildContext context) { + final tooltipMessage = tooltip ?? ''; + + return FlowyTooltip( + message: tooltipMessage, + padding: EdgeInsets.zero, + child: RawMaterialButton( + clipBehavior: Clip.antiAlias, + constraints: const BoxConstraints(minWidth: 36, minHeight: 32), + hoverElevation: 0, + highlightElevation: 0, + padding: EdgeInsets.zero, + shape: const RoundedRectangleBorder(borderRadius: Corners.s6Border), + hoverColor: Colors.transparent, + focusColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + elevation: 0, + onPressed: onPressed, + child: child, + ), + ); + } +} 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 index ee51d400c9..c81c81f356 100644 --- 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 @@ -53,16 +53,19 @@ class BaseStyledBtnState extends State { void initState() { super.initState(); _focusNode = FocusNode(debugLabel: '', canRequestFocus: true); - _focusNode.addListener(() { - if (_focusNode.hasFocus != _isFocused) { - setState(() => _isFocused = _focusNode.hasFocus); - widget.onFocusChanged?.call(_isFocused); - } - }); + _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(); } @@ -118,7 +121,7 @@ class BaseStyledBtnState extends State { fillColor: Colors.transparent, hoverColor: widget.hoverColor ?? Colors.transparent, highlightColor: widget.highlightColor ?? Colors.transparent, - focusColor: widget.focusColor ?? Colors.grey.withOpacity(0.35), + focusColor: widget.focusColor ?? Colors.grey.withValues(alpha: 0.35), constraints: BoxConstraints( minHeight: widget.minHeight ?? 0, minWidth: widget.minWidth ?? 0), onPressed: widget.onPressed, 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 index 6207419009..e2fbd49db3 100644 --- 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 @@ -44,7 +44,7 @@ class PrimaryButton extends StatelessWidget { return BaseStyledButton( minWidth: mode.size.width, minHeight: mode.size.height, - contentPadding: EdgeInsets.zero, + contentPadding: const EdgeInsets.symmetric(horizontal: 6), bgColor: backgroundColor ?? Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primaryContainer, borderRadius: mode.borderRadius, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart index ffab50de4b..7048ed32ec 100644 --- a/frontend/appflowy_flutter/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,10 +1,11 @@ import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + 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'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; extension IntoDialog on Widget { Future show(BuildContext context) async { @@ -57,15 +58,17 @@ class StyledDialog extends StatelessWidget { ); 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, @@ -93,7 +96,7 @@ class Dialogs { {required Widget child}) async { return await Navigator.of(context).push( StyledDialogRoute( - barrier: DialogBarrier(color: Colors.black.withOpacity(0.4)), + barrier: DialogBarrier(color: Colors.black.withValues(alpha: 0.4)), pageBuilder: (BuildContext buildContext, Animation animation, Animation secondaryAnimation) { return SafeArea(child: child); 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 deleted file mode 100644 index ac44197abe..0000000000 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart +++ /dev/null @@ -1,208 +0,0 @@ -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_svg/flowy_svg.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.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( - // mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const FlowyText.medium( - "AppFlowy Error", - fontSize: _titleFontSize, - ), - const SizedBox( - height: _titleToMessagePadding, - ), - FlowyText.semibold( - message, - maxLines: 10, - ), - const SizedBox( - height: _titleToMessagePadding, - ), - FlowyText.regular( - howToFix, - maxLines: 10, - ), - const SizedBox( - height: _titleToMessagePadding, - ), - const GitHubRedirectButton(), - 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: Theme.of(context).colorScheme.onBackground, - text: const FlowyText( - "Copy", - ), - useIntrinsicWidth: true, - onTap: () => Clipboard.setData( - ClipboardData(text: stackTrace), - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -class GitHubRedirectButton extends StatelessWidget { - const GitHubRedirectButton({super.key}); - - 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&title=%5BBug%5D+', - ); - - @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 index 47a684cf01..5b0b791c6c 100644 --- 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 @@ -8,28 +8,140 @@ class FlowyTooltip extends StatelessWidget { this.message, this.richMessage, this.preferBelow, - this.showDuration, this.margin, + this.verticalOffset, + this.padding, this.child, }); final String? message; final InlineSpan? richMessage; final bool? preferBelow; - final Duration? showDuration; final EdgeInsetsGeometry? margin; final Widget? child; + final double? verticalOffset; + final EdgeInsets? padding; @override Widget build(BuildContext context) { + if (message == null && richMessage == null) { + return child ?? const SizedBox.shrink(); + } + return Tooltip( margin: margin, + verticalOffset: verticalOffset ?? 16.0, + padding: 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, - showDuration: showDuration, preferBelow: preferBelow, child: child, ); } } + +class ManualTooltip extends StatefulWidget { + const ManualTooltip({ + super.key, + this.message, + this.richMessage, + this.preferBelow, + this.margin, + this.verticalOffset, + this.padding, + this.showAutomaticlly = false, + this.child, + }); + + final String? message; + final InlineSpan? richMessage; + final bool? preferBelow; + final EdgeInsetsGeometry? margin; + final Widget? child; + final double? verticalOffset; + final EdgeInsets? padding; + final bool showAutomaticlly; + + @override + State createState() => _ManualTooltipState(); +} + +class _ManualTooltipState extends State { + final key = GlobalKey(); + + @override + void initState() { + if (widget.showAutomaticlly) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) key.currentState?.ensureTooltipVisible(); + }); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Tooltip( + key: key, + margin: widget.margin, + verticalOffset: widget.verticalOffset ?? 16.0, + triggerMode: widget.showAutomaticlly ? TooltipTriggerMode.manual : null, + padding: widget.padding ?? + const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + decoration: BoxDecoration( + color: context.tooltipBackgroundColor(), + borderRadius: BorderRadius.circular(10.0), + ), + waitDuration: _tooltipWaitDuration, + message: widget.message, + textStyle: widget.message != null ? context.tooltipTextStyle() : null, + richMessage: widget.richMessage, + preferBelow: widget.preferBelow, + child: widget.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().withValues(alpha: 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/rounded_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart index 11b71b7d28..0fa181a1dd 100644 --- 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 @@ -13,6 +13,7 @@ class RoundedTextButton extends StatelessWidget { final Color? hoverColor; final Color? textColor; final double? fontSize; + final FontWeight? fontWeight; final EdgeInsets padding; const RoundedTextButton({ @@ -27,6 +28,7 @@ class RoundedTextButton extends StatelessWidget { this.hoverColor, this.textColor, this.fontSize, + this.fontWeight, this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), }); @@ -42,6 +44,7 @@ class RoundedTextButton extends StatelessWidget { child: SizedBox.expand( child: FlowyTextButton( title ?? '', + fontWeight: fontWeight, onPressed: onPressed, fontSize: fontSize, mainAxisAlignment: MainAxisAlignment.center, @@ -83,7 +86,7 @@ class RoundedImageButton extends StatelessWidget { child: TextButton( onPressed: press, style: ButtonStyle( - shape: MaterialStateProperty.all( + 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 index 40978aa461..de5e3061fd 100644 --- 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 @@ -1,9 +1,10 @@ -import 'package:flowy_infra/size.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: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; @@ -60,33 +61,26 @@ class RoundedInputField extends StatefulWidget { class _RoundedInputFieldState extends State { String inputText = ""; - bool obscuteText = false; + bool obscureText = false; @override void initState() { - obscuteText = widget.obscureText; - if (widget.controller != null) { - inputText = widget.controller!.text; - } else { - inputText = widget.initialValue ?? ""; - } - super.initState(); + obscureText = widget.obscureText; + inputText = widget.controller != null + ? widget.controller!.text + : widget.initialValue ?? ""; } - String? _suffixText() { - if (widget.maxLength != null) { - return ' ${widget.controller!.text.length}/${widget.maxLength}'; - } else { - return null; - } - } + String? _suffixText() => widget.maxLength != null + ? ' ${widget.controller!.text.length}/${widget.maxLength}' + : null; @override Widget build(BuildContext context) { - var borderColor = + Color borderColor = widget.normalBorderColor ?? Theme.of(context).colorScheme.outline; - var focusBorderColor = + Color focusBorderColor = widget.focusBorderColor ?? Theme.of(context).colorScheme.primary; if (widget.errorText.isNotEmpty) { @@ -122,7 +116,7 @@ class _RoundedInputFieldState extends State { }, cursorColor: widget.cursorColor ?? Theme.of(context).colorScheme.primary, - obscureText: obscuteText, + obscureText: obscureText, style: widget.style ?? Theme.of(context).textTheme.bodyMedium, decoration: InputDecoration( contentPadding: widget.contentPadding, @@ -134,17 +128,11 @@ class _RoundedInputFieldState extends State { suffixText: _suffixText(), counterText: "", enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: borderColor, - width: 1.0, - ), + borderSide: BorderSide(color: borderColor, width: 1.0), borderRadius: Corners.s10Border, ), focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: focusBorderColor, - width: 1.0, - ), + borderSide: BorderSide(color: focusBorderColor, width: 1.0), borderRadius: Corners.s10Border, ), suffixIcon: obscureIcon(), @@ -186,19 +174,11 @@ class _RoundedInputFieldState extends State { } assert(widget.obscureIcon != null && widget.obscureHideIcon != null); - Widget? icon; - if (obscuteText) { - icon = widget.obscureIcon!; - } else { - icon = widget.obscureHideIcon!; - } + final icon = obscureText ? widget.obscureIcon! : widget.obscureHideIcon!; return RoundedImageButton( size: iconWidth, - press: () { - obscuteText = !obscuteText; - setState(() {}); - }, + press: () => setState(() => obscureText = !obscureText), child: icon, ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml index 3d73a51d7b..b5b5c22bc7 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml @@ -13,19 +13,17 @@ dependencies: sdk: flutter # Thirdparty packages - provider: ^6.0.5 + styled_widget: ^0.4.1 - equatable: ^2.0.5 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 - flowy_infra_ui_web: - path: flowy_infra_ui_web appflowy_popover: path: ../appflowy_popover flowy_infra: @@ -33,7 +31,11 @@ dependencies: flowy_svg: path: ../flowy_svg + analyzer: 6.11.0 + dev_dependencies: + build_runner: ^2.4.9 + provider: ^6.0.5 flutter_test: sdk: flutter flutter_lints: ^3.0.1 diff --git a/frontend/appflowy_flutter/packages/flowy_svg/analysis_options.yaml b/frontend/appflowy_flutter/packages/flowy_svg/analysis_options.yaml index 315807278e..543c78a7d4 100644 --- a/frontend/appflowy_flutter/packages/flowy_svg/analysis_options.yaml +++ b/frontend/appflowy_flutter/packages/flowy_svg/analysis_options.yaml @@ -1,3 +1 @@ -include: package:very_good_analysis/analysis_options.yaml - 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 index cbf114156d..e87bb3fa01 100644 --- a/frontend/appflowy_flutter/packages/flowy_svg/bin/flowy_svg.dart +++ b/frontend/appflowy_flutter/packages/flowy_svg/bin/flowy_svg.dart @@ -242,7 +242,13 @@ String varNameFor(File file, Options options) { return simplified; } -const sizeMap = {r'$16x': 's', r'$24x': 'm', r'$32x': 'lg', r'$40x': 'xl'}; +const sizeMap = { + r'$16x': 's', + r'$20x': 'm', + 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) { 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 index baf08538b8..1f861156eb 100644 --- a/frontend/appflowy_flutter/packages/flowy_svg/lib/src/flowy_svg.dart +++ b/frontend/appflowy_flutter/packages/flowy_svg/lib/src/flowy_svg.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +export 'package:flutter_svg/flutter_svg.dart'; + /// The class for FlowySvgData that the code generator will implement class FlowySvgData { /// The svg data @@ -23,8 +25,30 @@ class FlowySvg extends StatelessWidget { 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; @@ -32,6 +56,9 @@ class FlowySvg extends StatelessWidget { /// 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 @@ -46,27 +73,54 @@ class FlowySvg extends StatelessWidget { /// 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) { - final iconColor = color ?? Theme.of(context).iconTheme.color; + Color? iconColor = color ?? Theme.of(context).iconTheme.color; + if (opacity != null) { + iconColor = iconColor?.withValues(alpha: 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: SvgPicture.asset( - _normalized(), - width: size?.width, - height: size?.height, - colorFilter: iconColor != null && blendMode != null - ? ColorFilter.mode( - iconColor, - blendMode!, - ) - : null, - ), + child: svg, ), ); } diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index d322aac654..1a393e1180 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -5,18 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "67.0.0" + version: "76.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.3" analyzer: - dependency: "direct dev" + dependency: "direct main" description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.11.0" animations: dependency: transitive description: @@ -25,14 +30,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.11" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + any_date: + dependency: "direct main" + description: + name: any_date + sha256: e9ed245ba44ccebf3c2d6daa3592213f409821128593d448b219a1f8e9bd17a1 + url: "https://pub.dev" + source: hosted + version: "1.1.1" app_links: dependency: "direct main" description: name: app_links - sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb" + sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "6.3.3" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" appflowy_backend: dependency: "direct main" description: @@ -44,8 +89,8 @@ packages: dependency: "direct main" description: path: "." - ref: "404262fca4369bc35ff305316e4d59341a732f56" - resolved-ref: "404262fca4369bc35ff305316e4d59341a732f56" + ref: e8317c0d1af8d23dc5707b02ea43864536b6de91 + resolved-ref: e8317c0d1af8d23dc5707b02ea43864536b6de91 url: "https://github.com/AppFlowy-IO/appflowy-board.git" source: git version: "0.1.2" @@ -53,19 +98,20 @@ packages: dependency: "direct main" description: path: "." - ref: b827d08 - resolved-ref: b827d089b6e97762806075953a433cfcbe697a73 + ref: "680222f" + resolved-ref: "680222f503f90d07c08c99c42764f9b08fd0f46c" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git - version: "2.4.0" + version: "5.1.0" appflowy_editor_plugins: dependency: "direct main" description: - name: appflowy_editor_plugins - sha256: "9d91f65e564f85ffc98a407524371beeb1fd40aabd621b00ba8a722058636094" - url: "https://pub.dev" - source: hosted - version: "0.0.2" + path: "packages/appflowy_editor_plugins" + ref: "4efcff7" + resolved-ref: "4efcff720ed01dd4d0f5f88a9f1ff6f79f423caa" + url: "https://github.com/AppFlowy-IO/AppFlowy-plugins.git" + source: git + version: "0.0.6" appflowy_popover: dependency: "direct main" description: @@ -80,22 +126,29 @@ packages: relative: true source: path version: "0.0.1" + appflowy_ui: + dependency: "direct main" + description: + path: "packages/appflowy_ui" + relative: true + source: path + version: "1.0.0" archive: dependency: "direct main" description: name: archive - sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d url: "https://pub.dev" source: hosted - version: "3.4.10" + version: "3.6.1" args: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.6.0" async: dependency: transitive description: @@ -104,30 +157,129 @@ packages: 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" + auto_updater: + dependency: "direct main" + description: + path: "packages/auto_updater" + ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + resolved-ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + url: "https://github.com/LucasXu0/auto_updater.git" + source: git + version: "1.0.0" + auto_updater_macos: + dependency: "direct overridden" + description: + path: "packages/auto_updater_macos" + ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + resolved-ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + url: "https://github.com/LucasXu0/auto_updater.git" + source: git + version: "1.0.0" + auto_updater_platform_interface: + dependency: "direct overridden" + description: + path: "packages/auto_updater_platform_interface" + ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + resolved-ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" + url: "https://github.com/LucasXu0/auto_updater.git" + source: git + version: "1.0.0" + auto_updater_windows: + dependency: transitive + description: + name: auto_updater_windows + sha256: "2bba20a71eee072f49b7267fedd5c4f1406c4b1b1e5b83932c634dbab75b80c9" + url: "https://pub.dev" + source: hosted + version: "1.0.0" avatar_stack: dependency: "direct main" description: name: avatar_stack - sha256: e4a1576f7478add964bbb8aa5e530db39288fbbf81c30c4fb4b81162dd68aa49 + sha256: "354527ba139956fd6439e2c49199d8298d72afdaa6c4cd6f37f26b97faf21f7e" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "3.0.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + 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: f53a110e3b48dcd78136c10daa5d51512443cea5e1348c9d80a320095fa2db9e + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" url: "https://pub.dev" source: hosted - version: "8.1.3" + version: "9.0.0" bloc_test: dependency: "direct dev" description: name: bloc_test - sha256: "55a48f69e0d480717067c5377c8485a3fcd41f1701a820deef72fa0f4ee7215f" + sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b" url: "https://pub.dev" source: hosted - version: "9.1.6" + version: "10.0.0" boolean_selector: dependency: transitive description: @@ -140,50 +292,50 @@ packages: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" + sha256: "294a2edaf4814a378725bfe6358210196f5ea37af89ecd81bfa32960113d4948" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.0.3" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + sha256: "99d3980049739a985cf9b21f30881f46db3ebc62c5b8d5e60e27440876b1ba1e" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" + sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" url: "https://pub.dev" source: hosted - version: "2.4.9" + version: "2.4.14" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "8.0.0" built_collection: dependency: transitive description: @@ -196,34 +348,34 @@ packages: dependency: transitive description: name: built_value - sha256: a3ec2e0f967bc47f69f95009bb93db936288d61d5343b9436e378b28a2f830c6 + sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2" url: "https://pub.dev" source: hosted - version: "8.9.0" + version: "8.9.3" cached_network_image: dependency: "direct main" description: name: cached_network_image - sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.1" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.1" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: "42a835caa27c220d1294311ac409a43361088625a4f23c820b006dd9bffb3316" + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.3.1" calendar_view: dependency: "direct main" description: @@ -242,13 +394,13 @@ packages: source: hosted version: "1.3.0" charcode: - dependency: "direct main" + dependency: transitive description: name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -257,14 +409,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" - clipboard: - dependency: "direct main" - description: - name: clipboard - sha256: "2ec38f0e59878008ceca0ab122e4bfde98847f88ef0f83331362ba4521f565a9" - url: "https://pub.dev" - source: hosted - version: "0.1.3" clock: dependency: transitive description: @@ -277,18 +421,18 @@ packages: dependency: transitive description: name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.10.1" collection: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" connectivity_plus: dependency: "direct main" description: @@ -309,50 +453,58 @@ packages: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" coverage: dependency: transitive description: name: coverage - sha256: "8acabb8306b57a409bf4c83522065672ee13179297a6bb0cb9ead73948df7c76" + sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 url: "https://pub.dev" source: hosted - version: "1.7.2" - cross_file: + version: "1.11.1" + cross_cache: dependency: transitive description: - name: cross_file - sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" + name: cross_cache + sha256: "80329477264c73f09945ee780ccdc84df9231f878dc7227d132d301e34ff310b" url: "https://pub.dev" source: hosted - version: "0.3.4+1" + version: "0.0.4" + 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: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.6" csslib: dependency: transitive description: name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.2" dart_style: dependency: transitive description: name: dart_style - sha256: "40ae61a5d43feea6d24bd22c0537a6629db858963b99b4bc1c3db80676f32368" + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.7" dbus: dependency: transitive description: @@ -361,22 +513,38 @@ packages: 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: "03abf1c0443afdd1d65cf8fa589a2f01c67a11da56bbb06f6ea1de79d5628e94" + url: "https://pub.dev" + source: hosted + version: "0.5.0" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91 + sha256: "72d146c6d7098689ff5c5f66bcf593ac11efc530095385356e131070333e64da" url: "https://pub.dev" source: hosted - version: "10.1.0" + version: "11.3.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.2" diff_match_patch: dependency: transitive description: @@ -385,6 +553,30 @@ packages: 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: @@ -405,10 +597,10 @@ packages: dependency: "direct main" description: name: easy_localization - sha256: "9c86754b22aaa3e74e471635b25b33729f958dd6fb83df0ad6612948a7b231af" + sha256: fa59bcdbbb911a764aa6acf96bbb6fa7a5cf8234354fc45ec1a43a0349ef0201 url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "3.0.7" easy_logger: dependency: transitive description: @@ -421,26 +613,34 @@ packages: dependency: "direct main" description: name: envied - sha256: dab29e21452c3d57ec10889d96b06b4a006b01375d4df10b33c9704800c208c4 + sha256: "08a9012e5d93e1a816919a52e37c7b8367e73ebb8d52d1ca7dd6fcd875a2cd2c" url: "https://pub.dev" source: hosted - version: "0.5.3" + version: "1.0.1" envied_generator: dependency: "direct dev" description: name: envied_generator - sha256: b8655d5cb39b4d1d449a79ff6f1367b252c23955ff17ec7c03aacdff938598bd + sha256: "9a49ca9f3744069661c4f2c06993647699fae2773bca10b593fbb3228d081027" url: "https://pub.dev" source: hosted - version: "0.5.3" + version: "1.0.1" equatable: dependency: "direct main" description: name: equatable - sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.0.7" + event_bus: + dependency: "direct main" + description: + name: event_bus + sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" + url: "https://pub.dev" + source: hosted + version: "2.0.1" expandable: dependency: "direct main" description: @@ -449,6 +649,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.1" + extended_text_field: + dependency: "direct main" + description: + name: extended_text_field + sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d" + url: "https://pub.dev" + source: hosted + version: "16.0.2" + extended_text_library: + dependency: "direct main" + description: + name: extended_text_library + sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba" + url: "https://pub.dev" + source: hosted + version: "12.0.1" fake_async: dependency: transitive description: @@ -461,10 +677,10 @@ packages: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" file: dependency: "direct main" description: @@ -474,29 +690,29 @@ packages: source: hosted version: "7.0.0" file_picker: - dependency: transitive + dependency: "direct overridden" description: name: file_picker - sha256: "29c90806ac5f5fb896547720b73b17ee9aed9bba540dc5d91fe29f8c5745b10a" + sha256: "16dc141db5a2ccc6520ebb6a2eb5945b1b09e95085c021d9f914f8ded7f1465c" url: "https://pub.dev" source: hosted - version: "8.0.3" + version: "8.1.4" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" url: "https://pub.dev" source: hosted - version: "0.9.2+1" + version: "0.9.3+2" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: b15c3da8bd4908b9918111fa486903f5808e388b8d1c559949f584725a6594d6 + sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" url: "https://pub.dev" source: hosted - version: "0.9.3+3" + version: "0.9.4+2" file_selector_platform_interface: dependency: transitive description: @@ -509,18 +725,34 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + sha256: "8f5d2f6590d51ecd9179ba39c64f722edc15226cc93dcc8698466ad36a4a85a4" url: "https://pub.dev" source: hosted - version: "0.9.3+1" + version: "0.9.3+3" fixnum: dependency: "direct main" description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" + flex_color_picker: + dependency: "direct main" + description: + name: flex_color_picker + sha256: c083b79f1c57eaeed9f464368be376951230b3cb1876323b784626152a86e480 + url: "https://pub.dev" + source: hosted + version: "3.7.0" + flex_seed_scheme: + dependency: transitive + description: + name: flex_seed_scheme + sha256: d3ba3c5c92d2d79d45e94b4c6c71d01fac3c15017da1545880c53864da5dfeb0 + url: "https://pub.dev" + source: hosted + version: "3.5.0" flowy_infra: dependency: "direct main" description: @@ -542,13 +774,6 @@ packages: 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_svg: dependency: "direct main" description: @@ -565,18 +790,18 @@ packages: dependency: "direct main" description: name: flutter_animate - sha256: "7c8a6594a9252dad30cc2ef16e33270b6248c4dedc3b3d06c86c4f3f4dc05ae5" + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "4.5.2" flutter_bloc: dependency: "direct main" description: name: flutter_bloc - sha256: "87325da1ac757fcc4813e6b34ed5dd61169973871fdf181d6c2109dd6935ece1" + sha256: "1046d719fbdf230330d3443187cc33cc11963d15c9089f6cc56faa42a4c5f0cc" url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "9.1.0" flutter_cache_manager: dependency: "direct main" description: @@ -586,6 +811,14 @@ packages: 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: @@ -594,14 +827,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.6.2" - flutter_colorpicker: + flutter_chat_ui: dependency: "direct main" description: - name: flutter_colorpicker - sha256: "458a6ed8ea480eb16ff892aedb4b7092b2804affd7e046591fb03127e8d8ef8b" + name: flutter_chat_ui + sha256: "2afd22eaebaf0f6ec8425048921479c3dd1a229604015dca05b174c6e8e44292" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "2.0.0-dev.1" flutter_driver: dependency: transitive description: flutter @@ -611,11 +844,19 @@ packages: dependency: "direct main" description: path: "." - ref: "4a5cac" - resolved-ref: "4a5cac57e31c0ffd49cd6257a9e078f084ae342c" + ref: "355aa56" + resolved-ref: "355aa56e9c74a91e00370a882739e0bb98c30bd8" 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: @@ -636,12 +877,12 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "5.0.0" flutter_localizations: - dependency: "direct main" + dependency: transitive description: flutter source: sdk version: "0.0.0" @@ -649,55 +890,71 @@ packages: dependency: "direct main" description: name: flutter_math_fork - sha256: "94bee4642892a94939af0748c6a7de0ff8318feee588379dcdfea7dc5cba06c8" + sha256: "284bab89b2fbf1bc3a0baf13d011c1dd324d004e35d177626b77f2fc056366ac" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.3" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da + sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" url: "https://pub.dev" source: hosted - version: "2.0.17" + version: "2.0.24" flutter_shaders: dependency: transitive description: name: flutter_shaders - sha256: "02750b545c01ff4d8e9bbe8f27a7731aa3778402506c67daa1de7f5fc3f4befe" + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.3" flutter_slidable: dependency: "direct main" description: name: flutter_slidable - sha256: "19ed4813003a6ff4e9c6bcce37e792a2a358919d7603b2b31ff200229191e44c" + sha256: a857de7ea701f276fd6a6c4c67ae885b60729a3449e42766bb0e655171042801 url: "https://pub.dev" source: hosted - version: "3.0.1" - flutter_sticky_header: - dependency: transitive - description: - name: flutter_sticky_header - sha256: "017f398fbb45a589e01491861ca20eb6570a763fd9f3888165a978e11248c709" - url: "https://pub.dev" - source: hosted - version: "0.6.5" - flutter_svg: + version: "3.1.2" + flutter_staggered_grid_view: dependency: "direct main" description: - name: flutter_svg - sha256: d39e7f95621fc84376bc0f7d504f05c3a41488c562f4a8ad410569127507402c + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" url: "https://pub.dev" source: hosted - version: "2.0.9" + version: "0.7.0" + flutter_sticky_header: + dependency: "direct overridden" + description: + name: flutter_sticky_header + sha256: "7f76d24d119424ca0c95c146b8627a457e8de8169b0d584f766c2c545db8f8be" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b + url: "https://pub.dev" + source: hosted + version: "2.0.17" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_tex: + dependency: "direct main" + description: + name: flutter_tex + sha256: ef7896946052e150514a2afe10f6e33e4fe0e7e4fc51195b65da811cb33c59ab + url: "https://pub.dev" + source: hosted + version: "4.0.13" flutter_web_plugins: dependency: transitive description: flutter @@ -707,55 +964,47 @@ packages: dependency: "direct main" description: name: fluttertoast - sha256: dfdde255317af381bfc1c486ed968d5a43a2ded9c931e87cbecd88767d6a71c1 + sha256: "24467dc20bbe49fd63e57d8e190798c4d22cbbdac30e54209d153a15273721d1" url: "https://pub.dev" source: hosted - version: "8.2.4" + version: "8.2.10" freezed: dependency: "direct dev" description: name: freezed - sha256: "57247f692f35f068cae297549a46a9a097100685c6780fe67177503eea5ed4e5" + sha256: "44c19278dd9d89292cf46e97dc0c1e52ce03275f40a97c5a348e802a924bf40e" url: "https://pub.dev" source: hosted - version: "2.4.7" + version: "2.5.7" freezed_annotation: dependency: "direct main" description: name: freezed_annotation - sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.4" frontend_server_client: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter source: sdk version: "0.0.0" - functions_client: - dependency: transitive - description: - name: functions_client - sha256: "9a0ab83a525c8691a6724746e642de755a299afa04158807787364cd9e718001" - url: "https://pub.dev" - source: hosted - version: "2.0.0" get_it: dependency: "direct main" description: name: get_it - sha256: e6017ce7fdeaf218dc51a100344d8cb70134b80e28b760f8bb23c242437bafd7 + sha256: f126a3e286b7f5b578bf436d5592968706c4c1de28a228b870ce375d9f743103 url: "https://pub.dev" source: hosted - version: "7.6.7" + version: "8.0.3" glob: dependency: transitive description: @@ -768,34 +1017,26 @@ packages: dependency: "direct main" description: name: go_router - sha256: "170c46e237d6eb0e6e9f0e8b3f56101e14fb64f787016e42edd74c39cf8b176a" + sha256: "7c2d40b59890a929824f30d442e810116caf5088482629c894b9e4478c67472d" url: "https://pub.dev" source: hosted - version: "13.2.0" + version: "14.6.3" google_fonts: dependency: "direct main" description: name: google_fonts - sha256: f0b8d115a13ecf827013ec9fc883390ccc0e87a96ed5347a3114cac177ef18e8 + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 url: "https://pub.dev" source: hosted - version: "6.1.0" - gotrue: - dependency: transitive - description: - name: gotrue - sha256: f40610bacf1074723354b0856a4f586508ffb075b799f72466f34e843133deb9 - url: "https://pub.dev" - source: hosted - version: "2.5.0" + version: "6.2.1" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" gtk: dependency: transitive description: @@ -813,7 +1054,7 @@ packages: source: hosted version: "0.7.0" hive: - dependency: "direct main" + dependency: transitive description: name: hive sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" @@ -840,74 +1081,90 @@ packages: dependency: transitive description: name: html - sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" url: "https://pub.dev" source: hosted - version: "0.15.4" + version: "0.15.5" + html2md: + dependency: "direct main" + description: + name: html2md + sha256: "465cf8ffa1b510fe0e97941579bf5b22e2d575f2cecb500a9c0254efe33a8036" + url: "https://pub.dev" + source: hosted + version: "1.3.2" http: dependency: "direct main" description: name: http - sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" - image_gallery_saver: - dependency: "direct main" + version: "4.1.2" + iconsax_flutter: + dependency: transitive description: - name: image_gallery_saver - sha256: "0aba74216a4d9b0561510cb968015d56b701ba1bd94aace26aacdd8ae5761816" + name: iconsax_flutter + sha256: "95b65699da8ea98f87c5d232f06b0debaaf1ec1332b697e4d90969ec9a93037d" url: "https://pub.dev" source: hosted - version: "2.0.3" + 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: "26222b01a0c9a2c8fe02fc90b8208bd3325da5ed1f4a2acabf75939031ac0bdd" + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" url: "https://pub.dev" source: hosted - version: "1.0.7" + version: "1.1.2" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "39f2bfe497e495450c81abcd44b62f56c2a36a37a175da7d137b4454977b51b1" + sha256: b62d34a506e12bb965e824b6db4fbf709ee4589cf5d3e99b45ab2287b008ee0c url: "https://pub.dev" source: hosted - version: "0.8.9+3" + version: "0.8.12+20" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: e2423c53a68b579a7c37a1eda967b8ae536c3d98518e5db95ca1fe5719a730a3 + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.6" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: fadafce49e8569257a0cad56d24438a6fa1f0cbd7ee0af9b631f7492818a4ca3 + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" url: "https://pub.dev" source: hosted - version: "0.8.9+1" + version: "0.8.12+2" image_picker_linux: dependency: transitive description: @@ -928,10 +1185,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: fa4e815e6fcada50e35718727d83ba1c92f1edf95c0b4436554cec301b56233b + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" url: "https://pub.dev" source: hosted - version: "2.9.3" + version: "2.10.1" image_picker_windows: dependency: transitive description: @@ -949,42 +1206,34 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" - intl_utils: - dependency: transitive - description: - name: intl_utils - sha256: c2b1f5c72c25512cbeef5ab015c008fc50fe7e04813ba5541c25272300484bf4 - url: "https://pub.dev" - source: hosted - version: "2.8.7" + version: "0.19.0" io: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" irondash_engine_context: dependency: transitive description: name: irondash_engine_context - sha256: "4f5e2629296430cce08cdff42e47cef07b8f74a64fdbdfb0525d147bc1a969a2" + sha256: cd7b769db11a2b5243b037c8a9b1ecaef02e1ae27a2d909ffa78c1dad747bb10 url: "https://pub.dev" source: hosted - version: "0.5.2" + version: "0.5.4" irondash_message_channel: dependency: transitive description: name: irondash_message_channel - sha256: dd581214215dca054bd9873209d690ec3609288c28774cb509dbd86b21180cf8 + sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.7.0" isolates: dependency: transitive description: @@ -1005,58 +1254,50 @@ packages: dependency: "direct main" description: name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "4.8.1" + version: "4.9.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: aa1f5a8912615733e0fdc7a02af03308933c93235bdc8d50d0b0c8a8ccb0b969 + sha256: c2fcb3920cf2b6ae6845954186420fca40bc0a8abcc84903b7801f17d7050d7c url: "https://pub.dev" source: hosted - version: "6.7.1" - jwt_decode: - dependency: transitive - description: - name: jwt_decode - sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb - url: "https://pub.dev" - source: hosted - version: "0.3.1" + version: "6.9.0" keyboard_height_plugin: dependency: "direct main" description: name: keyboard_height_plugin - sha256: bbb32804bf93601249c17c33125cd2e654f5ef650fc6acf1b031d69b478b35ce + sha256: "3a51c8ebb43465ebe0b3bad17f3b6d945421e58011f3f5a08134afe69a3d775f" url: "https://pub.dev" source: hosted - version: "0.0.5" + version: "0.1.5" leak_tracker: dependency: "direct main" description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" linked_scroll_controller: dependency: "direct main" description: @@ -1085,10 +1326,10 @@ packages: dependency: transitive description: name: lints - sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "5.1.1" loading_indicator: dependency: transitive description: @@ -1101,34 +1342,42 @@ packages: dependency: "direct main" description: name: local_notifier - sha256: cc855aa6362c8840e3d3b35b1c3b058a3a8becdb2b03d5a9aa3f3a1e861f0a03 + sha256: f6cfc933c6fbc961f4e52b5c880f68e41b2d3cd29aad557cc654fd211093a025 url: "https://pub.dev" source: hosted - version: "0.1.5" - logger: - dependency: transitive - description: - name: logger - sha256: "6bbb9d6f7056729537a4309bda2e74e18e5d9f14302489cc1e93f33b3fe32cac" - url: "https://pub.dev" - source: hosted - version: "2.0.2+1" + version: "0.1.6" logging: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" - markdown: + version: "1.3.0" + macros: dependency: transitive description: - name: markdown - sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + name: macros + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "7.2.2" + version: "0.1.3-main.0" + markdown: + dependency: "direct main" + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + 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: @@ -1141,42 +1390,42 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.15.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "2.0.0" mockito: dependency: transitive description: name: mockito - sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + sha256: f99d8d072e249f719a5531735d146d8cf04c580d93920b04de75bef6dfb2daf6 url: "https://pub.dev" source: hosted - version: "5.4.4" + version: "5.4.5" mocktail: - dependency: "direct main" + dependency: "direct dev" description: name: mocktail - sha256: c4b5007d91ca4f67256e720cb1b6d704e79a510183a12fa551021f652577dce6 + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" nanoid: dependency: "direct main" description: @@ -1213,42 +1462,50 @@ packages: dependency: "direct main" description: name: numerus - sha256: "49cd96fe774dd1f574fc9117ed67e8a2b06a612f723e87ef3119456a7729d837" + sha256: a17a3f34527497e89378696a76f382b40dc534c4a57b3778de246ebc1ce2ca99 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" octo_image: dependency: transitive description: name: octo_image - sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: dcb7bd3d32db8db5260253a62f1564c02c2c8df64bc0187cd213f65f827519bd + url: "https://pub.dev" + source: hosted + version: "4.6.0" package_config: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" package_info_plus: dependency: "direct main" description: name: package_info_plus - sha256: cb44f49b6e690fa766f023d5b22cac6b9affe741dd792b6ac7ad4fabe0d7b097 + sha256: "739e0a5c3c4055152520fa321d0645ee98e932718b4c8efeeb51451968fe0790" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "8.1.3" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" + sha256: a5ef9986efc7bf772f2696183a3992615baa76c1ffb1189318dd8803778fb05b url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.2" path: dependency: "direct main" description: @@ -1269,34 +1526,34 @@ packages: dependency: transitive description: name: path_parsing - sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" path_provider: dependency: "direct main" description: name: path_provider - sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.15" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -1317,10 +1574,26 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + 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: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" + url: "https://pub.dev" + source: hosted + version: "3.11.3" percent_indicator: dependency: "direct main" description: @@ -1341,34 +1614,34 @@ packages: dependency: transitive description: name: permission_handler_android - sha256: "8bb852cd759488893805c3161d0b2b5db55db52f773dbb014420b304055ba2c5" + sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1" url: "https://pub.dev" source: hosted - version: "12.0.6" + version: "12.0.13" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: e9ad66020b89ff1b63908f247c2c6f931c6e62699b756ef8b3c4569350cd8662 + sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 url: "https://pub.dev" source: hosted - version: "9.4.4" + version: "9.4.5" permission_handler_html: dependency: transitive description: name: permission_handler_html - sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.3+5" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" + sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "4.2.3" permission_handler_windows: dependency: transitive description: @@ -1397,10 +1670,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: "direct dev" description: @@ -1409,14 +1682,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" - url: "https://pub.dev" - source: hosted - version: "3.7.4" pool: dependency: transitive description: @@ -1425,14 +1690,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" - postgrest: - dependency: transitive - description: - name: postgrest - sha256: "748ebffffb60b4eaa270955dcf3742a19a2b315344c41ff1b4a0ebcd322b5181" - url: "https://pub.dev" - source: hosted - version: "2.1.0" process: dependency: transitive description: @@ -1461,26 +1718,26 @@ packages: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.2.3" - realtime_client: + version: "1.5.0" + qr: dependency: transitive description: - name: realtime_client - sha256: "5831636c19802ba936093a35a7c5b745b130e268fa052e84b4b5290139d2ae03" + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.2" recase: dependency: transitive description: @@ -1489,6 +1746,15 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + reorderable_tabbar: + dependency: "direct main" + description: + path: "." + ref: "93c4977" + resolved-ref: "93c4977ffab68906694cdeaea262be6045543c94" + url: "https://github.com/LucasXu0/reorderable_tabbar" + source: git + version: "1.0.6" reorderables: dependency: "direct main" description: @@ -1497,14 +1763,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" - retry: - dependency: transitive - description: - name: retry - sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" - url: "https://pub.dev" - source: hosted - version: "3.1.2" run_with_network_images: dependency: "direct dev" description: @@ -1521,14 +1779,70 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + saver_gallery: + dependency: "direct main" + description: + name: saver_gallery + sha256: bf59475e50b73d666630bed7a5fdb621fed92d637f64e3c61ce81653ec6a833c + url: "https://pub.dev" + source: hosted + version: "4.0.1" + 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" + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" url: "https://pub.dev" source: hosted - version: "0.1.9" + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + 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: @@ -1537,78 +1851,94 @@ packages: 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: "3ef39599b00059db0990ca2e30fca0a29d8b37aae924d60063f8e0184cf20900" + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da url: "https://pub.dev" source: hosted - version: "7.2.2" + version: "10.1.4" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: df08bc3a07d01f5ea47b45d03ffcba1fa9cd5370fb44b3f38c70e42cced0f956 + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "5.0.2" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + sha256: a752ce92ea7540fc35a0d19722816e04d0e72828a4200e83a98cf1a1eb524c9a url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + sha256: bf808be89fe9dc467475e982c1db6c2faf3d2acf54d526cd5ec37d86c99dbd84 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.4.1" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.5.4" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.2" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" sheet: dependency: "direct main" description: @@ -1622,10 +1952,10 @@ packages: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_packages_handler: dependency: transitive description: @@ -1638,18 +1968,18 @@ packages: dependency: transitive description: name: shelf_static - sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.0.1" simple_gesture_detector: dependency: transitive description: @@ -1670,7 +2000,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" sliver_tools: dependency: transitive description: @@ -1691,26 +2021,26 @@ packages: dependency: transitive description: name: source_helper - sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" url: "https://pub.dev" source: hosted - version: "1.3.4" + version: "1.3.5" source_map_stack_trace: dependency: transitive description: name: source_map_stack_trace - sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" source_maps: dependency: transitive description: name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" url: "https://pub.dev" source: hosted - version: "0.10.12" + version: "0.10.13" source_span: dependency: transitive description: @@ -1731,34 +2061,50 @@ packages: dependency: transitive description: name: sqflite - sha256: a9016f495c927cb90557c909ff26a6d92d9bd54fc42ba92e19d4e79d61e798c6 + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "28d8c66baee4968519fb8bd6cdbedad982d6e53359091f0b74544a9f32ec72d5" + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4+6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + url: "https://pub.dev" + source: hosted + version: "2.4.1+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" - storage_client: - dependency: transitive - description: - name: storage_client - sha256: bf5589d5de61a2451edb1b8960a0e673d4bb5c42ecc4dddf7c051a93789ced34 - url: "https://pub.dev" - source: hosted - version: "2.0.1" + version: "1.12.0" stream_channel: dependency: transitive description: @@ -1771,26 +2117,26 @@ packages: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" string_validator: dependency: "direct main" description: name: string_validator - sha256: "54d4f42cd6878ae72793a58a529d9a18ebfdfbfebd9793bbe55c9b28935e8543" + sha256: a278d038104aa2df15d0e09c47cb39a49f907260732067d0034dc2f2e4e2ac94 url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.1.0" styled_widget: dependency: "direct main" description: @@ -1799,39 +2145,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" - supabase: - dependency: transitive - description: - name: supabase - sha256: "4bce9c49f264f4cd44b4ffc895647af2dca0c40125c169045be9f708fd2a2a40" - url: "https://pub.dev" - source: hosted - version: "2.0.7" - supabase_flutter: - dependency: "direct main" - description: - path: "packages/supabase_flutter" - ref: "9b05eea" - resolved-ref: "9b05eeac559a1f2da6289e1d70b3fa89e262fa3c" - url: "https://github.com/supabase/supabase-flutter" - source: git - version: "2.3.1" super_clipboard: dependency: "direct main" description: name: super_clipboard - sha256: "15d25eb88df8e904e0c2ef77378c6010cc57bbfc0b6f91f2416d08fad5fcca92" + sha256: "4a6ae6dfaa282ec1f2bff750976f535517ed8ca842d5deae13985eb11c00ac1f" url: "https://pub.dev" source: hosted - version: "0.8.5" + version: "0.8.24" super_native_extensions: dependency: transitive description: name: super_native_extensions - sha256: "530a2118d032483b192713c68ed7105fe64418f22492165f87ed01f9b01d4965" + sha256: a433bba8186cd6b707560c42535bf284804665231c00bca86faf1aa4968b7637 url: "https://pub.dev" source: hosted - version: "0.8.12" + version: "0.8.24" sync_http: dependency: transitive description: @@ -1841,21 +2170,53 @@ packages: source: hosted version: "0.3.1" synchronized: - dependency: transitive + dependency: "direct main" description: name: synchronized - sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" url: "https://pub.dev" source: hosted - version: "3.1.0+1" + version: "3.3.0+3" + 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: "1e3521a3e6d3fc7f645a58b135ab663d458ab12504f1ea7f9b4b81d47086c478" + sha256: b2896b7c86adf3a4d9c911d860120fe3dbe03c85db43b22fd61f14ee78cdbb63 url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.1.3" + talker: + dependency: "direct main" + description: + name: talker + sha256: "45abef5b92f9b9bd42c3f20133ad4b20ab12e1da2aa206fc0a40ea874bed7c5d" + url: "https://pub.dev" + source: hosted + version: "4.7.1" + talker_bloc_logger: + dependency: "direct main" + description: + name: talker_bloc_logger + sha256: "2214a5f6ef9ff33494dc6149321c270356962725cc8fc1a485d44b1d9b812ddd" + url: "https://pub.dev" + source: hosted + version: "4.7.1" + talker_logger: + dependency: transitive + description: + name: talker_logger + sha256: ed9b20b8c09efff9f6b7c63fc6630ee2f84aa92661ae09e5ba04e77272bf2ad2 + url: "https://pub.dev" + source: hosted + version: "4.7.1" term_glyph: dependency: transitive description: @@ -1868,50 +2229,50 @@ packages: dependency: transitive description: name: test - sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f + sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f" url: "https://pub.dev" source: hosted - version: "1.24.9" + version: "1.25.8" test_api: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.3" test_core: dependency: transitive description: name: test_core - sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a + sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d" url: "https://pub.dev" source: hosted - version: "0.5.9" - textstyle_extensions: - dependency: transitive - description: - name: textstyle_extensions - sha256: b0538352844fb4d1d0eea82f7bc6b96e4dae03a3a071247e4dcc85ec627b2c6c - url: "https://pub.dev" - source: hosted - version: "2.0.0-nullsafety" + version: "0.6.5" time: dependency: "direct main" description: name: time - sha256: ad8e018a6c9db36cb917a031853a1aae49467a93e0d464683e029537d848c221 + sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" timing: dependency: transitive description: name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" + toastification: + dependency: "direct main" + description: + name: toastification + sha256: "4d97fbfa463dfe83691044cba9f37cb185a79bb9205cfecb655fa1f6be126a13" + url: "https://pub.dev" + source: hosted + version: "2.3.0" tuple: dependency: transitive description: @@ -1924,10 +2285,10 @@ packages: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" universal_html: dependency: transitive description: @@ -1945,61 +2306,62 @@ packages: source: hosted version: "2.2.2" universal_platform: - dependency: transitive + dependency: "direct main" description: name: universal_platform - sha256: d315be0f6641898b280ffa34e2ddb14f3d12b1a37882557869646e0cc363d0cc + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" url: "https://pub.dev" source: hosted - version: "1.0.0+1" + version: "1.1.0" unsplash_client: dependency: "direct main" description: - name: unsplash_client - sha256: "9827f4c1036b7a6ac8cb3f404ac179df7441eee69371d9b17f181817fe502fd7" - url: "https://pub.dev" - source: hosted + path: "." + ref: a8411fc + resolved-ref: a8411fcead178834d1f4572f64dc78b059ffa221 + url: "https://github.com/LucasXu0/unsplash_client.git" + source: git version: "2.2.0" url_launcher: dependency: "direct main" description: name: url_launcher - sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" url: "https://pub.dev" source: hosted - version: "6.2.6" + version: "6.3.1" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745 + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.14" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "75bb6fe3f60070407704282a2d295630cab232991eb52542b18347a8a941df03" + sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.3.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.2.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.2" url_launcher_platform_interface: dependency: "direct dev" description: @@ -2012,18 +2374,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.4" url_protocol: dependency: "direct main" description: @@ -2037,42 +2399,42 @@ packages: dependency: "direct overridden" description: name: uuid - sha256: cd210a09f7c18cbe5a02511718e0334de6559871052c90a90c0cca46a4aa81c8 + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "4.3.3" + version: "4.5.1" value_layout_builder: dependency: transitive description: name: value_layout_builder - sha256: "98202ec1807e94ac72725b7f0d15027afde513c55c69ff3f41bcfccb950831bc" + sha256: c02511ea91ca5c643b514a33a38fa52536f74aa939ec367d02938b5ede6807fa url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "0.4.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "4ac59808bbfca6da38c99f415ff2d3a5d7ca0a6b4809c71d9cf30fba5daf9752" + sha256: "27d5fefe86fb9aace4a9f8375b56b3c292b64d8c04510df230f849850d912cb7" url: "https://pub.dev" source: hosted - version: "1.1.10+1" + version: "1.1.15" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: f3247e7ab0ec77dc759263e68394990edc608fb2b480b80db8aa86ed09279e33 + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "1.1.10+1" + version: "1.1.13" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "18489bdd8850de3dd7ca8a34e0c446f719ec63e2bab2e7a8cc66a9028dd76c5a" + sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" url: "https://pub.dev" source: hosted - version: "1.1.10+1" + version: "1.1.16" vector_math: dependency: transitive description: @@ -2081,6 +2443,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + version: + dependency: "direct main" + description: + name: version + sha256: "3d4140128e6ea10d83da32fef2fa4003fccbf6852217bb854845802f04191f94" + url: "https://pub.dev" + source: hosted + version: "3.0.2" visibility_detector: dependency: transitive description: @@ -2093,42 +2463,50 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.3.0" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web: dependency: transitive description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.1.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "3.0.1" webdriver: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.4" webkit_inspection_protocol: dependency: transitive description: @@ -2137,40 +2515,80 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: "889a0a678e7c793c308c68739996227c9661590605e70b1f6cf6b9a6634f7aec" + url: "https://pub.dev" + source: hosted + version: "4.10.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: d1ee28f44894cbabb1d94cc42f9980297f689ff844d067ec50ff88d86e27d63f + url: "https://pub.dev" + source: hosted + version: "4.3.0" + 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: f883dfc94d03b1a2a17441c8e8a8e1941558ed3322f2b586cd06486114e18048 + url: "https://pub.dev" + source: hosted + version: "0.4.10" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "4adc14ea9a770cc9e2c8f1ac734536bd40e82615bd0fa6b94be10982de656cc7" + url: "https://pub.dev" + source: hosted + version: "3.17.0" win32: dependency: transitive description: name: win32 - sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.10.0" win32_registry: dependency: transitive description: name: win32_registry - sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.5" window_manager: dependency: "direct main" description: name: window_manager - sha256: b3c895bdf936c77b83c5254bec2e6b3f066710c1f89c38b20b8acc382b525494 + sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059" url: "https://pub.dev" source: hosted - version: "0.3.8" + version: "0.4.3" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 @@ -2181,18 +2599,10 @@ packages: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" - yet_another_json_isolate: - dependency: transitive - description: - name: yet_another_json_isolate - sha256: e727502a2640d65b4b8a8a6cb48af9dd0cbe644ba4b3ee667c7f4afa0c1d6069 - url: "https://pub.dev" - source: hosted - version: "2.0.0" + version: "3.1.3" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.19.0" + dart: ">=3.6.2 <4.0.0" + flutter: ">=3.27.4" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 56223b4d48..1e92765ff6 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -1,177 +1,199 @@ name: appflowy -description: A new Flutter project. +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" -# 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: 0.5.6 +version: 0.8.9 environment: - flutter: ">=3.19.0" + flutter: ">=3.27.4" 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 - flutter_localizations: - sdk: flutter + any_date: ^1.0.4 + app_links: ^6.3.3 appflowy_backend: path: packages/appflowy_backend - flowy_infra_ui: - path: packages/flowy_infra_ui - flowy_infra: - path: packages/flowy_infra - flowy_svg: - path: packages/flowy_svg appflowy_board: - # path: ../../../appflowy-board git: url: https://github.com/AppFlowy-IO/appflowy-board.git - ref: 404262fca4369bc35ff305316e4d59341a732f56 - appflowy_result: - path: packages/appflowy_result - appflowy_editor_plugins: ^0.0.2 - + ref: e8317c0d1af8d23dc5707b02ea43864536b6de91 appflowy_editor: - + appflowy_editor_plugins: appflowy_popover: path: packages/appflowy_popover + appflowy_result: + path: packages/appflowy_result + appflowy_ui: + path: packages/appflowy_ui + archive: ^3.4.10 + auto_size_text_field: ^2.2.3 + auto_updater: ^1.0.0 + avatar_stack: ^3.0.0 - # third party packages - intl: ^0.18.0 - time: ^2.1.3 - equatable: ^2.0.5 - freezed_annotation: ^2.2.0 - get_it: ^7.6.0 - flutter_bloc: ^8.1.3 - flutter_math_fork: ^0.7.2 - provider: ^6.0.5 - path_provider: ^2.0.15 - sized_context: ^1.0.0+4 - styled_widget: ^0.4.1 - expandable: ^5.0.1 - flutter_colorpicker: ^1.0.3 - highlight: ^0.7.0 - package_info_plus: ^6.0.0 - url_launcher: ^6.1.11 - clipboard: ^0.1.3 - connectivity_plus: ^5.0.2 - easy_localization: ^3.0.2 - device_info_plus: ^10.1.0 - fluttertoast: ^8.2.2 - json_annotation: ^4.8.1 - table_calendar: ^3.0.9 - reorderables: ^0.6.0 - linked_scroll_controller: ^0.2.0 - hotkey_manager: ^0.1.7 - fixnum: ^1.1.0 - flutter_svg: ^2.0.7 - protobuf: ^3.1.0 - charcode: ^1.3.1 - collection: ^1.17.1 - bloc: ^8.1.2 - shared_preferences: ^2.2.2 - google_fonts: ^6.1.0 - percent_indicator: ^4.2.3 + # BitsDojo Window for Windows + bitsdojo_window: ^0.1.6 + bloc: ^9.0.0 + cached_network_image: ^3.3.0 calendar_view: git: url: https://github.com/Xazin/flutter_calendar_view ref: "6fe0c98" - window_manager: ^0.3.4 - http: ^1.0.0 - path: ^1.8.3 - mocktail: ^1.0.1 - archive: ^3.4.10 - nanoid: ^1.0.0 - supabase_flutter: ^1.10.4 - envied: ^0.5.2 + 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.5.0 + device_info_plus: + diffutil_dart: ^4.0.1 dotted_border: ^2.0.0+3 - url_protocol: - hive: ^2.2.3 - hive_flutter: ^1.1.0 - super_clipboard: ^0.8.4 - go_router: ^13.1.0 - string_validator: ^1.0.0 - unsplash_client: ^2.1.1 + easy_localization: ^3.0.2 + envied: ^1.0.1 + equatable: ^2.0.5 + expandable: ^5.0.1 + extended_text_field: ^16.0.2 + 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: ^9.1.0 + 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: "4a5cac" + ref: "355aa56" + 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: ^8.0.3 + go_router: ^14.2.0 + google_fonts: ^6.1.0 + highlight: ^0.7.0 + hive_flutter: ^1.1.0 + hotkey_manager: ^0.1.7 + html2md: ^1.3.2 + 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 - app_links: ^3.5.0 - flutter_slidable: ^3.0.0 - image_picker: ^1.0.4 - image_gallery_saver: ^2.0.3 - cached_network_image: ^3.3.0 - leak_tracker: ^10.0.0 - keyboard_height_plugin: ^0.0.5 - scrollable_positioned_list: ^0.3.8 - flutter_cache_manager: ^3.3.1 - share_plus: ^7.2.1 - sheet: - file: ^7.0.0 - avatar_stack: ^1.2.0 + markdown: + markdown_widget: ^2.3.2+6 + mime: ^2.0.0 + nanoid: ^1.0.0 numerus: ^2.1.2 - flutter_animate: ^4.5.0 + + # 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.24 + synchronized: ^3.1.0+1 + table_calendar: ^3.0.9 + time: ^2.1.3 + event_bus: ^2.0.1 + + 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 + version: ^3.0.2 + xml: ^6.5.0 + window_manager: ^0.4.3 + saver_gallery: ^4.0.1 + talker_bloc_logger: ^4.7.1 + talker: ^4.7.1 + + analyzer: 6.11.0 dev_dependencies: - flutter_lints: ^3.0.1 - analyzer: ^6.3.0 + # Introduce talker to log the bloc events, and only log the events in the development mode + + bloc_test: ^10.0.0 + build_runner: ^2.4.9 + envied_generator: ^1.0.1 + flutter_lints: ^5.0.0 flutter_test: sdk: flutter + freezed: ^2.4.7 integration_test: sdk: flutter - build_runner: ^2.4.9 - freezed: ^2.4.7 - bloc_test: ^9.1.2 json_serializable: ^6.7.1 - envied_generator: ^0.5.2 + + mocktail: ^1.0.1 plugin_platform_interface: any - url_launcher_platform_interface: any run_with_network_images: ^0.0.1 + url_launcher_platform_interface: any dependency_overrides: http: ^1.0.0 - - supabase_flutter: - git: - url: https://github.com/supabase/supabase-flutter - ref: 9b05eea - path: packages/supabase_flutter + device_info_plus: ^11.2.2 url_protocol: git: url: https://github.com/LucasXu0/flutter_url_protocol.git - commit: 77a8420 + commit: 737681d appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "b827d08" + ref: "680222f" + + appflowy_editor_plugins: + git: + url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git + path: "packages/appflowy_editor_plugins" + ref: "4efcff7" sheet: git: @@ -179,7 +201,7 @@ dependency_overrides: ref: e44458d path: sheet - uuid: ^4.1.0 + uuid: ^4.4.0 flutter_cache_manager: git: @@ -187,29 +209,53 @@ dependency_overrides: commit: fbab857b1b1d209240a146d32f496379b9f62276 path: flutter_cache_manager - # 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_sticky_header: ^0.7.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec + reorderable_tabbar: + git: + url: https://github.com/LucasXu0/reorderable_tabbar + ref: 93c4977 + # Don't upgrade file_picker until the issue is fixed + # https://github.com/miguelpruivo/flutter_file_picker/issues/1652 + file_picker: 8.1.4 + + auto_updater: + git: + url: https://github.com/LucasXu0/auto_updater.git + path: packages/auto_updater + ref: 1d81a824f3633f1d0200ba51b78fe0f9ce429458 + + auto_updater_macos: + git: + url: https://github.com/LucasXu0/auto_updater.git + path: packages/auto_updater_macos + ref: 1d81a824f3633f1d0200ba51b78fe0f9ce429458 + + auto_updater_platform_interface: + git: + url: https://github.com/LucasXu0/auto_updater.git + path: packages/auto_updater_platform_interface + ref: 1d81a824f3633f1d0200ba51b78fe0f9ce429458 + + unsplash_client: + git: + url: https://github.com/LucasXu0/unsplash_client.git + ref: a8411fc + + # auto_updater: + # path: /Users/lucas.xu/Desktop/auto_updater/packages/auto_updater + + # auto_updater_macos: + # path: /Users/lucas.xu/Desktop/auto_updater/packages/auto_updater_macos + + # auto_updater_platform_interface: + # path: /Users/lucas.xu/Desktop/auto_updater/packages/auto_updater_platform_interface -# 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 @@ -235,19 +281,26 @@ flutter: - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf style: italic + # White-label font configuration will be added here + # BEGIN: WHITE_LABEL_FONT + # END: WHITE_LABEL_FONT # 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/20x/ - assets/flowy_icons/24x/ - assets/flowy_icons/32x/ - assets/flowy_icons/40x/ - assets/images/emoji/ - assets/images/login/ - assets/translations/ + - assets/icons/icons.json + - assets/fonts/ # The following assets will be excluded in release. # BEGIN: EXCLUDE_IN_RELEASE diff --git a/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart new file mode 100644 index 0000000000..46b8118087 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart @@ -0,0 +1,424 @@ +import 'dart:async'; + +import 'package:appflowy/ai/ai.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.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 _MockCompletionStream extends Mock implements CompletionStream {} + +class _MockAIRepository extends Mock implements AppFlowyAIService { + @override + Future<(String, CompletionStream)?> streamCompletion({ + String? objectId, + required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, + required Future Function() onEnd, + required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, + }) async { + final stream = _MockCompletionStream(); + unawaited( + Future(() async { + await onStart(); + final lines = text.split('\n'); + for (final line in lines) { + if (line.isNotEmpty) { + await processMessage('$_aiResponse $line\n\n'); + } + } + await onEnd(); + }), + ); + return ('mock_id', stream); + } +} + +class _MockAIRepositoryLess extends Mock implements AppFlowyAIService { + @override + Future<(String, CompletionStream)?> streamCompletion({ + String? objectId, + required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, + required Future Function() onEnd, + required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, + }) async { + final stream = _MockCompletionStream(); + unawaited( + Future(() async { + await onStart(); + // only return 1 line. + await processMessage('Hello World'); + await onEnd(); + }), + ); + return ('mock_id', stream); + } +} + +class _MockAIRepositoryMore extends Mock implements AppFlowyAIService { + @override + Future<(String, CompletionStream)?> streamCompletion({ + String? objectId, + required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, + required Future Function() onEnd, + required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, + }) async { + final stream = _MockCompletionStream(); + unawaited( + Future(() async { + await onStart(); + // return 10 lines + for (var i = 0; i < 10; i++) { + await processMessage('Hello World\n\n'); + } + await onEnd(); + }), + ); + return ('mock_id', stream); + } +} + +class _MockErrorRepository extends Mock implements AppFlowyAIService { + @override + Future<(String, CompletionStream)?> streamCompletion({ + String? objectId, + required String text, + PredefinedFormat? format, + List sourceIds = const [], + List history = const [], + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) processMessage, + required Future Function(String text) processAssistMessage, + required Future Function() onEnd, + required void Function(AIError error) onError, + required void Function(LocalAIStreamingState state) + onLocalAIStreamingStateChange, + }) async { + final stream = _MockCompletionStream(); + unawaited( + Future(() async { + await onStart(); + onError( + const AIError( + message: 'Error', + code: AIErrorCode.aiResponseLimitExceeded, + ), + ); + }), + ); + return ('mock_id', stream); + } +} + +void main() { + group('AIWriterCubit:', () { + 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.'; + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + 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 selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final editorState = EditorState(document: document) + ..selection = selection; + return AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockAIRepository(), + ); + }, + act: (bloc) => bloc.register( + aiWriterNode( + command: AiWriterCommand.explain, + selection: Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ), + ), + ), + wait: Duration(seconds: 1), + expect: () => [ + isA() + .having((s) => s.markdownText, 'result', isEmpty), + isA() + .having((s) => s.markdownText, 'result', isNotEmpty) + .having((s) => s.markdownText, 'result', contains('UPDATED:')), + isA() + .having((s) => s.markdownText, 'result', isNotEmpty) + .having((s) => s.markdownText, 'result', contains('UPDATED:')), + isA() + .having((s) => s.markdownText, 'result', isNotEmpty) + .having((s) => s.markdownText, 'result', contains('UPDATED:')), + isA() + .having((s) => s.markdownText, 'result', isNotEmpty) + .having((s) => s.markdownText, 'result', contains('UPDATED:')), + ], + ); + + 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 selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final editorState = EditorState(document: document) + ..selection = selection; + return AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockErrorRepository(), + ); + }, + act: (bloc) => bloc.register( + aiWriterNode( + command: AiWriterCommand.explain, + selection: Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ), + ), + ), + wait: Duration(seconds: 1), + expect: () => [ + isA() + .having((s) => s.markdownText, 'result', isEmpty), + isA().having( + (s) => s.error.code, + 'error code', + AIErrorCode.aiResponseLimitExceeded, + ), + ], + ); + + test('improve writing - the result contains the same number of paragraphs', + () async { + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + aiWriterNode( + command: AiWriterCommand.improveWriting, + selection: selection, + ), + ], + ), + ); + final editorState = EditorState(document: document) + ..selection = selection; + final aiNode = editorState.getNodeAtPath([3])!; + final bloc = AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockAIRepository(), + ); + bloc.register(aiNode); + await blocResponseFuture(); + bloc.runResponseAction(SuggestionAction.accept); + 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('improve writing - discard', () async { + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + aiWriterNode( + command: AiWriterCommand.improveWriting, + selection: selection, + ), + ], + ), + ); + final editorState = EditorState(document: document) + ..selection = selection; + final aiNode = editorState.getNodeAtPath([3])!; + final bloc = AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockAIRepository(), + ); + bloc.register(aiNode); + await blocResponseFuture(); + bloc.runResponseAction(SuggestionAction.discard); + await blocResponseFuture(); + expect( + editorState.document.root.children.length, + 3, + ); + expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), text1); + expect(editorState.getNodeAtPath([1])!.delta!.toPlainText(), text2); + expect(editorState.getNodeAtPath([2])!.delta!.toPlainText(), text3); + }); + + test('improve writing - the result less than the original text', () async { + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + aiWriterNode( + command: AiWriterCommand.improveWriting, + selection: selection, + ), + ], + ), + ); + final editorState = EditorState(document: document) + ..selection = selection; + final aiNode = editorState.getNodeAtPath([3])!; + final bloc = AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockAIRepositoryLess(), + ); + bloc.register(aiNode); + await blocResponseFuture(); + bloc.runResponseAction(SuggestionAction.accept); + await blocResponseFuture(); + expect(editorState.document.root.children.length, 2); + expect( + editorState.getNodeAtPath([0])!.delta!.toPlainText(), + 'Hello World', + ); + }); + + test('improve writing - the result more than the original text', () async { + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + aiWriterNode( + command: AiWriterCommand.improveWriting, + selection: selection, + ), + ], + ), + ); + final editorState = EditorState(document: document) + ..selection = selection; + final aiNode = editorState.getNodeAtPath([3])!; + final bloc = AiWriterCubit( + documentId: '', + editorState: editorState, + aiService: _MockAIRepositoryMore(), + ); + bloc.register(aiNode); + await blocResponseFuture(); + bloc.runResponseAction(SuggestionAction.accept); + 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/app_setting_test/appearance_test.dart b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart index 4f8f4b786a..d9cd57757e 100644 --- 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 @@ -37,7 +37,6 @@ void main() { ), verify: (bloc) { expect(bloc.state.font, defaultFontFamily); - expect(bloc.state.monospaceFont, 'SF Mono'); expect(bloc.state.themeMode, ThemeMode.system); }, ); 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 index 6b6ec926e2..4f855d4fb3 100644 --- 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 @@ -1,5 +1,6 @@ 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'; @@ -15,26 +16,42 @@ void main() { final context = await boardTest.createTestBoard(); final databaseController = DatabaseController(view: context.gridView); final boardBloc = BoardBloc( - view: context.gridView, databaseController: databaseController, )..add(const BoardEvent.initial()); await boardResponseFuture(); - final groupId = boardBloc.state.groupIds.last; + 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[groupId]!.group.rows.isEmpty); + assert(boardBloc.groupControllers[lastGroupId]!.group.rows.isEmpty); assert( - boardBloc.state.groupIds.length == 4, - 'but receive ${boardBloc.state.groupIds.length}', + groupIds.length == 4, + 'but receive ${groupIds.length}', ); - boardBloc.add(BoardEvent.createBottomRow(boardBloc.state.groupIds[3])); + 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[groupId]!.group.rows.length == 1, - 'but receive ${boardBloc.groupControllers[groupId]!.group.rows.length}', + 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 index 37b9f5f855..9131446347 100644 --- 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 @@ -15,7 +15,6 @@ void main() { test('create build-in kanban board test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); @@ -27,7 +26,6 @@ void main() { test('edit kanban board field name test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); @@ -36,8 +34,9 @@ void main() { final editorBloc = FieldEditorBloc( viewId: context.gridView.id, - field: fieldInfo.field, + fieldInfo: fieldInfo, fieldController: context.fieldController, + isNew: false, ); await boardResponseFuture(); @@ -59,7 +58,6 @@ void main() { test('create a new field in kanban board test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); 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 index a760268dfd..ad4d96f5eb 100644 --- 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 @@ -17,7 +17,6 @@ void main() { test('group by checkbox field test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); 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 0000000000..51bd537159 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_date_test.dart @@ -0,0 +1,116 @@ +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 == + DateTime.now().year.toString(), + ); + }); +} 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 index 2385373d14..e2a794b3c4 100644 --- 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 @@ -41,7 +41,6 @@ void main() { // assert only have the 'No status' group final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); @@ -91,7 +90,6 @@ void main() { // assert there are only three group final boardBloc = BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); 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 index b4321b5244..c68338b424 100644 --- 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 @@ -38,7 +38,6 @@ void main() { blocTest( 'assert the number of groups is 1', build: () => BoardBloc( - view: context.gridView, databaseController: DatabaseController(view: context.gridView), )..add( const BoardEvent.initial(), diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart index abff7256b2..eb9cdcd443 100644 --- a/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart @@ -29,7 +29,7 @@ class AppFlowyBoardTest { return ViewBackendService.createView( parentViewId: app.id, name: "Test Board", - layoutType: builder.layoutType!, + layoutType: builder.layoutType, openAfterCreate: true, ).then((result) { return result.fold( @@ -54,7 +54,7 @@ Future boardResponseFuture() { return Future.delayed(boardResponseDuration()); } -Duration boardResponseDuration({int milliseconds = 200}) { +Duration boardResponseDuration({int milliseconds = 2000}) { return Duration(milliseconds: milliseconds); } @@ -78,14 +78,13 @@ class BoardTestContext { FieldEditorBloc makeFieldEditor({ required FieldInfo fieldInfo, - }) { - final editorBloc = FieldEditorBloc( - viewId: databaseController.viewId, - fieldController: fieldController, - field: fieldInfo.field, - ); - return editorBloc; - } + }) => + FieldEditorBloc( + viewId: databaseController.viewId, + fieldController: fieldController, + fieldInfo: fieldInfo, + isNew: false, + ); CellController makeCellControllerFromFieldId(String fieldId) { return makeCellController( 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 0000000000..134a429a6b --- /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 0000000000..ece0c5e027 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/chat_test/util.dart @@ -0,0 +1,36 @@ +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(); + }, + ); + }); + } +} 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 0000000000..441ffea556 --- /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 0000000000..71a4dcb3e6 --- /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 index cb5a652c7c..4e15d3aaa7 100644 --- 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 @@ -1,4 +1,6 @@ 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'; @@ -6,20 +8,25 @@ import 'package:flutter_test/flutter_test.dart'; import '../util.dart'; void main() { - late AppFlowyGridCellTest cellTest; + late AppFlowyGridTest cellTest; + setUpAll(() async { - cellTest = await AppFlowyGridCellTest.ensureInitialized(); + cellTest = await AppFlowyGridTest.ensureInitialized(); }); - group('SingleSelectOptionBloc', () { - test('create options', () async { - await cellTest.createTestGrid(); - await cellTest.createTestRow(); - final cellController = cellTest.makeSelectOptionCellController( - FieldType.SingleSelect, - 0, - ); + 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(); @@ -32,13 +39,6 @@ void main() { }); test('update options', () async { - await cellTest.createTestGrid(); - await cellTest.createTestRow(); - final cellController = cellTest.makeSelectOptionCellController( - FieldType.SingleSelect, - 0, - ); - final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); @@ -57,13 +57,6 @@ void main() { }); test('delete options', () async { - await cellTest.createTestGrid(); - await cellTest.createTestRow(); - final cellController = cellTest.makeSelectOptionCellController( - FieldType.SingleSelect, - 0, - ); - final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); @@ -108,13 +101,6 @@ void main() { }); test('select/unselect option', () async { - await cellTest.createTestGrid(); - await cellTest.createTestRow(); - final cellController = cellTest.makeSelectOptionCellController( - FieldType.SingleSelect, - 0, - ); - final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); @@ -123,7 +109,7 @@ void main() { await gridResponseFuture(); final optionId = bloc.state.options[0].id; - bloc.add(SelectOptionCellEditorEvent.unSelectOption(optionId)); + bloc.add(SelectOptionCellEditorEvent.unselectOption(optionId)); await gridResponseFuture(); assert(bloc.state.selectedOptions.isEmpty); @@ -135,13 +121,6 @@ void main() { }); test('select an option or create one', () async { - await cellTest.createTestGrid(); - await cellTest.createTestRow(); - final cellController = cellTest.makeSelectOptionCellController( - FieldType.SingleSelect, - 0, - ); - final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); @@ -163,13 +142,6 @@ void main() { }); test('select multiple options', () async { - await cellTest.createTestGrid(); - await cellTest.createTestRow(); - final cellController = cellTest.makeSelectOptionCellController( - FieldType.SingleSelect, - 0, - ); - final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); @@ -195,13 +167,6 @@ void main() { }); test('filter options', () async { - await cellTest.createTestGrid(); - await cellTest.createTestRow(); - final cellController = cellTest.makeSelectOptionCellController( - FieldType.SingleSelect, - 0, - ); - final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); 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 0000000000..bcd39265d0 --- /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/edit_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/field/edit_field_test.dart deleted file mode 100644 index 32b1d603d9..0000000000 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/field/edit_field_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../util.dart'; - -Future createEditorBloc(AppFlowyGridTest gridTest) async { - final context = await gridTest.createTestGrid(); - final fieldInfo = context.singleSelectFieldContext(); - return FieldEditorBloc( - viewId: context.gridView.id, - fieldController: context.fieldController, - field: fieldInfo.field, - ); -} - -void main() { - late AppFlowyGridTest gridTest; - - setUpAll(() async { - gridTest = await AppFlowyGridTest.ensureInitialized(); - }); - - test('rename field', () async { - final editorBloc = await createEditorBloc(gridTest); - editorBloc.add(const FieldEditorEvent.renameField('Hello world')); - - await gridResponseFuture(); - expect(editorBloc.state.field.name, equals("Hello world")); - }); - - test('switch to text field', () async { - final editorBloc = await createEditorBloc(gridTest); - - editorBloc.add(const FieldEditorEvent.switchFieldType(FieldType.RichText)); - await gridResponseFuture(); - - // The default length of the fields is 3. The length of the fields - // should not change after switching to other field type - expect(editorBloc.state.field.fieldType, equals(FieldType.RichText)); - }); -} 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 index c9344a4917..a1fd6d35cc 100644 --- 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 @@ -11,19 +11,19 @@ void main() { gridTest = await AppFlowyGridTest.ensureInitialized(); }); - group('$FieldCellBloc', () { + group('field cell bloc:', () { late GridTestContext context; late double width; setUp(() async { - context = await gridTest.createTestGrid(); + context = await gridTest.makeDefaultTestGrid(); }); blocTest( 'update field width', build: () => FieldCellBloc( - fieldInfo: context.fieldInfos[0], - viewId: context.gridView.id, + fieldInfo: context.fieldController.fieldInfos[0], + viewId: context.viewId, ), act: (bloc) { width = bloc.state.width; @@ -37,10 +37,10 @@ void main() { ); blocTest( - 'field width should not be lesser than 50px', + 'field width should not be less than 50px', build: () => FieldCellBloc( - viewId: context.gridView.id, - fieldInfo: context.fieldInfos[0], + viewId: context.viewId, + fieldInfo: context.fieldController.fieldInfos[0], ), act: (bloc) { bloc.add(const FieldCellEvent.onResizeStart()); 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 0000000000..c46ea5532b --- /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/create_filter_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/create_filter_test.dart deleted file mode 100644 index d02a319ab1..0000000000 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/create_filter_test.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:appflowy/plugins/database/domain/filter_service.dart'; -import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; -import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../util.dart'; - -void main() { - late AppFlowyGridTest gridTest; - setUpAll(() async { - gridTest = await AppFlowyGridTest.ensureInitialized(); - }); - - test('create a text filter)', () async { - final context = await gridTest.createTestGrid(); - final service = FilterBackendService(viewId: context.gridView.id); - final textField = context.textFieldContext(); - await service.insertTextFilter( - fieldId: textField.id, - condition: TextFilterConditionPB.TextIsEmpty, - content: "", - ); - await gridResponseFuture(); - - assert(context.fieldController.filterInfos.length == 1); - }); - - test('delete a text filter)', () async { - final context = await gridTest.createTestGrid(); - final service = FilterBackendService(viewId: context.gridView.id); - final textField = context.textFieldContext(); - await service.insertTextFilter( - fieldId: textField.id, - condition: TextFilterConditionPB.TextIsEmpty, - content: "", - ); - await gridResponseFuture(); - - final filterInfo = context.fieldController.filterInfos.first; - await service.deleteFilter( - fieldId: textField.id, - filterId: filterInfo.filter.id, - ); - await gridResponseFuture(); - - expect(context.fieldController.filterInfos.length, 0); - }); - - test('filter rows with condition: text is empty', () async { - final context = await gridTest.createTestGrid(); - final service = FilterBackendService(viewId: context.gridView.id); - final gridController = DatabaseController( - view: context.gridView, - ); - final gridBloc = GridBloc( - view: context.gridView, - databaseController: gridController, - )..add(const GridEvent.initial()); - await gridResponseFuture(); - - final textField = context.textFieldContext(); - await service.insertTextFilter( - fieldId: textField.id, - condition: TextFilterConditionPB.TextIsEmpty, - content: "", - ); - await gridResponseFuture(); - - expect(gridBloc.state.rowInfos.length, 3); - }); - - test('filter rows with condition: text is empty(After edit the row)', - () async { - final context = await gridTest.createTestGrid(); - final service = FilterBackendService(viewId: context.gridView.id); - final gridController = DatabaseController( - view: context.gridView, - ); - final gridBloc = GridBloc( - view: context.gridView, - databaseController: gridController, - )..add(const GridEvent.initial()); - await gridResponseFuture(); - - final textField = context.textFieldContext(); - await service.insertTextFilter( - fieldId: textField.id, - condition: TextFilterConditionPB.TextIsEmpty, - content: "", - ); - await gridResponseFuture(); - - final controller = context.makeTextCellController(0); - await controller.saveCellData("edit text cell content"); - await gridResponseFuture(); - assert(gridBloc.state.rowInfos.length == 2); - - await controller.saveCellData(""); - await gridResponseFuture(); - assert(gridBloc.state.rowInfos.length == 3); - }); - - test('filter rows with condition: text is not empty', () async { - final context = await gridTest.createTestGrid(); - final service = FilterBackendService(viewId: context.gridView.id); - final textField = context.textFieldContext(); - await gridResponseFuture(); - await service.insertTextFilter( - fieldId: textField.id, - condition: TextFilterConditionPB.TextIsNotEmpty, - content: "", - ); - await gridResponseFuture(); - assert(context.rowInfos.isEmpty); - }); - - test('filter rows with condition: checkbox uncheck', () async { - final context = await gridTest.createTestGrid(); - final checkboxField = context.checkboxFieldContext(); - final service = FilterBackendService(viewId: context.gridView.id); - final gridController = DatabaseController( - view: context.gridView, - ); - final gridBloc = GridBloc( - view: context.gridView, - databaseController: gridController, - )..add(const GridEvent.initial()); - - await gridResponseFuture(); - await service.insertCheckboxFilter( - fieldId: checkboxField.id, - condition: CheckboxFilterConditionPB.IsUnChecked, - ); - await gridResponseFuture(); - assert(gridBloc.state.rowInfos.length == 3); - }); - - test('filter rows with condition: checkbox check', () async { - final context = await gridTest.createTestGrid(); - final checkboxField = context.checkboxFieldContext(); - final service = FilterBackendService(viewId: context.gridView.id); - final gridController = DatabaseController( - view: context.gridView, - ); - final gridBloc = GridBloc( - view: context.gridView, - databaseController: gridController, - )..add(const GridEvent.initial()); - - await gridResponseFuture(); - await service.insertCheckboxFilter( - fieldId: checkboxField.id, - condition: CheckboxFilterConditionPB.IsChecked, - ); - await gridResponseFuture(); - assert(gridBloc.state.rowInfos.isEmpty); - }); -} 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 0000000000..f5068282a9 --- /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 0000000000..12afca48f8 --- /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/filter/filter_menu_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_menu_test.dart deleted file mode 100644 index a5aa0c9f58..0000000000 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_menu_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:appflowy/plugins/database/domain/filter_service.dart'; -import 'package:appflowy/plugins/database/grid/application/filter/filter_menu_bloc.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../util.dart'; - -void main() { - late AppFlowyGridTest gridTest; - setUpAll(() async { - gridTest = await AppFlowyGridTest.ensureInitialized(); - }); - - test('test filter menu after create a text filter)', () async { - final context = await gridTest.createTestGrid(); - final menuBloc = DatabaseFilterMenuBloc( - viewId: context.gridView.id, - fieldController: context.fieldController, - )..add(const DatabaseFilterMenuEvent.initial()); - await gridResponseFuture(); - assert(menuBloc.state.creatableFields.length == 3); - - final service = FilterBackendService(viewId: context.gridView.id); - final textField = context.textFieldContext(); - await service.insertTextFilter( - fieldId: textField.id, - condition: TextFilterConditionPB.TextIsEmpty, - content: "", - ); - await gridResponseFuture(); - assert(menuBloc.state.creatableFields.length == 3); - }); - - test('test filter menu after update existing text filter)', () async { - final context = await gridTest.createTestGrid(); - final menuBloc = DatabaseFilterMenuBloc( - viewId: context.gridView.id, - fieldController: context.fieldController, - )..add(const DatabaseFilterMenuEvent.initial()); - await gridResponseFuture(); - - final service = FilterBackendService(viewId: context.gridView.id); - final textField = context.textFieldContext(); - - // Create filter - await service.insertTextFilter( - fieldId: textField.id, - condition: TextFilterConditionPB.TextIsEmpty, - content: "", - ); - await gridResponseFuture(); - - final textFilter = context.fieldController.filterInfos.first; - // Update the existing filter - await service.insertTextFilter( - fieldId: textField.id, - filterId: textFilter.filter.id, - condition: TextFilterConditionPB.TextIs, - content: "ABC", - ); - await gridResponseFuture(); - assert( - menuBloc.state.filters.first.textFilter()!.condition == - TextFilterConditionPB.TextIs, - ); - assert(menuBloc.state.filters.first.textFilter()!.content == "ABC"); - }); -} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_checkbox_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_checkbox_test.dart deleted file mode 100644 index 675ee8fc57..0000000000 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_checkbox_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:appflowy/plugins/database/domain/filter_service.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../util.dart'; -import 'filter_util.dart'; - -void main() { - late AppFlowyGridTest gridTest; - setUpAll(() async { - gridTest = await AppFlowyGridTest.ensureInitialized(); - }); - - test('filter rows by checkbox is check condition)', () async { - final context = await createTestFilterGrid(gridTest); - final service = FilterBackendService(viewId: context.gridView.id); - - final controller = context.makeCheckboxCellController(0); - await controller.saveCellData("Yes"); - await gridResponseFuture(); - - // create a new filter - final checkboxField = context.checkboxFieldContext(); - await service.insertCheckboxFilter( - fieldId: checkboxField.id, - condition: CheckboxFilterConditionPB.IsChecked, - ); - await gridResponseFuture(); - assert( - context.rowInfos.length == 1, - "expect 1 but receive ${context.rowInfos.length}", - ); - }); - - test('filter rows by checkbox is uncheck condition)', () async { - final context = await createTestFilterGrid(gridTest); - final service = FilterBackendService(viewId: context.gridView.id); - - final controller = context.makeCheckboxCellController(0); - await controller.saveCellData("Yes"); - await gridResponseFuture(); - - // create a new filter - final checkboxField = context.checkboxFieldContext(); - await service.insertCheckboxFilter( - fieldId: checkboxField.id, - condition: CheckboxFilterConditionPB.IsUnChecked, - ); - await gridResponseFuture(); - assert( - context.rowInfos.length == 2, - "expect 2 but receive ${context.rowInfos.length}", - ); - }); -} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_text_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_text_test.dart deleted file mode 100644 index 0af6b18092..0000000000 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_text_test.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:appflowy/plugins/database/domain/filter_service.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../util.dart'; -import 'filter_util.dart'; - -void main() { - late AppFlowyGridTest gridTest; - setUpAll(() async { - gridTest = await AppFlowyGridTest.ensureInitialized(); - }); - - test('filter rows by text is empty condition)', () async { - final context = await createTestFilterGrid(gridTest); - - final service = FilterBackendService(viewId: context.gridView.id); - final textField = context.textFieldContext(); - // create a new filter - await service.insertTextFilter( - fieldId: textField.id, - condition: TextFilterConditionPB.TextIsEmpty, - content: "", - ); - await gridResponseFuture(); - assert( - context.fieldController.filterInfos.length == 1, - "expect 1 but receive ${context.fieldController.filterInfos.length}", - ); - assert( - context.rowInfos.length == 1, - "expect 1 but receive ${context.rowInfos.length}", - ); - - // delete the filter - final textFilter = context.fieldController.filterInfos.first; - await service.deleteFilter( - fieldId: textField.id, - filterId: textFilter.filter.id, - ); - await gridResponseFuture(); - assert(context.rowInfos.length == 3); - }); - - test('filter rows by text is not empty condition)', () async { - final context = await createTestFilterGrid(gridTest); - - final service = FilterBackendService(viewId: context.gridView.id); - final textField = context.textFieldContext(); - // create a new filter - await service.insertTextFilter( - fieldId: textField.id, - condition: TextFilterConditionPB.TextIsNotEmpty, - content: "", - ); - await gridResponseFuture(); - assert( - context.rowInfos.length == 2, - "expect 2 but receive ${context.rowInfos.length}", - ); - - // delete the filter - final textFilter = context.fieldController.filterInfos.first; - await service.deleteFilter( - fieldId: textField.id, - filterId: textFilter.filter.id, - ); - await gridResponseFuture(); - assert(context.rowInfos.length == 3); - }); - - test('filter rows by text is empty or is not empty condition)', () async { - final context = await createTestFilterGrid(gridTest); - - final service = FilterBackendService(viewId: context.gridView.id); - final textField = context.textFieldContext(); - // create a new filter - await service.insertTextFilter( - fieldId: textField.id, - condition: TextFilterConditionPB.TextIsEmpty, - content: "", - ); - await gridResponseFuture(); - assert( - context.fieldController.filterInfos.length == 1, - "expect 1 but receive ${context.fieldController.filterInfos.length}", - ); - assert( - context.rowInfos.length == 1, - "expect 1 but receive ${context.rowInfos.length}", - ); - - // Update the existing filter - final textFilter = context.fieldController.filterInfos.first; - await service.insertTextFilter( - fieldId: textField.id, - filterId: textFilter.filter.id, - condition: TextFilterConditionPB.TextIsNotEmpty, - content: "", - ); - await gridResponseFuture(); - assert(context.rowInfos.length == 2); - - // delete the filter - await service.deleteFilter( - fieldId: textField.id, - filterId: textFilter.filter.id, - ); - await gridResponseFuture(); - assert(context.rowInfos.length == 3); - }); - - test('filter rows by text is condition)', () async { - final context = await createTestFilterGrid(gridTest); - - final service = FilterBackendService(viewId: context.gridView.id); - final textField = context.textFieldContext(); - // create a new filter - await service.insertTextFilter( - fieldId: textField.id, - condition: TextFilterConditionPB.TextIs, - content: "A", - ); - await gridResponseFuture(); - assert( - context.rowInfos.length == 1, - "expect 1 but receive ${context.rowInfos.length}", - ); - - // Update the existing filter's content from 'A' to 'B' - final textFilter = context.fieldController.filterInfos.first; - await service.insertTextFilter( - fieldId: textField.id, - filterId: textFilter.filter.id, - condition: TextFilterConditionPB.TextIs, - content: "B", - ); - await gridResponseFuture(); - assert(context.rowInfos.length == 1); - - // Update the existing filter's content from 'B' to 'b' - await service.insertTextFilter( - fieldId: textField.id, - filterId: textFilter.filter.id, - condition: TextFilterConditionPB.TextIs, - content: "b", - ); - await gridResponseFuture(); - assert(context.rowInfos.length == 1); - - // Update the existing filter with content 'C' - await service.insertTextFilter( - fieldId: textField.id, - filterId: textFilter.filter.id, - condition: TextFilterConditionPB.TextIs, - content: "C", - ); - await gridResponseFuture(); - assert(context.rowInfos.isEmpty); - - // delete the filter - await service.deleteFilter( - fieldId: textField.id, - filterId: textFilter.filter.id, - ); - await gridResponseFuture(); - assert(context.rowInfos.length == 3); - }); -} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_util.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_util.dart deleted file mode 100644 index ee73cf44d9..0000000000 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_util.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/workspace/application/view/view_service.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; - -import '../util.dart'; - -Future createTestFilterGrid(AppFlowyGridTest gridTest) async { - final app = await gridTest.unitTest.createWorkspace(); - final context = await ViewBackendService.createView( - parentViewId: app.id, - name: "Filter Grid", - layoutType: ViewLayoutPB.Grid, - openAfterCreate: true, - ).then((result) { - return result.fold( - (view) async { - final context = GridTestContext( - view, - DatabaseController(view: view), - ); - final result = await context.gridController.open(); - - await editCells(context); - result.fold((l) => null, (r) => throw Exception(r)); - return context; - }, - (error) => throw Exception(), - ); - }); - - return context; -} - -Future editCells(GridTestContext context) async { - final controller0 = context.makeTextCellController(0); - final controller1 = context.makeTextCellController(1); - - await controller0.saveCellData('A'); - await gridResponseFuture(); - await controller1.saveCellData('B'); - await gridResponseFuture(); -} 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 index e9ae25b96f..8af1a4b7e1 100644 --- 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 @@ -7,6 +7,7 @@ import 'util.dart'; void main() { late AppFlowyGridTest gridTest; + setUpAll(() async { gridTest = await AppFlowyGridTest.ensureInitialized(); }); @@ -15,7 +16,7 @@ void main() { late GridTestContext context; setUp(() async { - context = await gridTest.createTestGrid(); + context = await gridTest.makeDefaultTestGrid(); }); // The initial number of rows is 3 for each grid @@ -23,32 +24,29 @@ void main() { blocTest( "create a row", build: () => GridBloc( - view: context.gridView, - databaseController: DatabaseController(view: context.gridView), + view: context.view, + databaseController: DatabaseController(view: context.view), )..add(const GridEvent.initial()), act: (bloc) => bloc.add(const GridEvent.createRow()), - wait: const Duration(milliseconds: 300), + wait: gridResponseDuration(), verify: (bloc) { - assert(bloc.state.rowInfos.length == 4); + expect(bloc.state.rowInfos.length, equals(4)); }, ); blocTest( "delete the last row", build: () => GridBloc( - view: context.gridView, - databaseController: DatabaseController(view: context.gridView), + 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: const Duration(milliseconds: 300), + wait: gridResponseDuration(), verify: (bloc) { - assert( - bloc.state.rowInfos.length == 2, - "Expected 2, but receive ${bloc.state.rowInfos.length}", - ); + expect(bloc.state.rowInfos.length, equals(2)); }, ); @@ -59,8 +57,8 @@ void main() { blocTest( 'reorder rows', build: () => GridBloc( - view: context.gridView, - databaseController: DatabaseController(view: context.gridView), + view: context.view, + databaseController: DatabaseController(view: context.view), )..add(const GridEvent.initial()), act: (bloc) async { await gridResponseFuture(); @@ -71,10 +69,11 @@ void main() { bloc.add(const GridEvent.moveRow(0, 2)); }, + wait: gridResponseDuration(), verify: (bloc) { - expect(secondId, bloc.state.rowInfos[0].rowId); - expect(thirdId, bloc.state.rowInfos[1].rowId); - expect(firstId, bloc.state.rowInfos[2].rowId); + 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 0000000000..a0abebd214 --- /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 index 66502b44cf..e80ff1cc4b 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart @@ -1,91 +1,54 @@ +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/application/field/field_info.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/plugins/database/application/row/row_service.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/view.pb.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'; -class GridTestContext { - GridTestContext(this.gridView, this.gridController); +const v020GridFileName = "v020.afdb"; +const v069GridFileName = "v069.afdb"; - final ViewPB gridView; - final DatabaseController gridController; +class GridTestContext { + GridTestContext(this.view, this.databaseController); + + final ViewPB view; + final DatabaseController databaseController; + + String get viewId => view.id; List get rowInfos { - return gridController.rowCache.rowInfos; + return databaseController.rowCache.rowInfos; } - List get fieldInfos => fieldController.fieldInfos; - - FieldController get fieldController { - return gridController.fieldController; - } + FieldController get fieldController => databaseController.fieldController; Future createField(FieldType fieldType) async { final editorBloc = - await createFieldEditor(databaseController: gridController); + await createFieldEditor(databaseController: databaseController); await gridResponseFuture(); editorBloc.add(FieldEditorEvent.switchFieldType(fieldType)); await gridResponseFuture(); - return Future(() => editorBloc); + return editorBloc; } - FieldInfo singleSelectFieldContext() { - final fieldInfo = fieldInfos - .firstWhere((element) => element.fieldType == FieldType.SingleSelect); - return fieldInfo; - } - - FieldInfo textFieldContext() { - final fieldInfo = fieldInfos - .firstWhere((element) => element.fieldType == FieldType.RichText); - return fieldInfo; - } - - FieldInfo checkboxFieldContext() { - final fieldInfo = fieldInfos - .firstWhere((element) => element.fieldType == FieldType.Checkbox); - return fieldInfo; - } - - SelectOptionCellController makeSelectOptionCellController( - FieldType fieldType, - int rowIndex, - ) { - assert( - fieldType == FieldType.SingleSelect || fieldType == FieldType.MultiSelect, - ); - final field = - fieldInfos.firstWhere((fieldInfo) => fieldInfo.fieldType == fieldType); + CellController makeGridCellController(int fieldIndex, int rowIndex) { return makeCellController( - gridController, - CellContext(fieldId: field.id, rowId: rowInfos[rowIndex].rowId), - ).as(); - } - - TextCellController makeTextCellController(int rowIndex) { - final field = fieldInfos - .firstWhere((element) => element.fieldType == FieldType.RichText); - return makeCellController( - gridController, - CellContext(fieldId: field.id, rowId: rowInfos[rowIndex].rowId), - ).as(); - } - - CheckboxCellController makeCheckboxCellController(int rowIndex) { - final field = fieldInfos - .firstWhere((element) => element.fieldType == FieldType.Checkbox); - return makeCellController( - gridController, - CellContext(fieldId: field.id, rowId: rowInfos[rowIndex].rowId), + databaseController, + CellContext( + fieldId: fieldController.fieldInfos[fieldIndex].id, + rowId: rowInfos[rowIndex].rowId, + ), ).as(); } } @@ -102,7 +65,8 @@ Future createFieldEditor({ return FieldEditorBloc( viewId: databaseController.viewId, fieldController: databaseController.fieldController, - field: field, + fieldInfo: databaseController.fieldController.getField(field.id)!, + isNew: true, ); }, (err) => throw Exception(err), @@ -120,65 +84,74 @@ class AppFlowyGridTest { return AppFlowyGridTest(unitTest: inner); } - Future createTestGrid() async { - final app = await unitTest.createWorkspace(); + Future makeDefaultTestGrid() async { + final workspace = await unitTest.createWorkspace(); final context = await ViewBackendService.createView( - parentViewId: app.id, + parentViewId: workspace.id, name: "Test Grid", layoutType: ViewLayoutPB.Grid, openAfterCreate: true, - ).then((result) { - return result.fold( - (view) async { - final context = GridTestContext( - view, - DatabaseController(view: view), - ); - final result = await context.gridController.open(); - result.fold((l) => null, (r) => throw Exception(r)); - return context; - }, - (error) { - throw Exception(); - }, - ); - }); + ).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; } } -/// Create a new Grid for cell test -class AppFlowyGridCellTest { - AppFlowyGridCellTest({required this.gridTest}); - - late GridTestContext context; - final AppFlowyGridTest gridTest; - - static Future ensureInitialized() async { - final gridTest = await AppFlowyGridTest.ensureInitialized(); - return AppFlowyGridCellTest(gridTest: gridTest); - } - - Future createTestGrid() async { - context = await gridTest.createTestGrid(); - } - - Future createTestRow() async { - await RowBackendService.createRow(viewId: context.gridView.id); - } - - SelectOptionCellController makeSelectOptionCellController( - FieldType fieldType, - int rowIndex, - ) => - context.makeSelectOptionCellController(fieldType, rowIndex); +Future gridResponseFuture({int milliseconds = 300}) { + return Future.delayed( + gridResponseDuration(milliseconds: milliseconds), + ); } -Future gridResponseFuture({int milliseconds = 400}) { - return Future.delayed(gridResponseDuration(milliseconds: milliseconds)); -} - -Duration gridResponseDuration({int milliseconds = 400}) { +Duration gridResponseDuration({int milliseconds = 300}) { return Duration(milliseconds: milliseconds); } 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 index b816a8b68e..41865b7dd7 100644 --- 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 @@ -182,14 +182,14 @@ void main() { await blocResponseFuture(); assert(viewBloc.state.lastCreatedView!.name == gird); - var workspaceSetting = + var workspaceLatest = await FolderEventGetCurrentWorkspaceSetting().send().then( (result) => result.fold( (l) => l, (r) => throw Exception(), ), ); - workspaceSetting.latestView.id == viewBloc.state.lastCreatedView!.id; + workspaceLatest.latestView.id == viewBloc.state.lastCreatedView!.id; // ignore: unused_local_variable final documentBloc = DocumentBloc(documentId: document.id) @@ -198,14 +198,13 @@ void main() { ); await blocResponseFuture(); - workspaceSetting = - await FolderEventGetCurrentWorkspaceSetting().send().then( - (result) => result.fold( - (l) => l, - (r) => throw Exception(), - ), - ); - workspaceSetting.latestView.id == document.id; + workspaceLatest = await FolderEventGetCurrentWorkspaceSetting().send().then( + (result) => result.fold( + (l) => l, + (r) => throw Exception(), + ), + ); + workspaceLatest.latestView.id == document.id; }); test('create views', () async { @@ -213,6 +212,9 @@ void main() { 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', 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 index 1b896cbd3d..ce57c61bd7 100644 --- 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 @@ -1,6 +1,6 @@ import 'dart:ffi'; -import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +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'; diff --git a/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart b/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart index a92ca4a3e7..2ccc3dad7a 100644 --- a/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/levenshtein.dart'; +import 'package:appflowy/util/levenshtein.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/frontend/appflowy_flutter/test/unit_test/document/document_diff/document_diff_test.dart b/frontend/appflowy_flutter/test/unit_test/document/document_diff/document_diff_test.dart new file mode 100644 index 0000000000..7124eb93fc --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/document_diff/document_diff_test.dart @@ -0,0 +1,403 @@ +import 'package:appflowy/plugins/document/application/document_diff.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('document diff:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + const diff = DocumentDiff(); + + Node createNodeWithId({required String id, required String text}) { + return Node( + id: id, + type: ParagraphBlockKeys.type, + attributes: { + ParagraphBlockKeys.delta: (Delta()..insert(text)).toJson(), + }, + ); + } + + Future applyOperationAndVerifyDocument( + Document before, + Document after, + List operations, + ) async { + final expected = after.toJson(); + final editorState = EditorState(document: before); + final transaction = editorState.transaction; + for (final operation in operations) { + transaction.add(operation); + } + await editorState.apply(transaction); + expect(editorState.document.toJson(), expected); + } + + test('no diff when the document is the same', () async { + // create two nodes with the same id and texts + final node1 = createNodeWithId(id: '1', text: 'Hello AppFlowy'); + final node2 = createNodeWithId(id: '1', text: 'Hello AppFlowy'); + + final previous = Document.blank()..insert([0], [node1]); + final next = Document.blank()..insert([0], [node2]); + final operations = diff.diffDocument(previous, next); + + expect(operations, isEmpty); + + await applyOperationAndVerifyDocument(previous, next, operations); + }); + + test('update text diff with the same id', () async { + final node1 = createNodeWithId(id: '1', text: 'Hello AppFlowy'); + final node2 = createNodeWithId(id: '1', text: 'Hello AppFlowy 2'); + + final previous = Document.blank()..insert([0], [node1]); + final next = Document.blank()..insert([0], [node2]); + final operations = diff.diffDocument(previous, next); + + expect(operations.length, 1); + expect(operations[0], isA()); + + await applyOperationAndVerifyDocument(previous, next, operations); + }); + + test('delete and insert text diff with different id', () async { + final node1 = createNodeWithId(id: '1', text: 'Hello AppFlowy'); + final node2 = createNodeWithId(id: '2', text: 'Hello AppFlowy 2'); + + final previous = Document.blank()..insert([0], [node1]); + final next = Document.blank()..insert([0], [node2]); + + final operations = diff.diffDocument(previous, next); + + expect(operations.length, 2); + expect(operations[0], isA()); + expect(operations[1], isA()); + + await applyOperationAndVerifyDocument(previous, next, operations); + }); + + test('insert single text diff', () async { + final node1 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + final node21 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + final node22 = createNodeWithId( + id: '2', + text: 'Hello AppFlowy - Second line', + ); + + final previous = Document.blank()..insert([0], [node1]); + final next = Document.blank()..insert([0], [node21, node22]); + + final operations = diff.diffDocument(previous, next); + + expect(operations.length, 1); + expect(operations[0], isA()); + + await applyOperationAndVerifyDocument(previous, next, operations); + }); + + test('delete single text diff', () async { + final node11 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + final node12 = createNodeWithId( + id: '2', + text: 'Hello AppFlowy - Second line', + ); + final node21 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + + final previous = Document.blank()..insert([0], [node11, node12]); + final next = Document.blank()..insert([0], [node21]); + + final operations = diff.diffDocument(previous, next); + + expect(operations.length, 1); + expect(operations[0], isA()); + + await applyOperationAndVerifyDocument(previous, next, operations); + }); + + test('insert multiple texts diff', () async { + final node11 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + final node15 = createNodeWithId( + id: '5', + text: 'Hello AppFlowy - Fifth line', + ); + final node21 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + final node22 = createNodeWithId( + id: '2', + text: 'Hello AppFlowy - Second line', + ); + final node23 = createNodeWithId( + id: '3', + text: 'Hello AppFlowy - Third line', + ); + final node24 = createNodeWithId( + id: '4', + text: 'Hello AppFlowy - Fourth line', + ); + final node25 = createNodeWithId( + id: '5', + text: 'Hello AppFlowy - Fifth line', + ); + + final previous = Document.blank() + ..insert( + [0], + [ + node11, + node15, + ], + ); + final next = Document.blank() + ..insert( + [0], + [ + node21, + node22, + node23, + node24, + node25, + ], + ); + + final operations = diff.diffDocument(previous, next); + + expect(operations.length, 1); + + final op = operations[0] as InsertOperation; + expect(op.path, [1]); + expect(op.nodes, [node22, node23, node24]); + + await applyOperationAndVerifyDocument(previous, next, operations); + }); + + test('delete multiple texts diff', () async { + final node11 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + final node12 = createNodeWithId( + id: '2', + text: 'Hello AppFlowy - Second line', + ); + final node13 = createNodeWithId( + id: '3', + text: 'Hello AppFlowy - Third line', + ); + final node14 = createNodeWithId( + id: '4', + text: 'Hello AppFlowy - Fourth line', + ); + final node15 = createNodeWithId( + id: '5', + text: 'Hello AppFlowy - Fifth line', + ); + + final node21 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + final node25 = createNodeWithId( + id: '5', + text: 'Hello AppFlowy - Fifth line', + ); + + final previous = Document.blank() + ..insert( + [0], + [ + node11, + node12, + node13, + node14, + node15, + ], + ); + final next = Document.blank() + ..insert( + [0], + [ + node21, + node25, + ], + ); + + final operations = diff.diffDocument(previous, next); + + expect(operations.length, 1); + + final op = operations[0] as DeleteOperation; + expect(op.path, [1]); + expect(op.nodes, [node12, node13, node14]); + + await applyOperationAndVerifyDocument(previous, next, operations); + }); + + test('multiple delete and update diff', () async { + final node11 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + final node12 = createNodeWithId( + id: '2', + text: 'Hello AppFlowy - Second line', + ); + final node13 = createNodeWithId( + id: '3', + text: 'Hello AppFlowy - Third line', + ); + final node14 = createNodeWithId( + id: '4', + text: 'Hello AppFlowy - Fourth line', + ); + final node15 = createNodeWithId( + id: '5', + text: 'Hello AppFlowy - Fifth line', + ); + + final node21 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + final node22 = createNodeWithId( + id: '2', + text: '', + ); + final node25 = createNodeWithId( + id: '5', + text: 'Hello AppFlowy - Fifth line', + ); + + final previous = Document.blank() + ..insert( + [0], + [ + node11, + node12, + node13, + node14, + node15, + ], + ); + final next = Document.blank() + ..insert( + [0], + [ + node21, + node22, + node25, + ], + ); + + final operations = diff.diffDocument(previous, next); + + expect(operations.length, 2); + final op1 = operations[0] as UpdateOperation; + expect(op1.path, [1]); + expect(op1.attributes, node22.attributes); + + final op2 = operations[1] as DeleteOperation; + expect(op2.path, [2]); + expect(op2.nodes, [node13, node14]); + + await applyOperationAndVerifyDocument(previous, next, operations); + }); + + test('multiple insert and update diff', () async { + final node11 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + final node12 = createNodeWithId( + id: '2', + text: 'Hello AppFlowy - Second line', + ); + final node13 = createNodeWithId( + id: '3', + text: 'Hello AppFlowy - Third line', + ); + final node21 = createNodeWithId( + id: '1', + text: 'Hello AppFlowy - First line', + ); + final node22 = createNodeWithId( + id: '2', + text: 'Hello AppFlowy - Second line - Updated', + ); + final node23 = createNodeWithId( + id: '3', + text: 'Hello AppFlowy - Third line - Updated', + ); + final node24 = createNodeWithId( + id: '4', + text: 'Hello AppFlowy - Fourth line - Updated', + ); + final node25 = createNodeWithId( + id: '5', + text: 'Hello AppFlowy - Fifth line - Updated', + ); + + final previous = Document.blank() + ..insert( + [0], + [ + node11, + node12, + node13, + ], + ); + final next = Document.blank() + ..insert( + [0], + [ + node21, + node22, + node23, + node24, + node25, + ], + ); + + final operations = diff.diffDocument(previous, next); + + expect(operations.length, 3); + final op1 = operations[0] as InsertOperation; + expect(op1.path, [3]); + expect(op1.nodes, [node24, node25]); + + final op2 = operations[1] as UpdateOperation; + expect(op2.path, [1]); + expect(op2.attributes, node22.attributes); + + final op3 = operations[2] as UpdateOperation; + expect(op3.path, [2]); + expect(op3.attributes, node23.attributes); + + await applyOperationAndVerifyDocument(previous, next, operations); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/document/html/_html_samples.dart b/frontend/appflowy_flutter/test/unit_test/document/html/_html_samples.dart new file mode 100644 index 0000000000..41dea6e01c --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/html/_html_samples.dart @@ -0,0 +1,541 @@ +// | Month | Savings | +// | -------- | ------- | +// | January | $250 | +// | February | $80 | +// | March | $420 | +const tableFromNotion = ''' + + + + + + + + + + + + + + + + + + + + +
MonthSavings
January\$250
February\$80
March\$420
+'''; + +// | Month | Savings | +// | -------- | ------- | +// | January | $250 | +// | February | $80 | +// | March | $420 | +const tableFromGoogleDocs = ''' + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+

+ + Month + +

+
+

+ + Savings + +

+
+

+ + January + +

+
+

+ + \$250 + +

+
+

+ + February + +

+
+

+ + \$80 + +

+
+

+ + March + +

+
+

+ + \$420 + +

+
+
+
+'''; + +// | Month | Savings | +// | -------- | ------- | +// | January | $250 | +// | February | $80 | +// | March | $420 | +const tableFromGoogleSheets = ''' + + + + + + + + + + + + + + + + + + + + + + + + + + +
MonthSavings
January\$250
February\$80
March\$420
+
+ +'''; + +// # The Benefits of a Balanced Diet +// A balanced diet is crucial for maintaining overall health and well-being. It provides the necessary nutrients your body needs to function effectively, supports growth and development, and helps prevent chronic diseases. In this guide, we will explore the key benefits of a balanced diet and how it can improve your life. +// --- +// ## Key Components of a Balanced Diet +// A balanced diet consists of various food groups, each providing essential nutrients. The main components include: +// 1. **Carbohydrates** – Provide energy for daily activities. +// 1. **Proteins** – Support growth, muscle repair, and immune function. +// 1. **Fats** – Aid in cell function and energy storage. +// 1. **Vitamins and Minerals** – Essential for immune function, bone health, and overall bodily processes. +// 1. **Fiber** – Promotes healthy digestion and reduces the risk of chronic diseases. +// 1. **Water** – Vital for hydration and proper bodily functions. +// --- +// ## Health Benefits of a Balanced Diet +// Maintaining a balanced diet can have profound effects on your health. Below are some of the most significant benefits: +// --- +// ### 1. **Improved Heart Health** +// A balanced diet rich in fruits, vegetables, and healthy fats helps lower cholesterol levels, reduce inflammation, and maintain a healthy blood pressure. +// ### 2. **Better Weight Management** +// By consuming nutrient-dense foods and avoiding overeating, you can achieve and maintain a healthy weight. +// ### 3. **Enhanced Mental Health** +// Proper nutrition supports brain function, which can improve mood, cognitive performance, and mental well-being. +// ### 4. **Stronger Immune System** +// A diet full of vitamins and minerals strengthens the immune system and helps the body fight off infections. +// --- +// ## Recommended Daily Nutrient Intake +// Below is a table that outlines the recommended daily intake for adults based on the different food groups: +// |Nutrient|Recommended Daily Intake|Example Foods| +// |---|---|---| +// |**Carbohydrates**|45-65% of total calories|Whole grains, fruits, vegetables| +// |**Proteins**|10-35% of total calories|Lean meats, beans, legumes, nuts, dairy| +// |**Fats**|20-35% of total calories|Olive oil, avocado, nuts, fatty fish| +// |**Fiber**|25-30 grams|Whole grains, fruits, vegetables, legumes| +// |**Vitamins & Minerals**|Varies (See below)|Fruits, vegetables, dairy, fortified cereals| +// |**Water**|2-3 liters/day|Water, herbal teas, soups| +// --- +// ## Conclusion +// Incorporating a variety of nutrient-rich foods into your diet is essential for maintaining your health. A balanced diet helps improve your physical and mental well-being, boosts energy levels, and reduces the risk of chronic conditions. By following the guidelines above, you can work toward achieving a healthier and happier life. +const tableFromChatGPT = ''' + +

The Benefits of a Balanced Diet

+

+ A balanced diet is crucial for maintaining overall health and well-being. It provides the necessary nutrients your body needs to function effectively, supports growth and development, and helps prevent chronic diseases. In this guide, + we will explore the key benefits of a balanced diet and how it can improve your life. +

+
+

Key Components of a Balanced Diet

+

A balanced diet consists of various food groups, each providing essential nutrients. The main components include:

+
    +
  1. Carbohydrates – Provide energy for daily activities.
  2. +
  3. Proteins – Support growth, muscle repair, and immune function.
  4. +
  5. Fats – Aid in cell function and energy storage.
  6. +
  7. Vitamins and Minerals – Essential for immune function, bone health, and overall bodily processes.
  8. +
  9. Fiber – Promotes healthy digestion and reduces the risk of chronic diseases.
  10. +
  11. Water – Vital for hydration and proper bodily functions.
  12. +
+
+

Health Benefits of a Balanced Diet

+

Maintaining a balanced diet can have profound effects on your health. Below are some of the most significant benefits:

+
+

1. Improved Heart Health

+

A balanced diet rich in fruits, vegetables, and healthy fats helps lower cholesterol levels, reduce inflammation, and maintain a healthy blood pressure.

+

2. Better Weight Management

+

By consuming nutrient-dense foods and avoiding overeating, you can achieve and maintain a healthy weight.

+

3. Enhanced Mental Health

+

Proper nutrition supports brain function, which can improve mood, cognitive performance, and mental well-being.

+

4. Stronger Immune System

+

A diet full of vitamins and minerals strengthens the immune system and helps the body fight off infections.

+
+

Recommended Daily Nutrient Intake

+

Below is a table that outlines the recommended daily intake for adults based on the different food groups:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NutrientRecommended Daily IntakeExample Foods
Carbohydrates45-65% of total caloriesWhole grains, fruits, vegetables
Proteins10-35% of total caloriesLean meats, beans, legumes, nuts, dairy
Fats20-35% of total caloriesOlive oil, avocado, nuts, fatty fish
Fiber25-30 gramsWhole grains, fruits, vegetables, legumes
Vitamins & MineralsVaries (See below)Fruits, vegetables, dairy, fortified cereals
Water2-3 liters/dayWater, herbal teas, soups
+
+

Conclusion

+

+ Incorporating a variety of nutrient-rich foods into your diet is essential for maintaining your health. A balanced diet helps improve your physical and mental well-being, boosts energy levels, and reduces the risk of chronic conditions. + By following the guidelines above, you can work toward achieving a healthier and happier life. +

+'''; + +// | Month | Savings | +// | -------- | ------- | +// | January | $250 | +// | February | $80 | +// | March | $420 | +const tableFromAppleNotes = ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Month

+
+

Savings

+
+

January

+
+

\$250

+
+

February

+
+

\$80

+
+

March

+
+

\$420

+
+ + +'''; diff --git a/frontend/appflowy_flutter/test/unit_test/document/html/paste_from_html_test.dart b/frontend/appflowy_flutter/test/unit_test/document/html/paste_from_html_test.dart new file mode 100644 index 0000000000..a36474ef3f --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/html/paste_from_html_test.dart @@ -0,0 +1,69 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '_html_samples.dart'; + +void main() { + group('paste from html:', () { + void checkTable(String html) { + final nodes = EditorState.blank().convertHtmlToNodes(html); + expect(nodes.length, 1); + final table = nodes.first; + expect(table.type, SimpleTableBlockKeys.type); + expect(table.getCellText(0, 0), 'Month'); + expect(table.getCellText(0, 1), 'Savings'); + expect(table.getCellText(1, 0), 'January'); + expect(table.getCellText(1, 1), '\$250'); + expect(table.getCellText(2, 0), 'February'); + expect(table.getCellText(2, 1), '\$80'); + expect(table.getCellText(3, 0), 'March'); + expect(table.getCellText(3, 1), '\$420'); + } + + test('sample 1 - paste table from Notion', () { + checkTable(tableFromNotion); + }); + + test('sample 2 - paste table from Google Docs', () { + checkTable(tableFromGoogleDocs); + }); + + test('sample 3 - paste table from Google Sheets', () { + checkTable(tableFromGoogleSheets); + }); + + test('sample 4 - paste table from ChatGPT', () { + final nodes = EditorState.blank().convertHtmlToNodes(tableFromChatGPT); + final table = + nodes.where((node) => node.type == SimpleTableBlockKeys.type).first; + + expect(table.columnLength, 3); + expect(table.rowLength, 7); + + final dividers = + nodes.where((node) => node.type == DividerBlockKeys.type); + expect(dividers.length, 5); + }); + + test('sample 5 - paste table from Apple Notes', () { + checkTable(tableFromAppleNotes); + }); + }); +} + +extension on Node { + String getCellText( + int row, + int column, { + int index = 0, + }) { + return children[row] + .children[column] + .children[index] + .delta + ?.toPlainText() ?? + ''; + } +} 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 0000000000..413d830d73 --- /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/format_shortcut_test.dart b/frontend/appflowy_flutter/test/unit_test/document/shortcuts/format_shortcut_test.dart new file mode 100644 index 0000000000..21011df540 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/shortcuts/format_shortcut_test.dart @@ -0,0 +1,103 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('format shortcut:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('turn = + > into ⇒', () async { + final document = Document.blank() + ..insert([ + 0, + ], [ + paragraphNode(text: '='), + ]); + + final editorState = EditorState(document: document); + editorState.selection = Selection.collapsed( + Position(path: [0], offset: 1), + ); + + final result = await customFormatGreaterEqual.execute(editorState); + expect(result, true); + + expect(editorState.document.root.children.length, 1); + final node = editorState.document.root.children[0]; + expect(node.delta!.toPlainText(), '⇒'); + + // use undo to revert the change + undoCommand.execute(editorState); + expect(editorState.document.root.children.length, 1); + final nodeAfterUndo = editorState.document.root.children[0]; + expect(nodeAfterUndo.delta!.toPlainText(), '=>'); + + editorState.dispose(); + }); + + test('turn - + > into →', () async { + final document = Document.blank() + ..insert([ + 0, + ], [ + paragraphNode(text: '-'), + ]); + + final editorState = EditorState(document: document); + editorState.selection = Selection.collapsed( + Position(path: [0], offset: 1), + ); + + final result = await customFormatDashGreater.execute(editorState); + expect(result, true); + + expect(editorState.document.root.children.length, 1); + final node = editorState.document.root.children[0]; + expect(node.delta!.toPlainText(), '→'); + + // use undo to revert the change + undoCommand.execute(editorState); + expect(editorState.document.root.children.length, 1); + final nodeAfterUndo = editorState.document.root.children[0]; + expect(nodeAfterUndo.delta!.toPlainText(), '->'); + + editorState.dispose(); + }); + + test('turn -- into —', () async { + final document = Document.blank() + ..insert([ + 0, + ], [ + paragraphNode(text: '-'), + ]); + + final editorState = EditorState(document: document); + editorState.selection = Selection.collapsed( + Position(path: [0], offset: 1), + ); + + final result = await customFormatDoubleHyphenEmDash.execute(editorState); + expect(result, true); + + expect(editorState.document.root.children.length, 1); + final node = editorState.document.root.children[0]; + expect(node.delta!.toPlainText(), '—'); + + // use undo to revert the change + undoCommand.execute(editorState); + expect(editorState.document.root.children.length, 1); + final nodeAfterUndo = editorState.document.root.children[0]; + expect(nodeAfterUndo.delta!.toPlainText(), '--'); + + 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 0000000000..fa84293a33 --- /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 0000000000..8b1b710f4e --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart @@ -0,0 +1,1153 @@ +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.persist(); + + 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.persist(); + + final nodes = editorState.document.root.children; + expect(nodes.length, 4); + + 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, 4); + + 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, 5); + + 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, 2); + + 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'); + }, + ); + }); + }); + + group('markdown text robot - replace in same line:', () { + final text1 = + '''The introduction of the World Wide Web in the early 1990s marked a turning point. '''; + final text2 = + '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. '''; + final text3 = + '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; + + Document buildTestDocument() { + return Document( + root: pageNode( + children: [ + paragraphNode(delta: Delta()..insert(text1 + text2 + text3)), + ], + ), + ); + } + + // 1. create a document with a paragraph node + // 2. use the text robot to replace the selected content in the same line + // 3. check the document + test('the selection is in the middle of the text', () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: text1.length, + ), + end: Position( + path: [0], + offset: text1.length + text2.length, + ), + ); + + final markdownText = + '''Tim Berners-Lee's invention of the **World Wide Web** transformed the internet, making it accessible to _non-technical users_ and opening the floodgates for global mass adoption.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterDelta = editorState.document.root.children[0].delta!.toList(); + expect(afterDelta.length, 5); + + final d1 = afterDelta[0] as TextInsert; + expect(d1.text, '${text1}Tim Berners-Lee\'s invention of the '); + expect(d1.attributes, null); + + final d2 = afterDelta[1] as TextInsert; + expect(d2.text, 'World Wide Web'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = afterDelta[2] as TextInsert; + expect(d3.text, ' transformed the internet, making it accessible to '); + expect(d3.attributes, null); + + final d4 = afterDelta[3] as TextInsert; + expect(d4.text, 'non-technical users'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = afterDelta[4] as TextInsert; + expect( + d5.text, + ' and opening the floodgates for global mass adoption.$text3', + ); + expect(d5.attributes, null); + }); + + test('replace markdown text with selection from start to middle', () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + ), + end: Position( + path: [0], + offset: text1.length, + ), + ); + + final markdownText = + '''The **invention** of the _World Wide Web_ by Tim Berners-Lee transformed how we access information.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterDelta = editorState.document.root.children[0].delta!.toList(); + expect(afterDelta.length, 5); + + final d1 = afterDelta[0] as TextInsert; + expect(d1.text, 'The '); + expect(d1.attributes, null); + + final d2 = afterDelta[1] as TextInsert; + expect(d2.text, 'invention'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = afterDelta[2] as TextInsert; + expect(d3.text, ' of the '); + expect(d3.attributes, null); + + final d4 = afterDelta[3] as TextInsert; + expect(d4.text, 'World Wide Web'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = afterDelta[4] as TextInsert; + expect( + d5.text, + ' by Tim Berners-Lee transformed how we access information.$text2$text3', + ); + expect(d5.attributes, null); + }); + + test('replace markdown text with selection from middle to end', () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: text1.length + text2.length, + ), + end: Position( + path: [0], + offset: text1.length + text2.length + text3.length, + ), + ); + + final markdownText = + '''**Email** became widespread, and instant messaging services like *ICQ* and **AOL Instant Messenger** gained tremendous popularity, allowing for seamless real-time text communication across the globe.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterDelta = editorState.document.root.children[0].delta!.toList(); + expect(afterDelta.length, 7); + + final d1 = afterDelta[0] as TextInsert; + expect( + d1.text, + text1 + text2, + ); + expect(d1.attributes, null); + + final d2 = afterDelta[1] as TextInsert; + expect(d2.text, 'Email'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = afterDelta[2] as TextInsert; + expect( + d3.text, + ' became widespread, and instant messaging services like ', + ); + expect(d3.attributes, null); + + final d4 = afterDelta[3] as TextInsert; + expect(d4.text, 'ICQ'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = afterDelta[4] as TextInsert; + expect(d5.text, ' and '); + expect(d5.attributes, null); + + final d6 = afterDelta[5] as TextInsert; + expect( + d6.text, + 'AOL Instant Messenger', + ); + expect(d6.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d7 = afterDelta[6] as TextInsert; + expect( + d7.text, + ' gained tremendous popularity, allowing for seamless real-time text communication across the globe.', + ); + expect(d7.attributes, null); + }); + + test('replace markdown text with selection from start to end', () async { + final text1 = + '''The introduction of the World Wide Web in the early 1990s marked a turning point.'''; + final text2 = + '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption.'''; + final text3 = + '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; + + final document = Document( + root: pageNode( + children: [ + paragraphNode(delta: Delta()..insert(text1)), + paragraphNode(delta: Delta()..insert(text2)), + paragraphNode(delta: Delta()..insert(text3)), + ], + ), + ); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: text1.length), + ); + + final markdownText = '''1. $text1 + +2. $text1 + +3. $text1'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final nodes = editorState.document.root.children; + expect(nodes.length, 5); + + final d1 = nodes[0].delta!.toList()[0] as TextInsert; + expect(d1.text, text1); + expect(d1.attributes, null); + expect(nodes[0].type, NumberedListBlockKeys.type); + + final d2 = nodes[1].delta!.toList()[0] as TextInsert; + expect(d2.text, text1); + expect(d2.attributes, null); + expect(nodes[1].type, NumberedListBlockKeys.type); + + final d3 = nodes[2].delta!.toList()[0] as TextInsert; + expect(d3.text, text1); + expect(d3.attributes, null); + expect(nodes[2].type, NumberedListBlockKeys.type); + + final d4 = nodes[3].delta!.toList()[0] as TextInsert; + expect(d4.text, text2); + expect(d4.attributes, null); + + final d5 = nodes[4].delta!.toList()[0] as TextInsert; + expect(d5.text, text3); + expect(d5.attributes, null); + }); + }); + + group('markdown text robot - replace in multiple lines:', () { + final text1 = + '''The introduction of the World Wide Web in the early 1990s marked a turning point. '''; + final text2 = + '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. '''; + final text3 = + '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; + + Document buildTestDocument() { + return Document( + root: pageNode( + children: [ + paragraphNode(delta: Delta()..insert(text1)), + paragraphNode(delta: Delta()..insert(text2)), + paragraphNode(delta: Delta()..insert(text3)), + ], + ), + ); + } + + // 1. create a document with 3 paragraph nodes + // 2. use the text robot to replace the selected content in the multiple lines + // 3. check the document + test( + 'the selection starts with the first paragraph and ends with the middle of second paragraph', + () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + ), + end: Position( + path: [1], + offset: text2.length - + ', opening the floodgates for mass adoption. '.length, + ), + ); + + final markdownText = + '''The **introduction** of the World Wide Web in the *early 1990s* marked a significant turning point. + +Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterNodes = editorState.document.root.children; + expect(afterNodes.length, 3); + + { + // first paragraph + final delta1 = afterNodes[0].delta!.toList(); + expect(delta1.length, 5); + + final d1 = delta1[0] as TextInsert; + expect(d1.text, 'The '); + expect(d1.attributes, null); + + final d2 = delta1[1] as TextInsert; + expect(d2.text, 'introduction'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta1[2] as TextInsert; + expect(d3.text, ' of the World Wide Web in the '); + expect(d3.attributes, null); + + final d4 = delta1[3] as TextInsert; + expect(d4.text, 'early 1990s'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta1[4] as TextInsert; + expect(d5.text, ' marked a significant turning point.'); + expect(d5.attributes, null); + } + + { + // second paragraph + final delta2 = afterNodes[1].delta!.toList(); + expect(delta2.length, 3); + + final d1 = delta2[0] as TextInsert; + expect(d1.text, "Tim Berners-Lee's "); + expect(d1.attributes, null); + + final d2 = delta2[1] as TextInsert; + expect(d2.text, "revolutionary invention"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta2[2] as TextInsert; + expect( + d3.text, + " made the internet accessible to non-technical users, opening the floodgates for mass adoption. ", + ); + expect(d3.attributes, null); + } + + { + // third paragraph + final delta3 = afterNodes[2].delta!.toList(); + expect(delta3.length, 1); + + final d1 = delta3[0] as TextInsert; + expect(d1.text, text3); + expect(d1.attributes, null); + } + }); + + test( + 'the selection starts with the middle of the first paragraph and ends with the middle of last paragraph', + () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: 'The introduction of the World Wide Web'.length, + ), + end: Position( + path: [2], + offset: + 'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity' + .length, + ), + ); + + final markdownText = + ''' in the **early 1990s** marked a *significant turning point* in technological history. + +Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users, opening the floodgates for *unprecedented mass adoption*. + +Email became **widely prevalent**, and instant messaging services like *ICQ* and *AOL Instant Messenger* gained tremendous popularity + '''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterNodes = editorState.document.root.children; + expect(afterNodes.length, 3); + + { + // first paragraph + final delta1 = afterNodes[0].delta!.toList(); + expect(delta1.length, 5); + + final d1 = delta1[0] as TextInsert; + expect(d1.text, 'The introduction of the World Wide Web in the '); + expect(d1.attributes, null); + + final d2 = delta1[1] as TextInsert; + expect(d2.text, 'early 1990s'); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta1[2] as TextInsert; + expect(d3.text, ' marked a '); + expect(d3.attributes, null); + + final d4 = delta1[3] as TextInsert; + expect(d4.text, 'significant turning point'); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta1[4] as TextInsert; + expect(d5.text, ' in technological history.'); + expect(d5.attributes, null); + } + + { + // second paragraph + final delta2 = afterNodes[1].delta!.toList(); + expect(delta2.length, 5); + + final d1 = delta2[0] as TextInsert; + expect(d1.text, "Tim Berners-Lee's "); + expect(d1.attributes, null); + + final d2 = delta2[1] as TextInsert; + expect(d2.text, "revolutionary invention"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta2[2] as TextInsert; + expect( + d3.text, + " made the internet accessible to non-technical users, opening the floodgates for ", + ); + expect(d3.attributes, null); + + final d4 = delta2[3] as TextInsert; + expect(d4.text, "unprecedented mass adoption"); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta2[4] as TextInsert; + expect(d5.text, "."); + expect(d5.attributes, null); + } + + { + // third paragraph + // third paragraph + final delta3 = afterNodes[2].delta!.toList(); + expect(delta3.length, 7); + + final d1 = delta3[0] as TextInsert; + expect(d1.text, "Email became "); + expect(d1.attributes, null); + + final d2 = delta3[1] as TextInsert; + expect(d2.text, "widely prevalent"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta3[2] as TextInsert; + expect(d3.text, ", and instant messaging services like "); + expect(d3.attributes, null); + + final d4 = delta3[3] as TextInsert; + expect(d4.text, "ICQ"); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta3[4] as TextInsert; + expect(d5.text, " and "); + expect(d5.attributes, null); + + final d6 = delta3[5] as TextInsert; + expect(d6.text, "AOL Instant Messenger"); + expect(d6.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d7 = delta3[6] as TextInsert; + expect( + d7.text, + " gained tremendous popularity, allowing for real-time text communication.", + ); + expect(d7.attributes, null); + } + }); + + test( + 'the length of the returned response less than the length of the selected text', + () async { + final document = buildTestDocument(); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position( + path: [0], + offset: 'The introduction of the World Wide Web'.length, + ), + end: Position( + path: [2], + offset: + 'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity' + .length, + ), + ); + + final markdownText = + ''' in the **early 1990s** marked a *significant turning point* in technological history.'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final afterNodes = editorState.document.root.children; + expect(afterNodes.length, 2); + + { + // first paragraph + final delta1 = afterNodes[0].delta!.toList(); + expect(delta1.length, 5); + + final d1 = delta1[0] as TextInsert; + expect(d1.text, "The introduction of the World Wide Web in the "); + expect(d1.attributes, null); + + final d2 = delta1[1] as TextInsert; + expect(d2.text, "early 1990s"); + expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); + + final d3 = delta1[2] as TextInsert; + expect(d3.text, " marked a "); + expect(d3.attributes, null); + + final d4 = delta1[3] as TextInsert; + expect(d4.text, "significant turning point"); + expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); + + final d5 = delta1[4] as TextInsert; + expect(d5.text, " in technological history."); + expect(d5.attributes, null); + } + + { + // second paragraph + final delta2 = afterNodes[1].delta!.toList(); + expect(delta2.length, 1); + + final d1 = delta2[0] as TextInsert; + expect(d1.text, ", allowing for real-time text communication."); + expect(d1.attributes, null); + } + }); + }); +} + +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 0000000000..0186d36c7d --- /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 0000000000..d2432557eb --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart @@ -0,0 +1,914 @@ +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' + hide quoteNode, QuoteBlockKeys; +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 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 BlockActionOptionCubit.turnIntoBlock( + type, + node, + editorState, + 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 BlockActionOptionCubit.turnIntoBlock( + originalType, + newNode, + editorState, + ); + 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, + ]) { + 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, + ]) { + 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, + ]) { + // 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, + 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 + // - 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 0000000000..b17588674c --- /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/transaction_adapter_test.dart b/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart index 93e27bc45b..9e60c13ed7 100644 --- a/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart @@ -1,10 +1,13 @@ +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', () { + group('TransactionAdapter:', () { test('toBlockAction insert node with children operation', () { final editorState = EditorState.blank(); @@ -148,5 +151,246 @@ void main() { 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; + }); + + test('text retain with attributes that are false', () async { + final node = paragraphNode( + delta: Delta() + ..insert( + 'Hello AppFlowy', + attributes: { + 'bold': true, + }, + ), + ); + final document = Document( + root: pageNode( + children: [ + node, + ], + ), + ); + final transactionAdapter = TransactionAdapter( + documentId: '', + documentService: DocumentService(), + ); + + final editorState = EditorState( + document: document, + ); + + int counter = 0; + 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); + if (counter == 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":"Hello","attributes":{"bold":null}},{"insert":" AppFlowy","attributes":{"bold":true}}]', + ), + ); + } + } else if (counter == 3) { + final textAction = textActions.first; + final textId = textAction.textDeltaPayloadPB?.textId; + { + expect(textAction.textDeltaType, TextDeltaType.update); + + expect(textId, isNotEmpty); + final delta = textAction.textDeltaPayloadPB?.delta; + expect( + delta, + equals( + '[{"retain":5,"attributes":{"bold":null}}]', + ), + ); + } + } + } else if (time == TransactionTime.after && counter == 3) { + completer.complete(); + } + }); + + counter = 1; + final insertTransaction = editorState.transaction; + insertTransaction.formatText(node, 0, 5, { + 'bold': false, + }); + + await editorState.apply(insertTransaction); + + counter = 2; + final updateTransaction = editorState.transaction; + updateTransaction.formatText(node, 0, 5, { + 'bold': true, + }); + await editorState.apply(updateTransaction); + + counter = 3; + final formatTransaction = editorState.transaction; + formatTransaction.formatText(node, 0, 5, { + 'bold': false, + }); + await editorState.apply(formatTransaction); + + await completer.future; + }); }); } diff --git a/frontend/appflowy_flutter/test/unit_test/image/appflowy_network_image_test.dart b/frontend/appflowy_flutter/test/unit_test/image/appflowy_network_image_test.dart new file mode 100644 index 0000000000..3c075126db --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/image/appflowy_network_image_test.dart @@ -0,0 +1,35 @@ +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AppFlowy Network Image:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test( + 'retry count should be clear if the value exceeds max retries', + () async { + const maxRetries = 5; + const fakeUrl = 'https://plus.unsplash.com/premium_photo-1731948132439'; + final retryCounter = FlowyNetworkRetryCounter(); + final tag = retryCounter.add(fakeUrl); + for (var i = 0; i < maxRetries; i++) { + retryCounter.increment(fakeUrl); + expect(retryCounter.getRetryCount(fakeUrl), i + 1); + } + retryCounter.clear( + tag: tag, + url: fakeUrl, + maxRetries: maxRetries, + ); + expect(retryCounter.getRetryCount(fakeUrl), 0); + }, + ); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart b/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart new file mode 100644 index 0000000000..5b6f88801a --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + test( + 'description', + () async { + final links = [ + 'https://www.baidu.com/', + 'https://appflowy.io/', + 'https://github.com/AppFlowy-IO/AppFlowy', + 'https://github.com/', + 'https://www.figma.com/design/3K0ai4FhDOJ3Lts8G3KOVP/Page?node-id=7282-4007&p=f&t=rpfvEvh9K9J9WkIo-0', + 'https://www.figma.com/files/drafts', + 'https://www.youtube.com/watch?v=LyY5Rh9qBvA', + 'https://www.youtube.com/', + 'https://www.youtube.com/watch?v=a6GDT7', + 'http://www.test.com/', + 'https://www.baidu.com/s?wd=test&rsv_spt=1&rsv_iqid=0xb6a7840b00e5324a&issp=1&f=8&rsv_bp=1&rsv_idx=2&ie=utf-8&tn=22073068_7_oem_dg&rsv_dl=tb&rsv_enter=1&rsv_sug3=5&rsv_sug1=4&rsv_sug7=100&rsv_sug2=0&rsv_btype=i&prefixsug=test&rsp=9&inputT=478&rsv_sug4=547', + 'https://www.google.com/', + 'https://www.google.com.hk/search?q=test&oq=test&gs_lcrp=EgZjaHJvbWUyCQgAEEUYORiABDIHCAEQABiABDIHCAIQABiABDIHCAMQABiABDIHCAQQABiABDIHCAUQABiABDIHCAYQABiABDIHCAcQABiABDIHCAgQLhiABDIHCAkQABiABNIBCTE4MDJqMGoxNagCCLACAfEFAQs7K9PprSfxBQELOyvT6a0n&sourceid=chrome&ie=UTF-8', + 'www.baidu.com', + 'baidu.com', + 'com', + 'https://www.baidu.com', + 'https://github.com/AppFlowy-IO/AppFlowy', + 'https://appflowy.com/app/c29fafc4-b7c0-4549-8702-71339b0fd9ea/59f36be8-9b2f-4d3e-b6a1-816c6c2043e5?blockId=GCY_T4', + ]; + + final parser = DefaultParser(); + int i = 1; + for (final link in links) { + final formatLink = LinkInfoParser.formatUrl(link); + final siteInfo = await parser + .parse(Uri.tryParse(formatLink) ?? Uri.parse(formatLink)); + if (siteInfo?.isEmpty() ?? true) { + debugPrint('$i : $formatLink ---- empty \n'); + } else { + debugPrint('$i : $formatLink ---- \n$siteInfo \n'); + } + i++; + } + }, + timeout: const Timeout(Duration(seconds: 120)), + ); +} 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 0000000000..707cc23d4f --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart @@ -0,0 +1,97 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +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/shared/markdown_to_document.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_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 = await customDocumentToMarkdown(document); + expect(markdown, '[file.txt](https://file.com)\n'); + }); + + test('link preview', () async { + final document = Document.blank() + ..insert( + [0], + [linkPreviewNode(url: 'https://www.link_preview.com')], + ); + final markdown = await customDocumentToMarkdown(document); + expect( + markdown, + '[https://www.link_preview.com](https://www.link_preview.com)\n', + ); + }); + + test('multiple images', () async { + const png1 = 'https://www.appflowy.png', + png2 = 'https://www.appflowy2.png'; + final document = Document.blank() + ..insert( + [0], + [ + multiImageNode( + images: [ + ImageBlockData( + url: png1, + type: CustomImageType.external, + ), + ImageBlockData( + url: png2, + type: CustomImageType.external, + ), + ], + ), + ], + ); + final markdown = await customDocumentToMarkdown(document); + expect( + markdown, + '![]($png1)\n![]($png2)', + ); + }); + + test('subpage block', () async { + const testSubpageId = 'testSubpageId'; + final subpageNode = pageMentionNode(testSubpageId); + final document = Document.blank() + ..insert( + [0], + [subpageNode], + ); + final markdown = await customDocumentToMarkdown(document); + expect( + markdown, + '[]($testSubpageId)\n', + ); + }); + + test('date or reminder', () async { + final dateTime = DateTime.now(); + final document = Document.blank() + ..insert( + [0], + [dateMentionNode()], + ); + final markdown = await customDocumentToMarkdown(document); + expect( + markdown, + '${DateFormat.yMMMd().format(dateTime)}\n', + ); + }); + }); +} 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 0000000000..646ce7fbac --- /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 0000000000..57b8319d06 --- /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 0000000000..cb28f955da --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_delete_operation_test.dart @@ -0,0 +1,236 @@ +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, + }); + }); + + test('delete a column with text color & bold style (1)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // delete the column 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); + await editorState.updateColumnTextColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + await editorState.toggleColumnBoldAttribute( + tableCellNode: tableCellNode, + isBold: true, + ); + expect(tableNode.columnTextColors, { + '1': '0xFF0000FF', + }); + expect(tableNode.columnBoldAttributes, { + '1': true, + }); + await editorState.deleteColumnInTable(tableNode, 0); + expect(tableNode.columnTextColors, { + '0': '0xFF0000FF', + }); + expect(tableNode.columnBoldAttributes, { + '0': true, + }); + expect(tableNode.rowLength, 3); + expect(tableNode.columnLength, 3); + }); + + test('delete a column with text color & bold style (2)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // delete the column 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); + await editorState.updateColumnTextColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + await editorState.toggleColumnBoldAttribute( + tableCellNode: tableCellNode, + isBold: true, + ); + expect(tableNode.columnTextColors, { + '1': '0xFF0000FF', + }); + expect(tableNode.columnBoldAttributes, { + '1': true, + }); + await editorState.deleteColumnInTable(tableNode, 1); + expect(tableNode.columnTextColors, {}); + expect(tableNode.columnBoldAttributes, {}); + expect(tableNode.rowLength, 3); + expect(tableNode.columnLength, 3); + }); + }); +} 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 0000000000..85a1c252c7 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_duplicate_operation_test.dart @@ -0,0 +1,229 @@ +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, + }); + }); + + test('duplicate a column with text color & bold style (1)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // duplicate the column 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); + await editorState.updateColumnTextColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + await editorState.toggleColumnBoldAttribute( + tableCellNode: tableCellNode, + isBold: true, + ); + expect(tableNode.columnTextColors, { + '1': '0xFF0000FF', + }); + expect(tableNode.columnBoldAttributes, { + '1': true, + }); + await editorState.duplicateColumnInTable(tableNode, 1); + expect(tableNode.columnTextColors, { + '1': '0xFF0000FF', + '2': '0xFF0000FF', + }); + expect(tableNode.columnBoldAttributes, { + '1': true, + '2': true, + }); + }); + + test('duplicate a column with text color & bold style (2)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // duplicate the column 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); + await editorState.updateColumnTextColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + await editorState.toggleColumnBoldAttribute( + tableCellNode: tableCellNode, + isBold: true, + ); + expect(tableNode.columnTextColors, { + '1': '0xFF0000FF', + }); + expect(tableNode.columnBoldAttributes, { + '1': true, + }); + await editorState.duplicateColumnInTable(tableNode, 0); + expect(tableNode.columnTextColors, { + '2': '0xFF0000FF', + }); + expect(tableNode.columnBoldAttributes, { + '2': true, + }); + }); + }); +} 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 0000000000..1f0707cc0d --- /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 0000000000..86c4236a03 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_insert_operation_test.dart @@ -0,0 +1,256 @@ +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, + }); + }); + + test('insert a column with text color & bold style (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.updateColumnTextColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + await editorState.toggleColumnBoldAttribute( + tableCellNode: tableCellNode, + isBold: true, + ); + expect(tableNode.columnTextColors, { + '0': '0xFF0000FF', + }); + expect(tableNode.columnBoldAttributes, { + '0': true, + }); + await editorState.insertColumnInTable(tableNode, 0); + expect(tableNode.columnTextColors, { + '1': '0xFF0000FF', + }); + expect(tableNode.columnBoldAttributes, { + '1': true, + }); + }); + + test('insert a column with text color & bold style (2)', () 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.updateColumnTextColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + await editorState.toggleColumnBoldAttribute( + tableCellNode: tableCellNode, + isBold: true, + ); + expect(tableNode.columnTextColors, { + '0': '0xFF0000FF', + }); + expect(tableNode.columnBoldAttributes, { + '0': true, + }); + await editorState.insertColumnInTable(tableNode, 1); + expect(tableNode.columnTextColors, { + '0': '0xFF0000FF', + }); + expect(tableNode.columnBoldAttributes, { + '0': true, + }); + }); + }); +} 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 0000000000..354e6bfa5e --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_markdown_test.dart @@ -0,0 +1,177 @@ +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)); + }); + + test('convert markdown to simple table (2)', () async { + final document = customMarkdownToDocument( + _sampleMarkdown1, + tableWidth: 200, + ); + expect(document, isNotNull); + final tableNode = document.nodeAtPath([0])!; + expect(tableNode, isNotNull); + expect(tableNode.type, equals(SimpleTableBlockKeys.type)); + expect(tableNode.columnWidths.length, 4); + for (final entry in tableNode.columnWidths.entries) { + expect(entry.value, equals(200)); + } + }); + }); +} + +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 0000000000..703a63b40d --- /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 0000000000..dd127d3d0b --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_style_operation_test.dart @@ -0,0 +1,238 @@ +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_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); + } + }); + + test('clear the existing align of the column before updating', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + + final firstCellNode = tableNode.getTableCellNode( + rowIndex: 0, + columnIndex: 0, + ); + + Node firstParagraphNode = firstCellNode!.children.first; + + // format the first paragraph to center align + final transaction = editorState.transaction; + transaction.updateNode( + firstParagraphNode, + { + blockComponentAlign: TableAlign.right.key, + }, + ); + await editorState.apply(transaction); + + firstParagraphNode = editorState.getNodeAtPath([0, 0, 0, 0])!; + expect( + firstParagraphNode.attributes[blockComponentAlign], + TableAlign.right.key, + ); + + await editorState.updateColumnAlign( + tableCellNode: firstCellNode, + align: TableAlign.center, + ); + + expect( + firstParagraphNode.attributes[blockComponentAlign], + null, + ); + }); + }); +} 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 0000000000..e190925bee --- /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/url_launcher/url_launcher_test.dart b/frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart new file mode 100644 index 0000000000..feac569127 --- /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 0000000000..a2326a3e33 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/util/recent_icons_test.dart @@ -0,0 +1,116 @@ +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; + }); + + bool equalIcon(RecentIcon a, RecentIcon b) => + a.groupName == b.groupName && + a.name == b.name && + a.keywords.equals(b.keywords) && + a.content == b.content; + + 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.map((e) => RecentIcon(e, e.name)).toList()); + } + + 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]), + ); + } + }); + + test('put without group name', () async { + RecentIcons.clear(); + 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.map((e) => RecentIcon(e, e.name)).toList()); + } + + await RecentIcons.putIcon(RecentIcon(localIcons.first.icon, '')); + icons = await RecentIcons.getIcons(); + assert(icons.isEmpty); + + await RecentIcons.putIcon( + RecentIcon(localIcons.first.icon, 'Test group name'), + ); + icons = await RecentIcons.getIcons(); + assert(icons.isNotEmpty); + }); +} 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 0000000000..ca4f2b8230 --- /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 index 65303cb789..3bb774411b 100644 --- a/frontend/appflowy_flutter/test/util.dart +++ b/frontend/appflowy_flutter/test/util.dart @@ -24,7 +24,7 @@ class AppFlowyUnitTest { _pathProviderInitialized(); await FlowyRunner.run( - AppFlowyApplicationUniTest(), + AppFlowyApplicationUnitTest(), IntegrationMode.unitTest, ); @@ -58,9 +58,8 @@ class AppFlowyUnitTest { } WorkspacePB get currentWorkspace => workspace; - Future _loadWorkspace() async { - final result = await userService.getCurrentWorkspace(); + final result = await UserBackendService.getCurrentWorkspace(); result.fold( (value) => workspace = value, (error) { @@ -70,7 +69,10 @@ class AppFlowyUnitTest { } Future _initialServices() async { - workspaceService = WorkspaceService(workspaceId: currentWorkspace.id); + workspaceService = WorkspaceService( + workspaceId: currentWorkspace.id, + userId: userProfile.id, + ); } Future createWorkspace() async { @@ -83,15 +85,6 @@ class AppFlowyUnitTest { (error) => throw Exception(error), ); } - - Future> loadApps() async { - final result = await workspaceService.getPublicViews(); - - return result.fold( - (apps) => apps, - (error) => throw Exception(error), - ); - } } void _pathProviderInitialized() { @@ -103,7 +96,7 @@ void _pathProviderInitialized() { }); } -class AppFlowyApplicationUniTest implements EntryPoint { +class AppFlowyApplicationUnitTest implements EntryPoint { @override Widget create(LaunchConfiguration config) { return const SizedBox.shrink(); 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 0000000000..4458d588cc --- /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 0000000000..8e9e916aa7 --- /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 0000000000..4d954d1724 --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/direction_setting_test.dart @@ -0,0 +1,159 @@ +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(); + registerFallbackValue(AppFlowyTextDirection.ltr); + }); + + 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/spae_cion_test.dart b/frontend/appflowy_flutter/test/widget_test/spae_cion_test.dart new file mode 100644 index 0000000000..491afdbf77 --- /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 0000000000..ddcabff61f --- /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 0000000000..2ebc61a8bc --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/test_material_app.dart @@ -0,0 +1,76 @@ +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, + toolbarHoverColor: 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 index d8639aa600..19c36a8b59 100644 --- 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 @@ -1,11 +1,12 @@ +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:appflowy/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.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_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; diff --git a/frontend/appflowy_flutter/windows/flutter/CMakeLists.txt b/frontend/appflowy_flutter/windows/flutter/CMakeLists.txt index 8ec917b8c3..c1a3afe639 100644 --- a/frontend/appflowy_flutter/windows/flutter/CMakeLists.txt +++ b/frontend/appflowy_flutter/windows/flutter/CMakeLists.txt @@ -9,6 +9,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # 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") @@ -92,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/frontend/appflowy_flutter/windows/runner/Runner.rc b/frontend/appflowy_flutter/windows/runner/Runner.rc index 77795cde10..3477dab755 100644 --- a/frontend/appflowy_flutter/windows/runner/Runner.rc +++ b/frontend/appflowy_flutter/windows/runner/Runner.rc @@ -119,3 +119,11 @@ END ///////////////////////////////////////////////////////////////////////////// #endif // not APSTUDIO_INVOKED + +///////////////////////////////////////////////////////////////////////////// +// +// WinSparkle +// + +// And verify signature using DSA public key: +DSAPub DSAPEM "../../dsa_pub.pem" \ No newline at end of file diff --git a/frontend/appflowy_flutter/windows/runner/flutter_window.cpp b/frontend/appflowy_flutter/windows/runner/flutter_window.cpp index b43b9095ea..955ee3038f 100644 --- a/frontend/appflowy_flutter/windows/runner/flutter_window.cpp +++ b/frontend/appflowy_flutter/windows/runner/flutter_window.cpp @@ -26,6 +26,16 @@ bool FlutterWindow::OnCreate() { } 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; } diff --git a/frontend/appflowy_flutter/windows/runner/main.cpp b/frontend/appflowy_flutter/windows/runner/main.cpp index 8def4fafbb..b1fff72b84 100644 --- a/frontend/appflowy_flutter/windows/runner/main.cpp +++ b/frontend/appflowy_flutter/windows/runner/main.cpp @@ -5,6 +5,9 @@ #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"); @@ -44,9 +47,12 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.CreateAndShow(L"AppFlowy", origin, size)) { + + if (!window.Create(L"AppFlowy", origin, size)) { return EXIT_FAILURE; } + + window.Show(); window.SetQuitOnClose(true); ::MSG msg; diff --git a/frontend/appflowy_flutter/windows/runner/win32_window.cpp b/frontend/appflowy_flutter/windows/runner/win32_window.cpp index a46adb6af5..2f78196d35 100644 --- a/frontend/appflowy_flutter/windows/runner/win32_window.cpp +++ b/frontend/appflowy_flutter/windows/runner/win32_window.cpp @@ -1,60 +1,70 @@ #include "win32_window.h" +#include #include #include "resource.h" #include "app_links/app_links_plugin_c_api.h" -namespace -{ +namespace { - constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; +/// 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 - // The number of Win32Window objects that currently exist. - static int g_active_window_count = 0; +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); +/// 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"; - // 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); +// 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; } - - // 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); - } + 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 +} // namespace // Manages the Win32Window's window class registration. -class WindowClassRegistrar -{ -public: +class WindowClassRegistrar { + public: ~WindowClassRegistrar() = default; - // Returns the singleton registar instance. - static WindowClassRegistrar *GetInstance() - { - if (!instance_) - { + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { instance_ = new WindowClassRegistrar(); } return instance_; @@ -62,26 +72,24 @@ public: // Returns the name of the window class, registering the class if it hasn't // previously been registered. - const wchar_t *GetWindowClass(); + const wchar_t* GetWindowClass(); // Unregisters the window class. Should only be called if there are no // instances of the window. void UnregisterWindowClass(); -private: + private: WindowClassRegistrar() = default; - static WindowClassRegistrar *instance_; + static WindowClassRegistrar* instance_; bool class_registered_ = false; }; -WindowClassRegistrar *WindowClassRegistrar::instance_ = nullptr; +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; -const wchar_t *WindowClassRegistrar::GetWindowClass() -{ - if (!class_registered_) - { +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { WNDCLASS window_class{}; window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); window_class.lpszClassName = kWindowClassName; @@ -100,35 +108,31 @@ const wchar_t *WindowClassRegistrar::GetWindowClass() return kWindowClassName; } -void WindowClassRegistrar::UnregisterWindowClass() -{ +void WindowClassRegistrar::UnregisterWindowClass() { UnregisterClass(kWindowClassName, nullptr); class_registered_ = false; } -Win32Window::Win32Window() -{ +Win32Window::Win32Window() { ++g_active_window_count; } -Win32Window::~Win32Window() -{ +Win32Window::~Win32Window() { --g_active_window_count; Destroy(); } -bool Win32Window::CreateAndShow(const std::wstring &title, - const Point &origin, - const Size &size) -{ +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + if (SendAppLinkToInstance(title)) { return false; } - Destroy(); - - const wchar_t *window_class = + const wchar_t* window_class = WindowClassRegistrar::GetInstance()->GetWindowClass(); const POINT target_point = {static_cast(origin.x), @@ -138,19 +142,158 @@ bool Win32Window::CreateAndShow(const std::wstring &title, double scale_factor = dpi / 96.0; HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + 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) - { + 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 @@ -186,140 +329,4 @@ bool Win32Window::SendAppLinkToInstance(const std::wstring &title) } return false; -} - -// 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. -} +} \ 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 index 4d717b053d..fae0d8a741 100644 --- a/frontend/appflowy_flutter/windows/runner/win32_window.h +++ b/frontend/appflowy_flutter/windows/runner/win32_window.h @@ -10,18 +10,15 @@ // 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 - { +class Win32Window { + public: + struct Point { unsigned int x; unsigned int y; Point(unsigned int x, unsigned int y) : x(x), y(y) {} }; - struct Size - { + struct Size { unsigned int width; unsigned int height; Size(unsigned int width, unsigned int height) @@ -31,19 +28,16 @@ public: Win32Window(); virtual ~Win32Window(); - // Creates and shows a win32 window with |title| and position and size using + // 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 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); + // 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); - // Dispatches link if any. - // This method enables our app to be with a single instance too. - bool SendAppLinkToInstance(const std::wstring &title); + // Show the current window. Returns true if the window was successfully shown. + bool Show(); // Release OS resources associated with window. void Destroy(); @@ -61,7 +55,11 @@ public: // Return a RECT representing the bounds of the current client area. RECT GetClientArea(); -protected: + // 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. @@ -77,13 +75,13 @@ protected: // Called when Destroy is called. virtual void OnDestroy(); -private: + 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 + // responds to changes in DPI. All other messages are handled by // MessageHandler. static LRESULT CALLBACK WndProc(HWND const window, UINT const message, @@ -91,7 +89,10 @@ private: LPARAM const lparam) noexcept; // Retrieves a class instance pointer for |window| - static Win32Window *GetThisFromHandle(HWND const window) noexcept; + 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; @@ -102,4 +103,4 @@ private: HWND child_content_ = nullptr; }; -#endif // RUNNER_WIN32_WINDOW_H_ +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/frontend/appflowy_tauri/.eslintignore b/frontend/appflowy_tauri/.eslintignore deleted file mode 100644 index e0ff674834..0000000000 --- a/frontend/appflowy_tauri/.eslintignore +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index a1160f0bd3..0000000000 --- a/frontend/appflowy_tauri/.eslintrc.cjs +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 32a3d59bc2..0000000000 --- a/frontend/appflowy_tauri/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# 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 deleted file mode 100644 index d515c1c2f2..0000000000 --- a/frontend/appflowy_tauri/.prettierignore +++ /dev/null @@ -1,19 +0,0 @@ -.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 deleted file mode 100644 index f283db53a2..0000000000 --- a/frontend/appflowy_tauri/.prettierrc.cjs +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 24d7cc6de8..0000000000 --- a/frontend/appflowy_tauri/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] -} diff --git a/frontend/appflowy_tauri/README.md b/frontend/appflowy_tauri/README.md deleted file mode 100644 index 102e366893..0000000000 --- a/frontend/appflowy_tauri/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# 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 deleted file mode 100644 index 4983fb648b..0000000000 --- a/frontend/appflowy_tauri/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - AppFlowy: The Open Source Alternative To Notion - - - -
- - - diff --git a/frontend/appflowy_tauri/jest.config.cjs b/frontend/appflowy_tauri/jest.config.cjs deleted file mode 100644 index 4939478165..0000000000 --- a/frontend/appflowy_tauri/jest.config.cjs +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 30c7978771..0000000000 --- a/frontend/appflowy_tauri/package.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index d670b8b312..0000000000 --- a/frontend/appflowy_tauri/pnpm-lock.yaml +++ /dev/null @@ -1,7264 +0,0 @@ -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 deleted file mode 100644 index 12a703d900..0000000000 --- a/frontend/appflowy_tauri/postcss.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 246c977c9f..0000000000 --- a/frontend/appflowy_tauri/public/google_fonts/Poppins/OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 71c0f995ee..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Black.ttf and /dev/null 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 deleted file mode 100644 index 7aeb58bd1b..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BlackItalic.ttf and /dev/null 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 deleted file mode 100644 index 00559eeb29..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Bold.ttf and /dev/null 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 deleted file mode 100644 index e61e8e88bd..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BoldItalic.ttf and /dev/null 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 deleted file mode 100644 index df7093608a..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBold.ttf and /dev/null 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 deleted file mode 100644 index 14d2b375dc..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf and /dev/null 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 deleted file mode 100644 index e76ec69a65..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLight.ttf and /dev/null 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 deleted file mode 100644 index 89513d9469..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf and /dev/null 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 deleted file mode 100644 index 12b7b3c40b..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Italic.ttf and /dev/null 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 deleted file mode 100644 index bc36bcc242..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Light.ttf and /dev/null 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 deleted file mode 100644 index 9e70be6a9e..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-LightItalic.ttf and /dev/null 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 deleted file mode 100644 index 6bcdcc27f2..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Medium.ttf and /dev/null 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 deleted file mode 100644 index be67410fd0..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-MediumItalic.ttf and /dev/null 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 deleted file mode 100644 index 9f0c71b70a..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Regular.ttf and /dev/null 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 deleted file mode 100644 index 74c726e327..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBold.ttf and /dev/null 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 deleted file mode 100644 index 3e6c942233..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf and /dev/null 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 deleted file mode 100644 index 03e736613a..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Thin.ttf and /dev/null 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 deleted file mode 100644 index e26db5dd3d..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ThinItalic.ttf and /dev/null 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 deleted file mode 100644 index 75b52484ea..0000000000 --- a/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - 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 deleted file mode 100644 index 61e5303325..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf and /dev/null 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 deleted file mode 100644 index 6df2b25360..0000000000 Binary files a/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf and /dev/null differ diff --git a/frontend/appflowy_tauri/public/launch_splash.jpg b/frontend/appflowy_tauri/public/launch_splash.jpg deleted file mode 100644 index 7e3bb9cee6..0000000000 Binary files a/frontend/appflowy_tauri/public/launch_splash.jpg and /dev/null differ diff --git a/frontend/appflowy_tauri/public/tauri.svg b/frontend/appflowy_tauri/public/tauri.svg deleted file mode 100644 index 31b62c9280..0000000000 --- a/frontend/appflowy_tauri/public/tauri.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_tauri/public/vite.svg b/frontend/appflowy_tauri/public/vite.svg deleted file mode 100644 index e7b8dfb1b2..0000000000 --- a/frontend/appflowy_tauri/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/appflowy_tauri/scripts/i18n/index.cjs b/frontend/appflowy_tauri/scripts/i18n/index.cjs deleted file mode 100644 index c3789e0c56..0000000000 --- a/frontend/appflowy_tauri/scripts/i18n/index.cjs +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 498b8c3e4f..0000000000 --- a/frontend/appflowy_tauri/scripts/update_version.cjs +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index bff29e6e17..0000000000 --- a/frontend/appflowy_tauri/src-tauri/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -rustflags = ["--cfg", "tokio_unstable"] diff --git a/frontend/appflowy_tauri/src-tauri/.gitignore b/frontend/appflowy_tauri/src-tauri/.gitignore deleted file mode 100644 index 61e1bdd46a..0000000000 --- a/frontend/appflowy_tauri/src-tauri/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# 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 deleted file mode 100644 index 1da9cd7027..0000000000 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ /dev/null @@ -1,8072 +0,0 @@ -# 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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" -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 = "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.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" - -[[package]] -name = "app-error" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "bincode", - "getrandom 0.2.10", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "tokio", - "tsify", - "url", - "uuid", - "wasm-bindgen", -] - -[[package]] -name = "appflowy_tauri" -version = "0.0.0" -dependencies = [ - "bytes", - "dotenv", - "flowy-config", - "flowy-core", - "flowy-date", - "flowy-document", - "flowy-error", - "flowy-notification", - "flowy-search", - "flowy-user", - "lib-dispatch", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-deep-link", - "tauri-utils", - "tracing", - "uuid", -] - -[[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-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.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.47", -] - -[[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_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 = "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.65.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" -dependencies = [ - "bitflags 1.3.2", - "cexpr", - "clang-sys", - "lazy_static", - "lazycell", - "peeking_take_while", - "prettyplease", - "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.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c7d2ac73c167c06af4a5f37e6e59d84148d57ccbe4480b76f0273eefea82d7" -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 = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4114279215a005bc675e386011e594e1d9b800918cea18fcadadcce864a2046b" -dependencies = [ - "borsh-derive", - "hashbrown 0.12.3", -] - -[[package]] -name = "borsh-derive" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0754613691538d51f329cce9af41d7b7ca150bc973056f1156611489475f54f7" -dependencies = [ - "borsh-derive-internal", - "borsh-schema-derive-internal", - "proc-macro-crate 0.1.5", - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "borsh-derive-internal" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afb438156919598d2c7bad7e1c0adf3d26ed3840dbc010db1a882a65583ca2fb" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "borsh-schema-derive-internal" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634205cc43f74a1b9046ef87c4540ebda95696ec0f315024860cad7c5b0f5ccd" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[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.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" - -[[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.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" -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 = "chrono" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f13690e35a5e4ace198e7beea2895d29f3a9cc55015fcebe6336bd2010af9eb" -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-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 = "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "again", - "anyhow", - "app-error", - "async-trait", - "bincode", - "brotli", - "bytes", - "chrono", - "client-websocket", - "collab", - "collab-entity", - "collab-rt-entity", - "collab-rt-protocol", - "database-entity", - "futures-core", - "futures-util", - "getrandom 0.2.10", - "gotrue", - "gotrue-entity", - "mime", - "parking_lot 0.12.1", - "prost", - "reqwest", - "scraper 0.17.1", - "semver", - "serde", - "serde_json", - "serde_repr", - "shared-entity", - "thiserror", - "tokio", - "tokio-retry", - "tokio-stream", - "tokio-util", - "tracing", - "url", - "uuid", - "wasm-bindgen-futures", - "yrs", -] - -[[package]] -name = "client-websocket" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -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=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "async-trait", - "bincode", - "bytes", - "chrono", - "js-sys", - "parking_lot 0.12.1", - "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=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "async-trait", - "chrono", - "collab", - "collab-entity", - "collab-plugins", - "dashmap", - "getrandom 0.2.10", - "js-sys", - "lazy_static", - "nanoid", - "parking_lot 0.12.1", - "rayon", - "serde", - "serde_json", - "serde_repr", - "strum", - "strum_macros 0.25.2", - "thiserror", - "tokio", - "tokio-stream", - "tracing", - "uuid", -] - -[[package]] -name = "collab-document" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "getrandom 0.2.10", - "nanoid", - "parking_lot 0.12.1", - "serde", - "serde_json", - "thiserror", - "tokio", - "tokio-stream", - "tracing", -] - -[[package]] -name = "collab-entity" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "bytes", - "collab", - "getrandom 0.2.10", - "serde", - "serde_json", - "serde_repr", - "uuid", -] - -[[package]] -name = "collab-folder" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "chrono", - "collab", - "collab-entity", - "getrandom 0.2.10", - "parking_lot 0.12.1", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "tokio", - "tokio-stream", - "tracing", -] - -[[package]] -name = "collab-integrate" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "collab", - "collab-entity", - "collab-plugins", - "futures", - "lib-infra", - "parking_lot 0.12.1", - "serde", - "serde_json", - "tokio", - "tracing", -] - -[[package]] -name = "collab-plugins" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -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", - "parking_lot 0.12.1", - "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "bincode", - "bytes", - "chrono", - "client-websocket", - "collab", - "collab-entity", - "collab-rt-protocol", - "database-entity", - "prost", - "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -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=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "getrandom 0.2.10", - "parking_lot 0.12.1", - "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 = "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 = "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 = "crc32fast" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" -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.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" -dependencies = [ - "cfg-if", -] - -[[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.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626ae34994d3d8d668f4269922248239db4ae42d538b14c398b74a52208e8086" -dependencies = [ - "csv-core", - "itoa 1.0.6", - "ryu", - "serde", -] - -[[package]] -name = "csv-core" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" -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 = "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "app-error", - "bincode", - "chrono", - "collab-entity", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "tracing", - "uuid", - "validator", -] - -[[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 = "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 = "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_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", - "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 = "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 = "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_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.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cbc844cecaee9d4443931972e1289c8ff485cb4cc2767cb03ca139ed6885153" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.2.16", - "windows-sys 0.48.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.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" -dependencies = [ - "crc32fast", - "miniz_oxide 0.7.1", -] - -[[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", - "base64 0.21.5", - "bytes", - "client-api", - "collab", - "collab-entity", - "collab-integrate", - "collab-plugins", - "diesel", - "flowy-config", - "flowy-database-pub", - "flowy-database2", - "flowy-date", - "flowy-document", - "flowy-document-pub", - "flowy-error", - "flowy-folder", - "flowy-folder-pub", - "flowy-search", - "flowy-server", - "flowy-server-pub", - "flowy-sqlite", - "flowy-storage", - "flowy-user", - "flowy-user-pub", - "futures", - "futures-core", - "lib-dispatch", - "lib-infra", - "lib-log", - "parking_lot 0.12.1", - "semver", - "serde", - "serde_json", - "serde_repr", - "sysinfo", - "tokio", - "tokio-stream", - "tracing", - "uuid", - "walkdir", -] - -[[package]] -name = "flowy-database-pub" -version = "0.1.0" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "lib-infra", -] - -[[package]] -name = "flowy-database2" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-stream", - "async-trait", - "bytes", - "chrono", - "chrono-tz 0.8.2", - "collab", - "collab-database", - "collab-entity", - "collab-integrate", - "collab-plugins", - "csv", - "dashmap", - "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", - "nanoid", - "parking_lot 0.12.1", - "protobuf", - "rayon", - "rust_decimal", - "rusty-money", - "serde", - "serde_json", - "serde_repr", - "strum", - "strum_macros 0.25.2", - "tokio", - "tracing", - "url", - "validator", -] - -[[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", - "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", - "flowy-codegen", - "flowy-derive", - "flowy-document-pub", - "flowy-error", - "flowy-notification", - "flowy-storage", - "futures", - "getrandom 0.2.10", - "indexmap 2.1.0", - "lib-dispatch", - "lib-infra", - "nanoid", - "parking_lot 0.12.1", - "protobuf", - "scraper 0.18.1", - "serde", - "serde_json", - "strum_macros 0.21.1", - "tokio", - "tokio-stream", - "tracing", - "uuid", - "validator", -] - -[[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-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", -] - -[[package]] -name = "flowy-folder" -version = "0.1.0" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "collab", - "collab-document", - "collab-entity", - "collab-folder", - "collab-integrate", - "collab-plugins", - "flowy-codegen", - "flowy-derive", - "flowy-error", - "flowy-folder-pub", - "flowy-notification", - "flowy-search-pub", - "lazy_static", - "lib-dispatch", - "lib-infra", - "nanoid", - "parking_lot 0.12.1", - "protobuf", - "serde_json", - "strum_macros 0.21.1", - "tokio", - "tokio-stream", - "tracing", - "unicode-segmentation", - "uuid", - "validator", -] - -[[package]] -name = "flowy-folder-pub" -version = "0.1.0" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "collab-folder", - "lib-infra", - "uuid", -] - -[[package]] -name = "flowy-notification" -version = "0.1.0" -dependencies = [ - "bytes", - "dashmap", - "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-notification", - "flowy-search-pub", - "flowy-sqlite", - "flowy-user", - "futures", - "lib-dispatch", - "protobuf", - "serde", - "serde_json", - "strsim 0.11.0", - "strum_macros 0.26.1", - "tantivy", - "tempfile", - "tokio", - "tracing", - "validator", -] - -[[package]] -name = "flowy-search-pub" -version = "0.1.0" -dependencies = [ - "collab", - "collab-folder", - "flowy-error", -] - -[[package]] -name = "flowy-server" -version = "0.1.0" -dependencies = [ - "anyhow", - "bytes", - "chrono", - "client-api", - "collab", - "collab-document", - "collab-entity", - "collab-folder", - "collab-plugins", - "flowy-database-pub", - "flowy-document-pub", - "flowy-encrypt", - "flowy-error", - "flowy-folder-pub", - "flowy-server-pub", - "flowy-storage", - "flowy-user-pub", - "futures", - "futures-util", - "hex", - "hyper", - "lazy_static", - "lib-dispatch", - "lib-infra", - "mime_guess", - "parking_lot 0.12.1", - "postgrest", - "rand 0.8.5", - "reqwest", - "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", - "parking_lot 0.12.1", - "r2d2", - "scheduled-thread-pool", - "serde", - "serde_json", - "thiserror", - "tracing", -] - -[[package]] -name = "flowy-storage" -version = "0.1.0" -dependencies = [ - "async-trait", - "bytes", - "flowy-error", - "fxhash", - "lib-infra", - "mime", - "mime_guess", - "reqwest", - "serde", - "serde_json", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "flowy-user" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64 0.21.5", - "bytes", - "chrono", - "collab", - "collab-database", - "collab-document", - "collab-entity", - "collab-folder", - "collab-integrate", - "collab-plugins", - "collab-user", - "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", - "parking_lot 0.12.1", - "protobuf", - "semver", - "serde", - "serde_json", - "serde_repr", - "strum", - "strum_macros 0.25.2", - "tokio", - "tokio-stream", - "tracing", - "unicode-segmentation", - "uuid", - "validator", -] - -[[package]] -name = "flowy-user-pub" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64 0.21.5", - "chrono", - "collab", - "collab-entity", - "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.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eeb4ed9e12f43b7fa0baae3f9cdda28352770132ef2e09a23760c29cae8bd47" -dependencies = [ - "rustix", - "windows-sys 0.48.0", -] - -[[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-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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -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.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "windows 0.48.0", -] - -[[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 = "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.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" -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 = "if_chain" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" - -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cc2083760572ee02385ab8b7c02c20925d2dd1f97a1a25a8737a238608f1152" -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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "reqwest", - "serde", - "serde_json", - "tracing", -] - -[[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.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -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 = "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", - "parking_lot 0.12.1", - "pin-project", - "protobuf", - "serde", - "serde_json", - "serde_repr", - "thread-id", - "tokio", - "tracing", - "validator", - "wasm-bindgen", - "wasm-bindgen-futures", -] - -[[package]] -name = "lib-infra" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "atomic_refcell", - "bytes", - "chrono", - "futures-core", - "md5", - "pin-project", - "tempfile", - "tokio", - "tracing", - "validator", - "walkdir", - "zip", -] - -[[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.11.0+8.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e" -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 = "lock_api" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" - -[[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.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a83fb7698b3643a0e34f9ae6f2e8f0178c0fd42f8b59d493aa271ff3a5bf21" -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 = "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 = "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", -] - -[[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.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" - -[[package]] -name = "memmap2" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49388d20533534cd19360ad3d6a7dadc885944aa802ba3995040c5ec11288c6" -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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" -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", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - -[[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 = "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-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", -] - -[[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.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" - -[[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.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e8a72b918ae8198abb3a18c190288123e1d442b6b9a7d709305fd194688b4b7" -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_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 = "peeking_take_while" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" - -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" -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 = "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 = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" -dependencies = [ - "toml 0.5.11", -] - -[[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", -] - -[[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-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", -] - -[[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", - "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-types" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" -dependencies = [ - "prost", -] - -[[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 = "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_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-window-handle" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" - -[[package]] -name = "rayon" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4963ed1bc86e4f3ee217022bd855b297cef07fb9eac5dfa1f788b220b49b3bd" -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.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0ab3ca65655bb1e41f2a8c8cd662eb4fb035e67c3f78da1d61dffe89d07300f" -dependencies = [ - "aho-corasick 1.0.2", - "memchr", - "regex-syntax 0.7.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-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" - -[[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.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe" -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.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0446843641c69436765a35a5a77088e28c2e6a12da93e84aa3ab1cd4aa5a042" -dependencies = [ - "arrayvec", - "borsh", - "bytecheck", - "byteorder", - "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 = "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.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" -dependencies = [ - "serde", -] - -[[package]] -name = "serde" -version = "1.0.195" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.195" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" -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.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" -dependencies = [ - "itoa 1.0.6", - "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.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" -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.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" -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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "app-error", - "chrono", - "collab-entity", - "database-entity", - "gotrue-entity", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "uuid", -] - -[[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.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "238abfbb77c1915110ad968465608b68e869e0772622c9656714e73e5a1a522f" - -[[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 = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[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 = "tantivy" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6083cd777fa94271b8ce0fe4533772cb8110c3044bab048d20f70108329a1f2" -dependencies = [ - "aho-corasick 1.0.2", - "arc-swap", - "async-trait", - "base64 0.21.5", - "bitpacking", - "byteorder", - "census", - "crc32fast", - "crossbeam-channel", - "downcast-rs", - "fastdivide", - "fs4", - "htmlescape", - "itertools 0.11.0", - "levenshtein_automata", - "log", - "lru", - "lz4_flex", - "measure_time", - "memmap2", - "murmurhash32", - "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.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecb164321482301f514dd582264fa67f70da2d7eb01872ccd71e35e0d96655a" -dependencies = [ - "bitpacking", -] - -[[package]] -name = "tantivy-columnar" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d85f8019af9a78b3118c11298b36ffd21c2314bd76bbcd9d12e00124cbb7e70" -dependencies = [ - "fastdivide", - "fnv", - "itertools 0.11.0", - "serde", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-sstable", - "tantivy-stacker", -] - -[[package]] -name = "tantivy-common" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4a3a975e604a2aba6b1106a04505e1e7a025e6def477fab6e410b4126471e1" -dependencies = [ - "async-trait", - "byteorder", - "ownedbytes", - "serde", - "time", -] - -[[package]] -name = "tantivy-fst" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3c506b1a8443a3a65352df6382a1fb6a7afe1a02e871cee0d25e2c3d5f3944" -dependencies = [ - "byteorder", - "regex-syntax 0.6.29", - "utf8-ranges", -] - -[[package]] -name = "tantivy-query-grammar" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d39c5a03100ac10c96e0c8b07538e2ab8b17da56434ab348309b31f23fada77" -dependencies = [ - "nom", -] - -[[package]] -name = "tantivy-sstable" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0c1bb43e5e8b8e05eb8009610344dbf285f06066c844032fbb3e546b3c71df" -dependencies = [ - "tantivy-common", - "tantivy-fst", - "zstd 0.12.4", -] - -[[package]] -name = "tantivy-stacker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2c078595413f13f218cf6f97b23dcfd48936838f1d3d13a1016e05acd64ed6c" -dependencies = [ - "murmurhash32", - "tantivy-common", -] - -[[package]] -name = "tantivy-tokenizer-api" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "347b6fb212b26d3505d224f438e3c4b827ab8bd847fe9953ad5ac6b8f9443b66" -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", -] - -[[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.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" -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.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea9e1b3cf1243ae005d9e74085d4d542f3125458f3a81af210d901dcd7411efd" -dependencies = [ - "itoa 1.0.6", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" - -[[package]] -name = "time-macros" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372950940a5f07bf38dbe211d7283c9e6d7327df53794992d293e534c733d09b" -dependencies = [ - "time-core", -] - -[[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.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" -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.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" -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.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" -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.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", - "tracing", -] - -[[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", -] - -[[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", -] - -[[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.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" -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.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.47", -] - -[[package]] -name = "tracing-bunyan-formatter" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c266b9ac83dedf0e0385ad78514949e6d89491269e7065bee51d2bb8ec7373" -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.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" -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.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" -dependencies = [ - "serde", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" -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 = "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-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 = "utf8-ranges" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" - -[[package]] -name = "uuid" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" -dependencies = [ - "getrandom 0.2.10", - "serde", - "sha1_smol", - "wasm-bindgen", -] - -[[package]] -name = "validator" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" -dependencies = [ - "idna 0.4.0", - "lazy_static", - "regex", - "serde", - "serde_derive", - "serde_json", - "url", - "validator_derive", -] - -[[package]] -name = "validator_derive" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc44ca3088bb3ba384d9aecf40c6a23a676ce23e09bdaca2073d99c207f864af" -dependencies = [ - "if_chain", - "lazy_static", - "proc-macro-error", - "proc-macro2", - "quote", - "regex", - "syn 1.0.109", - "validator_types", -] - -[[package]] -name = "validator_types" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111abfe30072511849c5910134e8baf8dc05de4c0e5903d681cbd5c9c4d611e3" -dependencies = [ - "proc-macro2", - "syn 1.0.109", -] - -[[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.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" -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 = "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 = "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 = "yrs" -version = "0.18.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d58fbc807677598fedfab76f99f6e1aa5c644411255002b5438ea0ab14672398" -dependencies = [ - "arc-swap", - "atomic_refcell", - "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 = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "aes", - "byteorder", - "bzip2", - "constant_time_eq", - "crc32fast", - "crossbeam-utils", - "flate2", - "hmac", - "pbkdf2 0.11.0", - "sha1", - "time", - "zstd 0.11.2+zstd.1.5.2", -] - -[[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.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a27595e173641171fc74a1232b7b1c7a7cb6e18222c11e9dfb9888fa424c53c" -dependencies = [ - "zstd-safe 6.0.6", -] - -[[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 = "6.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee98ffd0b48ee95e6c5168188e44a54550b1564d9d530ee21d5f0eaed1069581" -dependencies = [ - "libc", - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.8+zstd.1.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5556e6ee25d32df2586c098bbfa278803692a20d0ab9565e049480d52707ec8c" -dependencies = [ - "cc", - "libc", - "pkg-config", -] diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml deleted file mode 100644 index a46ae6084e..0000000000 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ /dev/null @@ -1,104 +0,0 @@ -[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"] } -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.18.7" -# 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 = "870cd70" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } - -# 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 = "ef8e6f3" } - -[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-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" - -[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"] diff --git a/frontend/appflowy_tauri/src-tauri/Info.plist b/frontend/appflowy_tauri/src-tauri/Info.plist deleted file mode 100644 index 25b430c049..0000000000 --- a/frontend/appflowy_tauri/src-tauri/Info.plist +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - 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 deleted file mode 100644 index d860e1e6a7..0000000000 --- a/frontend/appflowy_tauri/src-tauri/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tauri_build::build() -} diff --git a/frontend/appflowy_tauri/src-tauri/env.development b/frontend/appflowy_tauri/src-tauri/env.development deleted file mode 100644 index 188835e3d0..0000000000 --- a/frontend/appflowy_tauri/src-tauri/env.development +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index b03c328b84..0000000000 --- a/frontend/appflowy_tauri/src-tauri/env.production +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 3a51041313..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/128x128.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/128x128@2x.png b/frontend/appflowy_tauri/src-tauri/icons/128x128@2x.png deleted file mode 100644 index 9076de3a4b..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/128x128@2x.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/32x32.png b/frontend/appflowy_tauri/src-tauri/icons/32x32.png deleted file mode 100644 index 6ae6683fef..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/32x32.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square107x107Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square107x107Logo.png deleted file mode 100644 index b08dcf7d21..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/Square107x107Logo.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square142x142Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square142x142Logo.png deleted file mode 100644 index f3e437b76e..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/Square142x142Logo.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square150x150Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square150x150Logo.png deleted file mode 100644 index 6a1dc04864..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/Square150x150Logo.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square284x284Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square284x284Logo.png deleted file mode 100644 index 2f2d9d6fe6..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/Square284x284Logo.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square30x30Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square30x30Logo.png deleted file mode 100644 index 46e3802c0b..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/Square30x30Logo.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square310x310Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square310x310Logo.png deleted file mode 100644 index 230b1abe58..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/Square310x310Logo.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square44x44Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square44x44Logo.png deleted file mode 100644 index ad188037a3..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/Square44x44Logo.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square71x71Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square71x71Logo.png deleted file mode 100644 index ceae9ad1bb..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/Square71x71Logo.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square89x89Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square89x89Logo.png deleted file mode 100644 index 123dcea650..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/Square89x89Logo.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/StoreLogo.png b/frontend/appflowy_tauri/src-tauri/icons/StoreLogo.png deleted file mode 100644 index d7906c3c03..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/StoreLogo.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/icon.icns b/frontend/appflowy_tauri/src-tauri/icons/icon.icns deleted file mode 100644 index 74b585f25d..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/icon.icns and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/icon.ico b/frontend/appflowy_tauri/src-tauri/icons/icon.ico deleted file mode 100644 index cd9ad402d1..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/icon.ico and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/icon.png b/frontend/appflowy_tauri/src-tauri/icons/icon.png deleted file mode 100644 index 7cc3853d67..0000000000 Binary files a/frontend/appflowy_tauri/src-tauri/icons/icon.png and /dev/null differ diff --git a/frontend/appflowy_tauri/src-tauri/rust-toolchain.toml b/frontend/appflowy_tauri/src-tauri/rust-toolchain.toml deleted file mode 100644 index 6f14058b2e..0000000000 --- a/frontend/appflowy_tauri/src-tauri/rust-toolchain.toml +++ /dev/null @@ -1,2 +0,0 @@ -[toolchain] -channel = "1.77.2" diff --git a/frontend/appflowy_tauri/src-tauri/rustfmt.toml b/frontend/appflowy_tauri/src-tauri/rustfmt.toml deleted file mode 100644 index 5cb0d67ee5..0000000000 --- a/frontend/appflowy_tauri/src-tauri/rustfmt.toml +++ /dev/null @@ -1,12 +0,0 @@ -# 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 deleted file mode 100644 index 1702c02923..0000000000 --- a/frontend/appflowy_tauri/src-tauri/src/init.rs +++ /dev/null @@ -1,65 +0,0 @@ -use flowy_core::config::AppFlowyCoreConfig; -use flowy_core::{AppFlowyCore, DEFAULT_NAME}; -use lib_dispatch::runtime::AFPluginRuntime; -use std::sync::Arc; - -use dotenv::dotenv; - -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_flowy_core() -> AppFlowyCore { - 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.0.0".to_string()); - 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 { AppFlowyCore::new(config, cloned_runtime, None).await }) -} diff --git a/frontend/appflowy_tauri/src-tauri/src/main.rs b/frontend/appflowy_tauri/src-tauri/src/main.rs deleted file mode 100644 index 6a69de07fd..0000000000 --- a/frontend/appflowy_tauri/src-tauri/src/main.rs +++ /dev/null @@ -1,71 +0,0 @@ -#![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_flowy_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 deleted file mode 100644 index b42541edec..0000000000 --- a/frontend/appflowy_tauri/src-tauri/src/notification.rs +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 029e71c18c..0000000000 --- a/frontend/appflowy_tauri/src-tauri/src/request.rs +++ /dev/null @@ -1,45 +0,0 @@ -use flowy_core::AppFlowyCore; -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.inner().dispatcher(); - let response = AFPluginDispatcher::async_send(dispatcher.as_ref(), request).await; - response.into() -} diff --git a/frontend/appflowy_tauri/src-tauri/tauri.conf.json b/frontend/appflowy_tauri/src-tauri/tauri.conf.json deleted file mode 100644 index 11dd7c206c..0000000000 --- a/frontend/appflowy_tauri/src-tauri/tauri.conf.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "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 deleted file mode 100644 index 6adbb4a512..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/@types/i18next.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 479f05f013..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/@types/resources.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 9381737341..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/App.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 9c46b8ab38..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 76bdb167b0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index c5c94daebc..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_listeners.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 950f5becb3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index f36f68ad8b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_types.ts +++ /dev/null @@ -1,163 +0,0 @@ -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 deleted file mode 100644 index bc6bdc4417..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 74ebfb1df0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 627cd94013..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_types.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index e656d98287..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/database/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 87d99d9b75..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/database_view/database_view_service.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index b2a6e1a5f1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/database_view/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index ef36daa20c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_listeners.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 219aeb3ea5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_service.ts +++ /dev/null @@ -1,215 +0,0 @@ -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 deleted file mode 100644 index 00e7e02d4e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_types.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index fa993023e1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index f0b9e58852..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 5757b8185d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/select_option_service.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index ec36639a7c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/select_option_types.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index d0b9122d90..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 90a0dd3106..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/type_option_service.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 57de7b828c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/type_option_types.ts +++ /dev/null @@ -1,148 +0,0 @@ -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 deleted file mode 100644 index 72526b577f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_data.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 323f8dac82..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 6283763d28..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_service.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index f9f80985e5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_types.ts +++ /dev/null @@ -1,202 +0,0 @@ -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 deleted file mode 100644 index ac10d27d0a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 24f24d65ec..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_service.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index b75ecc0bd4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_types.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index bb872d6677..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/group/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index f44da5b857..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 69260223ef..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index e8a638403e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 029da3b0c9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { - CreateRowPayloadPB, - MoveGroupRowPayloadPB, - MoveRowPayloadPB, - OrderObjectPositionTypePB, - RowIdPB, - UpdateRowMetaChangesetPB, -} from '@/services/backend'; -import { - DatabaseEventCreateRow, - DatabaseEventDeleteRow, - 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 = RowIdPB.fromObject({ - view_id: viewId, - row_id: rowId, - group_id: groupId, - }); - - const result = await DatabaseEventDeleteRow(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 deleted file mode 100644 index 1b964a6bb5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_types.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 6c7d4bd60a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 808c62e0d2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_listeners.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 2546ec780c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_service.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index a8089878d1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_types.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 0db128ec7a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts +++ /dev/null @@ -1,284 +0,0 @@ -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 deleted file mode 100644 index e6eb1d6923..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts +++ /dev/null @@ -1,242 +0,0 @@ -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 deleted file mode 100644 index 7d988b9866..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/folder/page.service.ts +++ /dev/null @@ -1,166 +0,0 @@ -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 deleted file mode 100644 index dfbe742ca0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/folder/trash.service.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index fe066b7377..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index c63a5d9823..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts +++ /dev/null @@ -1,157 +0,0 @@ -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 deleted file mode 100644 index ec258abc87..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index ec64fb810c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/application/user/user.service.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index 049be05cec..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/add.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/align-center.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/align-center.svg deleted file mode 100644 index f4f4999514..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/align-center.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/align-left.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/align-left.svg deleted file mode 100644 index 23957285c7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/align-left.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/align-right.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/align-right.svg deleted file mode 100644 index bca2d14fc7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/align-right.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/arrow-left.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/arrow-left.svg deleted file mode 100644 index e4ab9068be..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/arrow-left.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/arrow-right.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/arrow-right.svg deleted file mode 100644 index dc40ae52a6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/arrow-right.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/board.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/board.svg deleted file mode 100644 index 0bb0e3fabe..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/board.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/bold.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/bold.svg deleted file mode 100644 index 878b6329b3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/bold.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/close.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/close.svg deleted file mode 100644 index b519b419c0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/close.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/copy.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/copy.svg deleted file mode 100644 index e21e6cb082..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/copy.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/dark-logo.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/dark-logo.svg deleted file mode 100644 index 80d8c4132e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/dark-logo.svg +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ 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 deleted file mode 100644 index 15632e4ea6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/checkbox-check.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/checkbox-uncheck.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/checkbox-uncheck.svg deleted file mode 100644 index 6c487795c6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/checkbox-uncheck.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-attach.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-attach.svg deleted file mode 100644 index f00f5c7aa2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-attach.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-checkbox.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-checkbox.svg deleted file mode 100644 index 37f52c47ed..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-checkbox.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-checklist.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-checklist.svg deleted file mode 100644 index 3a88d236a1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-checklist.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-date.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-date.svg deleted file mode 100644 index 78243f1e75..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-date.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-last-edited-time.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-last-edited-time.svg deleted file mode 100644 index 634af3e361..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-last-edited-time.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-multi-select.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-multi-select.svg deleted file mode 100644 index 97a2e9c434..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-multi-select.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-number.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-number.svg deleted file mode 100644 index 9d8b98d10d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-number.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-person.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-person.svg deleted file mode 100644 index 2fc04be065..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-person.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-relation.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-relation.svg deleted file mode 100644 index f82a41d226..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-relation.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-single-select.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-single-select.svg deleted file mode 100644 index 8ccbc9a2e3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-single-select.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-text.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-text.svg deleted file mode 100644 index 7befa5080f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-text.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-url.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-url.svg deleted file mode 100644 index f00f5c7aa2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-url.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/date.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/date.svg deleted file mode 100644 index 78243f1e75..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/date.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/delete.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/delete.svg deleted file mode 100644 index 9e51636798..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/delete.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/details.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/details.svg deleted file mode 100644 index 22c6830916..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/details.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/document.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/document.svg deleted file mode 100644 index b00e1cfb38..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/document.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/drag.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/drag.svg deleted file mode 100644 index 627c959f9f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/drag.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/dropdown.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/dropdown.svg deleted file mode 100644 index 95e4964b53..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/dropdown.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - \ 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 deleted file mode 100644 index ae93287114..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/edit.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 116c715ca8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/eye_close.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ 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 deleted file mode 100644 index fa3017c04d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/eye_open.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ 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 deleted file mode 100644 index c397af8130..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/grid.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/h1.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/h1.svg deleted file mode 100644 index b33bd52135..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/h1.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/h2.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/h2.svg deleted file mode 100644 index 7449c57391..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/h2.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/h3.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/h3.svg deleted file mode 100644 index 0976945974..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/h3.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/hide-menu.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/hide-menu.svg deleted file mode 100644 index ce88af8ea7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/hide-menu.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/hide.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/hide.svg deleted file mode 100644 index 22001ef65d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/hide.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg deleted file mode 100644 index 0739605066..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - 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 deleted file mode 100644 index aeaa6a0f29..0000000000 Binary files a/frontend/appflowy_tauri/src/appflowy_app/assets/images/default_cover.jpg and /dev/null differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/information.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/information.svg deleted file mode 100644 index 37ca4d5837..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/information.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/inline-code.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/inline-code.svg deleted file mode 100644 index 3585603096..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/inline-code.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/italic.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/italic.svg deleted file mode 100644 index b295c230f0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/italic.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/left.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/left.svg deleted file mode 100644 index 0f771a3858..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/left.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/light-logo.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/light-logo.svg deleted file mode 100644 index f5cd761ba7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/light-logo.svg +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/link.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/link.svg deleted file mode 100644 index 5fbcc8d787..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/link.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/list-dropdown.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/list-dropdown.svg deleted file mode 100644 index 4a8424c5f8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/list-dropdown.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/list.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/list.svg deleted file mode 100644 index 97a2e9c434..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/list.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/logo.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/logo.svg deleted file mode 100644 index b1ac8d66fb..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/logo.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/mention.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/mention.svg deleted file mode 100644 index b98318132c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/mention.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/more.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/more.svg deleted file mode 100644 index b191e64a10..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/more.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/numbers.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/numbers.svg deleted file mode 100644 index 9d8b98d10d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/numbers.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/open.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/open.svg deleted file mode 100644 index b443c8b993..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/open.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/quote.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/quote.svg deleted file mode 100644 index 57839231ff..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/quote.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/react.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/react.svg deleted file mode 100644 index 6c87de9bb3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/right.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/right.svg deleted file mode 100644 index 7d738f4e69..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/right.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/search.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/search.svg deleted file mode 100644 index a8a92df509..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/search.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg deleted file mode 100644 index 05caec861a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/settings.svg deleted file mode 100644 index 92140a3c23..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/settings.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/account.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/account.svg deleted file mode 100644 index fddfca7575..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/account.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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 deleted file mode 100644 index c6fa56067b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/check_circle.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/dark.png b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/dark.png deleted file mode 100644 index 15a2db5eb8..0000000000 Binary files a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/dark.png and /dev/null 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 deleted file mode 100644 index f71e68c6ed..0000000000 Binary files a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/discord.png and /dev/null 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 deleted file mode 100644 index 597883b7a3..0000000000 Binary files a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/github.png and /dev/null 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 deleted file mode 100644 index 60032628a8..0000000000 Binary files a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/google.png and /dev/null 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 deleted file mode 100644 index 09b2d9c475..0000000000 Binary files a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/light.png and /dev/null 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 deleted file mode 100644 index 2076ea3e2c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/workplace.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/show-menu.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/show-menu.svg deleted file mode 100644 index 8baf55bffd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/show-menu.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/sort.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/sort.svg deleted file mode 100644 index e3b6a49a56..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/sort.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/strikethrough.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/strikethrough.svg deleted file mode 100644 index c118422a15..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/strikethrough.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/text.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/text.svg deleted file mode 100644 index 7befa5080f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/text.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/todo-list.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/todo-list.svg deleted file mode 100644 index 37f52c47ed..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/todo-list.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/underline.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/underline.svg deleted file mode 100644 index f5d53f0ec2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/underline.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/up.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/up.svg deleted file mode 100644 index bd8f3067d3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/up.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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 deleted file mode 100644 index 1248882238..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/ProfileAvatar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 079342b528..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/WorkplaceAvatar.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 772056737a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 058335d30c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index cb8b7a80ed..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/RenameDialog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 5d3ed1e3de..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/AppFlowyDevTool.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 364b334a07..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/ManualSignInDialog.tsx +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index 85f0507fff..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag_block/drag.hooks.ts +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index e0cb540f75..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag_block/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index af82c82df5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.hooks.ts +++ /dev/null @@ -1,165 +0,0 @@ -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 deleted file mode 100644 index b8dcb3f6c7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index eefea8db11..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx +++ /dev/null @@ -1,354 +0,0 @@ -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 deleted file mode 100644 index 177ac2e7a0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerHeader.tsx +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index 9b9ba159fb..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/error_boundary/withError.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 34a99007ad..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index d94e5f2889..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/LocalImage.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 01da8323b9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index a6b66a4c1f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index fb65c709ce..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadTabs.tsx +++ /dev/null @@ -1,128 +0,0 @@ -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 deleted file mode 100644 index 28673cae5f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index e6c7cac5ed..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/katex_math/KatexMath.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index d127dc343b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/katex_math/index.css +++ /dev/null @@ -1,4 +0,0 @@ - -.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 deleted file mode 100644 index 7db90c4e8f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx +++ /dev/null @@ -1,317 +0,0 @@ -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 deleted file mode 100644 index 621143869b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 1086cabdfd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/notify/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 0fc1b5e61e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts +++ /dev/null @@ -1,237 +0,0 @@ -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 deleted file mode 100644 index dccaf2f4d4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 0527b6cc26..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/AFScroller.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 7a740a5bb0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 95e44ae9c2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 009548df53..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 54256f8eb1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIconGroup.tsx +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 8d81b6d4b7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitle.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 2c69bb4d76..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitleInput.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 78b8bbcc46..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/Colors.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index bd8c178380..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/CoverPopover.tsx +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index f207e07886..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCover.tsx +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index fbf8063f44..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCoverActions.tsx +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 8df50bb41e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 481b80a532..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/auth/LoginButtonGroup.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 523f0b5188..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index eadcf08c21..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/auth/Welcome.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 89b7388e64..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts +++ /dev/null @@ -1,186 +0,0 @@ -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 deleted file mode 100644 index 2597c158a1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index d5e7bba45b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx +++ /dev/null @@ -1,202 +0,0 @@ -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 deleted file mode 100644 index b0aeab10a2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseLoader.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index cd94947d8d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseTitle.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 98b16d5fea..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseView.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 01666121cd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/CellText.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 7d4c0d1811..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/LinearProgressWithLabel.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index fd1aab7a37..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 8954dc733a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/dnd.context.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index ce8afe6f31..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drag.hooks.ts +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index 7b3d79aeb2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drop.hooks.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 8688534359..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 3aafa6f77c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/utils.ts +++ /dev/null @@ -1,170 +0,0 @@ -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 deleted file mode 100644 index 6bfa1f812b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 790f841701..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/board/Board.tsx +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 9294d869ce..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/board/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index b8473fda25..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/calendar/Calendar.tsx +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index a723380592..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/calendar/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 76bba7b152..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.hooks.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index a092a1d75a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index f6f3dcf0d2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/CheckboxCell.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 5ecac431c4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index aea4f79849..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/DateTimeCell.tsx +++ /dev/null @@ -1,125 +0,0 @@ -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 deleted file mode 100644 index 727e78de3f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/NumberCell.tsx +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index a951ddd9e4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 38927d744b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/TextCell.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 5889a13915..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/TimestampCell.tsx +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 592850ada1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 2440976340..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 0fe9fb6d5b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index ea1378eab8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index d0b89208d0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index af2bbce218..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index c6a9d244f0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index 7f978120df..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index efb89af437..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 13f29a7dfc..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 7056cd353d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 412c1a953e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 653f3c5944..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index d2381ec165..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index c2f195aee2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordTitle.tsx +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 279ee13f68..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/Property.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 138c7543fd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index e7de3f1fb0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyName.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 4bb33e7f05..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyValue.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 16fc122615..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/RecordProperties.tsx +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index f8937bbf21..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/SwitchPropertiesVisible.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 027936d280..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 61583c4746..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index 5c6a55fa60..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index e8b9c95a44..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/LinearProgressWithLabel.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 4a36498bd2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index fd5ba57889..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 78e3129d4f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx +++ /dev/null @@ -1,197 +0,0 @@ -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 deleted file mode 100644 index 6c4b41a494..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFieldActions.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 0107997c24..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormat.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index f0393139b0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 82080b7d25..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 8e86b952d1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeSet.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index f40e179ae4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/IncludeTimeSwitch.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 76431af5fa..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/RangeSwitch.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 89a9ad1756..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 257467ed24..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/calendar.scss +++ /dev/null @@ -1,82 +0,0 @@ - -.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 deleted file mode 100644 index 129e84c4e7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/utils.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index d2b538a7e1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index eceb128804..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 0f9be6a21a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 5a02c6759b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 38621cb114..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/const.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 6c6cf37aae..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionModifyMenu.tsx +++ /dev/null @@ -1,165 +0,0 @@ -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 deleted file mode 100644 index 3e4677d57d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/Tag.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 58a42f7dad..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/constants.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 5c8acb4759..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index e2cd27019f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx +++ /dev/null @@ -1,162 +0,0 @@ -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 deleted file mode 100644 index 2a855a4085..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 0fb180bb08..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/AddAnOption.tsx +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index ad363d4a1d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 4e06236263..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Options.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index a3d51ceb60..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/SelectFieldActions.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 005d185c8f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/text/EditTextCellInput.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 0ca6c42a86..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/ConditionSelect.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index fdd7bccb5b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx +++ /dev/null @@ -1,200 +0,0 @@ -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 deleted file mode 100644 index ebc9e8982c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 8b793942da..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx +++ /dev/null @@ -1,224 +0,0 @@ -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 deleted file mode 100644 index e161badbf8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 860ce9f69f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 5c96d42b96..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/date_filter/DateFilter.tsx +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index dd75d25852..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/date_filter/DateFilterValue.tsx +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 658ef13d69..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/number_filter/NumberFilterValue.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index bd1d1f239a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilter.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 72576deae1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilterValue.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 0c7eab6e05..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 5718a3e2b8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilterValue.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 7da8d0eab4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index bb71befa8d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index a9865c467f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 3091ba4ea1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index b319940996..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx +++ /dev/null @@ -1,271 +0,0 @@ -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 deleted file mode 100644 index 55b314b821..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 4e20531335..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 0741bbc05b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 0b338836d6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index e3021249ee..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index b45b670757..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenuExtension.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 28d62b82c6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index daae232fde..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeText.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 7ee4e6f83d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index fdb508cb8f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortConditionSelect.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 724c28467a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index fe1074bbde..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 88df70b2e4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 7a4fa57a6f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/Sorts.tsx +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index e64dba3a6e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 717bf1eb18..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/AddViewBtn.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index f7375e0c70..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/DatabaseTabBar.tsx +++ /dev/null @@ -1,110 +0,0 @@ -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 deleted file mode 100644 index 60dfaa2e53..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/TextButton.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index be545e51e3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewActions.tsx +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index e2ff336c73..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewTabs.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index fc0c62963e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 492ff2a713..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/database.scss +++ /dev/null @@ -1,19 +0,0 @@ -.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 deleted file mode 100644 index beb90c66dc..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/Grid.tsx +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index eadfadaa89..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/constants.ts +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index beed71fca4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_calculate/GridCalculate.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 2bd3b71b1e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_calculate/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 042ba1777d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/GridCell.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index b9a734de7b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/PrimaryCell.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 949d5054bf..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 3c3921abf7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridField.tsx +++ /dev/null @@ -1,179 +0,0 @@ -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 deleted file mode 100644 index 1407fe30c2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridFieldMenu.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index d0b739298a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridNewField.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 12aef74996..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 384ee2af3b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 4dc70e21dc..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index 07ece5dec2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_overlay/GridTableOverlay.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index a4251c9ed5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts +++ /dev/null @@ -1,244 +0,0 @@ -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 deleted file mode 100644 index f4b39e2561..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index a93188ddc4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowContextMenu.tsx +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 0790e48183..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 2190e8739b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx +++ /dev/null @@ -1,160 +0,0 @@ -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 deleted file mode 100644 index fb50b6248c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index ac5c0688b9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.hooks.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index e9d01508b1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 0d676f3bb2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 0cd17d6a05..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx +++ /dev/null @@ -1,161 +0,0 @@ -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 deleted file mode 100644 index dfdb9b7949..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 762542e7cb..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 42a6f31592..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 079a6fd75f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index f6e8736c54..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 00f48716bf..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index a844aa51ad..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 1fc25346d2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.hooks.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 879dc5f9c0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 04a2e7c0f1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 557b91f936..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts +++ /dev/null @@ -1,715 +0,0 @@ -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 deleted file mode 100644 index 649eaca564..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts +++ /dev/null @@ -1,137 +0,0 @@ -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 deleted file mode 100644 index 819596f92f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index d9a60f09ad..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/Placeholder.tsx +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 91645e0051..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx +++ /dev/null @@ -1,130 +0,0 @@ -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 deleted file mode 100644 index 9e9e4fcb38..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/unSupportBlock.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 41fce1c9dc..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedList.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index ea0de80f55..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 2095dff308..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index a20300bbc2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index e9bba448a7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/CalloutIcon.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 4ca74e4be8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 0b043f4579..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.hooks.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 7fe7b205f4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 4805233e1d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index dee71624db..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/constants.ts +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index c3aa9443d1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 52eeebc8c4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/utils.ts +++ /dev/null @@ -1,132 +0,0 @@ -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 deleted file mode 100644 index a0f50016e1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseEmpty.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 543b9900ca..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.hooks.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 5d06a13c06..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 54c0005027..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/Drawer.tsx +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 936da9c2c8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridBlock.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 695482bbd8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 986343f9df..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 032502b415..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index d7d475199b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 8f6141749a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 4d23069c46..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/Heading.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 6406e7b07f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index b3d3575af2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx +++ /dev/null @@ -1,163 +0,0 @@ -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 deleted file mode 100644 index 661eb3e3de..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index e0b649939e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 07310b05be..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index e0d272acf3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 0aff9fb0cc..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 73c3003a92..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index f44158bdf2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx +++ /dev/null @@ -1,165 +0,0 @@ -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 deleted file mode 100644 index ee441be624..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index ae6eb70209..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 888b46c980..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index f3e34e1571..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberedList.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 6e985ae25b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index f93cb897ba..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index d9925d7520..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 96524db239..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/Paragraph.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 01752c914c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 5afc35289b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/Quote.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index c88e677a53..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index acf16581f4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 768524394e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index b0c76af0b0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index d98990c886..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index c662c48153..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/TodoList.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index f239f43459..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index ad27822cb5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleIcon.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 809f3b750d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 833bdb5210..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 2526df895e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index b0bbe0eb28..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index f2443ba44b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts +++ /dev/null @@ -1,137 +0,0 @@ -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 deleted file mode 100644 index d87dbe3f35..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index bf7045705d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.hooks.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 1824d8a590..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx +++ /dev/null @@ -1,132 +0,0 @@ -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 deleted file mode 100644 index 188ac33361..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Leaf.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index c0c3c728d1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index cc6960cdf4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index fb32eb18a9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/InlineChromiumBugfix.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 29f27984f7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index c60d7af40e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover.tsx +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index 324d273ab3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 204047304f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index 5643ae8943..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 09095480dc..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index af62a7b28f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx +++ /dev/null @@ -1,179 +0,0 @@ -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 deleted file mode 100644 index 6e9a0bb497..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 2a5e3630da..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 295683a3bc..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 7511147ad0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/Mention.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 10def395c5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index d3ee18034d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 2859c1f0a8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/withInline.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 41dea96f1e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/ColorPicker.tsx +++ /dev/null @@ -1,174 +0,0 @@ -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 deleted file mode 100644 index ae463a2ff3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/CustomColorPicker.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 00e212aa7f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index eb2675bc71..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/AddBlockBelow.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index f5833e538b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActions.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index bc1086dde9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index 729b4df144..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.tsx +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index ade9817503..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx +++ /dev/null @@ -1,209 +0,0 @@ -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 deleted file mode 100644 index 499ab95c76..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/Color.tsx +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 0fd619bc86..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index e8b87721c2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index b63afe9dc1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 633d09349d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts +++ /dev/null @@ -1,259 +0,0 @@ -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 deleted file mode 100644 index db58e2deca..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/CommandPanel.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index cf07c7d996..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 5d83870719..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index 6ca0225579..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 36b00ca2b6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index bfca34ef9a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index c2d9445b56..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx +++ /dev/null @@ -1,245 +0,0 @@ -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 deleted file mode 100644 index b09af97b39..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.tsx +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 256e82f811..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 7dfaa2b4a0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/const.ts +++ /dev/null @@ -1,174 +0,0 @@ -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 deleted file mode 100644 index 688a6ffb7d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 65a095dc58..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 2b6a715baa..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/popover.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 9d7c19b999..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 58834db6d5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts +++ /dev/null @@ -1,239 +0,0 @@ -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 deleted file mode 100644 index d4ca9c9de0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 3f86d1eab9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 23917e146b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/align/Align.tsx +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index 6cba19d7bd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/align/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 22be9f970a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/Bold.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 6ef457faaa..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index f35f2aeeea..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bulleted_list/BulletedList.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 2095dff308..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bulleted_list/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 60c44423bd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/Color.tsx +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 9f007dc7b5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/ColorPopover.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 0fd619bc86..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index c7bfc11352..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/Formula.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index dc4ad2cd03..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 1fc639c41f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/heading/Heading.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 6406e7b07f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/heading/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index e7412a909e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/Href.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index b77a249051..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/LinkActions.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 9a7210c140..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 3cf9c7ed85..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 9a4c4930c7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 89fff40e6f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/Italic.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 70bb069b60..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 006247ca8b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 6e985ae25b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 1ac5610787..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/paragraph/Paragraph.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 01752c914c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/paragraph/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 29ad0de104..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index c88e677a53..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 325f6ac55a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/StrikeThrough.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index f8314d16e3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index cd576edafa..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index f239f43459..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 4d82652988..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 833bdb5210..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index b0df70e30e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/Underline.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index a1d53a4384..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index a6ced3f248..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 178da73df4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 271dd36cda..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss +++ /dev/null @@ -1,231 +0,0 @@ - -.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 deleted file mode 100644 index 8b7c4c267a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 03e441b1c3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index bf2b09a1c3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index cb377fece4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/utils.ts +++ /dev/null @@ -1,311 +0,0 @@ -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 deleted file mode 100644 index c0daab0a8f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withCopy.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 2266ff41c7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withPasted.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 0292784ba5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 59ff0a8593..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/markdown.ts +++ /dev/null @@ -1,172 +0,0 @@ -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 deleted file mode 100644 index 45d61f847c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts +++ /dev/null @@ -1,349 +0,0 @@ -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 deleted file mode 100644 index fd7801204c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdown.ts +++ /dev/null @@ -1,239 +0,0 @@ -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 deleted file mode 100644 index 62e3ad945a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 0bcd0965a9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts +++ /dev/null @@ -1,228 +0,0 @@ -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 deleted file mode 100644 index b6f8da0e56..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 814c6e7333..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockMove.ts +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index 1e9fc7f105..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index eee7dd92d0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index 026ee57222..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 0937d265ed..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index adb85f2bfd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/read_me.ts +++ /dev/null @@ -1,437 +0,0 @@ -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 deleted file mode 100644 index 028fff7419..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/convert.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * @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 deleted file mode 100644 index 3bd7646268..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/mockBackendService.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index bf0ea2c2a7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/data_client.ts +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 03be03e588..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 727b33ec69..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 36ec97aa39..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/types/y_event.ts +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 447a8f95f9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts +++ /dev/null @@ -1,301 +0,0 @@ -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 deleted file mode 100644 index b4da4b3ca7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index 630b6fbdf5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/delta.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 72b2e126df..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/relation.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 00992964fb..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 078296aade..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/decorate.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 22f0bb81be..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 6607a546d8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/inline_node.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 803f474723..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 13e9447d0c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/slash.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index ceaa5a51a0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/error/Error.hooks.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 1bb15f2ca3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorHandlerPage.tsx +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 6da2ee96d0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorModal.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index cb0ff5c3b5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 4f468f5461..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/FooterPanel.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 807c1e6811..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.hooks.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 509aa388cf..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index ec9e990cdb..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index f2bec915d9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/Breadcrumb.hooks.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 87662a99bb..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 43f4f55892..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/layout.scss +++ /dev/null @@ -1,81 +0,0 @@ - - -.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 deleted file mode 100644 index 1387f16f4d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/AddButton.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 4af8a2f2f1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/DeleteDialog.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 94a86655ac..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx +++ /dev/null @@ -1,130 +0,0 @@ -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 deleted file mode 100644 index d43499e801..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.hooks.ts +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index e423f05517..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.tsx +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index 948aedcae2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPageTitle.tsx +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index cef1d3307c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/OperationMenu.tsx +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index b281706848..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/share/Share.hooks.ts +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 1a10cb08e6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/share/Share.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 639d5283e0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/Resizer.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 5cdbfb125b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 62763c670e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/UserInfo.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index f5638362b9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/DeletePageSnackbar.tsx +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 4b439cc3fb..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/FontSizeConfig.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index d37d1bf060..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 63f1173885..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreOptions.hooks.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 7f77259212..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreOptions.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 173bf86cab..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index ec3335b6b3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/NestedPages.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 537b7d2d9a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/NewPageButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 984ed6f67f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/TrashButton.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 86bca45ada..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index 24fc7be91e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 083dd61ec3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index d5ecc4bc0c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/Login.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 1d9f3c0cd9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/Settings.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index b53f8a6002..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/SettingsDialog.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @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 deleted file mode 100644 index 0f0a2c23f4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 05b375c920..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/AccountLogin.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 82a909180e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccount.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 2f8cc37258..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccountDialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index b3a315994b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/MyAccount.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 2ac672b0e5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/Profile.tsx +++ /dev/null @@ -1,180 +0,0 @@ -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 deleted file mode 100644 index d923fcefce..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 1dc8581dae..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Appearance.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 8af69eec51..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Workplace.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 3a71c5f070..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/WorkplaceDisplay.tsx +++ /dev/null @@ -1,155 +0,0 @@ -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 deleted file mode 100644 index 41a42bd011..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/LanguageSetting.tsx +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index 34fdb8e598..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/ThemeModeSwitch.tsx +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 075e2744a5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/const.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index a64592ac8b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index b6748614b8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index f10848dc9b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index d266005612..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 6711ece8c8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/hooks/ViewId.hooks.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index c29ddd04aa..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/hooks/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index f8669852d3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* 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 deleted file mode 100644 index 49e01e75c0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/hooks/page.hooks.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index d36dba3bb2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/i18n/config.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 26b713e333..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/slate-editor.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 464b7428a3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 9b47df7777..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/error/slice.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 90014c1e7f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index dbf313ecc1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts +++ /dev/null @@ -1,223 +0,0 @@ -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 deleted file mode 100644 index fae1d59214..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 98d850f6fe..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/trash/slice.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index d071de846e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 269f46884c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 7e673506de..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index a9a752c579..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/avatar.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 57d9f2a370..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/change_notifier.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 025c8c45ed..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/color.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 8d5adb5df6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/emoji.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 064dc042aa..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/env.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 20aa05db27..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index 6e5d22ccda..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/list.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index daccf21d0a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/log.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 94e2cf94d5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index d854be5211..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index afcd7a32b4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* 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 deleted file mode 100644 index 22213ac8b3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/upload_image.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 004fc4355b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 03ba493c10..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 78baa9872d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/views/TrashPage.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index b1f45c7866..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/vite-env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/frontend/appflowy_tauri/src/main.tsx b/frontend/appflowy_tauri/src/main.tsx deleted file mode 100644 index e53dc96c43..0000000000 --- a/frontend/appflowy_tauri/src/main.tsx +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 1cc17c1e1b..0000000000 --- a/frontend/appflowy_tauri/src/services/backend/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -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"; diff --git a/frontend/appflowy_tauri/src/styles/font.css b/frontend/appflowy_tauri/src/styles/font.css deleted file mode 100644 index 514b5a6e38..0000000000 --- a/frontend/appflowy_tauri/src/styles/font.css +++ /dev/null @@ -1,125 +0,0 @@ -@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 deleted file mode 100644 index b5c61c9567..0000000000 --- a/frontend/appflowy_tauri/src/styles/tailwind.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/frontend/appflowy_tauri/src/styles/template.css b/frontend/appflowy_tauri/src/styles/template.css deleted file mode 100644 index 1bff6bdc76..0000000000 --- a/frontend/appflowy_tauri/src/styles/template.css +++ /dev/null @@ -1,60 +0,0 @@ -@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 deleted file mode 100644 index ca7544687b..0000000000 --- a/frontend/appflowy_tauri/src/styles/variables/dark.variables.css +++ /dev/null @@ -1,121 +0,0 @@ -/** -* 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 deleted file mode 100644 index 08d6a948f1..0000000000 --- a/frontend/appflowy_tauri/src/styles/variables/index.css +++ /dev/null @@ -1,7 +0,0 @@ -@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 deleted file mode 100644 index 26acc76f0a..0000000000 --- a/frontend/appflowy_tauri/src/styles/variables/light.variables.css +++ /dev/null @@ -1,124 +0,0 @@ -/** -* 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 deleted file mode 100644 index cb0ff5c3b5..0000000000 --- a/frontend/appflowy_tauri/src/tests/helpers/init.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/frontend/appflowy_tauri/style-dictionary/config.cjs b/frontend/appflowy_tauri/style-dictionary/config.cjs deleted file mode 100644 index 10d7084060..0000000000 --- a/frontend/appflowy_tauri/style-dictionary/config.cjs +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index e9d8024320..0000000000 --- a/frontend/appflowy_tauri/style-dictionary/tailwind/box-shadow.cjs +++ /dev/null @@ -1,9 +0,0 @@ -/** -* 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 deleted file mode 100644 index bfa25fa56f..0000000000 --- a/frontend/appflowy_tauri/style-dictionary/tailwind/colors.cjs +++ /dev/null @@ -1,75 +0,0 @@ -/** -* 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 deleted file mode 100644 index 4e31b0523d..0000000000 --- a/frontend/appflowy_tauri/style-dictionary/tokens/base.json +++ /dev/null @@ -1,290 +0,0 @@ -{ - "Base": { - "Light": { - "neutral": { - "50": { - "value": "#f9fafd", - "type": "color" - }, - "100": { - "value": "#edeef2", - "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 deleted file mode 100644 index c67af7c9ec..0000000000 --- a/frontend/appflowy_tauri/style-dictionary/tokens/dark.json +++ /dev/null @@ -1,221 +0,0 @@ -{ - "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 deleted file mode 100644 index 173f3d35aa..0000000000 --- a/frontend/appflowy_tauri/style-dictionary/tokens/light.json +++ /dev/null @@ -1,233 +0,0 @@ -{ - "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 deleted file mode 100644 index 06390d938f..0000000000 --- a/frontend/appflowy_tauri/tailwind.config.cjs +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 63b15b6039..0000000000 --- a/frontend/appflowy_tauri/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 9d31e2aed9..0000000000 --- a/frontend/appflowy_tauri/tsconfig.node.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "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 deleted file mode 100644 index b571cc40de..0000000000 --- a/frontend/appflowy_tauri/vite.config.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 78bbd20aad..0000000000 --- a/frontend/appflowy_tauri/webdriver/selenium/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "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 deleted file mode 100644 index 7a57bdbbaf..0000000000 --- a/frontend/appflowy_tauri/webdriver/selenium/test/test.cjs +++ /dev/null @@ -1,76 +0,0 @@ -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/.eslintignore b/frontend/appflowy_web/.eslintignore deleted file mode 100644 index e0ff674834..0000000000 --- a/frontend/appflowy_web/.eslintignore +++ /dev/null @@ -1,7 +0,0 @@ -src/services -src/styles -node_modules/ -dist/ -src-tauri/ -.eslintrc.cjs -tsconfig.json \ No newline at end of file diff --git a/frontend/appflowy_web/.eslintrc.cjs b/frontend/appflowy_web/.eslintrc.cjs deleted file mode 100644 index a1160f0bd3..0000000000 --- a/frontend/appflowy_web/.eslintrc.cjs +++ /dev/null @@ -1,73 +0,0 @@ -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_web/.gitignore b/frontend/appflowy_web/.gitignore deleted file mode 100644 index d347429756..0000000000 --- a/frontend/appflowy_web/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# 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? - -wasm-libs/**/target -**/src/services/backend/models/ -**/src/services/backend/events/ -**/src/appflowy_app/i18n/translations/ diff --git a/frontend/appflowy_web/.prettierignore b/frontend/appflowy_web/.prettierignore deleted file mode 100644 index d515c1c2f2..0000000000 --- a/frontend/appflowy_web/.prettierignore +++ /dev/null @@ -1,19 +0,0 @@ -.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_web/.prettierrc.cjs b/frontend/appflowy_web/.prettierrc.cjs deleted file mode 100644 index f283db53a2..0000000000 --- a/frontend/appflowy_web/.prettierrc.cjs +++ /dev/null @@ -1,20 +0,0 @@ -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/README.md b/frontend/appflowy_web/README.md deleted file mode 100644 index b92a4c4960..0000000000 --- a/frontend/appflowy_web/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# AppFlowy Web Project - -## Installation - -```bash -cd appflowy-web - -# Install dependencies -rm -rf node_modules && pnpm install -``` - -## Running the app - -```bash -# development -pnpm run dev - -# production mode -pnpm run build - -# generate wasm -pnpm run wasm -``` - - - -## Run tests in Chrome - -> Before executing the test, you need to install the [Chrome Driver](https://chromedriver.chromium.org/downloads). If -> you are using a Mac, you can easily install it using Homebrew. -> -> ```shell -> brew install chromedriver -> ``` - - -Go to `frontend/appflowy_web/wasm-libs` and run: -```shell -wasm-pack test --chrome -``` - -Run tests in headless Chrome -```shell -wasm-pack test --headless --chrome -``` \ No newline at end of file diff --git a/frontend/appflowy_web/index.html b/frontend/appflowy_web/index.html deleted file mode 100644 index e4b78eae12..0000000000 --- a/frontend/appflowy_web/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + React + TS - - -
- - - diff --git a/frontend/appflowy_web/package.json b/frontend/appflowy_web/package.json deleted file mode 100644 index faafdba154..0000000000 --- a/frontend/appflowy_web/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "appflowy_web", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "build_release_wasm": "cd wasm-libs/af-wasm && wasm-pack build", - "build_dev_wasm": "cd wasm-libs/af-wasm && wasm-pack build --features=\"localhost_dev\"", - "dev": "pnpm run build_dev_wasm && vite", - "build": "tsc && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "clean": "cargo make --cwd .. web_clean", - "test": "cargo test && wasm-pack test --headless", - "preview": "vite preview" - }, - "dependencies": { - "events": "^3.3.0", - "google-protobuf": "^3.21.2", - "protoc-gen-ts": "^0.8.5", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "ts-results": "^3.3.0", - "uuid": "^9.0.1" - }, - "devDependencies": { - "@types/events": "^3.0.3", - "@types/node": "^20.10.6", - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", - "@typescript-eslint/eslint-plugin": "^6.14.0", - "@typescript-eslint/parser": "^6.14.0", - "@vitejs/plugin-react": "^4.2.1", - "eslint": "^8.55.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.5", - "typescript": "^5.2.2", - "vite": "^5.0.8", - "vite-plugin-wasm": "^3.3.0", - "rimraf": "^5.0.5" - } -} diff --git a/frontend/appflowy_web/pnpm-lock.yaml b/frontend/appflowy_web/pnpm-lock.yaml deleted file mode 100644 index 3c23a1ff65..0000000000 --- a/frontend/appflowy_web/pnpm-lock.yaml +++ /dev/null @@ -1,2133 +0,0 @@ -lockfileVersion: '6.0' - -dependencies: - events: - specifier: ^3.3.0 - version: 3.3.0 - google-protobuf: - specifier: ^3.21.2 - version: 3.21.2 - protoc-gen-ts: - specifier: ^0.8.5 - version: 0.8.7 - react: - specifier: ^18.2.0 - version: 18.2.0 - react-dom: - specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) - ts-results: - specifier: ^3.3.0 - version: 3.3.0 - uuid: - specifier: ^9.0.1 - version: 9.0.1 - -devDependencies: - '@types/events': - specifier: ^3.0.3 - version: 3.0.3 - '@types/node': - specifier: ^20.10.6 - version: 20.10.6 - '@types/react': - specifier: ^18.2.43 - version: 18.2.43 - '@types/react-dom': - specifier: ^18.2.17 - version: 18.2.17 - '@typescript-eslint/eslint-plugin': - specifier: ^6.14.0 - version: 6.14.0(@typescript-eslint/parser@6.14.0)(eslint@8.55.0)(typescript@5.2.2) - '@typescript-eslint/parser': - specifier: ^6.14.0 - version: 6.14.0(eslint@8.55.0)(typescript@5.2.2) - '@vitejs/plugin-react': - specifier: ^4.2.1 - version: 4.2.1(vite@5.0.8) - eslint: - specifier: ^8.55.0 - version: 8.55.0 - eslint-plugin-react-hooks: - specifier: ^4.6.0 - version: 4.6.0(eslint@8.55.0) - eslint-plugin-react-refresh: - specifier: ^0.4.5 - version: 0.4.5(eslint@8.55.0) - rimraf: - specifier: ^5.0.5 - version: 5.0.5 - typescript: - specifier: ^5.2.2 - version: 5.2.2 - vite: - specifier: ^5.0.8 - version: 5.0.8(@types/node@20.10.6) - vite-plugin-wasm: - specifier: ^3.3.0 - version: 3.3.0(vite@5.0.8) - -packages: - - /@aashutoshrathi/word-wrap@1.2.6: - resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} - engines: {node: '>=0.10.0'} - 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.20 - dev: true - - /@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 - dev: true - - /@babel/compat-data@7.23.5: - resolution: {integrity: sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/core@7.23.7: - resolution: {integrity: sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==} - engines: {node: '>=6.9.0'} - dependencies: - '@ampproject/remapping': 2.2.1 - '@babel/code-frame': 7.23.5 - '@babel/generator': 7.23.6 - '@babel/helper-compilation-targets': 7.23.6 - '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.7) - '@babel/helpers': 7.23.7 - '@babel/parser': 7.23.6 - '@babel/template': 7.22.15 - '@babel/traverse': 7.23.7 - '@babel/types': 7.23.6 - convert-source-map: 2.0.0 - debug: 4.3.4 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - dev: true - - /@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.20 - jsesc: 2.5.2 - 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.23.5 - '@babel/helper-validator-option': 7.23.5 - browserslist: 4.22.2 - lru-cache: 5.1.1 - semver: 6.3.1 - dev: true - - /@babel/helper-environment-visitor@7.22.20: - resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==} - engines: {node: '>=6.9.0'} - 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.22.15 - '@babel/types': 7.23.6 - dev: true - - /@babel/helper-hoist-variables@7.22.5: - resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.23.6 - dev: true - - /@babel/helper-module-imports@7.22.15: - resolution: {integrity: sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.23.6 - dev: true - - /@babel/helper-module-transforms@7.23.3(@babel/core@7.23.7): - resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.23.7 - '@babel/helper-environment-visitor': 7.22.20 - '@babel/helper-module-imports': 7.22.15 - '@babel/helper-simple-access': 7.22.5 - '@babel/helper-split-export-declaration': 7.22.6 - '@babel/helper-validator-identifier': 7.22.20 - dev: true - - /@babel/helper-plugin-utils@7.22.5: - resolution: {integrity: sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==} - engines: {node: '>=6.9.0'} - 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.23.6 - 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.23.6 - dev: true - - /@babel/helper-string-parser@7.23.4: - resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} - 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'} - dev: true - - /@babel/helper-validator-option@7.23.5: - resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==} - engines: {node: '>=6.9.0'} - dev: true - - /@babel/helpers@7.23.7: - resolution: {integrity: sha512-6AMnjCoC8wjqBzDHkuqpa7jAKwvMo4dC+lr/TFBz+ucfulO1XMpDnwWPGBNwClOKZ8h6xn5N81W/R5OrcKtCbQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.22.15 - '@babel/traverse': 7.23.7 - '@babel/types': 7.23.6 - transitivePeerDependencies: - - supports-color - dev: true - - /@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 - dev: true - - /@babel/parser@7.23.6: - resolution: {integrity: sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==} - engines: {node: '>=6.0.0'} - hasBin: true - dependencies: - '@babel/types': 7.23.6 - dev: true - - /@babel/plugin-transform-react-jsx-self@7.23.3(@babel/core@7.23.7): - resolution: {integrity: sha512-qXRvbeKDSfwnlJnanVRp0SfuWE5DQhwQr5xtLBzp56Wabyo+4CMosF6Kfp+eOD/4FYpql64XVJ2W0pVLlJZxOQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.23.7 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@babel/plugin-transform-react-jsx-source@7.23.3(@babel/core@7.23.7): - resolution: {integrity: sha512-91RS0MDnAWDNvGC6Wio5XYkyWI39FMFO+JK9+4AlgaTH+yWwVTsw7/sn6LK0lH7c5F+TFkpv/3LfCJ1Ydwof/g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.23.7 - '@babel/helper-plugin-utils': 7.22.5 - dev: true - - /@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 - dev: true - - /@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 - dev: true - - /@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 - dev: true - - /@esbuild/aix-ppc64@0.19.11: - resolution: {integrity: sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-arm64@0.19.11: - resolution: {integrity: sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-arm@0.19.11: - resolution: {integrity: sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-x64@0.19.11: - resolution: {integrity: sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/darwin-arm64@0.19.11: - resolution: {integrity: sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@esbuild/darwin-x64@0.19.11: - resolution: {integrity: sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@esbuild/freebsd-arm64@0.19.11: - resolution: {integrity: sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/freebsd-x64@0.19.11: - resolution: {integrity: sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-arm64@0.19.11: - resolution: {integrity: sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-arm@0.19.11: - resolution: {integrity: sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-ia32@0.19.11: - resolution: {integrity: sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-loong64@0.19.11: - resolution: {integrity: sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-mips64el@0.19.11: - resolution: {integrity: sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-ppc64@0.19.11: - resolution: {integrity: sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-riscv64@0.19.11: - resolution: {integrity: sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-s390x@0.19.11: - resolution: {integrity: sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-x64@0.19.11: - resolution: {integrity: sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/netbsd-x64@0.19.11: - resolution: {integrity: sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/openbsd-x64@0.19.11: - resolution: {integrity: sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/sunos-x64@0.19.11: - resolution: {integrity: sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-arm64@0.19.11: - resolution: {integrity: sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-ia32@0.19.11: - resolution: {integrity: sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-x64@0.19.11: - resolution: {integrity: sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@eslint-community/eslint-utils@4.4.0(eslint@8.55.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.55.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 - espree: 9.6.1 - globals: 13.24.0 - ignore: 5.3.0 - 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.55.0: - resolution: {integrity: sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true - - /@humanwhocodes/config-array@0.11.13: - resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} - engines: {node: '>=10.10.0'} - dependencies: - '@humanwhocodes/object-schema': 2.0.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@2.0.1: - resolution: {integrity: sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==} - dev: true - - /@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 - - /@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.20 - dev: true - - /@jridgewell/resolve-uri@3.1.1: - resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} - engines: {node: '>=6.0.0'} - dev: true - - /@jridgewell/set-array@1.1.2: - resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} - engines: {node: '>=6.0.0'} - dev: true - - /@jridgewell/sourcemap-codec@1.4.15: - resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - dev: true - - /@jridgewell/trace-mapping@0.3.20: - resolution: {integrity: sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==} - dependencies: - '@jridgewell/resolve-uri': 3.1.1 - '@jridgewell/sourcemap-codec': 1.4.15 - dev: true - - /@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.16.0 - dev: true - - /@pkgjs/parseargs@0.11.0: - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-android-arm-eabi@4.9.2: - resolution: {integrity: sha512-RKzxFxBHq9ysZ83fn8Iduv3A283K7zPPYuhL/z9CQuyFrjwpErJx0h4aeb/bnJ+q29GRLgJpY66ceQ/Wcsn3wA==} - cpu: [arm] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-android-arm64@4.9.2: - resolution: {integrity: sha512-yZ+MUbnwf3SHNWQKJyWh88ii2HbuHCFQnAYTeeO1Nb8SyEiWASEi5dQUygt3ClHWtA9My9RQAYkjvrsZ0WK8Xg==} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-darwin-arm64@4.9.2: - resolution: {integrity: sha512-vqJ/pAUh95FLc/G/3+xPqlSBgilPnauVf2EXOQCZzhZJCXDXt/5A8mH/OzU6iWhb3CNk5hPJrh8pqJUPldN5zw==} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-darwin-x64@4.9.2: - resolution: {integrity: sha512-otPHsN5LlvedOprd3SdfrRNhOahhVBwJpepVKUN58L0RnC29vOAej1vMEaVU6DadnpjivVsNTM5eNt0CcwTahw==} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-linux-arm-gnueabihf@4.9.2: - resolution: {integrity: sha512-ewG5yJSp+zYKBYQLbd1CUA7b1lSfIdo9zJShNTyc2ZP1rcPrqyZcNlsHgs7v1zhgfdS+kW0p5frc0aVqhZCiYQ==} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-linux-arm64-gnu@4.9.2: - resolution: {integrity: sha512-pL6QtV26W52aCWTG1IuFV3FMPL1m4wbsRG+qijIvgFO/VBsiXJjDPE/uiMdHBAO6YcpV4KvpKtd0v3WFbaxBtg==} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-linux-arm64-musl@4.9.2: - resolution: {integrity: sha512-On+cc5EpOaTwPSNetHXBuqylDW+765G/oqB9xGmWU3npEhCh8xu0xqHGUA+4xwZLqBbIZNcBlKSIYfkBm6ko7g==} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-linux-riscv64-gnu@4.9.2: - resolution: {integrity: sha512-Wnx/IVMSZ31D/cO9HSsU46FjrPWHqtdF8+0eyZ1zIB5a6hXaZXghUKpRrC4D5DcRTZOjml2oBhXoqfGYyXKipw==} - cpu: [riscv64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-linux-x64-gnu@4.9.2: - resolution: {integrity: sha512-ym5x1cj4mUAMBummxxRkI4pG5Vht1QMsJexwGP8547TZ0sox9fCLDHw9KCH9c1FO5d9GopvkaJsBIOkTKxksdw==} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-linux-x64-musl@4.9.2: - resolution: {integrity: sha512-m0hYELHGXdYx64D6IDDg/1vOJEaiV8f1G/iO+tejvRCJNSwK4jJ15e38JQy5Q6dGkn1M/9KcyEOwqmlZ2kqaZg==} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-win32-arm64-msvc@4.9.2: - resolution: {integrity: sha512-x1CWburlbN5JjG+juenuNa4KdedBdXLjZMp56nHFSHTOsb/MI2DYiGzLtRGHNMyydPGffGId+VgjOMrcltOksA==} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-win32-ia32-msvc@4.9.2: - resolution: {integrity: sha512-VVzCB5yXR1QlfsH1Xw1zdzQ4Pxuzv+CPr5qpElpKhVxlxD3CRdfubAG9mJROl6/dmj5gVYDDWk8sC+j9BI9/kQ==} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@rollup/rollup-win32-x64-msvc@4.9.2: - resolution: {integrity: sha512-SYRedJi+mweatroB+6TTnJYLts0L0bosg531xnQWtklOI6dezEagx4Q0qDyvRdK+qgdA3YZpjjGuPFtxBmddBA==} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@types/babel__core@7.20.5: - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - dependencies: - '@babel/parser': 7.23.6 - '@babel/types': 7.23.6 - '@types/babel__generator': 7.6.8 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.20.5 - dev: true - - /@types/babel__generator@7.6.8: - resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} - dependencies: - '@babel/types': 7.23.6 - dev: true - - /@types/babel__template@7.4.4: - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - dependencies: - '@babel/parser': 7.23.6 - '@babel/types': 7.23.6 - dev: true - - /@types/babel__traverse@7.20.5: - resolution: {integrity: sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==} - dependencies: - '@babel/types': 7.23.6 - dev: true - - /@types/events@3.0.3: - resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==} - dev: true - - /@types/json-schema@7.0.15: - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - dev: true - - /@types/node@20.10.6: - resolution: {integrity: sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==} - dependencies: - undici-types: 5.26.5 - dev: true - - /@types/prop-types@15.7.11: - resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} - dev: true - - /@types/react-dom@18.2.17: - resolution: {integrity: sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==} - dependencies: - '@types/react': 18.2.43 - dev: true - - /@types/react@18.2.43: - resolution: {integrity: sha512-nvOV01ZdBdd/KW6FahSbcNplt2jCJfyWdTos61RYHV+FVv5L/g9AOX1bmbVcWcLFL8+KHQfh1zVIQrud6ihyQA==} - dependencies: - '@types/prop-types': 15.7.11 - '@types/scheduler': 0.16.8 - csstype: 3.1.3 - dev: true - - /@types/scheduler@0.16.8: - resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==} - dev: true - - /@types/semver@7.5.6: - resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==} - dev: true - - /@typescript-eslint/eslint-plugin@6.14.0(@typescript-eslint/parser@6.14.0)(eslint@8.55.0)(typescript@5.2.2): - resolution: {integrity: sha512-1ZJBykBCXaSHG94vMMKmiHoL0MhNHKSVlcHVYZNw+BKxufhqQVTOawNpwwI1P5nIFZ/4jLVop0mcY6mJJDFNaw==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 6.14.0(eslint@8.55.0)(typescript@5.2.2) - '@typescript-eslint/scope-manager': 6.14.0 - '@typescript-eslint/type-utils': 6.14.0(eslint@8.55.0)(typescript@5.2.2) - '@typescript-eslint/utils': 6.14.0(eslint@8.55.0)(typescript@5.2.2) - '@typescript-eslint/visitor-keys': 6.14.0 - debug: 4.3.4 - eslint: 8.55.0 - graphemer: 1.4.0 - ignore: 5.3.0 - natural-compare: 1.4.0 - semver: 7.5.4 - ts-api-utils: 1.0.3(typescript@5.2.2) - typescript: 5.2.2 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/parser@6.14.0(eslint@8.55.0)(typescript@5.2.2): - resolution: {integrity: sha512-QjToC14CKacd4Pa7JK4GeB/vHmWFJckec49FR4hmIRf97+KXole0T97xxu9IFiPxVQ1DBWrQ5wreLwAGwWAVQA==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/scope-manager': 6.14.0 - '@typescript-eslint/types': 6.14.0 - '@typescript-eslint/typescript-estree': 6.14.0(typescript@5.2.2) - '@typescript-eslint/visitor-keys': 6.14.0 - debug: 4.3.4 - eslint: 8.55.0 - typescript: 5.2.2 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/scope-manager@6.14.0: - resolution: {integrity: sha512-VT7CFWHbZipPncAZtuALr9y3EuzY1b1t1AEkIq2bTXUPKw+pHoXflGNG5L+Gv6nKul1cz1VH8fz16IThIU0tdg==} - engines: {node: ^16.0.0 || >=18.0.0} - dependencies: - '@typescript-eslint/types': 6.14.0 - '@typescript-eslint/visitor-keys': 6.14.0 - dev: true - - /@typescript-eslint/type-utils@6.14.0(eslint@8.55.0)(typescript@5.2.2): - resolution: {integrity: sha512-x6OC9Q7HfYKqjnuNu5a7kffIYs3No30isapRBJl1iCHLitD8O0lFbRcVGiOcuyN837fqXzPZ1NS10maQzZMKqw==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/typescript-estree': 6.14.0(typescript@5.2.2) - '@typescript-eslint/utils': 6.14.0(eslint@8.55.0)(typescript@5.2.2) - debug: 4.3.4 - eslint: 8.55.0 - ts-api-utils: 1.0.3(typescript@5.2.2) - typescript: 5.2.2 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/types@6.14.0: - resolution: {integrity: sha512-uty9H2K4Xs8E47z3SnXEPRNDfsis8JO27amp2GNCnzGETEW3yTqEIVg5+AI7U276oGF/tw6ZA+UesxeQ104ceA==} - engines: {node: ^16.0.0 || >=18.0.0} - dev: true - - /@typescript-eslint/typescript-estree@6.14.0(typescript@5.2.2): - resolution: {integrity: sha512-yPkaLwK0yH2mZKFE/bXkPAkkFgOv15GJAUzgUVonAbv0Hr4PK/N2yaA/4XQbTZQdygiDkpt5DkxPELqHguNvyw==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@typescript-eslint/types': 6.14.0 - '@typescript-eslint/visitor-keys': 6.14.0 - debug: 4.3.4 - globby: 11.1.0 - is-glob: 4.0.3 - semver: 7.5.4 - ts-api-utils: 1.0.3(typescript@5.2.2) - typescript: 5.2.2 - transitivePeerDependencies: - - supports-color - dev: true - - /@typescript-eslint/utils@6.14.0(eslint@8.55.0)(typescript@5.2.2): - resolution: {integrity: sha512-XwRTnbvRr7Ey9a1NT6jqdKX8y/atWG+8fAIu3z73HSP8h06i3r/ClMhmaF/RGWGW1tHJEwij1uEg2GbEmPYvYg==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.55.0) - '@types/json-schema': 7.0.15 - '@types/semver': 7.5.6 - '@typescript-eslint/scope-manager': 6.14.0 - '@typescript-eslint/types': 6.14.0 - '@typescript-eslint/typescript-estree': 6.14.0(typescript@5.2.2) - eslint: 8.55.0 - semver: 7.5.4 - transitivePeerDependencies: - - supports-color - - typescript - dev: true - - /@typescript-eslint/visitor-keys@6.14.0: - resolution: {integrity: sha512-fB5cw6GRhJUz03MrROVuj5Zm/Q+XWlVdIsFj+Zb1Hvqouc8t+XP2H5y53QYU/MGtd2dPg6/vJJlhoX3xc2ehfw==} - engines: {node: ^16.0.0 || >=18.0.0} - dependencies: - '@typescript-eslint/types': 6.14.0 - eslint-visitor-keys: 3.4.3 - dev: true - - /@ungap/structured-clone@1.2.0: - resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - dev: true - - /@vitejs/plugin-react@4.2.1(vite@5.0.8): - 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.23.7 - '@babel/plugin-transform-react-jsx-self': 7.23.3(@babel/core@7.23.7) - '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.7) - '@types/babel__core': 7.20.5 - react-refresh: 0.14.0 - vite: 5.0.8(@types/node@20.10.6) - transitivePeerDependencies: - - supports-color - 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@8.11.3: - resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} - engines: {node: '>=0.4.0'} - hasBin: true - 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-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - dev: true - - /ansi-regex@6.0.1: - resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} - engines: {node: '>=12'} - 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 - dev: true - - /ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - dependencies: - color-convert: 2.0.1 - dev: true - - /ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} - engines: {node: '>=12'} - dev: true - - /argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true - - /array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - dev: true - - /balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - dev: true - - /brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - dev: true - - /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 - dev: true - - /browserslist@4.22.2: - resolution: {integrity: sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - dependencies: - caniuse-lite: 1.0.30001572 - electron-to-chromium: 1.4.620 - node-releases: 2.0.14 - update-browserslist-db: 1.0.13(browserslist@4.22.2) - dev: true - - /callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - dev: true - - /caniuse-lite@1.0.30001572: - resolution: {integrity: sha512-1Pbh5FLmn5y4+QhNyJE9j3/7dK44dGB83/ZMjv/qJk86TvDbjk0LosiZo0i0WB0Vx607qMX9jYrn1VLHCkN4rw==} - 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 - dev: true - - /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 - dev: true - - /color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - dependencies: - color-name: 1.1.3 - dev: true - - /color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - dependencies: - color-name: 1.1.4 - dev: true - - /color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - dev: true - - /color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - dev: true - - /concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - dev: true - - /convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - 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 - dev: true - - /csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - dev: true - - /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 - dev: true - - /deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dev: true - - /dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - dependencies: - path-type: 4.0.0 - dev: true - - /doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - dependencies: - esutils: 2.0.3 - dev: true - - /eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - dev: true - - /electron-to-chromium@1.4.620: - resolution: {integrity: sha512-a2fcSHOHrqBJsPNXtf6ZCEZpXrFCcbK1FBxfX3txoqWzNgtEDG1f3M59M98iwxhRW4iMKESnSjbJ310/rkrp0g==} - dev: true - - /emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - dev: true - - /emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - dev: true - - /esbuild@0.19.11: - resolution: {integrity: sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==} - engines: {node: '>=12'} - hasBin: true - requiresBuild: true - optionalDependencies: - '@esbuild/aix-ppc64': 0.19.11 - '@esbuild/android-arm': 0.19.11 - '@esbuild/android-arm64': 0.19.11 - '@esbuild/android-x64': 0.19.11 - '@esbuild/darwin-arm64': 0.19.11 - '@esbuild/darwin-x64': 0.19.11 - '@esbuild/freebsd-arm64': 0.19.11 - '@esbuild/freebsd-x64': 0.19.11 - '@esbuild/linux-arm': 0.19.11 - '@esbuild/linux-arm64': 0.19.11 - '@esbuild/linux-ia32': 0.19.11 - '@esbuild/linux-loong64': 0.19.11 - '@esbuild/linux-mips64el': 0.19.11 - '@esbuild/linux-ppc64': 0.19.11 - '@esbuild/linux-riscv64': 0.19.11 - '@esbuild/linux-s390x': 0.19.11 - '@esbuild/linux-x64': 0.19.11 - '@esbuild/netbsd-x64': 0.19.11 - '@esbuild/openbsd-x64': 0.19.11 - '@esbuild/sunos-x64': 0.19.11 - '@esbuild/win32-arm64': 0.19.11 - '@esbuild/win32-ia32': 0.19.11 - '@esbuild/win32-x64': 0.19.11 - dev: true - - /escalade@3.1.1: - resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} - engines: {node: '>=6'} - dev: true - - /escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - dev: true - - /escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - dev: true - - /eslint-plugin-react-hooks@4.6.0(eslint@8.55.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.55.0 - dev: true - - /eslint-plugin-react-refresh@0.4.5(eslint@8.55.0): - resolution: {integrity: sha512-D53FYKJa+fDmZMtriODxvhwrO+IOqrxoEo21gMA0sjHdU6dPVH4OhyFip9ypl8HOF5RV5KdTo+rBQLvnY2cO8w==} - peerDependencies: - eslint: '>=7' - dependencies: - eslint: 8.55.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@8.55.0: - resolution: {integrity: sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - hasBin: true - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.55.0) - '@eslint-community/regexpp': 4.10.0 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.55.0 - '@humanwhocodes/config-array': 0.11.13 - '@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 - 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.0 - 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@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 - - /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@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - dev: true - - /esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - dev: true - - /events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - dev: false - - /fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - 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==} - dev: true - - /fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - dev: true - - /fastq@1.16.0: - resolution: {integrity: sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==} - dependencies: - reusify: 1.0.4 - 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 - - /fill-range@7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} - engines: {node: '>=8'} - dependencies: - to-regex-range: 5.0.1 - dev: true - - /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.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} - dependencies: - flatted: 3.2.9 - keyv: 4.5.4 - rimraf: 3.0.2 - dev: true - - /flatted@3.2.9: - resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} - 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 - - /fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - dev: true - - /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 - dev: true - optional: true - - /gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - dev: true - - /glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - dependencies: - is-glob: 4.0.3 - dev: true - - /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@10.3.10: - resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - dependencies: - foreground-child: 3.1.1 - jackspeak: 2.3.6 - minimatch: 9.0.3 - minipass: 7.0.4 - path-scurry: 1.10.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 - dev: true - - /globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} - dev: true - - /globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} - dependencies: - type-fest: 0.20.2 - 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.0 - merge2: 1.4.1 - slash: 3.0.0 - dev: true - - /google-protobuf@3.21.2: - resolution: {integrity: sha512-3MSOYFO5U9mPGikIYCzK0SaThypfGgS6bHqrUGXG3DPHCrb+txNqeEcns1W0lkGfk0rCyNXm7xB9rMxnCiZOoA==} - dev: false - - /graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - dev: true - - /has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} - dev: true - - /has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - dev: true - - /ignore@5.3.0: - resolution: {integrity: sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==} - engines: {node: '>= 4'} - dev: true - - /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 - dev: true - - /imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - dev: true - - /inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - dev: true - - /inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true - - /is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - dev: true - - /is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - dev: true - - /is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - dependencies: - is-extglob: 2.1.1 - dev: true - - /is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - dev: true - - /is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - dev: true - - /isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - 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 - - /js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - /js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - dependencies: - argparse: 2.0.1 - dev: true - - /jsesc@2.5.2: - resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} - engines: {node: '>=4'} - hasBin: true - dev: true - - /json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - dev: true - - /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 - dev: true - - /keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - dependencies: - json-buffer: 3.0.1 - dev: true - - /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 - - /locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - dependencies: - p-locate: 5.0.0 - dev: true - - /lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - dev: true - - /loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - dependencies: - js-tokens: 4.0.0 - dev: false - - /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 - dev: true - - /lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - dependencies: - yallist: 4.0.0 - dev: true - - /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 - dev: true - - /minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - dependencies: - brace-expansion: 1.1.11 - 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 - - /minipass@7.0.4: - resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} - engines: {node: '>=16 || 14 >=14.17'} - dev: true - - /ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true - - /nanoid@3.3.7: - resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - dev: true - - /natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - dev: true - - /node-releases@2.0.14: - resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} - dev: true - - /once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - dependencies: - wrappy: 1.0.2 - 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 - - /p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - dependencies: - yocto-queue: 0.1.0 - dev: true - - /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 - - /parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - dependencies: - callsites: 3.1.0 - dev: true - - /path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - dev: true - - /path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - dev: true - - /path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - dev: true - - /path-scurry@1.10.1: - resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} - 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'} - dev: true - - /picocolors@1.0.0: - resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - dev: true - - /picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - dev: true - - /postcss@8.4.32: - resolution: {integrity: sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==} - engines: {node: ^10 || ^12 || >=14} - dependencies: - nanoid: 3.3.7 - picocolors: 1.0.0 - source-map-js: 1.0.2 - dev: true - - /prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - dev: true - - /protoc-gen-ts@0.8.7: - resolution: {integrity: sha512-jr4VJey2J9LVYCV7EVyVe53g1VMw28cCmYJhBe5e3YX5wiyiDwgxWxeDf9oTqAe4P1bN/YGAkW2jhlH8LohwiQ==} - hasBin: true - dev: false - - /punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - dev: true - - /queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true - - /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 - dev: false - - /react-refresh@0.14.0: - resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} - engines: {node: '>=0.10.0'} - dev: true - - /react@18.2.0: - resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} - engines: {node: '>=0.10.0'} - dependencies: - loose-envify: 1.4.0 - dev: false - - /resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - 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@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - hasBin: true - dependencies: - glob: 7.2.3 - dev: true - - /rimraf@5.0.5: - resolution: {integrity: sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==} - engines: {node: '>=14'} - hasBin: true - dependencies: - glob: 10.3.10 - dev: true - - /rollup@4.9.2: - resolution: {integrity: sha512-66RB8OtFKUTozmVEh3qyNfH+b+z2RXBVloqO2KCC/pjFaGaHtxP9fVfOQKPSGXg2mElmjmxjW/fZ7iKrEpMH5Q==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.9.2 - '@rollup/rollup-android-arm64': 4.9.2 - '@rollup/rollup-darwin-arm64': 4.9.2 - '@rollup/rollup-darwin-x64': 4.9.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.9.2 - '@rollup/rollup-linux-arm64-gnu': 4.9.2 - '@rollup/rollup-linux-arm64-musl': 4.9.2 - '@rollup/rollup-linux-riscv64-gnu': 4.9.2 - '@rollup/rollup-linux-x64-gnu': 4.9.2 - '@rollup/rollup-linux-x64-musl': 4.9.2 - '@rollup/rollup-win32-arm64-msvc': 4.9.2 - '@rollup/rollup-win32-ia32-msvc': 4.9.2 - '@rollup/rollup-win32-x64-msvc': 4.9.2 - fsevents: 2.3.3 - dev: true - - /run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - dependencies: - queue-microtask: 1.2.3 - dev: true - - /scheduler@0.23.0: - resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} - dependencies: - loose-envify: 1.4.0 - dev: false - - /semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - dev: true - - /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 - - /shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - dependencies: - shebang-regex: 3.0.0 - dev: true - - /shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - dev: true - - /signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - dev: true - - /slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - dev: true - - /source-map-js@1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} - engines: {node: '>=0.10.0'} - dev: true - - /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 - dev: true - - /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 - - /strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - dependencies: - ansi-regex: 5.0.1 - dev: true - - /strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} - engines: {node: '>=12'} - dependencies: - ansi-regex: 6.0.1 - dev: true - - /strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - dev: true - - /supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - dependencies: - has-flag: 3.0.0 - dev: true - - /supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - dependencies: - has-flag: 4.0.0 - dev: true - - /text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - dev: true - - /to-fast-properties@2.0.0: - resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} - engines: {node: '>=4'} - dev: true - - /to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - dependencies: - is-number: 7.0.0 - dev: true - - /ts-api-utils@1.0.3(typescript@5.2.2): - resolution: {integrity: sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==} - engines: {node: '>=16.13.0'} - peerDependencies: - typescript: '>=4.2.0' - dependencies: - typescript: 5.2.2 - dev: true - - /ts-results@3.3.0: - resolution: {integrity: sha512-FWqxGX2NHp5oCyaMd96o2y2uMQmSu8Dey6kvyuFdRJ2AzfmWo3kWa4UsPlCGlfQ/qu03m09ZZtppMoY8EMHuiA==} - dev: false - - /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-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - dev: true - - /typescript@5.2.2: - resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} - engines: {node: '>=14.17'} - hasBin: true - dev: true - - /undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - dev: true - - /update-browserslist-db@1.0.13(browserslist@4.22.2): - resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - dependencies: - browserslist: 4.22.2 - escalade: 3.1.1 - picocolors: 1.0.0 - dev: true - - /uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - dependencies: - punycode: 2.3.1 - dev: true - - /uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - hasBin: true - dev: false - - /vite-plugin-wasm@3.3.0(vite@5.0.8): - resolution: {integrity: sha512-tVhz6w+W9MVsOCHzxo6SSMSswCeIw4HTrXEi6qL3IRzATl83jl09JVO1djBqPSwfjgnpVHNLYcaMbaDX5WB/pg==} - peerDependencies: - vite: ^2 || ^3 || ^4 || ^5 - dependencies: - vite: 5.0.8(@types/node@20.10.6) - dev: true - - /vite@5.0.8(@types/node@20.10.6): - resolution: {integrity: sha512-jYMALd8aeqR3yS9xlHd0OzQJndS9fH5ylVgWdB+pxTwxLKdO1pgC5Dlb398BUxpfaBxa4M9oT7j1g503Gaj5IQ==} - 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.10.6 - esbuild: 0.19.11 - postcss: 8.4.32 - rollup: 4.9.2 - optionalDependencies: - fsevents: 2.3.3 - dev: true - - /which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - dependencies: - isexe: 2.0.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 - dev: true - - /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==} - dev: true - - /yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - dev: true - - /yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - dev: true - - /yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - dev: true diff --git a/frontend/appflowy_web/public/vite.svg b/frontend/appflowy_web/public/vite.svg deleted file mode 100644 index e7b8dfb1b2..0000000000 --- a/frontend/appflowy_web/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/appflowy_web/src/@types/global.d.ts b/frontend/appflowy_web/src/@types/global.d.ts deleted file mode 100644 index 6b17142a09..0000000000 --- a/frontend/appflowy_web/src/@types/global.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface NotifyArgs { - source: string; - ty: number; - id: string; - payload?: Unit8Array; - error?: Unit8Array; -} - -declare global { - interface Window { - onFlowyNotify: (eventName: string, args: NotifyArgs) => void; - } -} - -export {}; diff --git a/frontend/appflowy_web/src/App.css b/frontend/appflowy_web/src/App.css deleted file mode 100644 index b9d355df2a..0000000000 --- a/frontend/appflowy_web/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/frontend/appflowy_web/src/App.tsx b/frontend/appflowy_web/src/App.tsx deleted file mode 100644 index 6acdc9a892..0000000000 --- a/frontend/appflowy_web/src/App.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import reactLogo from "./assets/react.svg"; -import viteLogo from "/vite.svg"; -import "./App.css"; -import { useEffect } from "react"; -import { initApp } from "@/application/app.ts"; -import { subscribeNotification } from "@/application/notification.ts"; -import { NotifyArgs } from "./@types/global"; -import { init_tracing_log, init_wasm_core } from "../wasm-libs/af-wasm/pkg"; -import { v4 as uuidv4 } from 'uuid'; -import {AddUserPB, UserWasmEventAddUser} from "@/services/backend/events/user"; - -init_tracing_log(); -// FIXME: handle the promise that init_wasm_core returns -init_wasm_core(); - -function App() { - useEffect(() => { - initApp(); - return subscribeNotification((event: NotifyArgs) => { - console.log(event); - }); - }, []); - - const handleClick = async () => { - let email = `${uuidv4()}@example.com`; - let password = "AppFlowy!2024"; - const payload = AddUserPB.fromObject({email: email, password: password }) - let result = await UserWasmEventAddUser(payload); - if (!result.ok) { - - } - }; - - return ( - <> - -

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ); -} - -export default App; diff --git a/frontend/appflowy_web/src/application/app.ts b/frontend/appflowy_web/src/application/app.ts deleted file mode 100644 index 918d5583b1..0000000000 --- a/frontend/appflowy_web/src/application/app.ts +++ /dev/null @@ -1,35 +0,0 @@ - - -import { initEventBus } from "./event_bus.ts"; -import {async_event, register_listener} from "../../wasm-libs/af-wasm/pkg"; - -export function initApp() { - initEventBus(); - register_listener(); -} - -type InvokeArgs = Record; - -export async function invoke(cmd: string, args?: InvokeArgs): Promise { - switch (cmd) { - case "invoke_request": - const request = args?.request as { ty?: unknown, payload?: unknown } | undefined; - if (!request || typeof request !== 'object') { - throw new Error("Invalid or missing 'request' argument in 'invoke_request'"); - } - - const { ty, payload } = request; - - if (typeof ty !== 'string') { - throw new Error("Invalid 'ty' in request for 'invoke_request'"); - } - - if (!(payload instanceof Array)) { - throw new Error("Invalid 'payload' in request for 'invoke_request'"); - } - - return async_event(ty, new Uint8Array(payload)); - default: - throw new Error(`Unknown command: ${cmd}`); - } -} diff --git a/frontend/appflowy_web/src/application/event_bus.ts b/frontend/appflowy_web/src/application/event_bus.ts deleted file mode 100644 index 6a3e95804f..0000000000 --- a/frontend/appflowy_web/src/application/event_bus.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { EventEmitter } from "events"; -import { NotifyArgs } from "../@types/global"; - -const AF_NOTIFICATION = "af-notification"; - -let eventEmitter: EventEmitter; -export function getEventEmitterInstance() { - if (!eventEmitter) { - eventEmitter = new EventEmitter(); - } - return eventEmitter; -} - -export function initEventBus() { - window.onFlowyNotify = (eventName: string, args: NotifyArgs) => { - notify(eventName, args); - }; -} - -export function notify(_eventName: string, args: NotifyArgs) { - const eventEmitter = getEventEmitterInstance(); - eventEmitter.emit(AF_NOTIFICATION, args); -} - -export function onNotify(callback: (args: NotifyArgs) => void) { - const eventEmitter = getEventEmitterInstance(); - eventEmitter.on(AF_NOTIFICATION, callback); - return offNotify; -} - -export function offNotify() { - const eventEmitter = getEventEmitterInstance(); - eventEmitter.removeAllListeners(AF_NOTIFICATION); -} diff --git a/frontend/appflowy_web/src/application/notification.ts b/frontend/appflowy_web/src/application/notification.ts deleted file mode 100644 index aa9b0188e9..0000000000 --- a/frontend/appflowy_web/src/application/notification.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NotifyArgs } from "../@types/global"; -import { onNotify } from "./event_bus.ts"; - -export function subscribeNotification( - callback: (args: NotifyArgs) => void, - options?: { id?: string } -) { - return onNotify((payload) => { - const { id } = payload; - - if (options?.id !== undefined && id !== options.id) { - return; - } - - callback(payload); - }); -} diff --git a/frontend/appflowy_web/src/assets/react.svg b/frontend/appflowy_web/src/assets/react.svg deleted file mode 100644 index 6c87de9bb3..0000000000 --- a/frontend/appflowy_web/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/appflowy_web/src/index.css b/frontend/appflowy_web/src/index.css deleted file mode 100644 index 6119ad9a8f..0000000000 --- a/frontend/appflowy_web/src/index.css +++ /dev/null @@ -1,68 +0,0 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} diff --git a/frontend/appflowy_web/src/main.tsx b/frontend/appflowy_web/src/main.tsx deleted file mode 100644 index 3d7150da80..0000000000 --- a/frontend/appflowy_web/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -) diff --git a/frontend/appflowy_web/src/services/backend/index.ts b/frontend/appflowy_web/src/services/backend/index.ts deleted file mode 100644 index d01244a3b1..0000000000 --- a/frontend/appflowy_web/src/services/backend/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./models/af-user"; -export * from "./models/flowy-error"; -export * from "./models/flowy-folder"; -export * from "./models/flowy-document"; -export * from "./models/flowy-notification"; \ No newline at end of file diff --git a/frontend/appflowy_web/src/vite-env.d.ts b/frontend/appflowy_web/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/frontend/appflowy_web/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/frontend/appflowy_web/tsconfig.json b/frontend/appflowy_web/tsconfig.json deleted file mode 100644 index 67609fa96f..0000000000 --- a/frontend/appflowy_web/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "typeRoots": ["./node_modules/@types", "./src/@types"], - - "baseUrl": "./", - "paths": { - "@/*": ["src/*"], - } - }, - "include": ["src", "vite.config.ts"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/frontend/appflowy_web/tsconfig.node.json b/frontend/appflowy_web/tsconfig.node.json deleted file mode 100644 index 42872c59f5..0000000000 --- a/frontend/appflowy_web/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/frontend/appflowy_web/vite.config.ts b/frontend/appflowy_web/vite.config.ts deleted file mode 100644 index 49a8c2b3ad..0000000000 --- a/frontend/appflowy_web/vite.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import wasm from "vite-plugin-wasm"; - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react(), wasm()], - resolve: { - alias: [ - { find: 'src/', replacement: `${__dirname}/src/` }, - { find: '@/', replacement: `${__dirname}/src/` }, - { find: '$app/', replacement: `${__dirname}/src/application/` }, - ], - }, -}) diff --git a/frontend/appflowy_web/wasm-libs/Cargo.lock b/frontend/appflowy_web/wasm-libs/Cargo.lock deleted file mode 100644 index c85cb2ae63..0000000000 --- a/frontend/appflowy_web/wasm-libs/Cargo.lock +++ /dev/null @@ -1,4993 +0,0 @@ -# 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.48", -] - -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" -dependencies = [ - "cfg-if 1.0.0", - "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 = "af-persistence" -version = "0.1.0" -dependencies = [ - "anyhow", - "flowy-error", - "futures-util", - "indexed_db_futures", - "js-sys", - "serde", - "serde_json", - "thiserror", - "tokio", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "af-user" -version = "0.1.0" -dependencies = [ - "af-persistence", - "anyhow", - "bytes", - "collab", - "collab-entity", - "collab-integrate", - "collab-user", - "flowy-codegen", - "flowy-derive", - "flowy-error", - "flowy-user-pub", - "lib-dispatch", - "lib-infra", - "protobuf", - "strum_macros 0.25.3", - "tokio", - "tracing", - "wasm-bindgen-futures", -] - -[[package]] -name = "af-wasm" -version = "0.1.0" -dependencies = [ - "af-persistence", - "af-user", - "anyhow", - "collab", - "collab-integrate", - "console_error_panic_hook", - "flowy-document", - "flowy-error", - "flowy-folder", - "flowy-notification", - "flowy-server", - "flowy-server-pub", - "flowy-storage", - "flowy-user-pub", - "js-sys", - "lazy_static", - "lib-dispatch", - "lib-infra", - "parking_lot 0.12.1", - "serde", - "serde-wasm-bindgen", - "tokio", - "tokio-stream", - "tracing", - "tracing-core", - "tracing-wasm", - "uuid", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test", - "web-sys", - "wee_alloc", -] - -[[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.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c3a9648d43b9cd48db467b3f87fdd6e146bcc88ab0180006cef2179fe11d01" -dependencies = [ - "cfg-if 1.0.0", - "getrandom 0.2.12", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" -dependencies = [ - "memchr", -] - -[[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 = "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.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" - -[[package]] -name = "app-error" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "bincode", - "getrandom 0.2.12", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "tokio", - "tsify", - "url", - "uuid", - "wasm-bindgen", -] - -[[package]] -name = "arc-swap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" - -[[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.48", -] - -[[package]] -name = "async-trait" -version = "0.1.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[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.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" -dependencies = [ - "addr2line", - "cc", - "cfg-if 1.0.0", - "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 = "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.65.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" -dependencies = [ - "bitflags 1.3.2", - "cexpr", - "clang-sys", - "lazy_static", - "lazycell", - "peeking_take_while", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn 2.0.48", -] - -[[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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" - -[[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 = "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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "bumpalo" -version = "3.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" -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 = "cc" -version = "1.0.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" -dependencies = [ - "jobserver", - "libc", -] - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "chrono" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f13690e35a5e4ace198e7beea2895d29f3a9cc55015fcebe6336bd2010af9eb" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-targets 0.52.0", -] - -[[package]] -name = "chrono-tz" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91d7b79e99bfaa0d47da0687c43aa3b7381938a62ad3a6498599039321f660b7" -dependencies = [ - "chrono", - "chrono-tz-build", - "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 = "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "again", - "anyhow", - "app-error", - "async-trait", - "bincode", - "brotli", - "bytes", - "chrono", - "client-websocket", - "collab", - "collab-entity", - "collab-rt-entity", - "collab-rt-protocol", - "database-entity", - "futures-core", - "futures-util", - "getrandom 0.2.12", - "gotrue", - "gotrue-entity", - "mime", - "parking_lot 0.12.1", - "prost", - "reqwest", - "scraper 0.17.1", - "semver", - "serde", - "serde_json", - "serde_repr", - "shared-entity", - "thiserror", - "tokio", - "tokio-retry", - "tokio-stream", - "tokio-util", - "tracing", - "url", - "uuid", - "wasm-bindgen-futures", - "yrs", -] - -[[package]] -name = "client-websocket" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -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.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 = "collab" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "async-trait", - "bincode", - "bytes", - "chrono", - "js-sys", - "parking_lot 0.12.1", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "tokio", - "tokio-stream", - "tracing", - "unicode-segmentation", - "web-sys", - "yrs", -] - -[[package]] -name = "collab-document" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "getrandom 0.2.12", - "nanoid", - "parking_lot 0.12.1", - "serde", - "serde_json", - "thiserror", - "tokio", - "tokio-stream", - "tracing", -] - -[[package]] -name = "collab-entity" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "bytes", - "collab", - "getrandom 0.2.12", - "serde", - "serde_json", - "serde_repr", - "uuid", -] - -[[package]] -name = "collab-folder" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "chrono", - "collab", - "collab-entity", - "getrandom 0.2.12", - "parking_lot 0.12.1", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "tokio", - "tokio-stream", - "tracing", -] - -[[package]] -name = "collab-integrate" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "collab", - "collab-entity", - "collab-plugins", - "futures", - "lib-infra", - "parking_lot 0.12.1", - "serde", - "serde_json", - "tokio", - "tracing", -] - -[[package]] -name = "collab-plugins" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -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", - "parking_lot 0.12.1", - "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "bincode", - "bytes", - "chrono", - "client-websocket", - "collab", - "collab-entity", - "collab-rt-protocol", - "database-entity", - "prost", - "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -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=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "getrandom 0.2.12", - "parking_lot 0.12.1", - "serde", - "serde_json", - "tokio", - "tokio-stream", - "tracing", -] - -[[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 = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if 1.0.0", - "wasm-bindgen", -] - -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - -[[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 = "cpufeatures" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" -dependencies = [ - "cfg-if 1.0.0", -] - -[[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.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" - -[[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.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "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.48", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if 1.0.0", - "hashbrown", - "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "app-error", - "bincode", - "chrono", - "collab-entity", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "tracing", - "uuid", - "validator", -] - -[[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.48", -] - -[[package]] -name = "deranged" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" -dependencies = [ - "powerfmt", -] - -[[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_more" -version = "0.99.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "deunicode" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae2a35373c5c74340b79ae6780b498b2b183915ec5dacf263aac5a099bf485a" - -[[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 = "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 = "dyn-clone" -version = "1.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" - -[[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.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" - -[[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 1.0.0", -] - -[[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 = "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_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.48", -] - -[[package]] -name = "fastrand" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" -dependencies = [ - "getrandom 0.2.12", -] - -[[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.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[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", - "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", - "walkdir", -] - -[[package]] -name = "flowy-database-pub" -version = "0.1.0" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "lib-infra", -] - -[[package]] -name = "flowy-derive" -version = "0.1.0" -dependencies = [ - "dashmap", - "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", - "flowy-codegen", - "flowy-derive", - "flowy-document-pub", - "flowy-error", - "flowy-notification", - "flowy-storage", - "futures", - "getrandom 0.2.12", - "indexmap", - "lib-dispatch", - "lib-infra", - "nanoid", - "parking_lot 0.12.1", - "protobuf", - "scraper 0.18.1", - "serde", - "serde_json", - "strum_macros 0.21.1", - "tokio", - "tokio-stream", - "tracing", - "uuid", - "validator", -] - -[[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-document", - "collab-folder", - "collab-plugins", - "fancy-regex 0.11.0", - "flowy-codegen", - "flowy-derive", - "lib-dispatch", - "protobuf", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "tokio", - "url", - "validator", -] - -[[package]] -name = "flowy-folder" -version = "0.1.0" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "collab", - "collab-document", - "collab-entity", - "collab-folder", - "collab-integrate", - "collab-plugins", - "flowy-codegen", - "flowy-derive", - "flowy-error", - "flowy-folder-pub", - "flowy-notification", - "flowy-search-pub", - "lazy_static", - "lib-dispatch", - "lib-infra", - "nanoid", - "parking_lot 0.12.1", - "protobuf", - "serde_json", - "strum_macros 0.21.1", - "tokio", - "tokio-stream", - "tracing", - "unicode-segmentation", - "uuid", - "validator", -] - -[[package]] -name = "flowy-folder-pub" -version = "0.1.0" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "collab-folder", - "lib-infra", - "uuid", -] - -[[package]] -name = "flowy-notification" -version = "0.1.0" -dependencies = [ - "bytes", - "dashmap", - "flowy-codegen", - "flowy-derive", - "lazy_static", - "lib-dispatch", - "protobuf", - "serde", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "flowy-search-pub" -version = "0.1.0" -dependencies = [ - "collab", - "collab-folder", - "flowy-error", -] - -[[package]] -name = "flowy-server" -version = "0.1.0" -dependencies = [ - "anyhow", - "bytes", - "chrono", - "client-api", - "collab", - "collab-document", - "collab-entity", - "collab-folder", - "collab-plugins", - "flowy-database-pub", - "flowy-document-pub", - "flowy-encrypt", - "flowy-error", - "flowy-folder-pub", - "flowy-server-pub", - "flowy-storage", - "flowy-user-pub", - "futures", - "futures-util", - "hex", - "hyper", - "lazy_static", - "lib-dispatch", - "lib-infra", - "mime_guess", - "parking_lot 0.12.1", - "postgrest", - "rand 0.8.5", - "reqwest", - "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-storage" -version = "0.1.0" -dependencies = [ - "async-trait", - "bytes", - "flowy-error", - "fxhash", - "lib-infra", - "mime", - "mime_guess", - "reqwest", - "serde", - "serde_json", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "flowy-user-pub" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64 0.21.7", - "chrono", - "collab", - "collab-entity", - "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 = "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-macro" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[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 = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[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 1.0.0", - "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 1.0.0", - "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.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" - -[[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", - "regex-syntax", -] - -[[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 = "gotrue" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "app-error", - "chrono", - "jsonwebtoken", - "lazy_static", - "serde", - "serde_json", -] - -[[package]] -name = "h2" -version = "0.3.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" - -[[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.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d3d0e0f38255e7fa3cf31335b3a56f05febd18025f4db5ef7a0cfb4f8da651f" - -[[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 = "http" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[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 = "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", - "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.59" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6a67363e2aa4443928ce15e57ebae94fd8949958fd1223c4cfc0cd473ad7539" -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 = "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.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" -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 = "if_chain" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" - -[[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", - "same-file", - "walkdir", - "winapi-util", -] - -[[package]] -name = "indexed_db_futures" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cc2083760572ee02385ab8b7c02c20925d2dd1f97a1a25a8737a238608f1152" -dependencies = [ - "accessory", - "cfg-if 1.0.0", - "delegate-display", - "fancy_constructor", - "js-sys", - "uuid", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "indexmap" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" -dependencies = [ - "equivalent", - "hashbrown", - "serde", -] - -[[package]] -name = "infra" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "reqwest", - "serde", - "serde_json", - "tracing", -] - -[[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 1.0.0", -] - -[[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.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bad00257d07be169d870ab665980b06cdb366d792ad690bf2e76876dc503455" -dependencies = [ - "hermit-abi", - "rustix", - "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 = "itoa" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" - -[[package]] -name = "jobserver" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" -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 = "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 = "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 = "lib-dispatch" -version = "0.1.0" -dependencies = [ - "bincode", - "bytes", - "derivative", - "dyn-clone", - "futures", - "futures-channel", - "futures-core", - "futures-util", - "getrandom 0.2.12", - "nanoid", - "parking_lot 0.12.1", - "pin-project", - "protobuf", - "serde", - "serde_json", - "serde_repr", - "thread-id", - "tokio", - "tracing", - "validator", - "wasm-bindgen", - "wasm-bindgen-futures", -] - -[[package]] -name = "lib-infra" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "atomic_refcell", - "bytes", - "chrono", - "futures-core", - "md5", - "pin-project", - "tempfile", - "tokio", - "tracing", - "validator", - "walkdir", - "zip", -] - -[[package]] -name = "libc" -version = "0.2.152" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" - -[[package]] -name = "libloading" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c571b676ddfc9a8c12f1f3d3085a7b163966a8fd8098a90640953ce5f6170161" -dependencies = [ - "cfg-if 1.0.0", - "windows-sys 0.48.0", -] - -[[package]] -name = "libm" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" - -[[package]] -name = "librocksdb-sys" -version = "0.11.0+8.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e" -dependencies = [ - "bindgen", - "bzip2-sys", - "cc", - "glob", - "libc", - "libz-sys", - "zstd-sys", -] - -[[package]] -name = "libz-sys" -version = "1.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "295c17e837573c8c821dbaeb3cceb3d745ad082f7572191409e69cbc1b3fd050" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" - -[[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 = "log" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" - -[[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 1.0.0", - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[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.48", -] - -[[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.48", -] - -[[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 = "md5" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" - -[[package]] -name = "memchr" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" - -[[package]] -name = "memory_units" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" -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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" -dependencies = [ - "adler", -] - -[[package]] -name = "mio" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" -dependencies = [ - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - -[[package]] -name = "multimap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" - -[[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 = "new_debug_unreachable" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" - -[[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 = "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-integer" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" -dependencies = [ - "autocfg", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" -dependencies = [ - "autocfg", -] - -[[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 = "opaque-debug" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" - -[[package]] -name = "openssl" -version = "0.10.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8" -dependencies = [ - "bitflags 2.4.2", - "cfg-if 1.0.0", - "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.48", -] - -[[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.1+3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fe476c29791a5ca0d1273c697e96085bbabbbea2ef7afd5617e78a4b40332d3" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - -[[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 = "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 1.0.0", - "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 1.0.0", - "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 = "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 = "peeking_take_while" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" - -[[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.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f200d8d83c44a45b21764d1916299752ca035d15ecd46faca3e9a2a2bf6ad06" -dependencies = [ - "memchr", - "thiserror", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcd6ab1236bbdb3a49027e920e693192ebfe8913f6d60e294de57463a493cfde" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a31940305ffc96863a735bef7c7994a00b325a7138fdbc5bda0f1a0476d3275" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "pest_meta" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7ff62f5259e53b78d1af898941cdcdccfae7385cf7d793a6e55de5d05bb4b7d" -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", -] - -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_macros", - "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_shared 0.11.2", -] - -[[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_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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0302c4a0442c456bd56f841aee5c3bfd17967563f6fadc9ceb9f9c23cf3807e0" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[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.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2900ede94e305130c13ddd391e0ab7cbaeb783945ae07a279c268cb05109c6cb" - -[[package]] -name = "polyval" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb" -dependencies = [ - "cfg-if 1.0.0", - "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.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41cf62165e97c7f814d2221421dbb9afcbcdb0a88068e5ea206e19951c2cbb5" -dependencies = [ - "proc-macro2", - "syn 2.0.48", -] - -[[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-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.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" -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", -] - -[[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", - "log", - "multimap", - "once_cell", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "regex", - "syn 2.0.48", - "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", - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "prost-types" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" -dependencies = [ - "prost", -] - -[[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 = "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 = "quote" -version = "1.0.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" -dependencies = [ - "proc-macro2", -] - -[[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_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 = "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 = "regex" -version = "1.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" - -[[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", -] - -[[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.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" -dependencies = [ - "cc", - "getrandom 0.2.12", - "libc", - "spin 0.9.8", - "untrusted 0.9.0", - "windows-sys 0.48.0", -] - -[[package]] -name = "rocksdb" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe" -dependencies = [ - "libc", - "librocksdb-sys", -] - -[[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 = "rustix" -version = "0.38.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" -dependencies = [ - "bitflags 2.4.2", - "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.7", - "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.7", - "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 = "ryu" -version = "1.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" - -[[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 = "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 = "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", - "cssparser", - "ego-tree", - "getopts", - "html5ever", - "once_cell", - "selectors", - "smallvec", - "tendril", -] - -[[package]] -name = "scraper" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585480e3719b311b78a573db1c9d9c4c1f8010c2dee4cc59c2efe58ea4dbc3e1" -dependencies = [ - "ahash", - "cssparser", - "ego-tree", - "getopts", - "html5ever", - "once_cell", - "selectors", - "tendril", -] - -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring 0.17.7", - "untrusted 0.9.0", -] - -[[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.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" -dependencies = [ - "bitflags 2.4.2", - "cssparser", - "derive_more", - "fxhash", - "log", - "new_debug_unreachable", - "phf 0.10.1", - "phf_codegen 0.10.0", - "precomputed-hash", - "servo_arc", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" - -[[package]] -name = "serde" -version = "1.0.195" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde-wasm-bindgen" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3b4c031cd0d9014307d82b8abf653c0290fbdaeb4c02d00c63cf52f728628bf" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - -[[package]] -name = "serde_derive" -version = "1.0.195" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[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.48", -] - -[[package]] -name = "serde_json" -version = "1.0.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" -dependencies = [ - "itoa", - "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.48", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[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 1.0.0", - "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 1.0.0", - "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "app-error", - "chrono", - "collab-entity", - "database-entity", - "gotrue-entity", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "uuid", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[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 = "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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" - -[[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 = "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 = "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 = "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.48", -] - -[[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.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[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 = "tempfile" -version = "3.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" -dependencies = [ - "cfg-if 1.0.0", - "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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8" -dependencies = [ - "chrono", - "chrono-tz", - "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 = "thiserror" -version = "1.0.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[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.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" -dependencies = [ - "cfg-if 1.0.0", - "once_cell", -] - -[[package]] -name = "time" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" -dependencies = [ - "deranged", - "itoa", - "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.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f" -dependencies = [ - "time-core", -] - -[[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 = "tokio" -version = "1.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" -dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.48.0", -] - -[[package]] -name = "tokio-macros" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[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.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" -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.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", - "tracing", -] - -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - -[[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.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "tracing-core" -version = "0.1.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" -dependencies = [ - "sharded-slab", - "thread_local", - "tracing-core", -] - -[[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 = "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.48", -] - -[[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-ident" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" - -[[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.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", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "uuid" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" -dependencies = [ - "getrandom 0.2.12", - "serde", - "sha1_smol", - "wasm-bindgen", -] - -[[package]] -name = "validator" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" -dependencies = [ - "idna 0.4.0", - "lazy_static", - "regex", - "serde", - "serde_derive", - "serde_json", - "url", - "validator_derive", -] - -[[package]] -name = "validator_derive" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc44ca3088bb3ba384d9aecf40c6a23a676ce23e09bdaca2073d99c207f864af" -dependencies = [ - "if_chain", - "lazy_static", - "proc-macro-error", - "proc-macro2", - "quote", - "regex", - "syn 1.0.109", - "validator_types", -] - -[[package]] -name = "validator_types" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111abfe30072511849c5910134e8baf8dc05de4c0e5903d681cbd5c9c4d611e3" -dependencies = [ - "proc-macro2", - "syn 1.0.109", -] - -[[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_check" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" - -[[package]] -name = "walkdir" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" -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 1.0.0", - "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.48", - "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 1.0.0", - "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.48", - "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-bindgen-test" -version = "0.3.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139bd73305d50e1c1c4333210c0db43d989395b64a237bd35c10ef3832a7f70c" -dependencies = [ - "console_error_panic_hook", - "js-sys", - "scoped-tls", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test-macro", -] - -[[package]] -name = "wasm-bindgen-test-macro" -version = "0.3.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70072aebfe5da66d2716002c729a14e4aec4da0e23cc2ea66323dac541c93928" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[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.67" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58cd2333b6e0be7a39605f0e255892fd7418a682d8da8fe042fe25128794d2ed" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1778a42e8b3b90bff8d0f5032bf22250792889a5cdc752aa0020c84abe3aaf10" - -[[package]] -name = "wee_alloc" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e" -dependencies = [ - "cfg-if 0.1.10", - "libc", - "memory_units", - "winapi", -] - -[[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-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-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.0", -] - -[[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.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_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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" - -[[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" - -[[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" - -[[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" - -[[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" - -[[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" - -[[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if 1.0.0", - "windows-sys 0.48.0", -] - -[[package]] -name = "yrs" -version = "0.18.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d58fbc807677598fedfab76f99f6e1aa5c644411255002b5438ea0ab14672398" -dependencies = [ - "arc-swap", - "atomic_refcell", - "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.48", -] - -[[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", - "crc32fast", - "crossbeam-utils", - "flate2", - "hmac", - "pbkdf2 0.11.0", - "sha1", - "time", - "zstd", -] - -[[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", -] - -[[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-sys" -version = "2.0.9+zstd.1.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/frontend/appflowy_web/wasm-libs/Cargo.toml b/frontend/appflowy_web/wasm-libs/Cargo.toml deleted file mode 100644 index bd2427c83d..0000000000 --- a/frontend/appflowy_web/wasm-libs/Cargo.toml +++ /dev/null @@ -1,70 +0,0 @@ -[workspace] -members = ["af-wasm", "af-user", "af-persistence"] -resolver = "2" - -[workspace.dependencies] -af-user = { path = "af-user" } -af-persistence = { path = "af-persistence" } -lib-dispatch = { path = "../../rust-lib/lib-dispatch" } -parking_lot = { version = "0.12.1" } -tracing = { version = "0.1.22" } -serde = { version = "1.0.194", features = ["derive"] } -serde_json = "1.0" -collab-integrate = { path = "../../rust-lib/collab-integrate" } -flowy-notification = { path = "../../rust-lib/flowy-notification" } -flowy-user-pub = { path = "../../rust-lib/flowy-user-pub" } -flowy-server = { path = "../../rust-lib/flowy-server" } -flowy-server-pub = { path = "../../rust-lib/flowy-server-pub" } -flowy-error = { path = "../../rust-lib/flowy-error" } -flowy-derive = { path = "../../rust-lib/build-tool/flowy-derive" } -flowy-codegen = { path = "../../rust-lib/build-tool/flowy-codegen" } -flowy-document = { path = "../../rust-lib/flowy-document" } -flowy-folder = { path = "../../rust-lib/flowy-folder" } -flowy-storage = { path = "../../rust-lib/flowy-storage" } -lib-infra = { path = "../../rust-lib/lib-infra" } -bytes = { version = "1.5" } -protobuf = { version = "2.28.0" } -thiserror = "1.0" -anyhow = "1.0" -futures-util = "0.3" -uuid = { version = "1.5", features = ["serde", "v4", "v5"] } -tokio-stream = "0.1" -tokio = { version = "1.35", features = ["sync"] } -wasm-bindgen-futures = "0.4.40" -serde-wasm-bindgen = "0.4" -# 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 = "870cd70" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -yrs = "0.18.7" - -# 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 = "ef8e6f3" } - - - -[profile.dev] -opt-level = 0 -lto = false -codegen-units = 16 - -[profile.release] -lto = true -opt-level = 3 -codegen-units = 1 diff --git a/frontend/appflowy_web/wasm-libs/af-persistence/Cargo.toml b/frontend/appflowy_web/wasm-libs/af-persistence/Cargo.toml deleted file mode 100644 index 14825ef54a..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-persistence/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "af-persistence" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -indexed_db_futures = { version = "0.4" } -js-sys = "0.3" -wasm-bindgen = "0.2" -web-sys = { version = "0.3", features = ["console", "Window"] } -tokio = { version = "1.26.0", features = ["sync", "rt"] } -flowy-error.workspace = true -thiserror.workspace = true -anyhow.workspace = true -serde.workspace = true -serde_json.workspace = true -futures-util.workspace = true -wasm-bindgen-futures.workspace = true diff --git a/frontend/appflowy_web/wasm-libs/af-persistence/src/error.rs b/frontend/appflowy_web/wasm-libs/af-persistence/src/error.rs deleted file mode 100644 index 1a6ad08d9a..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-persistence/src/error.rs +++ /dev/null @@ -1,30 +0,0 @@ -use flowy_error::FlowyError; -use web_sys::DomException; - -#[derive(Debug, thiserror::Error)] -pub enum PersistenceError { - #[error(transparent)] - Internal(#[from] anyhow::Error), - - #[error(transparent)] - SerdeError(#[from] serde_json::Error), - - #[error("{0}")] - RecordNotFound(String), -} - -impl From for PersistenceError { - fn from(value: DomException) -> Self { - PersistenceError::Internal(anyhow::anyhow!("DOMException: {:?}", value)) - } -} - -impl From for FlowyError { - fn from(value: PersistenceError) -> Self { - match value { - PersistenceError::Internal(value) => FlowyError::internal().with_context(value), - PersistenceError::SerdeError(value) => FlowyError::serde().with_context(value), - PersistenceError::RecordNotFound(value) => FlowyError::record_not_found().with_context(value), - } - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-persistence/src/lib.rs b/frontend/appflowy_web/wasm-libs/af-persistence/src/lib.rs deleted file mode 100644 index 4f89971020..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-persistence/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod error; -pub mod store; diff --git a/frontend/appflowy_web/wasm-libs/af-persistence/src/store.rs b/frontend/appflowy_web/wasm-libs/af-persistence/src/store.rs deleted file mode 100644 index 92335e32b3..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-persistence/src/store.rs +++ /dev/null @@ -1,192 +0,0 @@ -use crate::error::PersistenceError; -use anyhow::anyhow; -use futures_util::future::LocalBoxFuture; -use indexed_db_futures::idb_object_store::IdbObjectStore; -use indexed_db_futures::idb_transaction::{IdbTransaction, IdbTransactionResult}; -use indexed_db_futures::prelude::IdbOpenDbRequestLike; -use indexed_db_futures::{IdbDatabase, IdbQuerySource, IdbVersionChangeEvent}; -use js_sys::Uint8Array; -use serde::de::DeserializeOwned; -use serde::Serialize; -use std::future::Future; -use std::sync::Arc; -use tokio::sync::RwLock; -use wasm_bindgen::JsValue; -use web_sys::IdbTransactionMode; - -pub trait IndexddbStore { - fn get<'a, T>(&'a self, key: &'a str) -> LocalBoxFuture, PersistenceError>> - where - T: serde::de::DeserializeOwned + Sync; - fn set<'a, T>(&'a self, key: &'a str, value: T) -> LocalBoxFuture> - where - T: serde::Serialize + Sync + 'a; - fn remove<'a>(&'a self, key: &'a str) -> LocalBoxFuture>; -} - -const APPFLOWY_STORE: &str = "appflowy_store"; -pub struct AppFlowyWASMStore { - db: Arc>, -} - -unsafe impl Send for AppFlowyWASMStore {} -unsafe impl Sync for AppFlowyWASMStore {} - -impl AppFlowyWASMStore { - pub async fn new() -> Result { - let mut db_req = IdbDatabase::open_u32("appflowy", 1)?; - db_req.set_on_upgrade_needed(Some(|evt: &IdbVersionChangeEvent| -> Result<(), JsValue> { - if !evt.db().object_store_names().any(|n| &n == APPFLOWY_STORE) { - evt.db().create_object_store(APPFLOWY_STORE)?; - } - Ok(()) - })); - let db = Arc::new(RwLock::new(db_req.await?)); - Ok(Self { db }) - } - - pub async fn begin_read_transaction(&self, f: F) -> Result<(), PersistenceError> - where - F: FnOnce(&IndexddbStoreImpl<'_>) -> Fut, - Fut: Future>, - { - let db = self.db.read().await; - let txn = db.transaction_on_one_with_mode(APPFLOWY_STORE, IdbTransactionMode::Readonly)?; - let store = store_from_transaction(&txn)?; - let operation = IndexddbStoreImpl(store); - f(&operation).await?; - transaction_result_to_result(txn.await)?; - Ok(()) - } - - pub async fn begin_write_transaction(&self, f: F) -> Result<(), PersistenceError> - where - F: FnOnce(IndexddbStoreImpl<'_>) -> LocalBoxFuture<'_, Result<(), PersistenceError>>, - { - let db = self.db.write().await; - let txn = db.transaction_on_one_with_mode(APPFLOWY_STORE, IdbTransactionMode::Readwrite)?; - let store = store_from_transaction(&txn)?; - let operation = IndexddbStoreImpl(store); - f(operation).await?; - transaction_result_to_result(txn.await)?; - Ok(()) - } -} - -pub struct IndexddbStoreImpl<'i>(IdbObjectStore<'i>); - -impl<'i> IndexddbStore for IndexddbStoreImpl<'i> { - fn get<'a, T>(&'a self, key: &'a str) -> LocalBoxFuture, PersistenceError>> - where - T: DeserializeOwned + Sync, - { - let js_key = to_js_value(key); - Box::pin(async move { - match self.0.get(&js_key)?.await? { - None => Err(PersistenceError::RecordNotFound(format!( - "Can't find the value for given key: {} ", - key - ))), - Some(value) => { - let bytes = Uint8Array::new(&value).to_vec(); - let object = serde_json::from_slice::(&bytes)?; - Ok(Some(object)) - }, - } - }) - } - - fn set<'a, T>(&'a self, key: &'a str, value: T) -> LocalBoxFuture> - where - T: Serialize + Sync + 'a, - { - let js_key = to_js_value(key); - Box::pin(async move { - let js_value = to_js_value(serde_json::to_vec(&value)?); - self.0.put_key_val(&js_key, &js_value)?.await?; - Ok(()) - }) - } - - fn remove<'a>(&'a self, key: &'a str) -> LocalBoxFuture> { - let js_key = to_js_value(key); - Box::pin(async move { - self.0.delete(&js_key)?.await?; - Ok(()) - }) - } -} - -impl IndexddbStore for AppFlowyWASMStore { - fn get<'a, T>(&'a self, key: &'a str) -> LocalBoxFuture, PersistenceError>> - where - T: serde::de::DeserializeOwned + Sync, - { - let db = Arc::downgrade(&self.db); - Box::pin(async move { - let db = db.upgrade().ok_or_else(|| { - PersistenceError::Internal(anyhow!("Failed to upgrade the database reference")) - })?; - let read_guard = db.read().await; - let txn = - read_guard.transaction_on_one_with_mode(APPFLOWY_STORE, IdbTransactionMode::Readonly)?; - let store = store_from_transaction(&txn)?; - IndexddbStoreImpl(store).get(key).await - }) - } - - fn set<'a, T>(&'a self, key: &'a str, value: T) -> LocalBoxFuture> - where - T: serde::Serialize + Sync + 'a, - { - let db = Arc::downgrade(&self.db); - Box::pin(async move { - let db = db.upgrade().ok_or_else(|| { - PersistenceError::Internal(anyhow!("Failed to upgrade the database reference")) - })?; - let read_guard = db.read().await; - let txn = - read_guard.transaction_on_one_with_mode(APPFLOWY_STORE, IdbTransactionMode::Readwrite)?; - let store = store_from_transaction(&txn)?; - IndexddbStoreImpl(store).set(key, value).await?; - transaction_result_to_result(txn.await)?; - Ok(()) - }) - } - - fn remove<'a>(&'a self, key: &'a str) -> LocalBoxFuture> { - let db = Arc::downgrade(&self.db); - Box::pin(async move { - let db = db.upgrade().ok_or_else(|| { - PersistenceError::Internal(anyhow!("Failed to upgrade the database reference")) - })?; - let read_guard = db.read().await; - let txn = - read_guard.transaction_on_one_with_mode(APPFLOWY_STORE, IdbTransactionMode::Readwrite)?; - let store = store_from_transaction(&txn)?; - IndexddbStoreImpl(store).remove(key).await?; - transaction_result_to_result(txn.await)?; - Ok(()) - }) - } -} - -fn to_js_value>(key: K) -> JsValue { - JsValue::from(Uint8Array::from(key.as_ref())) -} - -fn store_from_transaction<'a>( - txn: &'a IdbTransaction<'a>, -) -> Result, PersistenceError> { - txn - .object_store(APPFLOWY_STORE) - .map_err(PersistenceError::from) -} - -fn transaction_result_to_result(result: IdbTransactionResult) -> Result<(), PersistenceError> { - match result { - IdbTransactionResult::Success => Ok(()), - IdbTransactionResult::Error(err) => Err(PersistenceError::from(err)), - IdbTransactionResult::Abort => Err(PersistenceError::Internal(anyhow!("Transaction aborted"))), - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-user/Cargo.toml b/frontend/appflowy_web/wasm-libs/af-user/Cargo.toml deleted file mode 100644 index 27351d8f06..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "af-user" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -af-persistence.workspace = true -lib-dispatch = { workspace = true } -flowy-derive = { workspace = true } -flowy-error = { workspace = true, features = ["impl_from_dispatch_error", "impl_from_collab_persistence"] } -flowy-user-pub = { workspace = true } -strum_macros = "0.25.2" -tracing.workspace = true -lib-infra = { workspace = true } -collab = { workspace = true } -collab-entity.workspace = true -collab-user.workspace = true -collab-integrate = { workspace = true } -protobuf.workspace = true -bytes.workspace = true -anyhow.workspace = true -wasm-bindgen-futures.workspace = true -tokio = { workspace = true, features = ["sync"] } - -[build-dependencies] -flowy-codegen = { workspace = true, features = ["ts"] } diff --git a/frontend/appflowy_web/wasm-libs/af-user/Flowy.toml b/frontend/appflowy_web/wasm-libs/af-user/Flowy.toml deleted file mode 100644 index 593c05b741..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/Flowy.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Check out the FlowyConfig (located in flowy_toml.rs) for more details. -proto_input = ["src/entities", "src/event_map.rs"] -event_files = ["src/event_map.rs"] \ No newline at end of file diff --git a/frontend/appflowy_web/wasm-libs/af-user/build.rs b/frontend/appflowy_web/wasm-libs/af-user/build.rs deleted file mode 100644 index 11b5513097..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/build.rs +++ /dev/null @@ -1,17 +0,0 @@ -use flowy_codegen::Project; - -fn main() { - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - "user", - Project::Web { - relative_path: "../../../".to_string(), - }, - ); - flowy_codegen::ts_event::gen( - "user", - Project::Web { - relative_path: "../../../".to_string(), - }, - ); -} diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/authenticate_user.rs b/frontend/appflowy_web/wasm-libs/af-user/src/authenticate_user.rs deleted file mode 100644 index 57ba448081..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/authenticate_user.rs +++ /dev/null @@ -1,20 +0,0 @@ -use af_persistence::store::AppFlowyWASMStore; -use flowy_error::FlowyResult; -use flowy_user_pub::session::Session; -use std::rc::Rc; -use std::sync::Arc; -use tokio::sync::RwLock; - -pub struct AuthenticateUser { - session: Arc>>, - store: Rc, -} - -impl AuthenticateUser { - pub async fn new(store: Rc) -> FlowyResult { - Ok(Self { - session: Arc::new(RwLock::new(None)), - store, - }) - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/define.rs b/frontend/appflowy_web/wasm-libs/af-user/src/define.rs deleted file mode 100644 index 520cf21f34..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/define.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub(crate) const AF_USER_SESSION_KEY: &str = "af-user-session"; - -pub(crate) fn user_workspace_key(uid: i64) -> String { - format!("af-user-workspaces-{}", uid) -} - -pub(crate) fn user_profile_key(uid: i64) -> String { - format!("af-user-profile-{}", uid) -} diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/entities/auth.rs b/frontend/appflowy_web/wasm-libs/af-user/src/entities/auth.rs deleted file mode 100644 index 96455adc5f..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/entities/auth.rs +++ /dev/null @@ -1,68 +0,0 @@ -use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; -use flowy_user_pub::entities::Authenticator; -use std::collections::HashMap; - -#[derive(ProtoBuf, Default)] -pub struct OauthSignInPB { - /// Use this field to store the third party auth information. - /// Different auth type has different fields. - /// Supabase: - /// - map: { "uuid": "xxx" } - /// - #[pb(index = 1)] - pub map: HashMap, - - #[pb(index = 2)] - pub authenticator: AuthenticatorPB, -} - -#[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] -pub enum AuthenticatorPB { - Local = 0, - Supabase = 1, - AppFlowyCloud = 2, -} - -impl From for AuthenticatorPB { - fn from(auth_type: Authenticator) -> Self { - match auth_type { - Authenticator::Supabase => AuthenticatorPB::Supabase, - Authenticator::Local => AuthenticatorPB::Local, - Authenticator::AppFlowyCloud => AuthenticatorPB::AppFlowyCloud, - } - } -} - -impl From for Authenticator { - fn from(pb: AuthenticatorPB) -> Self { - match pb { - AuthenticatorPB::Supabase => Authenticator::Supabase, - AuthenticatorPB::Local => Authenticator::Local, - AuthenticatorPB::AppFlowyCloud => Authenticator::AppFlowyCloud, - } - } -} - -impl Default for AuthenticatorPB { - fn default() -> Self { - Self::AppFlowyCloud - } -} - -#[derive(ProtoBuf, Default)] -pub struct AddUserPB { - #[pb(index = 1)] - pub email: String, - - #[pb(index = 2)] - pub password: String, -} - -#[derive(ProtoBuf, Default)] -pub struct UserSignInPB { - #[pb(index = 1)] - pub email: String, - - #[pb(index = 2)] - pub password: String, -} diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/entities/mod.rs b/frontend/appflowy_web/wasm-libs/af-user/src/entities/mod.rs deleted file mode 100644 index fd9d22da5d..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/entities/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod auth; -mod user; - -pub use auth::*; -pub use user::*; diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/entities/user.rs b/frontend/appflowy_web/wasm-libs/af-user/src/entities/user.rs deleted file mode 100644 index 0aa22d6e7a..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/entities/user.rs +++ /dev/null @@ -1,69 +0,0 @@ -use crate::entities::AuthenticatorPB; -use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; -use flowy_user_pub::entities::{EncryptionType, UserProfile}; - -#[derive(ProtoBuf, Default, Eq, PartialEq, Debug, Clone)] -pub struct UserProfilePB { - #[pb(index = 1)] - pub id: i64, - - #[pb(index = 2)] - pub email: String, - - #[pb(index = 3)] - pub name: String, - - #[pb(index = 4)] - pub token: String, - - #[pb(index = 5)] - pub icon_url: String, - - #[pb(index = 6)] - pub openai_key: String, - - #[pb(index = 7)] - pub authenticator: AuthenticatorPB, - - #[pb(index = 8)] - pub encryption_sign: String, - - #[pb(index = 9)] - pub workspace_id: String, - - #[pb(index = 10)] - pub stability_ai_key: String, -} - -impl From for UserProfilePB { - fn from(user_profile: UserProfile) -> Self { - let (encryption_sign, _encryption_ty) = match user_profile.encryption_type { - EncryptionType::NoEncryption => ("".to_string(), EncryptionTypePB::NoEncryption), - EncryptionType::SelfEncryption(sign) => (sign, EncryptionTypePB::Symmetric), - }; - Self { - id: user_profile.uid, - email: user_profile.email, - name: user_profile.name, - token: user_profile.token, - icon_url: user_profile.icon_url, - openai_key: user_profile.openai_key, - encryption_sign, - authenticator: user_profile.authenticator.into(), - workspace_id: user_profile.workspace_id, - stability_ai_key: user_profile.stability_ai_key, - } - } -} - -#[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] -pub enum EncryptionTypePB { - NoEncryption = 0, - Symmetric = 1, -} - -impl Default for EncryptionTypePB { - fn default() -> Self { - Self::NoEncryption - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/event_handler.rs b/frontend/appflowy_web/wasm-libs/af-user/src/event_handler.rs deleted file mode 100644 index 401f1e98ab..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/event_handler.rs +++ /dev/null @@ -1,48 +0,0 @@ -use crate::entities::*; -use crate::manager::UserManager; -use flowy_error::{FlowyError, FlowyResult}; -use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; -use lib_infra::box_any::BoxAny; -use std::rc::{Rc, Weak}; - -#[tracing::instrument(level = "debug", skip(data, manager), err)] -pub async fn oauth_sign_in_handler( - data: AFPluginData, - manager: AFPluginState>, -) -> DataResult { - let manager = upgrade_manager(manager)?; - let params = data.into_inner(); - let user_profile = manager.sign_up(BoxAny::new(params.map)).await?; - data_result_ok(user_profile.into()) -} - -#[tracing::instrument(level = "debug", skip(data, manager), err)] -pub async fn add_user_handler( - data: AFPluginData, - manager: AFPluginState>, -) -> Result<(), FlowyError> { - let manager = upgrade_manager(manager)?; - let params = data.into_inner(); - manager.add_user(¶ms.email, ¶ms.password).await?; - Ok(()) -} - -#[tracing::instrument(level = "debug", skip(data, manager), err)] -pub async fn sign_in_with_password_handler( - data: AFPluginData, - manager: AFPluginState>, -) -> DataResult { - let manager = upgrade_manager(manager)?; - let params = data.into_inner(); - let user_profile = manager - .sign_in_with_password(¶ms.email, ¶ms.password) - .await?; - data_result_ok(UserProfilePB::from(user_profile)) -} - -fn upgrade_manager(manager: AFPluginState>) -> FlowyResult> { - let manager = manager - .upgrade() - .ok_or(FlowyError::internal().with_context("The user session is already drop"))?; - Ok(manager) -} diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/event_map.rs b/frontend/appflowy_web/wasm-libs/af-user/src/event_map.rs deleted file mode 100644 index 1047760352..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/event_map.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::event_handler::*; -use crate::manager::UserManager; -use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; -use lib_dispatch::prelude::AFPlugin; -use std::rc::Weak; -use strum_macros::Display; - -#[rustfmt::skip] -pub fn init(user_manager: Weak) -> AFPlugin { - AFPlugin::new() - .name("Flowy-User") - .state(user_manager) - .event(UserWasmEvent::OauthSignIn, oauth_sign_in_handler) - .event(UserWasmEvent::AddUser, add_user_handler) - .event(UserWasmEvent::SignInPassword, sign_in_with_password_handler) -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] -#[event_err = "FlowyError"] -pub enum UserWasmEvent { - #[event(input = "OauthSignInPB", output = "UserProfilePB")] - OauthSignIn = 0, - - #[event(input = "AddUserPB")] - AddUser = 1, - - #[event(input = "UserSignInPB", output = "UserProfilePB")] - SignInPassword = 2, -} diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/lib.rs b/frontend/appflowy_web/wasm-libs/af-user/src/lib.rs deleted file mode 100644 index d3519c7552..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod authenticate_user; -mod define; -pub mod entities; -mod event_handler; -pub mod event_map; -pub mod manager; -mod protobuf; diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/manager.rs b/frontend/appflowy_web/wasm-libs/af-user/src/manager.rs deleted file mode 100644 index 3832218ae8..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/manager.rs +++ /dev/null @@ -1,205 +0,0 @@ -use crate::authenticate_user::AuthenticateUser; -use crate::define::{user_profile_key, user_workspace_key, AF_USER_SESSION_KEY}; -use af_persistence::store::{AppFlowyWASMStore, IndexddbStore}; -use anyhow::Context; -use collab::core::collab::DataSource; -use collab_entity::CollabType; -use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabBuilderConfig}; -use collab_integrate::{CollabKVDB, MutexCollab}; -use collab_user::core::{MutexUserAwareness, UserAwareness}; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_user_pub::cloud::{UserCloudConfig, UserCloudServiceProvider}; -use flowy_user_pub::entities::{ - user_awareness_object_id, AuthResponse, Authenticator, UserAuthResponse, UserProfile, - UserWorkspace, -}; -use flowy_user_pub::session::Session; -use lib_infra::box_any::BoxAny; -use lib_infra::future::Fut; -use std::rc::Rc; -use std::sync::{Arc, Mutex, Weak}; -use tracing::{error, instrument, trace}; - -pub trait UserCallback { - fn did_init( - &self, - user_id: i64, - cloud_config: &Option, - user_workspace: &UserWorkspace, - device_id: &str, - ) -> Fut>; - fn did_sign_in(&self, uid: i64, workspace: &UserWorkspace, device_id: &str) -> FlowyResult<()>; - fn did_sign_up( - &self, - is_new_user: bool, - user_profile: &UserProfile, - user_workspace: &UserWorkspace, - device_id: &str, - ) -> Fut>; -} - -pub struct UserManager { - device_id: String, - pub(crate) store: Rc, - pub(crate) cloud_services: Rc, - pub(crate) collab_builder: Weak, - pub(crate) authenticate_user: Rc, - - #[allow(dead_code)] - pub(crate) user_awareness: Rc>>, - pub(crate) collab_db: Arc, - - user_callbacks: Vec>, -} - -impl UserManager { - pub async fn new( - device_id: &str, - store: Rc, - cloud_services: Rc, - authenticate_user: Rc, - collab_builder: Weak, - ) -> Result { - let device_id = device_id.to_string(); - let store = Rc::new(AppFlowyWASMStore::new().await?); - let collab_db = Arc::new(CollabKVDB::new().await?); - Ok(Self { - device_id, - cloud_services, - collab_builder, - store, - authenticate_user, - user_callbacks: vec![], - user_awareness: Rc::new(Default::default()), - collab_db, - }) - } - - pub async fn sign_up(&self, params: BoxAny) -> FlowyResult { - let auth_service = self.cloud_services.get_user_service()?; - let response: AuthResponse = auth_service.sign_up(params).await?; - let new_user_profile = UserProfile::from((&response, &Authenticator::AppFlowyCloud)); - let new_session = Session::from(&response); - - self.prepare_collab(&new_session); - self - .save_auth_data(&response, &new_user_profile, &new_session) - .await?; - - for callback in self.user_callbacks.iter() { - if let Err(e) = callback - .did_sign_up( - response.is_new_user, - &new_user_profile, - &new_session.user_workspace, - &self.device_id, - ) - .await - { - error!("Failed to call did_sign_up callback: {:?}", e); - } - } - - // TODO(nathan): send notification - // send_auth_state_notification(AuthStateChangedPB { - // state: AuthStatePB::AuthStateSignIn, - // message: "Sign in success".to_string(), - // }); - Ok(new_user_profile) - } - - pub(crate) async fn add_user(&self, email: &str, password: &str) -> Result<(), FlowyError> { - let auth_service = self.cloud_services.get_user_service()?; - auth_service.create_user(email, password).await?; - Ok(()) - } - - pub(crate) async fn sign_in_with_password( - &self, - email: &str, - password: &str, - ) -> Result { - let auth_service = self.cloud_services.get_user_service()?; - let user_profile = auth_service.sign_in_with_password(email, password).await?; - Ok(user_profile) - } - - fn prepare_collab(&self, session: &Session) { - let collab_builder = self.collab_builder.upgrade().unwrap(); - collab_builder.initialize(session.user_workspace.id.clone()); - } - - #[instrument(level = "info", skip_all, err)] - async fn save_auth_data( - &self, - response: &impl UserAuthResponse, - user_profile: &UserProfile, - session: &Session, - ) -> Result<(), FlowyError> { - let uid = user_profile.uid; - let user_profile = user_profile.clone(); - let session = session.clone(); - let user_workspace = response.user_workspaces().to_vec(); - self - .store - .begin_write_transaction(|store| { - Box::pin(async move { - store.set(&user_workspace_key(uid), &user_workspace).await?; - store.set(AF_USER_SESSION_KEY, session).await?; - store.set(&user_profile_key(uid), user_profile).await?; - Ok(()) - }) - }) - .await?; - - Ok(()) - } - - pub async fn save_user_session(&self, session: &Session) -> FlowyResult<()> { - self.store.set(AF_USER_SESSION_KEY, session).await?; - Ok(()) - } - - pub async fn save_user_workspaces( - &self, - uid: i64, - user_workspaces: &[UserWorkspace], - ) -> FlowyResult<()> { - self - .store - .set(&user_workspace_key(uid), &user_workspaces.to_vec()) - .await?; - Ok(()) - } - - pub async fn save_user_profile(&self, user_profile: &UserProfile) -> FlowyResult<()> { - let uid = user_profile.uid; - self.store.set(&user_profile_key(uid), user_profile).await?; - Ok(()) - } - - async fn collab_for_user_awareness( - &self, - uid: i64, - object_id: &str, - collab_db: Weak, - raw_data: Vec, - ) -> Result, FlowyError> { - let collab_builder = self.collab_builder.upgrade().ok_or(FlowyError::new( - ErrorCode::Internal, - "Unexpected error: collab builder is not available", - ))?; - let collab = collab_builder - .build( - uid, - object_id, - CollabType::UserAwareness, - DataSource::DocStateV1(raw_data), - collab_db, - CollabBuilderConfig::default().sync_enable(true), - ) - .await - .context("Build collab for user awareness failed")?; - Ok(collab) - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/auth.rs b/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/auth.rs deleted file mode 100644 index 6dd0bd20f0..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/auth.rs +++ /dev/null @@ -1,689 +0,0 @@ -// This file is generated by rust-protobuf 2.28.0. Do not edit -// @generated - -// https://github.com/rust-lang/rust-clippy/issues/702 -#![allow(unknown_lints)] -#![allow(clippy::all)] - -#![allow(unused_attributes)] -#![cfg_attr(rustfmt, rustfmt::skip)] - -#![allow(box_pointers)] -#![allow(dead_code)] -#![allow(missing_docs)] -#![allow(non_camel_case_types)] -#![allow(non_snake_case)] -#![allow(non_upper_case_globals)] -#![allow(trivial_casts)] -#![allow(unused_imports)] -#![allow(unused_results)] -//! Generated file from `auth.proto` - -/// Generated files are compatible only with the same version -/// of protobuf runtime. -// const _PROTOBUF_VERSION_CHECK: () = ::protobuf::VERSION_2_28_0; - -#[derive(PartialEq,Clone,Default)] -pub struct OauthSignInPB { - // message fields - pub map: ::std::collections::HashMap<::std::string::String, ::std::string::String>, - pub authenticator: AuthenticatorPB, - // special fields - pub unknown_fields: ::protobuf::UnknownFields, - pub cached_size: ::protobuf::CachedSize, -} - -impl<'a> ::std::default::Default for &'a OauthSignInPB { - fn default() -> &'a OauthSignInPB { - ::default_instance() - } -} - -impl OauthSignInPB { - pub fn new() -> OauthSignInPB { - ::std::default::Default::default() - } - - // repeated .OauthSignInPB.MapEntry map = 1; - - - pub fn get_map(&self) -> &::std::collections::HashMap<::std::string::String, ::std::string::String> { - &self.map - } - pub fn clear_map(&mut self) { - self.map.clear(); - } - - // Param is passed by value, moved - pub fn set_map(&mut self, v: ::std::collections::HashMap<::std::string::String, ::std::string::String>) { - self.map = v; - } - - // Mutable pointer to the field. - pub fn mut_map(&mut self) -> &mut ::std::collections::HashMap<::std::string::String, ::std::string::String> { - &mut self.map - } - - // Take field - pub fn take_map(&mut self) -> ::std::collections::HashMap<::std::string::String, ::std::string::String> { - ::std::mem::replace(&mut self.map, ::std::collections::HashMap::new()) - } - - // .AuthenticatorPB authenticator = 2; - - - pub fn get_authenticator(&self) -> AuthenticatorPB { - self.authenticator - } - pub fn clear_authenticator(&mut self) { - self.authenticator = AuthenticatorPB::Local; - } - - // Param is passed by value, moved - pub fn set_authenticator(&mut self, v: AuthenticatorPB) { - self.authenticator = v; - } -} - -impl ::protobuf::Message for OauthSignInPB { - fn is_initialized(&self) -> bool { - true - } - - fn merge_from(&mut self, is: &mut ::protobuf::CodedInputStream<'_>) -> ::protobuf::ProtobufResult<()> { - while !is.eof()? { - let (field_number, wire_type) = is.read_tag_unpack()?; - match field_number { - 1 => { - ::protobuf::rt::read_map_into::<::protobuf::types::ProtobufTypeString, ::protobuf::types::ProtobufTypeString>(wire_type, is, &mut self.map)?; - }, - 2 => { - ::protobuf::rt::read_proto3_enum_with_unknown_fields_into(wire_type, is, &mut self.authenticator, 2, &mut self.unknown_fields)? - }, - _ => { - ::protobuf::rt::read_unknown_or_skip_group(field_number, wire_type, is, self.mut_unknown_fields())?; - }, - }; - } - ::std::result::Result::Ok(()) - } - - // Compute sizes of nested messages - #[allow(unused_variables)] - fn compute_size(&self) -> u32 { - let mut my_size = 0; - my_size += ::protobuf::rt::compute_map_size::<::protobuf::types::ProtobufTypeString, ::protobuf::types::ProtobufTypeString>(1, &self.map); - if self.authenticator != AuthenticatorPB::Local { - my_size += ::protobuf::rt::enum_size(2, self.authenticator); - } - my_size += ::protobuf::rt::unknown_fields_size(self.get_unknown_fields()); - self.cached_size.set(my_size); - my_size - } - - fn write_to_with_cached_sizes(&self, os: &mut ::protobuf::CodedOutputStream<'_>) -> ::protobuf::ProtobufResult<()> { - ::protobuf::rt::write_map_with_cached_sizes::<::protobuf::types::ProtobufTypeString, ::protobuf::types::ProtobufTypeString>(1, &self.map, os)?; - if self.authenticator != AuthenticatorPB::Local { - os.write_enum(2, ::protobuf::ProtobufEnum::value(&self.authenticator))?; - } - os.write_unknown_fields(self.get_unknown_fields())?; - ::std::result::Result::Ok(()) - } - - fn get_cached_size(&self) -> u32 { - self.cached_size.get() - } - - fn get_unknown_fields(&self) -> &::protobuf::UnknownFields { - &self.unknown_fields - } - - fn mut_unknown_fields(&mut self) -> &mut ::protobuf::UnknownFields { - &mut self.unknown_fields - } - - fn as_any(&self) -> &dyn (::std::any::Any) { - self as &dyn (::std::any::Any) - } - fn as_any_mut(&mut self) -> &mut dyn (::std::any::Any) { - self as &mut dyn (::std::any::Any) - } - fn into_any(self: ::std::boxed::Box) -> ::std::boxed::Box { - self - } - - fn descriptor(&self) -> &'static ::protobuf::reflect::MessageDescriptor { - Self::descriptor_static() - } - - fn new() -> OauthSignInPB { - OauthSignInPB::new() - } - - fn descriptor_static() -> &'static ::protobuf::reflect::MessageDescriptor { - static descriptor: ::protobuf::rt::LazyV2<::protobuf::reflect::MessageDescriptor> = ::protobuf::rt::LazyV2::INIT; - descriptor.get(|| { - let mut fields = ::std::vec::Vec::new(); - fields.push(::protobuf::reflect::accessor::make_map_accessor::<_, ::protobuf::types::ProtobufTypeString, ::protobuf::types::ProtobufTypeString>( - "map", - |m: &OauthSignInPB| { &m.map }, - |m: &mut OauthSignInPB| { &mut m.map }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeEnum>( - "authenticator", - |m: &OauthSignInPB| { &m.authenticator }, - |m: &mut OauthSignInPB| { &mut m.authenticator }, - )); - ::protobuf::reflect::MessageDescriptor::new_pb_name::( - "OauthSignInPB", - fields, - file_descriptor_proto() - ) - }) - } - - fn default_instance() -> &'static OauthSignInPB { - static instance: ::protobuf::rt::LazyV2 = ::protobuf::rt::LazyV2::INIT; - instance.get(OauthSignInPB::new) - } -} - -impl ::protobuf::Clear for OauthSignInPB { - fn clear(&mut self) { - self.map.clear(); - self.authenticator = AuthenticatorPB::Local; - self.unknown_fields.clear(); - } -} - -impl ::std::fmt::Debug for OauthSignInPB { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - ::protobuf::text_format::fmt(self, f) - } -} - -impl ::protobuf::reflect::ProtobufValue for OauthSignInPB { - fn as_ref(&self) -> ::protobuf::reflect::ReflectValueRef { - ::protobuf::reflect::ReflectValueRef::Message(self) - } -} - -#[derive(PartialEq,Clone,Default)] -pub struct AddUserPB { - // message fields - pub email: ::std::string::String, - pub password: ::std::string::String, - // special fields - pub unknown_fields: ::protobuf::UnknownFields, - pub cached_size: ::protobuf::CachedSize, -} - -impl<'a> ::std::default::Default for &'a AddUserPB { - fn default() -> &'a AddUserPB { - ::default_instance() - } -} - -impl AddUserPB { - pub fn new() -> AddUserPB { - ::std::default::Default::default() - } - - // string email = 1; - - - pub fn get_email(&self) -> &str { - &self.email - } - pub fn clear_email(&mut self) { - self.email.clear(); - } - - // Param is passed by value, moved - pub fn set_email(&mut self, v: ::std::string::String) { - self.email = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_email(&mut self) -> &mut ::std::string::String { - &mut self.email - } - - // Take field - pub fn take_email(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.email, ::std::string::String::new()) - } - - // string password = 2; - - - pub fn get_password(&self) -> &str { - &self.password - } - pub fn clear_password(&mut self) { - self.password.clear(); - } - - // Param is passed by value, moved - pub fn set_password(&mut self, v: ::std::string::String) { - self.password = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_password(&mut self) -> &mut ::std::string::String { - &mut self.password - } - - // Take field - pub fn take_password(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.password, ::std::string::String::new()) - } -} - -impl ::protobuf::Message for AddUserPB { - fn is_initialized(&self) -> bool { - true - } - - fn merge_from(&mut self, is: &mut ::protobuf::CodedInputStream<'_>) -> ::protobuf::ProtobufResult<()> { - while !is.eof()? { - let (field_number, wire_type) = is.read_tag_unpack()?; - match field_number { - 1 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.email)?; - }, - 2 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.password)?; - }, - _ => { - ::protobuf::rt::read_unknown_or_skip_group(field_number, wire_type, is, self.mut_unknown_fields())?; - }, - }; - } - ::std::result::Result::Ok(()) - } - - // Compute sizes of nested messages - #[allow(unused_variables)] - fn compute_size(&self) -> u32 { - let mut my_size = 0; - if !self.email.is_empty() { - my_size += ::protobuf::rt::string_size(1, &self.email); - } - if !self.password.is_empty() { - my_size += ::protobuf::rt::string_size(2, &self.password); - } - my_size += ::protobuf::rt::unknown_fields_size(self.get_unknown_fields()); - self.cached_size.set(my_size); - my_size - } - - fn write_to_with_cached_sizes(&self, os: &mut ::protobuf::CodedOutputStream<'_>) -> ::protobuf::ProtobufResult<()> { - if !self.email.is_empty() { - os.write_string(1, &self.email)?; - } - if !self.password.is_empty() { - os.write_string(2, &self.password)?; - } - os.write_unknown_fields(self.get_unknown_fields())?; - ::std::result::Result::Ok(()) - } - - fn get_cached_size(&self) -> u32 { - self.cached_size.get() - } - - fn get_unknown_fields(&self) -> &::protobuf::UnknownFields { - &self.unknown_fields - } - - fn mut_unknown_fields(&mut self) -> &mut ::protobuf::UnknownFields { - &mut self.unknown_fields - } - - fn as_any(&self) -> &dyn (::std::any::Any) { - self as &dyn (::std::any::Any) - } - fn as_any_mut(&mut self) -> &mut dyn (::std::any::Any) { - self as &mut dyn (::std::any::Any) - } - fn into_any(self: ::std::boxed::Box) -> ::std::boxed::Box { - self - } - - fn descriptor(&self) -> &'static ::protobuf::reflect::MessageDescriptor { - Self::descriptor_static() - } - - fn new() -> AddUserPB { - AddUserPB::new() - } - - fn descriptor_static() -> &'static ::protobuf::reflect::MessageDescriptor { - static descriptor: ::protobuf::rt::LazyV2<::protobuf::reflect::MessageDescriptor> = ::protobuf::rt::LazyV2::INIT; - descriptor.get(|| { - let mut fields = ::std::vec::Vec::new(); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "email", - |m: &AddUserPB| { &m.email }, - |m: &mut AddUserPB| { &mut m.email }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "password", - |m: &AddUserPB| { &m.password }, - |m: &mut AddUserPB| { &mut m.password }, - )); - ::protobuf::reflect::MessageDescriptor::new_pb_name::( - "AddUserPB", - fields, - file_descriptor_proto() - ) - }) - } - - fn default_instance() -> &'static AddUserPB { - static instance: ::protobuf::rt::LazyV2 = ::protobuf::rt::LazyV2::INIT; - instance.get(AddUserPB::new) - } -} - -impl ::protobuf::Clear for AddUserPB { - fn clear(&mut self) { - self.email.clear(); - self.password.clear(); - self.unknown_fields.clear(); - } -} - -impl ::std::fmt::Debug for AddUserPB { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - ::protobuf::text_format::fmt(self, f) - } -} - -impl ::protobuf::reflect::ProtobufValue for AddUserPB { - fn as_ref(&self) -> ::protobuf::reflect::ReflectValueRef { - ::protobuf::reflect::ReflectValueRef::Message(self) - } -} - -#[derive(PartialEq,Clone,Default)] -pub struct UserSignInPB { - // message fields - pub email: ::std::string::String, - pub password: ::std::string::String, - // special fields - pub unknown_fields: ::protobuf::UnknownFields, - pub cached_size: ::protobuf::CachedSize, -} - -impl<'a> ::std::default::Default for &'a UserSignInPB { - fn default() -> &'a UserSignInPB { - ::default_instance() - } -} - -impl UserSignInPB { - pub fn new() -> UserSignInPB { - ::std::default::Default::default() - } - - // string email = 1; - - - pub fn get_email(&self) -> &str { - &self.email - } - pub fn clear_email(&mut self) { - self.email.clear(); - } - - // Param is passed by value, moved - pub fn set_email(&mut self, v: ::std::string::String) { - self.email = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_email(&mut self) -> &mut ::std::string::String { - &mut self.email - } - - // Take field - pub fn take_email(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.email, ::std::string::String::new()) - } - - // string password = 2; - - - pub fn get_password(&self) -> &str { - &self.password - } - pub fn clear_password(&mut self) { - self.password.clear(); - } - - // Param is passed by value, moved - pub fn set_password(&mut self, v: ::std::string::String) { - self.password = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_password(&mut self) -> &mut ::std::string::String { - &mut self.password - } - - // Take field - pub fn take_password(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.password, ::std::string::String::new()) - } -} - -impl ::protobuf::Message for UserSignInPB { - fn is_initialized(&self) -> bool { - true - } - - fn merge_from(&mut self, is: &mut ::protobuf::CodedInputStream<'_>) -> ::protobuf::ProtobufResult<()> { - while !is.eof()? { - let (field_number, wire_type) = is.read_tag_unpack()?; - match field_number { - 1 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.email)?; - }, - 2 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.password)?; - }, - _ => { - ::protobuf::rt::read_unknown_or_skip_group(field_number, wire_type, is, self.mut_unknown_fields())?; - }, - }; - } - ::std::result::Result::Ok(()) - } - - // Compute sizes of nested messages - #[allow(unused_variables)] - fn compute_size(&self) -> u32 { - let mut my_size = 0; - if !self.email.is_empty() { - my_size += ::protobuf::rt::string_size(1, &self.email); - } - if !self.password.is_empty() { - my_size += ::protobuf::rt::string_size(2, &self.password); - } - my_size += ::protobuf::rt::unknown_fields_size(self.get_unknown_fields()); - self.cached_size.set(my_size); - my_size - } - - fn write_to_with_cached_sizes(&self, os: &mut ::protobuf::CodedOutputStream<'_>) -> ::protobuf::ProtobufResult<()> { - if !self.email.is_empty() { - os.write_string(1, &self.email)?; - } - if !self.password.is_empty() { - os.write_string(2, &self.password)?; - } - os.write_unknown_fields(self.get_unknown_fields())?; - ::std::result::Result::Ok(()) - } - - fn get_cached_size(&self) -> u32 { - self.cached_size.get() - } - - fn get_unknown_fields(&self) -> &::protobuf::UnknownFields { - &self.unknown_fields - } - - fn mut_unknown_fields(&mut self) -> &mut ::protobuf::UnknownFields { - &mut self.unknown_fields - } - - fn as_any(&self) -> &dyn (::std::any::Any) { - self as &dyn (::std::any::Any) - } - fn as_any_mut(&mut self) -> &mut dyn (::std::any::Any) { - self as &mut dyn (::std::any::Any) - } - fn into_any(self: ::std::boxed::Box) -> ::std::boxed::Box { - self - } - - fn descriptor(&self) -> &'static ::protobuf::reflect::MessageDescriptor { - Self::descriptor_static() - } - - fn new() -> UserSignInPB { - UserSignInPB::new() - } - - fn descriptor_static() -> &'static ::protobuf::reflect::MessageDescriptor { - static descriptor: ::protobuf::rt::LazyV2<::protobuf::reflect::MessageDescriptor> = ::protobuf::rt::LazyV2::INIT; - descriptor.get(|| { - let mut fields = ::std::vec::Vec::new(); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "email", - |m: &UserSignInPB| { &m.email }, - |m: &mut UserSignInPB| { &mut m.email }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "password", - |m: &UserSignInPB| { &m.password }, - |m: &mut UserSignInPB| { &mut m.password }, - )); - ::protobuf::reflect::MessageDescriptor::new_pb_name::( - "UserSignInPB", - fields, - file_descriptor_proto() - ) - }) - } - - fn default_instance() -> &'static UserSignInPB { - static instance: ::protobuf::rt::LazyV2 = ::protobuf::rt::LazyV2::INIT; - instance.get(UserSignInPB::new) - } -} - -impl ::protobuf::Clear for UserSignInPB { - fn clear(&mut self) { - self.email.clear(); - self.password.clear(); - self.unknown_fields.clear(); - } -} - -impl ::std::fmt::Debug for UserSignInPB { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - ::protobuf::text_format::fmt(self, f) - } -} - -impl ::protobuf::reflect::ProtobufValue for UserSignInPB { - fn as_ref(&self) -> ::protobuf::reflect::ReflectValueRef { - ::protobuf::reflect::ReflectValueRef::Message(self) - } -} - -#[derive(Clone,PartialEq,Eq,Debug,Hash)] -pub enum AuthenticatorPB { - Local = 0, - Supabase = 1, - AppFlowyCloud = 2, -} - -impl ::protobuf::ProtobufEnum for AuthenticatorPB { - fn value(&self) -> i32 { - *self as i32 - } - - fn from_i32(value: i32) -> ::std::option::Option { - match value { - 0 => ::std::option::Option::Some(AuthenticatorPB::Local), - 1 => ::std::option::Option::Some(AuthenticatorPB::Supabase), - 2 => ::std::option::Option::Some(AuthenticatorPB::AppFlowyCloud), - _ => ::std::option::Option::None - } - } - - fn values() -> &'static [Self] { - static values: &'static [AuthenticatorPB] = &[ - AuthenticatorPB::Local, - AuthenticatorPB::Supabase, - AuthenticatorPB::AppFlowyCloud, - ]; - values - } - - fn enum_descriptor_static() -> &'static ::protobuf::reflect::EnumDescriptor { - static descriptor: ::protobuf::rt::LazyV2<::protobuf::reflect::EnumDescriptor> = ::protobuf::rt::LazyV2::INIT; - descriptor.get(|| { - ::protobuf::reflect::EnumDescriptor::new_pb_name::("AuthenticatorPB", file_descriptor_proto()) - }) - } -} - -impl ::std::marker::Copy for AuthenticatorPB { -} - -impl ::std::default::Default for AuthenticatorPB { - fn default() -> Self { - AuthenticatorPB::Local - } -} - -impl ::protobuf::reflect::ProtobufValue for AuthenticatorPB { - fn as_ref(&self) -> ::protobuf::reflect::ReflectValueRef { - ::protobuf::reflect::ReflectValueRef::Enum(::protobuf::ProtobufEnum::descriptor(self)) - } -} - -static file_descriptor_proto_data: &'static [u8] = b"\ - \n\nauth.proto\"\xaa\x01\n\rOauthSignInPB\x12)\n\x03map\x18\x01\x20\x03(\ - \x0b2\x17.OauthSignInPB.MapEntryR\x03map\x126\n\rauthenticator\x18\x02\ - \x20\x01(\x0e2\x10.AuthenticatorPBR\rauthenticator\x1a6\n\x08MapEntry\ - \x12\x10\n\x03key\x18\x01\x20\x01(\tR\x03key\x12\x14\n\x05value\x18\x02\ - \x20\x01(\tR\x05value:\x028\x01\"=\n\tAddUserPB\x12\x14\n\x05email\x18\ - \x01\x20\x01(\tR\x05email\x12\x1a\n\x08password\x18\x02\x20\x01(\tR\x08p\ - assword\"@\n\x0cUserSignInPB\x12\x14\n\x05email\x18\x01\x20\x01(\tR\x05e\ - mail\x12\x1a\n\x08password\x18\x02\x20\x01(\tR\x08password*=\n\x0fAuthen\ - ticatorPB\x12\t\n\x05Local\x10\0\x12\x0c\n\x08Supabase\x10\x01\x12\x11\n\ - \rAppFlowyCloud\x10\x02b\x06proto3\ -"; - -static file_descriptor_proto_lazy: ::protobuf::rt::LazyV2<::protobuf::descriptor::FileDescriptorProto> = ::protobuf::rt::LazyV2::INIT; - -fn parse_descriptor_proto() -> ::protobuf::descriptor::FileDescriptorProto { - ::protobuf::Message::parse_from_bytes(file_descriptor_proto_data).unwrap() -} - -pub fn file_descriptor_proto() -> &'static ::protobuf::descriptor::FileDescriptorProto { - file_descriptor_proto_lazy.get(|| { - parse_descriptor_proto() - }) -} diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/event_map.rs b/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/event_map.rs deleted file mode 100644 index f3d5173833..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/event_map.rs +++ /dev/null @@ -1,95 +0,0 @@ -// This file is generated by rust-protobuf 2.28.0. Do not edit -// @generated - -// https://github.com/rust-lang/rust-clippy/issues/702 -#![allow(unknown_lints)] -#![allow(clippy::all)] - -#![allow(unused_attributes)] -#![cfg_attr(rustfmt, rustfmt::skip)] - -#![allow(box_pointers)] -#![allow(dead_code)] -#![allow(missing_docs)] -#![allow(non_camel_case_types)] -#![allow(non_snake_case)] -#![allow(non_upper_case_globals)] -#![allow(trivial_casts)] -#![allow(unused_imports)] -#![allow(unused_results)] -//! Generated file from `event_map.proto` - -/// Generated files are compatible only with the same version -/// of protobuf runtime. -// const _PROTOBUF_VERSION_CHECK: () = ::protobuf::VERSION_2_28_0; - -#[derive(Clone,PartialEq,Eq,Debug,Hash)] -pub enum UserWasmEvent { - OauthSignIn = 0, - AddUser = 1, - SignInPassword = 2, -} - -impl ::protobuf::ProtobufEnum for UserWasmEvent { - fn value(&self) -> i32 { - *self as i32 - } - - fn from_i32(value: i32) -> ::std::option::Option { - match value { - 0 => ::std::option::Option::Some(UserWasmEvent::OauthSignIn), - 1 => ::std::option::Option::Some(UserWasmEvent::AddUser), - 2 => ::std::option::Option::Some(UserWasmEvent::SignInPassword), - _ => ::std::option::Option::None - } - } - - fn values() -> &'static [Self] { - static values: &'static [UserWasmEvent] = &[ - UserWasmEvent::OauthSignIn, - UserWasmEvent::AddUser, - UserWasmEvent::SignInPassword, - ]; - values - } - - fn enum_descriptor_static() -> &'static ::protobuf::reflect::EnumDescriptor { - static descriptor: ::protobuf::rt::LazyV2<::protobuf::reflect::EnumDescriptor> = ::protobuf::rt::LazyV2::INIT; - descriptor.get(|| { - ::protobuf::reflect::EnumDescriptor::new_pb_name::("UserWasmEvent", file_descriptor_proto()) - }) - } -} - -impl ::std::marker::Copy for UserWasmEvent { -} - -impl ::std::default::Default for UserWasmEvent { - fn default() -> Self { - UserWasmEvent::OauthSignIn - } -} - -impl ::protobuf::reflect::ProtobufValue for UserWasmEvent { - fn as_ref(&self) -> ::protobuf::reflect::ReflectValueRef { - ::protobuf::reflect::ReflectValueRef::Enum(::protobuf::ProtobufEnum::descriptor(self)) - } -} - -static file_descriptor_proto_data: &'static [u8] = b"\ - \n\x0fevent_map.proto*A\n\rUserWasmEvent\x12\x0f\n\x0bOauthSignIn\x10\0\ - \x12\x0b\n\x07AddUser\x10\x01\x12\x12\n\x0eSignInPassword\x10\x02b\x06pr\ - oto3\ -"; - -static file_descriptor_proto_lazy: ::protobuf::rt::LazyV2<::protobuf::descriptor::FileDescriptorProto> = ::protobuf::rt::LazyV2::INIT; - -fn parse_descriptor_proto() -> ::protobuf::descriptor::FileDescriptorProto { - ::protobuf::Message::parse_from_bytes(file_descriptor_proto_data).unwrap() -} - -pub fn file_descriptor_proto() -> &'static ::protobuf::descriptor::FileDescriptorProto { - file_descriptor_proto_lazy.get(|| { - parse_descriptor_proto() - }) -} diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/mod.rs b/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/mod.rs deleted file mode 100644 index b79dbb09f6..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -#![cfg_attr(rustfmt, rustfmt::skip)] - #![allow(ambiguous_glob_reexports)] -// Auto-generated, do not edit - -mod event_map; -pub use event_map::*; - -mod auth; -pub use auth::*; - -mod user; -pub use user::*; diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/user.rs b/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/user.rs deleted file mode 100644 index 52f066e2d4..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-user/src/protobuf/user.rs +++ /dev/null @@ -1,618 +0,0 @@ -// This file is generated by rust-protobuf 2.28.0. Do not edit -// @generated - -// https://github.com/rust-lang/rust-clippy/issues/702 -#![allow(unknown_lints)] -#![allow(clippy::all)] - -#![allow(unused_attributes)] -#![cfg_attr(rustfmt, rustfmt::skip)] - -#![allow(box_pointers)] -#![allow(dead_code)] -#![allow(missing_docs)] -#![allow(non_camel_case_types)] -#![allow(non_snake_case)] -#![allow(non_upper_case_globals)] -#![allow(trivial_casts)] -#![allow(unused_imports)] -#![allow(unused_results)] -//! Generated file from `user.proto` - -/// Generated files are compatible only with the same version -/// of protobuf runtime. -// const _PROTOBUF_VERSION_CHECK: () = ::protobuf::VERSION_2_28_0; - -#[derive(PartialEq,Clone,Default)] -pub struct UserProfilePB { - // message fields - pub id: i64, - pub email: ::std::string::String, - pub name: ::std::string::String, - pub token: ::std::string::String, - pub icon_url: ::std::string::String, - pub openai_key: ::std::string::String, - pub authenticator: super::auth::AuthenticatorPB, - pub encryption_sign: ::std::string::String, - pub workspace_id: ::std::string::String, - pub stability_ai_key: ::std::string::String, - // special fields - pub unknown_fields: ::protobuf::UnknownFields, - pub cached_size: ::protobuf::CachedSize, -} - -impl<'a> ::std::default::Default for &'a UserProfilePB { - fn default() -> &'a UserProfilePB { - ::default_instance() - } -} - -impl UserProfilePB { - pub fn new() -> UserProfilePB { - ::std::default::Default::default() - } - - // int64 id = 1; - - - pub fn get_id(&self) -> i64 { - self.id - } - pub fn clear_id(&mut self) { - self.id = 0; - } - - // Param is passed by value, moved - pub fn set_id(&mut self, v: i64) { - self.id = v; - } - - // string email = 2; - - - pub fn get_email(&self) -> &str { - &self.email - } - pub fn clear_email(&mut self) { - self.email.clear(); - } - - // Param is passed by value, moved - pub fn set_email(&mut self, v: ::std::string::String) { - self.email = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_email(&mut self) -> &mut ::std::string::String { - &mut self.email - } - - // Take field - pub fn take_email(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.email, ::std::string::String::new()) - } - - // string name = 3; - - - pub fn get_name(&self) -> &str { - &self.name - } - pub fn clear_name(&mut self) { - self.name.clear(); - } - - // Param is passed by value, moved - pub fn set_name(&mut self, v: ::std::string::String) { - self.name = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_name(&mut self) -> &mut ::std::string::String { - &mut self.name - } - - // Take field - pub fn take_name(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.name, ::std::string::String::new()) - } - - // string token = 4; - - - pub fn get_token(&self) -> &str { - &self.token - } - pub fn clear_token(&mut self) { - self.token.clear(); - } - - // Param is passed by value, moved - pub fn set_token(&mut self, v: ::std::string::String) { - self.token = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_token(&mut self) -> &mut ::std::string::String { - &mut self.token - } - - // Take field - pub fn take_token(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.token, ::std::string::String::new()) - } - - // string icon_url = 5; - - - pub fn get_icon_url(&self) -> &str { - &self.icon_url - } - pub fn clear_icon_url(&mut self) { - self.icon_url.clear(); - } - - // Param is passed by value, moved - pub fn set_icon_url(&mut self, v: ::std::string::String) { - self.icon_url = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_icon_url(&mut self) -> &mut ::std::string::String { - &mut self.icon_url - } - - // Take field - pub fn take_icon_url(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.icon_url, ::std::string::String::new()) - } - - // string openai_key = 6; - - - pub fn get_openai_key(&self) -> &str { - &self.openai_key - } - pub fn clear_openai_key(&mut self) { - self.openai_key.clear(); - } - - // Param is passed by value, moved - pub fn set_openai_key(&mut self, v: ::std::string::String) { - self.openai_key = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_openai_key(&mut self) -> &mut ::std::string::String { - &mut self.openai_key - } - - // Take field - pub fn take_openai_key(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.openai_key, ::std::string::String::new()) - } - - // .AuthenticatorPB authenticator = 7; - - - pub fn get_authenticator(&self) -> super::auth::AuthenticatorPB { - self.authenticator - } - pub fn clear_authenticator(&mut self) { - self.authenticator = super::auth::AuthenticatorPB::Local; - } - - // Param is passed by value, moved - pub fn set_authenticator(&mut self, v: super::auth::AuthenticatorPB) { - self.authenticator = v; - } - - // string encryption_sign = 8; - - - pub fn get_encryption_sign(&self) -> &str { - &self.encryption_sign - } - pub fn clear_encryption_sign(&mut self) { - self.encryption_sign.clear(); - } - - // Param is passed by value, moved - pub fn set_encryption_sign(&mut self, v: ::std::string::String) { - self.encryption_sign = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_encryption_sign(&mut self) -> &mut ::std::string::String { - &mut self.encryption_sign - } - - // Take field - pub fn take_encryption_sign(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.encryption_sign, ::std::string::String::new()) - } - - // string workspace_id = 9; - - - pub fn get_workspace_id(&self) -> &str { - &self.workspace_id - } - pub fn clear_workspace_id(&mut self) { - self.workspace_id.clear(); - } - - // Param is passed by value, moved - pub fn set_workspace_id(&mut self, v: ::std::string::String) { - self.workspace_id = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_workspace_id(&mut self) -> &mut ::std::string::String { - &mut self.workspace_id - } - - // Take field - pub fn take_workspace_id(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.workspace_id, ::std::string::String::new()) - } - - // string stability_ai_key = 10; - - - pub fn get_stability_ai_key(&self) -> &str { - &self.stability_ai_key - } - pub fn clear_stability_ai_key(&mut self) { - self.stability_ai_key.clear(); - } - - // Param is passed by value, moved - pub fn set_stability_ai_key(&mut self, v: ::std::string::String) { - self.stability_ai_key = v; - } - - // Mutable pointer to the field. - // If field is not initialized, it is initialized with default value first. - pub fn mut_stability_ai_key(&mut self) -> &mut ::std::string::String { - &mut self.stability_ai_key - } - - // Take field - pub fn take_stability_ai_key(&mut self) -> ::std::string::String { - ::std::mem::replace(&mut self.stability_ai_key, ::std::string::String::new()) - } -} - -impl ::protobuf::Message for UserProfilePB { - fn is_initialized(&self) -> bool { - true - } - - fn merge_from(&mut self, is: &mut ::protobuf::CodedInputStream<'_>) -> ::protobuf::ProtobufResult<()> { - while !is.eof()? { - let (field_number, wire_type) = is.read_tag_unpack()?; - match field_number { - 1 => { - if wire_type != ::protobuf::wire_format::WireTypeVarint { - return ::std::result::Result::Err(::protobuf::rt::unexpected_wire_type(wire_type)); - } - let tmp = is.read_int64()?; - self.id = tmp; - }, - 2 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.email)?; - }, - 3 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.name)?; - }, - 4 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.token)?; - }, - 5 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.icon_url)?; - }, - 6 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.openai_key)?; - }, - 7 => { - ::protobuf::rt::read_proto3_enum_with_unknown_fields_into(wire_type, is, &mut self.authenticator, 7, &mut self.unknown_fields)? - }, - 8 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.encryption_sign)?; - }, - 9 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.workspace_id)?; - }, - 10 => { - ::protobuf::rt::read_singular_proto3_string_into(wire_type, is, &mut self.stability_ai_key)?; - }, - _ => { - ::protobuf::rt::read_unknown_or_skip_group(field_number, wire_type, is, self.mut_unknown_fields())?; - }, - }; - } - ::std::result::Result::Ok(()) - } - - // Compute sizes of nested messages - #[allow(unused_variables)] - fn compute_size(&self) -> u32 { - let mut my_size = 0; - if self.id != 0 { - my_size += ::protobuf::rt::value_size(1, self.id, ::protobuf::wire_format::WireTypeVarint); - } - if !self.email.is_empty() { - my_size += ::protobuf::rt::string_size(2, &self.email); - } - if !self.name.is_empty() { - my_size += ::protobuf::rt::string_size(3, &self.name); - } - if !self.token.is_empty() { - my_size += ::protobuf::rt::string_size(4, &self.token); - } - if !self.icon_url.is_empty() { - my_size += ::protobuf::rt::string_size(5, &self.icon_url); - } - if !self.openai_key.is_empty() { - my_size += ::protobuf::rt::string_size(6, &self.openai_key); - } - if self.authenticator != super::auth::AuthenticatorPB::Local { - my_size += ::protobuf::rt::enum_size(7, self.authenticator); - } - if !self.encryption_sign.is_empty() { - my_size += ::protobuf::rt::string_size(8, &self.encryption_sign); - } - if !self.workspace_id.is_empty() { - my_size += ::protobuf::rt::string_size(9, &self.workspace_id); - } - if !self.stability_ai_key.is_empty() { - my_size += ::protobuf::rt::string_size(10, &self.stability_ai_key); - } - my_size += ::protobuf::rt::unknown_fields_size(self.get_unknown_fields()); - self.cached_size.set(my_size); - my_size - } - - fn write_to_with_cached_sizes(&self, os: &mut ::protobuf::CodedOutputStream<'_>) -> ::protobuf::ProtobufResult<()> { - if self.id != 0 { - os.write_int64(1, self.id)?; - } - if !self.email.is_empty() { - os.write_string(2, &self.email)?; - } - if !self.name.is_empty() { - os.write_string(3, &self.name)?; - } - if !self.token.is_empty() { - os.write_string(4, &self.token)?; - } - if !self.icon_url.is_empty() { - os.write_string(5, &self.icon_url)?; - } - if !self.openai_key.is_empty() { - os.write_string(6, &self.openai_key)?; - } - if self.authenticator != super::auth::AuthenticatorPB::Local { - os.write_enum(7, ::protobuf::ProtobufEnum::value(&self.authenticator))?; - } - if !self.encryption_sign.is_empty() { - os.write_string(8, &self.encryption_sign)?; - } - if !self.workspace_id.is_empty() { - os.write_string(9, &self.workspace_id)?; - } - if !self.stability_ai_key.is_empty() { - os.write_string(10, &self.stability_ai_key)?; - } - os.write_unknown_fields(self.get_unknown_fields())?; - ::std::result::Result::Ok(()) - } - - fn get_cached_size(&self) -> u32 { - self.cached_size.get() - } - - fn get_unknown_fields(&self) -> &::protobuf::UnknownFields { - &self.unknown_fields - } - - fn mut_unknown_fields(&mut self) -> &mut ::protobuf::UnknownFields { - &mut self.unknown_fields - } - - fn as_any(&self) -> &dyn (::std::any::Any) { - self as &dyn (::std::any::Any) - } - fn as_any_mut(&mut self) -> &mut dyn (::std::any::Any) { - self as &mut dyn (::std::any::Any) - } - fn into_any(self: ::std::boxed::Box) -> ::std::boxed::Box { - self - } - - fn descriptor(&self) -> &'static ::protobuf::reflect::MessageDescriptor { - Self::descriptor_static() - } - - fn new() -> UserProfilePB { - UserProfilePB::new() - } - - fn descriptor_static() -> &'static ::protobuf::reflect::MessageDescriptor { - static descriptor: ::protobuf::rt::LazyV2<::protobuf::reflect::MessageDescriptor> = ::protobuf::rt::LazyV2::INIT; - descriptor.get(|| { - let mut fields = ::std::vec::Vec::new(); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeInt64>( - "id", - |m: &UserProfilePB| { &m.id }, - |m: &mut UserProfilePB| { &mut m.id }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "email", - |m: &UserProfilePB| { &m.email }, - |m: &mut UserProfilePB| { &mut m.email }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "name", - |m: &UserProfilePB| { &m.name }, - |m: &mut UserProfilePB| { &mut m.name }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "token", - |m: &UserProfilePB| { &m.token }, - |m: &mut UserProfilePB| { &mut m.token }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "icon_url", - |m: &UserProfilePB| { &m.icon_url }, - |m: &mut UserProfilePB| { &mut m.icon_url }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "openai_key", - |m: &UserProfilePB| { &m.openai_key }, - |m: &mut UserProfilePB| { &mut m.openai_key }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeEnum>( - "authenticator", - |m: &UserProfilePB| { &m.authenticator }, - |m: &mut UserProfilePB| { &mut m.authenticator }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "encryption_sign", - |m: &UserProfilePB| { &m.encryption_sign }, - |m: &mut UserProfilePB| { &mut m.encryption_sign }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "workspace_id", - |m: &UserProfilePB| { &m.workspace_id }, - |m: &mut UserProfilePB| { &mut m.workspace_id }, - )); - fields.push(::protobuf::reflect::accessor::make_simple_field_accessor::<_, ::protobuf::types::ProtobufTypeString>( - "stability_ai_key", - |m: &UserProfilePB| { &m.stability_ai_key }, - |m: &mut UserProfilePB| { &mut m.stability_ai_key }, - )); - ::protobuf::reflect::MessageDescriptor::new_pb_name::( - "UserProfilePB", - fields, - file_descriptor_proto() - ) - }) - } - - fn default_instance() -> &'static UserProfilePB { - static instance: ::protobuf::rt::LazyV2 = ::protobuf::rt::LazyV2::INIT; - instance.get(UserProfilePB::new) - } -} - -impl ::protobuf::Clear for UserProfilePB { - fn clear(&mut self) { - self.id = 0; - self.email.clear(); - self.name.clear(); - self.token.clear(); - self.icon_url.clear(); - self.openai_key.clear(); - self.authenticator = super::auth::AuthenticatorPB::Local; - self.encryption_sign.clear(); - self.workspace_id.clear(); - self.stability_ai_key.clear(); - self.unknown_fields.clear(); - } -} - -impl ::std::fmt::Debug for UserProfilePB { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - ::protobuf::text_format::fmt(self, f) - } -} - -impl ::protobuf::reflect::ProtobufValue for UserProfilePB { - fn as_ref(&self) -> ::protobuf::reflect::ReflectValueRef { - ::protobuf::reflect::ReflectValueRef::Message(self) - } -} - -#[derive(Clone,PartialEq,Eq,Debug,Hash)] -pub enum EncryptionTypePB { - NoEncryption = 0, - Symmetric = 1, -} - -impl ::protobuf::ProtobufEnum for EncryptionTypePB { - fn value(&self) -> i32 { - *self as i32 - } - - fn from_i32(value: i32) -> ::std::option::Option { - match value { - 0 => ::std::option::Option::Some(EncryptionTypePB::NoEncryption), - 1 => ::std::option::Option::Some(EncryptionTypePB::Symmetric), - _ => ::std::option::Option::None - } - } - - fn values() -> &'static [Self] { - static values: &'static [EncryptionTypePB] = &[ - EncryptionTypePB::NoEncryption, - EncryptionTypePB::Symmetric, - ]; - values - } - - fn enum_descriptor_static() -> &'static ::protobuf::reflect::EnumDescriptor { - static descriptor: ::protobuf::rt::LazyV2<::protobuf::reflect::EnumDescriptor> = ::protobuf::rt::LazyV2::INIT; - descriptor.get(|| { - ::protobuf::reflect::EnumDescriptor::new_pb_name::("EncryptionTypePB", file_descriptor_proto()) - }) - } -} - -impl ::std::marker::Copy for EncryptionTypePB { -} - -impl ::std::default::Default for EncryptionTypePB { - fn default() -> Self { - EncryptionTypePB::NoEncryption - } -} - -impl ::protobuf::reflect::ProtobufValue for EncryptionTypePB { - fn as_ref(&self) -> ::protobuf::reflect::ReflectValueRef { - ::protobuf::reflect::ReflectValueRef::Enum(::protobuf::ProtobufEnum::descriptor(self)) - } -} - -static file_descriptor_proto_data: &'static [u8] = b"\ - \n\nuser.proto\x1a\nauth.proto\"\xc7\x02\n\rUserProfilePB\x12\x0e\n\x02i\ - d\x18\x01\x20\x01(\x03R\x02id\x12\x14\n\x05email\x18\x02\x20\x01(\tR\x05\ - email\x12\x12\n\x04name\x18\x03\x20\x01(\tR\x04name\x12\x14\n\x05token\ - \x18\x04\x20\x01(\tR\x05token\x12\x19\n\x08icon_url\x18\x05\x20\x01(\tR\ - \x07iconUrl\x12\x1d\n\nopenai_key\x18\x06\x20\x01(\tR\topenaiKey\x126\n\ - \rauthenticator\x18\x07\x20\x01(\x0e2\x10.AuthenticatorPBR\rauthenticato\ - r\x12'\n\x0fencryption_sign\x18\x08\x20\x01(\tR\x0eencryptionSign\x12!\n\ - \x0cworkspace_id\x18\t\x20\x01(\tR\x0bworkspaceId\x12(\n\x10stability_ai\ - _key\x18\n\x20\x01(\tR\x0estabilityAiKey*3\n\x10EncryptionTypePB\x12\x10\ - \n\x0cNoEncryption\x10\0\x12\r\n\tSymmetric\x10\x01b\x06proto3\ -"; - -static file_descriptor_proto_lazy: ::protobuf::rt::LazyV2<::protobuf::descriptor::FileDescriptorProto> = ::protobuf::rt::LazyV2::INIT; - -fn parse_descriptor_proto() -> ::protobuf::descriptor::FileDescriptorProto { - ::protobuf::Message::parse_from_bytes(file_descriptor_proto_data).unwrap() -} - -pub fn file_descriptor_proto() -> &'static ::protobuf::descriptor::FileDescriptorProto { - file_descriptor_proto_lazy.get(|| { - parse_descriptor_proto() - }) -} diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/Cargo.toml b/frontend/appflowy_web/wasm-libs/af-wasm/Cargo.toml deleted file mode 100644 index db754e681e..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/Cargo.toml +++ /dev/null @@ -1,59 +0,0 @@ -[package] -name = "af-wasm" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -wasm-bindgen = { version = "0.2.89" } -lazy_static = "1.4.0" -lib-dispatch = { workspace = true, features = ["use_serde"] } -parking_lot.workspace = true -tracing.workspace = true -tracing-core = { version = "0.1.32" } -tracing-wasm = "0.2.1" -serde.workspace = true -collab-integrate = { workspace = true } -tokio-stream.workspace = true - -af-user.workspace = true -af-persistence.workspace = true -flowy-storage = { workspace = true } -flowy-notification = { workspace = true, features = ["web_ts"] } -flowy-user-pub = { workspace = true } -flowy-server = { workspace = true } -flowy-server-pub = { workspace = true } -flowy-error = { workspace = true, features = ["impl_from_dispatch_error", "web_ts"] } -flowy-document = { workspace = true, features = ["web_ts"] } -flowy-folder = { workspace = true, features = ["web_ts"] } -lib-infra = { workspace = true } -collab = { workspace = true } -web-sys = "0.3" -wasm-bindgen-futures.workspace = true -uuid.workspace = true -serde-wasm-bindgen.workspace = true -js-sys = "0.3.67" -anyhow = "1.0" - -# `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size -# compared to the default allocator's ~10K. However, it is slower than the default -# allocator, so it's not enabled by default. -wee_alloc = { version = "0.4.2", optional = true } - -# The `console_error_panic_hook` crate provides better debugging of panics by -# logging them with `console.error`. This is great for development, but requires -# all the `std::fmt` and `std::panicking` infrastructure, so it's only enabled -# in debug mode. -[target."cfg(debug_assertions)".dependencies] -console_error_panic_hook = "0.1.5" - -[dev-dependencies] -wasm-bindgen-test = "0.3.40" -tokio = { version = "1.0", features = ["sync"] } - -[features] -#default = ["wee_alloc"] -localhost_dev = [] \ No newline at end of file diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/src/core.rs b/frontend/appflowy_web/wasm-libs/af-wasm/src/core.rs deleted file mode 100644 index f841a13a73..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/src/core.rs +++ /dev/null @@ -1,89 +0,0 @@ -use crate::deps_resolve::document_deps::DocumentDepsResolver; -use crate::deps_resolve::folder_deps::FolderDepsResolver; -use crate::integrate::server::ServerProviderWASM; -use af_persistence::store::AppFlowyWASMStore; -use af_user::authenticate_user::AuthenticateUser; -use af_user::manager::UserManager; -use collab_integrate::collab_builder::{AppFlowyCollabBuilder, WorkspaceCollabIntegrate}; -use flowy_document::manager::DocumentManager; -use flowy_error::FlowyResult; -use flowy_folder::manager::FolderManager; -use flowy_server_pub::af_cloud_config::AFCloudConfiguration; -use flowy_storage::ObjectStorageService; -use lib_dispatch::prelude::AFPluginDispatcher; -use lib_dispatch::runtime::AFPluginRuntime; -use std::rc::Rc; -use std::sync::Arc; - -pub struct AppFlowyWASMCore { - pub collab_builder: Arc, - pub event_dispatcher: Rc, - pub user_manager: Rc, - pub folder_manager: Rc, - pub document_manager: Rc, -} - -impl AppFlowyWASMCore { - pub async fn new(device_id: &str, cloud_config: AFCloudConfiguration) -> FlowyResult { - let runtime = Arc::new(AFPluginRuntime::new().unwrap()); - let server_provider = Rc::new(ServerProviderWASM::new(device_id, cloud_config)); - let store = Rc::new(AppFlowyWASMStore::new().await?); - let auth_user = Rc::new(AuthenticateUser::new(store.clone()).await?); - let collab_builder = Arc::new(AppFlowyCollabBuilder::new( - device_id.to_string(), - server_provider.clone(), - WorkspaceCollabIntegrateImpl(auth_user.clone()), - )); - - let document_manager = DocumentDepsResolver::resolve( - Rc::downgrade(&auth_user), - collab_builder.clone(), - server_provider.clone(), - Rc::downgrade(&(server_provider.clone() as Rc)), - ) - .await; - - let folder_manager = FolderDepsResolver::resolve( - Rc::downgrade(&auth_user), - document_manager.clone(), - collab_builder.clone(), - server_provider.clone(), - ) - .await; - - let user_manager = Rc::new( - UserManager::new( - device_id, - store, - server_provider.clone(), - auth_user, - Arc::downgrade(&collab_builder), - ) - .await?, - ); - - let event_dispatcher = Rc::new(AFPluginDispatcher::new( - runtime, - vec![af_user::event_map::init(Rc::downgrade(&user_manager))], - )); - Ok(Self { - collab_builder, - event_dispatcher, - user_manager, - folder_manager, - document_manager, - }) - } -} - -struct WorkspaceCollabIntegrateImpl(Rc); -impl WorkspaceCollabIntegrate for WorkspaceCollabIntegrateImpl { - fn workspace_id(&self) -> Result { - let workspace_id = self.0.workspace_id()?; - Ok(workspace_id) - } - - fn device_id(&self) -> Result { - Ok("fake device id".to_string()) - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/src/deps_resolve/document_deps.rs b/frontend/appflowy_web/wasm-libs/af-wasm/src/deps_resolve/document_deps.rs deleted file mode 100644 index 3580bb762f..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/src/deps_resolve/document_deps.rs +++ /dev/null @@ -1,19 +0,0 @@ -use crate::integrate::server::ServerProviderWASM; -use af_user::authenticate_user::AuthenticateUser; -use collab_integrate::collab_builder::AppFlowyCollabBuilder; -use flowy_document::manager::DocumentManager; -use flowy_storage::ObjectStorageService; -use std::rc::{Rc, Weak}; -use std::sync::Arc; - -pub struct DocumentDepsResolver; -impl DocumentDepsResolver { - pub async fn resolve( - authenticate_user: Weak, - collab_builder: Arc, - server_provider: Rc, - storage_service: Weak, - ) -> Rc { - todo!() - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/src/deps_resolve/folder_deps.rs b/frontend/appflowy_web/wasm-libs/af-wasm/src/deps_resolve/folder_deps.rs deleted file mode 100644 index e291b5551a..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/src/deps_resolve/folder_deps.rs +++ /dev/null @@ -1,20 +0,0 @@ -use crate::integrate::server::ServerProviderWASM; -use af_user::authenticate_user::AuthenticateUser; -use collab_integrate::collab_builder::AppFlowyCollabBuilder; -use flowy_document::manager::DocumentManager; -use flowy_folder::manager::FolderManager; -use std::rc::{Rc, Weak}; -use std::sync::Arc; - -pub struct FolderDepsResolver; - -impl FolderDepsResolver { - pub async fn resolve( - authenticate_user: Weak, - document_manager: Rc, - collab_builder: Arc, - server_provider: Rc, - ) -> Rc { - todo!() - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/src/deps_resolve/mod.rs b/frontend/appflowy_web/wasm-libs/af-wasm/src/deps_resolve/mod.rs deleted file mode 100644 index b210522360..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/src/deps_resolve/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub(crate) mod document_deps; -pub(crate) mod folder_deps; diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/src/integrate/mod.rs b/frontend/appflowy_web/wasm-libs/af-wasm/src/integrate/mod.rs deleted file mode 100644 index 74f47ad347..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/src/integrate/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod server; diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/src/integrate/server.rs b/frontend/appflowy_web/wasm-libs/af-wasm/src/integrate/server.rs deleted file mode 100644 index 6f3c71025a..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/src/integrate/server.rs +++ /dev/null @@ -1,122 +0,0 @@ -use collab::preclude::CollabPlugin; -use collab_integrate::collab_builder::{ - CollabCloudPluginProvider, CollabPluginProviderContext, CollabPluginProviderType, -}; -use flowy_error::FlowyError; -use flowy_server::af_cloud::AppFlowyCloudServer; -use flowy_server::AppFlowyServer; -use flowy_server_pub::af_cloud_config::AFCloudConfiguration; -use flowy_storage::{ObjectIdentity, ObjectStorageService, ObjectValue}; -use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; -use flowy_user_pub::entities::{Authenticator, UserTokenState}; -use lib_infra::future::{to_fut, Fut, FutureResult}; -use parking_lot::RwLock; -use std::rc::Rc; -use std::sync::Arc; -use tokio_stream::wrappers::WatchStream; -use tracing::{info, warn}; - -pub struct ServerProviderWASM { - device_id: String, - config: AFCloudConfiguration, - server: RwLock>>, -} - -impl ServerProviderWASM { - pub fn new(device_id: &str, config: AFCloudConfiguration) -> Self { - info!("Server config: {}", config); - Self { - device_id: device_id.to_string(), - server: RwLock::new(Default::default()), - config, - } - } - - pub fn get_server(&self) -> Rc { - let server = self.server.read().as_ref().cloned(); - match server { - Some(server) => server, - None => { - let server = Rc::new(AppFlowyCloudServer::new( - self.config.clone(), - true, - self.device_id.clone(), - "0.0.1" - )); - *self.server.write() = Some(server.clone()); - server - }, - } - } -} - -impl CollabCloudPluginProvider for ServerProviderWASM { - fn provider_type(&self) -> CollabPluginProviderType { - CollabPluginProviderType::AppFlowyCloud - } - - fn get_plugins(&self, _context: CollabPluginProviderContext) -> Vec> { - vec![] - } - - fn is_sync_enabled(&self) -> bool { - true - } -} - -impl UserCloudServiceProvider for ServerProviderWASM { - fn set_token(&self, token: &str) -> Result<(), FlowyError> { - self.get_server().set_token(token)?; - Ok(()) - } - - fn subscribe_token_state(&self) -> Option> { - self.get_server().subscribe_token_state() - } - - fn set_enable_sync(&self, _uid: i64, _enable_sync: bool) { - warn!("enable sync is not supported in wasm") - } - - fn set_user_authenticator(&self, _authenticator: &Authenticator) { - warn!("set user authenticator is not supported in wasm") - } - - fn get_user_authenticator(&self) -> Authenticator { - Authenticator::AppFlowyCloud - } - - fn set_network_reachable(&self, _reachable: bool) { - warn!("set network reachable is not supported in wasm") - } - - fn set_encrypt_secret(&self, _secret: String) { - warn!("set encrypt secret is not supported in wasm") - } - - fn get_user_service(&self) -> Result, FlowyError> { - Ok(self.get_server().user_service()) - } - - fn service_url(&self) -> String { - self.config.base_url.clone() - } -} - -impl ObjectStorageService for ServerProviderWASM { - fn get_object_url(&self, object_id: ObjectIdentity) -> FutureResult { - todo!() - } - - fn put_object(&self, url: String, object_value: ObjectValue) -> FutureResult<(), FlowyError> { - todo!() - } - - fn delete_object(&self, url: String) -> FutureResult<(), FlowyError> { - todo!() - } - - fn get_object(&self, url: String) -> FutureResult { - todo!() - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/src/lib.rs b/frontend/appflowy_web/wasm-libs/af-wasm/src/lib.rs deleted file mode 100644 index efe3855f28..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/src/lib.rs +++ /dev/null @@ -1,170 +0,0 @@ -use crate::notification::TSNotificationSender; -use flowy_notification::{register_notification_sender, unregister_all_notification_sender}; -use std::cell::RefCell; -use std::rc::Rc; - -pub mod core; -mod deps_resolve; -mod integrate; -pub mod notification; - -use crate::core::AppFlowyWASMCore; -use lazy_static::lazy_static; -use lib_dispatch::prelude::{ - AFPluginDispatcher, AFPluginEventResponse, AFPluginRequest, StatusCode, -}; - -use flowy_server_pub::af_cloud_config::AFCloudConfiguration; -use tracing::{error, info}; -use wasm_bindgen::prelude::wasm_bindgen; -use wasm_bindgen::JsValue; -use wasm_bindgen_futures::{future_to_promise, js_sys}; - -lazy_static! { - static ref APPFLOWY_CORE: RefCellAppFlowyCore = RefCellAppFlowyCore::new(); -} - -#[cfg(feature = "wee_alloc")] -#[global_allocator] -static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; - -#[wasm_bindgen] -extern "C" { - #[wasm_bindgen(js_namespace = console)] - pub fn log(s: &str); - #[wasm_bindgen(js_namespace = window)] - fn onFlowyNotify(event_name: &str, payload: JsValue); -} -#[wasm_bindgen] -pub fn init_tracing_log() { - tracing_wasm::set_as_global_default(); -} - -#[wasm_bindgen] -pub fn init_wasm_core() -> js_sys::Promise { - // It's disabled in release mode so it doesn't bloat up the file size. - #[cfg(debug_assertions)] - console_error_panic_hook::set_once(); - - #[cfg(feature = "localhost_dev")] - let config = AFCloudConfiguration { - base_url: "http://localhost".to_string(), - ws_base_url: "ws://localhost/ws/v1".to_string(), - gotrue_url: "http://localhost/gotrue".to_string(), - }; - - #[cfg(not(feature = "localhost_dev"))] - let config = AFCloudConfiguration { - base_url: "https://beta.appflowy.cloud".to_string(), - ws_base_url: "wss://beta.appflowy.cloud/ws/v1".to_string(), - gotrue_url: "https://beta.appflowy.cloud/gotrue".to_string(), - }; - - let future = async move { - if let Ok(core) = AppFlowyWASMCore::new("device_id", config).await { - *APPFLOWY_CORE.0.borrow_mut() = Some(core); - info!("🔥🔥🔥Initialized AppFlowyWASMCore"); - } else { - error!("Failed to initialize AppFlowyWASMCore") - } - Ok(JsValue::from_str("")) - }; - future_to_promise(future) -} - -#[wasm_bindgen] -pub fn async_event(name: String, payload: Vec) -> js_sys::Promise { - if let Some(dispatcher) = APPFLOWY_CORE.dispatcher() { - let future = async move { - let request = WasmRequest::new(name, payload); - let event_resp = - AFPluginDispatcher::boxed_async_send_with_callback(dispatcher.as_ref(), request, |_| { - Box::pin(async {}) - }) - .await; - - let response = WasmResponse::from(event_resp); - serde_wasm_bindgen::to_value(&response).map_err(error_response) - }; - - future_to_promise(future) - } else { - future_to_promise(async { Err(JsValue::from_str("Dispatcher is not initialized")) }) - } -} - -#[wasm_bindgen] -pub fn register_listener() { - unregister_all_notification_sender(); - register_notification_sender(TSNotificationSender::new()); -} - -pub fn on_event(event_name: &str, args: JsValue) { - onFlowyNotify(event_name, args); -} - -struct RefCellAppFlowyCore(RefCell>); - -/// safety: -/// In a WebAssembly, implement the Sync for RefCellAppFlowyCore is safety -/// since WASM currently operates in a single-threaded environment. -unsafe impl Sync for RefCellAppFlowyCore {} - -impl RefCellAppFlowyCore { - fn new() -> Self { - Self(RefCell::new(None)) - } - - fn dispatcher(&self) -> Option> { - self - .0 - .borrow() - .as_ref() - .map(|core| core.event_dispatcher.clone()) - } -} - -fn error_response(error: serde_wasm_bindgen::Error) -> JsValue { - error!("Error: {}", error); - serde_wasm_bindgen::to_value(&WasmResponse::error(error.to_string())).unwrap() -} - -pub struct WasmRequest { - name: String, - payload: Vec, -} - -impl WasmRequest { - pub fn new(name: String, payload: Vec) -> Self { - Self { name, payload } - } -} - -impl From for AFPluginRequest { - fn from(request: WasmRequest) -> Self { - AFPluginRequest::new(request.name).payload(request.payload) - } -} - -#[derive(serde::Serialize)] -pub struct WasmResponse { - pub code: i8, - pub payload: Vec, -} -impl WasmResponse { - pub fn error(msg: String) -> Self { - Self { - code: StatusCode::Err as i8, - payload: msg.into_bytes(), - } - } -} - -impl From for WasmResponse { - fn from(response: AFPluginEventResponse) -> Self { - Self { - code: response.status_code as i8, - payload: response.payload.to_vec(), - } - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/src/notification.rs b/frontend/appflowy_web/wasm-libs/af-wasm/src/notification.rs deleted file mode 100644 index d329ee0cb1..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/src/notification.rs +++ /dev/null @@ -1,19 +0,0 @@ -use flowy_notification::entities::SubscribeObject; -use flowy_notification::NotificationSender; - -pub const AF_NOTIFICATION: &str = "af-notification"; - -pub struct TSNotificationSender {} - -impl TSNotificationSender { - pub(crate) fn new() -> Self { - TSNotificationSender {} - } -} - -impl NotificationSender for TSNotificationSender { - fn send_subject(&self, _subject: SubscribeObject) -> Result<(), String> { - // on_event(AF_NOTIFICATION, serde_wasm_bindgen::to_value(&subject).unwrap_or(JsValue::UNDEFINED)); - Ok(()) - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/tests/main.rs b/frontend/appflowy_web/wasm-libs/af-wasm/tests/main.rs deleted file mode 100644 index 0498e45195..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/tests/main.rs +++ /dev/null @@ -1,4 +0,0 @@ -use wasm_bindgen_test::wasm_bindgen_test_configure; -wasm_bindgen_test_configure!(run_in_browser); -mod user; -pub(crate) mod util; diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/tests/user/event_test.rs b/frontend/appflowy_web/wasm-libs/af-wasm/tests/user/event_test.rs deleted file mode 100644 index d053043e7e..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/tests/user/event_test.rs +++ /dev/null @@ -1,10 +0,0 @@ -use crate::util::tester::{unique_email, WASMEventTester}; -use wasm_bindgen_test::wasm_bindgen_test; - -#[wasm_bindgen_test] -async fn sign_up_event_test() { - let tester = WASMEventTester::new().await; - let email = unique_email(); - let user_profile = tester.sign_in_with_email(&email).await.unwrap(); - assert_eq!(user_profile.email, email); -} diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/tests/user/mod.rs b/frontend/appflowy_web/wasm-libs/af-wasm/tests/user/mod.rs deleted file mode 100644 index 83ac8063ea..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/tests/user/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod event_test; diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/event_builder.rs b/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/event_builder.rs deleted file mode 100644 index 99185a6837..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/event_builder.rs +++ /dev/null @@ -1,132 +0,0 @@ -use af_wasm::core::AppFlowyWASMCore; -use flowy_error::{internal_error, FlowyError}; -use std::rc::Rc; -use std::{ - convert::TryFrom, - fmt::{Debug, Display}, - hash::Hash, - sync::Arc, -}; - -use lib_dispatch::prelude::{ - AFPluginDispatcher, AFPluginEventResponse, AFPluginFromBytes, AFPluginRequest, ToBytes, *, -}; - -#[derive(Clone)] -pub struct EventBuilder { - context: TestContext, -} - -impl EventBuilder { - pub fn new(core: Arc) -> Self { - Self { - context: TestContext::new(core), - } - } - - pub fn payload

(mut self, payload: P) -> Self - where - P: ToBytes, - { - match payload.into_bytes() { - Ok(bytes) => { - let module_request = self.take_request(); - self.context.request = Some(module_request.payload(bytes)) - }, - Err(e) => { - tracing::error!("Set payload failed: {:?}", e); - }, - } - self - } - - pub fn event(mut self, event: Event) -> Self - where - Event: Eq + Hash + Debug + Clone + Display, - { - self.context.request = Some(AFPluginRequest::new(event)); - self - } - - pub async fn async_send(mut self) -> Self { - let request = self.take_request(); - let resp = AFPluginDispatcher::async_send(self.dispatch().as_ref(), request).await; - self.context.response = Some(resp); - self - } - - pub fn parse(self) -> R - where - R: AFPluginFromBytes, - { - let response = self.get_response(); - match response.clone().parse::() { - Ok(Ok(data)) => data, - Ok(Err(e)) => { - panic!( - "Parser {:?} failed: {:?}, response {:?}", - std::any::type_name::(), - e, - response - ) - }, - Err(e) => panic!( - "Dispatch {:?} failed: {:?}, response {:?}", - std::any::type_name::(), - e, - response - ), - } - } - - #[allow(dead_code)] - pub fn try_parse(self) -> Result - where - R: AFPluginFromBytes, - { - let response = self.get_response(); - response.parse::().map_err(internal_error)? - } - - #[allow(dead_code)] - pub fn error(self) -> Option { - let response = self.get_response(); - >::try_from(response.payload) - .ok() - .map(|data| data.into_inner()) - } - - fn dispatch(&self) -> &Rc { - &self.context.sdk.event_dispatcher - } - - fn get_response(&self) -> AFPluginEventResponse { - self - .context - .response - .as_ref() - .expect("must call sync_send/async_send first") - .clone() - } - - fn take_request(&mut self) -> AFPluginRequest { - self.context.request.take().expect("must call event first") - } -} - -#[derive(Clone)] -pub struct TestContext { - pub sdk: Arc, - request: Option, - response: Option, -} - -impl TestContext { - pub fn new(sdk: Arc) -> Self { - Self { - sdk, - request: None, - response: None, - } - } -} diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/mod.rs b/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/mod.rs deleted file mode 100644 index 8458398ffd..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod event_builder; -pub mod tester; diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/tester.rs b/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/tester.rs deleted file mode 100644 index 5142d8012f..0000000000 --- a/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/tester.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::util::event_builder::EventBuilder; -use af_user::entities::*; -use af_user::event_map::UserWasmEvent::*; -use af_wasm::core::AppFlowyWASMCore; -use flowy_error::FlowyResult; - -use flowy_server_pub::af_cloud_config::AFCloudConfiguration; -use parking_lot::Once; - -use flowy_document::deps::DocumentData; -use flowy_document::entities::{CreateDocumentPayloadPB, DocumentDataPB, OpenDocumentPayloadPB}; -use flowy_document::event_map::DocumentEvent; -use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; -use flowy_folder::event_map::FolderEvent; -use std::sync::Arc; -use uuid::Uuid; - -pub struct WASMEventTester { - core: Arc, -} - -impl WASMEventTester { - pub async fn new() -> Self { - setup_log(); - let config = AFCloudConfiguration { - base_url: "http://localhost".to_string(), - ws_base_url: "ws://localhost/ws/v1".to_string(), - gotrue_url: "http://localhost/gotrue".to_string(), - }; - let core = Arc::new(AppFlowyWASMCore::new("device_id", config).await.unwrap()); - Self { core } - } - - pub async fn sign_in_with_email(&self, email: &str) -> FlowyResult { - let email = email.to_string(); - let password = "AppFlowy!2024".to_string(); - let payload = AddUserPB { - email: email.clone(), - password: password.clone(), - }; - EventBuilder::new(self.core.clone()) - .event(AddUser) - .payload(payload) - .async_send() - .await; - - let payload = UserSignInPB { email, password }; - let user_profile = EventBuilder::new(self.core.clone()) - .event(SignInPassword) - .payload(payload) - .async_send() - .await - .parse::(); - Ok(user_profile) - } - - pub async fn create_and_open_document(&self, parent_id: &str) -> ViewPB { - let payload = CreateViewPayloadPB { - parent_view_id: parent_id.to_string(), - name, - desc: "".to_string(), - thumbnail: None, - layout: ViewLayoutPB::Document, - initial_data, - meta: Default::default(), - set_as_current: true, - index: None, - }; - let view = self - .event_builder() - .event(FolderEvent::CreateView) - .payload(payload) - .async_send() - .await - .parse::(); - - let payload = OpenDocumentPayloadPB { - document_id: view.id.clone(), - }; - - let _ = self - .event_builder() - .event(DocumentEvent::OpenDocument) - .payload(payload) - .async_send() - .await - .parse::(); - view - } - - fn event_builder(&self) -> EventBuilder { - EventBuilder::new(self.core.clone()) - } -} - -pub fn unique_email() -> String { - format!("{}@appflowy.io", Uuid::new_v4()) -} - -pub fn setup_log() { - static START: Once = Once::new(); - START.call_once(|| { - tracing_wasm::set_as_global_default(); - }); -} diff --git a/frontend/appflowy_web/wasm-libs/rust-toolchain.toml b/frontend/appflowy_web/wasm-libs/rust-toolchain.toml deleted file mode 100644 index 6f14058b2e..0000000000 --- a/frontend/appflowy_web/wasm-libs/rust-toolchain.toml +++ /dev/null @@ -1,2 +0,0 @@ -[toolchain] -channel = "1.77.2" diff --git a/frontend/appflowy_web/wasm-libs/rustfmt.toml b/frontend/appflowy_web/wasm-libs/rustfmt.toml deleted file mode 100644 index 5cb0d67ee5..0000000000 --- a/frontend/appflowy_web/wasm-libs/rustfmt.toml +++ /dev/null @@ -1,12 +0,0 @@ -# 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/.eslintignore b/frontend/appflowy_web_app/.eslintignore deleted file mode 100644 index 8b900e497c..0000000000 --- a/frontend/appflowy_web_app/.eslintignore +++ /dev/null @@ -1,9 +0,0 @@ -node_modules/ -dist/ -src-tauri/ -.eslintrc.cjs -tsconfig.json -**/backend/** -vite.config.ts -**/*.cy.tsx -*.config.ts \ No newline at end of file diff --git a/frontend/appflowy_web_app/.eslintignore.web b/frontend/appflowy_web_app/.eslintignore.web deleted file mode 100644 index 4cee523276..0000000000 --- a/frontend/appflowy_web_app/.eslintignore.web +++ /dev/null @@ -1,7 +0,0 @@ -node_modules/ -dist/ -src-tauri/ -.eslintrc.cjs -tsconfig.json -src/application/services/tauri-services/ -vite.config.ts \ No newline at end of file diff --git a/frontend/appflowy_web_app/.eslintrc.cjs b/frontend/appflowy_web_app/.eslintrc.cjs deleted file mode 100644 index ff6f405885..0000000000 --- a/frontend/appflowy_web_app/.eslintrc.cjs +++ /dev/null @@ -1,73 +0,0 @@ -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_web_app/.gitignore b/frontend/appflowy_web_app/.gitignore deleted file mode 100644 index 474a3a975e..0000000000 --- a/frontend/appflowy_web_app/.gitignore +++ /dev/null @@ -1,32 +0,0 @@ -# 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 \ No newline at end of file diff --git a/frontend/appflowy_web_app/.prettierrc.cjs b/frontend/appflowy_web_app/.prettierrc.cjs deleted file mode 100644 index f283db53a2..0000000000 --- a/frontend/appflowy_web_app/.prettierrc.cjs +++ /dev/null @@ -1,20 +0,0 @@ -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/Dockerfile b/frontend/appflowy_web_app/Dockerfile deleted file mode 100644 index e7094ffe14..0000000000 --- a/frontend/appflowy_web_app/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM node:latest - -RUN apt-get update && \ - apt-get install -y nginx - -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 nginx-signed.crt /etc/ssl/certs/nginx-signed.crt -COPY nginx-signed.key /etc/ssl/private/nginx-signed.key - -RUN chown -R nginx:nginx /etc/ssl/certs/nginx-signed.crt /etc/ssl/private/nginx-signed.key - -EXPOSE 80 443 - -CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/appflowy_web_app/README.md b/frontend/appflowy_web_app/README.md deleted file mode 100644 index c5c8ebf51f..0000000000 --- a/frontend/appflowy_web_app/README.md +++ /dev/null @@ -1,284 +0,0 @@ -

- -

AppFlowy Web Project

- -
Welcome to the AppFlowy Web Project, a robust and versatile platform designed to bring the innovative features of -AppFlowy to the web. This project uniquely supports running as a desktop application via Tauri, and offers web -interfaces powered by WebAssembly (WASM). Dive into an exceptional development experience with high performance and -extensive capabilities.
- -
- -## 🐑 Features - -- **Cross-Platform Compatibility**: Seamlessly run on desktop environments with Tauri, and on any web browser through - WASM. -- **High Performance**: Leverage the speed and efficiency of WebAssembly for your web interfaces. -- **Tauri Integration**: Build lightweight, secure, and efficient desktop applications. -- **Flexible Development**: Utilize a wide range of AppFlowy's functionalities in your web or desktop projects. - -## 🚀 Getting Started - -### 🛠️ Prerequisites - -Before you begin, ensure you have the following installed: - -- Node.js (v14 or later) -- Rust (latest stable version) -- Tauri prerequisites for your operating system -- PNPM (8.5.0) - -### 🏗️ Installation - -#### Clone the Repository - - ```bash - git clone https://github.com/AppFlowy-IO/AppFlowy - ``` - -#### 🌐 Install the frontend dependencies: - - ```bash - cd frontend/appflowy_web_app - pnpm install - ``` - -#### 🖥️ Desktop Application (Tauri) (Optional) - -> **Note**: if you want to run the web app in the browser, skip this step - -- Follow the instructions [here](https://tauri.app/v1/guides/getting-started/prerequisites/) to install Tauri - -##### Windows and Linux Prerequisites - -###### Windows only - -- Install the Duckscript CLI and vcpkg - - ```bash - cargo install --force duckscript_cli - vcpkg integrate install - ``` - -###### Linux only - -- Install the required dependencies - - ```bash - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf - ``` - -- **Get error**: failed to run custom build command for librocksdb-sys v6.11.4 - - ```bash - sudo apt install clang - ``` - -##### Install Tauri Dependencies - -- Install cargo-make - - ```bash - cargo install --force cargo-make - ``` - - -- Install AppFlowy dev tools - - ```bash - # install development tools - # make sure you are in the root directory of the project - cd frontend - cargo make appflowy-tauri-deps-tools - ``` - -- Build the service/dependency - - ```bash - # make sure you are in the root directory of the project - cd frontend/appflowy_web_app - mkdir dist - cd src-tauri - cargo build - ``` - -### 🚀 Running the Application - -#### 🌐 Web Application - -- Run the web application - - ```bash - pnpm run dev - ``` -- Open your browser and navigate to `http://localhost:3000`, You can now interact with the AppFlowy web application - -#### 🖥️ Desktop Application (Tauri) - -**Ensure close web application before running the desktop application** - -- Run the desktop application - - ```bash - pnpm run tauri:dev - ``` -- The AppFlowy desktop application will open, and you can interact with it - -### 🛠️ Development - -#### How to add or modify i18n keys - -- Modify the i18n files in `frontend/resources/translations/en.json` to add or modify i18n keys -- Run the following command to update the i18n keys in the application - - ```bash - pnpm run sync:i18n - ``` - -#### How to modify the theme - -Don't modify the theme file in `frontend/appflowy_web_app/src/styles/variables` directly) - -- Modify the theme file in `frontend/appflowy_web_app/style-dictionary/tokens/base.json( or dark.json or light.json)` to - add or modify theme keys -- Run the following command to update the theme in the application - - ```bash - pnpm run css:variables - ``` - -#### How to add or modify the environment variables - -- Modify the environment file in `frontend/appflowy_web_app/.env` to add or modify environment variables - -#### How to create symlink for the @appflowyinc/client-api-wasm in local development - -- Run the following command to create a symlink for the @appflowyinc/client-api-wasm - - ```bash - # ensure you are in the frontend/appflowy_web_app directory - - pnpm run link:client-api $source_path $target_path - - # Example - # pnpm run link:client-api ../../../AppFlowy-Cloud/libs/client-api-wasm/pkg ./node_modules/@appflowyinc/client-api-wasm - ``` - -### 📝 About the Project - -#### 📁 Directory Structure - -- `frontend/appflowy_web_app`: Contains the web application source code -- `frontend/appflowy_web_app/src`: Contains the app entry point and the source code -- `frontend/appflowy_web_app/src/components`: Contains the react components -- `frontend/appflowy_web_app/src/styles`: Contains the styles for the application -- `frontend/appflowy_web_app/src/utils`: Contains the utility functions -- `frontend/appflowy_web_app/src/i18n`: Contains the i18n files -- `frontend/appflowy_web_app/src/assets`: Contains the assets for the application -- `frontend/appflowy_web_app/src/store`: Contains the redux store -- `frontend/appflowy_web_app/src/@types`: Contains the typescript types -- `frontend/appflowy_web_app/src/applications/services`: Contains the services for the application. In vite.config.ts, - we have defined the alias for the services directory for different environments(Tauri/Web) - ```typescript - resolve: { - alias: [ - // ... - { - find: '$client-services', - replacement: !!process.env.TAURI_PLATFORM - ? `${__dirname}/src/application/services/tauri-services` - : `${__dirname}/src/application/services/js-services`, - }, - ] - } - ``` - -### 📦 Deployment - -Use the AppFlowy CI/CD pipeline to deploy the application to the test and production environments. - -- Push the changes to the main branch -- Deploy Test Environment - - Automatically, the test environment will be deployed if merged to the main branch or build/test branch -- Deploy Production Environment - - Navigate to the Actions tab - - Click on the workflow and select the Run workflow - - Enter the options - - Click on the Run workflow button - -#### 📦 Deployment (Self-Hosted EC2) - -##### Pre-requisites - -Please ensure you have learned about: - -- [Deploy Web application on AWS Cloud using EC2 Instance](https://www.youtube.com/watch?v=gWVIIU1ev0Y) -- [How to Install and Use Rsync Command](https://operavps.com/docs/install-rsync-command-in-linux/) -- [How to Use ssh-keygen to Generate a New SSH Key?](https://www.ssh.com/academy/ssh/keygen) -- [Linux post-installation steps for Docker Engine](https://docs.docker.com/engine/install/linux-postinstall/) -- [Configuring HTTPS servers](https://nginx.org/en/docs/http/configuring_https_servers.html) - -And then follow the steps below: - -1. Ensure you have the following installed on your server: - - Docker: [Install Docker](https://docs.docker.com/engine/install/) - - Rsync: [Install Rsync](https://operavps.com/docs/install-rsync-command-in-linux/) - -2. Create a new user for deploy, and generate an SSH key for the user - - ```bash - sudo adduser appflowy(or any name) - sudo su - appflowy - mkdir ~/.ssh - chmod 700 ~/.ssh - ssh-keygen -t rsa - chmod 600 ~/.ssh/authorized_keys - # add the user to the docker group, to run docker commands without sudo - sudo usermod -aG docker ${USER} - ``` - - visit the `~/.ssh/id_rsa` and `~/.ssh/id_rsa.pub` to get the private and public key respectively - - add the public key to the `~/.ssh/authorized_keys` file - - ensure the private key is kept safe - - exit and login back to the server with the new - user: `ssh -i your-existing-key.pem ec2-user@your-instance-public-dns` - -3. Clone the AppFlowy repository - -4. Set the following secrets in your - repository, have to - know [Using secrets in GitHub Actions](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions) - -> Note: Test Environment: prefix the secret with `WEB_TEST_` and Production Environment: prefix the secret with `WEB_` - -> for example, `WEB_TEST_SSH_PRIVATE_KEY` and `WEB_SSH_PRIVATE_KEY` - -- `SSH_PRIVATE_KEY`: The private key generated in step 2: cat ~/.ssh/id_rsa -- `REMOTE_HOST`: The host of the server: `your-instance-public-dns` or `your-instance-ip` -- `REMOTE_USER`: The user created in step 2: `appflowy` -- `SSL_CERTIFICATE`: The SSL certificate for the - server - [Configuring HTTPS servers](https://nginx.org/en/docs/http/configuring_https_servers.html) -- `SSL_CERTIFICATE_KEY`: The SSL certificate key for the - server - [Configuring HTTPS servers](https://nginx.org/en/docs/http/configuring_https_servers.html) - -5. Run the deployment workflow to deploy the application(production or test environment) - -> Note: the test server will **automatically** deploy if merged to the main branch or build/test branch - -### 🧪 Testing - -> We use Cypress for end-to-end testing and component testing - [Cypress](https://www.cypress.io/) - -#### 🧪 End-to-End Testing - -> to be continued - -#### 🧪 Component Testing - -Run the following command to run the component tests - -```bash -pnpm run test:components -``` - - diff --git a/frontend/appflowy_web_app/beta.env b/frontend/appflowy_web_app/beta.env deleted file mode 100644 index ab31b57db7..0000000000 --- a/frontend/appflowy_web_app/beta.env +++ /dev/null @@ -1,3 +0,0 @@ -AF_WS_URL=wss://beta.appflowy.cloud/ws/v1 -AF_BASE_URL=https://beta.appflowy.cloud -AF_GOTRUE_URL=https://beta.appflowy.cloud/gotrue \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress.config.ts b/frontend/appflowy_web_app/cypress.config.ts deleted file mode 100644 index c8f9f6972f..0000000000 --- a/frontend/appflowy_web_app/cypress.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from 'cypress'; - -export default defineConfig({ - component: { - devServer: { - framework: 'react', - bundler: 'vite', - }, - }, - retries: { - // Configure retry attempts for `cypress run` - // Default is 0 - runMode: 2, - // Configure retry attempts for `cypress open` - // Default is 0 - openMode: 0, - }, -}); diff --git a/frontend/appflowy_web_app/cypress/fixtures/full_doc.json b/frontend/appflowy_web_app/cypress/fixtures/full_doc.json deleted file mode 100644 index e47a6c6fc0..0000000000 --- a/frontend/appflowy_web_app/cypress/fixtures/full_doc.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"state_vector":[43,192,246,139,213,2,35,131,182,180,202,12,53,132,236,218,251,9,14,197,205,192,233,12,9,198,223,206,159,1,145,2,199,130,209,189,2,141,8,136,172,186,168,4,182,6,131,128,202,229,9,1,204,195,206,156,1,153,9,141,178,210,127,3,140,167,201,161,14,10,207,231,154,196,9,3,142,211,188,164,13,15,206,214,243,86,178,1,146,216,250,133,2,180,1,150,152,188,203,6,20,151,234,142,238,11,27,150,216,171,142,3,188,8,217,168,198,159,4,7,218,255,204,32,21,155,213,159,176,1,10,219,200,174,197,9,25,224,159,166,178,15,30,161,234,157,145,5,7,226,167,254,250,5,13,228,242,134,215,15,12,165,131,171,211,15,20,229,154,194,35,178,1,164,202,219,213,10,122,168,215,223,235,2,56,171,236,222,251,5,252,4,172,254,181,239,1,15,236,158,128,159,2,4,239,239,208,251,10,17,176,238,158,139,14,175,2,241,147,239,232,6,4,178,187,245,161,14,11,243,138,171,183,10,252,1,245,181,155,135,2,23,181,156,253,158,6,5,247,212,219,208,10,46,184,146,243,216,14,7,190,183,139,210,2,110],"doc_state":[43,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,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,132,204,195,206,156,1,245,5,9,229,176,177,229,135,160,229,174,182,168,198,223,206,159,1,89,1,119,96,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,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,168,198,223,206,159,1,90,1,119,10,119,79,108,117,99,85,55,51,73,76,168,198,223,206,159,1,91,1,119,4,116,101,120,116,1,176,238,158,139,14,0,161,206,214,243,86,177,1,175,2,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,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,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,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,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,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,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,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,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,168,146,216,250,133,2,156,1,1,119,68,123,34,104,101,105,103,104,116,34,58,52,48,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,49,53,48,46,56,53,53,52,54,56,55,53,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,48,125,168,146,216,250,133,2,157,1,1,119,68,123,34,99,111,108,80,111,115,105,116,105,111,110,34,58,49,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,48,46,48,44,34,119,105,100,116,104,34,58,49,48,53,46,48,53,48,55,56,49,50,53,125,168,146,216,250,133,2,158,1,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,48,44,34,104,101,105,103,104,116,34,58,52,48,46,48,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,50,125,161,146,216,250,133,2,167,1,3,168,146,216,250,133,2,160,1,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,48,46,48,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,48,44,34,114,111,119,80,111,115,105,116,105,111,110,34,58,49,125,168,146,216,250,133,2,161,1,1,119,68,123,34,104,101,105,103,104,116,34,58,52,48,46,48,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,49,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,49,125,168,146,216,250,133,2,162,1,1,119,60,123,34,104,101,105,103,104,116,34,58,52,48,46,48,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,50,44,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,49,125,168,146,216,250,133,2,164,1,1,119,68,123,34,99,111,108,80,111,115,105,116,105,111,110,34,58,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,44,34,104,101,105,103,104,116,34,58,52,48,46,48,125,168,146,216,250,133,2,165,1,1,119,68,123,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,104,101,105,103,104,116,34,58,52,48,46,48,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,49,125,168,146,216,250,133,2,166,1,1,119,60,123,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,48,46,48,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,50,44,34,119,105,100,116,104,34,58,56,48,46,48,125,1,132,236,218,251,9,0,161,218,255,204,32,20,14,1,131,128,202,229,9,0,168,243,138,171,183,10,245,1,1,119,114,123,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,50,56,46,48,44,34,99,111,108,115,76,101,110,34,58,51,44,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,114,111,119,115,76,101,110,34,58,51,125,1,219,200,174,197,9,0,161,161,234,157,145,5,6,25,3,207,231,154,196,9,0,168,204,195,206,156,1,209,1,1,119,101,123,34,105,109,97,103,101,95,116,121,112,101,34,58,34,49,34,44,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,99,111,108,111,114,34,44,34,100,101,108,116,97,34,58,91,93,44,34,99,111,118,101,114,95,115,101,108,101,99,116,105,111,110,34,58,34,48,120,102,102,101,56,101,48,102,102,34,125,168,204,195,206,156,1,210,1,1,119,10,112,70,113,76,55,45,79,83,121,86,168,204,195,206,156,1,211,1,1,119,4,116,101,120,116,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,1,136,172,186,168,4,0,161,176,238,158,139,14,174,2,182,6,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,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,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,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,168,150,216,171,142,3,162,8,1,119,253,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,105,110,115,101,114,116,34,58,34,108,111,110,34,125,44,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,68,105,115,112,108,97,121,95,114,101,103,117,108,97,114,34,125,44,34,105,110,115,101,114,116,34,58,34,103,32,116,101,120,116,110,103,32,116,101,120,116,110,34,125,44,123,34,105,110,115,101,114,116,34,58,34,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,34,125,44,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,68,105,115,112,108,97,121,95,114,101,103,117,108,97,114,34,125,44,34,105,110,115,101,114,116,34,58,34,116,101,120,116,110,103,32,116,101,34,125,44,123,34,105,110,115,101,114,116,34,58,34,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,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,2,190,183,139,210,2,0,161,241,147,239,232,6,3,109,168,190,183,139,210,2,108,1,122,0,0,0,0,102,35,115,218,143,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,39,0,204,195,206,156,1,1,6,84,69,81,71,120,89,1,40,0,199,130,209,189,2,167,6,2,105,100,1,119,6,84,69,81,71,120,89,40,0,199,130,209,189,2,167,6,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,199,130,209,189,2,167,6,6,112,97,114,101,110,116,1,119,6,78,99,104,45,81,78,40,0,199,130,209,189,2,167,6,8,99,104,105,108,100,114,101,110,1,119,6,72,120,102,70,78,49,40,0,199,130,209,189,2,167,6,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,49,50,51,34,125,93,125,40,0,199,130,209,189,2,167,6,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,167,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,72,120,102,70,78,49,0,136,199,130,209,189,2,191,5,1,119,6,84,69,81,71,120,89,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,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,196,204,195,206,156,1,244,5,204,195,206,156,1,245,5,9,229,147,136,229,147,136,229,147,136,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,230,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,40,0,204,195,206,156,1,51,4,100,97,116,97,1,119,2,123,125,40,0,204,195,206,156,1,51,11,101,120,116,101,114,110,97,108,95,105,100,1,119,10,119,86,82,81,117,71,111,121,116,48,40,0,204,195,206,156,1,51,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,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,40,0,204,195,206,156,1,78,4,100,97,116,97,1,119,2,123,125,40,0,204,195,206,156,1,78,11,101,120,116,101,114,110,97,108,95,105,100,1,119,10,107,106,48,68,49,121,121,88,78,119,40,0,204,195,206,156,1,78,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,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,4,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,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,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,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,4,0,204,195,206,156,1,134,4,0,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,4,0,204,195,206,156,1,208,5,36,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,132,204,195,206,156,1,244,5,1,46,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,1,141,178,210,127,0,0,3,1,206,214,243,86,0,161,236,158,128,159,2,3,178,1,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,41,192,246,139,213,2,1,0,35,131,182,180,202,12,1,0,53,132,236,218,251,9,1,0,14,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,89,3,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,199,130,209,189,2,202,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,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,136,172,186,168,4,1,0,182,6,204,195,206,156,1,27,11,3,60,9,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,135,4,1,206,4,4,131,5,77,246,5,1,130,6,4,181,6,3,133,7,1,135,7,40,144,8,1,141,178,210,127,1,0,3,140,167,201,161,14,1,0,4,142,211,188,164,13,1,0,15,206,214,243,86,1,0,178,1,146,216,250,133,2,1,0,180,1,150,152,188,203,6,1,0,20,151,234,142,238,11,1,0,27,150,216,171,142,3,86,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,217,168,198,159,4,1,0,7,218,255,204,32,1,0,21,155,213,159,176,1,1,0,10,219,200,174,197,9,1,0,25,224,159,166,178,15,1,0,30,161,234,157,145,5,1,0,7,226,167,254,250,5,3,8,1,10,1,12,1,228,242,134,215,15,4,5,1,7,1,9,1,11,1,165,131,171,211,15,1,0,20,229,154,194,35,1,0,178,1,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,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,236,158,128,159,2,1,0,4,239,239,208,251,10,1,0,17,176,238,158,139,14,1,0,175,2,241,147,239,232,6,1,0,4,178,187,245,161,14,2,8,1,10,1,243,138,171,183,10,2,0,240,1,243,1,3,245,181,155,135,2,1,0,23,181,156,253,158,6,1,0,4,247,212,219,208,10,1,0,46,184,146,243,216,14,1,0,7,190,183,139,210,2,1,0,109],"version":0},"code":0,"message":"Operation completed successfully."} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/sign_in_success.json b/frontend/appflowy_web_app/cypress/fixtures/sign_in_success.json deleted file mode 100644 index 0679311668..0000000000 --- a/frontend/appflowy_web_app/cypress/fixtures/sign_in_success.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTI4Mjk2MjAsImlhdCI6MTcxMjgyNjAyMCwic3ViIjoiY2JmZjA2MGEtMTk2ZC00MTVhLWFhODAtNzU5YzAxODg2NDY2IiwiZW1haWwiOiJsdUBhcHBmbG93eS5pbyIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZ29vZ2xlIiwicHJvdmlkZXJzIjpbImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTEhabVZBczRTb0ZlVFFuWG5CU2JiNTBBVXF0YktHNWx5MGllVHZCSklYZ1o3UmdRPXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImFwcGZsb3d5LmlvIn0sImVtYWlsIjoibHVAYXBwZmxvd3kuaW8iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTHUgSGUiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTHUgSGUiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NMSFptVkFzNFNvRmVUUW5YbkJTYmI1MEFVcXRiS0c1bHkwaWVUdkJKSVhnWjdSZ1E9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSIsInN1YiI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSJ9LCJyb2xlIjoiIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoicGFzc3dvcmQiLCJ0aW1lc3RhbXAiOjE3MTI4MjYwMjB9XSwic2Vzc2lvbl9pZCI6ImJmMzE5OTRlLTQwMTgtNDhjMS05Yzc0LWVmYzkyMGNjOWQ0NSJ9.QeTrRhsnBjBL1GUS3TIWOgU1SPM6RcaWwxZdMVfcFBU", - "token_type": "bearer", - "expires_in": 3600, - "expires_at": 4869016461, - "refresh_token": "71vp1jJnSAVluZKaXkhG1A", - "user": { - "id": "cbff060a-196d-415a-aa80-759c01886466", - "aud": "", - "role": "", - "email": "lu@appflowy.io", - "email_confirmed_at": "2024-03-13T10:49:53.165361Z", - "phone": "", - "confirmed_at": "2024-03-13T10:49:53.165361Z", - "last_sign_in_at": "2024-04-11T09:00:20.547468985Z", - "app_metadata": { - "provider": "google", - "providers": [ - "google" - ] - }, - "user_metadata": { - "avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c", - "custom_claims": { - "hd": "appflowy.io" - }, - "email": "lu@appflowy.io", - "email_verified": true, - "full_name": "Lu He", - "iss": "https://accounts.google.com", - "name": "Lu He", - "phone_verified": false, - "picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c", - "provider_id": "101169250829554028381", - "sub": "101169250829554028381" - }, - "identities": [ - { - "identity_id": "e4cf8b69-7f80-42e9-aed2-e25132ad0178", - "id": "101169250829554028381", - "user_id": "cbff060a-196d-415a-aa80-759c01886466", - "identity_data": { - "avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c", - "custom_claims": { - "hd": "appflowy.io" - }, - "email": "lu@appflowy.io", - "email_verified": true, - "full_name": "Lu He", - "iss": "https://accounts.google.com", - "name": "Lu He", - "phone_verified": false, - "picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c", - "provider_id": "101169250829554028381", - "sub": "101169250829554028381" - }, - "provider": "google", - "last_sign_in_at": "2024-03-13T07:22:43.110504Z", - "created_at": "2024-03-13T07:22:43.110543Z", - "updated_at": "2024-04-04T06:15:14.03093Z" - } - ], - "created_at": "2024-03-13T07:22:43.102586Z", - "updated_at": "2024-04-11T09:00:20.551485Z" - } -} \ 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 deleted file mode 100644 index 97bd9c99b5..0000000000 --- a/frontend/appflowy_web_app/cypress/fixtures/simple_doc.json +++ /dev/null @@ -1 +0,0 @@ -{"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/fixtures/user.json b/frontend/appflowy_web_app/cypress/fixtures/user.json deleted file mode 100644 index 5b429dcd59..0000000000 --- a/frontend/appflowy_web_app/cypress/fixtures/user.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "data": { - "uid": 304120109071339520, - "uuid": "cbff060a-196d-415a-aa80-759c01886466", - "email": "lu@appflowy.io", - "password": "", - "name": "Kilu", - "metadata": { - "icon_url": "🇽🇰" - }, - "encryption_sign": null, - "latest_workspace_id": "fcb503f9-9287-4de4-8de0-ea191e680968", - "updated_at": 1710421586 - }, - "code": 0, - "message": "Operation completed successfully." -} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/verify_token.json b/frontend/appflowy_web_app/cypress/fixtures/verify_token.json deleted file mode 100644 index 503838f0a6..0000000000 --- a/frontend/appflowy_web_app/cypress/fixtures/verify_token.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "code": 0, - "data": { - "is_new": false - } -} \ 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 deleted file mode 100644 index 6146bd1c01..0000000000 --- a/frontend/appflowy_web_app/cypress/support/commands.ts +++ /dev/null @@ -1,46 +0,0 @@ -/// -// *********************************************** -// 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) => { ... }) -// - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -Cypress.Commands.add('mockAPI', () => { - cy.fixture('sign_in_success').then((json) => { - cy.intercept('GET', `/api/user/verify/${json.access_token}`, { - fixture: 'verify_token', - }).as('verifyToken'); - cy.intercept('POST', '/gotrue/token?grant_type=password', json).as('loginSuccess'); - cy.intercept('POST', '/gotrue/token?grant_type=refresh_token', json).as('refreshToken'); - }); - cy.intercept('GET', '/api/user/profile', { fixture: 'user' }).as('getUserProfile'); -}); - -// Example use: -// beforeEach(() => { -// cy.mockAPI(); -// }); - diff --git a/frontend/appflowy_web_app/cypress/support/component-index.html b/frontend/appflowy_web_app/cypress/support/component-index.html deleted file mode 100644 index b8b58ae50c..0000000000 --- a/frontend/appflowy_web_app/cypress/support/component-index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - 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 deleted file mode 100644 index a6ca9728e3..0000000000 --- a/frontend/appflowy_web_app/cypress/support/component.ts +++ /dev/null @@ -1,41 +0,0 @@ -// *********************************************************** -// 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 commands.js using ES2015 syntax: -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; - } - } -} - -Cypress.Commands.add('mount', mount); - -// Example use: -// cy.mount() diff --git a/frontend/appflowy_web_app/cypress/support/document.ts b/frontend/appflowy_web_app/cypress/support/document.ts deleted file mode 100644 index 757974f14b..0000000000 --- a/frontend/appflowy_web_app/cypress/support/document.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { BlockId, BlockType, YBlocks, YChildrenMap, YjsEditorKey, YTextMap } from '@/application/collab.type'; -import { nanoid } from 'nanoid'; -import * as Y from 'yjs'; - -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; - } -} diff --git a/frontend/appflowy_web_app/index.html b/frontend/appflowy_web_app/index.html deleted file mode 100644 index 3548e9b85d..0000000000 --- a/frontend/appflowy_web_app/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - AppFlowy - - -
- - - - - diff --git a/frontend/appflowy_web_app/jest.config.cjs b/frontend/appflowy_web_app/jest.config.cjs deleted file mode 100644 index 0d20a53241..0000000000 --- a/frontend/appflowy_web_app/jest.config.cjs +++ /dev/null @@ -1,20 +0,0 @@ -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})`], -}; \ No newline at end of file diff --git a/frontend/appflowy_web_app/nginx.conf b/frontend/appflowy_web_app/nginx.conf deleted file mode 100644 index 56529cca1a..0000000000 --- a/frontend/appflowy_web_app/nginx.conf +++ /dev/null @@ -1,91 +0,0 @@ -# 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_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; - #server_name appflowy.com *.appflowy.com; - - location / { - return 301 https://$host$request_uri; - } - - } - - # Additional server block for HTTPS - server { - listen 443 ssl http2; - listen [::]:443 ssl http2; - server_name localhost; - #server_name appflowy.com *.appflowy.com; - - ssl_certificate /etc/ssl/certs/nginx-signed.crt; - ssl_certificate_key /etc/ssl/private/nginx-signed.key; - - ssl_session_cache shared:SSL:1m; - ssl_session_timeout 5m; - - ssl_ciphers HIGH:!aNULL:!MD5; - ssl_prefer_server_ciphers on; - - location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri/ /index.html; - } - - 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; - } - - 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; - } - } -} diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json deleted file mode 100644 index 1acc7d6e82..0000000000 --- a/frontend/appflowy_web_app/package.json +++ /dev/null @@ -1,146 +0,0 @@ -{ - "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 style-dictionary/config.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" - }, - "dependencies": { - "@appflowyinc/client-api-wasm": "0.0.2-alpha.2", - "@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", - "@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", - "axios": "^1.6.8", - "dayjs": "^1.11.9", - "dexie": "^4.0.1", - "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": "^4.0.13", - "react-hot-toast": "^2.4.1", - "react-i18next": "^14.1.0", - "react-katex": "^3.0.1", - "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", - "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", - "validator": "^13.11.0", - "valtio": "^1.12.1", - "vite-plugin-wasm": "^3.3.0", - "y-indexeddb": "9.0.12", - "yjs": "^13.6.14" - }, - "devDependencies": { - "@svgr/plugin-svgo": "^8.0.1", - "@tauri-apps/cli": "^1.5.11", - "@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/prismjs": "^1.26.0", - "@types/quill": "^2.0.10", - "@types/react": "^18.2.66", - "@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.2.22", - "@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", - "@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", - "babel-jest": "^29.6.2", - "chalk": "^4.1.2", - "cross-env": "^7.0.3", - "cypress": "^13.7.2", - "eslint": "^8.57.0", - "eslint-plugin-react": "^7.32.2", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.6", - "jest-environment-jsdom": "^29.6.2", - "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-importer": "^0.2.5", - "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 deleted file mode 100644 index b9fe83de2f..0000000000 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ /dev/null @@ -1,8843 +0,0 @@ -lockfileVersion: '6.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -dependencies: - '@appflowyinc/client-api-wasm': - specifier: 0.0.2-alpha.2 - version: 0.0.2-alpha.2 - '@atlaskit/primitives': - specifier: ^5.5.3 - version: 5.5.3(@types/react@18.2.66)(react@18.2.0) - '@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.10.6(@types/react@18.2.66)(react@18.2.0) - '@emotion/styled': - specifier: ^11.10.6 - version: 11.10.6(@emotion/react@11.10.6)(@types/react@18.2.66)(react@18.2.0) - '@mui/icons-material': - specifier: ^5.11.11 - version: 5.11.11(@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.10.6)(@emotion/styled@11.10.6)(@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.18.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.14.4)(@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.0.5)(react@18.2.0) - '@slate-yjs/core': - specifier: ^1.0.2 - version: 1.0.2(slate@0.101.4)(yjs@13.6.14) - '@tauri-apps/api': - specifier: ^1.5.3 - version: 1.5.3 - '@types/react-swipeable-views': - specifier: ^0.13.4 - version: 0.13.4 - axios: - specifier: ^1.6.8 - version: 1.6.8 - dayjs: - specifier: ^1.11.9 - version: 1.11.9 - dexie: - specifier: ^4.0.1 - version: 4.0.1 - 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.16.0 - i18next: - specifier: ^22.4.10 - version: 22.4.10 - 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@20.11.30) - 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.0 - 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: ^4.0.13 - version: 4.0.13(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.0(i18next@22.4.10)(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.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.22.3(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.2(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.0 - 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 - validator: - specifier: ^13.11.0 - version: 13.11.0 - valtio: - specifier: ^1.12.1 - version: 1.12.1(@types/react@18.2.66)(react@18.2.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@13.6.14) - yjs: - specifier: ^13.6.14 - version: 13.6.14 - -devDependencies: - '@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 - '@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/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-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-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 - '@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) - babel-jest: - specifier: ^29.6.2 - version: 29.6.2(@babel/core@7.24.3) - chalk: - specifier: ^4.1.2 - version: 4.1.2 - cross-env: - specifier: ^7.0.3 - version: 7.0.3 - cypress: - specifier: ^13.7.2 - version: 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) - jest-environment-jsdom: - specifier: ^29.6.2 - version: 29.6.2 - 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.70.0) - vite-plugin-compression2: - specifier: ^1.0.0 - version: 1.0.0 - vite-plugin-importer: - specifier: ^0.2.5 - version: 0.2.5 - 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 - - /@appflowyinc/client-api-wasm@0.0.2-alpha.2: - resolution: {integrity: sha512-BcRK06zHHJdaGNYohYxGaR2xPfQ1RwU48jMzdMZDf2HXVLU2WWQ6cYfuM4lrsK+O3QEfJdeEL2fntnQDaaeQng==} - dev: false - - /@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.2.3(react@18.2.0): - resolution: {integrity: sha512-zTtM6xHTNNMO0dxiPrsxMko6BawEh4wN0wWSvMdQXdnusvvWNEE8rnpjn23kS4M08bYWIEstRbgoPLx6VQisug==} - 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.4 - 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.0(react@18.2.0): - resolution: {integrity: sha512-0bHfIbXyCbYjuv0zMyHPyuCcyW5IIVCBsNbYEbEl9w5P9hChPyewNrz1VkNPRQBg18DF1zxIIsNyZOEuQjvs7Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ~18.2.0 - dependencies: - '@atlaskit/tokens': 1.43.0(react@18.2.0) - '@babel/runtime': 7.24.4 - bind-event-listener: 2.1.1 - 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.43.0(react@18.2.0) - '@babel/runtime': 7.24.4 - '@compiled/react': 0.17.1(react@18.2.0) - react: 18.2.0 - transitivePeerDependencies: - - supports-color - dev: false - - /@atlaskit/ds-lib@2.3.0(react@18.2.0): - resolution: {integrity: sha512-ULU9ZTVBvlQ9QUwKXhCju3/fUWT79zdX5omNYOIL3nUrAtBCPX7inNpIJ/D4Lx35O6XSWJRKuNFL5tR75FEYKQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ~18.2.0 - dependencies: - '@babel/runtime': 7.24.4 - bind-event-listener: 2.1.1 - 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.4 - react: 18.2.0 - dev: false - - /@atlaskit/platform-feature-flags@0.2.5: - resolution: {integrity: sha512-0fD2aDxn2mE59D4acUhVib+YF2HDYuuPH50aYwpQdcV/CsVkAaJsMKy8WhWSulcRFeMYp72kfIfdy0qGdRB7Uw==} - dependencies: - '@babel/runtime': 7.24.4 - dev: false - - /@atlaskit/primitives@5.5.3(@types/react@18.2.66)(react@18.2.0): - resolution: {integrity: sha512-UnKx0N/Jeo5Z61wFm5U2OkJM3bcb1qpKUHYrxzmSVe+QfQvBbJ/U5882WMnwQYq9rqYR2NcFvRNyT8Lf/L4FRQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - dependencies: - '@atlaskit/analytics-next': 9.2.3(react@18.2.0) - '@atlaskit/app-provider': 1.3.0(react@18.2.0) - '@atlaskit/css': 0.1.0(react@18.2.0) - '@atlaskit/ds-lib': 2.3.0(react@18.2.0) - '@atlaskit/interaction-context': 2.1.4(react@18.2.0) - '@atlaskit/tokens': 1.43.0(react@18.2.0) - '@atlaskit/visually-hidden': 1.2.5(@types/react@18.2.66)(react@18.2.0) - '@babel/runtime': 7.24.4 - '@emotion/react': 11.10.6(@types/react@18.2.66)(react@18.2.0) - '@emotion/serialize': 1.1.4 - bind-event-listener: 2.1.1 - react: 18.2.0 - tiny-invariant: 1.3.3 - transitivePeerDependencies: - - '@types/react' - - supports-color - dev: false - - /@atlaskit/tokens@1.43.0(react@18.2.0): - resolution: {integrity: sha512-3rRxGRnJGQBVKGqNqy+Zuad3xuDZ7uD+aFGRcU2OpLuIpiFLX95agDZ9w0HGzNiDw9eWi2f1j8Uzq06AyaRqTw==} - peerDependencies: - react: ^16.8.0 - dependencies: - '@atlaskit/ds-lib': 2.3.0(react@18.2.0) - '@atlaskit/platform-feature-flags': 0.2.5 - '@babel/runtime': 7.24.4 - '@babel/traverse': 7.24.1 - '@babel/types': 7.24.0 - bind-event-listener: 2.1.1 - react: 18.2.0 - transitivePeerDependencies: - - supports-color - dev: false - - /@atlaskit/visually-hidden@1.2.5(@types/react@18.2.66)(react@18.2.0): - resolution: {integrity: sha512-khxJB6456uMS1IkwnsH0ZTYGVAnE83aoGarz388I0zPydOZdNeeEdwsRPFOn9mua88Ez+rL0OEWV22k6XRUf6A==} - peerDependencies: - react: ^16.8.0 - dependencies: - '@babel/runtime': 7.24.4 - '@emotion/react': 11.10.6(@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/compat-data@7.24.1: - resolution: {integrity: sha512-Pc65opHDliVpRHuKfzI+gSA4zcgr65O4cl64fFJIWEEh8JoHIHh0Oez1Eo8Arz8zq/JhgKodQaxEwUPRtZylVA==} - engines: {node: '>=6.9.0'} - - /@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/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-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.24.0 - '@babel/types': 7.24.0 - - /@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-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-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-plugin-utils@7.24.0: - resolution: {integrity: sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==} - engines: {node: '>=6.9.0'} - - /@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-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-string-parser@7.24.1: - resolution: {integrity: sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==} - 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.23.5: - resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==} - engines: {node: '>=6.9.0'} - - /@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/parser@7.24.1: - resolution: {integrity: sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==} - engines: {node: '>=6.0.0'} - hasBin: true - dependencies: - '@babel/types': 7.24.0 - - /@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.0 - - /@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.0 - - /@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-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.0 - - /@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-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-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/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.4: - resolution: {integrity: sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==} - engines: {node: '>=6.9.0'} - dependencies: - regenerator-runtime: 0.14.1 - dev: false - - /@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/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/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 - - /@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/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/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.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.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.10.6(@types/react@18.2.66)(react@18.2.0): - resolution: {integrity: sha512-6HT8jBmcSkfzO7mc+N1L9uwvOnlcGoix8Zn7srt+9ga0MjREo6lRpuVX0kzo6Jp6oTqDhREOFsygN6Ew4fEQbw==} - 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.10.6(@emotion/react@11.10.6)(@types/react@18.2.66)(react@18.2.0): - resolution: {integrity: sha512-OXtBzOmDSJo5Q0AFemHCfl+bUueT8BIcPSxu0EGTpGk6DmI5dnhSzQANm1e1ze0YZL7TDyAyy6s/b/zmGOS3Og==} - 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.10.6(@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.0: - resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} - dependencies: - '@floating-ui/utils': 0.2.1 - dev: false - - /@floating-ui/dom@1.6.3: - resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==} - dependencies: - '@floating-ui/core': 1.6.0 - '@floating-ui/utils': 0.2.1 - dev: false - - /@floating-ui/react-dom@2.0.8(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - dependencies: - '@floating-ui/dom': 1.6.3 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@floating-ui/utils@0.2.1: - resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} - 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/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/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 - - /@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.0.8(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.0 - 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.4 - '@floating-ui/react-dom': 2.0.8(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.1(@types/react@18.2.66)(react@18.2.0) - '@popperjs/core': 2.11.8 - '@types/react': 18.2.66 - clsx: 2.1.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@6.0.0-alpha.2: - resolution: {integrity: sha512-6JSNsfMePoLZ0tKZzQ2i+W8mcZCbP9snFNf/pQGOsMK2igkPGqOfqgdsda2OJrD5tP9VGjEDrrmYGHuX3ir29g==} - dev: false - - /@mui/icons-material@5.11.11(@mui/material@6.0.0-alpha.2)(@types/react@18.2.66)(react@18.2.0): - resolution: {integrity: sha512-Eell3ADmQVE8HOpt/LZ3zIma8JSvPh3XgnhwZLT0k5HRqZcd6F/QDHc7xsWtgz09t+UEFvOYJXjtrwKmLdwwpw==} - 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.10.6)(@emotion/styled@11.10.6)(@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.10.6)(@emotion/styled@11.10.6)(@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.4 - '@emotion/react': 11.10.6(@types/react@18.2.66)(react@18.2.0) - '@emotion/styled': 11.10.6(@emotion/react@11.10.6)(@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-alpha.2 - '@mui/system': 6.0.0-alpha.1(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@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.1(@types/react@18.2.66)(react@18.2.0) - '@types/react': 18.2.66 - '@types/react-transition-group': 4.4.10 - clsx: 2.1.0 - 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.4 - '@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.1(@types/react@18.2.66)(react@18.2.0): - resolution: {integrity: sha512-S+vlRxja0ncLT1KK+Qkqy6ZNpIsw5ZxoTd70KR8foLtPQXs0XGWQkq9IotbcZnYyKUUhM/5Cqo3w8Q/zNNVzBQ==} - 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.4 - '@mui/utils': 6.0.0-alpha.1(@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.10.6)(@emotion/styled@11.10.6)(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.4 - '@emotion/cache': 11.11.0 - '@emotion/react': 11.10.6(@types/react@18.2.66)(react@18.2.0) - '@emotion/styled': 11.10.6(@emotion/react@11.10.6)(@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.1(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(react@18.2.0): - resolution: {integrity: sha512-lJmMF1D3SD9unSh5gjXYU3ChiCrNGrpT36cPOmGv7wiH2qLMov5Wo23nK7UfuaQVA4c4/JAS43SLT2O2z6hx4Q==} - 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.4 - '@emotion/cache': 11.11.0 - '@emotion/react': 11.10.6(@types/react@18.2.66)(react@18.2.0) - '@emotion/styled': 11.10.6(@emotion/react@11.10.6)(@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.14.4(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(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.24.4 - '@emotion/react': 11.10.6(@types/react@18.2.66)(react@18.2.0) - '@emotion/styled': 11.10.6(@emotion/react@11.10.6)(@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.10.6)(@emotion/styled@11.10.6)(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.0 - csstype: 3.1.3 - prop-types: 15.8.1 - react: 18.2.0 - dev: false - - /@mui/system@6.0.0-alpha.1(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react@18.2.0): - resolution: {integrity: sha512-NqOFHCT/lvi4v696b0pEEz2Jc3MgjjGRsbFNN+IW5EuIInHr/JEznnPw0GpEU8tgU544MdfMTlFpDgR1LmvmLA==} - 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.4 - '@emotion/react': 11.10.6(@types/react@18.2.66)(react@18.2.0) - '@emotion/styled': 11.10.6(@emotion/react@11.10.6)(@types/react@18.2.66)(react@18.2.0) - '@mui/private-theming': 6.0.0-alpha.1(@types/react@18.2.66)(react@18.2.0) - '@mui/styled-engine': 6.0.0-alpha.1(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(react@18.2.0) - '@mui/types': 7.2.14(@types/react@18.2.66) - '@mui/utils': 6.0.0-alpha.1(@types/react@18.2.66)(react@18.2.0) - '@types/react': 18.2.66 - clsx: 2.1.0 - 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.1(@types/react@18.2.66)(react@18.2.0): - resolution: {integrity: sha512-BvTVWvW6JDxMjDfEfTTEb6/CLgj87HyS3hYA4hbk0LdFsXeRnFSK6yDj8Dw3gxvXBymQ26kHDyFK3pEoAFlurw==} - 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.4 - '@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.18.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.14.4)(@types/react@18.2.66)(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.24.1 - '@emotion/react': 11.10.6(@types/react@18.2.66)(react@18.2.0) - '@emotion/styled': 11.10.6(@emotion/react@11.10.6)(@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.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) - '@mui/system': 5.14.4(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@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.18.2(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.14.4)(@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.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.10.6)(@emotion/styled@11.10.6)(@mui/material@6.0.0-alpha.2)(@mui/system@5.14.4)(@types/react@18.2.66)(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.24.1 - '@emotion/react': 11.10.6(@types/react@18.2.66)(react@18.2.0) - '@emotion/styled': 11.10.6(@emotion/react@11.10.6)(@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.10.6)(@emotion/styled@11.10.6)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) - '@mui/system': 5.14.4(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@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.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.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.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.4 - react: 18.2.0 - react-redux: 8.0.5(@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.0.1 - dev: false - - /@remix-run/router@1.15.3: - resolution: {integrity: sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==} - 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@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.4)(yjs@13.6.14): - 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.14) - yjs: 13.6.14 - 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.3: - resolution: {integrity: sha512-zxnDjHHKjOsrIzZm6nO5Xapb/BxqUq1tc7cGkFXsFkGTsSWgCPH1D8mm0XS9weJY2OaR73I3k3S+b7eSzJDfqA==} - 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 - - /@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/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/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/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/node@20.11.30: - resolution: {integrity: sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==} - dependencies: - undici-types: 5.26.5 - - /@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-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-katex@3.0.0: - resolution: {integrity: sha512-AiHHXh71a2M7Z6z1wj6iA23SkiRF9r0neHUdu8zjU/cT3MyLxDefYHbcceKhV/gjDEZgF3YaiNHyPNtoGUjPvg==} - 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.4: - resolution: {integrity: sha512-hQV9Oq6oa+9HKdnGd43xkckElwf5dThOiegtQxqE7qX761oHhxnZO07fz6IsKSnUy9J3tzlRQBu3sNyvC8+kYw==} - 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/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==} - dev: true - - /@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.70.0) - transitivePeerDependencies: - - supports-color - 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 - - /acorn-globals@7.0.1: - resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} - dependencies: - acorn: 8.11.3 - acorn-walk: 8.3.2 - 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@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-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@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@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 - - /arch@2.2.0: - resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} - 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 - - /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 - dev: true - - /assert-plus@1.0.0: - resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} - engines: {node: '>=0.8'} - dev: true - - /astral-regex@2.0.0: - resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} - engines: {node: '>=8'} - dev: true - - /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 - - /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==} - dev: true - - /aws4@1.12.0: - resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} - dev: true - - /axios@1.6.8: - resolution: {integrity: sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==} - dependencies: - follow-redirects: 1.15.6 - form-data: 4.0.0 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - dev: false - - /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-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.1 - cosmiconfig: 7.1.0 - resolve: 1.22.8 - dev: false - - /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) - - /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 - dev: true - - /binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - - /bind-event-listener@2.1.1: - resolution: {integrity: sha512-O+a5c0D2se/u2VlBJmPRn45IB6R4mYMh1ok3dWxrIZ2pmLqzggBhb875mbq73508ylzofc0+hT9W41x4Y2s8lg==} - dev: false - - /blob-util@2.0.2: - resolution: {integrity: sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==} - 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 - - /cachedir@2.4.0: - resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} - engines: {node: '>=6'} - 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==} - 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'} - - /check-more-types@2.24.0: - resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} - engines: {node: '>= 0.8.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 - - /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-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@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.1.0: - resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==} - 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 - - /combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - dependencies: - delayed-stream: 1.0.0 - - /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-tags@1.8.2: - resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} - engines: {node: '>=4.0.0'} - 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==} - - /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==} - dev: false - - /convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - /core-util-is@1.0.2: - resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} - dev: true - - /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@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@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==} - - /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 - dev: true - - /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 - - /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 - - /decimal.js@10.4.3: - resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} - dev: true - - /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'} - - /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'} - 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.66)(react@18.2.0) - dev: false - - /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 - - /dexie@4.0.1: - resolution: {integrity: sha512-wSNn+TcCh+DuE2pdg058K3MhxA4g+IiZlW7yGz4cMd/t3z2rJXZcV3HDxZljbrICU2Iq0qY4UHnbolTMK/+bcA==} - 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-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@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@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.6.2 - 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 - 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.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==} - - /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 - - /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@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.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-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 - - /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'} - - /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@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@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@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@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 - - /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'} - dev: false - - /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} - dev: true - - /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-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 - - /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 - - /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.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 - dev: false - - /for-each@0.3.3: - resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} - dependencies: - is-callable: 1.2.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==} - dev: true - - /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 - 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 - - /fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - 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@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-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - - /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 - 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@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 - - /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.16.0: - resolution: {integrity: sha512-gBY66yYL1wbQMU2r1POkXSXkm035Ni0wFv3vx0K9IEUsJLP9G5rAcFVn0xUXfZneRu6MmDjaw93pt/DE56VOyw==} - 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 - - /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 - - /hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - dependencies: - function-bind: 1.1.2 - - /header-case@2.0.4: - resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} - dependencies: - capital-case: 1.0.4 - tslib: 2.6.2 - 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(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - dev: true - - /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.0.1: - resolution: {integrity: sha512-Pa5kFwaczXJAeHE56CHG2aWzFBMJNUNghf0Pm4SwSrEMps/PTKqW90EYWlIvhuYStf3Sn1K0vw+gH3+TLdkH1g==} - dependencies: - '@babel/runtime': 7.24.1 - dev: false - - /i18next-resources-to-backend@1.1.4: - resolution: {integrity: sha512-hMyr9AOmIea17AOaVe1srNxK/l3mbk81P7Uf3fdcjlw3ehZy3UNTd0OP3EEi6yu4J02kf9jzhCcjokz6AFlEOg==} - dependencies: - '@babel/runtime': 7.24.1 - dev: false - - /i18next@22.4.10: - resolution: {integrity: sha512-3EqgGK6fAJRjnGgfkNSStl4mYLCjUoJID338yVyLMj5APT67HUtWoqSayZewiiC5elzMUB1VEUwcmSCoeQcNEA==} - 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.0.4: - resolution: {integrity: sha512-cuBuGK40P/sk5IzWa9QPUaAdvPHjkk1c+xYsd9oZw+YQQEV+10G0P5uMpGctZZKnyQ+ibRO08bD25nWLmYi2pw==} - dev: false - - /immutable@4.3.5: - resolution: {integrity: sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==} - - /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 - - /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-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-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@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==} - dev: true - - /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-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==} - - /isomorphic.js@0.2.5: - resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} - dev: false - - /isstream@0.1.2: - resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} - dev: true - - /istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - /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-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 - - /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 - - /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-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-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@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 - - /js-base64@3.7.5: - resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==} - 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==} - 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.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@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==} - dev: true - - /json-schema@0.4.0: - resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} - dev: true - - /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==} - dev: true - - /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@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - dev: true - - /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.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 - - /keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - dependencies: - json-buffer: 3.0.1 - 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.93: - resolution: {integrity: sha512-M5IKsiFJYulS+8Eal8f+zAqf5ckm1vffW0fFDxfgxJ+uiVopvDdd3PxJmz0GsVi3YNO7QCFSq0nAsiDmNhLj9Q==} - 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==} - - /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 - - /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.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 - - /magic-string@0.30.8: - resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} - engines: {node: '>=12'} - dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 - 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 - - /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'} - - /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@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@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.0: - resolution: {integrity: sha512-IgBP8piMxe/gf73RTQx7hmnhwz0aaEXYakvqZyE302IXW3HyVNhdNGC+O2MwMAVhLEnvXlvKtGbtJf6wvHihCg==} - engines: {node: ^14 || ^16 || >=18} - hasBin: true - dev: false - - /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.6.2 - dev: true - - /node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - - /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 - - /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.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 - - /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-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-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'} - - /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-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@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.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@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-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'} - - /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 - - /pirates@4.0.6: - resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} - 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 - - /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@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@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 - - /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 - - /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==} - dev: false - - /psl@1.9.0: - resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} - dev: true - - /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'} - dev: true - - /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 - - /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-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 - - /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.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.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@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.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.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@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.1 - 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.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.0(i18next@22.4.10)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-3KwX6LHpbvGQ+sBEntjV4sYW3Zovjjl3fpoHbUwSgFHf0uRBcbeCBLR5al6ikncI5+W0EFb71QXZmfop+J6NrQ==} - 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.4.10 - 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.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.0.5(@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-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.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.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.22.3(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - dependencies: - '@remix-run/router': 1.15.3 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-router: 6.22.3(react@18.2.0) - dev: false - - /react-router@6.22.3(react@18.2.0): - resolution: {integrity: sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - dependencies: - '@remix-run/router': 1.15.3 - 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.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.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 - - /react18-input-otp@1.1.2(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-E21NiPh/KH67Bq/uEAm78E8H+croiGAyX5WcXfX49qh0im1iKrk/3RCKCTESG6WUoJYyh/fj5JY0UrHm+Mm0eQ==} - 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 - - /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.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 - - /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==} - - /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 - - /request-progress@3.0.0: - resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} - dependencies: - throttleit: 1.0.1 - dev: true - - /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.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 - - /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==} - 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 - - /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.6.0 - immutable: 4.3.5 - 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 - - /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.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 - - /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-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.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.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.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.4 - tiny-invariant: 1.3.1 - dev: false - - /slate@0.101.4: - resolution: {integrity: sha512-8LazZrNDsYFKDg1wpb0HouAfX5Pw/UmOZ/vIrtqD2GSCDZvraOkV2nVJ9Ery8kIlsU1jeybwgcaCy4KkVwfvEg==} - dependencies: - immer: 10.0.4 - 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 - - /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 - - /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 - - /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 - 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 - - /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-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@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 - - /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 - - /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 - - /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==} - dev: true - - /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@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 - - /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 - dev: true - - /tweetnacl@0.14.5: - resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - 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-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 - - /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==} - - /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 - 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@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.11.0: - resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==} - engines: {node: '>= 0.10'} - dev: false - - /valtio@1.12.1(@types/react@18.2.66)(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.66 - 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 - - /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 - dev: true - - /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-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-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.70.0) - 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.70.0) - 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.70.0) - 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.70.0) - dev: false - - /vite@5.2.0(@types/node@20.11.30)(sass@1.70.0): - 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.70.0 - 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 - - /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.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@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@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 - - /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@13.6.14): - 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.93 - yjs: 13.6.14 - dev: false - - /y-protocols@1.0.6(yjs@13.6.14): - 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.93 - yjs: 13.6.14 - 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'} - - /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.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@13.6.14: - resolution: {integrity: sha512-D+7KcUr0j+vBCUSKXXEWfA+bG4UQBviAwP3gYBhkstkgwy5+8diOPMx0iqLIOxNo/HxaREUimZRxqHGAHCL2BQ==} - engines: {node: '>=16.0.0', npm: '>=8.0.0'} - dependencies: - lib0: 0.2.93 - 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'} diff --git a/frontend/appflowy_web_app/postcss.config.cjs b/frontend/appflowy_web_app/postcss.config.cjs deleted file mode 100644 index 12a703d900..0000000000 --- a/frontend/appflowy_web_app/postcss.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/frontend/appflowy_web_app/public/appflowy.svg b/frontend/appflowy_web_app/public/appflowy.svg deleted file mode 100644 index b1ac8d66fb..0000000000 --- a/frontend/appflowy_web_app/public/appflowy.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/public/launch_splash.jpg b/frontend/appflowy_web_app/public/launch_splash.jpg deleted file mode 100644 index 7e3bb9cee6..0000000000 Binary files a/frontend/appflowy_web_app/public/launch_splash.jpg and /dev/null differ diff --git a/frontend/appflowy_web_app/scripts/create-symlink.cjs b/frontend/appflowy_web_app/scripts/create-symlink.cjs deleted file mode 100644 index 472f511f27..0000000000 --- a/frontend/appflowy_web_app/scripts/create-symlink.cjs +++ /dev/null @@ -1,43 +0,0 @@ -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/i18n.cjs b/frontend/appflowy_web_app/scripts/i18n.cjs deleted file mode 100644 index 407a03694a..0000000000 --- a/frontend/appflowy_web_app/scripts/i18n.cjs +++ /dev/null @@ -1,63 +0,0 @@ -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/src-tauri/.gitignore b/frontend/appflowy_web_app/src-tauri/.gitignore deleted file mode 100644 index 9e4914893d..0000000000 --- a/frontend/appflowy_web_app/src-tauri/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# 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 deleted file mode 100644 index f669732bdc..0000000000 --- a/frontend/appflowy_web_app/src-tauri/Cargo.lock +++ /dev/null @@ -1,8375 +0,0 @@ -# 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 = "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.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" - -[[package]] -name = "app-error" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "bincode", - "getrandom 0.2.12", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "tokio", - "tsify", - "url", - "uuid", - "wasm-bindgen", -] - -[[package]] -name = "appflowy_tauri" -version = "0.0.0" -dependencies = [ - "bytes", - "dotenv", - "flowy-config", - "flowy-core", - "flowy-date", - "flowy-document", - "flowy-error", - "flowy-notification", - "flowy-user", - "lib-dispatch", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-deep-link", - "tauri-utils", - "tracing", - "uuid", -] - -[[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-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.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507401cad91ec6a857ed5513a2073c82a9b9048762b885bb98655b306964681" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[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_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 = "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.65.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" -dependencies = [ - "bitflags 1.3.2", - "cexpr", - "clang-sys", - "lazy_static", - "lazycell", - "peeking_take_while", - "prettyplease", - "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.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c7d2ac73c167c06af4a5f37e6e59d84148d57ccbe4480b76f0273eefea82d7" -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.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" - -[[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.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" -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.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a" -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", - "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 = "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "again", - "anyhow", - "app-error", - "async-trait", - "bincode", - "brotli", - "bytes", - "chrono", - "client-websocket", - "collab", - "collab-entity", - "collab-rt-entity", - "collab-rt-protocol", - "database-entity", - "futures-core", - "futures-util", - "getrandom 0.2.12", - "gotrue", - "gotrue-entity", - "mime", - "parking_lot 0.12.1", - "prost", - "reqwest", - "scraper 0.17.1", - "semver", - "serde", - "serde_json", - "serde_repr", - "shared-entity", - "thiserror", - "tokio", - "tokio-retry", - "tokio-stream", - "tokio-util", - "tracing", - "url", - "uuid", - "wasm-bindgen-futures", - "yrs", -] - -[[package]] -name = "client-websocket" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -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=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "async-trait", - "bincode", - "bytes", - "chrono", - "js-sys", - "parking_lot 0.12.1", - "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=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "async-trait", - "chrono", - "collab", - "collab-entity", - "collab-plugins", - "dashmap", - "getrandom 0.2.12", - "js-sys", - "lazy_static", - "nanoid", - "parking_lot 0.12.1", - "rayon", - "serde", - "serde_json", - "serde_repr", - "strum", - "strum_macros 0.25.3", - "thiserror", - "tokio", - "tokio-stream", - "tracing", - "uuid", -] - -[[package]] -name = "collab-document" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "getrandom 0.2.12", - "nanoid", - "parking_lot 0.12.1", - "serde", - "serde_json", - "thiserror", - "tokio", - "tokio-stream", - "tracing", -] - -[[package]] -name = "collab-entity" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "bytes", - "collab", - "getrandom 0.2.12", - "serde", - "serde_json", - "serde_repr", - "uuid", -] - -[[package]] -name = "collab-folder" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "chrono", - "collab", - "collab-entity", - "getrandom 0.2.12", - "parking_lot 0.12.1", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "tokio", - "tokio-stream", - "tracing", -] - -[[package]] -name = "collab-integrate" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "collab", - "collab-entity", - "collab-plugins", - "futures", - "lib-infra", - "parking_lot 0.12.1", - "serde", - "serde_json", - "tokio", - "tracing", -] - -[[package]] -name = "collab-plugins" -version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -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", - "parking_lot 0.12.1", - "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "bincode", - "bytes", - "chrono", - "client-websocket", - "collab", - "collab-entity", - "collab-rt-protocol", - "database-entity", - "prost", - "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -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=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "getrandom 0.2.12", - "parking_lot 0.12.1", - "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 = "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 = "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 = "crc32fast" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" -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.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" - -[[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 = "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "app-error", - "bincode", - "chrono", - "collab-entity", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "tracing", - "uuid", - "validator", -] - -[[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 = "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_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", - "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 = "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 = "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_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.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[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", - "base64 0.21.7", - "bytes", - "client-api", - "collab", - "collab-entity", - "collab-integrate", - "collab-plugins", - "diesel", - "flowy-config", - "flowy-database-pub", - "flowy-database2", - "flowy-date", - "flowy-document", - "flowy-document-pub", - "flowy-error", - "flowy-folder", - "flowy-folder-pub", - "flowy-search", - "flowy-server", - "flowy-server-pub", - "flowy-sqlite", - "flowy-storage", - "flowy-user", - "flowy-user-pub", - "futures", - "futures-core", - "lib-dispatch", - "lib-infra", - "lib-log", - "parking_lot 0.12.1", - "semver", - "serde", - "serde_json", - "serde_repr", - "sysinfo", - "tokio", - "tokio-stream", - "tracing", - "uuid", - "walkdir", -] - -[[package]] -name = "flowy-database-pub" -version = "0.1.0" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "lib-infra", -] - -[[package]] -name = "flowy-database2" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-stream", - "async-trait", - "bytes", - "chrono", - "chrono-tz", - "collab", - "collab-database", - "collab-entity", - "collab-integrate", - "collab-plugins", - "csv", - "dashmap", - "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", - "nanoid", - "parking_lot 0.12.1", - "protobuf", - "rayon", - "rust_decimal", - "rusty-money", - "serde", - "serde_json", - "serde_repr", - "strum", - "strum_macros 0.25.3", - "tokio", - "tracing", - "url", - "validator", -] - -[[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", - "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", - "flowy-codegen", - "flowy-derive", - "flowy-document-pub", - "flowy-error", - "flowy-notification", - "flowy-storage", - "futures", - "getrandom 0.2.12", - "indexmap 2.2.6", - "lib-dispatch", - "lib-infra", - "nanoid", - "parking_lot 0.12.1", - "protobuf", - "scraper 0.18.1", - "serde", - "serde_json", - "strum_macros 0.21.1", - "tokio", - "tokio-stream", - "tracing", - "uuid", - "validator", -] - -[[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-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", -] - -[[package]] -name = "flowy-folder" -version = "0.1.0" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "collab", - "collab-document", - "collab-entity", - "collab-folder", - "collab-integrate", - "collab-plugins", - "flowy-codegen", - "flowy-derive", - "flowy-error", - "flowy-folder-pub", - "flowy-notification", - "flowy-search-pub", - "lazy_static", - "lib-dispatch", - "lib-infra", - "nanoid", - "parking_lot 0.12.1", - "protobuf", - "serde_json", - "strum_macros 0.21.1", - "tokio", - "tokio-stream", - "tracing", - "unicode-segmentation", - "uuid", - "validator", -] - -[[package]] -name = "flowy-folder-pub" -version = "0.1.0" -dependencies = [ - "anyhow", - "collab", - "collab-entity", - "collab-folder", - "lib-infra", - "uuid", -] - -[[package]] -name = "flowy-notification" -version = "0.1.0" -dependencies = [ - "bytes", - "dashmap", - "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-notification", - "flowy-search-pub", - "flowy-sqlite", - "flowy-user", - "futures", - "lib-dispatch", - "protobuf", - "serde", - "serde_json", - "strsim 0.11.1", - "strum_macros 0.26.2", - "tantivy", - "tempfile", - "tokio", - "tracing", - "validator", -] - -[[package]] -name = "flowy-search-pub" -version = "0.1.0" -dependencies = [ - "collab", - "collab-folder", - "flowy-error", -] - -[[package]] -name = "flowy-server" -version = "0.1.0" -dependencies = [ - "anyhow", - "bytes", - "chrono", - "client-api", - "collab", - "collab-document", - "collab-entity", - "collab-folder", - "collab-plugins", - "flowy-database-pub", - "flowy-document-pub", - "flowy-encrypt", - "flowy-error", - "flowy-folder-pub", - "flowy-server-pub", - "flowy-storage", - "flowy-user-pub", - "futures", - "futures-util", - "hex", - "hyper", - "lazy_static", - "lib-dispatch", - "lib-infra", - "mime_guess", - "parking_lot 0.12.1", - "postgrest", - "rand 0.8.5", - "reqwest", - "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", - "parking_lot 0.12.1", - "r2d2", - "scheduled-thread-pool", - "serde", - "serde_json", - "thiserror", - "tracing", -] - -[[package]] -name = "flowy-storage" -version = "0.1.0" -dependencies = [ - "async-trait", - "bytes", - "flowy-error", - "fxhash", - "lib-infra", - "mime", - "mime_guess", - "reqwest", - "serde", - "serde_json", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "flowy-user" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64 0.21.7", - "bytes", - "chrono", - "collab", - "collab-database", - "collab-document", - "collab-entity", - "collab-folder", - "collab-integrate", - "collab-plugins", - "collab-user", - "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", - "parking_lot 0.12.1", - "protobuf", - "semver", - "serde", - "serde_json", - "serde_repr", - "strum", - "strum_macros 0.25.3", - "tokio", - "tokio-stream", - "tracing", - "unicode-segmentation", - "uuid", - "validator", -] - -[[package]] -name = "flowy-user-pub" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64 0.21.7", - "chrono", - "collab", - "collab-entity", - "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.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eeb4ed9e12f43b7fa0baae3f9cdda28352770132ef2e09a23760c29cae8bd47" -dependencies = [ - "rustix", - "windows-sys 0.48.0", -] - -[[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-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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -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.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" -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 = "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.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" -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 = "if_chain" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" - -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cc2083760572ee02385ab8b7c02c20925d2dd1f97a1a25a8737a238608f1152" -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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "reqwest", - "serde", - "serde_json", - "tracing", -] - -[[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.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -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 = "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", - "parking_lot 0.12.1", - "pin-project", - "protobuf", - "serde", - "serde_json", - "serde_repr", - "thread-id", - "tokio", - "tracing", - "validator", - "wasm-bindgen", - "wasm-bindgen-futures", -] - -[[package]] -name = "lib-infra" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "atomic_refcell", - "bytes", - "chrono", - "futures-core", - "md5", - "pin-project", - "tempfile", - "tokio", - "tracing", - "validator", - "walkdir", - "zip", -] - -[[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.11.0+8.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e" -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 = "lock_api" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[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.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a83fb7698b3643a0e34f9ae6f2e8f0178c0fd42f8b59d493aa271ff3a5bf21" -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 = "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 = "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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" - -[[package]] -name = "memmap2" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49388d20533534cd19360ad3d6a7dadc885944aa802ba3995040c5ec11288c6" -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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" -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", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - -[[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 = "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", -] - -[[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.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e8a72b918ae8198abb3a18c190288123e1d442b6b9a7d709305fd194688b4b7" -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_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 = "peeking_take_while" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" - -[[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.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" -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-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", -] - -[[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", - "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-types" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" -dependencies = [ - "prost", -] - -[[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 = "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_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-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.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe" -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.34.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39449a79f45e8da28c57c341891b69a183044b29518bb8f86dbac9df60bb7df" -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 = "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.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" -dependencies = [ - "serde", -] - -[[package]] -name = "serde" -version = "1.0.197" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.197" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" -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.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" -dependencies = [ - "indexmap 2.2.6", - "itoa 1.0.10", - "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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" -dependencies = [ - "anyhow", - "app-error", - "chrono", - "collab-entity", - "database-entity", - "gotrue-entity", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "thiserror", - "uuid", -] - -[[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 = "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 = "tantivy" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6083cd777fa94271b8ce0fe4533772cb8110c3044bab048d20f70108329a1f2" -dependencies = [ - "aho-corasick", - "arc-swap", - "async-trait", - "base64 0.21.7", - "bitpacking", - "byteorder", - "census", - "crc32fast", - "crossbeam-channel", - "downcast-rs", - "fastdivide", - "fs4", - "htmlescape", - "itertools 0.11.0", - "levenshtein_automata", - "log", - "lru", - "lz4_flex", - "measure_time", - "memmap2", - "murmurhash32", - "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.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecb164321482301f514dd582264fa67f70da2d7eb01872ccd71e35e0d96655a" -dependencies = [ - "bitpacking", -] - -[[package]] -name = "tantivy-columnar" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d85f8019af9a78b3118c11298b36ffd21c2314bd76bbcd9d12e00124cbb7e70" -dependencies = [ - "fastdivide", - "fnv", - "itertools 0.11.0", - "serde", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-sstable", - "tantivy-stacker", -] - -[[package]] -name = "tantivy-common" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4a3a975e604a2aba6b1106a04505e1e7a025e6def477fab6e410b4126471e1" -dependencies = [ - "async-trait", - "byteorder", - "ownedbytes", - "serde", - "time", -] - -[[package]] -name = "tantivy-fst" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3c506b1a8443a3a65352df6382a1fb6a7afe1a02e871cee0d25e2c3d5f3944" -dependencies = [ - "byteorder", - "regex-syntax 0.6.29", - "utf8-ranges", -] - -[[package]] -name = "tantivy-query-grammar" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d39c5a03100ac10c96e0c8b07538e2ab8b17da56434ab348309b31f23fada77" -dependencies = [ - "nom", -] - -[[package]] -name = "tantivy-sstable" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0c1bb43e5e8b8e05eb8009610344dbf285f06066c844032fbb3e546b3c71df" -dependencies = [ - "tantivy-common", - "tantivy-fst", - "zstd 0.12.4", -] - -[[package]] -name = "tantivy-stacker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2c078595413f13f218cf6f97b23dcfd48936838f1d3d13a1016e05acd64ed6c" -dependencies = [ - "murmurhash32", - "tantivy-common", -] - -[[package]] -name = "tantivy-tokenizer-api" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "347b6fb212b26d3505d224f438e3c4b827ab8bd847fe9953ad5ac6b8f9443b66" -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", - "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.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" -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.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" -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.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" -dependencies = [ - "num-conv", - "time-core", -] - -[[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.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" -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.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" -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.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", - "tracing", -] - -[[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.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" -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.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "tracing-bunyan-formatter" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c266b9ac83dedf0e0385ad78514949e6d89491269e7065bee51d2bb8ec7373" -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.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" -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.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" -dependencies = [ - "serde", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" -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 = "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-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 = "utf8-ranges" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" - -[[package]] -name = "uuid" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" -dependencies = [ - "getrandom 0.2.12", - "serde", - "sha1_smol", - "wasm-bindgen", -] - -[[package]] -name = "validator" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" -dependencies = [ - "idna 0.4.0", - "lazy_static", - "regex", - "serde", - "serde_derive", - "serde_json", - "url", - "validator_derive", -] - -[[package]] -name = "validator_derive" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc44ca3088bb3ba384d9aecf40c6a23a676ce23e09bdaca2073d99c207f864af" -dependencies = [ - "if_chain", - "lazy_static", - "proc-macro-error", - "proc-macro2", - "quote", - "regex", - "syn 1.0.109", - "validator_types", -] - -[[package]] -name = "validator_types" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111abfe30072511849c5910134e8baf8dc05de4c0e5903d681cbd5c9c4d611e3" -dependencies = [ - "proc-macro2", - "syn 1.0.109", -] - -[[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 = "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 = "yrs" -version = "0.18.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d58fbc807677598fedfab76f99f6e1aa5c644411255002b5438ea0ab14672398" -dependencies = [ - "arc-swap", - "atomic_refcell", - "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 = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "aes", - "byteorder", - "bzip2", - "constant_time_eq", - "crc32fast", - "crossbeam-utils", - "flate2", - "hmac", - "pbkdf2 0.11.0", - "sha1", - "time", - "zstd 0.11.2+zstd.1.5.2", -] - -[[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.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a27595e173641171fc74a1232b7b1c7a7cb6e18222c11e9dfb9888fa424c53c" -dependencies = [ - "zstd-safe 6.0.6", -] - -[[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 = "6.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee98ffd0b48ee95e6c5168188e44a54550b1564d9d530ee21d5f0eaed1069581" -dependencies = [ - "libc", - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.9+zstd.1.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.toml b/frontend/appflowy_web_app/src-tauri/Cargo.toml deleted file mode 100644 index 7c85ebbed6..0000000000 --- a/frontend/appflowy_web_app/src-tauri/Cargo.toml +++ /dev/null @@ -1,104 +0,0 @@ -[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"] } -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.18.7" -# 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 = "870cd70" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } - -# 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 = "ef8e6f3" } - -[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", -] } - -uuid = "1.5.0" -tauri-plugin-deep-link = "0.1.2" -dotenv = "0.15.0" - -[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"] - diff --git a/frontend/appflowy_web_app/src-tauri/Info.plist b/frontend/appflowy_web_app/src-tauri/Info.plist deleted file mode 100644 index 25b430c049..0000000000 --- a/frontend/appflowy_web_app/src-tauri/Info.plist +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - 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 deleted file mode 100644 index 795b9b7c83..0000000000 --- a/frontend/appflowy_web_app/src-tauri/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 188835e3d0..0000000000 --- a/frontend/appflowy_web_app/src-tauri/env.development +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index b03c328b84..0000000000 --- a/frontend/appflowy_web_app/src-tauri/env.production +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 3a51041313..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/128x128.png and /dev/null 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 deleted file mode 100644 index 9076de3a4b..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/128x128@2x.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/32x32.png b/frontend/appflowy_web_app/src-tauri/icons/32x32.png deleted file mode 100644 index 6ae6683fef..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/32x32.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square107x107Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square107x107Logo.png deleted file mode 100644 index b08dcf7d21..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/Square107x107Logo.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square142x142Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square142x142Logo.png deleted file mode 100644 index f3e437b76e..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/Square142x142Logo.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square150x150Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square150x150Logo.png deleted file mode 100644 index 6a1dc04864..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/Square150x150Logo.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square284x284Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square284x284Logo.png deleted file mode 100644 index 2f2d9d6fe6..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/Square284x284Logo.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square30x30Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square30x30Logo.png deleted file mode 100644 index 46e3802c0b..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/Square30x30Logo.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square310x310Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square310x310Logo.png deleted file mode 100644 index 230b1abe58..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/Square310x310Logo.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square44x44Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square44x44Logo.png deleted file mode 100644 index ad188037a3..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/Square44x44Logo.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square71x71Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square71x71Logo.png deleted file mode 100644 index ceae9ad1bb..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/Square71x71Logo.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square89x89Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square89x89Logo.png deleted file mode 100644 index 123dcea650..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/Square89x89Logo.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/StoreLogo.png b/frontend/appflowy_web_app/src-tauri/icons/StoreLogo.png deleted file mode 100644 index d7906c3c03..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/StoreLogo.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/icon.icns b/frontend/appflowy_web_app/src-tauri/icons/icon.icns deleted file mode 100644 index 74b585f25d..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/icon.icns and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/icon.ico b/frontend/appflowy_web_app/src-tauri/icons/icon.ico deleted file mode 100644 index cd9ad402d1..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/icon.ico and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/icon.png b/frontend/appflowy_web_app/src-tauri/icons/icon.png deleted file mode 100644 index 7cc3853d67..0000000000 Binary files a/frontend/appflowy_web_app/src-tauri/icons/icon.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src-tauri/rust-toolchain.toml b/frontend/appflowy_web_app/src-tauri/rust-toolchain.toml deleted file mode 100644 index 6f14058b2e..0000000000 --- a/frontend/appflowy_web_app/src-tauri/rust-toolchain.toml +++ /dev/null @@ -1,2 +0,0 @@ -[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 deleted file mode 100644 index 5cb0d67ee5..0000000000 --- a/frontend/appflowy_web_app/src-tauri/rustfmt.toml +++ /dev/null @@ -1,12 +0,0 @@ -# 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 deleted file mode 100644 index 923ab0ff8f..0000000000 --- a/frontend/appflowy_web_app/src-tauri/src/init.rs +++ /dev/null @@ -1,60 +0,0 @@ -use flowy_core::config::AppFlowyCoreConfig; -use flowy_core::{AppFlowyCore, DEFAULT_NAME}; -use lib_dispatch::runtime::AFPluginRuntime; -use std::sync::Arc; - -use dotenv::dotenv; - -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_flowy_core() -> AppFlowyCore { - 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.0.0".to_string()); - 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, - "web".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 { AppFlowyCore::new(config, cloned_runtime, None).await }) -} diff --git a/frontend/appflowy_web_app/src-tauri/src/main.rs b/frontend/appflowy_web_app/src-tauri/src/main.rs deleted file mode 100644 index 6a69de07fd..0000000000 --- a/frontend/appflowy_web_app/src-tauri/src/main.rs +++ /dev/null @@ -1,71 +0,0 @@ -#![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_flowy_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 deleted file mode 100644 index b42541edec..0000000000 --- a/frontend/appflowy_web_app/src-tauri/src/notification.rs +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 029e71c18c..0000000000 --- a/frontend/appflowy_web_app/src-tauri/src/request.rs +++ /dev/null @@ -1,45 +0,0 @@ -use flowy_core::AppFlowyCore; -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.inner().dispatcher(); - let response = AFPluginDispatcher::async_send(dispatcher.as_ref(), request).await; - response.into() -} diff --git a/frontend/appflowy_web_app/src-tauri/tauri.conf.json b/frontend/appflowy_web_app/src-tauri/tauri.conf.json deleted file mode 100644 index ea11f47def..0000000000 --- a/frontend/appflowy_web_app/src-tauri/tauri.conf.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "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 deleted file mode 100644 index 6adbb4a512..0000000000 --- a/frontend/appflowy_web_app/src/@types/i18next.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 6bd90364e0..0000000000 --- a/frontend/appflowy_web_app/src/@types/resources.ts +++ /dev/null @@ -1,7 +0,0 @@ -import translation from './translations/en.json'; - -const resources = { - translation, -} as const; - -export default resources; diff --git a/frontend/appflowy_web_app/src/application/collab.type.ts b/frontend/appflowy_web_app/src/application/collab.type.ts deleted file mode 100644 index 0df2729749..0000000000 --- a/frontend/appflowy_web_app/src/application/collab.type.ts +++ /dev/null @@ -1,294 +0,0 @@ -import Y from 'yjs'; - -export type BlockId = string; - -export type ExternalId = string; - -export type ChildrenId = string; - -export type ViewId = string; - -export enum BlockType { - 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', - OutlineBlock = 'outline', - TableBlock = 'table', - TableCell = 'table/cell', -} - -export enum InlineBlockType { - Formula = 'formula', - Mention = 'mention', -} - -export enum AlignType { - Left = 'left', - Center = 'center', - Right = 'right', -} - -export interface BlockData { - bg_color?: string; - font_color?: string; - align?: AlignType; -} - -export interface HeadingBlockData extends BlockData { - level: number; -} - -export interface NumberedListBlockData extends BlockData { - number: number; -} - -export interface TodoListBlockData extends BlockData { - checked: boolean; -} - -export interface ToggleListBlockData extends BlockData { - collapsed: boolean; -} - -export interface CodeBlockData extends BlockData { - language: string; -} - -export interface CalloutBlockData extends BlockData { - icon: string; -} - -export interface MathEquationBlockData extends BlockData { - formula?: string; -} - -export enum ImageType { - Local = 0, - Internal = 1, - External = 2, -} - -export interface ImageBlockData extends BlockData { - url?: string; - width?: number; - align?: AlignType; - image_type?: ImageType; - height?: number; -} - -export interface OutlineBlockData extends BlockData { - depth?: number; -} - -export interface TableBlockData extends BlockData { - colDefaultWidth: number; - colMinimumWidth: number; - colsHeight: number; - colsLen: number; - rowDefaultHeight: number; - rowsLen: number; -} - -export interface TableCellBlockData extends BlockData { - colPosition: number; - height: number; - rowPosition: number; - width: number; -} - -export enum MentionType { - PageRef = 'page', - Date = 'date', -} - -export interface Mention { - // inline page ref id - page_id?: string; - // reminder date ref id - date?: string; - reminder_id?: string; - reminder_option?: string; - - type: MentionType; -} - -export interface FolderMeta { - current_view: ViewId; - current_workspace: string; -} - -export enum CoverType { - Color = 'CoverType.color', - Image = 'CoverType.file', - Asset = 'CoverType.asset', -} - -export type PageCover = { - image_type?: ImageType; - cover_selection_type?: CoverType; - cover_selection?: string; -} | null; - -export enum ViewLayout { - Document = 0, - Grid = 1, - Board = 2, - Calendar = 3, -} - -export enum YjsEditorKey { - data_section = 'data', - document = 'document', - database = 'database', - workspace_database = 'databases', - folder = 'folder', - // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values - database_row = 'data', - user_awareness = 'user_awareness', - - // document - blocks = 'blocks', - page_id = 'page_id', - meta = 'meta', - children_map = 'children_map', - text_map = 'text_map', - text = 'text', - delta = 'delta', - block_id = 'id', - block_type = 'ty', - // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values - block_data = 'data', - block_parent = 'parent', - block_children = 'children', - block_external_id = 'external_id', - block_external_type = 'external_type', -} - -export enum YjsFolderKey { - views = 'views', - relation = 'relation', - section = 'section', - private = 'private', - favorite = 'favorite', - recent = 'recent', - trash = 'trash', - meta = 'meta', - current_view = 'current_view', - current_workspace = 'current_workspace', - id = 'id', - name = 'name', - icon = 'icon', - type = 'ty', - value = 'value', - layout = 'layout', -} - -export interface YDoc extends Y.Doc { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getMap(key: YjsEditorKey.data_section): YSharedRoot | any; -} - -export interface YSharedRoot extends Y.Map { - get(key: YjsEditorKey.document): YDocument; - - // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsEditorKey.folder): YFolder; -} - -export interface YFolder extends Y.Map { - get(key: YjsFolderKey.views): YViews; - - // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.meta): YFolderMeta; - - // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.relation): YFolderRelation; - - // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.section): YFolderSection; -} - -export interface YViews extends Y.Map { - get(key: ViewId): YView; -} - -export interface YView extends Y.Map { - get(key: YjsFolderKey.id): ViewId; - - get(key: YjsFolderKey.name): string; - - // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.icon): string; - - // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsFolderKey.layout): string; -} - -export interface YFolderRelation extends Y.Map { - get(key: ViewId): Y.Array; -} - -export interface YFolderMeta extends Y.Map { - get(key: YjsFolderKey.current_view | YjsFolderKey.current_workspace): string; -} - -export interface YFolderSection extends Y.Map { - get(key: YjsFolderKey.favorite | YjsFolderKey.private | YjsFolderKey.recent | YjsFolderKey.trash): YFolderSectionItem; -} - -export interface YFolderSectionItem extends Y.Map { - get(key: string): Y.Array; -} - -export interface YDocument extends Y.Map { - get(key: YjsEditorKey.blocks | YjsEditorKey.page_id | YjsEditorKey.meta): YBlocks | YMeta | string; -} - -export interface YBlocks extends Y.Map { - get(key: BlockId): Y.Map; -} - -export interface YMeta extends Y.Map { - get(key: YjsEditorKey.children_map | YjsEditorKey.text_map): YChildrenMap | YTextMap; -} - -export interface YChildrenMap extends Y.Map { - get(key: ChildrenId): Y.Array; -} - -export interface YTextMap extends Y.Map { - get(key: ExternalId): Y.Text; -} - -export enum CollabType { - Document = 0, - Database = 1, - WorkspaceDatabase = 2, - Folder = 3, - DatabaseRow = 4, - UserAwareness = 5, - Empty = 6, -} - -export enum CollabOrigin { - Local = 'local', - Remote = 'remote', -} - -export const layoutMap = { - [ViewLayout.Document]: 'document', - [ViewLayout.Grid]: 'grid', - [ViewLayout.Board]: 'board', - [ViewLayout.Calendar]: 'calendar', -}; diff --git a/frontend/appflowy_web_app/src/application/constants.ts b/frontend/appflowy_web_app/src/application/constants.ts deleted file mode 100644 index 36e31606ff..0000000000 --- a/frontend/appflowy_web_app/src/application/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const databasePrefix = 'af_database'; - diff --git a/frontend/appflowy_web_app/src/application/document.type.ts b/frontend/appflowy_web_app/src/application/document.type.ts deleted file mode 100644 index da559c5bde..0000000000 --- a/frontend/appflowy_web_app/src/application/document.type.ts +++ /dev/null @@ -1,176 +0,0 @@ -import Y from 'yjs'; - -export type BlockId = string; - -export type ExternalId = string; - -export type ChildrenId = string; - -export enum BlockType { - 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', - OutlineBlock = 'outline', - TableBlock = 'table', - TableCell = 'table/cell', -} - -export enum InlineBlockType { - Formula = 'formula', - Mention = 'mention', -} - -export enum AlignType { - Left = 'left', - Center = 'center', - Right = 'right', -} - -export interface BlockData { - bg_color?: string; - font_color?: string; - align?: AlignType; -} - -export interface HeadingBlockData extends BlockData { - level: number; -} - -export interface NumberedListBlockData extends BlockData { - number: number; -} - -export interface TodoListBlockData extends BlockData { - checked: boolean; -} - -export interface ToggleListBlockData extends BlockData { - collapsed: boolean; -} - -export interface CodeBlockData extends BlockData { - language: string; -} - -export interface CalloutBlockData extends BlockData { - icon: string; -} - -export interface MathEquationBlockData extends BlockData { - formula?: string; -} - -export enum ImageType { - Local = 0, - Internal = 1, - External = 2, -} - -export interface ImageBlockData extends BlockData { - url?: string; - width?: number; - align?: AlignType; - image_type?: ImageType; - height?: number; -} - -export interface OutlineBlockData extends BlockData { - depth?: number; -} - -export interface TableBlockData extends BlockData { - colDefaultWidth: number; - colMinimumWidth: number; - colsHeight: number; - colsLen: number; - rowDefaultHeight: number; - rowsLen: number; -} - -export interface TableCellBlockData extends BlockData { - colPosition: number; - height: number; - rowPosition: number; - width: number; -} - -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; -} - -export enum YjsEditorKey { - data_section = 'data', - document = 'document', - database = 'database', - workspace_database = 'databases', - folder = 'folder', - // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values - database_row = 'data', - user_awareness = 'user_awareness', - blocks = 'blocks', - page_id = 'page_id', - meta = 'meta', - children_map = 'children_map', - text_map = 'text_map', - text = 'text', - delta = 'delta', - - block_id = 'id', - block_type = 'ty', - // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values - block_data = 'data', - block_parent = 'parent', - block_children = 'children', - block_external_id = 'external_id', - block_external_type = 'external_type', -} - -export interface YDoc extends Y.Doc { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - get(key: YjsEditorKey.data_section | string): YSharedRoot | any; -} - -export interface YSharedRoot extends Y.Map { - get(key: YjsEditorKey.document): YDocument; -} - -export interface YDocument extends Y.Map { - get(key: YjsEditorKey.blocks | YjsEditorKey.page_id | YjsEditorKey.meta): YBlocks | YMeta | string; -} - -export interface YBlocks extends Y.Map { - get(key: BlockId): Y.Map; -} - -export interface YMeta extends Y.Map { - get(key: YjsEditorKey.children_map | YjsEditorKey.text_map): YChildrenMap | YTextMap; -} - -export interface YChildrenMap extends Y.Map { - get(key: ChildrenId): Y.Array; -} - -export interface YTextMap extends Y.Map { - get(key: ExternalId): Y.Text; -} diff --git a/frontend/appflowy_web_app/src/application/folder-yjs/context.ts b/frontend/appflowy_web_app/src/application/folder-yjs/context.ts deleted file mode 100644 index 57c4d171df..0000000000 --- a/frontend/appflowy_web_app/src/application/folder-yjs/context.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { YFolder } from '@/application/collab.type'; -import { createContext, useContext } from 'react'; - -export const FolderContext = createContext(null); - -export const useFolderContext = () => { - return useContext(FolderContext); -}; diff --git a/frontend/appflowy_web_app/src/application/folder-yjs/index.ts b/frontend/appflowy_web_app/src/application/folder-yjs/index.ts deleted file mode 100644 index f94cc509da..0000000000 --- a/frontend/appflowy_web_app/src/application/folder-yjs/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './selector'; -export * from './context'; diff --git a/frontend/appflowy_web_app/src/application/folder-yjs/selector.ts b/frontend/appflowy_web_app/src/application/folder-yjs/selector.ts deleted file mode 100644 index 295315874b..0000000000 --- a/frontend/appflowy_web_app/src/application/folder-yjs/selector.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { YjsFolderKey, YView } from '@/application/collab.type'; -import { useFolderContext } from '@/application/folder-yjs/context'; -import { useEffect, useState } from 'react'; - -export function useViewsIdSelector() { - const folder = useFolderContext(); - const [viewsId, setViewsId] = useState([]); - - useEffect(() => { - if (!folder) return; - - const views = folder.get(YjsFolderKey.views); - const trash = folder.get(YjsFolderKey.section)?.get(YjsFolderKey.trash); - const meta = folder.get(YjsFolderKey.meta); - - console.log('folder', folder.toJSON()); - const collectIds = () => { - return Array.from(views.keys()).filter( - (id) => !trash?.has(id) && id !== meta?.get(YjsFolderKey.current_workspace) - ); - }; - - setViewsId(collectIds()); - const observerEvent = () => setViewsId(collectIds()); - - folder.observe(observerEvent); - - return () => { - folder.unobserve(observerEvent); - }; - }, [folder]); - - return { - viewsId, - }; -} - -export function useViewSelector(viewId: string) { - const folder = useFolderContext(); - const [clock, setClock] = useState(0); - const [view, setView] = useState(null); - - useEffect(() => { - if (!folder) return; - - const view = folder.get(YjsFolderKey.views)?.get(viewId); - - setView(view || null); - const observerEvent = () => setClock((prev) => prev + 1); - - view.observe(observerEvent); - - return () => { - view.unobserve(observerEvent); - }; - }, [folder, viewId]); - - return { - clock, - view, - }; -} diff --git a/frontend/appflowy_web_app/src/application/services/index.ts b/frontend/appflowy_web_app/src/application/services/index.ts deleted file mode 100644 index 21d401d0da..0000000000 --- a/frontend/appflowy_web_app/src/application/services/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AFService, AFServiceConfig } from '@/application/services/services.type'; -import { AFClientService } from '$client-services'; - -let service: AFService; - -export async 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/auth.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/auth.service.ts deleted file mode 100644 index 7119497775..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/auth.service.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { AuthService } from '@/application/services/services.type'; -import { ProviderType, SignUpWithEmailPasswordParams } from '@/application/user.type'; -import { APIService } from 'src/application/services/js-services/wasm'; -import { signInSuccess } from '@/application/services/js-services/storage/auth'; -import { invalidToken } from '@/application/services/js-services/storage'; -import { afterSignInDecorator } from '@/application/services/js-services/decorator'; - -export class JSAuthService implements AuthService { - constructor() { - // Do nothing - } - - getOAuthURL = async (_provider: ProviderType): Promise => { - return Promise.reject('Not implemented'); - }; - - @afterSignInDecorator(signInSuccess) - async signInWithOAuth(_: { uri: string }): Promise { - return Promise.reject('Not implemented'); - } - - signupWithEmailPassword = async (_params: SignUpWithEmailPasswordParams): Promise => { - return Promise.reject('Not implemented'); - }; - - @afterSignInDecorator(signInSuccess) - async signinWithEmailPassword(email: string, password: string): Promise { - try { - return APIService.signIn(email, password); - } catch (e) { - return Promise.reject(e); - } - } - - signOut = async (): Promise => { - invalidToken(); - return APIService.logout(); - }; -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/db/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/db/index.ts deleted file mode 100644 index ebe8870c15..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/db/index.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { YDoc } from '@/application/collab.type'; -import { getAuthInfo } from '@/application/services/js-services/storage'; -import * as Y from 'yjs'; -import { IndexeddbPersistence } from 'y-indexeddb'; -import { databasePrefix } from '@/application/constants'; -import BaseDexie from 'dexie'; -import { usersSchema, UsersTable } from './tables/users'; - -const version = 1; - -type DexieTables = UsersTable; -export type Dexie = BaseDexie & T; - -let db: Dexie | undefined; - -export function getDB() { - const authInfo = getAuthInfo(); - - if (!db && authInfo?.uuid) { - return openDB(authInfo?.uuid); - } - - return db; -} - -export function openDB(uuid: string) { - const dbName = `${databasePrefix}_${uuid}`; - - if (db && db.name === dbName) { - return db; - } - - db = new BaseDexie(dbName) as Dexie; - const schema = Object.assign({}, usersSchema); - - db.version(version).stores(schema); - return db; -} - -/** - * 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(); - const provider = new IndexeddbPersistence(name, doc); - - let resolve: (value: unknown) => void; - const promise = new Promise((resolveFn) => { - resolve = resolveFn; - }); - - provider.on('synced', () => { - resolve(true); - }); - - await promise; - - return doc as YDoc; -} - -export async function deleteCollabDB(docName: string) { - const name = `${databasePrefix}_${docName}`; - const doc = new Y.Doc(); - const provider = new IndexeddbPersistence(name, doc); - - await provider.destroy(); -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/db/tables/users.ts b/frontend/appflowy_web_app/src/application/services/js-services/db/tables/users.ts deleted file mode 100644 index 1da8f20b0c..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/db/tables/users.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Table } from 'dexie'; -import { UserProfile } from '@/application/user.type'; - -export type UsersTable = { - users: Table; -}; - -export const usersSchema = { - users: 'uuid, uid, email, name, workspaceId, iconUrl', -}; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/services/js-services/decorator.ts b/frontend/appflowy_web_app/src/application/services/js-services/decorator.ts deleted file mode 100644 index a6f9cf9ee4..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/decorator.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @description: - * * This is a decorator that can be used to read data from storage and fetch data from the server. - * * If the data is already in storage, it will return the data from storage and fetch the data from the server in the background. - * - * @param getStorage A function that returns the data from storage. eg. `() => Promise` - * - * @param setStorage A function that saves the data to storage. eg. `(data: T) => Promise` - * - * @param fetchFunction A function that fetches the data from the server. eg. `(params: P) => Promise` - * - * @returns: A function that returns the data from storage and fetches the data from the server in the background. - */ -export function asyncDataDecorator( - getStorage: () => Promise, - setStorage: (data: T) => Promise, - fetchFunction: (params: P) => Promise -) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { - async function fetchData(params: P) { - const data = await fetchFunction(params); - - if (!data) return; - await setStorage(data); - return data; - } - - const originalMethod = descriptor.value; - - descriptor.value = async function (params: P) { - const data = await getStorage(); - - await originalMethod.apply(this, [params]); - if (data) { - void fetchData(params); - return data; - } else { - return fetchData(params); - } - }; - - return descriptor; - }; -} - -export function afterSignInDecorator(successCallback: () => Promise) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { - const originalMethod = descriptor.value; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - descriptor.value = async function (...args: any[]) { - await originalMethod.apply(this, args); - await successCallback(); - }; - - return descriptor; - }; -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts deleted file mode 100644 index 1af92df8a0..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type'; -import { getDocumentStorage } from '@/application/services/js-services/storage/document'; -import { DocumentService } from '@/application/services/services.type'; -import { APIService } from 'src/application/services/js-services/wasm'; -import { applyDocument } from 'src/application/ydoc/apply'; - -export class JSDocumentService implements DocumentService { - constructor() { - // - } - - fetchDocument(workspaceId: string, docId: string) { - return APIService.getCollab(workspaceId, docId, CollabType.Document); - } - - async openDocument(workspaceId: string, docId: string): Promise { - const { doc, localExist } = await getDocumentStorage(docId); - const asyncApply = async () => { - const res = await this.fetchDocument(workspaceId, docId); - - applyDocument(doc, res.state); - }; - - // If the document exists locally, apply the state asynchronously, - // otherwise, apply the state synchronously - if (localExist) { - void asyncApply(); - } else { - await asyncApply(); - } - - const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => { - if (origin === CollabOrigin.Remote) { - return; - } - - // Send the update to the server - console.log('update', update); - }; - - doc.on('update', handleUpdate); - - return doc; - } -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts deleted file mode 100644 index 796cd078d6..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type'; -import { getFolderStorage } from '@/application/services/js-services/storage/folder'; -import { FolderService } from '@/application/services/services.type'; -import { APIService } from 'src/application/services/js-services/wasm'; -import { applyDocument } from 'src/application/ydoc/apply'; - -export class JSFolderService implements FolderService { - constructor() { - // - } - - fetchFolder(workspaceId: string) { - return APIService.getCollab(workspaceId, workspaceId, CollabType.Folder); - } - - async openWorkspace(workspaceId: string): Promise { - const { doc, localExist } = await getFolderStorage(workspaceId); - const asyncApply = async () => { - const res = await this.fetchFolder(workspaceId); - - applyDocument(doc, res.state); - }; - - // If the document exists locally, apply the state asynchronously, - // otherwise, apply the state synchronously - if (localExist) { - void asyncApply(); - } else { - await asyncApply(); - } - - const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => { - if (origin === CollabOrigin.Remote) { - return; - } - - // Send the update to the server - console.log('update', update); - }; - - doc.on('update', handleUpdate); - - return doc; - } -} 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 deleted file mode 100644 index 3410c8d27e..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - AFService, - AFServiceConfig, - AuthService, - DocumentService, - FolderService, - UserService, -} from '@/application/services/services.type'; -import { JSUserService } from '@/application/services/js-services/user.service'; -import { JSAuthService } from '@/application/services/js-services/auth.service'; -import { JSFolderService } from '@/application/services/js-services/folder.service'; -import { JSDocumentService } from '@/application/services/js-services/document.service'; -import { nanoid } from 'nanoid'; -import { initAPIService } from '@/application/services/js-services/wasm/client_api'; - -export class AFClientService implements AFService { - authService: AuthService; - - userService: UserService; - - documentService: DocumentService; - - folderService: FolderService; - - private deviceId: string = nanoid(8); - - private clientId: string = 'web'; - - getDeviceID = (): string => { - return this.deviceId; - }; - - getClientID = (): string => { - return this.clientId; - }; - - constructor(config: AFServiceConfig) { - initAPIService({ - ...config.cloudConfig, - deviceId: this.deviceId, - clientId: this.clientId, - }); - - this.authService = new JSAuthService(); - this.userService = new JSUserService(); - this.documentService = new JSDocumentService(); - this.folderService = new JSFolderService(); - } -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/auth.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/auth.ts deleted file mode 100644 index bb19f590bc..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/auth.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { getAuthInfo } from '@/application/services/js-services/storage/token'; -import { openDB } from '@/application/services/js-services/db'; - -export async function signInSuccess() { - const authInfo = getAuthInfo(); - - if (authInfo) { - // Open the database - openDB(authInfo.uuid); - } -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/document.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/document.ts deleted file mode 100644 index 0c1278d216..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/document.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { YjsEditorKey } from '@/application/collab.type'; -import { openCollabDB } from '@/application/services/js-services/db'; -import { getAuthInfo } from '@/application/services/js-services/storage/token'; - -export async function getDocumentStorage(docId: string) { - const docName = getDocName(docId); - const doc = await openCollabDB(docName); - const localExist = doc.share.has(YjsEditorKey.data_section); - - return { - doc, - localExist, - }; -} - -export function getDocName(docId: string) { - const { uuid } = getAuthInfo() || {}; - - if (!uuid) throw new Error('No user found'); - return `${uuid}_document_${docId}`; -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/folder.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/folder.ts deleted file mode 100644 index 8d70df8d0a..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/folder.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { YjsEditorKey } from '@/application/collab.type'; -import { openCollabDB } from '@/application/services/js-services/db'; -import { getAuthInfo } from '@/application/services/js-services/storage/token'; - -export async function getFolderStorage(workspaceId: string) { - const docName = getDocName(workspaceId); - const doc = await openCollabDB(docName); - const localExist = doc.share.has(YjsEditorKey.data_section); - - return { - doc, - localExist, - }; -} - -export function getDocName(workspaceId: string) { - const { uuid } = getAuthInfo() || {}; - - if (!uuid) throw new Error('No user found'); - return `${uuid}_folder_${workspaceId}`; -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/index.ts deleted file mode 100644 index d983c71b07..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './token'; -export * from './user'; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/token.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/token.ts deleted file mode 100644 index e22f980423..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/token.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { notify } from '@/components/_shared/notify'; - -const tokenKey = 'token'; - -export function readTokenStr() { - return sessionStorage.getItem(tokenKey); -} - -export function getAuthInfo() { - const token = readTokenStr() || ''; - - try { - const info = JSON.parse(token); - - return { - uuid: info.user.id, - access_token: info.access_token, - email: info.user.email, - }; - } catch (e) { - return; - } -} - -export function writeToken(token: string) { - if (!token) { - invalidToken(); - return; - } - - sessionStorage.setItem(tokenKey, token); -} - -export function invalidToken() { - sessionStorage.removeItem(tokenKey); - notify.error('Invalid token, please login again'); -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/user.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/user.ts deleted file mode 100644 index 0194bb8e0f..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/user.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { UserProfile } from '@/application/user.type'; -import { getDB } from '@/application/services/js-services/db'; -import { getAuthInfo } from '@/application/services/js-services/storage/token'; - -const primaryKeyName = 'uid'; - -export async function getSignInUser(): Promise { - const db = getDB(); - const authInfo = getAuthInfo(); - - return db?.users.get(authInfo?.uuid); -} - -export async function setSignInUser(profile: UserProfile) { - const db = getDB(); - - return db?.users.put(profile, primaryKeyName); -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts deleted file mode 100644 index 88e8ba996a..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { UserService } from '@/application/services/services.type'; -import { UserProfile } from '@/application/user.type'; -import { APIService } from 'src/application/services/js-services/wasm'; -import { getAuthInfo, getSignInUser, invalidToken, setSignInUser } from '@/application/services/js-services/storage'; -import { asyncDataDecorator } from '@/application/services/js-services/decorator'; - -async function getUser() { - try { - const user = await APIService.getUser(); - - return user; - } catch (e) { - console.error(e); - invalidToken(); - } -} - -export class JSUserService implements UserService { - @asyncDataDecorator(getSignInUser, setSignInUser, getUser) - async getUserProfile(): Promise { - if (!getAuthInfo()) { - return Promise.reject('Not authenticated'); - } - - return null!; - } - - async checkUser(): Promise { - return (await getSignInUser()) !== undefined; - } -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts deleted file mode 100644 index 48a76d1837..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { CollabType } from '@/application/collab.type'; -import { ClientAPI } from '@appflowyinc/client-api-wasm'; -import { UserProfile } from '@/application/user.type'; -import { AFCloudConfig } from '@/application/services/services.type'; -import { invalidToken, readTokenStr, writeToken } from '@/application/services/js-services/storage'; - -let client: ClientAPI; - -export function initAPIService( - config: AFCloudConfig & { - deviceId: string; - clientId: string; - } -) { - window.refresh_token = writeToken; - window.invalid_token = invalidToken; - client = ClientAPI.new({ - base_url: config.baseURL, - ws_addr: config.wsURL, - gotrue_url: config.gotrueURL, - device_id: config.deviceId, - client_id: config.clientId, - configuration: { - compression_quality: 8, - compression_buffer_size: 10240, - }, - }); - - const token = readTokenStr(); - - if (token) { - client.restore_token(token); - } - - client.subscribe(); -} - -export function signIn(email: string, password: string) { - return client.login(email, password); -} - -export function logout() { - return client.logout(); -} - -export async function getUser(): Promise { - try { - const user = await client.get_user(); - - if (!user) { - throw new Error('No user found'); - } - - return { - uid: parseInt(user.uid), - uuid: user.uuid || undefined, - email: user.email || undefined, - name: user.name || undefined, - workspaceId: user.latest_workspace_id, - iconUrl: user.icon_url || undefined, - }; - } catch (e) { - return Promise.reject(e); - } -} - -export async function getCollab(workspaceId: string, object_id: string, collabType: CollabType) { - const res = await client.get_collab({ - workspace_id: workspaceId, - object_id: object_id, - collab_type: Number(collabType) as 0 | 1 | 2 | 3 | 4 | 5, - }); - - const state = new Uint8Array(res.doc_state); - - return { - state, - }; -} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/wasm/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/wasm/index.ts deleted file mode 100644 index b4f0b4f4cc..0000000000 --- a/frontend/appflowy_web_app/src/application/services/js-services/wasm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * as APIService from './client_api'; \ 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 deleted file mode 100644 index d7d3ad069c..0000000000 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { YDoc } from '@/application/collab.type'; -import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type'; - -export interface AFService { - getDeviceID: () => string; - getClientID: () => string; - authService: AuthService; - userService: UserService; - documentService: DocumentService; - folderService: FolderService; -} - -export interface AFServiceConfig { - cloudConfig: AFCloudConfig; -} - -export interface AFCloudConfig { - baseURL: string; - gotrueURL: string; - wsURL: string; -} - -export interface AuthService { - getOAuthURL: (provider: ProviderType) => Promise; - signInWithOAuth: (params: { uri: string }) => Promise; - signupWithEmailPassword: (params: SignUpWithEmailPasswordParams) => Promise; - signinWithEmailPassword: (email: string, password: string) => Promise; - signOut: () => Promise; -} - -export interface DocumentService { - openDocument: (workspaceId: string, docId: string) => Promise; -} - -export interface UserService { - getUserProfile: () => Promise; - checkUser: () => Promise; -} - -export interface FolderService { - openWorkspace: (workspaceId: string) => Promise; -} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/auth.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/auth.service.ts deleted file mode 100644 index f039782058..0000000000 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/auth.service.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { AFCloudConfig, AuthService } from '@/application/services/services.type'; -import { - AuthenticatorPB, - OauthProviderPB, - OauthSignInPB, - SignInPayloadPB, - SignUpPayloadPB, - UserEventGetOauthURLWithProvider, - UserEventOauthSignIn, - UserEventSignInWithEmailPassword, - UserEventSignOut, - UserEventSignUp, - UserProfilePB, -} from './backend/events/flowy-user'; -import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type'; - -export class TauriAuthService implements AuthService { - - constructor (private cloudConfig: AFCloudConfig, private clientConfig: { - deviceId: string; - clientId: string; - - }) {} - - getDeviceID = (): string => { - return this.clientConfig.deviceId; - }; - getOAuthURL = async (provider: ProviderType): Promise => { - const providerDataRes = await UserEventGetOauthURLWithProvider( - OauthProviderPB.fromObject({ - provider: provider as number, - }), - ); - - if (!providerDataRes.ok) { - throw new Error(providerDataRes.val.msg); - } - - const providerData = providerDataRes.val; - - return providerData.oauth_url; - }; - - signInWithOAuth = async ({ uri }: { uri: string }): Promise => { - const payload = OauthSignInPB.fromObject({ - authenticator: AuthenticatorPB.AppFlowyCloud, - map: { - sign_in_url: uri, - device_id: this.getDeviceID(), - }, - }); - - const res = await UserEventOauthSignIn(payload); - - if (!res.ok) { - throw new Error(res.val.msg); - } - - return; - }; - signinWithEmailPassword = async (email: string, password: string): Promise => { - const payload = SignInPayloadPB.fromObject({ - email, - password, - }); - - const res = await UserEventSignInWithEmailPassword(payload); - - if (!res.ok) { - return Promise.reject(res.val.msg); - } - - return; - }; - - signupWithEmailPassword = async (params: SignUpWithEmailPasswordParams): Promise => { - const payload = SignUpPayloadPB.fromObject({ - name: params.name, - email: params.email, - password: params.password, - device_id: this.getDeviceID(), - }); - - const res = await UserEventSignUp(payload); - - if (!res.ok) { - return Promise.reject(res.val.msg); - } - - return; - }; - - signOut = async () => { - const res = await UserEventSignOut(); - - if (!res.ok) { - return Promise.reject(res.val.msg); - } - - return; - }; -} - -export function parseUserProfileFrom (userPB: UserProfilePB): UserProfile { - const user = userPB.toObject(); - - return { - uid: user.id as number, - email: user.email, - name: user.name, - iconUrl: user.icon_url, - workspaceId: user.workspace_id, - }; -} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/backend/index.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/backend/index.ts deleted file mode 100644 index 38a126a402..0000000000 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/backend/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -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"; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/document.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/document.service.ts deleted file mode 100644 index 8bcede6523..0000000000 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/document.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DocumentService } from '@/application/services/services.type'; -import Y from 'yjs'; - -export class TauriDocumentService implements DocumentService { - async openDocument(_id: string): Promise { - return Promise.reject('Not implemented'); - } -} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/folder.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/folder.service.ts deleted file mode 100644 index 868e6f1391..0000000000 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/folder.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { YDoc } from '@/application/collab.type'; -import { FolderService } from '@/application/services/services.type'; - -export class TauriFolderService implements FolderService { - constructor() { - // - } - - async openWorkspace(_workspaceId: string): Promise { - return Promise.reject('Not implemented'); - } -} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts deleted file mode 100644 index 0f162ba36f..0000000000 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - AFService, - AFServiceConfig, - AuthService, - DocumentService, - FolderService, - UserService, -} from '@/application/services/services.type'; -import { TauriAuthService } from '@/application/services/tauri-services/auth.service'; -import { TauriFolderService } from '@/application/services/tauri-services/folder.service'; -import { TauriUserService } from '@/application/services/tauri-services/user.service'; -import { TauriDocumentService } from '@/application/services/tauri-services/document.service'; -import { nanoid } from 'nanoid'; - -export class AFClientService implements AFService { - authService: AuthService; - - userService: UserService; - - documentService: DocumentService; - - folderService: FolderService; - - private deviceId: string = nanoid(8); - - private clientId: string = 'web'; - - getDeviceID = (): string => { - return this.deviceId; - }; - - getClientID = (): string => { - return this.clientId; - }; - - constructor(config: AFServiceConfig) { - this.authService = new TauriAuthService(config.cloudConfig, { - deviceId: this.deviceId, - clientId: this.clientId, - }); - this.userService = new TauriUserService(); - this.documentService = new TauriDocumentService(); - this.folderService = new TauriFolderService(); - } -} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/user.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/user.service.ts deleted file mode 100644 index 383e648052..0000000000 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/user.service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { UserService } from '@/application/services/services.type'; -import { UserProfile } from '@/application/user.type'; -import { UserEventGetUserProfile } from './backend/events/flowy-user'; -import { parseUserProfileFrom } from '@/application/services/tauri-services/auth.service'; - -export class TauriUserService implements UserService { - async getUserProfile(): Promise { - const res = await UserEventGetUserProfile(); - - if (res.ok) { - return parseUserProfileFrom(res.val); - } - - return null; - } - - async checkUser(): Promise { - return Promise.resolve(false); - } -} diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/index.ts b/frontend/appflowy_web_app/src/application/slate-yjs/index.ts deleted file mode 100644 index 715957a727..0000000000 --- a/frontend/appflowy_web_app/src/application/slate-yjs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './plugins/withYjs'; diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts b/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts deleted file mode 100644 index 1484813ab1..0000000000 --- a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { CollabOrigin, YjsEditorKey, YSharedRoot } from '@/application/collab.type'; -import { applySlateOp } from '@/application/slate-yjs/utils/applySlateOpts'; -import { translateYjsEvent } from 'src/application/slate-yjs/utils/translateYjsEvent'; -import { Editor, Operation, Descendant } from 'slate'; -import Y, { YEvent, Transaction } from 'yjs'; -import { yDocToSlateContent } from '@/application/slate-yjs/utils/convert'; - -type LocalChange = { - op: Operation; - slateContent: Descendant[]; -}; - -export interface YjsEditor extends Editor { - connect: () => void; - disconnect: () => void; - sharedRoot: YSharedRoot; - applyRemoteEvents: (events: Array>, transaction: Transaction) => void; - flushLocalChanges: () => void; - storeLocalChange: (op: Operation) => void; -} - -const connectSet = new WeakSet(); - -const localChanges = new WeakMap(); - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const YjsEditor = { - connected(editor: YjsEditor): boolean { - return connectSet.has(editor); - }, - - connect(editor: YjsEditor): void { - editor.connect(); - }, - - disconnect(editor: YjsEditor): void { - editor.disconnect(); - }, - - applyRemoteEvents(editor: YjsEditor, events: Array>, transaction: Transaction): void { - editor.applyRemoteEvents(events, transaction); - }, - - localChanges(editor: YjsEditor): LocalChange[] { - return localChanges.get(editor) ?? []; - }, - - storeLocalChange(editor: YjsEditor, op: Operation): void { - editor.storeLocalChange(op); - }, - - flushLocalChanges(editor: YjsEditor): void { - editor.flushLocalChanges(); - }, -}; - -export function withYjs(editor: T, doc: Y.Doc): T & YjsEditor { - const e = editor as T & YjsEditor; - const { apply, onChange } = e; - - e.sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; - e.applyRemoteEvents = (events: Array>, _: Transaction) => { - YjsEditor.flushLocalChanges(e); - - Editor.withoutNormalizing(editor, () => { - events.forEach((event) => { - translateYjsEvent(e.sharedRoot, editor, event).forEach((op) => { - // apply remote events to slate, don't call e.apply here because e.apply has been overridden. - apply(op); - }); - }); - }); - }; - - const handleYEvents = (events: Array>, transaction: Transaction) => { - if (transaction.origin === CollabOrigin.Local) { - return; - } - - YjsEditor.applyRemoteEvents(e, events, transaction); - }; - - e.connect = () => { - if (YjsEditor.connected(e)) { - throw new Error('Already connected'); - } - - const content = yDocToSlateContent(doc, true); - - if (!content) { - return; - } - - console.log(content); - - e.sharedRoot.observeDeep(handleYEvents); - e.children = content.children; - Editor.normalize(editor, { force: true }); - connectSet.add(e); - }; - - e.disconnect = () => { - if (!YjsEditor.connected(e)) { - throw new Error('Not connected'); - } - - e.sharedRoot.unobserveDeep(handleYEvents); - connectSet.delete(e); - }; - - e.storeLocalChange = (op) => { - const changes = localChanges.get(e) ?? []; - - localChanges.set(e, [...changes, { op, slateContent: e.children }]); - }; - - e.flushLocalChanges = () => { - const changes = YjsEditor.localChanges(e); - - localChanges.delete(e); - // parse changes and apply to ydoc - doc.transact(() => { - changes.forEach((change) => { - applySlateOp(doc, { children: change.slateContent }, change.op); - }); - }, CollabOrigin.Local); - }; - - e.apply = (op) => { - if (YjsEditor.connected(e)) { - YjsEditor.storeLocalChange(e, op); - } - - apply(op); - }; - - e.onChange = () => { - if (YjsEditor.connected(e)) { - YjsEditor.flushLocalChanges(e); - } - - onChange(); - }; - - return e; -} diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/applySlateOpts.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/applySlateOpts.ts deleted file mode 100644 index edb14cfa0a..0000000000 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/applySlateOpts.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Operation, Node } from 'slate'; -import Y from 'yjs'; - -export function applySlateOp (ydoc: Y.Doc, slateRoot: Node, op: Operation) { - console.log('applySlateOp', op); -} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/convert.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/convert.ts deleted file mode 100644 index ae8b6698e6..0000000000 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/convert.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { - InlineBlockType, - YBlocks, - YChildrenMap, - YSharedRoot, - YDoc, - YjsEditorKey, - YMeta, - YTextMap, - BlockData, - BlockType, -} from '@/application/collab.type'; -import { getFontFamily } from '@/utils/font'; -import { uniq } from 'lodash-es'; -import { Element, Text } from 'slate'; - -interface BlockJson { - id: string; - ty: string; - data?: string; - children?: string; - external_id?: string; -} - -export function yDocToSlateContent(doc: YDoc, includeRoot?: boolean): Element | undefined { - console.log(doc); - const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; - - console.log(sharedRoot.toJSON()); - const document = sharedRoot.get(YjsEditorKey.document); - const pageId = document.get(YjsEditorKey.page_id) as string; - const blocks = document.get(YjsEditorKey.blocks) as YBlocks; - const meta = document.get(YjsEditorKey.meta) as YMeta; - const childrenMap = meta.get(YjsEditorKey.children_map) as YChildrenMap; - const textMap = meta.get(YjsEditorKey.text_map) as YTextMap; - const fontFamilys: string[] = []; - - function traverse(id: string) { - const block = blocks.get(id).toJSON() as BlockJson; - const childrenId = block.children as string; - - const children = (childrenMap.get(childrenId)?.toJSON() ?? []).map(traverse) as (Element | Text)[]; - - const slateNode = blockToSlateNode(block); - - slateNode.children = children; - - if (slateNode.type === BlockType.Page) { - return slateNode; - } - - let textId = block.external_id as string; - - let delta; - - if (!textId) { - if (children.length === 0) { - children.push({ - text: '', - }); - } - - // Compatible data - // The old version of delta data is fully covered through the data field - if (slateNode.data) { - const data = slateNode.data as BlockData; - - if (YjsEditorKey.delta in data) { - textId = block.id; - delta = data.delta; - } else { - return slateNode; - } - } - } else { - delta = textMap.get(textId)?.toDelta(); - } - - try { - const slateDelta = delta.flatMap(deltaInsertToSlateNode); - - // collect font family - slateDelta.forEach((node: Text) => { - if (node.font_family) { - fontFamilys.push(getFontFamily(node.font_family)); - } - }); - const textNode: Element = { - textId, - type: YjsEditorKey.text, - children: slateDelta, - }; - - children.unshift(textNode); - return slateNode; - } catch (e) { - console.error(e); - return; - } - } - - const root = blocks.get(pageId); - - if (!root) return; - - const result = traverse(pageId); - - if (!result) return; - - if (!includeRoot) { - return result; - } - - const { children, ...rootNode } = result; - - // load font family - if (fontFamilys.length > 0) { - window.WebFont?.load({ - google: { - families: uniq(fontFamilys), - }, - }); - } - - return { - children: [ - { - ...rootNode, - children: [ - { - textId: pageId, - type: YjsEditorKey.text, - children: [{ text: '' }], - }, - ], - }, - ...children, - ], - }; -} - -export function blockToSlateNode(block: BlockJson): Element { - const data = block.data; - let blockData; - - try { - blockData = data ? JSON.parse(data) : {}; - } catch (e) { - blockData = {}; - } - - return { - blockId: block.id, - data: blockData, - type: block.ty, - children: [], - }; -} - -export function deltaInsertToSlateNode({ - attributes, - insert, -}: { - insert: string; - attributes: Record; -}): Element | Text | Element[] { - const matchInlines = transformToInlineElement({ - insert, - attributes, - }); - - if (matchInlines.length > 0) { - return matchInlines; - } - - if (attributes) { - if ('font_color' in attributes && attributes['font_color'] === '') { - delete attributes['font_color']; - } - - if ('bg_color' in attributes && attributes['bg_color'] === '') { - delete attributes['bg_color']; - } - - if ('code' in attributes && !attributes['code']) { - delete attributes['code']; - } - } - - return { - ...attributes, - text: insert, - }; -} - -export function transformToInlineElement(op: { - insert: string; - attributes: Record; -}): Element[] { - const attributes = op.attributes; - - if (!attributes) return []; - const { formula, mention, ...attrs } = attributes; - - if (formula) { - const texts = op.insert.split(''); - - return texts.map((text) => { - return { - type: InlineBlockType.Formula, - data: formula, - children: [ - { - text, - ...attrs, - }, - ], - }; - }); - } - - if (mention) { - const texts = op.insert.split(''); - - return texts.map((text) => { - return { - type: InlineBlockType.Mention, - data: mention, - children: [ - { - text, - ...attrs, - }, - ], - }; - }); - } - - return []; -} diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/arrayEvent.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/arrayEvent.ts deleted file mode 100644 index 8be1dbc297..0000000000 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/arrayEvent.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { YSharedRoot } from '@/application/collab.type'; -import * as Y from 'yjs'; -import { Editor, Operation } from 'slate'; - -export function translateYArrayEvent( - sharedRoot: YSharedRoot, - editor: Editor, - event: Y.YEvent> -): Operation[] { - console.log('translateYArrayEvent', sharedRoot, editor, event); - return []; -} diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/index.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/index.ts deleted file mode 100644 index 10af76fcde..0000000000 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { YSharedRoot } from '@/application/collab.type'; -import { translateYArrayEvent } from '@/application/slate-yjs/utils/translateYjsEvent/arrayEvent'; -import { translateYMapEvent } from '@/application/slate-yjs/utils/translateYjsEvent/mapEvent'; -import { Editor, Operation } from 'slate'; -import * as Y from 'yjs'; -import { translateYTextEvent } from 'src/application/slate-yjs/utils/translateYjsEvent/textEvent'; - -/** - * Translate a yjs event into slate operations. The editor state has to match the - * yText state before the event occurred. - * - * @param sharedType - * @param op - */ -export function translateYjsEvent(sharedRoot: YSharedRoot, editor: Editor, event: Y.YEvent): Operation[] { - console.log('translateYjsEvent', event); - if (event instanceof Y.YMapEvent) { - return translateYMapEvent(sharedRoot, editor, event); - } - - if (event instanceof Y.YTextEvent) { - return translateYTextEvent(sharedRoot, editor, event); - } - - if (event instanceof Y.YArrayEvent) { - return translateYArrayEvent(sharedRoot, editor, event); - } - - throw new Error('Unexpected Y event type'); -} diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/mapEvent.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/mapEvent.ts deleted file mode 100644 index fd50bb6df8..0000000000 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/mapEvent.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { YSharedRoot } from '@/application/collab.type'; -import * as Y from 'yjs'; -import { Editor, Operation } from 'slate'; - -export function translateYMapEvent( - sharedRoot: YSharedRoot, - editor: Editor, - event: Y.YEvent> -): Operation[] { - console.log('translateYMapEvent', sharedRoot, editor, event); - return []; -} diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/textEvent.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/textEvent.ts deleted file mode 100644 index 3dce8a3d59..0000000000 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/textEvent.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { YSharedRoot } from '@/application/document.type'; -import * as Y from 'yjs'; -import { Editor, Operation } from 'slate'; - -export function translateYTextEvent(sharedRoot: YSharedRoot, editor: Editor, event: Y.YEvent): Operation[] { - console.log('translateYTextEvent', sharedRoot, editor, event); - return []; -} diff --git a/frontend/appflowy_web_app/src/application/user.type.ts b/frontend/appflowy_web_app/src/application/user.type.ts deleted file mode 100644 index be64d574b4..0000000000 --- a/frontend/appflowy_web_app/src/application/user.type.ts +++ /dev/null @@ -1,68 +0,0 @@ -export enum Authenticator { - Local = 0, - Supabase = 1, - AppFlowyCloud = 2, -} - -export enum EncryptionType { - NoEncryption = 0, - Symmetric = 1, -} - -export interface UserProfile { - uid: number; - uuid?: string; - email?: string; - name?: string; - iconUrl?: string; - workspaceId?: string; -} - -export interface Workspace { - id: string; - name: string; - icon: string; - owner: { - id: number; - name: string; - }; -} - -export interface SignUpWithEmailPasswordParams { - name: string; - email: string; - password: string; -} - -export enum ProviderType { - Apple = 0, - Azure = 1, - Bitbucket = 2, - Discord = 3, - Facebook = 4, - Figma = 5, - Github = 6, - Gitlab = 7, - Google = 8, - Keycloak = 9, - Kakao = 10, - Linkedin = 11, - Notion = 12, - Spotify = 13, - Slack = 14, - Workos = 15, - Twitch = 16, - Twitter = 17, - Email = 18, - Phone = 19, - Zoom = 20, -} - -export interface UserSetting { - workspaceId: string; - latestView?: { - id: string; - name: string; - }; - hasLatestView: boolean; -} diff --git a/frontend/appflowy_web_app/src/application/ydoc/apply/__tests__/document.test.ts b/frontend/appflowy_web_app/src/application/ydoc/apply/__tests__/document.test.ts deleted file mode 100644 index 512c28ae6a..0000000000 --- a/frontend/appflowy_web_app/src/application/ydoc/apply/__tests__/document.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { YjsEditorKey } from '@/application/collab.type'; -import { applyDocument } from '@/application/ydoc/apply'; -import * as Y from 'yjs'; -import * as docJson from '../../../../../cypress/fixtures/simple_doc.json'; - -describe('apply document', () => { - it('should apply document', () => { - const collab = new Y.Doc(); - const data = collab.getMap(YjsEditorKey.data_section); - const document = new Y.Map(); - data.set(YjsEditorKey.document, document); - - const state = new Uint8Array(docJson.data.doc_state); - applyDocument(collab, state); - }); -}); - -export {}; diff --git a/frontend/appflowy_web_app/src/application/ydoc/apply/document.ts b/frontend/appflowy_web_app/src/application/ydoc/apply/document.ts deleted file mode 100644 index 60d02d0450..0000000000 --- a/frontend/appflowy_web_app/src/application/ydoc/apply/document.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CollabOrigin } from '@/application/collab.type'; -import * as Y from 'yjs'; - -/** - * Apply doc state from server to client - * Note: origin is always remote - * @param doc local Y.Doc - * @param state state from server - */ -export function applyDocument(doc: Y.Doc, state: Uint8Array) { - Y.transact( - doc, - () => { - Y.applyUpdate(doc, state); - }, - CollabOrigin.Remote - ); -} diff --git a/frontend/appflowy_web_app/src/application/ydoc/apply/index.ts b/frontend/appflowy_web_app/src/application/ydoc/apply/index.ts deleted file mode 100644 index 8147823035..0000000000 --- a/frontend/appflowy_web_app/src/application/ydoc/apply/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'src/application/ydoc/apply/document'; diff --git a/frontend/appflowy_web_app/src/assets/add.svg b/frontend/appflowy_web_app/src/assets/add.svg deleted file mode 100644 index 049be05cec..0000000000 --- a/frontend/appflowy_web_app/src/assets/add.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/align-center.svg b/frontend/appflowy_web_app/src/assets/align-center.svg deleted file mode 100644 index f4f4999514..0000000000 --- a/frontend/appflowy_web_app/src/assets/align-center.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/align-left.svg b/frontend/appflowy_web_app/src/assets/align-left.svg deleted file mode 100644 index 23957285c7..0000000000 --- a/frontend/appflowy_web_app/src/assets/align-left.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/align-right.svg b/frontend/appflowy_web_app/src/assets/align-right.svg deleted file mode 100644 index bca2d14fc7..0000000000 --- a/frontend/appflowy_web_app/src/assets/align-right.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/arrow-left.svg b/frontend/appflowy_web_app/src/assets/arrow-left.svg deleted file mode 100644 index e4ab9068be..0000000000 --- a/frontend/appflowy_web_app/src/assets/arrow-left.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/arrow-right.svg b/frontend/appflowy_web_app/src/assets/arrow-right.svg deleted file mode 100644 index dc40ae52a6..0000000000 --- a/frontend/appflowy_web_app/src/assets/arrow-right.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/board.svg b/frontend/appflowy_web_app/src/assets/board.svg deleted file mode 100644 index 0bb0e3fabe..0000000000 --- a/frontend/appflowy_web_app/src/assets/board.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/bold.svg b/frontend/appflowy_web_app/src/assets/bold.svg deleted file mode 100644 index 878b6329b3..0000000000 --- a/frontend/appflowy_web_app/src/assets/bold.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/clock_alarm.svg b/frontend/appflowy_web_app/src/assets/clock_alarm.svg deleted file mode 100644 index 33a5585ceb..0000000000 --- a/frontend/appflowy_web_app/src/assets/clock_alarm.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/close.svg b/frontend/appflowy_web_app/src/assets/close.svg deleted file mode 100644 index b519b419c0..0000000000 --- a/frontend/appflowy_web_app/src/assets/close.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/copy.svg b/frontend/appflowy_web_app/src/assets/copy.svg deleted file mode 100644 index e21e6cb082..0000000000 --- a/frontend/appflowy_web_app/src/assets/copy.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/dark-logo.svg b/frontend/appflowy_web_app/src/assets/dark-logo.svg deleted file mode 100644 index 80d8c4132e..0000000000 --- a/frontend/appflowy_web_app/src/assets/dark-logo.svg +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/database/checkbox-check.svg b/frontend/appflowy_web_app/src/assets/database/checkbox-check.svg deleted file mode 100644 index d2fc54c4b7..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/checkbox-check.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/checkbox-uncheck.svg b/frontend/appflowy_web_app/src/assets/database/checkbox-uncheck.svg deleted file mode 100644 index 3b3e17dd31..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/checkbox-uncheck.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-attach.svg b/frontend/appflowy_web_app/src/assets/database/field-type-attach.svg deleted file mode 100644 index f00f5c7aa2..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-attach.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-checkbox.svg b/frontend/appflowy_web_app/src/assets/database/field-type-checkbox.svg deleted file mode 100644 index 37f52c47ed..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-checkbox.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-checklist.svg b/frontend/appflowy_web_app/src/assets/database/field-type-checklist.svg deleted file mode 100644 index 3a88d236a1..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-checklist.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-date.svg b/frontend/appflowy_web_app/src/assets/database/field-type-date.svg deleted file mode 100644 index 78243f1e75..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-date.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-last-edited-time.svg b/frontend/appflowy_web_app/src/assets/database/field-type-last-edited-time.svg deleted file mode 100644 index 634af3e361..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-last-edited-time.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-multi-select.svg b/frontend/appflowy_web_app/src/assets/database/field-type-multi-select.svg deleted file mode 100644 index 97a2e9c434..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-multi-select.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-number.svg b/frontend/appflowy_web_app/src/assets/database/field-type-number.svg deleted file mode 100644 index 9d8b98d10d..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-number.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-person.svg b/frontend/appflowy_web_app/src/assets/database/field-type-person.svg deleted file mode 100644 index 2fc04be065..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-person.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-relation.svg b/frontend/appflowy_web_app/src/assets/database/field-type-relation.svg deleted file mode 100644 index f82a41d226..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-relation.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-single-select.svg b/frontend/appflowy_web_app/src/assets/database/field-type-single-select.svg deleted file mode 100644 index 8ccbc9a2e3..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-single-select.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-text.svg b/frontend/appflowy_web_app/src/assets/database/field-type-text.svg deleted file mode 100644 index 7befa5080f..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-text.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/database/field-type-url.svg b/frontend/appflowy_web_app/src/assets/database/field-type-url.svg deleted file mode 100644 index f00f5c7aa2..0000000000 --- a/frontend/appflowy_web_app/src/assets/database/field-type-url.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/date.svg b/frontend/appflowy_web_app/src/assets/date.svg deleted file mode 100644 index 78243f1e75..0000000000 --- a/frontend/appflowy_web_app/src/assets/date.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/delete.svg b/frontend/appflowy_web_app/src/assets/delete.svg deleted file mode 100644 index 9e51636798..0000000000 --- a/frontend/appflowy_web_app/src/assets/delete.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/details.svg b/frontend/appflowy_web_app/src/assets/details.svg deleted file mode 100644 index 22c6830916..0000000000 --- a/frontend/appflowy_web_app/src/assets/details.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/document.svg b/frontend/appflowy_web_app/src/assets/document.svg deleted file mode 100644 index b00e1cfb38..0000000000 --- a/frontend/appflowy_web_app/src/assets/document.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/drag.svg b/frontend/appflowy_web_app/src/assets/drag.svg deleted file mode 100644 index 627c959f9f..0000000000 --- a/frontend/appflowy_web_app/src/assets/drag.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/dropdown.svg b/frontend/appflowy_web_app/src/assets/dropdown.svg deleted file mode 100644 index 95e4964b53..0000000000 --- a/frontend/appflowy_web_app/src/assets/dropdown.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/edit.svg b/frontend/appflowy_web_app/src/assets/edit.svg deleted file mode 100644 index ae93287114..0000000000 --- a/frontend/appflowy_web_app/src/assets/edit.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/eye_close.svg b/frontend/appflowy_web_app/src/assets/eye_close.svg deleted file mode 100644 index 116c715ca8..0000000000 --- a/frontend/appflowy_web_app/src/assets/eye_close.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/eye_open.svg b/frontend/appflowy_web_app/src/assets/eye_open.svg deleted file mode 100644 index fa3017c04d..0000000000 --- a/frontend/appflowy_web_app/src/assets/eye_open.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/grid.svg b/frontend/appflowy_web_app/src/assets/grid.svg deleted file mode 100644 index c397af8130..0000000000 --- a/frontend/appflowy_web_app/src/assets/grid.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/h1.svg b/frontend/appflowy_web_app/src/assets/h1.svg deleted file mode 100644 index b33bd52135..0000000000 --- a/frontend/appflowy_web_app/src/assets/h1.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/h2.svg b/frontend/appflowy_web_app/src/assets/h2.svg deleted file mode 100644 index 7449c57391..0000000000 --- a/frontend/appflowy_web_app/src/assets/h2.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/h3.svg b/frontend/appflowy_web_app/src/assets/h3.svg deleted file mode 100644 index 0976945974..0000000000 --- a/frontend/appflowy_web_app/src/assets/h3.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/hide-menu.svg b/frontend/appflowy_web_app/src/assets/hide-menu.svg deleted file mode 100644 index ce88af8ea7..0000000000 --- a/frontend/appflowy_web_app/src/assets/hide-menu.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/hide.svg b/frontend/appflowy_web_app/src/assets/hide.svg deleted file mode 100644 index 22001ef65d..0000000000 --- a/frontend/appflowy_web_app/src/assets/hide.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/image.svg b/frontend/appflowy_web_app/src/assets/image.svg deleted file mode 100644 index 0739605066..0000000000 --- a/frontend/appflowy_web_app/src/assets/image.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/images/default_cover.jpg b/frontend/appflowy_web_app/src/assets/images/default_cover.jpg deleted file mode 100644 index aeaa6a0f29..0000000000 Binary files a/frontend/appflowy_web_app/src/assets/images/default_cover.jpg and /dev/null differ diff --git a/frontend/appflowy_web_app/src/assets/information.svg b/frontend/appflowy_web_app/src/assets/information.svg deleted file mode 100644 index 37ca4d5837..0000000000 --- a/frontend/appflowy_web_app/src/assets/information.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/inline-code.svg b/frontend/appflowy_web_app/src/assets/inline-code.svg deleted file mode 100644 index 3585603096..0000000000 --- a/frontend/appflowy_web_app/src/assets/inline-code.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/italic.svg b/frontend/appflowy_web_app/src/assets/italic.svg deleted file mode 100644 index b295c230f0..0000000000 --- a/frontend/appflowy_web_app/src/assets/italic.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/left.svg b/frontend/appflowy_web_app/src/assets/left.svg deleted file mode 100644 index 0f771a3858..0000000000 --- a/frontend/appflowy_web_app/src/assets/left.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/light-logo.svg b/frontend/appflowy_web_app/src/assets/light-logo.svg deleted file mode 100644 index f5cd761ba7..0000000000 --- a/frontend/appflowy_web_app/src/assets/light-logo.svg +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/link.svg b/frontend/appflowy_web_app/src/assets/link.svg deleted file mode 100644 index 5fbcc8d787..0000000000 --- a/frontend/appflowy_web_app/src/assets/link.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/list-dropdown.svg b/frontend/appflowy_web_app/src/assets/list-dropdown.svg deleted file mode 100644 index 4a8424c5f8..0000000000 --- a/frontend/appflowy_web_app/src/assets/list-dropdown.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/list.svg b/frontend/appflowy_web_app/src/assets/list.svg deleted file mode 100644 index 97a2e9c434..0000000000 --- a/frontend/appflowy_web_app/src/assets/list.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/logo.svg b/frontend/appflowy_web_app/src/assets/logo.svg deleted file mode 100644 index b1ac8d66fb..0000000000 --- a/frontend/appflowy_web_app/src/assets/logo.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/mention.svg b/frontend/appflowy_web_app/src/assets/mention.svg deleted file mode 100644 index b98318132c..0000000000 --- a/frontend/appflowy_web_app/src/assets/mention.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/more.svg b/frontend/appflowy_web_app/src/assets/more.svg deleted file mode 100644 index b191e64a10..0000000000 --- a/frontend/appflowy_web_app/src/assets/more.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/numbers.svg b/frontend/appflowy_web_app/src/assets/numbers.svg deleted file mode 100644 index 9d8b98d10d..0000000000 --- a/frontend/appflowy_web_app/src/assets/numbers.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/open.svg b/frontend/appflowy_web_app/src/assets/open.svg deleted file mode 100644 index b443c8b993..0000000000 --- a/frontend/appflowy_web_app/src/assets/open.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/quote.svg b/frontend/appflowy_web_app/src/assets/quote.svg deleted file mode 100644 index 57839231ff..0000000000 --- a/frontend/appflowy_web_app/src/assets/quote.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/react.svg b/frontend/appflowy_web_app/src/assets/react.svg deleted file mode 100644 index 6c87de9bb3..0000000000 --- a/frontend/appflowy_web_app/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/right.svg b/frontend/appflowy_web_app/src/assets/right.svg deleted file mode 100644 index 7d738f4e69..0000000000 --- a/frontend/appflowy_web_app/src/assets/right.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/appflowy_web_app/src/assets/search.svg b/frontend/appflowy_web_app/src/assets/search.svg deleted file mode 100644 index a8a92df509..0000000000 --- a/frontend/appflowy_web_app/src/assets/search.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/select-check.svg b/frontend/appflowy_web_app/src/assets/select-check.svg deleted file mode 100644 index 05caec861a..0000000000 --- a/frontend/appflowy_web_app/src/assets/select-check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/settings.svg b/frontend/appflowy_web_app/src/assets/settings.svg deleted file mode 100644 index 92140a3c23..0000000000 --- a/frontend/appflowy_web_app/src/assets/settings.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/settings/account.svg b/frontend/appflowy_web_app/src/assets/settings/account.svg deleted file mode 100644 index fddfca7575..0000000000 --- a/frontend/appflowy_web_app/src/assets/settings/account.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/assets/settings/check_circle.svg b/frontend/appflowy_web_app/src/assets/settings/check_circle.svg deleted file mode 100644 index c6fa56067b..0000000000 --- a/frontend/appflowy_web_app/src/assets/settings/check_circle.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/settings/dark.png b/frontend/appflowy_web_app/src/assets/settings/dark.png deleted file mode 100644 index 15a2db5eb8..0000000000 Binary files a/frontend/appflowy_web_app/src/assets/settings/dark.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src/assets/settings/discord.png b/frontend/appflowy_web_app/src/assets/settings/discord.png deleted file mode 100644 index f71e68c6ed..0000000000 Binary files a/frontend/appflowy_web_app/src/assets/settings/discord.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src/assets/settings/github.png b/frontend/appflowy_web_app/src/assets/settings/github.png deleted file mode 100644 index 597883b7a3..0000000000 Binary files a/frontend/appflowy_web_app/src/assets/settings/github.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src/assets/settings/google.png b/frontend/appflowy_web_app/src/assets/settings/google.png deleted file mode 100644 index 60032628a8..0000000000 Binary files a/frontend/appflowy_web_app/src/assets/settings/google.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src/assets/settings/light.png b/frontend/appflowy_web_app/src/assets/settings/light.png deleted file mode 100644 index 09b2d9c475..0000000000 Binary files a/frontend/appflowy_web_app/src/assets/settings/light.png and /dev/null differ diff --git a/frontend/appflowy_web_app/src/assets/settings/workplace.svg b/frontend/appflowy_web_app/src/assets/settings/workplace.svg deleted file mode 100644 index 2076ea3e2c..0000000000 --- a/frontend/appflowy_web_app/src/assets/settings/workplace.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/show-menu.svg b/frontend/appflowy_web_app/src/assets/show-menu.svg deleted file mode 100644 index 8baf55bffd..0000000000 --- a/frontend/appflowy_web_app/src/assets/show-menu.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/appflowy_web_app/src/assets/sort.svg b/frontend/appflowy_web_app/src/assets/sort.svg deleted file mode 100644 index e3b6a49a56..0000000000 --- a/frontend/appflowy_web_app/src/assets/sort.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/strikethrough.svg b/frontend/appflowy_web_app/src/assets/strikethrough.svg deleted file mode 100644 index c118422a15..0000000000 --- a/frontend/appflowy_web_app/src/assets/strikethrough.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/text.svg b/frontend/appflowy_web_app/src/assets/text.svg deleted file mode 100644 index 7befa5080f..0000000000 --- a/frontend/appflowy_web_app/src/assets/text.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/todo-list.svg b/frontend/appflowy_web_app/src/assets/todo-list.svg deleted file mode 100644 index 37f52c47ed..0000000000 --- a/frontend/appflowy_web_app/src/assets/todo-list.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/underline.svg b/frontend/appflowy_web_app/src/assets/underline.svg deleted file mode 100644 index f5d53f0ec2..0000000000 --- a/frontend/appflowy_web_app/src/assets/underline.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/appflowy_web_app/src/assets/up.svg b/frontend/appflowy_web_app/src/assets/up.svg deleted file mode 100644 index bd8f3067d3..0000000000 --- a/frontend/appflowy_web_app/src/assets/up.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_web_app/src/components/_shared/context-provider/FolderProvider.tsx b/frontend/appflowy_web_app/src/components/_shared/context-provider/FolderProvider.tsx deleted file mode 100644 index be466fdc49..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/context-provider/FolderProvider.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { YFolder } from '@/application/collab.type'; -import { FolderContext } from '@/application/folder-yjs'; - -export const FolderProvider: React.FC<{ folder: YFolder | null; children?: React.ReactNode }> = ({ - folder, - children, -}) => { - return {children}; -}; diff --git a/frontend/appflowy_web_app/src/components/_shared/context-provider/IdProvider.tsx b/frontend/appflowy_web_app/src/components/_shared/context-provider/IdProvider.tsx deleted file mode 100644 index 789642420d..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/context-provider/IdProvider.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { CollabType } from '@/application/collab.type'; -import { useContext, createContext } from 'react'; - -export const IdContext = createContext(null); - -interface IdProviderProps { - workspaceId: string; - objectId: string; - collabType: CollabType; -} - -export const IdProvider = ({ children, ...props }: IdProviderProps & { children: React.ReactNode }) => { - return {children}; -}; - -const defaultIdValue = {} as IdProviderProps; - -export function useId() { - return useContext(IdContext) || defaultIdValue; -} diff --git a/frontend/appflowy_web_app/src/components/_shared/katex-math/KatexMath.tsx b/frontend/appflowy_web_app/src/components/_shared/katex-math/KatexMath.tsx deleted file mode 100644 index e6c7cac5ed..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/katex-math/KatexMath.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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_web_app/src/components/_shared/katex-math/index.css b/frontend/appflowy_web_app/src/components/_shared/katex-math/index.css deleted file mode 100644 index d127dc343b..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/katex-math/index.css +++ /dev/null @@ -1,4 +0,0 @@ - -.katex-html { - white-space: normal; -} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx b/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx deleted file mode 100644 index 00441e5281..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; -import React from 'react'; -import { useNavigate } from 'react-router-dom'; - -export function RecordNotFound({ open, workspaceId }: { workspaceId: string; open: boolean }) { - const navigate = useNavigate(); - - return ( - - Oops.. something went wrong - - - Sorry, the document you are looking for does not exist. - - - - - - - ); -} - -export default RecordNotFound; diff --git a/frontend/appflowy_web_app/src/components/_shared/not-found/index.ts b/frontend/appflowy_web_app/src/components/_shared/not-found/index.ts deleted file mode 100644 index e4f431167c..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/not-found/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './RecordNotFound'; diff --git a/frontend/appflowy_web_app/src/components/_shared/notify/index.ts b/frontend/appflowy_web_app/src/components/_shared/notify/index.ts deleted file mode 100644 index 1086cabdfd..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/notify/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -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_web_app/src/components/_shared/page/Page.tsx b/frontend/appflowy_web_app/src/components/_shared/page/Page.tsx deleted file mode 100644 index 090c15d3b2..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/page/Page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { YView } from '@/application/collab.type'; -import { usePageInfo } from '@/components/_shared/page/usePageInfo'; -import React from 'react'; - -export function Page({ - id, - onClick, - ...props -}: { - id: string; - onClick?: (view: YView) => void; - style?: React.CSSProperties; -}) { - const { view, icon, name } = usePageInfo(id); - - return ( -
{ - onClick && view && onClick(view); - }} - className={'flex items-center justify-center gap-2 overflow-hidden'} - {...props} - > -
{icon}
-
{name}
-
- ); -} - -export default Page; diff --git a/frontend/appflowy_web_app/src/components/_shared/page/index.ts b/frontend/appflowy_web_app/src/components/_shared/page/index.ts deleted file mode 100644 index d9925d7520..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/page/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Page'; diff --git a/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx b/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx deleted file mode 100644 index 4fec272b79..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { ViewLayout, YjsFolderKey, YView } from '@/application/collab.type'; -import { useViewSelector } from '@/application/folder-yjs'; -import React, { useMemo } from 'react'; -import { ReactComponent as DocumentSvg } from '@/assets/document.svg'; -import { ReactComponent as GridSvg } from '@/assets/grid.svg'; -import { ReactComponent as BoardSvg } from '@/assets/board.svg'; -import { ReactComponent as CalendarSvg } from '@/assets/date.svg'; -import { useTranslation } from 'react-i18next'; - -export function usePageInfo(id: string) { - const { view } = useViewSelector(id); - - const layout = view?.get(YjsFolderKey.layout); - const icon = view?.get(YjsFolderKey.icon); - const name = view?.get(YjsFolderKey.name) || ''; - const iconObj = useMemo(() => { - try { - return JSON.parse(icon || ''); - } catch (e) { - return null; - } - }, [icon]); - const defaultIcon = useMemo(() => { - switch (parseInt(layout ?? '0')) { - case ViewLayout.Document: - return ; - case ViewLayout.Grid: - return ; - case ViewLayout.Board: - return ; - case ViewLayout.Calendar: - return ; - default: - return ; - } - }, [layout]); - - const { t } = useTranslation(); - - return { - icon: iconObj?.value || defaultIcon, - name: name || t('menuAppHeader.defaultNewPageName'), - view: view as YView, - }; -} diff --git a/frontend/appflowy_web_app/src/components/_shared/scroller/AFScroller.tsx b/frontend/appflowy_web_app/src/components/_shared/scroller/AFScroller.tsx deleted file mode 100644 index 0527b6cc26..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/scroller/AFScroller.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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_web_app/src/components/_shared/scroller/index.ts b/frontend/appflowy_web_app/src/components/_shared/scroller/index.ts deleted file mode 100644 index 7a740a5bb0..0000000000 --- a/frontend/appflowy_web_app/src/components/_shared/scroller/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './AFScroller'; diff --git a/frontend/appflowy_web_app/src/components/app/App.tsx b/frontend/appflowy_web_app/src/components/app/App.tsx deleted file mode 100644 index 1504c99f07..0000000000 --- a/frontend/appflowy_web_app/src/components/app/App.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import FolderPage from '@/pages/FolderPage'; -import { BrowserRouter, Route, Routes } from 'react-router-dom'; -import ProtectedRoutes from '@/components/auth/ProtectedRoutes'; -import LoginPage from '@/pages/LoginPage'; -import ProductPage from '@/pages/ProductPage'; -import withAppWrapper from '@/components/app/withAppWrapper'; - -const AppMain = withAppWrapper(() => { - return ( - - }> - } /> - } /> - - } /> - - ); -}); - -function App() { - return ( - - - - ); -} - -export default App; diff --git a/frontend/appflowy_web_app/src/components/app/AppConfig.tsx b/frontend/appflowy_web_app/src/components/app/AppConfig.tsx deleted file mode 100644 index 1308855bbc..0000000000 --- a/frontend/appflowy_web_app/src/components/app/AppConfig.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { createContext, useEffect, useMemo, useState } from 'react'; -import { AFService } from '@/application/services/services.type'; -import { getService } from '@/application/services'; -import { useAppSelector } from '@/stores/store'; - -export const AFConfigContext = createContext< - | { - service: AFService | undefined; -} - | undefined ->(undefined); - -function AppConfig ({ children }: { children: React.ReactNode }) { - const appConfig = useAppSelector((state) => state.app.appConfig); - const [service, setService] = useState(); - - useEffect(() => { - void (async () => { - if (!appConfig) return; - setService(await getService(appConfig)); - })(); - }, [appConfig]); - - const config = useMemo( - () => ({ - service, - }), - [service], - ); - - return {children}; -} - -export default AppConfig; diff --git a/frontend/appflowy_web_app/src/components/app/AppTheme.tsx b/frontend/appflowy_web_app/src/components/app/AppTheme.tsx deleted file mode 100644 index 2d00bec2a3..0000000000 --- a/frontend/appflowy_web_app/src/components/app/AppTheme.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React, { useMemo } from 'react'; -import createTheme from '@mui/material/styles/createTheme'; -import ThemeProvider from '@mui/material/styles/ThemeProvider'; -import '@/i18n/config'; - -import 'src/styles/tailwind.css'; -import 'src/styles/template.css'; - -function AppTheme({ children }: { children: React.ReactNode }) { - const isDark = false; - const theme = useMemo( - () => - createTheme({ - typography: { - fontFamily: ['inherit'].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)', - }, - }, - }, - MuiButtonBase: { - styleOverrides: { - root: { - '&:not(.MuiButton-contained)': { - '&: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', - }, - }, - }), - [isDark] - ); - - return {children}; -} - -export default AppTheme; diff --git a/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx b/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx deleted file mode 100644 index ca5bdcd100..0000000000 --- a/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Provider } from 'react-redux'; -import { store } from 'src/stores/store'; -import { ErrorBoundary } from 'react-error-boundary'; -import { ErrorHandlerPage } from 'src/components/error/ErrorHandlerPage'; -import AppTheme from '@/components/app/AppTheme'; -import { Toaster } from 'react-hot-toast'; -import AppConfig from '@/components/app/AppConfig'; -import { Suspense } from 'react'; - -export default function withAppWrapper (Component: React.FC): React.FC { - return function AppWrapper (): JSX.Element { - return ( - - - - - - - - - - - - - ); - }; -} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/auth/LoginButtonGroup.tsx b/frontend/appflowy_web_app/src/components/auth/LoginButtonGroup.tsx deleted file mode 100644 index 5e437bd0f7..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/LoginButtonGroup.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import Button from '@mui/material/Button'; -import GoogleIcon from '@/assets/settings/google.png'; -import GithubIcon from '@/assets/settings/github.png'; -import DiscordIcon from '@/assets/settings/discord.png'; -import { useTranslation } from 'react-i18next'; -import { useAuth } from './auth.hooks'; -import { ProviderType } from '@/application/user.type'; -import { useState } from 'react'; -import EmailOutlined from '@mui/icons-material/EmailOutlined'; -import SignInWithEmail from './SignInWithEmail'; - -export const LoginButtonGroup = () => { - const { t } = useTranslation(); - const [openSignInWithEmail, setOpenSignInWithEmail] = useState(false); - const { signInWithProvider } = useAuth(); - - return ( -
- - - - - setOpenSignInWithEmail(false)} /> -
- ); -}; diff --git a/frontend/appflowy_web_app/src/components/auth/ProtectedRoutes.tsx b/frontend/appflowy_web_app/src/components/auth/ProtectedRoutes.tsx deleted file mode 100644 index 728d30443a..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/ProtectedRoutes.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; -import { useAuth } from '@/components/auth/auth.hooks'; -import { currentUserActions, LoginState } from '@/stores/currentUser/slice'; -import { useAppDispatch } from '@/stores/store'; -import { getPlatform } from '@/utils/platform'; -import SplashScreen from '@/components/auth/SplashScreen'; -import CircularProgress from '@mui/material/CircularProgress'; -import Portal from '@mui/material/Portal'; -import { ReactComponent as Logo } from '@/assets/logo.svg'; -import { useNavigate } from 'react-router-dom'; - -const TauriAuth = lazy(() => import('@/components/tauri/TauriAuth')); - -function ProtectedRoutes() { - const { currentUser, checkUser, isReady } = useAuth(); - - const isLoading = currentUser?.loginState === LoginState.LOADING; - const [checked, setChecked] = useState(false); - - const checkUserStatus = useCallback(async () => { - if (!isReady) return; - setChecked(false); - try { - if (!currentUser.isAuthenticated) { - await checkUser(); - } - } finally { - setChecked(true); - } - }, [checkUser, isReady, currentUser.isAuthenticated]); - - useEffect(() => { - void checkUserStatus(); - }, [checkUserStatus]); - - const platform = useMemo(() => getPlatform(), []); - - const navigate = useNavigate(); - - if (checked && !currentUser.isAuthenticated && window.location.pathname !== '/login') { - navigate(`/login?redirect=${encodeURIComponent(window.location.pathname)}`); - return null; - } - - return ( -
- {checked ? ( - - ) : ( -
- -
- )} - - {isLoading && } - {platform.isTauri && } -
- ); -} - -export default ProtectedRoutes; - -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 ( - -
- -
-
- ); -}; diff --git a/frontend/appflowy_web_app/src/components/auth/SignInWithEmail.tsx b/frontend/appflowy_web_app/src/components/auth/SignInWithEmail.tsx deleted file mode 100644 index 06d36c2594..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/SignInWithEmail.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Button, CircularProgress, Dialog, DialogActions, DialogContent, TextField } from '@mui/material'; -import React, { useState } from 'react'; -import { useAuth } from '@/components/auth/auth.hooks'; -import { useTranslation } from 'react-i18next'; - -function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void }) { - const { t } = useTranslation(); - const [loading, setLoading] = useState(false); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const { signInWithEmailPassword } = useAuth(); - - const handleSignIn = async () => { - setLoading(true); - try { - await signInWithEmailPassword(email, password); - onClose(); - } catch (e) { - // Handle error - } - - setLoading(false); - }; - - return ( - { - if (e.key === 'Enter') { - e.preventDefault(); - void handleSignIn(); - } - }} - > - - setEmail(e.target.value)} - /> - setPassword(e.target.value)} - /> - - - - - - - ); -} - -export default SignInWithEmail; diff --git a/frontend/appflowy_web_app/src/components/auth/SplashScreen.tsx b/frontend/appflowy_web_app/src/components/auth/SplashScreen.tsx deleted file mode 100644 index bf5a5a854d..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/SplashScreen.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { Outlet } from 'react-router-dom'; -import Layout from '@/components/layout/Layout'; - -function SplashScreen () { - - return ( - - - - ); -} - -export default SplashScreen; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx b/frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx deleted file mode 100644 index f0f83d366a..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import Welcome from './Welcome'; -import withAppWrapper from '@/components/app/withAppWrapper'; - -describe('', () => { - beforeEach(() => { - cy.mockAPI(); - }); - - it('renders', () => { - const AppWrapper = withAppWrapper(Welcome); - - cy.mount(); - }); - - it('should handle login success', () => { - const AppWrapper = withAppWrapper(Welcome); - - cy.mount(); - - cy.get('[data-cy=signInWithEmail]').click(); - - cy.wait(100); - - cy.get('[data-cy=signInWithEmailDialog]').as('dialog').should('be.visible'); - cy.get('[data-cy=email]').type('fakeEmail123'); - cy.get('[data-cy=password]').type('fakePassword123'); - cy.get('[data-cy=submit]').click(); - cy.wait('@loginSuccess'); - cy.wait('@verifyToken'); - cy.wait('@getUserProfile'); - cy.get('@dialog').should('not.exist'); - }); -}); diff --git a/frontend/appflowy_web_app/src/components/auth/Welcome.tsx b/frontend/appflowy_web_app/src/components/auth/Welcome.tsx deleted file mode 100644 index 1281c3336f..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/Welcome.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { ReactComponent as AppflowyLogo } from '@/assets/logo.svg'; -import { Stack } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import { LoginButtonGroup } from './LoginButtonGroup'; -import { getPlatform } from '@/utils/platform'; -import { lazy } from 'react'; - -const SignInAsAnonymous = lazy(() => import('@/components/tauri/SignInAsAnonymous')); - -export const Welcome = () => { - const { t } = useTranslation(); - - return ( - <> -
e.preventDefault()} method='POST'> - -
- -
- -
- - {t('welcomeTo')} {t('appName')} - -
- -
- {getPlatform().isTauri && } -
- -
-
-
-
- - ); -}; - -export default Welcome; diff --git a/frontend/appflowy_web_app/src/components/auth/auth.hooks.ts b/frontend/appflowy_web_app/src/components/auth/auth.hooks.ts deleted file mode 100644 index cb972283bf..0000000000 --- a/frontend/appflowy_web_app/src/components/auth/auth.hooks.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { useAppDispatch, useAppSelector } from '@/stores/store'; -import { useCallback, useContext } from 'react'; -import { nanoid } from 'nanoid'; -import { open } from '@tauri-apps/api/shell'; -import { ProviderType, UserProfile } from '@/application/user.type'; -import { currentUserActions } from '@/stores/currentUser/slice'; -import { AFConfigContext } from '@/components/app/AppConfig'; -import { notify } from '@/components/_shared/notify'; - -export const useAuth = () => { - const dispatch = useAppDispatch(); - const AFConfig = useContext(AFConfigContext); - const currentUser = useAppSelector((state) => state.currentUser); - const isReady = !!AFConfig?.service; - - const handleSuccess = useCallback(() => { - notify.clear(); - dispatch(currentUserActions.loginSuccess()); - }, [dispatch]); - - const setUser = useCallback( - async (userProfile: UserProfile) => { - handleSuccess(); - dispatch(currentUserActions.updateUser(userProfile)); - }, - [dispatch, handleSuccess] - ); - - const handleStart = useCallback(() => { - notify.clear(); - notify.loading('Loading...'); - dispatch(currentUserActions.loginStart()); - }, [dispatch]); - - const handleError = useCallback( - ({ message }: { message: string }) => { - notify.clear(); - notify.error(message); - dispatch(currentUserActions.loginError()); - }, - [dispatch] - ); - - // Check if the user is authenticated - const checkUser = useCallback(async () => { - try { - const userHasSignIn = await AFConfig?.service?.userService.checkUser(); - - if (!userHasSignIn) { - throw new Error('Failed to check user'); - } - - const userProfile = await AFConfig?.service?.userService.getUserProfile(); - - if (!userProfile) { - throw new Error('Failed to check user'); - } - - await setUser(userProfile); - - return userProfile; - } catch (e) { - return Promise.reject('Failed to check user'); - } - }, [AFConfig?.service?.userService, setUser]); - - const register = useCallback( - async (email: string, password: string, name: string): Promise => { - handleStart(); - try { - const userProfile = await AFConfig?.service?.authService.signupWithEmailPassword({ - email, - password, - name, - }); - - if (!userProfile) { - throw new Error('Failed to register'); - } - - await setUser(userProfile); - - return userProfile; - } catch (e) { - handleError({ - message: 'Failed to register', - }); - return null; - } - }, - [handleStart, AFConfig?.service?.authService, setUser, handleError] - ); - - const logout = useCallback(async () => { - try { - await AFConfig?.service?.authService.signOut(); - dispatch(currentUserActions.logout()); - } catch (e) { - handleError({ - message: 'Failed to logout', - }); - } - }, [AFConfig?.service?.authService, dispatch, handleError]); - - const signInAsAnonymous = useCallback(async () => { - const fakeEmail = nanoid(8) + '@appflowy.io'; - const fakePassword = 'AppFlowy123@'; - const fakeName = 'Me'; - - await register(fakeEmail, fakePassword, fakeName); - }, [register]); - - const signInWithProvider = useCallback( - async (provider: ProviderType) => { - handleStart(); - try { - const url = await AFConfig?.service?.authService.getOAuthURL(provider); - - if (!url) { - throw new Error(); - } - - await open(url); - } catch { - handleError({ - message: 'Failed to sign in', - }); - } - }, - [AFConfig?.service?.authService, handleError, handleStart] - ); - - const signInWithOAuth = useCallback( - async (uri: string) => { - handleStart(); - try { - await AFConfig?.service?.authService.signInWithOAuth({ uri }); - const userProfile = await AFConfig?.service?.userService.getUserProfile(); - - if (!userProfile) { - throw new Error(); - } - - await setUser(userProfile); - - return userProfile; - } catch (e) { - handleError({ - message: 'Failed to sign in', - }); - } - }, - [AFConfig?.service?.authService, AFConfig?.service?.userService, handleError, handleStart, setUser] - ); - - const signInWithEmailPassword = useCallback( - async (email: string, password: string) => { - handleStart(); - try { - await AFConfig?.service?.authService.signinWithEmailPassword(email, password); - - const userProfile = await AFConfig?.service?.userService.getUserProfile(); - - if (!userProfile) { - throw new Error(); - } - - await setUser(userProfile); - - return userProfile; - } catch (e) { - handleError({ - message: 'Failed to sign in', - }); - } - }, - [AFConfig?.service?.authService, AFConfig?.service?.userService, handleError, handleStart, setUser] - ); - - return { - isReady, - currentUser, - checkUser, - register, - logout, - signInWithProvider, - signInAsAnonymous, - signInWithOAuth, - signInWithEmailPassword, - }; -}; diff --git a/frontend/appflowy_web_app/src/components/document/Document.tsx b/frontend/appflowy_web_app/src/components/document/Document.tsx deleted file mode 100644 index 82e20bed4d..0000000000 --- a/frontend/appflowy_web_app/src/components/document/Document.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { YDoc } from '@/application/collab.type'; -import { useId } from '@/components/_shared/context-provider/IdProvider'; -import { AFConfigContext } from '@/components/app/AppConfig'; -import { DocumentHeader } from '@/components/document/document_header'; -import { Editor } from '@/components/editor'; -import { Log } from '@/utils/log'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; -import RecordNotFound from 'src/components/_shared/not-found/RecordNotFound'; - -export const Document = () => { - const { objectId: documentId, workspaceId } = useId() || {}; - const [doc, setDoc] = useState(null); - const [notFound, setNotFound] = useState(false); - - const documentService = useContext(AFConfigContext)?.service?.documentService; - - const handleOpenDocument = useCallback(async () => { - if (!documentService || !workspaceId || !documentId) return; - try { - setDoc(null); - const doc = await documentService.openDocument(workspaceId, documentId); - - setDoc(doc); - } catch (e) { - Log.error(e); - setNotFound(true); - } - }, [documentService, workspaceId, documentId]); - - useEffect(() => { - setNotFound(false); - void handleOpenDocument(); - }, [handleOpenDocument]); - - if (!documentId) return null; - - return ( - <> - {doc && ( -
- -
-
- -
-
-
- )} - - - - ); -}; diff --git a/frontend/appflowy_web_app/src/components/document/document_header/DocumentCover.tsx b/frontend/appflowy_web_app/src/components/document/document_header/DocumentCover.tsx deleted file mode 100644 index 08ee25ef87..0000000000 --- a/frontend/appflowy_web_app/src/components/document/document_header/DocumentCover.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { CoverType, YDoc } from '@/application/collab.type'; -import { useBlockCover } from '@/components/document/document_header/useBlockCover'; -import { renderColor } from '@/utils/color'; -import React, { useCallback } from 'react'; -import DefaultImage from './default_cover.jpg'; - -function DocumentCover({ doc }: { doc: YDoc }) { - const { cover } = useBlockCover(doc); - const renderCoverColor = useCallback((color: string) => { - return ( -
- ); - }, []); - - const renderCoverImage = useCallback((url: string) => { - return {''}; - }, []); - - const { cover_selection_type: type, cover_selection: value = '' } = cover || {}; - - return value ? ( -
- <> - {type === CoverType.Asset ? renderCoverImage(DefaultImage) : null} - {type === CoverType.Color ? renderCoverColor(value) : null} - {type === CoverType.Image ? renderCoverImage(value) : null} - -
- ) : null; -} - -export default DocumentCover; diff --git a/frontend/appflowy_web_app/src/components/document/document_header/DocumentHeader.tsx b/frontend/appflowy_web_app/src/components/document/document_header/DocumentHeader.tsx deleted file mode 100644 index 0610b8a834..0000000000 --- a/frontend/appflowy_web_app/src/components/document/document_header/DocumentHeader.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { YDoc, YjsFolderKey } from '@/application/collab.type'; -import { useViewSelector } from '@/application/folder-yjs'; -import DocumentCover from '@/components/document/document_header/DocumentCover'; -import React, { memo, useMemo, useRef } from 'react'; - -export function DocumentHeader({ viewId, doc }: { viewId: string; doc: YDoc }) { - const ref = useRef(null); - const { view } = useViewSelector(viewId); - - const icon = view?.get(YjsFolderKey.icon); - const iconObject = useMemo(() => { - try { - return JSON.parse(icon || ''); - } catch (e) { - return null; - } - }, [icon]); - - return ( -
-
-
- - -
-
-
{iconObject?.value}
-
-
-
-
-
-
- ); -} - -export default memo(DocumentHeader); diff --git a/frontend/appflowy_web_app/src/components/document/document_header/default_cover.jpg b/frontend/appflowy_web_app/src/components/document/document_header/default_cover.jpg deleted file mode 100644 index aeaa6a0f29..0000000000 Binary files a/frontend/appflowy_web_app/src/components/document/document_header/default_cover.jpg and /dev/null differ diff --git a/frontend/appflowy_web_app/src/components/document/document_header/index.ts b/frontend/appflowy_web_app/src/components/document/document_header/index.ts deleted file mode 100644 index 00f48716bf..0000000000 --- a/frontend/appflowy_web_app/src/components/document/document_header/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './DocumentHeader'; diff --git a/frontend/appflowy_web_app/src/components/document/document_header/useBlockCover.ts b/frontend/appflowy_web_app/src/components/document/document_header/useBlockCover.ts deleted file mode 100644 index 589a1e4169..0000000000 --- a/frontend/appflowy_web_app/src/components/document/document_header/useBlockCover.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { PageCover, YBlocks, YDoc, YDocument, YjsEditorKey } from '@/application/collab.type'; -import { useEffect, useMemo, useState } from 'react'; - -export function useBlockCover(doc: YDoc) { - const [cover, setCover] = useState(null); - - useEffect(() => { - if (!doc) return; - - const document = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.document) as YDocument; - const pageId = document.get(YjsEditorKey.page_id) as string; - const blocks = document.get(YjsEditorKey.blocks) as YBlocks; - const root = blocks.get(pageId); - - setCover(root.toJSON().data || null); - const observerEvent = () => setCover(root.toJSON().data || null); - - root.observe(observerEvent); - - return () => { - root.unobserve(observerEvent); - }; - }, [doc]); - - const coverObj: PageCover = useMemo(() => { - try { - return JSON.parse(cover || ''); - } catch (e) { - return null; - } - }, [cover]); - - return { - cover: coverObj, - }; -} diff --git a/frontend/appflowy_web_app/src/components/document/index.ts b/frontend/appflowy_web_app/src/components/document/index.ts deleted file mode 100644 index a844aa51ad..0000000000 --- a/frontend/appflowy_web_app/src/components/document/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Document'; diff --git a/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx b/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx deleted file mode 100644 index 83e36b88f8..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { YjsFolderKey } from '@/application/collab.type'; -import { useViewSelector } from '@/application/folder-yjs'; -import { withYjs, YjsEditor } from '@/application/slate-yjs/plugins/withYjs'; -import { useId } from '@/components/_shared/context-provider/IdProvider'; -import { CustomEditor } from '@/components/editor/command'; -import EditorEditable from '@/components/editor/Editable'; -import { withPlugins } from '@/components/editor/plugins'; -import React, { useEffect, useMemo, useState } from 'react'; -import { createEditor, Descendant } from 'slate'; -import { Slate, withReact } from 'slate-react'; -import * as Y from 'yjs'; - -const defaultInitialValue: Descendant[] = []; - -function CollaborativeEditor({ doc }: { doc: Y.Doc }) { - const editor = useMemo(() => doc && (withPlugins(withReact(withYjs(createEditor(), doc))) as YjsEditor), [doc]); - const [connected, setIsConnected] = useState(false); - const viewId = useId()?.objectId || ''; - const { view } = useViewSelector(viewId); - const title = view?.get(YjsFolderKey.name); - - useEffect(() => { - if (!editor) return; - editor.connect(); - setIsConnected(true); - - return () => { - editor.disconnect(); - }; - }, [editor]); - - useEffect(() => { - if (!editor || !connected) return; - CustomEditor.setDocumentTitle(editor, title || ''); - }, [editor, title, connected]); - - return ( - - - - ); -} - -export default CollaborativeEditor; diff --git a/frontend/appflowy_web_app/src/components/editor/Editable.tsx b/frontend/appflowy_web_app/src/components/editor/Editable.tsx deleted file mode 100644 index e712fc88fd..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/Editable.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useDecorate } from '@/components/editor/components/blocks/code/useDecorate'; -import { Leaf } from '@/components/editor/components/leaf'; -import { useEditorContext } from '@/components/editor/EditorContext'; -import React, { useCallback } from 'react'; -import { NodeEntry } from 'slate'; -import { Editable, ReactEditor } from 'slate-react'; -import { Element } from './components/element'; - -const EditorEditable = ({ editor }: { editor: ReactEditor }) => { - const { readOnly } = useEditorContext(); - const codeDecorate = useDecorate(editor); - - const decorate = useCallback( - (entry: NodeEntry) => { - return [...codeDecorate(entry)]; - }, - [codeDecorate] - ); - - return ( - - ); -}; - -export default EditorEditable; diff --git a/frontend/appflowy_web_app/src/components/editor/Editor.cy.tsx b/frontend/appflowy_web_app/src/components/editor/Editor.cy.tsx deleted file mode 100644 index 12c2feb435..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/Editor.cy.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { YDoc } from '@/application/collab.type'; -import { DocumentTest } from '@/../cypress/support/document'; -import { applyDocument } from '@/application/ydoc/apply'; -import React from 'react'; -import * as Y from 'yjs'; -import { Editor } from './Editor'; -import withAppWrapper from '@/components/app/withAppWrapper'; - -describe('', () => { - it('renders with a paragraph', () => { - const documentTest = new DocumentTest(); - - documentTest.insertParagraph('Hello, world!'); - renderEditor(documentTest.doc); - cy.get('[role="textbox"]').should('contain', 'Hello, world!'); - }); - - it('renders with a full document', () => { - cy.fixture('full_doc').then((docJson) => { - const doc = new Y.Doc(); - const state = new Uint8Array(docJson.data.doc_state); - - applyDocument(doc, state); - renderEditor(doc); - }); - }); -}); - -function renderEditor(doc: YDoc) { - const AppWrapper = withAppWrapper(() => { - return ( -
- -
- ); - }); - - cy.mount(); -} diff --git a/frontend/appflowy_web_app/src/components/editor/Editor.tsx b/frontend/appflowy_web_app/src/components/editor/Editor.tsx deleted file mode 100644 index 7777973061..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/Editor.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { YDoc } from '@/application/collab.type'; -import CollaborativeEditor from '@/components/editor/CollaborativeEditor'; -import { EditorContextProvider } from '@/components/editor/EditorContext'; -import React from 'react'; -import './editor.scss'; - -export const Editor = ({ readOnly, doc }: { readOnly: boolean; doc: YDoc }) => { - return ( - - - - ); -}; - -export default Editor; diff --git a/frontend/appflowy_web_app/src/components/editor/EditorContext.tsx b/frontend/appflowy_web_app/src/components/editor/EditorContext.tsx deleted file mode 100644 index 7b4162891e..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/EditorContext.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { createContext, useContext } from 'react'; - -interface EditorContextState { - readOnly: boolean; -} - -export const EditorContext = createContext({ - readOnly: true, -}); - -export const EditorContextProvider = ({ children, ...props }: EditorContextState & { children: React.ReactNode }) => { - return {children}; -}; - -export function useEditorContext() { - return useContext(EditorContext); -} diff --git a/frontend/appflowy_web_app/src/components/editor/command/index.ts b/frontend/appflowy_web_app/src/components/editor/command/index.ts deleted file mode 100644 index dc4668760c..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/command/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { InlineBlockType, Mention, MentionType } from '@/application/collab.type'; -import { FormulaNode } from '@/components/editor/editor.type'; -import { renderDate } from '@/utils/time'; -import { Editor, Transforms, Element, Text, Node } from 'slate'; -import { ReactEditor } from 'slate-react'; - -export const CustomEditor = { - setDocumentTitle: (editor: ReactEditor, title: string) => { - const length = Editor.string(editor, [0, 0]).length; - - Transforms.insertText(editor, title, { - at: { - anchor: { path: [0, 0, 0], offset: 0 }, - focus: { path: [0, 0, 0], offset: length }, - }, - }); - }, - - // Get the text content of a block node, including the text content of its children and formula nodes - getBlockTextContent(node: Node): string { - if (Element.isElement(node)) { - if (node.type === InlineBlockType.Formula) { - return (node as FormulaNode).data || ''; - } - - if (node.type === InlineBlockType.Mention && (node.data as Mention)?.type === MentionType.Date) { - return renderDate((node.data as Mention).date || ''); - } - } - - if (Text.isText(node)) { - return node.text || ''; - } - - return node.children.map((n) => CustomEditor.getBlockTextContent(n)).join(''); - }, -}; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/bulleted_list/BulletedList.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/bulleted_list/BulletedList.tsx deleted file mode 100644 index 53552336d2..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/bulleted_list/BulletedList.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { BulletedListNode, EditorElementProps } from '@/components/editor/editor.type'; -import React, { forwardRef, memo } from 'react'; - -export const BulletedList = memo( - forwardRef>( - ({ node: _, children, className, ...attributes }, ref) => { - return ( -
- {children} -
- ); - } - ) -); - -export default BulletedList; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx deleted file mode 100644 index 62e06b6ba9..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { BulletedListNode } from '@/components/editor/editor.type'; -import { getListLevel } from '@/components/editor/utils/list'; -import React, { useMemo } from 'react'; -import { ReactEditor, useSlateStatic } from 'slate-react'; - -enum Letter { - Disc, - Circle, - Square, -} - -export function BulletedListIcon({ block, className }: { block: BulletedListNode; className: string }) { - const staticEditor = useSlateStatic(); - const path = ReactEditor.findPath(staticEditor, block); - - const letter = useMemo(() => { - const level = 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`} - /> - ); -} diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/bulleted_list/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/bulleted_list/index.ts deleted file mode 100644 index 393f4a03aa..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/bulleted_list/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './BulletedList'; -export * from './BulletedListIcon'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/callout/Callout.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/callout/Callout.tsx deleted file mode 100644 index a60255f951..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/callout/Callout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { EditorElementProps, CalloutNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo } from 'react'; -import CalloutIcon from './CalloutIcon'; - -export const Callout = memo( - forwardRef>(({ node, children, ...attributes }, ref) => { - return ( - <> -
- -
-
-
- {children} -
-
- - ); - }) -); - -export default Callout; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/callout/CalloutIcon.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/callout/CalloutIcon.tsx deleted file mode 100644 index 6f4f9b53ed..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/callout/CalloutIcon.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { CalloutNode } from '@/components/editor/editor.type'; -import React, { useRef } from 'react'; - -function CalloutIcon({ node }: { node: CalloutNode }) { - const ref = useRef(null); - - return ( - <> - - {node.data.icon} - - - ); -} - -export default React.memo(CalloutIcon); diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/callout/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/callout/index.ts deleted file mode 100644 index 4ca74e4be8..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/callout/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Callout'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.hooks.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.hooks.ts deleted file mode 100644 index b7bb3500af..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.hooks.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CodeNode } from '@/components/editor/editor.type'; -import { useCallback } from 'react'; -import { ReactEditor, useSlateStatic } from 'slate-react'; -import { Element as SlateElement, Transforms } from 'slate'; - -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_web_app/src/components/editor/components/blocks/code/Code.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.tsx deleted file mode 100644 index 4a3b0be961..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useCodeBlock } from '@/components/editor/components/blocks/code/Code.hooks'; -import { CodeNode, EditorElementProps } from '@/components/editor/editor.type'; -import { forwardRef, memo } from 'react'; -import LanguageSelect from './SelectLanguage'; - -export const CodeBlock = memo( - forwardRef>(({ node, children, ...attributes }, ref) => { - const { language, handleChangeLanguage } = useCodeBlock(node); - - return ( - <> -
- -
-
-
-            {children}
-          
-
- - ); - }) -); diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/SelectLanguage.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/SelectLanguage.tsx deleted file mode 100644 index f249a19951..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/SelectLanguage.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React, { useRef } from 'react'; -import { TextField } from '@mui/material'; -import { useTranslation } from 'react-i18next'; - -function SelectLanguage({ - readOnly, - language = 'json', -}: { - readOnly?: boolean; - language: string; - onChangeLanguage: (language: string) => void; - onBlur?: () => void; -}) { - const { t } = useTranslation(); - const ref = useRef(null); - - return ( - <> - { - if (readOnly) return; - }} - InputProps={{ - readOnly: true, - }} - placeholder={t('document.codeBlock.language.placeholder')} - label={t('document.codeBlock.language.label')} - /> - - ); -} - -export default SelectLanguage; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/constants.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/constants.ts deleted file mode 100644 index dee71624db..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/constants.ts +++ /dev/null @@ -1,154 +0,0 @@ -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_web_app/src/components/editor/components/blocks/code/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/index.ts deleted file mode 100644 index c3aa9443d1..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Code'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/useDecorate.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/useDecorate.ts deleted file mode 100644 index 1ec1a2e980..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/useDecorate.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { BlockType } from '@/application/collab.type'; -import { decorateCode } from '@/components/editor/components/blocks/code/utils'; -import { CodeNode } from '@/components/editor/editor.type'; -import { useCallback } from 'react'; -import { BaseRange, Editor, NodeEntry, Element } from 'slate'; -import { ReactEditor } from 'slate-react'; - -export function useDecorate(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 && n.type === BlockType.CodeBlock, - }); - - if (!blockEntry) return []; - - const block = blockEntry[0] as CodeNode; - - if (block.type === BlockType.CodeBlock) { - const language = block.data.language; - - return decorateCode(entry, language, false); - } - - return []; - }, - [editor] - ); -} diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/utils.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/utils.ts deleted file mode 100644 index 458d9e8d7b..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/utils.ts +++ /dev/null @@ -1,137 +0,0 @@ -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 && link.classList.contains('dark') === isDark) { - return; - } - - 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'; - newLink.classList.add(isDark ? 'dark' : 'light'); - 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.toLowerCase()]); - - 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_web_app/src/components/editor/components/blocks/divider/DividerNode.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/divider/DividerNode.tsx deleted file mode 100644 index 450f865b79..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/divider/DividerNode.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { EditorElementProps, DividerNode as DividerBlock } from '@/components/editor/editor.type'; -import React, { forwardRef, memo, useMemo } from 'react'; -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} -
-
- ); - } - ) -); - -export default DividerNode; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/divider/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/divider/index.ts deleted file mode 100644 index 8f6141749a..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/divider/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './DividerNode'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/heading/Heading.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/heading/Heading.tsx deleted file mode 100644 index 8d4351a2d0..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/heading/Heading.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { getHeadingCssProperty } from './utils'; -import { EditorElementProps, HeadingNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo } from 'react'; - -export const Heading = memo( - forwardRef>(({ node, children, ...attributes }, ref) => { - const level = node.data.level; - const fontSizeCssProperty = getHeadingCssProperty(level); - - const className = `${attributes.className ?? ''} ${fontSizeCssProperty} level-${level}`; - - return ( -
- {children} -
- ); - }) -); - -export default Heading; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/heading/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/heading/index.ts deleted file mode 100644 index a202e12acd..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/heading/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './utils'; -export * from './Heading'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/heading/utils.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/heading/utils.ts deleted file mode 100644 index bab542fc84..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/heading/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 ''; - } -} diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageBlock.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageBlock.tsx deleted file mode 100644 index 50de92cc66..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageBlock.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { AlignType } from '@/application/collab.type'; -import { EditorElementProps, ImageBlockNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; -import { ReactEditor, useSelected, useSlateStatic } from 'slate-react'; -import ImageEmpty from './ImageEmpty'; -import ImageRender from './ImageRender'; - -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]); - - const alignCss = useMemo(() => { - if (!align) return ''; - - return align === AlignType.Center ? 'justify-center' : align === AlignType.Right ? 'justify-end' : 'justify-start'; - }, [align]); - - 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_web_app/src/components/editor/components/blocks/image/ImageEmpty.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageEmpty.tsx deleted file mode 100644 index ab95cbe37d..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageEmpty.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { ImageBlockNode } from '@/components/editor/editor.type'; -import React from 'react'; -import { ReactComponent as ImageIcon } from '@/assets/image.svg'; -import { useTranslation } from 'react-i18next'; - -function ImageEmpty(_: { containerRef: React.RefObject; onEscape: () => void; node: ImageBlockNode }) { - const { t } = useTranslation(); - - return ( - <> -
- - {t('document.plugins.image.addAnImage')} -
- - ); -} - -export default ImageEmpty; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageRender.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageRender.tsx deleted file mode 100644 index 55677506b3..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageRender.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { ImageBlockNode } from '@/components/editor/editor.type'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { CircularProgress } from '@mui/material'; -import { ErrorOutline } from '@mui/icons-material'; - -const MIN_WIDTH = 100; - -function ImageRender({ selected, node }: { selected: boolean; node: ImageBlockNode }) { - const [loading, setLoading] = useState(true); - const [hasError, setHasError] = useState(false); - - const imgRef = useRef(null); - const { url = '', width: imageWidth } = useMemo(() => node.data || {}, [node.data]); - const { t } = useTranslation(); - const blockId = node.blockId; - const [initialWidth, setInitialWidth] = useState(null); - const [newWidth] = useState(imageWidth ?? null); - - 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 ( -
- {`image-${blockId}`} - {hasError ? ( - renderErrorNode() - ) : loading ? ( -
- -
{t('editor.loading')}
-
- ) : null} -
- ); -} - -export default ImageRender; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/index.ts deleted file mode 100644 index 73c3003a92..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ImageBlock'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/math_equation/MathEquation.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/math_equation/MathEquation.tsx deleted file mode 100644 index db00aeb777..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/math_equation/MathEquation.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import KatexMath from '@/components/_shared/katex-math/KatexMath'; -import { EditorElementProps, MathEquationNode } from '@/components/editor/editor.type'; -import { FunctionsOutlined } from '@mui/icons-material'; -import { forwardRef, memo, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -export const MathEquation = memo( - forwardRef>( - ({ node, children, className, ...attributes }, ref) => { - const formula = node.data.formula; - const { t } = useTranslation(); - const containerRef = useRef(null); - - return ( - <> -
-
- {formula ? ( - - ) : ( -
- - {t('document.plugins.mathEquation.addMathEquation')} -
- )} -
-
- {children} -
-
- - ); - } - ) -); - -export default MathEquation; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/math_equation/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/math_equation/index.ts deleted file mode 100644 index 27b52f50f2..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/math_equation/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { lazy } from 'react'; - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-expect-error -export const MathEquation = lazy(() => import('./MathEquation?chunkName=formula')); diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/numbered_list/NumberListIcon.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/numbered_list/NumberListIcon.tsx deleted file mode 100644 index 12b4c5d0e2..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/numbered_list/NumberListIcon.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { NumberedListNode } from '@/components/editor/editor.type'; -import { getListLevel, letterize, romanize } from '@/components/editor/utils/list'; -import React, { useMemo } from 'react'; -import { ReactEditor, useSlate, useSlateStatic } from 'slate-react'; -import { Element, Path } from 'slate'; - -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); - } -} - -export 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 = 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`} - /> - ); -} diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/numbered_list/NumberedList.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/numbered_list/NumberedList.tsx deleted file mode 100644 index 9b10389944..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/numbered_list/NumberedList.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { EditorElementProps, NumberedListNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo } from 'react'; - -export const NumberedList = memo( - forwardRef>( - ({ node: _, children, className, ...attributes }, ref) => { - return ( -
- {children} -
- ); - } - ) -); - -export default NumberedList; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/numbered_list/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/numbered_list/index.ts deleted file mode 100644 index df030e8e83..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/numbered_list/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './NumberedList'; -export * from './NumberListIcon'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/outline/Outline.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/outline/Outline.tsx deleted file mode 100644 index cddab4cfb7..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/outline/Outline.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { extractHeadings, nestHeadings } from '@/components/editor/components/blocks/outline/utils'; -import { EditorElementProps, HeadingNode, OutlineNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo, useCallback, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useSlate } from 'slate-react'; - -export const Outline = memo( - forwardRef>(({ node, children, className, ...attributes }, ref) => { - const editor = useSlate(); - const [root, setRoot] = useState([]); - const { t } = useTranslation(); - - useEffect(() => { - const root = nestHeadings(extractHeadings(editor, node.data.depth || 6)); - - setRoot(root); - }, [editor, node.data.depth]); - - const jumpToHeading = useCallback((heading: HeadingNode) => { - const id = `heading-${heading.blockId}`; - - const element = document.getElementById(id); - - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - }, []); - - const renderHeading = useCallback( - (heading: HeadingNode, index: number) => { - const children = (heading.children as HeadingNode[]).map(renderHeading); - const { text, level } = heading.data as { text: string; level: number }; - - return ( -
jumpToHeading(heading)} className={`my-1 ml-4 `} key={`${level}-${index}`}> -
{text}
- -
{children}
-
- ); - }, - [jumpToHeading] - ); - - return ( -
-
- {children} -
-
-
{t('document.outlineBlock.placeholder')}
- {root.map(renderHeading)} -
-
- ); - }) -); - -export default Outline; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/outline/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/outline/index.ts deleted file mode 100644 index 739fdf04f6..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/outline/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Outline'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/outline/utils.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/outline/utils.ts deleted file mode 100644 index 57105fef5a..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/outline/utils.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { BlockType } from '@/application/collab.type'; -import { CustomEditor } from '@/components/editor/command'; -import { HeadingNode } from '@/components/editor/editor.type'; -import { Element, Text } from 'slate'; -import { ReactEditor } from 'slate-react'; - -export function extractHeadings(editor: ReactEditor, maxDepth: number): HeadingNode[] { - const headings: HeadingNode[] = []; - const blocks = editor.children; - - function traverse(children: (Element | Text)[]) { - for (const block of children) { - if (Text.isText(block)) continue; - if (block.type === BlockType.HeadingBlock && (block as HeadingNode).data?.level <= maxDepth) { - headings.push({ - ...block, - data: { - level: (block as HeadingNode).data.level, - text: CustomEditor.getBlockTextContent(block), - }, - children: [], - } as HeadingNode); - } else { - traverse(block.children); - } - } - - return headings; - } - - return traverse(blocks); -} - -export function nestHeadings(headings: HeadingNode[]): HeadingNode[] { - const root: HeadingNode[] = []; - const stack: HeadingNode[] = []; - - headings.forEach((heading) => { - const node = { ...heading, children: [] }; - - while (stack.length > 0 && stack[stack.length - 1].data.level >= node.data.level) { - stack.pop(); - } - - if (stack.length === 0) { - root.push(node); - } else { - stack[stack.length - 1].children.push(node); - } - - stack.push(node); - }); - - return root; -} diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/page/Page.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/page/Page.tsx deleted file mode 100644 index e0d3b4e293..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/page/Page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { EditorElementProps, PageNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo, useMemo } from 'react'; - -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_web_app/src/components/editor/components/blocks/page/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/page/index.ts deleted file mode 100644 index d9925d7520..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/page/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Page'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/paragraph/Paragraph.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/paragraph/Paragraph.tsx deleted file mode 100644 index ad6737510e..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/paragraph/Paragraph.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { EditorElementProps, ParagraphNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo } from 'react'; - -export const Paragraph = memo( - forwardRef>(({ node: _, children, ...attributes }, ref) => { - { - return ( -
- {children} -
- ); - } - }) -); diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/paragraph/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/paragraph/index.ts deleted file mode 100644 index 01752c914c..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/paragraph/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Paragraph'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/Quote.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/Quote.tsx deleted file mode 100644 index 0ddc0af985..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/Quote.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { EditorElementProps, QuoteNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo, useMemo } from 'react'; - -export const Quote = 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} -
- ); - }) -); - -export default Quote; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/index.ts deleted file mode 100644 index c88e677a53..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Quote'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/table/Table.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/table/Table.tsx deleted file mode 100644 index 3b2ffb1a22..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/table/Table.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { EditorElementProps, TableCellNode, TableNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo, useMemo } from 'react'; -import { Grid } from '@atlaskit/primitives'; -import './table.scss'; - -const Table = memo( - forwardRef>(({ node, children, className, ...attributes }, ref) => { - const { rowsLen, colsLen, rowDefaultHeight, colsHeight } = node.data; - const cells = node.children as TableCellNode[]; - - const columnGroup = useMemo(() => { - return Array.from({ length: colsLen }, (_, index) => { - return cells.filter((cell) => cell.data.colPosition === index); - }); - }, [cells, colsLen]); - - const rowGroup = useMemo(() => { - return Array.from({ length: rowsLen }, (_, index) => { - return cells.filter((cell) => cell.data.rowPosition === index); - }); - }, [cells, rowsLen]); - - const templateColumns = useMemo(() => { - return columnGroup - .map((group) => { - return `${group[0].data.width || colsHeight}px`; - }) - .join(' '); - }, [colsHeight, columnGroup]); - - const templateRows = useMemo(() => { - return rowGroup - .map((group) => { - return `${group[0].data.height || rowDefaultHeight}px`; - }) - .join(' '); - }, [rowGroup, rowDefaultHeight]); - - return ( -
- - {children} - -
- ); - }) -); - -export default Table; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/table/TableCell.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/table/TableCell.tsx deleted file mode 100644 index b2a01d5c8c..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/table/TableCell.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { EditorElementProps, TableCellNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo } from 'react'; - -const TableCell = memo( - forwardRef>( - ({ node: _, children, className, ...attributes }, ref) => { - return ( -
- {children} -
- ); - } - ) -); - -export default TableCell; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/table/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/table/index.ts deleted file mode 100644 index 99a5478645..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/table/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as TableBlock } from './Table'; - -export { default as TableCellBlock } from './TableCell'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/table/table.scss b/frontend/appflowy_web_app/src/components/editor/components/blocks/table/table.scss deleted file mode 100644 index 1aa812f6c5..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/table/table.scss +++ /dev/null @@ -1,10 +0,0 @@ -.table-block { - [id^=table-] { - width: fit-content; - @apply border-t border-l border-line-border; - } - - .table-cell { - @apply border-b border-r border-line-border; - } -} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/text/Placeholder.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/text/Placeholder.tsx deleted file mode 100644 index 44c1391e13..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/text/Placeholder.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { BlockType } from '@/application/collab.type'; -import { HeadingNode } from '@/components/editor/editor.type'; -import { useEditorContext } from '@/components/editor/EditorContext'; -import React, { CSSProperties, useEffect, useMemo, useState } from 'react'; -import { ReactEditor, useSelected, useSlate } from 'slate-react'; -import { Editor, Element, Range } from 'slate'; -import { useTranslation } from 'react-i18next'; - -function Placeholder({ node, ...attributes }: { node: Element; className?: string; style?: CSSProperties }) { - const { t } = useTranslation(); - const { readOnly } = useEditorContext(); - const editor = useSlate(); - const selected = useSelected() && !readOnly && !!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 BlockType.Paragraph: { - if (editor.children.length === 1) { - return t('editor.slashPlaceHolder'); - } - - return ''; - } - - case BlockType.ToggleListBlock: - return t('blockPlaceholders.bulletList'); - case BlockType.QuoteBlock: - return t('blockPlaceholders.quote'); - case BlockType.TodoListBlock: - return t('blockPlaceholders.todoList'); - case BlockType.NumberedListBlock: - return t('blockPlaceholders.numberList'); - case BlockType.BulletedListBlock: - return t('blockPlaceholders.bulletList'); - case BlockType.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 BlockType.Page: - return t('document.title.placeholder'); - case BlockType.CalloutBlock: - case BlockType.CodeBlock: - return t('editor.typeSomething'); - default: - return ''; - } - }, [block, t, editor.children.length]); - - const selectedPlaceholder = useMemo(() => { - switch (block?.type) { - case BlockType.HeadingBlock: - return unSelectedPlaceholder; - case BlockType.Page: - return t('document.title.placeholder'); - case BlockType.GridBlock: - case BlockType.EquationBlock: - case BlockType.CodeBlock: - case BlockType.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 Placeholder; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/text/StartIcon.hooks.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/text/StartIcon.hooks.tsx deleted file mode 100644 index 119c6fe9fe..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/text/StartIcon.hooks.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { BlockType } from '@/application/collab.type'; -import { BulletedListIcon } from '@/components/editor/components/blocks/bulleted_list'; -import { NumberListIcon } from '@/components/editor/components/blocks/numbered_list'; -import ToggleIcon from '@/components/editor/components/blocks/toggle_list/ToggleIcon'; -import { TextNode } from '@/components/editor/editor.type'; -import React, { FC, useCallback, useMemo } from 'react'; -import { ReactEditor, useSlate } from 'slate-react'; -import { Editor, Element } from 'slate'; -import CheckboxIcon from '../todo_list/CheckboxIcon'; - -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 BlockType.TodoListBlock: - return CheckboxIcon; - case BlockType.ToggleListBlock: - return ToggleIcon; - case BlockType.NumberedListBlock: - return NumberListIcon; - case BlockType.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_web_app/src/components/editor/components/blocks/text/Text.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/text/Text.tsx deleted file mode 100644 index 854789969c..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/text/Text.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import Placeholder from '@/components/editor/components/blocks/text/Placeholder'; -import { useSlateStatic } from 'slate-react'; -import { useStartIcon } from './StartIcon.hooks'; -import { EditorElementProps, TextNode } from '@/components/editor/editor.type'; - -import React, { forwardRef, memo, useMemo } from 'react'; - -export const Text = memo( - forwardRef>( - ({ node, children, className: classNameProp, ...attributes }, ref) => { - const { hasStartIcon, renderIcon } = useStartIcon(node); - const editor = useSlateStatic(); - const isEmpty = editor.isEmpty(node); - const className = useMemo( - () => - `text-element relative my-1 flex w-full whitespace-pre-wrap break-all px-1 ${classNameProp ?? ''} ${ - hasStartIcon ? 'has-start-icon' : '' - }`, - [classNameProp, hasStartIcon] - ); - - return ( - - {renderIcon()} - {isEmpty && } - {children} - - ); - } - ) -); diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/text/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/text/index.ts deleted file mode 100644 index b0c76af0b0..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/text/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Text'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/CheckboxIcon.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/CheckboxIcon.tsx deleted file mode 100644 index aad5969e86..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/CheckboxIcon.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { TodoListNode } from '@/components/editor/editor.type'; -import React from 'react'; -import { ReactComponent as CheckboxCheckSvg } from '@/assets/database/checkbox-check.svg'; -import { ReactComponent as CheckboxUncheckSvg } from '@/assets/database/checkbox-uncheck.svg'; - -function CheckboxIcon({ block, className }: { block: TodoListNode; className: string }) { - const { checked } = block.data; - - return ( - { - e.preventDefault(); - }} - className={`${className} cursor-pointer pr-1 text-xl text-fill-default`} - > - {checked ? : } - - ); -} - -export default CheckboxIcon; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/TodoList.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/TodoList.tsx deleted file mode 100644 index ab3a437b78..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/TodoList.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { EditorElementProps, TodoListNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo, useMemo } from 'react'; - -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_web_app/src/components/editor/components/blocks/todo_list/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/index.ts deleted file mode 100644 index f239f43459..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/todo_list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './TodoList'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/ToggleIcon.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/ToggleIcon.tsx deleted file mode 100644 index 4e2735d128..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/ToggleIcon.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ToggleListNode } from '@/components/editor/editor.type'; -import React from 'react'; -import { ReactComponent as RightSvg } from '@/assets/more.svg'; - -function ToggleIcon({ block, className }: { block: ToggleListNode; className: string }) { - const { collapsed } = block.data; - - return ( - { - e.preventDefault(); - }} - className={`${className} cursor-pointer pr-1 text-xl hover:text-fill-default`} - > - {collapsed ? : } - - ); -} - -export default ToggleIcon; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/ToggleList.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/ToggleList.tsx deleted file mode 100644 index 6ccc38f757..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/ToggleList.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React, { forwardRef, memo, useMemo } from 'react'; -import { EditorElementProps, ToggleListNode } from '@/components/editor/editor.type'; - -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} -
- - ); - }) -); - -export default ToggleList; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/index.ts deleted file mode 100644 index 833bdb5210..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle_list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ToggleList'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/element/Element.tsx b/frontend/appflowy_web_app/src/components/editor/components/element/Element.tsx deleted file mode 100644 index f535bde10c..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/element/Element.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { BlockData, BlockType, InlineBlockType, YjsEditorKey } from '@/application/collab.type'; -import { BulletedList } from '@/components/editor/components/blocks/bulleted_list'; -import { Callout } from '@/components/editor/components/blocks/callout'; -import { CodeBlock } from '@/components/editor/components/blocks/code'; -import { DividerNode } from '@/components/editor/components/blocks/divider'; -import { Heading } from '@/components/editor/components/blocks/heading'; -import { ImageBlock } from '@/components/editor/components/blocks/image'; -import { MathEquation } from '@/components/editor/components/blocks/math_equation'; -import { NumberedList } from '@/components/editor/components/blocks/numbered_list'; -import { Outline } from '@/components/editor/components/blocks/outline'; -import { Page } from '@/components/editor/components/blocks/page'; -import { Paragraph } from '@/components/editor/components/blocks/paragraph'; -import { Quote } from '@/components/editor/components/blocks/quote'; -import { TableBlock, TableCellBlock } from '@/components/editor/components/blocks/table'; -import { Text } from '@/components/editor/components/blocks/text'; -import { TodoList } from '@/components/editor/components/blocks/todo_list'; -import { ToggleList } from '@/components/editor/components/blocks/toggle_list'; -import { UnSupportedBlock } from '@/components/editor/components/element/UnSupportedBlock'; -import { Formula } from '@/components/editor/components/leaf/formula'; -import { Mention } from '@/components/editor/components/leaf/mention'; -import { EditorElementProps, TextNode } from '@/components/editor/editor.type'; -import { renderColor } from '@/utils/color'; -import React, { FC, useMemo } from 'react'; -import { RenderElementProps } from 'slate-react'; - -export const Element = ({ - element: node, - attributes, - children, -}: RenderElementProps & { - element: EditorElementProps['node']; -}) => { - const Component = useMemo(() => { - switch (node.type) { - case BlockType.HeadingBlock: - return Heading; - case BlockType.TodoListBlock: - return TodoList; - case BlockType.ToggleListBlock: - return ToggleList; - case BlockType.Paragraph: - return Paragraph; - case BlockType.DividerBlock: - return DividerNode; - case BlockType.Page: - return Page; - case BlockType.QuoteBlock: - return Quote; - case BlockType.BulletedListBlock: - return BulletedList; - case BlockType.NumberedListBlock: - return NumberedList; - case BlockType.CodeBlock: - return CodeBlock; - case BlockType.CalloutBlock: - return Callout; - case BlockType.EquationBlock: - return MathEquation; - case BlockType.ImageBlock: - return ImageBlock; - case BlockType.OutlineBlock: - return Outline; - case BlockType.TableBlock: - return TableBlock; - case BlockType.TableCell: - return TableCellBlock; - default: - return UnSupportedBlock; - } - }, [node.type]) as FC; - - const InlineComponent = useMemo(() => { - switch (node.type) { - case InlineBlockType.Formula: - return Formula; - case InlineBlockType.Mention: - return Mention; - default: - return null; - } - }, [node.type]) as FC; - - const className = useMemo(() => { - const data = (node.data as BlockData) || {}; - const align = data.align; - - return `block-element flex rounded ${align ? `block-align-${align}` : ''}`; - }, [node.data]); - - 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 === YjsEditorKey.text) { - return ( - - {children} - - ); - } - - return ( -
- - {children} - -
- ); -}; diff --git a/frontend/appflowy_web_app/src/components/editor/components/element/UnSupportedBlock.tsx b/frontend/appflowy_web_app/src/components/editor/components/element/UnSupportedBlock.tsx deleted file mode 100644 index 99c299bc64..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/element/UnSupportedBlock.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { EditorElementProps } from '@/components/editor/editor.type'; -import React, { forwardRef } from 'react'; -import { Alert } from '@mui/material'; - -export const UnSupportedBlock = forwardRef(({ node }, ref) => { - return ( -
- - {`Unsupported block: ${node.type}`} - -
- ); -}); diff --git a/frontend/appflowy_web_app/src/components/editor/components/element/index.ts b/frontend/appflowy_web_app/src/components/editor/components/element/index.ts deleted file mode 100644 index b5347c4fe9..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/element/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Element'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/Leaf.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/Leaf.tsx deleted file mode 100644 index 714b41db97..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/Leaf.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Href } from '@/components/editor/components/leaf/href'; -import { getFontFamily } from '@/utils/font'; -import React, { CSSProperties } from 'react'; -import { RenderLeafProps } from 'slate-react'; -import { renderColor } from '@/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}; - } - - if (leaf.font_family) { - style['fontFamily'] = getFontFamily(leaf.font_family); - } - - return ( - - {newChildren} - - ); -} diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/Formula.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/Formula.tsx deleted file mode 100644 index 3731fc7216..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/Formula.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import KatexMath from '@/components/_shared/katex-math/KatexMath'; -import { EditorElementProps, FormulaNode } from '@/components/editor/editor.type'; -import React, { memo, forwardRef } from 'react'; -import { useSelected } from 'slate-react'; - -export const Formula = memo( - forwardRef>(({ node, children, ...attributes }, ref) => { - const formula = node.data; - const selected = useSelected(); - - return ( - - - - - - {children} - - ); - }) -); - -export default Formula; diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/index.ts b/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/index.ts deleted file mode 100644 index 1c01fca07e..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { lazy } from 'react'; - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-expect-error -export const Formula = lazy(() => import('./Formula?chunkName=formula')); diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/href/Href.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/href/Href.tsx deleted file mode 100644 index 83c0ed2297..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/href/Href.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useEditorContext } from '@/components/editor/EditorContext'; -import { openUrl } from '@/utils/url'; -import React, { memo } from 'react'; -import { Text } from 'slate'; - -export const Href = memo(({ children, leaf }: { leaf: Text; children: React.ReactNode }) => { - const readonly = useEditorContext().readOnly; - - return ( - { - if (readonly && leaf.href) { - void openUrl(leaf.href, '_blank'); - } - }} - className={`cursor-pointer select-auto px-1 py-0.5 text-fill-default underline`} - > - {children} - - ); -}); diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/href/index.ts b/frontend/appflowy_web_app/src/components/editor/components/leaf/href/index.ts deleted file mode 100644 index 758b3b39d3..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/href/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Href'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/index.ts b/frontend/appflowy_web_app/src/components/editor/components/leaf/index.ts deleted file mode 100644 index 711768ed28..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Leaf'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/Mention.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/Mention.tsx deleted file mode 100644 index eba846b9c1..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/Mention.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { EditorElementProps, MentionNode } from '@/components/editor/editor.type'; -import React, { forwardRef, memo } from 'react'; -import { useSelected } from 'slate-react'; - -import MentionLeaf from './MentionLeaf'; - -export const Mention = memo( - forwardRef>(({ node, children, ...attributes }, ref) => { - const selected = useSelected(); - - return ( - - {children} - - - ); - }) -); - -export default Mention; diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionDate.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionDate.tsx deleted file mode 100644 index fe1e844a4a..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionDate.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { renderDate } from '@/utils/time'; -import React, { useMemo } from 'react'; -import { ReactComponent as DateSvg } from '@/assets/date.svg'; -import { ReactComponent as ReminderSvg } from '@/assets/clock_alarm.svg'; - -function MentionDate({ date, reminder }: { date: string; reminder?: { id: string; option: string } }) { - const dateFormat = useMemo(() => { - return renderDate(date); - }, [date]); - - return ( - - {reminder ? : } - - {dateFormat} - - ); -} - -export default MentionDate; diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionLeaf.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionLeaf.tsx deleted file mode 100644 index 360dc2a678..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionLeaf.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Mention, MentionType } from '@/application/collab.type'; -import MentionDate from '@/components/editor/components/leaf/mention/MentionDate'; -import MentionPage from '@/components/editor/components/leaf/mention/MentionPage'; -import { useMemo } from 'react'; - -export function MentionLeaf({ mention }: { mention: Mention }) { - const { type, date, page_id, reminder_id, reminder_option } = mention; - - const reminder = useMemo(() => { - return reminder_id ? { id: reminder_id ?? '', option: reminder_option ?? '' } : undefined; - }, [reminder_id, reminder_option]); - - if (type === MentionType.PageRef && page_id) { - return ; - } - - if (type === MentionType.Date && date) { - return ; - } - - return null; -} - -export default MentionLeaf; diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx deleted file mode 100644 index e4df335a3b..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { layoutMap, ViewLayout, YjsFolderKey } from '@/application/collab.type'; -import { useId } from '@/components/_shared/context-provider/IdProvider'; -import { usePageInfo } from '@/components/_shared/page/usePageInfo'; -import React from 'react'; -import { useNavigate } from 'react-router-dom'; - -function MentionPage({ pageId }: { pageId: string }) { - const navigate = useNavigate(); - const { workspaceId } = useId(); - const { view, icon, name } = usePageInfo(pageId); - - return ( - { - const layout = parseInt(view?.get(YjsFolderKey.layout) ?? '0') as ViewLayout; - - navigate(`/workspace/${workspaceId}/${layoutMap[layout]}/${pageId}`); - }} - className={`mention-inline px-1 underline`} - contentEditable={false} - > - {icon} - - {name} - - ); -} - -export default MentionPage; diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/index.ts b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/index.ts deleted file mode 100644 index d3ee18034d..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Mention'; diff --git a/frontend/appflowy_web_app/src/components/editor/editor.scss b/frontend/appflowy_web_app/src/components/editor/editor.scss deleted file mode 100644 index 338e21de7b..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/editor.scss +++ /dev/null @@ -1,273 +0,0 @@ - -.block-element:not([data-block-type="table/cell"]) { - @apply my-[4px]; - -} - -.block-element .block-element:not([data-block-type="table/cell"]) { - @apply mb-0; - margin-left: 24px; -} - -.block-element[data-block-type="table/cell"] { - margin-left: 0 !important; - - .block-element { - margin-left: 0 !important; - height: 100%; - } -} - -.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-content { - 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(data-placeholder)); - } -} - -.text-placeholder { - &:after { - @apply left-0; - } -} - -.has-start-icon .text-placeholder { - &:after { - @apply left-[24px]; - } -} - -.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-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, .mention { - &.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; - } - } -} - -[data-block-type='heading'] { - .mention-inline .mention-content { - @apply ml-6; - } - - .level-1, .level-2 { - .mention-inline .mention-content { - @apply ml-8; - } - } - -} - -.mention-inline { - height: inherit; - overflow: hidden; - @apply inline-flex select-none gap-1 relative; - - .mention-icon { - @apply absolute top-1/2 transform -translate-y-1/2; - font-size: 1.25em; - } - - .mention-content { - @apply ml-5; - } - -} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/editor.type.ts b/frontend/appflowy_web_app/src/components/editor/editor.type.ts deleted file mode 100644 index fec9ffbcbf..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/editor.type.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { - BlockType, - CalloutBlockData, - CodeBlockData, - HeadingBlockData, - ImageBlockData, - MathEquationBlockData, - NumberedListBlockData, - TodoListBlockData, - ToggleListBlockData, - YjsEditorKey, - InlineBlockType, - Mention, - OutlineBlockData, - TableBlockData, - TableCellBlockData, - BlockId, - BlockData, -} from '@/application/collab.type'; -import { HTMLAttributes } from 'react'; -import { Element } from 'slate'; - -export interface BlockNode extends Element { - blockId: BlockId; - type: BlockType; - data?: BlockData; -} - -export interface TextNode extends Element { - type: YjsEditorKey.text; - textId: string; -} - -export interface PageNode extends BlockNode { - type: BlockType.Page; -} - -export interface ParagraphNode extends BlockNode { - type: BlockType.Paragraph; -} - -export interface HeadingNode extends BlockNode { - blockId: string; - type: BlockType.HeadingBlock; - data: HeadingBlockData; -} - -export interface DividerNode extends BlockNode { - type: BlockType.DividerBlock; - blockId: string; -} - -export interface TodoListNode extends BlockNode { - type: BlockType.TodoListBlock; - blockId: string; - data: TodoListBlockData; -} - -export interface ToggleListNode extends BlockNode { - type: BlockType.ToggleListBlock; - blockId: string; - data: ToggleListBlockData; -} - -export interface BulletedListNode extends BlockNode { - type: BlockType.BulletedListBlock; - blockId: string; -} - -export interface NumberedListNode extends BlockNode { - type: BlockType.NumberedListBlock; - blockId: string; - data: NumberedListBlockData; -} - -export interface QuoteNode extends BlockNode { - type: BlockType.QuoteBlock; - blockId: string; -} - -export interface CodeNode extends BlockNode { - type: BlockType.CodeBlock; - blockId: string; - data: CodeBlockData; -} - -export interface CalloutNode extends BlockNode { - type: BlockType.CalloutBlock; - blockId: string; - data: CalloutBlockData; -} - -export interface MathEquationNode extends BlockNode { - type: BlockType.EquationBlock; - blockId: string; - data: MathEquationBlockData; -} - -export interface ImageBlockNode extends BlockNode { - type: BlockType.ImageBlock; - blockId: string; - data: ImageBlockData; -} - -export interface OutlineNode extends BlockNode { - type: BlockType.OutlineBlock; - blockId: string; - data: OutlineBlockData; -} - -export interface TableNode extends BlockNode { - type: BlockType.TableBlock; - blockId: string; - data: TableBlockData; -} - -export interface TableCellNode extends BlockNode { - type: BlockType.TableCell; - blockId: string; - data: TableCellBlockData; -} - -export interface EditorElementProps extends HTMLAttributes { - node: T; -} - -type FormulaValue = string; - -export interface FormulaNode extends Element { - type: InlineBlockType.Formula; - data: FormulaValue; -} - -export interface MentionNode extends Element { - type: InlineBlockType.Mention; - data: Mention; -} diff --git a/frontend/appflowy_web_app/src/components/editor/index.ts b/frontend/appflowy_web_app/src/components/editor/index.ts deleted file mode 100644 index 59a50adf9b..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { lazy } from 'react'; - -export const Editor = lazy(() => import('./Editor')); diff --git a/frontend/appflowy_web_app/src/components/editor/plugins/index.ts b/frontend/appflowy_web_app/src/components/editor/plugins/index.ts deleted file mode 100644 index 4a87275330..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/plugins/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { withInlines } from '@/components/editor/plugins/withInlineElement'; -import { ReactEditor } from 'slate-react'; - -export function withPlugins(editor: ReactEditor) { - return withInlines(editor); -} diff --git a/frontend/appflowy_web_app/src/components/editor/plugins/withInlineElement.ts b/frontend/appflowy_web_app/src/components/editor/plugins/withInlineElement.ts deleted file mode 100644 index fbbd238f5f..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/plugins/withInlineElement.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { InlineBlockType } from '@/application/collab.type'; -import { ReactEditor } from 'slate-react'; -import { Element } from 'slate'; - -export function withInlines(editor: ReactEditor) { - const { isInline, isElementReadOnly, isSelectable, isVoid, markableVoid } = editor; - - const matchInlineType = (element: Element) => { - return Object.values(InlineBlockType).includes(element.type as InlineBlockType); - }; - - 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) => - Object.values(InlineBlockType).includes(element.type as InlineBlockType) || isElementReadOnly(element); - - editor.isSelectable = (element) => - !Object.values(InlineBlockType).includes(element.type as InlineBlockType) && isSelectable(element); - - return editor; -} diff --git a/frontend/appflowy_web_app/src/components/editor/utils/list.ts b/frontend/appflowy_web_app/src/components/editor/utils/list.ts deleted file mode 100644 index b5e34184b7..0000000000 --- a/frontend/appflowy_web_app/src/components/editor/utils/list.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { BlockType } from '@/application/collab.type'; -import { Element, NodeEntry, Path } from 'slate'; -import { ReactEditor } from 'slate-react'; - -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; -} - -export function getListLevel(editor: ReactEditor, type: BlockType, 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; -} diff --git a/frontend/appflowy_web_app/src/components/error/Error.hooks.ts b/frontend/appflowy_web_app/src/components/error/Error.hooks.ts deleted file mode 100644 index a9da4ed829..0000000000 --- a/frontend/appflowy_web_app/src/components/error/Error.hooks.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useAppDispatch, useAppSelector } from '@/stores/store'; -import { useCallback, useEffect, useState } from 'react'; -import {errorActions} from "@/stores/error/slice"; - -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_web_app/src/components/error/ErrorHandlerPage.tsx b/frontend/appflowy_web_app/src/components/error/ErrorHandlerPage.tsx deleted file mode 100644 index 1bb15f2ca3..0000000000 --- a/frontend/appflowy_web_app/src/components/error/ErrorHandlerPage.tsx +++ /dev/null @@ -1,8 +0,0 @@ -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_web_app/src/components/error/ErrorModal.tsx b/frontend/appflowy_web_app/src/components/error/ErrorModal.tsx deleted file mode 100644 index c4382c8182..0000000000 --- a/frontend/appflowy_web_app/src/components/error/ErrorModal.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { ReactComponent as InformationSvg } from '@/assets/information.svg'; -import { ReactComponent as CloseSvg } from '@/assets/close.svg'; -import { Button } from "@mui/material"; - -export const ErrorModal = ({ message, onClose }: { message: string; onClose: () => void }) => { - return ( -
-
- -
- -
-

Oops.. something went wrong

-

{message}

- - -
-
- ); -}; diff --git a/frontend/appflowy_web_app/src/components/folder/Folder.tsx b/frontend/appflowy_web_app/src/components/folder/Folder.tsx deleted file mode 100644 index f3b9641723..0000000000 --- a/frontend/appflowy_web_app/src/components/folder/Folder.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useViewsIdSelector } from '@/application/folder-yjs'; -import ViewItem from '@/components/folder/ViewItem'; -import React from 'react'; - -export function Folder() { - const { viewsId } = useViewsIdSelector(); - - return ( -
- {viewsId.map((viewId) => { - return ; - })} -
- ); -} - -export default Folder; diff --git a/frontend/appflowy_web_app/src/components/folder/ViewItem.tsx b/frontend/appflowy_web_app/src/components/folder/ViewItem.tsx deleted file mode 100644 index 7465857b17..0000000000 --- a/frontend/appflowy_web_app/src/components/folder/ViewItem.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { layoutMap, ViewLayout, YjsFolderKey } from '@/application/collab.type'; -import React from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import Page from 'src/components/_shared/page/Page'; - -function ViewItem({ id }: { id: string }) { - const navigate = useNavigate(); - const { pathname } = useLocation(); - - return ( -
- { - const layout = parseInt(view?.get(YjsFolderKey.layout) ?? '0') as ViewLayout; - - navigate(`${pathname}/${layoutMap[layout]}/${id}`); - }} - id={id} - /> -
- ); -} - -export default ViewItem; diff --git a/frontend/appflowy_web_app/src/components/folder/index.ts b/frontend/appflowy_web_app/src/components/folder/index.ts deleted file mode 100644 index 569707cd4f..0000000000 --- a/frontend/appflowy_web_app/src/components/folder/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Folder'; diff --git a/frontend/appflowy_web_app/src/components/layout/Header.tsx b/frontend/appflowy_web_app/src/components/layout/Header.tsx deleted file mode 100644 index 5c874db35c..0000000000 --- a/frontend/appflowy_web_app/src/components/layout/Header.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { downloadPage, openAppFlowySchema, openUrl } from '@/utils/url'; -import { Button } from '@mui/material'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router-dom'; -import Page from 'src/components/_shared/page/Page'; -import { ReactComponent as Logo } from '@/assets/logo.svg'; -import Popover, { PopoverOrigin } from '@mui/material/Popover'; - -const popoverOrigin: { - anchorOrigin: PopoverOrigin; - transformOrigin: PopoverOrigin; -} = { - anchorOrigin: { - vertical: 'bottom', - horizontal: 'right', - }, - transformOrigin: { - vertical: -10, - horizontal: 'right', - }, -}; - -function Header() { - const { objectId } = useParams(); - const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = React.useState(null); - - return ( -
-
-
{objectId && }
- - -
- setAnchorEl(null)}> -
- -
-
- {t('signIn.or')} -
-
- -
- -
- ); -} - -export default Header; diff --git a/frontend/appflowy_web_app/src/components/layout/Layout.tsx b/frontend/appflowy_web_app/src/components/layout/Layout.tsx deleted file mode 100644 index c10048aa82..0000000000 --- a/frontend/appflowy_web_app/src/components/layout/Layout.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { YFolder, YjsEditorKey } from '@/application/collab.type'; -import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider'; -import { AFConfigContext } from '@/components/app/AppConfig'; -import Header from '@/components/layout/Header'; -import { AFScroller } from '@/components/_shared/scroller'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; -import './layout.scss'; - -function Layout({ children }: { children: React.ReactNode }) { - const { workspaceId } = useParams(); - const folderService = useContext(AFConfigContext)?.service?.folderService; - const [folder, setFolder] = useState(null); - const getFolder = useCallback( - async (workspaceId: string) => { - const folder = (await folderService?.openWorkspace(workspaceId)) - ?.getMap(YjsEditorKey.data_section) - .get(YjsEditorKey.folder); - - if (!folder) return; - - setFolder(folder); - }, - [folderService] - ); - - useEffect(() => { - if (!workspaceId) return; - - void getFolder(workspaceId); - }, [getFolder, workspaceId]); - return ( - -
- - {children} - - - ); -} - -export default Layout; diff --git a/frontend/appflowy_web_app/src/components/layout/layout.scss b/frontend/appflowy_web_app/src/components/layout/layout.scss deleted file mode 100644 index 4133489130..0000000000 --- a/frontend/appflowy_web_app/src/components/layout/layout.scss +++ /dev/null @@ -1,86 +0,0 @@ - -.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 { - @apply flex w-fit cursor-pointer rounded-lg py-2 text-6xl; - font-family: "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols; - line-height: 1em; - white-space: nowrap; - //&: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_web_app/src/components/tauri/SignInAsAnonymous.tsx b/frontend/appflowy_web_app/src/components/tauri/SignInAsAnonymous.tsx deleted file mode 100644 index ff241f728b..0000000000 --- a/frontend/appflowy_web_app/src/components/tauri/SignInAsAnonymous.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { Button } from '@mui/material'; -import { useAuth } from '@/components/auth/auth.hooks'; -import { useTranslation } from 'react-i18next'; - -function SignInAsAnonymous() { - const { signInAsAnonymous } = useAuth(); - const { t } = useTranslation(); - - return ( - <> - -
-
- {t('signIn.or')} -
-
- - ); -} - -export default SignInAsAnonymous; diff --git a/frontend/appflowy_web_app/src/components/tauri/TauriAuth.tsx b/frontend/appflowy_web_app/src/components/tauri/TauriAuth.tsx deleted file mode 100644 index 5ee605463e..0000000000 --- a/frontend/appflowy_web_app/src/components/tauri/TauriAuth.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect } from 'react'; -import { useDeepLink } from '@/components/tauri/tauri.hooks'; - -function TauriAuth() { - const { - onDeepLink, - } = useDeepLink(); - - useEffect(() => { - void onDeepLink(); - }, [onDeepLink]); - - return null; -} - -export default TauriAuth; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/tauri/tauri.hooks.ts b/frontend/appflowy_web_app/src/components/tauri/tauri.hooks.ts deleted file mode 100644 index f95c2ca696..0000000000 --- a/frontend/appflowy_web_app/src/components/tauri/tauri.hooks.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useCallback } from 'react'; -import { notify } from '@/components/_shared/notify'; -import { useAuth } from '@/components/auth/auth.hooks'; - -export function useDeepLink() { - const { - signInWithOAuth, - } = useAuth(); - const onDeepLink = useCallback(async () => { - 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'); - // update login state to error - return; - } - - await signInWithOAuth(payload); - }); - }, [signInWithOAuth]); - - return { - onDeepLink, - }; - -} - -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_web_app/src/i18n/config.ts b/frontend/appflowy_web_app/src/i18n/config.ts deleted file mode 100644 index b2a116e0b6..0000000000 --- a/frontend/appflowy_web_app/src/i18n/config.ts +++ /dev/null @@ -1,15 +0,0 @@ -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(`../@types/translations/${language}.json`))) - .use(LanguageDetector) - .use(initReactI18next) - .init({ - lng: 'en', - defaultNS: 'translation', - debug: false, - fallbackLng: 'en', - }); diff --git a/frontend/appflowy_web_app/src/main.tsx b/frontend/appflowy_web_app/src/main.tsx deleted file mode 100644 index d964a40e3d..0000000000 --- a/frontend/appflowy_web_app/src/main.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import ReactDOM from 'react-dom/client'; -import App from 'src/components/app/App'; - -ReactDOM.createRoot(document.getElementById('root')!).render(); diff --git a/frontend/appflowy_web_app/src/pages/DocumentPage.tsx b/frontend/appflowy_web_app/src/pages/DocumentPage.tsx deleted file mode 100644 index 0a9a359afc..0000000000 --- a/frontend/appflowy_web_app/src/pages/DocumentPage.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { Document } from '@/components/document'; -import React from 'react'; - -function DocumentPage() { - return ; -} - -export default DocumentPage; diff --git a/frontend/appflowy_web_app/src/pages/FolderPage.tsx b/frontend/appflowy_web_app/src/pages/FolderPage.tsx deleted file mode 100644 index 6381fe4ace..0000000000 --- a/frontend/appflowy_web_app/src/pages/FolderPage.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import { Folder } from 'src/components/folder'; - -function FolderPage() { - return ; -} - -export default FolderPage; diff --git a/frontend/appflowy_web_app/src/pages/LoginPage.tsx b/frontend/appflowy_web_app/src/pages/LoginPage.tsx deleted file mode 100644 index 5d2dcceee4..0000000000 --- a/frontend/appflowy_web_app/src/pages/LoginPage.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, { useEffect } from 'react'; -import Welcome from '@/components/auth/Welcome'; -import { useNavigate } from 'react-router-dom'; -import { useAppSelector } from '@/stores/store'; - -function LoginPage() { - const currentUser = useAppSelector((state) => state.currentUser); - const navigate = useNavigate(); - - useEffect(() => { - if (currentUser.isAuthenticated) { - const redirect = new URLSearchParams(window.location.search).get('redirect'); - const workspaceId = currentUser.user?.workspaceId; - - if (!redirect || redirect === '/') { - return navigate(`/workspace/${workspaceId}`); - } - - navigate(`${redirect}`); - } - }, [currentUser, navigate]); - return ; -} - -export default LoginPage; diff --git a/frontend/appflowy_web_app/src/pages/ProductPage.tsx b/frontend/appflowy_web_app/src/pages/ProductPage.tsx deleted file mode 100644 index 8080e339ef..0000000000 --- a/frontend/appflowy_web_app/src/pages/ProductPage.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { CollabType } from '@/application/collab.type'; -import { IdProvider } from '@/components/_shared/context-provider/IdProvider'; -import React, { useMemo } from 'react'; -import { useParams } from 'react-router-dom'; -import DocumentPage from '@/pages/DocumentPage'; - -enum URL_COLLAB_TYPE { - DOCUMENT = 'document', - DATABASE = 'database', -} - -const collabTypeMap: Record = { - [URL_COLLAB_TYPE.DOCUMENT]: CollabType.Document, - [URL_COLLAB_TYPE.DATABASE]: CollabType.Database, -}; - -function ProductPage() { - const { workspaceId, collabType, objectId } = useParams(); - const PageComponent = useMemo(() => { - switch (collabType) { - case URL_COLLAB_TYPE.DOCUMENT: - return DocumentPage; - default: - return null; - } - }, [collabType]); - - if (!workspaceId || !collabType || !objectId) return null; - - return ( - - {PageComponent && } - - ); -} - -export default ProductPage; diff --git a/frontend/appflowy_web_app/src/slate-editor.d.ts b/frontend/appflowy_web_app/src/slate-editor.d.ts deleted file mode 100644 index 2ebcf7a88d..0000000000 --- a/frontend/appflowy_web_app/src/slate-editor.d.ts +++ /dev/null @@ -1,45 +0,0 @@ -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; - font_family?: string; - 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_web_app/src/stores/app/slice.ts b/frontend/appflowy_web_app/src/stores/app/slice.ts deleted file mode 100644 index dee62a6fc7..0000000000 --- a/frontend/appflowy_web_app/src/stores/app/slice.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { AFServiceConfig } from '@/application/services/services.type'; -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; - -const defaultConfig: AFServiceConfig = { - cloudConfig: { - baseURL: import.meta.env.AF_BASE_URL - ? import.meta.env.AF_BASE_URL - : import.meta.env.DEV - ? 'https://test.appflowy.cloud' - : 'https://beta.appflowy.cloud', - gotrueURL: import.meta.env.AF_GOTRUE_URL - ? import.meta.env.AF_GOTRUE_URL - : import.meta.env.DEV - ? 'https://test.appflowy.cloud/gotrue' - : 'https://beta.appflowy.cloud/gotrue', - wsURL: import.meta.env.AF_WS_URL - ? import.meta.env.AF_WS_URL - : import.meta.env.DEV - ? 'wss://test.appflowy.cloud/ws/v1' - : 'wss://beta.appflowy.cloud/ws/v1', - }, -}; - -export interface AppState { - appConfig: AFServiceConfig; -} - -const initialState: AppState = { - appConfig: defaultConfig, -}; - -export const slice = createSlice({ - name: 'app', - initialState, - reducers: { - setAppConfig: (state, action: PayloadAction) => { - state.appConfig = action.payload; - }, - }, -}); - -export const { setAppConfig } = slice.actions; diff --git a/frontend/appflowy_web_app/src/stores/currentUser/slice.ts b/frontend/appflowy_web_app/src/stores/currentUser/slice.ts deleted file mode 100644 index ecd40a433e..0000000000 --- a/frontend/appflowy_web_app/src/stores/currentUser/slice.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { UserProfile, UserSetting } from '@/application/user.type'; - -export enum LoginState { - IDLE = 'idle', - LOADING = 'loading', - SUCCESS = 'success', - ERROR = 'error', -} - -export interface InitialState { - user?: UserProfile; - isAuthenticated: boolean; - userSetting?: UserSetting; - loginState?: LoginState; -} - -const initialState: InitialState = { - isAuthenticated: false, -}; - -export const currentUserSlice = createSlice({ - name: 'currentUser', - initialState: initialState, - reducers: { - updateUser: (state, action: PayloadAction) => { - state.user = action.payload; - state.isAuthenticated = true; - }, - logout: (state) => { - state.user = undefined; - state.isAuthenticated = false; - }, - setUserSetting: (state, action: PayloadAction) => { - state.userSetting = action.payload; - }, - loginStart: (state) => { - state.loginState = LoginState.LOADING; - }, - loginSuccess: (state) => { - state.loginState = LoginState.SUCCESS; - }, - loginError: (state) => { - state.loginState = LoginState.ERROR; - }, - resetLoginState: (state) => { - state.loginState = LoginState.IDLE; - }, - - }, -}); - -export const currentUserActions = currentUserSlice.actions; diff --git a/frontend/appflowy_web_app/src/stores/error/slice.ts b/frontend/appflowy_web_app/src/stores/error/slice.ts deleted file mode 100644 index 9b47df7777..0000000000 --- a/frontend/appflowy_web_app/src/stores/error/slice.ts +++ /dev/null @@ -1,32 +0,0 @@ -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_web_app/src/stores/store.ts b/frontend/appflowy_web_app/src/stores/store.ts deleted file mode 100644 index b75363e911..0000000000 --- a/frontend/appflowy_web_app/src/stores/store.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; -import { - configureStore, - createListenerMiddleware, - TypedStartListening, - TypedAddListener, - ListenerEffectAPI, - addListener, -} from '@reduxjs/toolkit'; -import { errorSlice } from '@/stores/error/slice'; -import { currentUserSlice } from '@/stores/currentUser/slice'; -import { slice as appSlice } from '@/stores/app/slice'; - -const listenerMiddlewareInstance = createListenerMiddleware({ - onError: () => console.error, -}); - -const store = configureStore({ - reducer: { - [appSlice.name]: appSlice.reducer, - [errorSlice.name]: errorSlice.reducer, - [currentUserSlice.name]: currentUserSlice.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_web_app/src/styles/tailwind.css b/frontend/appflowy_web_app/src/styles/tailwind.css deleted file mode 100644 index b5c61c9567..0000000000 --- a/frontend/appflowy_web_app/src/styles/tailwind.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/frontend/appflowy_web_app/src/styles/template.css b/frontend/appflowy_web_app/src/styles/template.css deleted file mode 100644 index b255483f4e..0000000000 --- a/frontend/appflowy_web_app/src/styles/template.css +++ /dev/null @@ -1,65 +0,0 @@ -@import "./variables/light.variables.css"; -@import "./variables/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); -} - -* { - 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; -} - - -::-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_web_app/src/styles/variables/dark.variables.css b/frontend/appflowy_web_app/src/styles/variables/dark.variables.css deleted file mode 100644 index b82d97e5be..0000000000 --- a/frontend/appflowy_web_app/src/styles/variables/dark.variables.css +++ /dev/null @@ -1,121 +0,0 @@ -/** -* Do not edit directly -* Generated on Mon, 25 Mar 2024 05:19:13 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_web_app/src/styles/variables/light.variables.css b/frontend/appflowy_web_app/src/styles/variables/light.variables.css deleted file mode 100644 index 0477655f66..0000000000 --- a/frontend/appflowy_web_app/src/styles/variables/light.variables.css +++ /dev/null @@ -1,124 +0,0 @@ -/** -* Do not edit directly -* Generated on Mon, 25 Mar 2024 05:19:13 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_web_app/src/utils/color.ts b/frontend/appflowy_web_app/src/utils/color.ts deleted file mode 100644 index 025c8c45ed..0000000000 --- a/frontend/appflowy_web_app/src/utils/color.ts +++ /dev/null @@ -1,50 +0,0 @@ -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_web_app/src/utils/font.ts b/frontend/appflowy_web_app/src/utils/font.ts deleted file mode 100644 index 765ebf5f00..0000000000 --- a/frontend/appflowy_web_app/src/utils/font.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function getFontFamily(attribute: string) { - return attribute.split('_')[0]; -} diff --git a/frontend/appflowy_web_app/src/utils/log.ts b/frontend/appflowy_web_app/src/utils/log.ts deleted file mode 100644 index daccf21d0a..0000000000 --- a/frontend/appflowy_web_app/src/utils/log.ts +++ /dev/null @@ -1,20 +0,0 @@ -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_web_app/src/utils/platform.ts b/frontend/appflowy_web_app/src/utils/platform.ts deleted file mode 100644 index 285e38bc73..0000000000 --- a/frontend/appflowy_web_app/src/utils/platform.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function getPlatform() { - return { - isTauri: !!import.meta.env.TAURI_PLATFORM, - }; -} diff --git a/frontend/appflowy_web_app/src/utils/time.ts b/frontend/appflowy_web_app/src/utils/time.ts deleted file mode 100644 index 3b6920fb34..0000000000 --- a/frontend/appflowy_web_app/src/utils/time.ts +++ /dev/null @@ -1,10 +0,0 @@ -import dayjs from 'dayjs'; - -export enum DateFormat { - Date = 'MMM D, YYYY', - DateTime = 'MMM D, YYYY h:mm A', -} - -export function renderDate(date: string, format: DateFormat = DateFormat.Date): string { - return dayjs(date).format(format); -} diff --git a/frontend/appflowy_web_app/src/utils/url.ts b/frontend/appflowy_web_app/src/utils/url.ts deleted file mode 100644 index 8d67f3583f..0000000000 --- a/frontend/appflowy_web_app/src/utils/url.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getPlatform } from '@/utils/platform'; -import validator from 'validator'; - -export const downloadPage = 'https://appflowy.io/download'; - -export const openAppFlowySchema = 'appflowy-flutter://'; - -export function isValidUrl(input: string) { - return validator.isURL(input, { require_protocol: true, require_host: false }); -} - -// Process the URL to make sure it's a valid URL -// If it's not a valid URL(eg: 'appflowy.io' or '192.168.1.2'), we'll add 'https://' to the URL -export function processUrl(input: string) { - let processedUrl = input; - - if (isValidUrl(input)) { - return processedUrl; - } - - const domain = input.split('/')[0]; - - if (validator.isIP(domain) || validator.isFQDN(domain)) { - processedUrl = `https://${input}`; - if (isValidUrl(processedUrl)) { - return processedUrl; - } - } - - return; -} - -export async function openUrl(url: string, target: string = '_current') { - const platform = getPlatform(); - - const newUrl = processUrl(url); - - if (!newUrl) return; - if (platform.isTauri) { - const { open } = await import('@tauri-apps/api/shell'); - - await open(newUrl); - return; - } - - window.open(newUrl, target); -} diff --git a/frontend/appflowy_web_app/src/vite-env.d.ts b/frontend/appflowy_web_app/src/vite-env.d.ts deleted file mode 100644 index 5748ee1aed..0000000000 --- a/frontend/appflowy_web_app/src/vite-env.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -/// -/// -/// -/// -/// -interface Window { - refresh_token: (token: string) => void; - invalid_token: () => void; - WebFont?: { - load: (options: { google: { families: string[] } }) => void; - }; -} diff --git a/frontend/appflowy_web_app/style-dictionary/config.cjs b/frontend/appflowy_web_app/style-dictionary/config.cjs deleted file mode 100644 index 10d7084060..0000000000 --- a/frontend/appflowy_web_app/style-dictionary/config.cjs +++ /dev/null @@ -1,114 +0,0 @@ -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_web_app/style-dictionary/tailwind/box-shadow.cjs b/frontend/appflowy_web_app/style-dictionary/tailwind/box-shadow.cjs deleted file mode 100644 index 00647333e2..0000000000 --- a/frontend/appflowy_web_app/style-dictionary/tailwind/box-shadow.cjs +++ /dev/null @@ -1,9 +0,0 @@ -/** -* Do not edit directly -* Generated on Mon, 25 Mar 2024 05:19:13 GMT -* Generated from $pnpm css:variables -*/ - -module.exports = { - "md": "var(--shadow)" -} \ No newline at end of file diff --git a/frontend/appflowy_web_app/style-dictionary/tailwind/colors.cjs b/frontend/appflowy_web_app/style-dictionary/tailwind/colors.cjs deleted file mode 100644 index 798741f06c..0000000000 --- a/frontend/appflowy_web_app/style-dictionary/tailwind/colors.cjs +++ /dev/null @@ -1,75 +0,0 @@ -/** -* Do not edit directly -* Generated on Mon, 25 Mar 2024 05:19:13 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_web_app/style-dictionary/tokens/base.json b/frontend/appflowy_web_app/style-dictionary/tokens/base.json deleted file mode 100644 index 4e31b0523d..0000000000 --- a/frontend/appflowy_web_app/style-dictionary/tokens/base.json +++ /dev/null @@ -1,290 +0,0 @@ -{ - "Base": { - "Light": { - "neutral": { - "50": { - "value": "#f9fafd", - "type": "color" - }, - "100": { - "value": "#edeef2", - "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_web_app/style-dictionary/tokens/dark.json b/frontend/appflowy_web_app/style-dictionary/tokens/dark.json deleted file mode 100644 index c67af7c9ec..0000000000 --- a/frontend/appflowy_web_app/style-dictionary/tokens/dark.json +++ /dev/null @@ -1,221 +0,0 @@ -{ - "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_web_app/style-dictionary/tokens/light.json b/frontend/appflowy_web_app/style-dictionary/tokens/light.json deleted file mode 100644 index 173f3d35aa..0000000000 --- a/frontend/appflowy_web_app/style-dictionary/tokens/light.json +++ /dev/null @@ -1,233 +0,0 @@ -{ - "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_web_app/tailwind.config.cjs b/frontend/appflowy_web_app/tailwind.config.cjs deleted file mode 100644 index 06390d938f..0000000000 --- a/frontend/appflowy_web_app/tailwind.config.cjs +++ /dev/null @@ -1,20 +0,0 @@ -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_web_app/test.env b/frontend/appflowy_web_app/test.env deleted file mode 100644 index 89e2936fab..0000000000 --- a/frontend/appflowy_web_app/test.env +++ /dev/null @@ -1,3 +0,0 @@ -AF_WS_URL=wss://test.appflowy.cloud/ws/v1 -AF_BASE_URL=https://test.appflowy.cloud -AF_GOTRUE_URL=https://test.appflowy.cloud/gotrue \ No newline at end of file diff --git a/frontend/appflowy_web_app/tsconfig.json b/frontend/appflowy_web_app/tsconfig.json deleted file mode 100644 index de30c24901..0000000000 --- a/frontend/appflowy_web_app/tsconfig.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "lib": [ - "DOM", - "DOM.Iterable", - "ESNext" - ], - "allowJs": false, - "skipLibCheck": true, - "esModuleInterop": false, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "preserveValueImports": false, - "noEmit": true, - "jsx": "react-jsx", - "types": [ - "cypress", - "node", - "jest" - ], - "baseUrl": "./", - "paths": { - "@/*": [ - "src/*" - ], - "src/*": [ - "src/*" - ], - "$client-services": [ - "src/application/services/js-services" - ] - } - }, - "include": [ - "src", - "vite.config.ts", - "cypress.config.ts", - "cypress" - ], - "exclude": [ - "node_modules" - ], - "references": [ - { - "path": "./tsconfig.node.json" - } - ] -} diff --git a/frontend/appflowy_web_app/tsconfig.node.json b/frontend/appflowy_web_app/tsconfig.node.json deleted file mode 100644 index b8afcc8fa2..0000000000 --- a/frontend/appflowy_web_app/tsconfig.node.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "module": "ESNext", - "moduleResolution": "Node", - "allowSyntheticDefaultImports": true - }, - "include": [ - "vite.config.ts" - ] -} diff --git a/frontend/appflowy_web_app/tsconfig.web.json b/frontend/appflowy_web_app/tsconfig.web.json deleted file mode 100644 index 5e0cdd176d..0000000000 --- a/frontend/appflowy_web_app/tsconfig.web.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": [ - "node_modules", - "src/application/services/tauri-services", - "**/*.cy.tsx" - ], - "references": [ - { - "path": "./tsconfig.node.json" - } - ] -} diff --git a/frontend/appflowy_web_app/vite.config.ts b/frontend/appflowy_web_app/vite.config.ts deleted file mode 100644 index b2621799ed..0000000000 --- a/frontend/appflowy_web_app/vite.config.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import svgr from 'vite-plugin-svgr'; -import wasm from 'vite-plugin-wasm'; -import { visualizer } from 'rollup-plugin-visualizer'; -import usePluginImport from 'vite-plugin-importer'; -import { totalBundleSize } from 'vite-plugin-total-bundle-size'; - -const isDev = process.env.NODE_ENV === 'development'; -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [ - react(), - wasm(), - 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', - }, - }, - }), - usePluginImport({ - libraryName: '@mui/icons-material', - libraryDirectory: '', - camel2DashComponentName: false, - style: false, - }), - process.env.ANALYZE_MODE - ? visualizer({ - emitFile: true, - }) - : undefined, - process.env.ANALYZE_MODE - ? totalBundleSize({ - fileNameRegex: /\.(js|css)$/, - calculateGzip: false, - }) - : undefined, - ], - // 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: !!process.env.TAURI_PLATFORM ? 5173 : process.env.PORT ? parseInt(process.env.PORT) : 3000, - strictPort: true, - watch: { - ignored: ['**/__tests__/**'], - }, - cors: false, - }, - envPrefix: ['AF', 'TAURI_'], - build: !!process.env.TAURI_PLATFORM - ? { - // 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, - } - : { - target: `esnext`, - terserOptions: !isDev - ? { - compress: { - keep_infinity: true, - drop_console: true, - drop_debugger: true, - }, - } - : {}, - reportCompressedSize: true, - sourcemap: isDev, - rollupOptions: !isDev - ? { - output: { - chunkFileNames: 'static/js/[name]-[hash].js', - entryFileNames: 'static/js/[name]-[hash].js', - assetFileNames: 'static/[ext]/[name]-[hash].[ext]', - manualChunks(id) { - if ( - id.includes('/react@') || - id.includes('/react-dom@') || - id.includes('/react-is@') || - id.includes('/yjs@') || - id.includes('/y-indexeddb@') || - id.includes('/dexie@') || - id.includes('/redux') - ) { - return 'common'; - } - }, - }, - } - : {}, - }, - resolve: { - alias: [ - { find: 'src/', replacement: `${__dirname}/src/` }, - { find: '@/', replacement: `${__dirname}/src/` }, - { - find: '$client-services', - replacement: !!process.env.TAURI_PLATFORM - ? `${__dirname}/src/application/services/tauri-services` - : `${__dirname}/src/application/services/js-services`, - }, - ], - }, - - optimizeDeps: { - include: ['@mui/material/Tooltip'], - }, -}); diff --git a/frontend/resources/flowy_icons/16x/add_cover.svg b/frontend/resources/flowy_icons/16x/add_cover.svg new file mode 100644 index 0000000000..ac83855416 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/add_cover.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/add_icon.svg b/frontend/resources/flowy_icons/16x/add_icon.svg new file mode 100644 index 0000000000..e49b54ec14 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/add_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/add_less_padding.svg b/frontend/resources/flowy_icons/16x/add_less_padding.svg new file mode 100644 index 0000000000..4c56779b38 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/add_less_padding.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/add_thin.svg b/frontend/resources/flowy_icons/16x/add_thin.svg new file mode 100644 index 0000000000..56520cf3cf --- /dev/null +++ b/frontend/resources/flowy_icons/16x/add_thin.svg @@ -0,0 +1 @@ +Plus Light Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/add_workspace.svg b/frontend/resources/flowy_icons/16x/add_workspace.svg new file mode 100644 index 0000000000..83dabfe0a1 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/add_workspace.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_add_to_page.svg b/frontend/resources/flowy_icons/16x/ai_add_to_page.svg new file mode 100644 index 0000000000..8895d9953d --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_add_to_page.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_at.svg b/frontend/resources/flowy_icons/16x/ai_at.svg new file mode 100644 index 0000000000..72128f6c9c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_at.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_attachment.svg b/frontend/resources/flowy_icons/16x/ai_attachment.svg new file mode 100644 index 0000000000..d998bbd621 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_attachment.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_chat_logo.svg b/frontend/resources/flowy_icons/16x/ai_chat_logo.svg new file mode 100644 index 0000000000..a719222956 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_chat_logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/ai_chat_outlined.svg b/frontend/resources/flowy_icons/16x/ai_chat_outlined.svg new file mode 100644 index 0000000000..3be82d4bf2 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_chat_outlined.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_check_filled.svg b/frontend/resources/flowy_icons/16x/ai_check_filled.svg new file mode 100644 index 0000000000..79efe0bd0a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_check_filled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_close_filled.svg b/frontend/resources/flowy_icons/16x/ai_close_filled.svg new file mode 100644 index 0000000000..9bf64c2bd8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_close_filled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_dislike.svg b/frontend/resources/flowy_icons/16x/ai_dislike.svg new file mode 100644 index 0000000000..5754775e88 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_dislike.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_expand.svg b/frontend/resources/flowy_icons/16x/ai_expand.svg new file mode 100644 index 0000000000..83df4e9234 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_expand.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_fix_spelling_grammar.svg b/frontend/resources/flowy_icons/16x/ai_fix_spelling_grammar.svg new file mode 100644 index 0000000000..2f7df6c78a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_fix_spelling_grammar.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_image.svg b/frontend/resources/flowy_icons/16x/ai_image.svg new file mode 100644 index 0000000000..6f33715496 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_image.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_improve_writing.svg b/frontend/resources/flowy_icons/16x/ai_improve_writing.svg new file mode 100644 index 0000000000..9cff9e9875 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_improve_writing.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_indicator.svg b/frontend/resources/flowy_icons/16x/ai_indicator.svg new file mode 100644 index 0000000000..390571cb8c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_indicator.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_keyword.svg b/frontend/resources/flowy_icons/16x/ai_keyword.svg new file mode 100644 index 0000000000..60e87b4913 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_keyword.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_like.svg b/frontend/resources/flowy_icons/16x/ai_like.svg new file mode 100644 index 0000000000..b7a92725aa --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_like.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_list.svg b/frontend/resources/flowy_icons/16x/ai_list.svg new file mode 100644 index 0000000000..ee31345b13 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_make_longer.svg b/frontend/resources/flowy_icons/16x/ai_make_longer.svg new file mode 100644 index 0000000000..9f61441f0f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_make_longer.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_make_shorter.svg b/frontend/resources/flowy_icons/16x/ai_make_shorter.svg new file mode 100644 index 0000000000..5f07c58fcc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_make_shorter.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_number_list.svg b/frontend/resources/flowy_icons/16x/ai_number_list.svg new file mode 100644 index 0000000000..c226e339f7 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_number_list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_page.svg b/frontend/resources/flowy_icons/16x/ai_page.svg new file mode 100644 index 0000000000..09598791b9 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_paragraph.svg b/frontend/resources/flowy_icons/16x/ai_paragraph.svg new file mode 100644 index 0000000000..fc184ace91 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_paragraph.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_reset.svg b/frontend/resources/flowy_icons/16x/ai_reset.svg new file mode 100644 index 0000000000..a589eda1fe --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_reset.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_retry.svg b/frontend/resources/flowy_icons/16x/ai_retry.svg new file mode 100644 index 0000000000..cfc50452af --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_retry.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_retry_filled.svg b/frontend/resources/flowy_icons/16x/ai_retry_filled.svg new file mode 100644 index 0000000000..37fcdaea43 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_retry_filled.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_retry_font.svg b/frontend/resources/flowy_icons/16x/ai_retry_font.svg new file mode 100644 index 0000000000..1c622f4040 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_retry_font.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_save.svg b/frontend/resources/flowy_icons/16x/ai_save.svg new file mode 100644 index 0000000000..ad228863b2 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_save.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_scroll_to_bottom.svg b/frontend/resources/flowy_icons/16x/ai_scroll_to_bottom.svg new file mode 100644 index 0000000000..e91acbda42 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_scroll_to_bottom.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_send_filled.svg b/frontend/resources/flowy_icons/16x/ai_send_filled.svg new file mode 100644 index 0000000000..5f86dd3528 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_send_filled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_source_drop_down.svg b/frontend/resources/flowy_icons/16x/ai_source_drop_down.svg new file mode 100644 index 0000000000..5fe1dc47eb --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_source_drop_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_sparks.svg b/frontend/resources/flowy_icons/16x/ai_sparks.svg new file mode 100644 index 0000000000..a419454f8e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_sparks.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_star.svg b/frontend/resources/flowy_icons/16x/ai_star.svg new file mode 100644 index 0000000000..336e160f6f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_star.svg @@ -0,0 +1 @@ +Ai Chip Spark Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/ai_stop_filled.svg b/frontend/resources/flowy_icons/16x/ai_stop_filled.svg new file mode 100644 index 0000000000..33c0fe090c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_stop_filled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_summarize.svg b/frontend/resources/flowy_icons/16x/ai_summarize.svg new file mode 100644 index 0000000000..761dae9dc0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_summarize.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_summary.svg b/frontend/resources/flowy_icons/16x/ai_summary.svg index 2455874bc5..f3bfa1d9f3 100644 --- a/frontend/resources/flowy_icons/16x/ai_summary.svg +++ b/frontend/resources/flowy_icons/16x/ai_summary.svg @@ -1,3 +1,7 @@ - + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_table.svg b/frontend/resources/flowy_icons/16x/ai_table.svg new file mode 100644 index 0000000000..993e1d5764 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_table.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_tag.svg b/frontend/resources/flowy_icons/16x/ai_tag.svg new file mode 100644 index 0000000000..317ad8fb2f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_tag.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_text.svg b/frontend/resources/flowy_icons/16x/ai_text.svg new file mode 100644 index 0000000000..0198b267b8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_text.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_text_auto.svg b/frontend/resources/flowy_icons/16x/ai_text_auto.svg new file mode 100644 index 0000000000..b09fd45305 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_text_auto.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_text_image.svg b/frontend/resources/flowy_icons/16x/ai_text_image.svg new file mode 100644 index 0000000000..320aad6bab --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_text_image.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_translate.svg b/frontend/resources/flowy_icons/16x/ai_translate.svg new file mode 100644 index 0000000000..a32d7cd908 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_translate.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_try_again.svg b/frontend/resources/flowy_icons/16x/ai_try_again.svg new file mode 100644 index 0000000000..c3f51de34d --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_try_again.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/app_logo.svg b/frontend/resources/flowy_icons/16x/app_logo.svg new file mode 100644 index 0000000000..96af87f8ff --- /dev/null +++ b/frontend/resources/flowy_icons/16x/app_logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/board.svg b/frontend/resources/flowy_icons/16x/board.svg index 550d045178..bde3149c31 100644 --- a/frontend/resources/flowy_icons/16x/board.svg +++ b/frontend/resources/flowy_icons/16x/board.svg @@ -1,6 +1,4 @@ - - - - - + + + diff --git a/frontend/resources/flowy_icons/16x/calendar.svg b/frontend/resources/flowy_icons/16x/calendar.svg new file mode 100644 index 0000000000..34f184b4d2 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/calendar.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/calendar_layout.svg b/frontend/resources/flowy_icons/16x/calendar_layout.svg new file mode 100644 index 0000000000..eee5fca964 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/calendar_layout.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/camera.svg b/frontend/resources/flowy_icons/16x/camera.svg new file mode 100644 index 0000000000..ce3eda4fb9 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/camera.svg @@ -0,0 +1 @@ +Camera Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/change_icon.svg b/frontend/resources/flowy_icons/16x/change_icon.svg new file mode 100644 index 0000000000..38c7e41710 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/change_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/chat_ai_page.svg b/frontend/resources/flowy_icons/16x/chat_ai_page.svg new file mode 100644 index 0000000000..3033ab47a0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/chat_ai_page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/chat_at.svg b/frontend/resources/flowy_icons/16x/chat_at.svg new file mode 100644 index 0000000000..2d4c8d507b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/chat_at.svg @@ -0,0 +1 @@ +At Sign Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/check_circle.svg b/frontend/resources/flowy_icons/16x/check_circle.svg new file mode 100644 index 0000000000..6703344199 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/check_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/check_circle_outlined.svg b/frontend/resources/flowy_icons/16x/check_circle_outlined.svg new file mode 100644 index 0000000000..3677adcc96 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/check_circle_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/check_partial.svg b/frontend/resources/flowy_icons/16x/check_partial.svg new file mode 100644 index 0000000000..528d630790 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/check_partial.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/checkbox.svg b/frontend/resources/flowy_icons/16x/checkbox.svg index 37f52c47ed..944381cd5f 100644 --- a/frontend/resources/flowy_icons/16x/checkbox.svg +++ b/frontend/resources/flowy_icons/16x/checkbox.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/resources/flowy_icons/16x/checkbox_ai_empty.svg b/frontend/resources/flowy_icons/16x/checkbox_ai_empty.svg new file mode 100644 index 0000000000..9b6bb59b26 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/checkbox_ai_empty.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/checkbox_ai_minus.svg b/frontend/resources/flowy_icons/16x/checkbox_ai_minus.svg new file mode 100644 index 0000000000..4adc9445c1 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/checkbox_ai_minus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/checkbox_ai_selected.svg b/frontend/resources/flowy_icons/16x/checkbox_ai_selected.svg new file mode 100644 index 0000000000..8f04722a89 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/checkbox_ai_selected.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/checklist.svg b/frontend/resources/flowy_icons/16x/checklist.svg index 3a88d236a1..006825d3e2 100644 --- a/frontend/resources/flowy_icons/16x/checklist.svg +++ b/frontend/resources/flowy_icons/16x/checklist.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/resources/flowy_icons/16x/child_page.svg b/frontend/resources/flowy_icons/16x/child_page.svg new file mode 100644 index 0000000000..40861287d4 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/child_page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/close_error.svg b/frontend/resources/flowy_icons/16x/close_error.svg new file mode 100644 index 0000000000..6e1321a6ec --- /dev/null +++ b/frontend/resources/flowy_icons/16x/close_error.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/close_filled.svg b/frontend/resources/flowy_icons/16x/close_filled.svg similarity index 100% rename from frontend/resources/flowy_icons/24x/close_filled.svg rename to frontend/resources/flowy_icons/16x/close_filled.svg diff --git a/frontend/resources/flowy_icons/16x/close_viewer.svg b/frontend/resources/flowy_icons/16x/close_viewer.svg new file mode 100644 index 0000000000..4fa8061bae --- /dev/null +++ b/frontend/resources/flowy_icons/16x/close_viewer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/collapse_all_page.svg b/frontend/resources/flowy_icons/16x/collapse_all_page.svg new file mode 100644 index 0000000000..2760daaaef --- /dev/null +++ b/frontend/resources/flowy_icons/16x/collapse_all_page.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/copy.svg b/frontend/resources/flowy_icons/16x/copy.svg index f11048fd2f..8830822589 100644 --- a/frontend/resources/flowy_icons/16x/copy.svg +++ b/frontend/resources/flowy_icons/16x/copy.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/resources/flowy_icons/16x/cover.svg b/frontend/resources/flowy_icons/16x/cover.svg new file mode 100644 index 0000000000..84749fbc30 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/cover.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/created_at.svg b/frontend/resources/flowy_icons/16x/created_at.svg deleted file mode 100644 index df94328071..0000000000 --- a/frontend/resources/flowy_icons/16x/created_at.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/resources/flowy_icons/16x/database_filter.svg b/frontend/resources/flowy_icons/16x/database_filter.svg new file mode 100644 index 0000000000..8ca4fa3e51 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/database_filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/database_fullscreen.svg b/frontend/resources/flowy_icons/16x/database_fullscreen.svg new file mode 100644 index 0000000000..7e8794cbc5 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/database_fullscreen.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/database_layout.svg b/frontend/resources/flowy_icons/16x/database_layout.svg new file mode 100644 index 0000000000..7b1fb9a846 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/database_layout.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/database_settings_arrow_right.svg b/frontend/resources/flowy_icons/16x/database_settings_arrow_right.svg new file mode 100644 index 0000000000..00fe13b7fc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/database_settings_arrow_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/database_sort.svg b/frontend/resources/flowy_icons/16x/database_sort.svg new file mode 100644 index 0000000000..3df5682f3a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/database_sort.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/date.svg b/frontend/resources/flowy_icons/16x/date.svg index 78243f1e75..9c19568379 100644 --- a/frontend/resources/flowy_icons/16x/date.svg +++ b/frontend/resources/flowy_icons/16x/date.svg @@ -1,6 +1,9 @@ - - - - + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/debug.svg b/frontend/resources/flowy_icons/16x/debug.svg new file mode 100644 index 0000000000..643edfbd23 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/debug.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/document.svg b/frontend/resources/flowy_icons/16x/document.svg index abb59d5c14..d73738329c 100644 --- a/frontend/resources/flowy_icons/16x/document.svg +++ b/frontend/resources/flowy_icons/16x/document.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/frontend/resources/flowy_icons/16x/dot.svg b/frontend/resources/flowy_icons/16x/dot.svg new file mode 100644 index 0000000000..70d26f3988 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/download.svg b/frontend/resources/flowy_icons/16x/download.svg new file mode 100644 index 0000000000..c753fba274 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/download.svg @@ -0,0 +1 @@ +Download Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/download_success.svg b/frontend/resources/flowy_icons/16x/download_success.svg new file mode 100644 index 0000000000..d7cbd38d82 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/download_success.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/download_warn.svg b/frontend/resources/flowy_icons/16x/download_warn.svg new file mode 100644 index 0000000000..8f4f3810af --- /dev/null +++ b/frontend/resources/flowy_icons/16x/download_warn.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/duplicate.svg b/frontend/resources/flowy_icons/16x/duplicate.svg new file mode 100644 index 0000000000..8830822589 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/duplicate.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/edit_layout.svg b/frontend/resources/flowy_icons/16x/edit_layout.svg new file mode 100644 index 0000000000..a2f875742e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/edit_layout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/export_html.svg b/frontend/resources/flowy_icons/16x/export_html.svg new file mode 100644 index 0000000000..02ae35a2b4 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/export_html.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/export_markdown.svg b/frontend/resources/flowy_icons/16x/export_markdown.svg new file mode 100644 index 0000000000..a40e5baa26 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/export_markdown.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/favorite.svg b/frontend/resources/flowy_icons/16x/favorite.svg index 8ad54bbbb5..addd7d4915 100644 --- a/frontend/resources/flowy_icons/16x/favorite.svg +++ b/frontend/resources/flowy_icons/16x/favorite.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/resources/flowy_icons/16x/favorite_header_icon.svg b/frontend/resources/flowy_icons/16x/favorite_header_icon.svg new file mode 100644 index 0000000000..8296f888f3 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/favorite_header_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/favorite_pin.svg b/frontend/resources/flowy_icons/16x/favorite_pin.svg new file mode 100644 index 0000000000..49ec94354a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/favorite_pin.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/favorite_section_pin.svg b/frontend/resources/flowy_icons/16x/favorite_section_pin.svg new file mode 100644 index 0000000000..32b2466de4 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/favorite_section_pin.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/favorite_section_remove_from_favorite.svg b/frontend/resources/flowy_icons/16x/favorite_section_remove_from_favorite.svg new file mode 100644 index 0000000000..b984afe017 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/favorite_section_remove_from_favorite.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/favorite_section_unpin.svg b/frontend/resources/flowy_icons/16x/favorite_section_unpin.svg new file mode 100644 index 0000000000..999adfa03b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/favorite_section_unpin.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/favorited.svg b/frontend/resources/flowy_icons/16x/favorited.svg new file mode 100644 index 0000000000..a17f7548cd --- /dev/null +++ b/frontend/resources/flowy_icons/16x/favorited.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/file.svg b/frontend/resources/flowy_icons/16x/file.svg new file mode 100644 index 0000000000..acf1094ee8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/file.svg @@ -0,0 +1,2 @@ + +Folder 2 Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/file_upload.svg b/frontend/resources/flowy_icons/16x/file_upload.svg new file mode 100644 index 0000000000..7df55f8984 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/file_upload.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ft_archive.svg b/frontend/resources/flowy_icons/16x/ft_archive.svg new file mode 100644 index 0000000000..64f1b3b039 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ft_archive.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ft_audio.svg b/frontend/resources/flowy_icons/16x/ft_audio.svg new file mode 100644 index 0000000000..a7d541d7d0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ft_audio.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/ft_link.svg b/frontend/resources/flowy_icons/16x/ft_link.svg new file mode 100644 index 0000000000..f8dd8460b9 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ft_link.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ft_text.svg b/frontend/resources/flowy_icons/16x/ft_text.svg new file mode 100644 index 0000000000..0b82109141 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ft_text.svg @@ -0,0 +1 @@ +Document Text Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/ft_video.svg b/frontend/resources/flowy_icons/16x/ft_video.svg new file mode 100644 index 0000000000..1065c0ea08 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ft_video.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/grid.svg b/frontend/resources/flowy_icons/16x/grid.svg index 8164b24e6a..e1b7c1f148 100644 --- a/frontend/resources/flowy_icons/16x/grid.svg +++ b/frontend/resources/flowy_icons/16x/grid.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/frontend/resources/flowy_icons/16x/group.svg b/frontend/resources/flowy_icons/16x/group.svg index f0a6dff4f9..eaa2b9c862 100644 --- a/frontend/resources/flowy_icons/16x/group.svg +++ b/frontend/resources/flowy_icons/16x/group.svg @@ -1,7 +1,15 @@ - - - - - + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/help_and_documentation.svg b/frontend/resources/flowy_icons/16x/help_and_documentation.svg new file mode 100644 index 0000000000..e4c68c2583 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/help_and_documentation.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/help_center.svg b/frontend/resources/flowy_icons/16x/help_center.svg new file mode 100644 index 0000000000..7840906683 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/help_center.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/hide.svg b/frontend/resources/flowy_icons/16x/hide.svg index 45e81d8748..d76e4576df 100644 --- a/frontend/resources/flowy_icons/16x/hide.svg +++ b/frontend/resources/flowy_icons/16x/hide.svg @@ -1,4 +1,6 @@ - - + + + + diff --git a/frontend/resources/flowy_icons/16x/hide_menu.svg b/frontend/resources/flowy_icons/16x/hide_menu.svg index ce88af8ea7..9e301210c4 100644 --- a/frontend/resources/flowy_icons/16x/hide_menu.svg +++ b/frontend/resources/flowy_icons/16x/hide_menu.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/frontend/resources/flowy_icons/16x/icon_board.svg b/frontend/resources/flowy_icons/16x/icon_board.svg new file mode 100644 index 0000000000..80ab6bb759 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/icon_board.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/icon_calendar.svg b/frontend/resources/flowy_icons/16x/icon_calendar.svg new file mode 100644 index 0000000000..ca65315d3c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/icon_calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/icon_code_block.svg b/frontend/resources/flowy_icons/16x/icon_code_block.svg new file mode 100644 index 0000000000..1d36321b6a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/icon_code_block.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/icon_delete.svg b/frontend/resources/flowy_icons/16x/icon_delete.svg new file mode 100644 index 0000000000..de7c3a7fc1 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/icon_delete.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/icon_document.svg b/frontend/resources/flowy_icons/16x/icon_document.svg new file mode 100644 index 0000000000..75acd9a2a3 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/icon_document.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/icon_grid.svg b/frontend/resources/flowy_icons/16x/icon_grid.svg new file mode 100644 index 0000000000..a167a4a6ad --- /dev/null +++ b/frontend/resources/flowy_icons/16x/icon_grid.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/icon_import.svg b/frontend/resources/flowy_icons/16x/icon_import.svg new file mode 100644 index 0000000000..bc8d7c7b72 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/icon_import.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/icon_math_eq.svg b/frontend/resources/flowy_icons/16x/icon_math_eq.svg new file mode 100644 index 0000000000..8f4a6fc4f4 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/icon_math_eq.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/icon_shuffle.svg b/frontend/resources/flowy_icons/16x/icon_shuffle.svg new file mode 100644 index 0000000000..9953ebe30c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/icon_shuffle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/icon_template.svg b/frontend/resources/flowy_icons/16x/icon_template.svg new file mode 100644 index 0000000000..094050cfb4 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/icon_template.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/image_rounded.svg b/frontend/resources/flowy_icons/16x/image_rounded.svg new file mode 100644 index 0000000000..d360e7de69 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/image_rounded.svg @@ -0,0 +1 @@ +Gallery Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/insert_document.svg b/frontend/resources/flowy_icons/16x/insert_document.svg new file mode 100644 index 0000000000..712203a2be --- /dev/null +++ b/frontend/resources/flowy_icons/16x/insert_document.svg @@ -0,0 +1 @@ +Document Medicine Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/keyboard.svg b/frontend/resources/flowy_icons/16x/keyboard.svg new file mode 100644 index 0000000000..40cb2d42a8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/keyboard.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/keyboard_arrow_down.svg b/frontend/resources/flowy_icons/16x/keyboard_arrow_down.svg new file mode 100644 index 0000000000..e97f68009b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/keyboard_arrow_down.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/keyboard_arrow_left.svg b/frontend/resources/flowy_icons/16x/keyboard_arrow_left.svg new file mode 100644 index 0000000000..9d235a7170 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/keyboard_arrow_left.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/keyboard_arrow_right.svg b/frontend/resources/flowy_icons/16x/keyboard_arrow_right.svg new file mode 100644 index 0000000000..1be939c778 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/keyboard_arrow_right.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/keyboard_arrow_up.svg b/frontend/resources/flowy_icons/16x/keyboard_arrow_up.svg new file mode 100644 index 0000000000..b67b2ea940 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/keyboard_arrow_up.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/keyboard_meta.svg b/frontend/resources/flowy_icons/16x/keyboard_meta.svg new file mode 100644 index 0000000000..73f4eb9d2a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/keyboard_meta.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/keyboard_option.svg b/frontend/resources/flowy_icons/16x/keyboard_option.svg new file mode 100644 index 0000000000..46d3907e69 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/keyboard_option.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/keyboard_return.svg b/frontend/resources/flowy_icons/16x/keyboard_return.svg new file mode 100644 index 0000000000..1e4106a450 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/keyboard_return.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/keyboard_shift.svg b/frontend/resources/flowy_icons/16x/keyboard_shift.svg new file mode 100644 index 0000000000..b546f0a7f0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/keyboard_shift.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/keyboard_tab.svg b/frontend/resources/flowy_icons/16x/keyboard_tab.svg new file mode 100644 index 0000000000..5cef294a9b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/keyboard_tab.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/last_modified.svg b/frontend/resources/flowy_icons/16x/last_modified.svg deleted file mode 100644 index 5ba3d7494e..0000000000 --- a/frontend/resources/flowy_icons/16x/last_modified.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/resources/flowy_icons/16x/leave_workspace.svg b/frontend/resources/flowy_icons/16x/leave_workspace.svg new file mode 100644 index 0000000000..5ce4842e80 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/leave_workspace.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/link_to_page.svg b/frontend/resources/flowy_icons/16x/link_to_page.svg new file mode 100644 index 0000000000..7968a5b367 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/link_to_page.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/load_more.svg b/frontend/resources/flowy_icons/16x/load_more.svg new file mode 100644 index 0000000000..078b8668b3 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/load_more.svg @@ -0,0 +1 @@ +Arrow Down Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/local_model_download.svg b/frontend/resources/flowy_icons/16x/local_model_download.svg new file mode 100644 index 0000000000..270bcb25d1 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/local_model_download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/lock_page.svg b/frontend/resources/flowy_icons/16x/lock_page.svg new file mode 100644 index 0000000000..b68b7ab42f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/lock_page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/lock_page_fill.svg b/frontend/resources/flowy_icons/16x/lock_page_fill.svg new file mode 100644 index 0000000000..b2ed846e69 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/lock_page_fill.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/m_add_block_photo_gallery.svg b/frontend/resources/flowy_icons/16x/m_add_block_photo_gallery.svg new file mode 100644 index 0000000000..ccbfa80599 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_add_block_photo_gallery.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/m_add_block_video.svg b/frontend/resources/flowy_icons/16x/m_add_block_video.svg new file mode 100644 index 0000000000..5bd19d0a3e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_add_block_video.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/m_app_bar_back.svg b/frontend/resources/flowy_icons/16x/m_app_bar_back.svg index 817fec672b..7a5d7cb387 100644 --- a/frontend/resources/flowy_icons/16x/m_app_bar_back.svg +++ b/frontend/resources/flowy_icons/16x/m_app_bar_back.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/frontend/resources/flowy_icons/16x/m_bottom_sheet_back.svg b/frontend/resources/flowy_icons/16x/m_bottom_sheet_back.svg new file mode 100644 index 0000000000..e67a6361ba --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_bottom_sheet_back.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/m_collapse.svg b/frontend/resources/flowy_icons/16x/m_collapse.svg new file mode 100644 index 0000000000..bf88470d69 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_collapse.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/m_copy_link.svg b/frontend/resources/flowy_icons/16x/m_copy_link.svg new file mode 100644 index 0000000000..89b5ba6c07 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_copy_link.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/m_expand.svg b/frontend/resources/flowy_icons/16x/m_expand.svg new file mode 100644 index 0000000000..48c7bc5ebd --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_expand.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/m_layout.svg b/frontend/resources/flowy_icons/16x/m_layout.svg index 7d9ba0d9dd..258e5f7288 100644 --- a/frontend/resources/flowy_icons/16x/m_layout.svg +++ b/frontend/resources/flowy_icons/16x/m_layout.svg @@ -1,8 +1,3 @@ - - - - - - + diff --git a/frontend/resources/flowy_icons/16x/m_notification_action_archive.svg b/frontend/resources/flowy_icons/16x/m_notification_action_archive.svg new file mode 100644 index 0000000000..ada869aebf --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_notification_action_archive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_notification_action_mark_as_read.svg b/frontend/resources/flowy_icons/16x/m_notification_action_mark_as_read.svg new file mode 100644 index 0000000000..68d249cf74 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_notification_action_mark_as_read.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/m_notification_action_multiple_choice.svg b/frontend/resources/flowy_icons/16x/m_notification_action_multiple_choice.svg new file mode 100644 index 0000000000..5c06dd12c3 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_notification_action_multiple_choice.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_notification_archived.svg b/frontend/resources/flowy_icons/16x/m_notification_archived.svg new file mode 100644 index 0000000000..37c769880e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_notification_archived.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_notification_mark_as_read.svg b/frontend/resources/flowy_icons/16x/m_notification_mark_as_read.svg new file mode 100644 index 0000000000..5292de6310 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_notification_mark_as_read.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/m_notification_multi_select.svg b/frontend/resources/flowy_icons/16x/m_notification_multi_select.svg new file mode 100644 index 0000000000..249d716cc1 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_notification_multi_select.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/m_notification_multi_unselect.svg b/frontend/resources/flowy_icons/16x/m_notification_multi_unselect.svg new file mode 100644 index 0000000000..a0ae39fe15 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_notification_multi_unselect.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/m_notification_reminder.svg b/frontend/resources/flowy_icons/16x/m_notification_reminder.svg new file mode 100644 index 0000000000..8369a8c9a8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_notification_reminder.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_notification_settings.svg b/frontend/resources/flowy_icons/16x/m_notification_settings.svg new file mode 100644 index 0000000000..987f547415 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_notification_settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/m_publish.svg b/frontend/resources/flowy_icons/16x/m_publish.svg new file mode 100644 index 0000000000..136dfaed6a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_publish.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_rename.svg b/frontend/resources/flowy_icons/16x/m_rename.svg index 98200fd061..8f7620a806 100644 --- a/frontend/resources/flowy_icons/16x/m_rename.svg +++ b/frontend/resources/flowy_icons/16x/m_rename.svg @@ -1,4 +1,13 @@ - - - + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_settings_member.svg b/frontend/resources/flowy_icons/16x/m_settings_member.svg new file mode 100644 index 0000000000..edd1e3fe83 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_settings_member.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_settings_more.svg b/frontend/resources/flowy_icons/16x/m_settings_more.svg new file mode 100644 index 0000000000..bfd339eb61 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_settings_more.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_space_add.svg b/frontend/resources/flowy_icons/16x/m_space_add.svg new file mode 100644 index 0000000000..dcab9d1eb5 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_space_add.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_space_more.svg b/frontend/resources/flowy_icons/16x/m_space_more.svg new file mode 100644 index 0000000000..ea557e960c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_space_more.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_spaces_expand.svg b/frontend/resources/flowy_icons/16x/m_spaces_expand.svg new file mode 100644 index 0000000000..af15050d18 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_spaces_expand.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_table_duplicate.svg b/frontend/resources/flowy_icons/16x/m_table_duplicate.svg new file mode 100644 index 0000000000..1a0b6902e4 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_table_duplicate.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_table_quick_action_copy.svg b/frontend/resources/flowy_icons/16x/m_table_quick_action_copy.svg new file mode 100644 index 0000000000..5e69041afe --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_table_quick_action_copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/m_table_quick_action_cut.svg b/frontend/resources/flowy_icons/16x/m_table_quick_action_cut.svg new file mode 100644 index 0000000000..60660f7891 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_table_quick_action_cut.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/m_table_quick_action_delete.svg b/frontend/resources/flowy_icons/16x/m_table_quick_action_delete.svg new file mode 100644 index 0000000000..e11d4fa185 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_table_quick_action_delete.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_table_quick_action_paste.svg b/frontend/resources/flowy_icons/16x/m_table_quick_action_paste.svg new file mode 100644 index 0000000000..6172fc1d40 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_table_quick_action_paste.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_unpublish.svg b/frontend/resources/flowy_icons/16x/m_unpublish.svg new file mode 100644 index 0000000000..49976703f8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_unpublish.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_visit_site.svg b/frontend/resources/flowy_icons/16x/m_visit_site.svg new file mode 100644 index 0000000000..cc0d41f282 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_visit_site.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/magnifier.svg b/frontend/resources/flowy_icons/16x/magnifier.svg new file mode 100644 index 0000000000..60c7ae8d5f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/magnifier.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/media.svg b/frontend/resources/flowy_icons/16x/media.svg new file mode 100644 index 0000000000..a56903e5da --- /dev/null +++ b/frontend/resources/flowy_icons/16x/media.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/menu_item_ai_writer.svg b/frontend/resources/flowy_icons/16x/menu_item_ai_writer.svg new file mode 100644 index 0000000000..744f4b9b05 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/menu_item_ai_writer.svg @@ -0,0 +1 @@ +Wand Sparkles Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/message_support.svg b/frontend/resources/flowy_icons/16x/message_support.svg new file mode 100644 index 0000000000..03e72384d7 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/message_support.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/minus.svg b/frontend/resources/flowy_icons/16x/minus.svg new file mode 100644 index 0000000000..8be3fe893d --- /dev/null +++ b/frontend/resources/flowy_icons/16x/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/more.svg b/frontend/resources/flowy_icons/16x/more.svg index b191e64a10..c7f6a4bbed 100644 --- a/frontend/resources/flowy_icons/16x/more.svg +++ b/frontend/resources/flowy_icons/16x/more.svg @@ -1,3 +1,3 @@ - - + + \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/move_to.svg b/frontend/resources/flowy_icons/16x/move_to.svg new file mode 100644 index 0000000000..1c7d6144ee --- /dev/null +++ b/frontend/resources/flowy_icons/16x/move_to.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/multiselect.svg b/frontend/resources/flowy_icons/16x/multiselect.svg index 97a2e9c434..e1682cebb5 100644 --- a/frontend/resources/flowy_icons/16x/multiselect.svg +++ b/frontend/resources/flowy_icons/16x/multiselect.svg @@ -1,8 +1,11 @@ - - - - - - + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/notification.svg b/frontend/resources/flowy_icons/16x/notification.svg new file mode 100644 index 0000000000..feb63cb9b3 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/notification.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/number.svg b/frontend/resources/flowy_icons/16x/number.svg index 9d8b98d10d..e0dcb04637 100644 --- a/frontend/resources/flowy_icons/16x/number.svg +++ b/frontend/resources/flowy_icons/16x/number.svg @@ -1,3 +1,6 @@ - + + + + diff --git a/frontend/resources/flowy_icons/16x/open_in_browser.svg b/frontend/resources/flowy_icons/16x/open_in_browser.svg new file mode 100644 index 0000000000..8c2964aa95 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/open_in_browser.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/paragraph_mark.svg b/frontend/resources/flowy_icons/16x/paragraph_mark.svg new file mode 100644 index 0000000000..be4b14bffd --- /dev/null +++ b/frontend/resources/flowy_icons/16x/paragraph_mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/photo_layout_browser.svg b/frontend/resources/flowy_icons/16x/photo_layout_browser.svg new file mode 100644 index 0000000000..9f9240da5f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/photo_layout_browser.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/photo_layout_grid.svg b/frontend/resources/flowy_icons/16x/photo_layout_grid.svg new file mode 100644 index 0000000000..dfc3cfd1e2 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/photo_layout_grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/pinned.svg b/frontend/resources/flowy_icons/16x/pinned.svg new file mode 100644 index 0000000000..55f9f30ddf --- /dev/null +++ b/frontend/resources/flowy_icons/16x/pinned.svg @@ -0,0 +1,2 @@ + +Pin Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/published_checkmark.svg b/frontend/resources/flowy_icons/16x/published_checkmark.svg new file mode 100644 index 0000000000..097ace0524 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/published_checkmark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/referenced_page.svg b/frontend/resources/flowy_icons/16x/referenced_page.svg new file mode 100644 index 0000000000..802212ac98 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/referenced_page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/relation.svg b/frontend/resources/flowy_icons/16x/relation.svg index f82a41d226..98d43aba76 100644 --- a/frontend/resources/flowy_icons/16x/relation.svg +++ b/frontend/resources/flowy_icons/16x/relation.svg @@ -1,8 +1,3 @@ - - - - - - + diff --git a/frontend/resources/flowy_icons/16x/reminder_clock.svg b/frontend/resources/flowy_icons/16x/reminder_clock.svg new file mode 100644 index 0000000000..c383974855 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/reminder_clock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/remove_from_recent.svg b/frontend/resources/flowy_icons/16x/remove_from_recent.svg new file mode 100644 index 0000000000..b12c8054aa --- /dev/null +++ b/frontend/resources/flowy_icons/16x/remove_from_recent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/rename.svg b/frontend/resources/flowy_icons/16x/rename.svg new file mode 100644 index 0000000000..6a820cf5fc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/rename.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/save_as.svg b/frontend/resources/flowy_icons/16x/save_as.svg new file mode 100644 index 0000000000..f82938f6db --- /dev/null +++ b/frontend/resources/flowy_icons/16x/save_as.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/search.svg b/frontend/resources/flowy_icons/16x/search.svg index 1efb2d475c..ed7e6de9d2 100644 --- a/frontend/resources/flowy_icons/16x/search.svg +++ b/frontend/resources/flowy_icons/16x/search.svg @@ -1,4 +1,6 @@ - - + + + + diff --git a/frontend/resources/flowy_icons/16x/send.svg b/frontend/resources/flowy_icons/16x/send.svg index a5f933a8ca..b141b255b0 100644 --- a/frontend/resources/flowy_icons/16x/send.svg +++ b/frontend/resources/flowy_icons/16x/send.svg @@ -1,3 +1 @@ - - - +Arrow Up Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/settings.svg b/frontend/resources/flowy_icons/16x/settings.svg index f9896aad52..bcc96b817b 100644 --- a/frontend/resources/flowy_icons/16x/settings.svg +++ b/frontend/resources/flowy_icons/16x/settings.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/resources/flowy_icons/16x/share_feedback.svg b/frontend/resources/flowy_icons/16x/share_feedback.svg new file mode 100644 index 0000000000..9dc9b7f404 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/share_feedback.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/share_publish.svg b/frontend/resources/flowy_icons/16x/share_publish.svg new file mode 100644 index 0000000000..345208ab90 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/share_publish.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/share_tab_copy.svg b/frontend/resources/flowy_icons/16x/share_tab_copy.svg new file mode 100644 index 0000000000..6e5b0429ad --- /dev/null +++ b/frontend/resources/flowy_icons/16x/share_tab_copy.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/share_tab_icon.svg b/frontend/resources/flowy_icons/16x/share_tab_icon.svg new file mode 100644 index 0000000000..a4d019edd5 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/share_tab_icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/show.svg b/frontend/resources/flowy_icons/16x/show.svg new file mode 100644 index 0000000000..1124aee221 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/show.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/show_menu.svg b/frontend/resources/flowy_icons/16x/show_menu.svg index 8baf55bffd..67edfc4d17 100644 --- a/frontend/resources/flowy_icons/16x/show_menu.svg +++ b/frontend/resources/flowy_icons/16x/show_menu.svg @@ -1,6 +1,4 @@ - - - - - + + + diff --git a/frontend/resources/flowy_icons/16x/sidebar_footer_trash.svg b/frontend/resources/flowy_icons/16x/sidebar_footer_trash.svg new file mode 100644 index 0000000000..f412bb86fd --- /dev/null +++ b/frontend/resources/flowy_icons/16x/sidebar_footer_trash.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/sidebar_footer_widget.svg b/frontend/resources/flowy_icons/16x/sidebar_footer_widget.svg new file mode 100644 index 0000000000..2dbb6f52cf --- /dev/null +++ b/frontend/resources/flowy_icons/16x/sidebar_footer_widget.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/sidebar_upgrade_version.svg b/frontend/resources/flowy_icons/16x/sidebar_upgrade_version.svg new file mode 100644 index 0000000000..3f23cbd709 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/sidebar_upgrade_version.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/single_select.svg b/frontend/resources/flowy_icons/16x/single_select.svg index 8ccbc9a2e3..431c228fcd 100644 --- a/frontend/resources/flowy_icons/16x/single_select.svg +++ b/frontend/resources/flowy_icons/16x/single_select.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_ai_writer.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_ai_writer.svg new file mode 100644 index 0000000000..7f72551486 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_ai_writer.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_bulleted_list.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_bulleted_list.svg new file mode 100644 index 0000000000..16af99d0c7 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_bulleted_list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_calendar-1.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_calendar-1.svg new file mode 100644 index 0000000000..bdabe8f5da --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_calendar-1.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_calendar.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_calendar.svg new file mode 100644 index 0000000000..d0bf3100bc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_calendar.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_callout.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_callout.svg new file mode 100644 index 0000000000..9539972a37 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_callout.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_checkbox.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_checkbox.svg new file mode 100644 index 0000000000..abcf733ccb --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_checkbox.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_code block.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_code block.svg new file mode 100644 index 0000000000..1d36321b6a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_code block.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_date_or_reminder.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_date_or_reminder.svg new file mode 100644 index 0000000000..71d0aa18b9 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_date_or_reminder.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_divider.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_divider.svg new file mode 100644 index 0000000000..fd53b92748 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_divider.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_doc.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_doc.svg new file mode 100644 index 0000000000..71b9659027 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_doc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_emoji_picker.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_emoji_picker.svg new file mode 100644 index 0000000000..f525e8cbfc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_emoji_picker.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_file.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_file.svg new file mode 100644 index 0000000000..32f4057a05 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_file.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_four_columns.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_four_columns.svg new file mode 100644 index 0000000000..aa4dbff160 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_four_columns.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_grid.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_grid.svg new file mode 100644 index 0000000000..9db2280443 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_grid.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_h1.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_h1.svg new file mode 100644 index 0000000000..a94b3fa12d --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_h1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_h2.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_h2.svg new file mode 100644 index 0000000000..a0580717b8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_h2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_h3.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_h3.svg new file mode 100644 index 0000000000..485caefb15 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_h3.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_image.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_image.svg new file mode 100644 index 0000000000..28e628ec8d --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_image.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_kanban.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_kanban.svg new file mode 100644 index 0000000000..0ee6e28ae7 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_kanban.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_math_equation.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_math_equation.svg new file mode 100644 index 0000000000..602b211850 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_math_equation.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_numbered_list.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_numbered_list.svg new file mode 100644 index 0000000000..ffc957d59f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_numbered_list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_outline.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_outline.svg new file mode 100644 index 0000000000..e6645d2168 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_photo_gallery.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_photo_gallery.svg new file mode 100644 index 0000000000..581c8c009e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_photo_gallery.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_quote.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_quote.svg new file mode 100644 index 0000000000..9028b658cb --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_quote.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_simple_table.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_simple_table.svg new file mode 100644 index 0000000000..1666d7aad7 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_simple_table.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_text.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_text.svg new file mode 100644 index 0000000000..134e755dfd --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_text.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_three_columns.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_three_columns.svg new file mode 100644 index 0000000000..6eb3aeab2b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_three_columns.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_toggle.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_toggle.svg new file mode 100644 index 0000000000..ee995b4d8e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_toggle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_two_columns.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_two_columns.svg new file mode 100644 index 0000000000..b3b3e55452 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_two_columns.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/slash_menu_icon_visuals.svg b/frontend/resources/flowy_icons/16x/slash_menu_icon_visuals.svg new file mode 100644 index 0000000000..65434c3316 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/slash_menu_icon_visuals.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_add.svg b/frontend/resources/flowy_icons/16x/space_add.svg new file mode 100644 index 0000000000..292917fdd0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_arrow_right.svg b/frontend/resources/flowy_icons/16x/space_arrow_right.svg new file mode 100644 index 0000000000..6b9e66285e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_arrow_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon.svg b/frontend/resources/flowy_icons/16x/space_icon.svg new file mode 100644 index 0000000000..093ae4bb12 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_1.svg b/frontend/resources/flowy_icons/16x/space_icon_1.svg new file mode 100644 index 0000000000..bfb045fedc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_1.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_10.svg b/frontend/resources/flowy_icons/16x/space_icon_10.svg new file mode 100644 index 0000000000..4b9d51a6b0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_10.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_11.svg b/frontend/resources/flowy_icons/16x/space_icon_11.svg new file mode 100644 index 0000000000..bb6ec9dea9 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_11.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_12.svg b/frontend/resources/flowy_icons/16x/space_icon_12.svg new file mode 100644 index 0000000000..a10232d2f0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_12.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_13.svg b/frontend/resources/flowy_icons/16x/space_icon_13.svg new file mode 100644 index 0000000000..da0007d043 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_13.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_14.svg b/frontend/resources/flowy_icons/16x/space_icon_14.svg new file mode 100644 index 0000000000..80e00912bd --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_14.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_15.svg b/frontend/resources/flowy_icons/16x/space_icon_15.svg new file mode 100644 index 0000000000..dcd06dc4b4 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_15.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_2.svg b/frontend/resources/flowy_icons/16x/space_icon_2.svg new file mode 100644 index 0000000000..ecfd797076 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_3.svg b/frontend/resources/flowy_icons/16x/space_icon_3.svg new file mode 100644 index 0000000000..cef3794152 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_4.svg b/frontend/resources/flowy_icons/16x/space_icon_4.svg new file mode 100644 index 0000000000..244db0745e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_4.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_5.svg b/frontend/resources/flowy_icons/16x/space_icon_5.svg new file mode 100644 index 0000000000..0ee1709993 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_5.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_6.svg b/frontend/resources/flowy_icons/16x/space_icon_6.svg new file mode 100644 index 0000000000..66dafd1e7f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_6.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_7.svg b/frontend/resources/flowy_icons/16x/space_icon_7.svg new file mode 100644 index 0000000000..4d7910296b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_7.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_8.svg b/frontend/resources/flowy_icons/16x/space_icon_8.svg new file mode 100644 index 0000000000..275bb3ae07 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_8.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_9.svg b/frontend/resources/flowy_icons/16x/space_icon_9.svg new file mode 100644 index 0000000000..c2cc63c35a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_9.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_lock.svg b/frontend/resources/flowy_icons/16x/space_lock.svg new file mode 100644 index 0000000000..e5103e7de8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_lock.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/space_manage.svg b/frontend/resources/flowy_icons/16x/space_manage.svg new file mode 100644 index 0000000000..547e0a8a18 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_manage.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_permission_dropdown.svg b/frontend/resources/flowy_icons/16x/space_permission_dropdown.svg new file mode 100644 index 0000000000..3342c3e468 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_permission_dropdown.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_permission_private.svg b/frontend/resources/flowy_icons/16x/space_permission_private.svg new file mode 100644 index 0000000000..db5463f4d0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_permission_private.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_permission_public.svg b/frontend/resources/flowy_icons/16x/space_permission_public.svg new file mode 100644 index 0000000000..cbd0b9fa2f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_permission_public.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/star.svg b/frontend/resources/flowy_icons/16x/star.svg new file mode 100644 index 0000000000..f4d6f7ae3c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/star.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/success.svg b/frontend/resources/flowy_icons/16x/success.svg new file mode 100644 index 0000000000..771d8d7f5c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/suggestion_insert_below.svg b/frontend/resources/flowy_icons/16x/suggestion_insert_below.svg new file mode 100644 index 0000000000..aa071665c3 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/suggestion_insert_below.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/table_align_center.svg b/frontend/resources/flowy_icons/16x/table_align_center.svg new file mode 100644 index 0000000000..a3e462df06 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_align_center.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/table_align_left.svg b/frontend/resources/flowy_icons/16x/table_align_left.svg new file mode 100644 index 0000000000..0f71e7fc3c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_align_left.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/table_align_right.svg b/frontend/resources/flowy_icons/16x/table_align_right.svg new file mode 100644 index 0000000000..de3efa902a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_align_right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/table_clear_content.svg b/frontend/resources/flowy_icons/16x/table_clear_content.svg new file mode 100644 index 0000000000..de1d42cabe --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_clear_content.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/table_distribute_columns_evenly.svg b/frontend/resources/flowy_icons/16x/table_distribute_columns_evenly.svg new file mode 100644 index 0000000000..fe7acf8c98 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_distribute_columns_evenly.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/table_header_column.svg b/frontend/resources/flowy_icons/16x/table_header_column.svg new file mode 100644 index 0000000000..3ba5640bec --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_header_column.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/table_header_row.svg b/frontend/resources/flowy_icons/16x/table_header_row.svg new file mode 100644 index 0000000000..a4d5c77606 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_header_row.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/table_insert_above.svg b/frontend/resources/flowy_icons/16x/table_insert_above.svg new file mode 100644 index 0000000000..e3bcfe4ca6 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_insert_above.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/table_insert_below.svg b/frontend/resources/flowy_icons/16x/table_insert_below.svg new file mode 100644 index 0000000000..de1719edeb --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_insert_below.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/table_insert_left.svg b/frontend/resources/flowy_icons/16x/table_insert_left.svg new file mode 100644 index 0000000000..47bbb0820c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_insert_left.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/table_insert_right.svg b/frontend/resources/flowy_icons/16x/table_insert_right.svg new file mode 100644 index 0000000000..f754f516d0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_insert_right.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/table_reorder_column.svg b/frontend/resources/flowy_icons/16x/table_reorder_column.svg new file mode 100644 index 0000000000..96142c1ecc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_reorder_column.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/table_reorder_row.svg b/frontend/resources/flowy_icons/16x/table_reorder_row.svg new file mode 100644 index 0000000000..9bd9dc2c14 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_reorder_row.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/table_set_to_page_width.svg b/frontend/resources/flowy_icons/16x/table_set_to_page_width.svg new file mode 100644 index 0000000000..0787942325 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/table_set_to_page_width.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/text.svg b/frontend/resources/flowy_icons/16x/text.svg index 7befa5080f..1862ea1801 100644 --- a/frontend/resources/flowy_icons/16x/text.svg +++ b/frontend/resources/flowy_icons/16x/text.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/resources/flowy_icons/16x/three-dots.svg b/frontend/resources/flowy_icons/16x/three-dots.svg index 4d37a346a0..07aa7ca706 100644 --- a/frontend/resources/flowy_icons/16x/three-dots.svg +++ b/frontend/resources/flowy_icons/16x/three-dots.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/frontend/resources/flowy_icons/16x/time.svg b/frontend/resources/flowy_icons/16x/time.svg index 634af3e361..cb3f6ca112 100644 --- a/frontend/resources/flowy_icons/16x/time.svg +++ b/frontend/resources/flowy_icons/16x/time.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/resources/flowy_icons/16x/title_bar_divider.svg b/frontend/resources/flowy_icons/16x/title_bar_divider.svg new file mode 100644 index 0000000000..5f92484836 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/title_bar_divider.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/toast_checked_filled.svg b/frontend/resources/flowy_icons/16x/toast_checked_filled.svg new file mode 100644 index 0000000000..6d43cf16c3 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toast_checked_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/toast_close.svg b/frontend/resources/flowy_icons/16x/toast_close.svg new file mode 100644 index 0000000000..9941c80abc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toast_close.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/toast_error_filled.svg b/frontend/resources/flowy_icons/16x/toast_error_filled.svg new file mode 100644 index 0000000000..bdf63223e2 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toast_error_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/toast_warning_filled.svg b/frontend/resources/flowy_icons/16x/toast_warning_filled.svg new file mode 100644 index 0000000000..5c60f1e009 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toast_warning_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/toggle_heading1.svg b/frontend/resources/flowy_icons/16x/toggle_heading1.svg new file mode 100644 index 0000000000..8392acb665 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toggle_heading1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/toggle_heading2.svg b/frontend/resources/flowy_icons/16x/toggle_heading2.svg new file mode 100644 index 0000000000..1b0721777e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toggle_heading2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/toggle_heading3.svg b/frontend/resources/flowy_icons/16x/toggle_heading3.svg new file mode 100644 index 0000000000..0939a5e997 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toggle_heading3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/toolbar_item_ai.svg b/frontend/resources/flowy_icons/16x/toolbar_item_ai.svg new file mode 100644 index 0000000000..e3828bddbd --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toolbar_item_ai.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/trash.svg b/frontend/resources/flowy_icons/16x/trash.svg new file mode 100644 index 0000000000..487a57001f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/trash.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/turninto.svg b/frontend/resources/flowy_icons/16x/turninto.svg new file mode 100644 index 0000000000..3d1b62b697 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/turninto.svg @@ -0,0 +1 @@ +Transfer Line Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/unable_select.svg b/frontend/resources/flowy_icons/16x/unable_select.svg new file mode 100644 index 0000000000..0c661c79a4 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/unable_select.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/unfavorite.svg b/frontend/resources/flowy_icons/16x/unfavorite.svg index 0ccfc1edff..b984afe017 100644 --- a/frontend/resources/flowy_icons/16x/unfavorite.svg +++ b/frontend/resources/flowy_icons/16x/unfavorite.svg @@ -1,3 +1,5 @@ - + + + diff --git a/frontend/resources/flowy_icons/16x/unlock_page.svg b/frontend/resources/flowy_icons/16x/unlock_page.svg new file mode 100644 index 0000000000..38f60dedb8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/unlock_page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/upgrade.svg b/frontend/resources/flowy_icons/16x/upgrade.svg new file mode 100644 index 0000000000..0790946477 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/upgrade.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/upgrade_close.svg b/frontend/resources/flowy_icons/16x/upgrade_close.svg new file mode 100644 index 0000000000..3275378614 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/upgrade_close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/upgrade_storage.svg b/frontend/resources/flowy_icons/16x/upgrade_storage.svg new file mode 100644 index 0000000000..ec0ff3a41b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/upgrade_storage.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/url.svg b/frontend/resources/flowy_icons/16x/url.svg index f00f5c7aa2..0b0d678074 100644 --- a/frontend/resources/flowy_icons/16x/url.svg +++ b/frontend/resources/flowy_icons/16x/url.svg @@ -1,3 +1,10 @@ - + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/view_item_add.svg b/frontend/resources/flowy_icons/16x/view_item_add.svg new file mode 100644 index 0000000000..9373ef6dd1 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/view_item_add.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/view_item_expand.svg b/frontend/resources/flowy_icons/16x/view_item_expand.svg new file mode 100644 index 0000000000..5fd5a1e719 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/view_item_expand.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/view_item_open_in_new_tab.svg b/frontend/resources/flowy_icons/16x/view_item_open_in_new_tab.svg new file mode 100644 index 0000000000..87f9f11949 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/view_item_open_in_new_tab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/view_item_rename.svg b/frontend/resources/flowy_icons/16x/view_item_rename.svg new file mode 100644 index 0000000000..c890184915 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/view_item_rename.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/view_item_right_arrow.svg b/frontend/resources/flowy_icons/16x/view_item_right_arrow.svg new file mode 100644 index 0000000000..de5db7fd68 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/view_item_right_arrow.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/view_item_unexpand.svg b/frontend/resources/flowy_icons/16x/view_item_unexpand.svg new file mode 100644 index 0000000000..8971910251 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/view_item_unexpand.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/warning.svg b/frontend/resources/flowy_icons/16x/warning.svg new file mode 100644 index 0000000000..7ac605dffc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/workspace_add_member.svg b/frontend/resources/flowy_icons/16x/workspace_add_member.svg new file mode 100644 index 0000000000..bd65361f8d --- /dev/null +++ b/frontend/resources/flowy_icons/16x/workspace_add_member.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/workspace_drop_down_menu_hide.svg b/frontend/resources/flowy_icons/16x/workspace_drop_down_menu_hide.svg new file mode 100644 index 0000000000..f6c5884f5b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/workspace_drop_down_menu_hide.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/workspace_drop_down_menu_show.svg b/frontend/resources/flowy_icons/16x/workspace_drop_down_menu_show.svg new file mode 100644 index 0000000000..80ce09be81 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/workspace_drop_down_menu_show.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/workspace_logout.svg b/frontend/resources/flowy_icons/16x/workspace_logout.svg new file mode 100644 index 0000000000..6e44da6b14 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/workspace_logout.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/workspace_selected.svg b/frontend/resources/flowy_icons/16x/workspace_selected.svg new file mode 100644 index 0000000000..73d86f8e02 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/workspace_selected.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/workspace_three_dots.svg b/frontend/resources/flowy_icons/16x/workspace_three_dots.svg new file mode 100644 index 0000000000..54332fb08b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/workspace_three_dots.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/20x/ai_explain.svg b/frontend/resources/flowy_icons/20x/ai_explain.svg new file mode 100644 index 0000000000..f490472688 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/ai_explain.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/anonymous_mode.svg b/frontend/resources/flowy_icons/20x/anonymous_mode.svg new file mode 100644 index 0000000000..bee519e54a --- /dev/null +++ b/frontend/resources/flowy_icons/20x/anonymous_mode.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/cloud_mode.svg b/frontend/resources/flowy_icons/20x/cloud_mode.svg new file mode 100644 index 0000000000..5aaf68e3db --- /dev/null +++ b/frontend/resources/flowy_icons/20x/cloud_mode.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/embed_fullscreen.svg b/frontend/resources/flowy_icons/20x/embed_fullscreen.svg new file mode 100644 index 0000000000..b8b197fb13 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/embed_fullscreen.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/hide_password.svg b/frontend/resources/flowy_icons/20x/hide_password.svg new file mode 100644 index 0000000000..2ebd274866 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/hide_password.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/20x/password_close.svg b/frontend/resources/flowy_icons/20x/password_close.svg new file mode 100644 index 0000000000..52a44e1a8e --- /dev/null +++ b/frontend/resources/flowy_icons/20x/password_close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/20x/show_password.svg b/frontend/resources/flowy_icons/20x/show_password.svg new file mode 100644 index 0000000000..ac8d092b37 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/show_password.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/sign_in_settings.svg b/frontend/resources/flowy_icons/20x/sign_in_settings.svg new file mode 100644 index 0000000000..5d88d23086 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/sign_in_settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/slash_menu_image.svg b/frontend/resources/flowy_icons/20x/slash_menu_image.svg new file mode 100644 index 0000000000..f5b7917ad3 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/slash_menu_image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_ai_improve_writing.svg b/frontend/resources/flowy_icons/20x/toolbar_ai_improve_writing.svg new file mode 100644 index 0000000000..dd0390d2d5 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_ai_improve_writing.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_ai_writer.svg b/frontend/resources/flowy_icons/20x/toolbar_ai_writer.svg new file mode 100644 index 0000000000..a8c8657135 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_ai_writer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_alignment.svg b/frontend/resources/flowy_icons/20x/toolbar_alignment.svg new file mode 100644 index 0000000000..638ff3ece8 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_alignment.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_arrow_down.svg b/frontend/resources/flowy_icons/20x/toolbar_arrow_down.svg new file mode 100644 index 0000000000..e6ef664403 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_arrow_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_arrow_right.svg b/frontend/resources/flowy_icons/20x/toolbar_arrow_right.svg new file mode 100644 index 0000000000..2e39539ab0 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_arrow_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_bold.svg b/frontend/resources/flowy_icons/20x/toolbar_bold.svg new file mode 100644 index 0000000000..a131c6aa3e --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_check.svg b/frontend/resources/flowy_icons/20x/toolbar_check.svg new file mode 100644 index 0000000000..e59186292c --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_check.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_inline_code.svg b/frontend/resources/flowy_icons/20x/toolbar_inline_code.svg new file mode 100644 index 0000000000..c263b0c66b --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_inline_code.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_inline_italic.svg b/frontend/resources/flowy_icons/20x/toolbar_inline_italic.svg new file mode 100644 index 0000000000..bc17a7b05b --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_inline_italic.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link.svg b/frontend/resources/flowy_icons/20x/toolbar_link.svg new file mode 100644 index 0000000000..8564d243d0 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg b/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg new file mode 100644 index 0000000000..57cb67da9a --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg b/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg new file mode 100644 index 0000000000..fc8765fa5b --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg b/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg new file mode 100644 index 0000000000..e1061b914a --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_more.svg b/frontend/resources/flowy_icons/20x/toolbar_more.svg new file mode 100644 index 0000000000..d156f313a1 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_more.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_align_center.svg b/frontend/resources/flowy_icons/20x/toolbar_text_align_center.svg new file mode 100644 index 0000000000..87c67115fb --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_align_center.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_align_left.svg b/frontend/resources/flowy_icons/20x/toolbar_text_align_left.svg new file mode 100644 index 0000000000..bcaebfe5d0 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_align_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_align_right.svg b/frontend/resources/flowy_icons/20x/toolbar_text_align_right.svg new file mode 100644 index 0000000000..68069290ce --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_align_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_color.svg b/frontend/resources/flowy_icons/20x/toolbar_text_color.svg new file mode 100644 index 0000000000..e96b00ac35 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_color.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_format.svg b/frontend/resources/flowy_icons/20x/toolbar_text_format.svg new file mode 100644 index 0000000000..0f3cd07a01 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_format.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_text_highlight.svg b/frontend/resources/flowy_icons/20x/toolbar_text_highlight.svg new file mode 100644 index 0000000000..ab53a64b38 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_text_highlight.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_underline.svg b/frontend/resources/flowy_icons/20x/toolbar_underline.svg new file mode 100644 index 0000000000..ea467a45d6 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_underline.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/turninto.svg b/frontend/resources/flowy_icons/20x/turninto.svg new file mode 100644 index 0000000000..598b870ec7 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/turninto.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_bulleted_list.svg b/frontend/resources/flowy_icons/20x/type_bulleted_list.svg new file mode 100644 index 0000000000..bc726f59ec --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_bulleted_list.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/20x/type_callout.svg b/frontend/resources/flowy_icons/20x/type_callout.svg new file mode 100644 index 0000000000..a933b4bbb3 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_callout.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_font.svg b/frontend/resources/flowy_icons/20x/type_font.svg new file mode 100644 index 0000000000..d0b33b0277 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_font.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_formula.svg b/frontend/resources/flowy_icons/20x/type_formula.svg new file mode 100644 index 0000000000..316c225b79 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_formula.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_h1.svg b/frontend/resources/flowy_icons/20x/type_h1.svg new file mode 100644 index 0000000000..a6a7f561cf --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_h1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_h2.svg b/frontend/resources/flowy_icons/20x/type_h2.svg new file mode 100644 index 0000000000..9bba1b7d33 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_h2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_h3.svg b/frontend/resources/flowy_icons/20x/type_h3.svg new file mode 100644 index 0000000000..3b231df67d --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_h3.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_numbered_list.svg b/frontend/resources/flowy_icons/20x/type_numbered_list.svg new file mode 100644 index 0000000000..23046f9b34 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_numbered_list.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/20x/type_page.svg b/frontend/resources/flowy_icons/20x/type_page.svg new file mode 100644 index 0000000000..405548fcf7 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_page.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_quote.svg b/frontend/resources/flowy_icons/20x/type_quote.svg new file mode 100644 index 0000000000..3564d92ff8 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_quote.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_strikethrough.svg b/frontend/resources/flowy_icons/20x/type_strikethrough.svg new file mode 100644 index 0000000000..dbf4e86116 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_strikethrough.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_text.svg b/frontend/resources/flowy_icons/20x/type_text.svg new file mode 100644 index 0000000000..40335aa89b --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_text.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_todo.svg b/frontend/resources/flowy_icons/20x/type_todo.svg new file mode 100644 index 0000000000..3d4f38ae9f --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_todo.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_toggle_h1.svg b/frontend/resources/flowy_icons/20x/type_toggle_h1.svg new file mode 100644 index 0000000000..45cc7d3859 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_toggle_h1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_toggle_h2.svg b/frontend/resources/flowy_icons/20x/type_toggle_h2.svg new file mode 100644 index 0000000000..3dce8523e8 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_toggle_h2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_toggle_h3.svg b/frontend/resources/flowy_icons/20x/type_toggle_h3.svg new file mode 100644 index 0000000000..e619a5f250 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_toggle_h3.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/type_toggle_list.svg b/frontend/resources/flowy_icons/20x/type_toggle_list.svg new file mode 100644 index 0000000000..2cb1e83599 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/type_toggle_list.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/calendar_layout.svg b/frontend/resources/flowy_icons/24x/calendar_layout.svg deleted file mode 100644 index 52e06e9111..0000000000 --- a/frontend/resources/flowy_icons/24x/calendar_layout.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/resources/flowy_icons/24x/check.svg b/frontend/resources/flowy_icons/24x/check.svg new file mode 100644 index 0000000000..9c158465f7 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/check.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/database_layout.svg b/frontend/resources/flowy_icons/24x/database_layout.svg deleted file mode 100644 index 240a5065d8..0000000000 --- a/frontend/resources/flowy_icons/24x/database_layout.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/frontend/resources/flowy_icons/24x/favorite_header_icon.svg b/frontend/resources/flowy_icons/24x/favorite_header_icon.svg new file mode 100644 index 0000000000..5b0ddbb6c5 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/favorite_header_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/m_board_thumbnail.svg b/frontend/resources/flowy_icons/24x/m_board_thumbnail.svg new file mode 100644 index 0000000000..685745b0b1 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_board_thumbnail.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_calendar_thumbnail.svg b/frontend/resources/flowy_icons/24x/m_calendar_thumbnail.svg new file mode 100644 index 0000000000..128f7b8377 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_calendar_thumbnail.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/m_chat_thumbnail.svg b/frontend/resources/flowy_icons/24x/m_chat_thumbnail.svg new file mode 100644 index 0000000000..f0ff36290a --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_chat_thumbnail.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_document_thumbnail.svg b/frontend/resources/flowy_icons/24x/m_document_thumbnail.svg new file mode 100644 index 0000000000..bf4ce416d9 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_document_thumbnail.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/m_grid_thumbnail.svg b/frontend/resources/flowy_icons/24x/m_grid_thumbnail.svg new file mode 100644 index 0000000000..36f9f1196c --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_grid_thumbnail.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/m_home_active_notification.svg b/frontend/resources/flowy_icons/24x/m_home_active_notification.svg new file mode 100644 index 0000000000..17cd501184 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_home_active_notification.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_home_add.svg b/frontend/resources/flowy_icons/24x/m_home_add.svg new file mode 100644 index 0000000000..bf93cf5e68 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_home_add.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_home_notification.svg b/frontend/resources/flowy_icons/24x/m_home_notification.svg new file mode 100644 index 0000000000..6d4b883701 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_home_notification.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_home_selected.svg b/frontend/resources/flowy_icons/24x/m_home_selected.svg new file mode 100644 index 0000000000..b92efcd453 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_home_selected.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_home_unselected.svg b/frontend/resources/flowy_icons/24x/m_home_unselected.svg new file mode 100644 index 0000000000..495c73923c --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_home_unselected.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_publish.svg b/frontend/resources/flowy_icons/24x/m_publish.svg new file mode 100644 index 0000000000..de50eea53a --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_publish.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_setting.svg b/frontend/resources/flowy_icons/24x/m_setting.svg index d8be6a36b7..a32f3bc7b3 100644 --- a/frontend/resources/flowy_icons/24x/m_setting.svg +++ b/frontend/resources/flowy_icons/24x/m_setting.svg @@ -1,3 +1,6 @@ - - + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_table_text_color.svg b/frontend/resources/flowy_icons/24x/m_table_text_color.svg new file mode 100644 index 0000000000..bd8253ea0d --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_table_text_color.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_unpublish.svg b/frontend/resources/flowy_icons/24x/m_unpublish.svg new file mode 100644 index 0000000000..b7ed4a5902 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_unpublish.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/new_app.svg b/frontend/resources/flowy_icons/24x/new_app.svg new file mode 100644 index 0000000000..77b11173d8 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/new_app.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/settings_billing.svg b/frontend/resources/flowy_icons/24x/settings_billing.svg new file mode 100644 index 0000000000..a9f45f7123 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/settings_billing.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/settings_plan.svg b/frontend/resources/flowy_icons/24x/settings_plan.svg index 5c6f53f836..3c6f05400f 100644 --- a/frontend/resources/flowy_icons/24x/settings_plan.svg +++ b/frontend/resources/flowy_icons/24x/settings_plan.svg @@ -1,8 +1,8 @@ - + - - + + diff --git a/frontend/resources/flowy_icons/24x/settings_selected_theme.svg b/frontend/resources/flowy_icons/24x/settings_selected_theme.svg new file mode 100644 index 0000000000..d6c6b6d809 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/settings_selected_theme.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/show.svg b/frontend/resources/flowy_icons/24x/show.svg index 3550115093..0d411e5cc4 100644 --- a/frontend/resources/flowy_icons/24x/show.svg +++ b/frontend/resources/flowy_icons/24x/show.svg @@ -1,4 +1,5 @@ - - + + + diff --git a/frontend/resources/flowy_icons/24x/sidebar_footer_trash.svg b/frontend/resources/flowy_icons/24x/sidebar_footer_trash.svg new file mode 100644 index 0000000000..02122ae127 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/sidebar_footer_trash.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/textdirection_auto.svg b/frontend/resources/flowy_icons/24x/textdirection_auto.svg new file mode 100644 index 0000000000..aed6289787 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/textdirection_auto.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/textdirection_ltr.svg b/frontend/resources/flowy_icons/24x/textdirection_ltr.svg new file mode 100644 index 0000000000..9fc05cf0ba --- /dev/null +++ b/frontend/resources/flowy_icons/24x/textdirection_ltr.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/textdirection_rtl.svg b/frontend/resources/flowy_icons/24x/textdirection_rtl.svg new file mode 100644 index 0000000000..62d87cef77 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/textdirection_rtl.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/40x/flowy_logo.svg b/frontend/resources/flowy_icons/40x/app_logo.svg similarity index 100% rename from frontend/resources/flowy_icons/40x/flowy_logo.svg rename to frontend/resources/flowy_icons/40x/app_logo.svg diff --git a/frontend/resources/flowy_icons/40x/flowy_logo_dark_mode.svg b/frontend/resources/flowy_icons/40x/app_logo_with_text_dark.svg similarity index 100% rename from frontend/resources/flowy_icons/40x/flowy_logo_dark_mode.svg rename to frontend/resources/flowy_icons/40x/app_logo_with_text_dark.svg diff --git a/frontend/resources/flowy_icons/40x/flowy_logo_text.svg b/frontend/resources/flowy_icons/40x/app_logo_with_text_light.svg similarity index 100% rename from frontend/resources/flowy_icons/40x/flowy_logo_text.svg rename to frontend/resources/flowy_icons/40x/app_logo_with_text_light.svg diff --git a/frontend/resources/flowy_icons/40x/embed_error.svg b/frontend/resources/flowy_icons/40x/embed_error.svg new file mode 100644 index 0000000000..68196c7b7e --- /dev/null +++ b/frontend/resources/flowy_icons/40x/embed_error.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/40x/icon_warning.svg b/frontend/resources/flowy_icons/40x/icon_warning.svg new file mode 100644 index 0000000000..abdf5fb20d --- /dev/null +++ b/frontend/resources/flowy_icons/40x/icon_warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/40x/m_apple_icon.svg b/frontend/resources/flowy_icons/40x/m_apple_icon.svg new file mode 100644 index 0000000000..5ec0b2627c --- /dev/null +++ b/frontend/resources/flowy_icons/40x/m_apple_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/40x/m_discord_icon.svg b/frontend/resources/flowy_icons/40x/m_discord_icon.svg new file mode 100644 index 0000000000..12842e2100 --- /dev/null +++ b/frontend/resources/flowy_icons/40x/m_discord_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/40x/m_empty_notification.svg b/frontend/resources/flowy_icons/40x/m_empty_notification.svg new file mode 100644 index 0000000000..f1ec21a65c --- /dev/null +++ b/frontend/resources/flowy_icons/40x/m_empty_notification.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/40x/m_empty_page.svg b/frontend/resources/flowy_icons/40x/m_empty_page.svg new file mode 100644 index 0000000000..c64507dd0a --- /dev/null +++ b/frontend/resources/flowy_icons/40x/m_empty_page.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/40x/m_empty_trash.svg b/frontend/resources/flowy_icons/40x/m_empty_trash.svg new file mode 100644 index 0000000000..1cb0d4ba15 --- /dev/null +++ b/frontend/resources/flowy_icons/40x/m_empty_trash.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/resources/flowy_icons/40x/m_github_icon.svg b/frontend/resources/flowy_icons/40x/m_github_icon.svg new file mode 100644 index 0000000000..000c5f9213 --- /dev/null +++ b/frontend/resources/flowy_icons/40x/m_github_icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/40x/m_google_icon.svg b/frontend/resources/flowy_icons/40x/m_google_icon.svg new file mode 100644 index 0000000000..3f1aaeafe8 --- /dev/null +++ b/frontend/resources/flowy_icons/40x/m_google_icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/resources/translations/am-ET.json b/frontend/resources/translations/am-ET.json index b07649f0e4..9cebbf8241 100644 --- a/frontend/resources/translations/am-ET.json +++ b/frontend/resources/translations/am-ET.json @@ -358,7 +358,7 @@ "email": "ኢሜል", "tooltipSelectIcon": "አዶን ይምረጡ", "selectAnIcon": "አዶን ይምረጡ", - "pleaseInputYourOpenAIKey": "እባክዎን OpenAI ቁልፍዎን ያስገቡ", + "pleaseInputYourOpenAIKey": "እባክዎን AI ቁልፍዎን ያስገቡ", "pleaseInputYourStabilityAIKey": "እባክዎ Stability AI ቁልፍን ያስገቡ", "clickToLogout": "የአሁኑን ተጠቃሚ ለመግባት ጠቅ ያድርጉ" }, @@ -556,12 +556,12 @@ "referencedBoard": "ማጣቀሻ ቦርድ", "referencedGrid": "ማጣቀሻ ፍርግርግ", "referencedCalendar": "የቀን ቀን መቁጠሪያ", - "autoGeneratorMenuItemName": "OpenAI ጸሐፊ", - "autoGeneratorTitleName": "OpenAI ማንኛውንም ነገር እንዲጽፉ ይጠይቁ ...", + "autoGeneratorMenuItemName": "AI ጸሐፊ", + "autoGeneratorTitleName": "AI ማንኛውንም ነገር እንዲጽፉ ይጠይቁ ...", "autoGeneratorLearnMore": "ተጨማሪ እወቅ", "autoGeneratorGenerate": "ማመንጨት", - "autoGeneratorHintText": "OpenAI ይጠይቁ ...", - "autoGeneratorCantGetOpenAIKey": "የ OpenAI ቁልፍ ማግኘት አልተቻለም", + "autoGeneratorHintText": "AI ይጠይቁ ...", + "autoGeneratorCantGetOpenAIKey": "የ AI ቁልፍ ማግኘት አልተቻለም", "autoGeneratorRewrite": "እንደገና ይፃፉ", "smartEdit": "ረዳቶች", "openAI": "ኦፔና", @@ -572,7 +572,7 @@ "smartEditMakeLonger": "ረዘም ላለ ጊዜ ያድርጉ", "smartEditCouldNotFetchResult": "ከOpenAI ውጤት ማምለጥ አልተቻለም", "smartEditCouldNotFetchKey": "ኦፕናይ ቁልፍን ማጣት አልተቻለም", - "smartEditDisabled": "በቅንብሮች ውስጥ OpenAI ያገናኙ", + "smartEditDisabled": "በቅንብሮች ውስጥ AI ያገናኙ", "discardResponse": "የ AI ምላሾችን መጣል ይፈልጋሉ?", "createInlineMathEquation": "እኩልነት ይፍጠሩ", "toggleList": "የተስተካከለ ዝርዝር", @@ -657,8 +657,8 @@ "placeholder": "የምስል ዩአርኤል ያስገቡ" }, "ai": { - "label": "ምስል OpenAI ውስጥ ምስልን ማመንጨት", - "placeholder": "ምስልን ለማመንጨት እባክዎን ለ OpenAI ይጠይቁ" + "label": "ምስል AI ውስጥ ምስልን ማመንጨት", + "placeholder": "ምስልን ለማመንጨት እባክዎን ለ AI ይጠይቁ" }, "stability_ai": { "label": "ምስልን Stability AI ያመነጫል", diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index 84d0f64985..e8ca8c4ceb 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -2,12 +2,14 @@ "appName": "AppFlowy", "defaultUsername": "أنا", "welcomeText": "مرحبًا بك في @: appName", + "welcomeTo": "مرحبا بكم في", "githubStarText": "نجمة على GitHub", "subscribeNewsletterText": "اشترك في النشرة الإخبارية", "letsGoButtonText": "بداية سريعة", "title": "عنوان", "youCanAlso": "بامكانك ايضا", "and": "و", + "failedToOpenUrl": "فشل في فتح الرابط: {}", "blockActions": { "addBelowTooltip": "انقر للإضافة أدناه", "addAboveCmd": "Alt + انقر", @@ -34,16 +36,39 @@ "loginButtonText": "تسجيل الدخول", "loginStartWithAnonymous": "ابدأ بجلسة خفية", "continueAnonymousUser": "استمر بجلسة خفية", + "anonymous": "مجهول", "buttonText": "تسجيل الدخول", + "signingInText": "جاري تسجيل الدخول...", "forgotPassword": "هل نسيت كلمة السر؟", "emailHint": "بريد إلكتروني", "passwordHint": "كلمة المرور", "dontHaveAnAccount": "ليس لديك حساب؟", + "createAccount": "إنشاء حساب", "repeatPasswordEmptyError": "إعادة كلمة المرور لا يمكن أن تكون فارغة", "unmatchedPasswordError": "تكرار كلمة المرور ليس هو نفسه كلمة المرور", "syncPromptMessage": "قد تستغرق مزامنة البيانات بعض الوقت. من فضلك لا تغلق هذه الصفحة", "or": "أو", + "signInWithGoogle": "استكمال باستخدام Google", + "signInWithGithub": "استكمال باستخدام Github", + "signInWithDiscord": "استكمال باستخدام Discord", + "signInWithApple": "استكمال باستخدام Apple", + "continueAnotherWay": "استكمال بطريقة أخرى", + "signUpWithGoogle": "سجل باستخدام Google", + "signUpWithGithub": "سجل باستخدام Github", + "signUpWithDiscord": "سجل باستخدام Discord", "signInWith": "تسجيل الدخول ب:", + "signInWithEmail": "متابعة باستخدام البريد الإلكتروني", + "signInWithMagicLink": "استكمال", + "signUpWithMagicLink": "سجل باستخدام Magic Link", + "pleaseInputYourEmail": "الرجاء إدخال عنوان بريدك الإلكتروني", + "settings": "إعدادات", + "magicLinkSent": "تم إرسال Magic Link!", + "invalidEmail": "يرجى إدخال عنوان بريد إلكتروني صالح", + "alreadyHaveAnAccount": "هل لديك حساب؟", + "logIn": "تسجيل الدخول", + "generalError": "حدث خطأ ما. يرجى المحاولة مرة أخرى لاحقًا", + "limitRateError": "لأسباب أمنية، يمكنك طلب Magic Link كل 60 ثانية فقط", + "magicLinkSentDescription": "تم إرسال Magic Link إلى بريدك الإلكتروني. انقر على الرابط لإكمال تسجيل الدخول. ستنتهي صلاحية الرابط بعد 5 دقائق.", "LogInWithGoogle": "تسجيل الدخول عبر جوجل", "LogInWithGithub": "تسجيل الدخول مع جيثب", "LogInWithDiscord": "تسجيل الدخول مع ديسكورد", @@ -51,23 +76,68 @@ }, "workspace": { "chooseWorkspace": "اختر مساحة العمل الخاصة بك", + "defaultName": "مساحة العمل الخاصة بي", "create": "قم بإنشاء مساحة عمل", + "new": "مساحة عمل جديدة", + "importFromNotion": "الاستيراد من Notion", + "learnMore": "تعلم المزيد", "reset": "إعادة تعيين مساحة العمل", + "renameWorkspace": "إعادة تسمية مساحة العمل", + "workspaceNameCannotBeEmpty": "لا يمكن أن يكون اسم مساحة العمل فارغًا", "resetWorkspacePrompt": "ستؤدي إعادة تعيين مساحة العمل إلى حذف جميع الصفحات والبيانات الموجودة بداخلها. هل أنت متأكد أنك تريد إعادة تعيين مساحة العمل؟ وبدلاً من ذلك، يمكنك الاتصال بفريق الدعم لاستعادة مساحة العمل", "hint": "مساحة العمل", "notFoundError": "مساحة العمل غير موجودة", - "failedToLoad": "هناك خطأ ما! فشل تحميل مساحة العمل. حاول إغلاق أي مثيل مفتوح لـ AppFlowy وحاول مرة أخرى.", + "failedToLoad": "هناك خطأ ما! فشل تحميل مساحة العمل. حاول إغلاق أي مثيل مفتوح لـ @:appName وحاول مرة أخرى.", "errorActions": { "reportIssue": "بلغ عن خطأ", + "reportIssueOnGithub": "الإبلاغ عن مشكلة على Github", + "exportLogFiles": "تصدير ملفات السجل", "reachOut": "تواصل مع ديسكورد" - } + }, + "menuTitle": "مساحات العمل", + "deleteWorkspaceHintText": " هل أنت متأكد من أنك تريد حذف مساحة العمل؟ لا يمكن التراجع عن هذا الإجراء، وستختفي أي صفحات قمت بنشرها سابقاً.", + "createSuccess": "تم إنشاء مساحة العمل بنجاح", + "createFailed": "فشل في إنشاء مساحة العمل", + "createLimitExceeded": "لقد وصلت إلى الحد الأقصى لمساحة العمل المسموح بها لحسابك. إذا كنت بحاجة إلى مساحات عمل إضافية لمواصلة عملك، فيرجى تقديم طلب على Github", + "deleteSuccess": "تم حذف مساحة العمل بنجاح", + "deleteFailed": "فشل في حذف مساحة العمل", + "openSuccess": "تم فتح مساحة العمل بنجاح", + "openFailed": "فشل في فتح مساحة العمل", + "renameSuccess": "تم إعادة تسمية مساحة العمل بنجاح", + "renameFailed": "فشل في إعادة تسمية مساحة العمل", + "updateIconSuccess": "تم تحديث أيقونة مساحة العمل بنجاح", + "updateIconFailed": "فشل تحديث أيقونة مساحة العمل", + "cannotDeleteTheOnlyWorkspace": "لا يمكن حذف مساحة العمل الوحيدة", + "fetchWorkspacesFailed": "فشل في الوصول لمساحات العمل", + "leaveCurrentWorkspace": "مغادرة مساحة العمل", + "leaveCurrentWorkspacePrompt": "هل أنت متأكد أنك تريد مغادرة مساحة العمل الحالية؟" }, "shareAction": { "buttonText": "مشاركه", "workInProgress": "قريباً", "markdown": "Markdown", + "html": "HTML", + "clipboard": "نسخ إلى الحافظة", "csv": "CSV", - "copyLink": "نسخ الرابط" + "copyLink": "نسخ الرابط", + "publishToTheWeb": "نشر على الويب", + "publishToTheWebHint": "إنشاء موقع ويب مع AppFlowy", + "publish": "نشر", + "unPublish": "التراجع عن النشر", + "visitSite": "زيارة الموقع", + "exportAsTab": "تصدير كـ", + "publishTab": "نشر", + "shareTab": "مشاركة", + "publishOnAppFlowy": "نشر على AppFlowy", + "shareTabTitle": "دعوة للتعاون", + "shareTabDescription": "من أجل التعاون السهل مع أي شخص", + "copyLinkSuccess": "تم نسخ الرابط إلى الحافظة", + "copyShareLink": "نسخ رابط المشاركة", + "copyLinkFailed": "فشل في نسخ الرابط إلى الحافظة", + "copyLinkToBlockSuccess": "تم نسخ رابط الكتلة إلى الحافظة", + "copyLinkToBlockFailed": "فشل في نسخ رابط الكتلة إلى الحافظة", + "manageAllSites": "إدارة كافة المواقع", + "updatePathName": "تحديث اسم المسار" }, "moreAction": { "small": "صغير", @@ -75,15 +145,36 @@ "large": "كبير", "fontSize": "حجم الخط", "import": "استيراد", - "moreOptions": "المزيد من الخيارات" + "moreOptions": "المزيد من الخيارات", + "wordCount": "عدد الكلمات: {}", + "charCount": "عدد الأحرف: {}", + "createdAt": "منشأ: {}", + "deleteView": "يمسح", + "duplicateView": "تكرار", + "wordCountLabel": "عدد الكلمات: ", + "charCountLabel": "عدد الأحرف: ", + "createdAtLabel": "تم إنشاؤه: ", + "syncedAtLabel": "تم المزامنة: ", + "saveAsNewPage": "حفظ الرسائل في الصفحة", + "saveAsNewPageDisabled": "لا توجد رسائل متاحة" }, "importPanel": { "textAndMarkdown": "نص و Markdown", "documentFromV010": "مستند من الإصدار 0.1.0", "databaseFromV010": "قاعدة بيانات من الإصدار 0.1.0", + "notionZip": "ملف Zip المُصدَّر من Notion", "csv": "CSV", "database": "قاعدة البيانات" }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "اسحب وأفلِت ملفًا، وانقر فوقه ", + "placeholderUpload": "رفع", + "placeholderRight": "أو قم بلصق رابط الصورة.", + "dropToUpload": "إفلات ملف لتحميله", + "change": "تغير" + } + }, "disclosureAction": { "rename": "إعادة تسمية", "delete": "يمسح", @@ -93,7 +184,12 @@ "openNewTab": "افتح في علامة تبويب جديدة", "moveTo": "نقل إلى", "addToFavorites": "اضافة الى المفضلة", - "copyLink": "نسخ الرابط" + "copyLink": "نسخ الرابط", + "changeIcon": "تغيير الأيقونة", + "collapseAllPages": "طي جميع الصفحات الفرعية", + "movePageTo": "تحريك الصفحة إلى", + "move": "تحريك", + "lockPage": "إلغاء تأمين الصفحة" }, "blankPageTitle": "صفحة فارغة", "newPageText": "صفحة جديدة", @@ -101,9 +197,83 @@ "newGridText": "شبكة جديدة", "newCalendarText": "تقويم جديد", "newBoardText": "سبورة جديدة", + "chat": { + "newChat": "الدردشة بالذكاء الاصطناعي", + "inputMessageHint": "اسأل @:appName AI", + "inputLocalAIMessageHint": "اسأل @:appName Local AI", + "unsupportedCloudPrompt": "هذه الميزة متوفرة فقط عند استخدام @:appName Cloud", + "relatedQuestion": "ذات صلة", + "serverUnavailable": "الخدمة غير متاحة مؤقتًا. يرجى المحاولة مرة أخرى لاحقًا.", + "aiServerUnavailable": "تم فقد الاتصال. يرجى التحقق من اتصالك بالإنترنت", + "retry": "إعادة المحاولة", + "clickToRetry": "انقر لإعادة المحاولة", + "regenerateAnswer": "إعادة توليد", + "question1": "كيفية استخدام كانبان لإدارة المهام", + "question2": "شرح طريقة GTD", + "question3": "لماذا تستخدم Rust", + "question4": "وصفة بما في مطبخي", + "question5": "إنشاء رسم توضيحي لصفحتي", + "question6": "قم بإعداد قائمة بالمهام التي يجب القيام بها خلال الأسبوع القادم", + "aiMistakePrompt": "الذكاء الاصطناعي قد يرتكب أخطاء. تحقق من المعلومات المهمة.", + "chatWithFilePrompt": "هل تريد الدردشة مع الملف؟", + "indexFileSuccess": "تم فهرسة الملف بنجاح", + "inputActionNoPages": "لا توجد نتائج للصفحة", + "referenceSource": { + "zero": "تم العثور على 0 من مصادر", + "one": "تم العثور على {count} مصدر", + "other": "تم العثور على {count} مصادر" + }, + "clickToMention": "اذكر صفحة", + "uploadFile": "إرفاق ملفات PDF أو ملفات نصية أو ملفات Markdown", + "questionDetail": "مرحبًا {}! كيف يمكنني مساعدتك اليوم؟", + "indexingFile": "الفهرسة {}", + "generatingResponse": "توليد الاستجابة", + "selectSources": "اختر المصادر", + "currentPage": "الصفحة الحالية", + "sourcesLimitReached": "يمكنك فقط تحديد ما يصل إلى 3 مستندات من المستوى العلوي ومستنداتها الفرعية", + "sourceUnsupported": "نحن لا ندعم الدردشة مع قواعد البيانات في الوقت الحالي", + "regenerate": "حاول ثانية", + "addToPageButton": "أضف إلى الصفحة", + "addToPageTitle": "أضف رسالة إلى...", + "addToNewPage": "أضف إلى صفحة جديدة", + "addToNewPageName": "الرسائل المستخرجة من \"{}\"", + "addToNewPageSuccessToast": "تمت إضافة الرسالة إلى", + "openPagePreviewFailedToast": "فشل في فتح الصفحة", + "changeFormat": { + "actionButton": "تغيير التنسيق", + "confirmButton": "إعادة توليد مع هذا التنسيق", + "textOnly": "النص", + "imageOnly": "الصورة فقط", + "textAndImage": "النص والصورة", + "text": "الفقرة", + "bullet": "قائمة نقطية", + "number": "قائمة مرقمة", + "table": "الجدول", + "blankDescription": "تنسيق الاستجابة", + "defaultDescription": "الوضع التلقائي", + "textWithImageDescription": "@:chat.changeFormat.text مع الصورة", + "numberWithImageDescription": "@:chat.changeFormat.number مع الصورة", + "bulletWithImageDescription": "@:chat.changeFormat.bullet مع الصورة", + "tableWithImageDescription": "@:chat.changeFormat.table مع الصورة" + }, + "switchModel": { + "label": "تبديل النموذج", + "localModel": "النموذج المحلي", + "cloudModel": "نموذج السحابة", + "autoModel": "آلي" + }, + "selectBanner": { + "saveButton": "أضف إلى...", + "selectMessages": "حدد الرسائل", + "nSelected": "{} تم التحديد", + "allSelected": "جميعها محددة" + }, + "stopTooltip": "توقف عن التوليد" + }, "trash": { "text": "المهملات", "restoreAll": "استعادة الكل", + "restore": "إسترجاع", "deleteAll": "حذف الكل", "pageHeader": { "fileName": "اسم الملف", @@ -118,37 +288,46 @@ "title": "هل أنت متأكد من استعادة جميع الصفحات في المهملات؟", "caption": "لا يمكن التراجع عن هذا الإجراء." }, + "restorePage": { + "title": "إسترجاع: {}", + "caption": "هل أنت متأكد أنك تريد استعادة هذه الصفحة؟" + }, "mobile": { "actions": "إجراءات سلة المهملات", "empty": "سلة المهملات فارغة", "emptyDescription": "ليس لديك أي ملفات محذوفة", "isDeleted": "محذوف", "isRestored": "تمت استعادته" - } + }, + "confirmDeleteTitle": "هل أنت متأكد أنك تريد حذف هذه الصفحة نهائياً؟" }, "deletePagePrompt": { "text": "هذه الصفحة في المهملات", "restore": "استعادة الصفحة", - "deletePermanent": "الحذف بشكل نهائي" + "deletePermanent": "الحذف بشكل نهائي", + "deletePermanentDescription": "هل أنت متأكد أنك تريد حذف هذه الصفحة بشكل دائم؟ هذا لا يمكن التراجع عنه." }, "dialogCreatePageNameHint": "اسم الصفحة", "questionBubble": { "shortcuts": "الاختصارات", "whatsNew": "ما هو الجديد؟", - "help": "المساعدة والدعم", + "helpAndDocumentation": "المساعدة والتوثيق", + "getSupport": "احصل على الدعم", "markdown": "Markdown", "debug": { "name": "معلومات التصحيح", "success": "تم نسخ معلومات التصحيح إلى الحافظة!", "fail": "تعذر نسخ معلومات التصحيح إلى الحافظة" }, - "feedback": "تعليق" + "feedback": "تعليق", + "help": "المساعدة والدعم" }, "menuAppHeader": { "moreButtonToolTip": "إزالة وإعادة تسمية والمزيد...", "addPageTooltip": "أضف صفحة في الداخل بسرعة", "defaultNewPageName": "بدون عنوان", - "renameDialog": "إعادة تسمية" + "renameDialog": "إعادة تسمية", + "pageNameSuffix": "نسخ" }, "noPagesInside": "لا توجد صفحات في الداخل", "toolbar": { @@ -178,17 +357,61 @@ "dragRow": "اضغط مطولاً لإعادة ترتيب الصف", "viewDataBase": "عرض قاعدة البيانات", "referencePage": "تمت الإشارة إلى هذا {name}", - "addBlockBelow": "إضافة كتلة أدناه" + "addBlockBelow": "إضافة كتلة أدناه", + "aiGenerate": "توليد" }, "sideBar": { "closeSidebar": "إغلاق الشريط الجانبي", "openSidebar": "فتح الشريط الجانبي", + "expandSidebar": "توسيع كصفحة كاملة", "personal": "شخصي", + "private": "خاص", + "workspace": "مساحة العمل", "favorites": "المفضلة", + "clickToHidePrivate": "انقر لإخفاء المساحة الخاصة\nالصفحات التي قمت بإنشائها هنا مرئية لك فقط", + "clickToHideWorkspace": "انقر لإخفاء مساحة العمل\nالصفحات التي قمت بإنشائها هنا تكون مرئية لكل الأعضاء", "clickToHidePersonal": "انقر لإخفاء القسم الشخصي", "clickToHideFavorites": "انقر لإخفاء القسم المفضل", "addAPage": "أضف صفحة", - "recent": "مؤخرًا" + "addAPageToPrivate": "أضف صفحة إلى مساحة خاصة", + "addAPageToWorkspace": "إضافة صفحة إلى مساحة العمل", + "recent": "مؤخرًا", + "today": "اليوم", + "thisWeek": "هذا الأسبوع", + "others": "المفضلات السابقة", + "earlier": "سابقًا", + "justNow": "الآن", + "minutesAgo": "منذ {count} دقيقة", + "lastViewed": "آخر مشاهدة", + "favoriteAt": "المفضلة", + "emptyRecent": "لا توجد صفحات حديثة", + "emptyRecentDescription": "عند عرض الصفحات، ستظهر هنا لسهولة استرجاعها.", + "emptyFavorite": "لا توجد صفحات مفضلة", + "emptyFavoriteDescription": "قم بوضع علامة على الصفحات كمفضلة - سيتم إدراجها هنا للوصول السريع!", + "removePageFromRecent": "هل تريد إزالة هذه الصفحة من القائمة الأخيرة؟", + "removeSuccess": "تمت الإزالة بنجاح", + "favoriteSpace": "المفضلة", + "RecentSpace": "مؤخرًا", + "Spaces": "المساحات", + "upgradeToPro": "الترقية إلى الإصدار الاحترافي", + "upgradeToAIMax": "إلغاء تأمين الذكاء الاصطناعي غير المحدود", + "storageLimitDialogTitle": "لقد نفدت مساحة التخزين المجانية لديك. قم بالترقية لإلغاء تأمين مساحة تخزين غير محدودة", + "storageLimitDialogTitleIOS": "لقد نفدت مساحة التخزين المجانية.", + "aiResponseLimitTitle": "لقد نفدت منك استجابات الذكاء الاصطناعي المجانية. قم بالترقية إلى الخطة الاحترافية أو قم بشراء إضافة الذكاء الاصطناعي لفتح عدد غير محدود من الاستجابات", + "aiResponseLimitDialogTitle": "تم الوصول إلى الحد الأقصى لاستجابات الذكاء الاصطناعي", + "aiResponseLimit": "لقد نفدت استجابات الذكاء الاصطناعي المجانية.\nانتقل إلى الإعدادات -> الخطة -> انقر فوق AI Max أو Pro Plan للحصول على المزيد من استجابات الذكاء الاصطناعي", + "askOwnerToUpgradeToPro": "مساحة العمل الخاصة بك نفدت من مساحة التخزين المجانية. يرجى مطالبة مالك مساحة العمل الخاصة بك بالترقية إلى الخطة الاحترافية", + "askOwnerToUpgradeToProIOS": "مساحة العمل الخاصة بك على وشك النفاد من مساحة التخزين المجانية.", + "askOwnerToUpgradeToAIMax": "لقد نفدت الاستجابات المجانية للذكاء الاصطناعي من مساحة العمل الخاصة بك. يرجى مطالبة مالك مساحة العمل الخاصة بك بترقية الخطة أو شراء إضافات الذكاء الاصطناعي", + "askOwnerToUpgradeToAIMaxIOS": "مساحة العمل الخاصة بك تفتقر إلى الاستجابات المجانية للذكاء الاصطناعي.", + "purchaseAIMax": "لقد نفدت استجابات الصور بالذكاء الاصطناعي من مساحة العمل الخاصة بك. يرجى مطالبة مالك مساحة العمل الخاصة بك بشراء AI Max", + "aiImageResponseLimit": "لقد نفدت استجابات الصور الخاصة بالذكاء الاصطناعي.\nانتقل إلى الإعدادات -> الخطة -> انقر فوق AI Max للحصول على المزيد من استجابات صور AI", + "purchaseStorageSpace": "شراء مساحة تخزين", + "singleFileProPlanLimitationDescription": "لقد تجاوزت الحد الأقصى لحجم تحميل الملف المسموح به في الخطة المجانية. يرجى الترقية إلى الخطة الاحترافية لتحميل ملفات أكبر حجمًا", + "purchaseAIResponse": "شراء", + "askOwnerToUpgradeToLocalAI": "اطلب من مالك مساحة العمل تمكين الذكاء الاصطناعي على الجهاز", + "upgradeToAILocal": "قم بتشغيل النماذج المحلية على جهازك لتحقيق أقصى قدر من الخصوصية", + "upgradeToAILocalDesc": "الدردشة باستخدام ملفات PDF، وتحسين كتابتك، وملء الجداول تلقائيًا باستخدام الذكاء الاصطناعي المحلي" }, "notifications": { "export": { @@ -204,6 +427,7 @@ }, "button": { "ok": "حسنا", + "confirm": "تأكيد", "done": "منتهي", "cancel": "الغاء", "signIn": "تسجيل الدخول", @@ -221,17 +445,48 @@ "upload": "رفع", "edit": "يحرر", "delete": "يمسح", + "copy": "النسخ", "duplicate": "ينسخ", "putback": "ضعها بالخلف", "update": "تحديث", "share": "مشاركة", "removeFromFavorites": "إزالة من المفضلة", + "removeFromRecent": "إزالة من الأحدث", "addToFavorites": "اضافة الى المفضلة", + "favoriteSuccessfully": "النجاح المفضل", + "unfavoriteSuccessfully": "النجاح غير المفضل", + "duplicateSuccessfully": "تم التكرار بنجاح", "rename": "إعادة تسمية", "helpCenter": "مركز المساعدة", "add": "اضافة", "yes": "نعم", + "no": "لا", + "clear": "مسح", + "remove": "حذف", + "dontRemove": "لا تقم بالإزالة", + "copyLink": "نسخ الرابط", + "align": "المحاذاة", + "login": "تسجيل الدخول", + "logout": "تسجيل الخروج", + "deleteAccount": "حذف الحساب", "back": "خلف", + "signInGoogle": "متابعة مع جوجل", + "signInGithub": "متابعة مع GitHub", + "signInDiscord": "متابعة مع Discord", + "more": "أكثر", + "create": "إنشاء", + "close": "إغلاق", + "next": "التالي", + "previous": "السابق", + "submit": "إرسال", + "download": "تحميل", + "backToHome": "العودة إلى الصفحة الرئيسية", + "viewing": "عرض", + "editing": "تحرير", + "gotIt": "فهمتها", + "retry": "إعادة المحاولة", + "uploadFailed": "فشل الرفع.", + "copyLinkOriginal": "نسخ الرابط إلى الأصل", "tryAGain": "حاول ثانية" }, "label": { @@ -256,6 +511,613 @@ }, "settings": { "title": "إعدادات", + "popupMenuItem": { + "settings": "إعدادات", + "members": "الأعضاء", + "trash": "سلة المحذوفات", + "helpAndDocumentation": "المساعدة والتوثيق", + "getSupport": "احصل على الدعم", + "helpAndSupport": "المساعدة والدعم" + }, + "sites": { + "title": "المواقع", + "namespaceTitle": "مساحة الاسم", + "namespaceDescription": "إدارة مساحة الاسم والصفحة الرئيسية الخاصة بك", + "namespaceHeader": "مساحة الاسم", + "homepageHeader": "الصفحة الرئيسية", + "updateNamespace": "تحديث مساحة الاسم", + "removeHomepage": "إزالة الصفحة الرئيسية", + "selectHomePage": "حدد الصفحة", + "clearHomePage": "مسح الصفحة الرئيسية لهذه المساحة الاسمية", + "customUrl": "عنوان URL مخصص", + "namespace": { + "description": "سيتم تطبيق هذا التغيير على جميع الصفحات المنشورة الموجودة على مساحة الاسم هذه بشكل فوري", + "tooltip": "نحن نحتفظ بالحق في إزالة أي مساحات أسماء غير مناسبة", + "updateExistingNamespace": "تحديث مساحة الاسم الحالية", + "upgradeToPro": "قم بالترقية إلى الخطة الاحترافية لتعيين الصفحة الرئيسية", + "redirectToPayment": "إعادة التوجيه إلى صفحة الدفع...", + "onlyWorkspaceOwnerCanSetHomePage": "يمكن فقط لمالك مساحة العمل تعيين الصفحة الرئيسية", + "pleaseAskOwnerToSetHomePage": "يرجى طلب الترقية إلى الخطة الاحترافية من مالك مساحة العمل" + }, + "publishedPage": { + "title": "جميع الصفحات المنشورة", + "description": "إدارة صفحاتك المنشورة", + "page": "صفحة", + "pathName": "اسم المسار", + "date": "تاريخ النشر", + "emptyHinText": "ليس لديك صفحات منشورة في مساحة العمل هذه", + "noPublishedPages": "لا توجد صفحات منشورة", + "settings": "نشر الإعدادات", + "clickToOpenPageInApp": "فتح الصفحة في التطبيق", + "clickToOpenPageInBrowser": "فتح الصفحة في المتصفح" + }, + "error": { + "failedToGeneratePaymentLink": "فشل في إنشاء رابط الدفع لخطة الاحترافية", + "failedToUpdateNamespace": "فشل في تحديث مساحة الاسم", + "proPlanLimitation": "يجب عليك الترقية إلى الخطة الاحترافية لتحديث مساحة الاسم", + "namespaceAlreadyInUse": "تم أخذ مساحة الاسم بالفعل، يرجى تجربة مساحة اسم أخرى", + "invalidNamespace": "مساحة اسم غير صالحة، يرجى تجربة مساحة اسم أخرى", + "namespaceLengthAtLeast2Characters": "يجب أن تكون مساحة الاسم بطول حرفين على الأقل", + "onlyWorkspaceOwnerCanUpdateNamespace": "يمكن فقط لمالك مساحة العمل تحديث مساحة الاسم", + "onlyWorkspaceOwnerCanRemoveHomepage": "لا يمكن إلا لمالك مساحة العمل إزالة الصفحة الرئيسية", + "setHomepageFailed": "فشل في تعيين الصفحة الرئيسية", + "namespaceTooLong": "مساحة الاسم طويلة جدًا، يرجى تجربة مساحة اسم أخرى", + "namespaceTooShort": "مساحة الاسم قصيرة جدًا، يرجى تجربة مساحة اسم أخرى", + "namespaceIsReserved": "تم حجز مساحة الاسم، يرجى تجربة مساحة اسم أخرى", + "updatePathNameFailed": "فشل في تحديث اسم المسار", + "removeHomePageFailed": "فشل في إزالة الصفحة الرئيسية", + "publishNameContainsInvalidCharacters": "يحتوي اسم المسار على أحرف غير صالحة، يرجى تجربة اسم مسار آخر", + "publishNameTooShort": "اسم المسار قصير جدًا، يرجى تجربة اسم مسار آخر", + "publishNameTooLong": "اسم المسار طويل جدًا، يرجى تجربة اسم مسار آخر", + "publishNameAlreadyInUse": "اسم المسار قيد الاستخدام بالفعل، يرجى تجربة اسم مسار آخر", + "namespaceContainsInvalidCharacters": "تحتوي مساحة الاسم على أحرف غير صالحة، يرجى تجربة مساحة اسم أخرى", + "publishPermissionDenied": "يمكن فقط لمالك مساحة العمل أو ناشر الصفحة إدارة إعدادات النشر", + "publishNameCannotBeEmpty": "لا يمكن أن يكون اسم المسار فارغًا، يرجى تجربة اسم مسار آخر" + }, + "success": { + "namespaceUpdated": "تم تحديث مساحة الاسم بنجاح", + "setHomepageSuccess": "تم تعيين الصفحة الرئيسية بنجاح", + "updatePathNameSuccess": "تم تحديث اسم المسار بنجاح", + "removeHomePageSuccess": "تم إزالة الصفحة الرئيسية بنجاح" + } + }, + "accountPage": { + "menuLabel": "حسابي", + "title": "حسابي", + "general": { + "title": "اسم الحساب وصورة الملف الشخصي", + "changeProfilePicture": "تغيير صورة الملف الشخصي" + }, + "email": { + "title": "بريد إلكتروني", + "actions": { + "change": "تغيير البريد الإلكتروني" + } + }, + "login": { + "title": "تسجيل الدخول إلى الحساب", + "loginLabel": "تسجيل الدخول", + "logoutLabel": "تسجيل الخروج" + }, + "isUpToDate": "تم تحديث @:appName !", + "officialVersion": "الإصدار {version} (الإصدار الرسمي)" + }, + "workspacePage": { + "menuLabel": "مساحة العمل", + "title": "مساحة العمل", + "description": "قم بتخصيص مظهر مساحة العمل الخاصة بك، والسمة، والخط، وتخطيط النص، وتنسيق التاريخ/الوقت، واللغة.", + "workspaceName": { + "title": "اسم مساحة العمل" + }, + "workspaceIcon": { + "title": "أيقونة مساحة العمل", + "description": "قم بتحميل صورة أو استخدم رمزًا تعبيريًا لمساحة عملك. ستظهر الأيقونة في الشريط الجانبي والإشعارات." + }, + "appearance": { + "title": "المظهر", + "description": "قم بتخصيص مظهر مساحة العمل الخاصة بك والموضوع والخط وتخطيط النص والتاريخ والوقت واللغة.", + "options": { + "system": "آلي", + "light": "فاتح", + "dark": "داكن" + } + }, + "resetCursorColor": { + "title": "إعادة تعيين لون مؤشر المستند", + "description": "هل أنت متأكد أنك تريد إعادة تعيين لون المؤشر؟" + }, + "resetSelectionColor": { + "title": "إعادة تعيين لون اختيار المستند", + "description": "هل أنت متأكد أنك تريد إعادة تعيين لون الإختيار؟" + }, + "resetWidth": { + "resetSuccess": "تم إعادة تعيين عرض المستند بنجاح" + }, + "theme": { + "title": "السمة", + "description": "حدد السمة المحددة مسبقًا، أو قم برفع موضوعك المخصص.", + "uploadCustomThemeTooltip": "رفع السمة المخصصة" + }, + "workspaceFont": { + "title": "خط مساحة العمل", + "noFontHint": "لم يتم العثور على الخط، حاول مصطلحًا آخر." + }, + "textDirection": { + "title": "اتجاه النص", + "leftToRight": "من اليسار إلى اليمين", + "rightToLeft": "من اليمين إلى اليسار", + "auto": "آلي", + "enableRTLItems": "تمكين عناصر شريط أدوات RTL" + }, + "layoutDirection": { + "title": "اتجاه التخطيط", + "leftToRight": "من اليسار إلى اليمين", + "rightToLeft": "من اليمين إلى اليسار" + }, + "dateTime": { + "title": "التاريخ والوقت", + "example": "{} في {} ({})", + "24HourTime": "الوقت بنظام 24 ساعة", + "dateFormat": { + "label": "تنسيق التاريخ", + "local": "محلي", + "us": "US أميركي", + "iso": "ايزو", + "friendly": "ودي", + "dmy": "يوم/شهر/سنة" + } + }, + "language": { + "title": "اللغة" + }, + "deleteWorkspacePrompt": { + "title": "حذف مساحة العمل", + "content": "هل أنت متأكد من أنك تريد حذف مساحة العمل هذه؟ لا يمكن التراجع عن هذا الإجراء، وسيتم إلغاء نشر أي صفحات قمت بنشرها." + }, + "leaveWorkspacePrompt": { + "title": "مغادرة مساحة العمل", + "content": "هل أنت متأكد من أنك تريد مغادرة مساحة العمل هذه؟ ستفقد إمكانية الوصول إلى جميع الصفحات والبيانات الموجودة فيها.", + "success": "لقد غادرت مساحة العمل بنجاح.", + "fail": "فشل في مغادرة مساحة العمل." + }, + "manageWorkspace": { + "title": "إدارة مساحة العمل", + "leaveWorkspace": "مغادرة مساحة العمل", + "deleteWorkspace": "حذف مساحة العمل" + } + }, + "manageDataPage": { + "menuLabel": "إدارة البيانات", + "title": "إدارة البيانات", + "description": "إدارة تخزين البيانات محليًا أو استيراد البيانات الموجودة لديك إلى @:appName .", + "dataStorage": { + "title": "موقع تخزين الملفات", + "tooltip": "الموقع الذي يتم تخزين ملفاتك فيه", + "actions": { + "change": "تغيير المسار", + "open": "افتح المجلد", + "openTooltip": "فتح موقع مجلد البيانات الحالي", + "copy": "نسخ المسار", + "copiedHint": "تم نسخ المسار!", + "resetTooltip": "إعادة التعيين إلى الموقع الافتراضي" + }, + "resetDialog": { + "title": "هل أنت متأكد؟", + "description": "لن يؤدي إعادة تعيين المسار إلى موقع البيانات الافتراضي إلى حذف بياناتك. إذا كنت تريد إعادة استيراد بياناتك الحالية، فيجب عليك نسخ مسار موقعك الحالي أولاً." + } + }, + "importData": { + "title": "استيراد البيانات", + "tooltip": "استيراد البيانات من مجلدات النسخ الاحتياطية/البيانات @:appName", + "description": "نسخ البيانات من مجلد بيانات خارجي @:appName", + "action": "تصفح الملف" + }, + "encryption": { + "title": "التشفير", + "tooltip": "إدارة كيفية تخزين بياناتك وتشفيرها", + "descriptionNoEncryption": "سيؤدي تشغيل التشفير إلى تشفير كافة البيانات. ولا يمكن التراجع عن هذا الإجراء.", + "descriptionEncrypted": "بياناتك مشفرة.", + "action": "تشفير البيانات", + "dialog": { + "title": "هل تريد تشفير كافة بياناتك؟", + "description": "سيؤدي تشفير جميع بياناتك إلى الحفاظ على بياناتك آمنة. لا يمكن التراجع عن هذا الإجراء. هل أنت متأكد من أنك تريد المتابعة؟" + } + }, + "cache": { + "title": "مسح ذاكرة التخزين المؤقت", + "description": "ساعد في حل مشكلات مثل عدم تحميل الصور، أو عدم ظهور الصفحات في مساحة، أو عدم تحميل الخطوط. لن يؤثر هذا على بياناتك.", + "dialog": { + "title": "مسح ذاكرة التخزين المؤقت", + "description": "ساعد في حل مشكلات مثل عدم تحميل الصور، أو عدم ظهور الصفحات في مساحة، أو عدم تحميل الخطوط. لن يؤثر هذا على بياناتك.", + "successHint": "تم مسح ذاكرة التخزين المؤقت!" + } + }, + "data": { + "fixYourData": "إصلاح بياناتك", + "fixButton": "إصلاح", + "fixYourDataDescription": "إذا كنت تواجه مشكلات مع بياناتك، فيمكنك محاولة إصلاحها هنا." + } + }, + "shortcutsPage": { + "menuLabel": "اختصارات", + "title": "اختصارات", + "editBindingHint": "إدخال ربط جديد", + "searchHint": "بحث", + "actions": { + "resetDefault": "إعادة ضبط الافتراضي" + }, + "errorPage": { + "message": "فشل تحميل الاختصارات: {}", + "howToFix": "يرجى المحاولة مرة أخرى، إذا استمرت المشكلة، فيرجى التواصل معنا عبر GitHub." + }, + "resetDialog": { + "title": "إعادة ضبط الاختصارات", + "description": "سيؤدي هذا إلى إعادة ضبط كافة ارتباطات المفاتيح الخاصة بك إلى الوضع الافتراضي، ولا يمكنك التراجع عن هذا لاحقًا، هل أنت متأكد من أنك تريد المتابعة؟", + "buttonLabel": "إعادة ضبط" + }, + "conflictDialog": { + "title": "{} قيد الاستخدام حاليًا", + "descriptionPrefix": "يتم استخدام ارتباطات المفاتيح هذه حاليًا بواسطة ", + "descriptionSuffix": "إذا قمت باستبدال ارتباط المفتاح هذا، فسيتم إزالته من {}.", + "confirmLabel": "متابعة" + }, + "editTooltip": "اضغط لبدء تحرير ربط المفتاح", + "keybindings": { + "toggleToDoList": "التبديل إلى قائمة المهام", + "insertNewParagraphInCodeblock": "إدراج فقرة جديدة", + "pasteInCodeblock": "لصق في كتلة الكود", + "selectAllCodeblock": "حدد الكل", + "indentLineCodeblock": "أدخل فراغين فاصلين في بداية السطر", + "outdentLineCodeblock": "حذف فراغين فاصلين في بداية السطر", + "twoSpacesCursorCodeblock": "إدراج فراغين فاصلين عند المؤشر", + "copy": "اختيار النسخ", + "paste": "لصق في المحتوى", + "cut": "اختيار القص", + "alignLeft": "محاذاة النص إلى اليسار", + "alignCenter": "محاذاة النص إلى الوسط", + "alignRight": "محاذاة النص إلى اليمين", + "insertInlineMathEquation": "إدراج معادلة رياضية مضمنة", + "undo": "التراجع", + "redo": "الإعادة", + "convertToParagraph": "تحويل الكتلة إلى فقرة", + "backspace": "الحذف", + "deleteLeftWord": "حذف الكلمة اليسرى", + "deleteLeftSentence": "حذف الجملة اليسرى", + "delete": "حذف الحرف الأيمن", + "deleteMacOS": "حذف الحرف الأيسر", + "deleteRightWord": "حذف الكلمة اليمنى", + "moveCursorLeft": "حرك المؤشر إلى اليسار", + "moveCursorBeginning": "حرك المؤشر إلى البداية", + "moveCursorLeftWord": "حرك المؤشر إلى اليسار كلمة واحدة", + "moveCursorLeftSelect": "حدد وحرك المؤشر إلى اليسار", + "moveCursorBeginSelect": "حدد وحرك المؤشر إلى البداية", + "moveCursorLeftWordSelect": "حدد وحرك المؤشر إلى اليسار كلمة واحدة", + "moveCursorRight": "حرك المؤشر إلى اليمين", + "moveCursorEnd": "حرك المؤشر إلى النهاية", + "moveCursorRightWord": "حرك المؤشر إلى اليمين كلمة واحدة", + "moveCursorRightSelect": "حدد المؤشر وحركه إلى اليمين", + "moveCursorEndSelect": "حدد وحرك المؤشر إلى النهاية", + "moveCursorRightWordSelect": "حدد وحرك المؤشر إلى اليمين كلمة واحدة", + "moveCursorUp": "حرك المؤشر لأعلى", + "moveCursorTopSelect": "حدد وحرك المؤشر إلى الأعلى", + "moveCursorTop": "حرك المؤشر إلى الأعلى", + "moveCursorUpSelect": "حدد وحرك المؤشر لأعلى", + "moveCursorBottomSelect": "حدد وحرك المؤشر إلى الأسفل", + "moveCursorBottom": "حرك المؤشر إلى الأسفل", + "moveCursorDown": "حرك المؤشر إلى الأسفل", + "moveCursorDownSelect": "حدد وحرك المؤشر لأسفل", + "home": "تمرير إلى الأعلى", + "end": "تمرير إلى الأسفل", + "toggleBold": "تبديل الخط الغامق", + "toggleItalic": "تبديل الخط المائل", + "toggleUnderline": "تبديل التسطير", + "toggleStrikethrough": "تبديل الشطب", + "toggleCode": "تبديل الكود المضمن", + "toggleHighlight": "تبديل التمييز", + "showLinkMenu": "عرض قائمة الروابط", + "openInlineLink": "افتح الرابط المضمن", + "openLinks": "فتح جميع الروابط المحددة", + "indent": "المسافة البادئة", + "outdent": "مسافة بادئة سلبية", + "exit": "الخروج من التحرير", + "pageUp": "قم بالتمرير صفحة واحدة لأعلى", + "pageDown": "قم بالتمرير صفحة واحدة لأسفل", + "selectAll": "حدد الكل", + "pasteWithoutFormatting": "لصق المحتوى بدون تنسيق", + "showEmojiPicker": "عرض أداة اختيار الرموز التعبيرية", + "enterInTableCell": "إضافة فاصل الأسطر في الجدول", + "leftInTableCell": "حرك خلية واحدة إلى اليسار في الجدول", + "rightInTableCell": "حرك خلية واحدة إلى اليمين في الجدول", + "upInTableCell": "التحرك لأعلى خلية واحدة في الجدول", + "downInTableCell": "التحرك لأسفل خلية واحدة في الجدول", + "tabInTableCell": "انتقل إلى الخلية التالية المتوفرة في الجدول", + "shiftTabInTableCell": "انتقل إلى الخلية المتوفرة سابقًا في الجدول", + "backSpaceInTableCell": "توقف في بداية الخلية" + }, + "commands": { + "codeBlockNewParagraph": "إدراج فقرة جديدة بجوار كتلة الكود", + "codeBlockIndentLines": "أدخل مسافتين في بداية السطر في كتلة الكود", + "codeBlockOutdentLines": "حذف مسافتين في بداية السطر في كتلة الكود", + "codeBlockAddTwoSpaces": "أدخل مسافتين في موضع المؤشر في كتلة الكود", + "codeBlockSelectAll": "تحديد كل المحتوى داخل كتلة الكود", + "codeBlockPasteText": "لصق النص في كتلة الكود", + "textAlignLeft": "محاذاة النص إلى اليسار", + "textAlignCenter": "محاذاة النص إلى الوسط", + "textAlignRight": "محاذاة النص إلى اليمين" + }, + "couldNotLoadErrorMsg": "لم نتمكن من تحميل الاختصارات، حاول مرة أخرى", + "couldNotSaveErrorMsg": "لم نتمكن من حفظ الاختصارات، حاول مرة أخرى" + }, + "aiPage": { + "title": "إعدادات الذكاء الاصطناعي", + "menuLabel": "إعدادات الذكاء الاصطناعي", + "keys": { + "enableAISearchTitle": "بحث الذكاء الاصطناعي", + "aiSettingsDescription": "اختر النموذج المفضل لديك لتشغيل AppFlowy AI. يتضمن الآن GPT 4-o وClaude 3,5 وLlama 3.1 وMistral 7B", + "loginToEnableAIFeature": "لا يتم تمكين ميزات الذكاء الاصطناعي إلا بعد تسجيل الدخول باستخدام @:appName Cloud. إذا لم يكن لديك حساب @:appName ، فانتقل إلى \"حسابي\" للتسجيل", + "llmModel": "نموذج اللغة", + "llmModelType": "نوع نموذج اللغة", + "downloadLLMPrompt": "تنزيل {}", + "downloadAppFlowyOfflineAI": "سيؤدي تنزيل حزمة AI دون اتصال بالإنترنت إلى تفعيل تشغيل AI على جهازك. هل تريد الاستمرار؟", + "downloadLLMPromptDetail": " تنزيل النموذج المحلي {} سيستخدم ما يصل إلى {} من مساحة التخزين. هل تريد الاستمرار؟", + "downloadBigFilePrompt": "قد يستغرق الأمر حوالي 10 دقائق لإكمال التنزيل", + "downloadAIModelButton": "تنزيل", + "downloadingModel": "جاري التنزيل", + "localAILoaded": "تمت إضافة نموذج الذكاء الاصطناعي المحلي بنجاح وهو جاهز للاستخدام", + "localAIStart": "بدأت الدردشة المحلية بالذكاء الاصطناعي...", + "localAILoading": "جاري تحميل نموذج الدردشة المحلية للذكاء الاصطناعي...", + "localAIStopped": "تم إيقاف الذكاء الاصطناعي المحلي", + "localAIRunning": "الذكاء الاصطناعي المحلي قيد التشغيل", + "localAINotReadyRetryLater": "جاري تهيئة الذكاء الاصطناعي المحلي، يرجى المحاولة مرة أخرى لاحقًا", + "localAIDisabled": "أنت تستخدم الذكاء الاصطناعي المحلي، ولكنه مُعطّل. يُرجى الانتقال إلى الإعدادات لتفعيله أو تجربة نموذج آخر.", + "localAIInitializing": "يتم تهيئة الذكاء الاصطناعي المحلي وقد يستغرق الأمر بضع دقائق، حسب جهازك", + "localAINotReadyTextFieldPrompt": "لا يمكنك التحرير أثناء تحميل الذكاء الاصطناعي المحلي", + "failToLoadLocalAI": "فشل في بدء تشغيل الذكاء الاصطناعي المحلي", + "restartLocalAI": "إعادة تشغيل الذكاء الاصطناعي المحلي", + "disableLocalAITitle": "تعطيل الذكاء الاصطناعي المحلي", + "disableLocalAIDescription": "هل تريد تعطيل الذكاء الاصطناعي المحلي؟", + "localAIToggleTitle": "التبديل لتفعيل أو تعطيل الذكاء الاصطناعي المحلي", + "localAIToggleSubTitle": "قم بتشغيل نماذج الذكاء الاصطناعي المحلية الأكثر تقدمًا داخل AppFlowy للحصول على أقصى درجات الخصوصية والأمان", + "offlineAIInstruction1": "اتبع", + "offlineAIInstruction2": "تعليمات", + "offlineAIInstruction3": "لتفعيل الذكاء الاصطناعي دون اتصال بالإنترنت.", + "offlineAIDownload1": "إذا لم تقم بتنزيل AppFlowy AI، فيرجى", + "offlineAIDownload2": "التنزيل", + "offlineAIDownload3": "إنه أولا", + "activeOfflineAI": "نشط", + "downloadOfflineAI": "التنزيل", + "openModelDirectory": "افتح المجلد", + "laiNotReady": "لم يتم تثبيت تطبيق الذكاء الاصطناعي المحلي بشكل صحيح.", + "ollamaNotReady": "خادم Ollama غير جاهز.", + "pleaseFollowThese": "اتبع هؤلاء", + "instructions": "التعليمات", + "installOllamaLai": "لإعداد Ollama وAppFlowy Local AI. تخطَّ هذه الخطوة إذا كنت قد قمت بإعدادها بالفعل", + "modelsMissing": "لم يتم العثور على النماذج المطلوبة.", + "downloadModel": "لتنزيلها.", + "startLocalAI": "قد يستغرق الأمر بضع ثوانٍ لبدء تشغيل الذكاء الاصطناعي المحلي" + } + }, + "planPage": { + "menuLabel": "الخطة", + "title": "خطة التسعير", + "planUsage": { + "title": "ملخص استخدام الخطة", + "storageLabel": "تخزين", + "storageUsage": "{} من {} جيجا بايت", + "unlimitedStorageLabel": "تخزين غير محدود", + "collaboratorsLabel": "أعضاء", + "collaboratorsUsage": "{} ل {}", + "aiResponseLabel": "استجابات الذكاء الاصطناعي", + "aiResponseUsage": "{} ل {}", + "unlimitedAILabel": "استجابات غير محدودة", + "proBadge": "احترافية", + "aiMaxBadge": "الذكاء الاصطناعي ماكس", + "aiOnDeviceBadge": "الذكاء الاصطناعي على الجهاز لنظام التشغيل Mac", + "memberProToggle": "مزيد من الأعضاء وذكاء اصطناعي غير محدود", + "aiMaxToggle": "ذكاء اصطناعي غير محدود وإمكانية الوصول إلى نماذج متقدمة", + "aiOnDeviceToggle": "الذكاء الاصطناعي المحلي لتحقيق الخصوصية القصوى", + "aiCredit": { + "title": "أضف رصيد الذكاء الاصطناعي @:appName", + "price": "{}", + "priceDescription": "مقابل 1000 نقطة", + "purchase": "شراء الذكاء الاصطناعي", + "info": "أضف 1000 أرصدة الذكاء الاصطناعي لكل مساحة عمل وقم بدمج الذكاء الاصطناعي القابل للتخصيص بسلاسة في سير عملك للحصول على نتائج أذكى وأسرع مع ما يصل إلى:", + "infoItemOne": "10000 استجابة لكل قاعدة بيانات", + "infoItemTwo": "1000 استجابة لكل مساحة عمل" + }, + "currentPlan": { + "bannerLabel": "الخطة الحالية", + "freeTitle": "مجاني", + "proTitle": "محترف", + "teamTitle": "فريق", + "freeInfo": "مثالي للأفراد حتى عضوين لتنظيم كل شيء", + "proInfo": "مثالي للفرق الصغيرة والمتوسطة التي يصل عددها إلى 10 أعضاء.", + "teamInfo": "مثالي لجميع الفرق المنتجة والمنظمة جيدًا.", + "upgrade": "تغيير الخطة", + "canceledInfo": "لقد تم إلغاء خطتك، وسيتم تخفيض مستوى خطتك إلى الخطة المجانية في {}." + }, + "addons": { + "title": "مَرافِق", + "addLabel": "إضافة", + "activeLabel": "تمت الإضافة", + "aiMax": { + "title": "الحد الأقصى للذكاء الاصطناعي", + "description": "استجابات الذكاء الاصطناعي غير المحدودة مدعومة بـ GPT-4o وClaude 3.5 Sonnet والمزيد", + "price": "{}", + "priceInfo": "لكل مستخدم شهريًا يتم تحصيل الرسوم سنويًا" + }, + "aiOnDevice": { + "title": "الذكاء الاصطناعي على الجهاز لنظام التشغيل Mac", + "description": "قم بتشغيل Mistral 7B وLLAMA 3 والمزيد من النماذج المحلية على جهازك", + "price": "{}", + "priceInfo": "لكل مستخدم شهريًا يتم تحصيل الرسوم سنويًا", + "recommend": "يوصى باستخدام M1 أو أحدث" + } + }, + "deal": { + "bannerLabel": "صفقة العام الجديد!", + "title": "تنمية فريقك!", + "info": "قم بالترقية واحصل على خصم 10% على خطط Pro وTeam! عزز إنتاجية مساحة العمل لديك من خلال ميزات جديدة قوية بما في ذلك الذكاء الاصطناعي @:appName .", + "viewPlans": "عرض الخطط" + } + } + }, + "billingPage": { + "menuLabel": "الفوترة", + "title": "الفوترة", + "plan": { + "title": "الخطة", + "freeLabel": "مجاني", + "proLabel": "مجاني", + "planButtonLabel": "تغيير الخطة", + "billingPeriod": "فترة الفوترة", + "periodButtonLabel": "تعديل الفترة" + }, + "paymentDetails": { + "title": "تفاصيل الدفع", + "methodLabel": "طريقة الدفع", + "methodButtonLabel": "طريقة التعديل" + }, + "addons": { + "title": "مَرافِق", + "addLabel": "إضافة", + "removeLabel": "إزالة", + "renewLabel": "تجديد", + "aiMax": { + "label": "الحد الأقصى للذكاء الاصطناعي", + "description": "إلغاء تأمين عدد غير محدود من الذكاء الاصطناعي والنماذج المتقدمة", + "activeDescription": "الفاتورة القادمة مستحقة في {}", + "canceledDescription": "سيكون AI Max متوفرا حتى {}" + }, + "aiOnDevice": { + "label": "الذكاء الاصطناعي على الجهاز لنظام التشغيل Mac", + "description": "إلغاء تأمين الذكاء الاصطناعي غير المحدود على جهازك", + "activeDescription": "الفاتورة القادمة مستحقة في {}", + "canceledDescription": "ستتوفر ميزة AI On-device for Mac حتى {}" + }, + "removeDialog": { + "title": "إزالة {}", + "description": "هل أنت متأكد من أنك تريد إزالة {plan}؟ ستفقد الوصول إلى ميزات وفوائد {plan} على الفور." + } + }, + "currentPeriodBadge": "الحالي", + "changePeriod": "فترة التغيير", + "planPeriod": "{} فترة", + "monthlyInterval": "شهريا", + "monthlyPriceInfo": "لكل مقعد يتم دفع الفاتورة شهريًا", + "annualInterval": "سنويا", + "annualPriceInfo": "لكل مقعد يتم دفع الفاتورة سنويًا" + }, + "comparePlanDialog": { + "title": "قارن واختر الخطة", + "planFeatures": " الخطة\nميزات", + "current": "الحالي", + "actions": { + "upgrade": "ترقية", + "downgrade": "يرجع إلى إصدار أقدم", + "current": "الحالي" + }, + "freePlan": { + "title": "مجاني", + "description": "للأفراد حتى عضوين لتنظيم كل شيء", + "price": "{}", + "priceInfo": "مجاني للأبد" + }, + "proPlan": { + "title": "احترافي", + "description": "للفرق الصغيرة لإدارة المشاريع ومعرفة الفريق", + "price": "{}", + "priceInfo": "لكل مستخدم شهريا\nيتم فوترتها سنويا\n\n{} يتم الفوترتها شهريًا" + }, + "planLabels": { + "itemOne": "مساحات العمل", + "itemTwo": "الأعضاء", + "itemThree": "التخزين", + "itemFour": "التعاون في الزمن الحقيقي", + "itemFive": "تطبيق الجوال", + "itemSix": "استجابات الذكاء الاصطناعي", + "itemSeven": "صور الذكاء الاصطناعي", + "itemFileUpload": "رفع الملفات", + "customNamespace": "مساحة اسم مخصصة", + "tooltipSix": "تعني مدة الحياة أن عدد الاستجابات لا يتم إعادة ضبطه أبدًا", + "intelligentSearch": "البحث الذكي", + "tooltipSeven": "يتيح لك تخصيص جزء من عنوان URL لمساحة العمل الخاصة بك", + "customNamespaceTooltip": "عنوان URL للموقع المنشور المخصص" + }, + "freeLabels": { + "itemOne": "يتم فرض رسوم على كل مساحة عمل", + "itemTwo": "حتى 2", + "itemThree": "5 جيجا بايت", + "itemFour": "نعم", + "itemFive": "نعم", + "itemSix": "10 مدى الحياة", + "itemSeven": "2 مدى الحياة", + "itemFileUpload": "حتى 7 ميجا بايت", + "intelligentSearch": "البحث الذكي" + }, + "proLabels": { + "itemOne": "تتم الفوترة على كل مساحة عمل", + "itemTwo": "حتى 10", + "itemThree": "غير محدود", + "itemFour": "نعم", + "itemFive": "نعم", + "itemSix": "غير محدود", + "itemSeven": "10 صور شهريا", + "itemFileUpload": "غير محدود", + "intelligentSearch": "البحث الذكي" + }, + "paymentSuccess": { + "title": "أنت الآن على خطة {}!", + "description": "لقد تمت معالجة الدفع بنجاح وتم ترقية خطتك إلى @:appName {}. يمكنك عرض تفاصيل خطتك على صفحة الخطة" + }, + "downgradeDialog": { + "title": "هل أنت متأكد أنك تريد تخفيض خطتك؟", + "description": "سيؤدي تخفيض مستوى خطتك إلى إعادتك إلى الخطة المجانية. قد يفقد الأعضاء إمكانية الوصول إلى مساحة العمل هذه وقد تحتاج إلى تحرير مساحة لتلبية حدود التخزين للخطة المجانية.", + "downgradeLabel": " تخفيض الخطة" + } + }, + "cancelSurveyDialog": { + "title": "نحن نأسف لرحيلك", + "description": "نحن نأسف لرحيلك. يسعدنا سماع تعليقاتك لمساعدتنا على تحسين @:appName . يُرجى تخصيص بعض الوقت للإجابة على بعض الأسئلة.", + "commonOther": "آخر", + "otherHint": "اكتب إجابتك هنا", + "questionOne": { + "question": "ما الذي دفعك إلى إلغاء اشتراكك @:appName Pro؟", + "answerOne": "التكلفة مرتفعة للغاية", + "answerTwo": "الميزات لم تلبي التوقعات", + "answerThree": "وجدت بديلا أفضل", + "answerFour": "لم أستخدمه بشكل كافي لتبرير التكلفة", + "answerFive": "مشكلة في الخدمة أو صعوبات فنية" + }, + "questionTwo": { + "question": "ما مدى احتمالية أن تفكر في إعادة الاشتراك في @:appName Pro في المستقبل؟", + "answerOne": "من المرجح جدًا", + "answerTwo": "من المحتمل إلى حد ما", + "answerThree": "غير متأكد", + "answerFour": "من غير المحتمل", + "answerFive": "من غير المحتمل جدًا" + }, + "questionThree": { + "question": "ما هي الميزة الاحترافية التي تقدرها أكثر أثناء اشتراكك؟", + "answerOne": "التعاون بين المستخدمين المتعددين", + "answerTwo": "تاريخ الإصدار الأطول", + "answerThree": "استجابات الذكاء الاصطناعي غير المحدودة", + "answerFour": "الوصول إلى نماذج الذكاء الاصطناعي المحلية" + }, + "questionFour": { + "question": "كيف تصف تجربتك العامة مع @:appName ؟", + "answerOne": "عظيم", + "answerTwo": "جيد", + "answerThree": "متوسط", + "answerFour": "أقل من المتوسط", + "answerFive": "غير راضٍ" + } + }, + "common": { + "uploadingFile": "جاري رفع الملف. الرجاء عدم الخروج من التطبيق", + "uploadNotionSuccess": "تم تحميل ملف Notion zip الخاص بك بنجاح. بمجرد اكتمال عملية الاستيراد، ستتلقى رسالة تأكيد عبر البريد الإلكتروني", + "reset": "إعادة ضبط" + }, "menu": { "appearance": "مظهر", "language": "لغة", @@ -269,26 +1131,31 @@ "syncSetting": "إعدادات المزامنة", "cloudSettings": "إعدادات السحابة", "enableSync": "تفعيل المزامنة", + "enableSyncLog": "تفعيل تسجيل المزامنة", + "enableSyncLogWarning": "شكرًا لك على مساعدتك في تشخيص مشكلات المزامنة. سيؤدي هذا إلى تسجيل تعديلات المستندات الخاصة بك في ملف محلي. يرجى الخروج من التطبيق وإعادة فتحه بعد تفعيله", "enableEncrypt": "تشفير البيانات", "cloudURL": "الرابط الأساسي", + "webURL": "عنوان الويب", "invalidCloudURLScheme": "مخطط غير صالح", "cloudServerType": "خادم سحابي", "cloudServerTypeTip": "يرجى ملاحظة أنه قد يقوم بتسجيل الخروج من حسابك الحالي بعد تبديل الخادم السحابي", "cloudLocal": "محلي", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "رابط Supabase", - "cloudSupabaseAnonKey": "مفتاح Supabase الخفي", - "cloudSupabaseAnonKeyCanNotBeEmpty": "لا يمكن أن يكون المفتاح المجهول فارغًا إذا لم يكن عنوان URL الخاص بـ Supabase فارغًا", - "cloudAppFlowy": "سحابة AppFlowy", + "cloudAppFlowy": "سحابة @:appName", + "cloudAppFlowySelfHost": "@:appName استضافة ذاتية على السحابة", + "appFlowyCloudUrlCanNotBeEmpty": "لا يمكن أن يكون عنوان URL السحابي فارغًا", "clickToCopy": "انقر للنسخ", "selfHostStart": "إذا لم يكن لديك خادم، يرجى الرجوع إلى", "selfHostContent": "مستند", "selfHostEnd": "للحصول على إرشادات حول كيفية استضافة الخادم الخاص بك ذاتيًا", + "pleaseInputValidURL": "الرجاء إدخال عنوان URL صالح", + "changeUrl": "تغيير عنوان URL المستضاف ذاتيًا إلى {}", "cloudURLHint": "أدخل الرابط الأساسي لخادمك", + "webURLHint": "أدخل عنوان URL الأساسي لخادم الويب الخاص بك", "cloudWSURL": "عنوان Websockey", "cloudWSURLHint": "أدخل عنوان websocket لخادمك", "restartApp": "إعادة تشغيل", "restartAppTip": "أعد تشغيل التطبيق لتصبح التغييرات سارية المفعول. يرجى ملاحظة أن هذا قد يؤدي إلى تسجيل الخروج من حسابك الحالي", + "changeServerTip": "بعد تغيير الخادم يجب عليك الضغط على زر إعادة التشغيل حتى تسري التغييرات", "enableEncryptPrompt": "قم بتنشيط التشفير لتأمين بياناتك بهذا السر. قم بتخزينها بأمان؛ بمجرد تمكينه، لا يمكن إيقاف تشغيله. في حالة فقدانها، تصبح بياناتك غير قابلة للاسترداد. انقر للنسخ", "inputEncryptPrompt": "الرجاء إدخال سر التشفير الخاص بك ل", "clickToCopySecret": "انقر لنسخ السر", @@ -298,28 +1165,97 @@ "historicalUserList": "سجل تسجيل دخول المستخدم", "historicalUserListTooltip": "تعرض هذه القائمة حساباتك المجهولة. يمكنك النقر على الحساب لعرض تفاصيله. يتم إنشاء الحسابات المجهولة بالنقر فوق الزر \"البدء\".", "openHistoricalUser": "انقر لفتح الحساب الخفي", - "customPathPrompt": "قد يؤدي تخزين مجلد بيانات AppFlowy في مجلد متزامن على السحابة مثل Google Drive إلى مخاطر. إذا تم الوصول إلى قاعدة البيانات الموجودة في هذا المجلد أو تعديلها من مواقع متعددة في نفس الوقت، فقد يؤدي ذلك إلى حدوث تعارضات في المزامنة وتلف محتمل للبيانات", - "supabaseSetting": "إعداد Supabase", + "customPathPrompt": "قد يؤدي تخزين مجلد بيانات @:appName في مجلد متزامن على السحابة مثل Google Drive إلى مخاطر. إذا تم الوصول إلى قاعدة البيانات الموجودة في هذا المجلد أو تعديلها من مواقع متعددة في نفس الوقت، فقد يؤدي ذلك إلى حدوث تعارضات في المزامنة وتلف محتمل للبيانات", + "importAppFlowyData": "استيراد البيانات من مجلد خارجي @:appName", + "importingAppFlowyDataTip": "جاري استيراد البيانات. يرجى عدم إغلاق التطبيق", + "importAppFlowyDataDescription": "انسخ البيانات من مجلد بيانات خارجي @:appName واستوردها إلى مجلد بيانات AppFlowy الحالي", + "importSuccess": "تم استيراد مجلد البيانات @:appName بنجاح", + "importFailed": "فشل استيراد مجلد البيانات @:appName", + "importGuide": "لمزيد من التفاصيل، يرجى مراجعة الوثيقة المشار إليها", "cloudSetting": "إعداد السحابة" }, "notifications": { "enableNotifications": { "label": "تمكين الإشعارات", "hint": "قم بإيقاف تشغيله لمنع ظهور الإشعارات المحلية." + }, + "showNotificationsIcon": { + "label": "إظهار أيقونة الإشعارات", + "hint": "قم بإيقاف تشغيله لإخفاء أيقونة الإشعار في الشريط الجانبي." + }, + "archiveNotifications": { + "allSuccess": "تم أرشفة جميع الإشعارات بنجاح", + "success": "تم أرشفة الإشعار بنجاح" + }, + "markAsReadNotifications": { + "allSuccess": "تم وضع علامة على كل شيء كمقروء بنجاح", + "success": "تم وضع علامة على القراءة بنجاح" + }, + "action": { + "markAsRead": "وضع علامة كمقروءة", + "multipleChoice": "إختر المزيد", + "archive": "أرشيف" + }, + "settings": { + "settings": "إعدادات", + "markAllAsRead": "وضع علامة على الكل كمقروء", + "archiveAll": "أرشفة الكل" + }, + "emptyInbox": { + "title": "صندوق الوارد صفر!", + "description": "قم بتعيين التذكيرات لتلقي الإشعارات هنا." + }, + "emptyUnread": { + "title": "لا توجد إشعارات غير مقروءة", + "description": "لقد تم قراءة جميع الرسائل." + }, + "emptyArchived": { + "title": "غير مؤرشفة", + "description": "ستظهر الإشعارات المؤرشفة هنا." + }, + "tabs": { + "inbox": "صندوق الوارد", + "unread": "غير مقروء", + "archived": "مؤرشفة" + }, + "refreshSuccess": "تم تحديث الإشعارات بنجاح", + "titles": { + "notifications": "إشعارات", + "reminder": "تذكير" } }, "appearance": { "resetSetting": "إعادة ضبط هذا الإعداد", "fontFamily": { "label": "الخط", - "search": "يبحث" + "search": "يبحث", + "defaultFont": "نظام" }, "themeMode": { - "label": "وضع السمة", - "light": "وضع الضوء", - "dark": "الوضع الداكن", + "label": "مظهر السمة", + "light": " المظهر الفاتح", + "dark": "المظهر الداكن", "system": "التكيف مع النظام" }, + "fontScaleFactor": "عامل مقياس الخط", + "displaySize": "حجم العرض", + "documentSettings": { + "cursorColor": "لون مؤشر المستند", + "selectionColor": "لون اختيار المستند", + "width": "عرض المستند", + "changeWidth": "تغير", + "pickColor": "حدد اللون", + "colorShade": "ظل اللون", + "opacity": "الشفافية", + "hexEmptyError": "لا يمكن أن يكون اللون السادسة عشرية فارغًا", + "hexLengthError": "يجب أن تكون القيمة السداسية عشرية مكونة من 6 أرقام", + "hexInvalidError": "القيمة السادسة العشرية غير صالحة", + "opacityEmptyError": "لا يمكن أن تكون الشفافية فارغة", + "opacityRangeError": "يجب أن تكون الشفافية بين 1 و 100", + "app": "App", + "flowy": "Flowy", + "apply": "تطبيق" + }, "layoutDirection": { "label": "اتجاه التخطيط", "hint": "التحكم في تدفق المحتوى على شاشتك، من اليسار إلى اليمين أو من اليمين إلى اليسار.", @@ -336,10 +1272,10 @@ }, "themeUpload": { "button": "رفع", - "uploadTheme": "تحميل الموضوع", - "description": "قم بتحميل قالب AppFlowy الخاص بك باستخدام الزر أدناه.", - "loading": "يرجى الانتظار بينما نقوم بالتحقق من صحة السمة الخاصة بك وتحميلها ...", - "uploadSuccess": "تم تحميل موضوعك بنجاح", + "uploadTheme": "رفع السمة", + "description": "قم برفع السمة @:appName الخاص بك باستخدام الزر أدناه.", + "loading": "يرجى الانتظار بينما نقوم بالتحقق من صحة السمة الخاصة بك وترفعها ...", + "uploadSuccess": "تم رفع سمتك بنجاح", "deletionFailure": "فشل حذف الموضوع. حاول حذفه يدويًا.", "filePickerDialogTitle": "اختر ملف .flowy_plugin", "urlUploadFailure": "فشل فتح عنوان url: {}", @@ -362,6 +1298,48 @@ "twentyFourHour": "نظام أربع وعشرون ساعة" }, "showNamingDialogWhenCreatingPage": "إظهار مربع حوار التسمية عند إنشاء الصفحة", + "enableRTLToolbarItems": "تمكين عناصر شريط أدوات RTL", + "members": { + "title": "إعدادات الأعضاء", + "inviteMembers": "دعوة الأعضاء", + "inviteHint": "دعوة عبر البريد الإلكتروني", + "sendInvite": "إرسال دعوة", + "copyInviteLink": "نسخ رابط الدعوة", + "label": "الأعضاء", + "user": "المستخدم", + "role": "الدور", + "removeFromWorkspace": "الإزالة من مساحة العمل", + "removeFromWorkspaceSuccess": "تمت الإزالة من مساحة العمل بنجاح", + "removeFromWorkspaceFailed": "فشلت عملية الإزالة من مساحة العمل", + "owner": "المالك", + "guest": "الضيف", + "member": "العضو", + "memberHintText": "يمكن للعضو قراءة الصفحات وتحريرها", + "guestHintText": "يمكن للضيف القراءة والرد والتعليق وتحرير صفحات معينة بإذن.", + "emailInvalidError": "بريد إلكتروني غير صالح، يرجى التحقق والمحاولة مرة أخرى", + "emailSent": "تم إرسال البريد الإلكتروني، يرجى التحقق من صندوق الوارد", + "members": "الأعضاء", + "membersCount": { + "zero": "{} الأعضاء", + "one": "{} العضو", + "other": "{} الأعضاء" + }, + "inviteFailedDialogTitle": "فشل في إرسال الدعوة", + "inviteFailedMemberLimit": "لقد تم الوصول إلى الحد الأقصى للأعضاء، يرجى الترقية لدعوة المزيد من الأعضاء.", + "inviteFailedMemberLimitMobile": "لقد وصلت مساحة العمل الخاصة بك إلى الحد الأقصى للأعضاء.", + "memberLimitExceeded": "تم الوصول إلى الحد الأقصى للأعضاء، لدعوة المزيد من الأعضاء، يرجى ", + "memberLimitExceededUpgrade": "ترقية", + "memberLimitExceededPro": "تم الوصول إلى الحد الأقصى للأعضاء، إذا كنت بحاجة إلى المزيد من الأعضاء، فاتصل بنا ", + "memberLimitExceededProContact": "support@appflowy.io", + "failedToAddMember": "فشل في إضافة العضو", + "addMemberSuccess": "تمت إضافة العضو بنجاح", + "removeMember": "إزالة العضو", + "areYouSureToRemoveMember": "هل أنت متأكد أنك تريد إزالة هذا العضو؟", + "inviteMemberSuccess": "لقد تم ارسال الدعوة بنجاح", + "failedToInviteMember": "فشل في دعوة العضو", + "workspaceMembersError": "عفواً، حدث خطأ ما", + "workspaceMembersErrorDescription": "لم نتمكن من تحميل قائمة الأعضاء في هذا الوقت. يرجى المحاولة مرة أخرى لاحقًا" + }, "lightLabel": "فاتح", "darkLabel": "غامق" }, @@ -370,7 +1348,7 @@ "defaultLocation": "أين يتم تخزين بياناتك الآن", "exportData": "قم بتصدير بياناتك", "doubleTapToCopy": "انقر نقرًا مزدوجًا لنسخ المسار", - "restoreLocation": "استعادة المسار الافتراضي AppFlowy", + "restoreLocation": "استعادة المسار الافتراضي @:appName", "customizeLocation": "افتح مجلدًا آخر", "restartApp": "يرجى إعادة تشغيل التطبيق لتصبح التغييرات سارية المفعول.", "exportDatabase": "تصدير قاعدة البيانات", @@ -382,10 +1360,10 @@ "defineWhereYourDataIsStored": "حدد مكان تخزين بياناتك", "open": "يفتح", "openFolder": "افتح مجلدًا موجودًا", - "openFolderDesc": "اقرأها واكتبها في مجلد AppFlowy الموجود لديك", + "openFolderDesc": "اقرأها واكتبها في مجلد @:appName الموجود لديك", "folderHintText": "إسم الملف", "location": "إنشاء مجلد جديد", - "locationDesc": "اختر اسمًا لمجلد بيانات AppFlowy", + "locationDesc": "اختر اسمًا لمجلد بيانات @:appName", "browser": "تصفح", "create": "يخلق", "set": "تعيين", @@ -396,30 +1374,23 @@ "change": "يتغير", "openLocationTooltips": "افتح دليل بيانات آخر", "openCurrentDataFolder": "افتح دليل البيانات الحالي", - "recoverLocationTooltips": "إعادة التعيين إلى دليل البيانات الافتراضي لـ AppFlowy", + "recoverLocationTooltips": "إعادة التعيين إلى دليل البيانات الافتراضي لـ @:appName", "exportFileSuccess": "تم تصدير الملف بنجاح!", "exportFileFail": "فشل تصدير الملف!", - "export": "يصدّر" + "export": "يصدّر", + "clearCache": "مسح ذاكرة التخزين المؤقت", + "clearCacheDesc": "إذا واجهت مشكلات تتعلق بعدم تحميل الصور أو عدم عرض الخطوط بشكل صحيح، فحاول مسح ذاكرة التخزين المؤقت. لن يؤدي هذا الإجراء إلى إزالة بيانات المستخدم الخاصة بك.", + "areYouSureToClearCache": "هل أنت متأكد من مسح ذاكرة التخزين المؤقت؟", + "clearCacheSuccess": "تم مسح ذاكرة التخزين المؤقت بنجاح!" }, "user": { "name": "اسم", "email": "بريد إلكتروني", "tooltipSelectIcon": "حدد أيقونة", "selectAnIcon": "حدد أيقونة", - "pleaseInputYourOpenAIKey": "الرجاء إدخال مفتاح OpenAI الخاص بك", - "pleaseInputYourStabilityAIKey": "يرجى إدخال رمز Stability AI الخاص بك", - "clickToLogout": "انقر لتسجيل خروج المستخدم الحالي" - }, - "shortcuts": { - "shortcutsLabel": "الاختصارات", - "command": "امر", - "keyBinding": "ربط المفاتيح", - "addNewCommand": "إضافة أمر جديد", - "updateShortcutStep": "اضغط على مجموعة المفاتيح المطلوبة ثم اضغط على ENTER", - "shortcutIsAlreadyUsed": "هذا الاختصار مستخدم بالفعل لـ: {conflict}", - "resetToDefault": "إعادة التعيين إلى روابط المفاتيح الافتراضية", - "couldNotLoadErrorMsg": "تعذر تحميل الاختصارات، حاول مرة أخرى", - "couldNotSaveErrorMsg": "تعذر حفظ الاختصارات، حاول مرة أخرى" + "pleaseInputYourOpenAIKey": "الرجاء إدخال مفتاح AI الخاص بك", + "clickToLogout": "انقر لتسجيل خروج المستخدم الحالي", + "pleaseInputYourStabilityAIKey": "يرجى إدخال رمز Stability AI الخاص بك" }, "mobile": { "personalInfo": "معلومات شخصية", @@ -431,10 +1402,23 @@ "joinDiscord": "انضم إلينا على ديسكورد", "privacyPolicy": "سياسة الخصوصية", "userAgreement": "اتفاقية المستخدم", + "termsAndConditions": "الشروط والأحكام", "userprofileError": "فشل تحميل ملف تعريف المستخدم", "userprofileErrorDescription": "يرجى محاولة تسجيل الخروج وتسجيل الدخول مرة أخرى للتحقق مما إذا كانت المشكلة لا تزال قائمة.", "selectLayout": "حدد الشكل", - "selectStartingDay": "اختر يوم البدء" + "selectStartingDay": "اختر يوم البدء", + "version": "النسخة" + }, + "shortcuts": { + "shortcutsLabel": "الاختصارات", + "command": "امر", + "keyBinding": "ربط المفاتيح", + "addNewCommand": "إضافة أمر جديد", + "updateShortcutStep": "اضغط على مجموعة المفاتيح المطلوبة ثم اضغط على ENTER", + "shortcutIsAlreadyUsed": "هذا الاختصار مستخدم بالفعل لـ: {conflict}", + "resetToDefault": "إعادة التعيين إلى روابط المفاتيح الافتراضية", + "couldNotLoadErrorMsg": "تعذر تحميل الاختصارات، حاول مرة أخرى", + "couldNotSaveErrorMsg": "تعذر حفظ الاختصارات، حاول مرة أخرى" } }, "grid": { @@ -455,9 +1439,29 @@ "filterBy": "مصنف بواسطة...", "typeAValue": "اكتب قيمة ...", "layout": "تَخطِيط", + "compactMode": "الوضع المضغوط", "databaseLayout": "تَخطِيط", + "viewList": { + "zero": "0 مشاهدات", + "one": "{count} مشاهدة", + "other": "{count} المشاهدات" + }, + "editView": "تعديل العرض", + "boardSettings": "إعدادات اللوحة", + "calendarSettings": "إعدادات التقويم", + "createView": "عرض جديد", + "duplicateView": "عرض مكرر", + "deleteView": "حذف العرض", + "numberOfVisibleFields": "{} تم عرضه", "Properties": "ملكيات" }, + "filter": { + "empty": "لا توجد عوامل تصفية نشطة", + "addFilter": "أضف عامل التصفية", + "cannotFindCreatableField": "لا يمكن العثور على حقل مناسب للتصفية حسبه", + "conditon": "حالة", + "where": "أين" + }, "textFilter": { "contains": "يتضمن", "doesNotContain": "لا يحتوي", @@ -503,15 +1507,40 @@ "onOrAfter": "يكون في او بعد", "between": "يتراوح ما بين", "empty": "فارغ", - "notEmpty": "ليس فارغا" + "notEmpty": "ليس فارغا", + "startDate": "تاريخ البدء", + "endDate": "تاريخ النهاية", + "choicechipPrefix": { + "before": "قبل", + "after": "بعد", + "between": "بين", + "onOrBefore": "في أو قبل", + "onOrAfter": "في أو بعد", + "isEmpty": "هو فارغ", + "isNotEmpty": "ليس فارغا" + } + }, + "numberFilter": { + "equal": "يساوي", + "notEqual": "لا يساوي", + "lessThan": "أقل من", + "greaterThan": "أكبر من", + "lessThanOrEqualTo": "أقل من أو يساوي", + "greaterThanOrEqualTo": "أكبر من أو يساوي", + "isEmpty": "هو فارغ", + "isNotEmpty": "ليس فارغا" }, "field": { + "label": "خاصية", "hide": "يخفي", "show": "عرض", "insertLeft": "أدخل اليسار", "insertRight": "أدخل اليمين", "duplicate": "ينسخ", "delete": "يمسح", + "wrapCellContent": "لف النص", + "clear": "مسح الخلايا", + "switchPrimaryFieldTooltip": "لا يمكن تغيير نوع الحقل للحقل الأساسي", "textFieldName": "نص", "checkboxFieldName": "خانة اختيار", "dateFieldName": "تاريخ", @@ -522,6 +1551,12 @@ "multiSelectFieldName": "تحديد متعدد", "urlFieldName": "URL", "checklistFieldName": "قائمة تدقيق", + "relationFieldName": "العلاقة", + "summaryFieldName": "ملخص الذكاء الاصطناعي", + "timeFieldName": "وقت", + "mediaFieldName": "الملفات والوسائط", + "translateFieldName": "الترجمة بالذكاء الاصطناعي", + "translateTo": "ترجم إلى", "numberFormat": "تنسيق الأرقام", "dateFormat": "صيغة التاريخ", "includeTime": "أضف الوقت", @@ -550,9 +1585,13 @@ "addOption": "إضافة خيار", "editProperty": "تحرير الملكية", "newProperty": "خاصية جديدة", + "openRowDocument": "افتح كصفحة", "deleteFieldPromptMessage": "هل أنت متأكد؟ سيتم حذف هذه الخاصية", + "clearFieldPromptMessage": "هل أنت متأكد؟ سيتم إفراغ جميع الخلايا في هذا العمود", "newColumn": "عمود جديد", - "format": "شكل" + "format": "شكل", + "reminderOnDateTooltip": "تحتوي هذه الخلية على تذكير مجدول", + "optionAlreadyExist": "الخيار موجود بالفعل" }, "rowPage": { "newField": "إضافة حقل جديد", @@ -566,16 +1605,25 @@ "one": "إخفاء {count} الحقل المخفي", "many": "إخفاء {count} الحقول المخفية", "other": "إخفاء {count} الحقول المخفية" - } + }, + "openAsFullPage": "افتح كصفحة كاملة", + "moreRowActions": "مزيد من إجراءات الصف" }, "sort": { "ascending": "تصاعدي", "descending": "تنازلي", + "by": "بواسطة", + "empty": "لا توجد تصنيفات نشطة", + "cannotFindCreatableField": "لا يمكن العثور على حقل مناسب للتصنيف حسبه", "deleteAllSorts": "حذف جميع التراتيب", "addSort": "أضف نوعًا", + "sortsActive": "لا يمكن {intention} أثناء التصنيف", + "removeSorting": "هل ترغب في إزالة كافة التصنيفات في هذا العرض والمتابعة؟", + "fieldInUse": "أنت تقوم بالفعل بالتصنيف حسب هذا الحقل", "deleteSort": "حذف الفرز" }, "row": { + "label": "صف", "duplicate": "مكرره", "delete": "يمسح", "titlePlaceholder": "بدون عنوان", @@ -583,12 +1631,19 @@ "copyProperty": "نسخ الممتلكات إلى الحافظة", "count": "عدد", "newRow": "صف جديد", + "loadMore": "تحميل المزيد", "action": "فعل", "add": "انقر فوق إضافة إلى أدناه", "drag": "اسحب للتحريك", + "deleteRowPrompt": "هل أنت متأكد من أنك تريد حذف هذا الصف؟ لا يمكن التراجع عن هذا الإجراء.", + "deleteCardPrompt": "هل أنت متأكد من أنك تريد حذف هذه البطاقة؟ لا يمكن التراجع عن هذا الإجراء.", "dragAndClick": "اسحب للتحريك، انقر لفتح القائمة", "insertRecordAbove": "أدخل السجل أعلاه", - "insertRecordBelow": "أدخل السجل أدناه" + "insertRecordBelow": "أدخل السجل أدناه", + "noContent": "لا يوجد محتوى", + "reorderRowDescription": "إعادة ترتيب الصف", + "createRowAboveDescription": "إنشاء صف أعلى", + "createRowBelowDescription": "أدخل صفًا أدناه" }, "selectOption": { "create": "يخلق", @@ -609,9 +1664,7 @@ "createNew": "إنشاء جديد", "orSelectOne": "أو حدد خيارًا", "typeANewOption": "اكتب خيارًا جديدًا", - "tagName": "اسم العلامة", - "colorPannelTitle": "لوحة الالوان", - "pannelTitle": "لوحة الخيارات" + "tagName": "اسم العلامة" }, "checklist": { "taskHint": "وصف المهمة", @@ -622,10 +1675,53 @@ }, "url": { "launch": "فتح في المتصفح", - "copy": "إنسخ الرابط" + "copy": "إنسخ الرابط", + "textFieldHint": "أدخل عنوان URL" + }, + "relation": { + "relatedDatabasePlaceLabel": "قاعدة البيانات ذات الصلة", + "relatedDatabasePlaceholder": "لا أحد", + "inRelatedDatabase": "في", + "rowSearchTextFieldPlaceholder": "بحث", + "noDatabaseSelected": "لم يتم تحديد قاعدة بيانات، الرجاء تحديد قاعدة بيانات واحدة أولاً من القائمة أدناه:", + "emptySearchResult": "لم يتم العثور على أي سجلات", + "linkedRowListLabel": "{count} صفوف مرتبطة", + "unlinkedRowListLabel": "ربط صف آخر" }, "menuName": "شبكة", - "referencedGridPrefix": "نظرا ل" + "referencedGridPrefix": "نظرا ل", + "calculate": "احسب", + "calculationTypeLabel": { + "none": "لا أحد", + "average": "المعدل", + "max": "الحد الأقصى", + "median": "المتوسط", + "min": "الحد الأدنى", + "sum": "المجموع", + "count": "العدد", + "countEmpty": "عدد فارغ", + "countEmptyShort": "فارغ", + "countNonEmpty": "العدد ليس فارغا", + "countNonEmptyShort": "مملوء" + }, + "media": { + "rename": "إعادة تسمية", + "download": "التنزيل", + "expand": "توسيع", + "delete": "الحذف", + "moreFilesHint": "+{}", + "addFileOrImage": "إضافة ملف أو رابط", + "attachmentsHint": "{}", + "addFileMobile": "إضافة ملف", + "extraCount": "+{}", + "deleteFileDescription": "هل أنت متأكد من أنك تريد حذف هذا الملف؟ هذا الإجراء لا رجعة فيه.", + "showFileNames": "إظهار اسم الملف", + "downloadSuccess": "تم تنزيل الملف", + "downloadFailedToken": "فشل تنزيل الملف، الرمز المميز للمستخدم غير متوفر", + "setAsCover": "تعيين كغلاف", + "openInBrowser": "فتح في المتصفح", + "embedLink": "تضمين رابط الملف" + } }, "document": { "menuName": "وثيقة", @@ -633,6 +1729,7 @@ "timeHintTextInTwelveHour": "01:00 مساءً", "timeHintTextInTwentyFourHour": "13:00" }, + "creating": "جارٍ الإنشاء...", "slashMenu": { "board": { "selectABoardToLinkTo": "حدد لوحة للارتباط بها", @@ -648,43 +1745,144 @@ }, "document": { "selectADocumentToLinkTo": "حدد مستندًا للارتباط به" + }, + "name": { + "textStyle": "نمط النص", + "list": "قائمة", + "toggle": "تبديل", + "fileAndMedia": "الملفات والوسائط", + "simpleTable": "جدول بسيط", + "visuals": "المرئيات", + "document": "وثيقة", + "advanced": "متقدم", + "text": "نص", + "heading1": "العنوان 1", + "heading2": "العنوان 2", + "heading3": "العنوان 3", + "image": "صورة", + "bulletedList": "قائمة نقطية", + "numberedList": "قائمة مرقمة", + "todoList": "قائمة المهام", + "doc": "وثيقة", + "linkedDoc": "رابط الصفحة", + "grid": "شبكة", + "linkedGrid": "الشبكة المرتبطة", + "kanban": "كانبان", + "linkedKanban": "كانبان مرتبط", + "calendar": "تقويم", + "linkedCalendar": "التقويم المرتبط", + "quote": "اقتباس", + "divider": "فاصل", + "table": "الجدول", + "callout": "وسيلة الشرح", + "outline": "مخطط تفصيلي", + "mathEquation": "معادلة الرياضيات", + "code": "الكود", + "toggleList": "قائمة التبديل", + "toggleHeading1": "تبديل العنوان 1", + "toggleHeading2": "تبديل العنوان 2", + "toggleHeading3": "تبديل العنوان 3", + "emoji": "الرموز التعبيرية", + "aiWriter": "كاتب الذكاء الاصطناعي", + "dateOrReminder": "التاريخ أو التذكير", + "photoGallery": "معرض الصور", + "file": "الملف", + "twoColumns": "عمودين", + "threeColumns": "3 أعمدة", + "fourColumns": "4 أعمدة" + }, + "subPage": { + "name": "المستند", + "keyword1": "الصفحة الفرعية", + "keyword2": "الصفحة", + "keyword3": "الصفحة الطفل", + "keyword4": "ادراج الصفحة", + "keyword5": "تضمين الصفحة", + "keyword6": "صفحة جديدة", + "keyword7": "إنشاء صفحة", + "keyword8": "المستند" } }, "selectionMenu": { "outline": "الخطوط العريضة", - "codeBlock": "حقل الكود" + "codeBlock": "كتلة الكود" }, "plugins": { "referencedBoard": "المجلس المشار إليه", "referencedGrid": "الشبكة المشار إليها", "referencedCalendar": "التقويم المشار إليه", "referencedDocument": "الوثيقة المشار إليها", - "autoGeneratorMenuItemName": "كاتب OpenAI", - "autoGeneratorTitleName": "OpenAI: اطلب من الذكاء الاصطناعي كتابة أي شيء ...", + "aiWriter": { + "userQuestion": "اسأل الذكاء الاصطناعي عن أي شيء", + "continueWriting": "استمر في الكتابة", + "fixSpelling": "تصحيح الأخطاء الإملائية والنحوية", + "improveWriting": "تحسين الكتابة", + "summarize": "تلخيص", + "explain": "اشرح", + "makeShorter": "اجعلها أقصر", + "makeLonger": "اجعلها أطول" + }, + "autoGeneratorMenuItemName": "كاتب AI", + "autoGeneratorTitleName": "AI: اطلب من الذكاء الاصطناعي كتابة أي شيء ...", "autoGeneratorLearnMore": "يتعلم أكثر", "autoGeneratorGenerate": "يولد", - "autoGeneratorHintText": "اسأل OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "لا يمكن الحصول على مفتاح OpenAI", + "autoGeneratorHintText": "اسأل AI ...", + "autoGeneratorCantGetOpenAIKey": "لا يمكن الحصول على مفتاح AI", "autoGeneratorRewrite": "اعادة كتابة", "smartEdit": "مساعدي الذكاء الاصطناعي", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "أصلح التهجئة", "warning": "⚠️ يمكن أن تكون استجابات الذكاء الاصطناعي غير دقيقة أو مضللة.", "smartEditSummarize": "لخص", "smartEditImproveWriting": "تحسين الكتابة", "smartEditMakeLonger": "اجعله أطول", - "smartEditCouldNotFetchResult": "تعذر جلب النتيجة من OpenAI", - "smartEditCouldNotFetchKey": "تعذر جلب مفتاح OpenAI", - "smartEditDisabled": "قم بتوصيل OpenAI في الإعدادات", + "smartEditCouldNotFetchResult": "تعذر جلب النتيجة من AI", + "smartEditCouldNotFetchKey": "تعذر جلب مفتاح AI", + "smartEditDisabled": "قم بتوصيل AI في الإعدادات", + "appflowyAIEditDisabled": "تسجيل الدخول لتمكين ميزات الذكاء الاصطناعي", "discardResponse": "هل تريد تجاهل استجابات الذكاء الاصطناعي؟", "createInlineMathEquation": "اصنع معادلة", "fonts": "الخطوط", + "insertDate": "أدخل التاريخ", + "emoji": "الرموز التعبيرية", "toggleList": "تبديل القائمة", + "emptyToggleHeading": "تبديل فارغ h{}. انقر لإضافة محتوى.", + "emptyToggleList": "قائمة تبديل فارغة. انقر لإضافة المحتوى.", + "emptyToggleHeadingWeb": "تبديل فارغ h{level}. انقر لإضافة محتوى", "quoteList": "قائمة الاقتباس", "numberedList": "قائمة مرقمة", "bulletedList": "قائمة نقطية", "todoList": "قائمة مهام", "callout": "استدعاء", + "simpleTable": { + "moreActions": { + "color": "اللون", + "align": "المحاذاة", + "delete": "حذف", + "duplicate": "تكرار", + "insertLeft": "أدخل اليسار", + "insertRight": "إدراج إلى اليمين", + "insertAbove": "إدراج أعلاه", + "insertBelow": "إدراج أدناه", + "headerColumn": "عمود رأس الصفحة", + "headerRow": "صف رأس الصفحة", + "clearContents": "مسح المحتويات", + "setToPageWidth": "اضبط على عرض الصفحة", + "distributeColumnsWidth": "توزيع الأعمدة بالتساوي", + "duplicateRow": "صف مكرر", + "duplicateColumn": "عمود مكرر", + "textColor": "لون النص", + "cellBackgroundColor": "لون خلفية الخلية", + "duplicateTable": "جدول مكرر" + }, + "clickToAddNewRow": "انقر لإضافة صف جديد", + "clickToAddNewColumn": "انقر لإضافة عمود جديد", + "clickToAddNewRowAndColumn": "انقر لإضافة صف وعمود جديدين", + "headerName": { + "table": "الجدول", + "alignText": "محاذاة النص" + } + }, "cover": { "changeCover": "تبديل الغطاء", "colors": "الألوان", @@ -700,6 +1898,7 @@ "back": "خلف", "saveToGallery": "حفظ في المعرض", "removeIcon": "إزالة الرمز", + "removeCover": "إزالة الغلاف", "pasteImageUrl": "لصق عنوان URL للصورة", "or": "أو", "pickFromFiles": "اختر من الملفات", @@ -718,6 +1917,8 @@ "optionAction": { "click": "انقر", "toOpenMenu": " لفتح القائمة", + "drag": "سحب", + "toMove": " حرك", "delete": "يمسح", "duplicate": "ينسخ", "turnInto": "تحول إلى", @@ -728,14 +1929,44 @@ "left": "غادر", "center": "مركز", "right": "يمين", - "defaultColor": "تقصير" + "defaultColor": "تقصير", + "depth": "عمق", + "copyLinkToBlock": "نسخ الرابط إلى الكتلة" }, "image": { + "addAnImage": "أضف صورة", "copiedToPasteBoard": "تم نسخ رابط الصورة إلى الحافظة", - "addAnImage": "أضف صورة" + "addAnImageDesktop": "أضف صورة", + "addAnImageMobile": "انقر لإضافة صورة واحدة أو أكثر", + "dropImageToInsert": "إسقاط الصور لإدراجها", + "imageUploadFailed": "فشل رفع الصورة", + "imageDownloadFailed": "فشل تنزيل الصورة، يرجى المحاولة مرة أخرى", + "imageDownloadFailedToken": "فشل تنزيل الصورة بسبب عدم وجود رمز المستخدم، يرجى المحاولة مرة أخرى", + "errorCode": "كود الخطأ" + }, + "photoGallery": { + "name": "معرض الصور", + "imageKeyword": "صورة", + "imageGalleryKeyword": "معرض الصور", + "photoKeyword": "صورة", + "photoBrowserKeyword": "متصفح الصور", + "galleryKeyword": "معرض الصور", + "addImageTooltip": "أضف صورة", + "changeLayoutTooltip": "تغيير التخطيط", + "browserLayout": "المتصفح", + "gridLayout": "شبكة", + "deleteBlockTooltip": "حذف المعرض بأكمله" + }, + "math": { + "copiedToPasteBoard": "تم نسخ المعادلة الرياضية إلى الحافظة" + }, + "urlPreview": { + "copiedToPasteBoard": "تم نسخ الرابط إلى الحافظة", + "convertToLink": "تحويل إلى رابط التضمين" }, "outline": { - "addHeadingToCreateOutline": "أضف عناوين لإنشاء جدول محتويات." + "addHeadingToCreateOutline": "أضف عناوين لإنشاء جدول محتويات.", + "noMatchHeadings": "لم يتم العثور على عناوين مطابقة." }, "table": { "addAfter": "أضف بعد", @@ -748,11 +1979,69 @@ "contextMenu": { "copy": "نسخ", "cut": "قطع", - "paste": "لصق" + "paste": "لصق", + "pasteAsPlainText": "لصق كنص عادي" }, "action": "أجراءات", + "database": { + "selectDataSource": "حدد مصدر البيانات", + "noDataSource": "لا يوجد مصدر للبيانات", + "selectADataSource": "حدد مصدر البيانات", + "toContinue": "للمتابعة", + "newDatabase": "قاعدة بيانات جديدة", + "linkToDatabase": "رابط لقاعدة البيانات" + }, + "date": "تاريخ", + "video": { + "label": "فيديو", + "emptyLabel": "أضف فيديو", + "placeholder": "ألصق رابط الفيديو", + "copiedToPasteBoard": "تم نسخ رابط الفيديو إلى الحافظة", + "insertVideo": "إضافة الفيديو", + "invalidVideoUrl": "لم يتم دعم عنوان URL المصدر بعد.", + "invalidVideoUrlYouTube": "لم يتم دعم YouTube بعد.", + "supportedFormats": "التنسيقات المدعومة: MP4، WebM، MOV، AVI، FLV، MPEG/M4V، H.264" + }, + "file": { + "name": "ملف", + "uploadTab": "رفع", + "uploadMobile": "اختر ملف", + "uploadMobileGallery": "من معرض الصور", + "networkTab": "تضمين الرابط", + "placeholderText": "رفع أو تضمين ملف", + "placeholderDragging": "إفلات الملف للرفع", + "dropFileToUpload": "إفلات ملف لتحميله", + "fileUploadHint": "اسحب وأفلِت ملفًا أو انقر فوقه ", + "fileUploadHintSuffix": "تصفح", + "networkHint": "لصق رابط الملف", + "networkUrlInvalid": "عنوان URL غير صالح. تحقق من عنوان URL وحاول مرة أخرى.", + "networkAction": "تضمين", + "fileTooBigError": "حجم الملف كبير جدًا، يرجى رفع ملف بحجم أقل من 10 ميجا بايت", + "renameFile": { + "title": "إعادة تسمية الملف", + "description": "أدخل الاسم الجديد لهذا الملف", + "nameEmptyError": "لا يمكن ترك اسم الملف فارغًا." + }, + "uploadedAt": "تم الرفع في {}", + "linkedAt": "تمت إضافة الرابط في {}", + "failedToOpenMsg": "فشل في الفتح، لم يتم العثور على الملف" + }, + "subPage": { + "handlingPasteHint": " - (التعامل مع اللصق)", + "errors": { + "failedDeletePage": "فشل في حذف الصفحة", + "failedCreatePage": "فشل في إنشاء الصفحة", + "failedMovePage": "فشل نقل الصفحة إلى هذا المستند", + "failedDuplicatePage": "فشل في تكرار الصفحة", + "failedDuplicateFindView": "فشل في تكرار الصفحة - لم يتم العثور على العرض الأصلي" + } + }, + "cannotMoveToItsChildren": "لا يمكن الانتقال إلى أطفاله", "autoCompletionMenuItemName": "عنصر قائمة الإكمال التلقائي" }, + "outlineBlock": { + "placeholder": "جدول المحتويات" + }, "textBlock": { "placeholder": "اكتب \"/\" للأوامر" }, @@ -770,8 +2059,8 @@ "placeholder": "أدخل عنوان URL للصورة" }, "ai": { - "label": "إنشاء صورة من OpenAI", - "placeholder": "يرجى إدخال الامر الواصف لـ OpenAI لإنشاء الصورة" + "label": "إنشاء صورة من AI", + "placeholder": "يرجى إدخال الامر الواصف لـ AI لإنشاء الصورة" }, "stability_ai": { "label": "إنشاء صورة من Stability AI", @@ -782,7 +2071,9 @@ "invalidImage": "صورة غير صالحة", "invalidImageSize": "يجب أن يكون حجم الصورة أقل من 5 ميغا بايت", "invalidImageFormat": "تنسيق الصورة غير مدعوم. التنسيقات المدعومة: JPEG ، PNG ، GIF ، SVG", - "invalidImageUrl": "عنوان URL للصورة غير صالح" + "invalidImageUrl": "عنوان URL للصورة غير صالح", + "noImage": "لا يوجد مثل هذا الملف أو الدليل", + "multipleImagesFailed": "فشلت عملية رفع صورة واحدة أو أكثر، يرجى المحاولة مرة أخرى" }, "embedLink": { "label": "رابط متضمن", @@ -792,18 +2083,40 @@ "label": "Unsplash" }, "searchForAnImage": "ابحث عن صورة", - "pleaseInputYourOpenAIKey": "يرجى إدخال مفتاح OpenAI الخاص بك في صفحة الإعدادات", - "pleaseInputYourStabilityAIKey": "يرجى إدخال مفتاح Stability AI الخاص بك في صفحة الإعدادات", + "pleaseInputYourOpenAIKey": "يرجى إدخال مفتاح AI الخاص بك في صفحة الإعدادات", "saveImageToGallery": "احفظ الصورة", "failedToAddImageToGallery": "فشلت إضافة الصورة إلى المعرض", "successToAddImageToGallery": "تمت إضافة الصورة إلى المعرض بنجاح", - "unableToLoadImage": "غير قادر على تحميل الصورة" + "unableToLoadImage": "غير قادر على تحميل الصورة", + "maximumImageSize": "الحد الأقصى لحجم الصورة المسموح برفعها هو 10 ميجا بايت", + "uploadImageErrorImageSizeTooBig": "يجب أن يكون حجم الصورة أقل من 10 ميجا بايت", + "imageIsUploading": "جاري رفع الصورة", + "openFullScreen": "افتح في الشاشة الكاملة", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "الصورة السابقة", + "nextImageTooltip": "الصورة التالية", + "zoomOutTooltip": "تصغير", + "zoomInTooltip": "تكبير", + "changeZoomLevelTooltip": "تغيير مستوى التكبير", + "openLocalImage": "افتح الصورة", + "downloadImage": "تنزيل الصورة", + "closeViewer": "إغلاق العارض التفاعلي", + "scalePercentage": "{}%", + "deleteImageTooltip": "حذف الصورة" + } + }, + "pleaseInputYourStabilityAIKey": "يرجى إدخال مفتاح Stability AI الخاص بك في صفحة الإعدادات" }, "codeBlock": { "language": { "label": "لغة", - "placeholder": "اختار اللغة" - } + "placeholder": "اختار اللغة", + "auto": "آلي" + }, + "copyTooltip": "نسخ", + "searchLanguageHint": "ابحث عن لغة", + "codeCopiedSnackbar": "تم نسخ الكود إلى الحافظة!" }, "inlineLink": { "placeholder": "الصق أو اكتب ارتباطًا", @@ -824,14 +2137,54 @@ "page": { "label": "رابط إلى الصفحة", "tooltip": "انقر لفتح الصفحة" - } + }, + "deleted": "تم الحذف", + "deletedContent": "هذا المحتوى غير موجود أو تم حذفه", + "noAccess": "لا يوجد وصول", + "deletedPage": "الصفحة المحذوفة", + "trashHint": " - في سلة المهملات", + "morePages": "المزيد من الصفحات" }, "toolbar": { - "resetToDefaultFont": "إعادة تعيين إلى الافتراضي" + "resetToDefaultFont": "إعادة تعيين إلى الافتراضي", + "textSize": "حجم النص", + "textColor": "لون النص", + "h1": "العنوان 1", + "h2": "العنوان 2", + "h3": "العنوان 3", + "alignLeft": "محاذاة إلى اليسار", + "alignRight": "محاذاة إلى اليمين", + "alignCenter": "محاذاة إلى الوسط", + "link": "وصلة", + "textAlign": "محاذاة النص", + "moreOptions": "المزيد من الخيارات", + "font": "الخط", + "inlineCode": "الكود المضمن", + "suggestions": "اقتراحات", + "turnInto": "تحول إلى", + "equation": "معادلة", + "insert": "إدراج", + "linkInputHint": "لصق الرابط أو البحث عن الصفحات", + "pageOrURL": "الصفحة أو عنوان URL", + "linkName": "اسم الرابط", + "linkNameHint": "اسم رابط الإدخال" }, "errorBlock": { "theBlockIsNotSupported": "الإصدار الحالي لا يدعم هذا الحقل.", - "blockContentHasBeenCopied": "تم نسخ محتوى الحقل." + "clickToCopyTheBlockContent": "انقر هنا لنسخ محتوى الكتلة", + "blockContentHasBeenCopied": "تم نسخ محتوى الحقل.", + "parseError": "حدث خطأ أثناء تحليل الكتلة {}.", + "copyBlockContent": "نسخ محتوى الكتلة" + }, + "mobilePageSelector": { + "title": "حدد الصفحة", + "failedToLoad": "فشل تحميل قائمة الصفحات", + "noPagesFound": "لم يتم العثور على صفحات" + }, + "attachmentMenu": { + "choosePhoto": "اختر الصورة", + "takePicture": "التقط صورة", + "chooseFile": "اختر الملف" }, "data": { "timeHintTextInTwelveHour": "اثنا عشر ساعة", @@ -840,6 +2193,7 @@ }, "board": { "column": { + "label": "عمود", "createNewCard": "جديد", "renameGroupTooltip": "اضغط لإعادة تسمية المجموعة", "createNewColumn": "أضف مجموعة جديدة", @@ -870,6 +2224,7 @@ "ungroupedButtonTooltip": "تحتوي على بطاقات لا تنتمي إلى أي مجموعة", "ungroupedItemsTitle": "انقر للإضافة إلى السبورة", "groupBy": "مجموعة من", + "groupCondition": "حالة المجموعة", "referencedBoardPrefix": "نظرا ل", "notesTooltip": "ملاحظات بالداخل", "mobile": { @@ -877,6 +2232,22 @@ "showGroup": "إظهار المجموعة", "showGroupContent": "هل أنت متأكد من إظهار هذه المجموعة على السبورة؟", "failedToLoad": "فشل تحميل عرض السبورة" + }, + "dateCondition": { + "weekOf": "أسبوع {} - {}", + "today": "اليوم", + "yesterday": "أمس", + "tomorrow": "غداً", + "lastSevenDays": "آخر 7 أيام", + "nextSevenDays": "الأيام 7 القادمة", + "lastThirtyDays": "آخر 30 يوما", + "nextThirtyDays": "30 يوما القادم" + }, + "noGroup": "لا توجد مجموعة حسب الخاصية", + "noGroupDesc": "تتطلب وجهات نظر اللوحة خاصية للتجميع من أجل العرض", + "media": { + "cardText": "{} {}", + "fallbackName": "الملفات" } }, "calendar": { @@ -887,29 +2258,45 @@ "today": "اليوم", "jumpToday": "انتقل إلى اليوم", "previousMonth": "الشهر الماضى", - "nextMonth": "الشهر القادم" + "nextMonth": "الشهر القادم", + "views": { + "day": "يوم", + "week": "أسبوع", + "month": "شهر", + "year": "سنة" + } + }, + "mobileEventScreen": { + "emptyTitle": "لا يوجد أحداث حتى الآن", + "emptyBody": "اضغط على زر الإضافة \"+\" لإنشاء حدث في هذا اليوم." }, "settings": { "showWeekNumbers": "إظهار أرقام الأسبوع", "showWeekends": "عرض عطلات نهاية الأسبوع", "firstDayOfWeek": "اليوم الأول من الأسبوع", "layoutDateField": "تقويم التخطيط بواسطة", + "changeLayoutDateField": "تغيير حقل التخطيط", "noDateTitle": "بدون تاريخ", + "noDateHint": "ستظهر الأحداث غير المجدولة هنا", "unscheduledEventsTitle": "الأحداث غير المجدولة", "clickToAdd": "انقر للإضافة إلى التقويم", "name": "تخطيط التقويم", - "noDateHint": "ستظهر الأحداث غير المجدولة هنا" + "clickToOpen": "انقر لفتح السجل" }, "referencedCalendarPrefix": "نظرا ل", - "quickJumpYear": "انتقل إلى" + "quickJumpYear": "انتقل إلى", + "duplicateEvent": "حدث مكرر" }, "errorDialog": { - "title": "خطأ AppFlowy", + "title": "خطأ @:appName", "howToFixFallback": "نأسف للإزعاج! قم بإرسال مشكلة على صفحة GitHub الخاصة بنا والتي تصف الخطأ الخاص بك.", + "howToFixFallbackHint1": "نحن نأسف للإزعاج! أرسل مشكلة على ", + "howToFixFallbackHint2": " الصفحة التي تصف الخطأ الخاص بك.", "github": "عرض على جيثب" }, "search": { "label": "يبحث", + "sidebarSearchIcon": "ابحث وانتقل بسرعة إلى الصفحة", "placeholder": { "actions": "إجراءات البحث ..." } @@ -967,19 +2354,48 @@ "medium": "متوسط", "mediumDark": "متوسطة الظلمة", "dark": "مظلم" - } + }, + "openSourceIconsFrom": "أيقونات مفتوحة المصدر من" }, "inlineActions": { "noResults": "لا نتائج", + "recentPages": "الصفحات الأخيرة", "pageReference": "مرجع الصفحة", + "docReference": "مرجع المستند", + "boardReference": "مرجع اللوحة", + "calReference": "مرجع التقويم", + "gridReference": "مرجع الشبكة", "date": "تاريخ", "reminder": { "groupTitle": "تذكير", "shortKeyword": "يذكر" - } + }, + "createPage": "إنشاء صفحة فرعية \"{}\"" }, "datePicker": { - "dateTimeFormatTooltip": "تغيير تنسيق التاريخ والوقت في الإعدادات" + "dateTimeFormatTooltip": "تغيير تنسيق التاريخ والوقت في الإعدادات", + "dateFormat": "تنسيق التاريخ", + "includeTime": "تضمين الوقت", + "isRange": "تاريخ النهاية", + "timeFormat": "تنسيق الوقت", + "clearDate": "مسح التاريخ", + "reminderLabel": "تذكير", + "selectReminder": "حدد التذكير", + "reminderOptions": { + "none": "لا شيء", + "atTimeOfEvent": "وقت الحدث", + "fiveMinsBefore": "قبل 5 دقائق", + "tenMinsBefore": "قبل 10 دقائق", + "fifteenMinsBefore": "قبل 15 دقيقة", + "thirtyMinsBefore": "قبل 30 دقيقة", + "oneHourBefore": "قبل ساعة واحدة", + "twoHoursBefore": "قبل ساعتين", + "onDayOfEvent": "في يوم الحدث", + "oneDayBefore": "قبل يوم واحد", + "twoDaysBefore": "قبل يومين", + "oneWeekBefore": "قبل اسبوع واحد", + "custom": "مخصص" + } }, "relativeDates": { "yesterday": "أمس", @@ -1027,15 +2443,20 @@ "replace": "استبدل", "replaceAll": "استبدال الكل", "noResult": "لا نتائج", - "caseSensitive": "دقة الحروف" + "caseSensitive": "دقة الحروف", + "searchMore": "ابحث للعثور على المزيد من النتائج" }, "error": { "weAreSorry": "آسفون", - "loadingViewError": "نواجه مشكلة في تحميل هذا العرض. يرجى التحقق من اتصالك بالإنترنت، وتحديث التطبيق، ولا تتردد في التواصل مع الفريق إذا استمرت المشكلة." + "loadingViewError": "نواجه مشكلة في تحميل هذا العرض. يرجى التحقق من اتصالك بالإنترنت، وتحديث التطبيق، ولا تتردد في التواصل مع الفريق إذا استمرت المشكلة.", + "syncError": "لم تتم مزامنة البيانات من جهاز آخر", + "syncErrorHint": "يرجى إعادة فتح هذه الصفحة على الجهاز الذي تم تحريرها عليه آخر مرة، ثم فتحها مرة أخرى على الجهاز الحالي.", + "clickToCopy": "انقر هنا لنسخ كود الخطأ" }, "editor": { "bold": "عريض", "bulletedList": "قائمة نقطية", + "bulletedListShortForm": "نقطية", "checkbox": "خانة الاختيار", "embedCode": "كود متضمن", "heading1": "رأسية اولى", @@ -1044,9 +2465,15 @@ "highlight": "ابراز", "color": "لون", "image": "صورة", + "date": "تاريخ", + "page": "صفحة", "italic": "مائل", "link": "رابط", "numberedList": "قائمة مرقمة", + "numberedListShortForm": "مرقمة", + "toggleHeading1ShortForm": "تبديل h1", + "toggleHeading2ShortForm": "تبديل h2", + "toggleHeading3ShortForm": "تبديل h3", "quote": "اقتباس", "strikethrough": "يتوسطه خط", "text": "نص", @@ -1071,6 +2498,8 @@ "backgroundColorPurple": "خلفية بنفسجية", "backgroundColorPink": "خلفية وردية", "backgroundColorRed": "خلفية حمراء", + "backgroundColorLime": "خلفية ليمونية", + "backgroundColorAqua": "خلفية مائية", "done": "تم", "cancel": "الغاء", "tint1": "صبغة 1", @@ -1095,6 +2524,9 @@ "mobileHeading1": "عنوان 1", "mobileHeading2": "العنوان 2", "mobileHeading3": "العنوان 3", + "mobileHeading4": "العنوان 4", + "mobileHeading5": "العنوان 5", + "mobileHeading6": "العنوان 6", "textColor": "لون الخط", "backgroundColor": "لون الخلفية", "addYourLink": "أضف الرابط الخاص بك", @@ -1118,6 +2550,8 @@ "copy": "نسخ", "paste": "لصق", "find": "البحث", + "select": "حدد", + "selectAll": "حدد الكل", "previousMatch": "نتيجة البحث السابقة", "nextMatch": "نتيجة البحث التالية", "closeFind": "اغلاق", @@ -1144,11 +2578,18 @@ "rowDuplicate": "نسخة طبق الاصل", "colClear": "مسح المحتوى", "rowClear": "مسح المحتوى", - "slashPlaceHolder": "اكتب \"/\" لإدراج كتلة، أو ابدأ الكتابة" + "slashPlaceHolder": "اكتب \"/\" لإدراج كتلة، أو ابدأ الكتابة", + "typeSomething": "اكتب شيئا ما...", + "toggleListShortForm": "تبديل", + "quoteListShortForm": "إقتباس", + "mathEquationShortForm": "صيغة", + "codeBlockShortForm": "الكود" }, "favorite": { "noFavorite": "لا توجد صفحة مفضلة", - "noFavoriteHintText": "اسحب الصفحة إلى اليسار لإضافتها إلى المفضلة لديك" + "noFavoriteHintText": "اسحب الصفحة إلى اليسار لإضافتها إلى المفضلة لديك", + "removeFromSidebar": "إزالة من الشريط الجانبي", + "addToSidebar": "تثبيت على الشريط الجانبي" }, "cardDetails": { "notesPlaceholder": "أدخل / لإدراج كتلة، أو ابدأ في الكتابة" @@ -1168,5 +2609,635 @@ "date": "تاريخ", "addField": "إضافة حقل", "userIcon": "رمز المستخدم" + }, + "noLogFiles": "لا توجد ملفات السجل", + "newSettings": { + "myAccount": { + "title": "حسابي", + "subtitle": "قم بتخصيص ملفك الشخصي، وإدارة أمان الحساب، وفتح مفاتيح الذكاء الاصطناعي، أو تسجيل الدخول إلى حسابك.", + "profileLabel": "اسم الحساب وصورة الملف الشخصي", + "profileNamePlaceholder": "أدخل اسمك", + "accountSecurity": "أمان الحساب", + "2FA": "المصادقة بخطوتين", + "aiKeys": "مفاتيح الذكاء الاصطناعي", + "accountLogin": "تسجيل الدخول إلى الحساب", + "updateNameError": "فشل في تحديث الاسم", + "updateIconError": "فشل في تحديث الأيقونة", + "aboutAppFlowy": "حول appName", + "deleteAccount": { + "title": "حذف الحساب", + "subtitle": "احذف حسابك وجميع بياناتك بشكل دائم.", + "description": "احذف حسابك نهائيًا وأزل إمكانية الوصول إلى جميع مساحات العمل.", + "deleteMyAccount": "حذف حسابي", + "dialogTitle": "حذف الحساب", + "dialogContent1": "هل أنت متأكد أنك تريد حذف حسابك نهائياً؟", + "dialogContent2": "لا يمكن التراجع عن هذا الإجراء، وسوف يؤدي إلى إزالة الوصول من جميع مساحات العمل، ومسح حسابك بالكامل، بما في ذلك مساحات العمل الخاصة، وإزالتك من جميع مساحات العمل المشتركة.", + "confirmHint1": "من فضلك اكتب \"@:newSettings.myAccount.deleteAccount.confirmHint3\" للتأكيد.", + "confirmHint2": "أفهم أن هذا الإجراء لا رجعة فيه وسيؤدي إلى حذف حسابي وجميع البيانات المرتبطة به بشكل دائم.", + "confirmHint3": "حذف حسابي", + "checkToConfirmError": "يجب عليك تحديد المربع لتأكيد الحذف", + "failedToGetCurrentUser": "فشل في الحصول على البريد الإلكتروني الحالي للمستخدم", + "confirmTextValidationFailed": "نص التأكيد الخاص بك لا يتطابق مع \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", + "deleteAccountSuccess": "تم حذف الحساب بنجاح" + } + }, + "workplace": { + "name": "مكان العمل", + "title": "إعدادات مكان العمل", + "subtitle": "قم بتخصيص مظهر مساحة العمل الخاصة بك والسمة والخط وتخطيط النص والتاريخ والوقت واللغة.", + "workplaceName": "اسم مكان العمل", + "workplaceNamePlaceholder": "أدخل اسم مكان العمل", + "workplaceIcon": "أيقونة مكان العمل", + "workplaceIconSubtitle": "قم بتحميل صورة أو استخدم رمزًا تعبيريًا لمساحة عملك. سيظهر الرمز في الشريط الجانبي والإشعارات.", + "renameError": "فشل في إعادة تسمية مكان العمل", + "updateIconError": "فشل في تحديث الأيقونة", + "chooseAnIcon": "اختر أيقونة", + "appearance": { + "name": "المظهر", + "themeMode": { + "auto": "آلي", + "light": "فاتح", + "dark": "داكن" + }, + "language": "لغة" + } + }, + "syncState": { + "syncing": "المزامنة", + "synced": "متزامنة", + "noNetworkConnected": "لا يوجد شبكة متصلة" + } + }, + "pageStyle": { + "title": "نمط الصفحة", + "layout": "تَخطِيط", + "coverImage": "صورة الغلاف", + "pageIcon": "أيقونة الصفحة", + "colors": "الألوان", + "gradient": "متدرج", + "backgroundImage": "صورة الخلفية", + "presets": "الإعدادات المسبقة", + "photo": "صورة", + "unsplash": "Unsplash", + "pageCover": "غلاف الصفحة", + "none": "لا شيء", + "openSettings": "افتح الإعدادات", + "photoPermissionTitle": "يرغب @:appName في الوصول إلى مكتبة الصور الخاصة بك", + "photoPermissionDescription": "يحتاج @:appName إلى الوصول إلى صورك للسماح لك بإضافة صور إلى مستنداتك", + "cameraPermissionTitle": "يريد @:appName الوصول إلى الكاميرا الخاصة بك", + "cameraPermissionDescription": "يحتاج @:appName إلى الوصول إلى الكاميرا للسماح لك بإضافة صور إلى مستنداتك من الكاميرا", + "doNotAllow": "لا تسمح", + "image": "صورة" + }, + "commandPalette": { + "placeholder": "ابحث أو اطرح سؤالا...", + "bestMatches": "أفضل المطابقات", + "recentHistory": "التاريخ الحديث", + "navigateHint": "للتنقل", + "loadingTooltip": "نحن نبحث عن نتائج...", + "betaLabel": "النسخة التجريبية", + "betaTooltip": "نحن ندعم حاليًا البحث عن الصفحات والمحتوى في المستندات فقط", + "fromTrashHint": "من سلة المحذوفات", + "noResultsHint": "لم نعثر على ما تبحث عنه، حاول البحث عن مصطلح آخر.", + "clearSearchTooltip": "مسح حقل البحث" + }, + "space": { + "delete": "حذف", + "deleteConfirmation": "حذف: ", + "deleteConfirmationDescription": "سيتم حذف جميع الصفحات الموجودة ضمن هذه المساحة ونقلها إلى سلة المحذوفات، وسيتم إلغاء نشر أي صفحات منشورة.", + "rename": "إعادة تسمية المساحة", + "changeIcon": "تغيير الأيقونة", + "manage": "إدارة المساحة", + "addNewSpace": "إنشاء مساحة", + "collapseAllSubPages": "طي كل الصفحات الفرعية", + "createNewSpace": "إنشاء مساحة جديدة", + "createSpaceDescription": "إنشاء مساحات عامة وخاصة متعددة لتنظيم عملك بشكل أفضل.", + "spaceName": "اسم المساحة", + "spaceNamePlaceholder": "على سبيل المثال التسويق والهندسة والموارد البشرية", + "permission": "إذن المساحة", + "publicPermission": "عام", + "publicPermissionDescription": "جميع أعضاء مساحة العمل لديهم إمكانية الوصول الكامل", + "privatePermission": "خاص", + "privatePermissionDescription": "أنت فقط من يمكنه الوصول إلى هذه المساحة", + "spaceIconBackground": "لون الخلفية", + "spaceIcon": "أيقونة", + "dangerZone": "منطقة الخطر", + "unableToDeleteLastSpace": "غير قادر على حذف المساحة الأخيرة", + "unableToDeleteSpaceNotCreatedByYou": "غير قادر على حذف المساحات التي أنشأها الآخرون", + "enableSpacesForYourWorkspace": "تمكين المساحات لمساحة العمل الخاصة بك", + "title": "المساحات", + "defaultSpaceName": "عام", + "upgradeSpaceTitle": "تمكين المساحات", + "upgradeSpaceDescription": "قم بإنشاء مساحات عامة وخاصة متعددة لتنظيم مساحة عملك بشكل أفضل.", + "upgrade": "تحديث", + "upgradeYourSpace": "إنشاء مساحات متعددة", + "quicklySwitch": "انتقل بسرعة إلى المساحة التالية", + "duplicate": "مساحة مكررة", + "movePageToSpace": "نقل الصفحة إلى المساحة", + "cannotMovePageToDatabase": "لا يمكن نقل الصفحة إلى قاعدة البيانات", + "switchSpace": "تبديل المساحة", + "spaceNameCannotBeEmpty": "لا يمكن أن يكون اسم المساحة فارغًا", + "success": { + "deleteSpace": "تم حذف المساحة بنجاح", + "renameSpace": "تم إعادة تسمية المساحة بنجاح", + "duplicateSpace": "تم تكرار المساحة بنجاح", + "updateSpace": "تم تحديث المساحة بنجاح" + }, + "error": { + "deleteSpace": "فشل في حذف المساحة", + "renameSpace": "فشل في إعادة تسمية المساحة", + "duplicateSpace": "فشل في تكرار المساحة", + "updateSpace": "فشل في تحديث المساحة" + }, + "createSpace": "إنشاء مساحة", + "manageSpace": "إدارة المساحة", + "renameSpace": "إعادة تسمية المساحة", + "mSpaceIconColor": "لون أيقونة المساحة", + "mSpaceIcon": "أيقونة المساحة" + }, + "publish": { + "hasNotBeenPublished": "لم يتم نشر هذه الصفحة بعد", + "spaceHasNotBeenPublished": "لم يتم دعم نشر المساحة بعد", + "reportPage": "صفحة التقرير", + "databaseHasNotBeenPublished": "لم يتم دعم نشر قاعدة البيانات بعد.", + "createdWith": "تم إنشاؤه باستخدام", + "downloadApp": "تنزيل AppFlowy", + "copy": { + "codeBlock": "تم نسخ محتوى كتلة الكود إلى الحافظة", + "imageBlock": "تم نسخ رابط الصورة إلى الحافظة", + "mathBlock": "تم نسخ المعادلة الرياضية إلى الحافظة", + "fileBlock": "تم نسخ رابط الملف إلى الحافظة" + }, + "containsPublishedPage": "تحتوي هذه الصفحة على صفحة واحدة أو أكثر منشورة. إذا تابعت، فسيتم إلغاء نشرها. هل تريد متابعة الحذف؟", + "publishSuccessfully": "تم النشر بنجاح", + "unpublishSuccessfully": "تم إلغاء النشر بنجاح", + "publishFailed": "فشل في النشر", + "unpublishFailed": "فشل في إلغاء النشر", + "noAccessToVisit": "لا يمكن الوصول إلى هذه الصفحة...", + "createWithAppFlowy": "إنشاء موقع ويب مع AppFlowy", + "fastWithAI": "سريع وسهل مع الذكاء الاصطناعي.", + "tryItNow": "جربها الآن", + "onlyGridViewCanBePublished": "لا يمكن نشر سوى عرض الشبكة", + "database": { + "zero": "نشر {} عرض محدد", + "one": "نشر {} عروض محددة", + "many": "نشر {} عروض محددة", + "other": "نشر {} عروض محددة" + }, + "mustSelectPrimaryDatabase": "يجب تحديد العرض الأساسي", + "noDatabaseSelected": "لم يتم تحديد أي قاعدة بيانات، يرجى تحديد قاعدة بيانات واحدة على الأقل.", + "unableToDeselectPrimaryDatabase": "غير قادر على إلغاء تحديد قاعدة البيانات الأساسية", + "saveThisPage": "ابدأ بهذا القالب", + "duplicateTitle": "أين تريد أن تضيف", + "selectWorkspace": "حدد مساحة العمل", + "addTo": "أضف إلى", + "duplicateSuccessfully": "تمت إضافته إلى مساحة العمل الخاصة بك", + "duplicateSuccessfullyDescription": "لم تقم بتثبيت AppFlowy؟ سيبدأ التنزيل تلقائيًا بعد النقر فوق \"تنزيل\".", + "downloadIt": "تنزيل", + "openApp": "افتح في التطبيق", + "duplicateFailed": "مكررة فشلت", + "membersCount": { + "zero": "لا يوجد أعضاء", + "one": "1 عضو", + "many": "{count} عضوا", + "other": "{count} عضوا" + }, + "useThisTemplate": "استخدم القالب" + }, + "web": { + "continue": "متابعة", + "or": "أو", + "continueWithGoogle": "متابعة مع جوجل", + "continueWithGithub": "متابعة مع GitHub", + "continueWithDiscord": "متابعة مع Discord", + "continueWithApple": "متابعة مع Apple ", + "moreOptions": "المزيد من الخيارات", + "collapse": "طي", + "signInAgreement": "بالنقر فوق \"متابعة\" أعلاه، فإنك توافق على شروط استخدام AppFlowy", + "signInLocalAgreement": "من خلال النقر على \"البدء\" أعلاه، فإنك توافق على شروط وأحكام AppFlowy", + "and": "و", + "termOfUse": "شروط", + "privacyPolicy": "سياسة الخصوصية", + "signInError": "خطأ في تسجيل الدخول", + "login": "سجل أو قم بتسجيل الدخول", + "fileBlock": { + "uploadedAt": "تم الرفع في {time}", + "linkedAt": "تمت إضافة الرابط في {time}", + "empty": "رفع أو تضمين ملف", + "uploadFailed": "فشل الرفع، يرجى المحاولة مرة أخرى", + "retry": "إعادة المحاولة" + }, + "importNotion": "الاستيراد من Notion", + "import": "الاستيراد", + "importSuccess": "تم الرفع بنجاح", + "importSuccessMessage": "سنخطرك عند اكتمال عملية الاستيراد. بعد ذلك، يمكنك عرض الصفحات المستوردة في الشريط الجانبي.", + "importFailed": "فشل الاستيراد، يرجى التحقق من تنسيق الملف", + "dropNotionFile": "قم بإسقاط ملف Notion zip الخاص بك هنا للرفع، أو انقر للاستعراض", + "error": { + "pageNameIsEmpty": "اسم الصفحة فارغ، الرجاء تجربة صفحة أخرى" + } + }, + "globalComment": { + "comments": "تعليقات", + "addComment": "أضف تعليق", + "reactedBy": "تفاعل من قبل", + "addReaction": "إضافة تفاعل", + "reactedByMore": "و {count} آخرين", + "showSeconds": { + "one": "منذ ثانية واحدة", + "other": "منذ {count} ثانية", + "zero": "الآن", + "many": "منذ {count} ثانية" + }, + "showMinutes": { + "one": "منذ دقيقة واحدة", + "other": "منذ {count} دقيقة", + "many": "منذ {count} دقيقة" + }, + "showHours": { + "one": "منذ ساعة واحدة", + "other": "منذ {count} ساعة", + "many": "منذ {count} ساعة" + }, + "showDays": { + "one": "منذ يوم واحد", + "other": "منذ {count} يوم", + "many": "منذ {count} يوم" + }, + "showMonths": { + "one": "منذ شهر واحد", + "other": "منذ {count} شهر", + "many": "منذ {count} شهر" + }, + "showYears": { + "one": "منذ سنة واحدة", + "other": "منذ {count} سنة", + "many": "منذ {count} سنة" + }, + "reply": "رد", + "deleteComment": "حذف التعليق", + "youAreNotOwner": "أنت لست صاحب هذا التعليق", + "confirmDeleteDescription": "هل أنت متأكد أنك تريد حذف هذا التعليق؟", + "hasBeenDeleted": "تم الحذف", + "replyingTo": "الرد على", + "noAccessDeleteComment": "لا يجوز لك حذف هذا التعليق", + "collapse": "طي", + "readMore": "اقرأ المزيد", + "failedToAddComment": "فشل في إضافة التعليق", + "commentAddedSuccessfully": "تمت إضافة التعليق بنجاح.", + "commentAddedSuccessTip": "لقد قمت للتو بإضافة تعليق أو الرد عليه. هل ترغب في الانتقال إلى الأعلى لمشاهدة أحدث التعليقات؟" + }, + "template": { + "asTemplate": "حفظ كقالب", + "name": "اسم القالب", + "description": "وصف القالب", + "about": "قالب حول", + "deleteFromTemplate": "حذف من القوالب", + "preview": "معاينة القالب", + "categories": "فئات القوالب", + "isNewTemplate": "PIN إلى قالب جديد", + "featured": "PIN إلى المميز", + "relatedTemplates": "القوالب ذات الصلة", + "requiredField": "{field} مطلوب", + "addCategory": "أضف \"{category}\"", + "addNewCategory": "إضافة فئة جديدة", + "addNewCreator": "إضافة مبدع جديد", + "deleteCategory": "حذف الفئة", + "editCategory": "تعديل الفئة", + "editCreator": "مبدع التعديل", + "category": { + "name": "اسم الفئة", + "icon": "أيقونة الفئة", + "bgColor": "لون خلفية الفئة", + "priority": "أولوية الفئة", + "desc": "وصف الفئة", + "type": "نوع الفئة", + "icons": "أيقونات الفئة", + "colors": "فئة الألوان", + "byUseCase": "حسب حالة الاستخدام", + "byFeature": "حسب الميزة", + "deleteCategory": "حذف الفئة", + "deleteCategoryDescription": "هل أنت متأكد أنك تريد حذف هذه الفئة؟", + "typeToSearch": "اكتب للبحث عن الفئات..." + }, + "creator": { + "label": "مبدع القالب", + "name": "اسم المنشئ", + "avatar": "الصورة الرمزية للمبدع", + "accountLinks": "روابط حسابات المبدع", + "uploadAvatar": "انقر هنا لتحميل الصورة الرمزية", + "deleteCreator": "حذف المبدع", + "deleteCreatorDescription": "هل أنت متأكد أنك تريد حذف هذا المبدع؟", + "typeToSearch": "اكتب للبحث عن المبدعين..." + }, + "uploadSuccess": "تم تحميل القالب بنجاح", + "uploadSuccessDescription": "لقد تم رفع القالب الخاص بك بنجاح. يمكنك الآن عرضه في معرض القوالب.", + "viewTemplate": "عرض القالب", + "deleteTemplate": "حذف القالب", + "deleteSuccess": "تم حذف القالب بنجاح", + "deleteTemplateDescription": "لن يؤثر هذا على الصفحة الحالية أو حالة النشر. هل أنت متأكد من أنك تريد حذف هذا القالب؟", + "addRelatedTemplate": "إضافة قالب ذو صلة", + "removeRelatedTemplate": "إزالة القالب ذو الصلة", + "uploadAvatar": "رفع الصورة الرمزية", + "searchInCategory": "البحث في {category}", + "label": "القوالب" + }, + "fileDropzone": { + "dropFile": "انقر أو اسحب الملف إلى هذه المنطقة لتحميله", + "uploading": "جاري الرفع...", + "uploadFailed": "فشل الرفع", + "uploadSuccess": "تم الرفع بنجاح", + "uploadSuccessDescription": "تم رفع الملف بنجاح", + "uploadFailedDescription": "فشل رفع الملف", + "uploadingDescription": "جاري رفع الملف" + }, + "gallery": { + "preview": "افتح في شاشة كاملة", + "copy": "نسخ", + "download": "تنزيل", + "prev": "السابق", + "next": "التالي", + "resetZoom": "إعادة ضبط التكبير", + "zoomIn": "التكبير ", + "zoomOut": "التصغير" + }, + "invitation": { + "join": "الانضمام", + "on": "على", + "invitedBy": "بدعوة من", + "membersCount": { + "zero": "{count} عضوا", + "one": "{count} عضو", + "many": "{count} عضوا", + "other": "{count} عضوا" + }, + "tip": "لقد تمت دعوتك للانضمام إلى مساحة العمل هذه باستخدام معلومات الاتصال أدناه. إذا كانت هذه المعلومات غير صحيحة، فاتصل بالمسؤول لإعادة إرسال الدعوة.", + "joinWorkspace": "انضم إلى مساحة العمل", + "success": "لقد انضممت بنجاح إلى مساحة العمل", + "successMessage": "يمكنك الآن الوصول إلى كافة الصفحات ومساحات العمل الموجودة بداخله.", + "openWorkspace": "افتح AppFlowy", + "alreadyAccepted": "لقد قبلت الدعوة بالفعل", + "errorModal": { + "title": "لقد حدث خطأ ما", + "description": "قد لا يكون لحسابك الحالي {email} حق الوصول إلى مساحة العمل هذه. يرجى تسجيل الدخول بالحساب الصحيح أو الاتصال بمالك مساحة العمل للحصول على المساعدة.", + "contactOwner": "اتصل بالمالك", + "close": "العودة إلى الرئيسية", + "changeAccount": "تغيير الحساب" + } + }, + "requestAccess": { + "title": "لا يمكن الوصول إلى هذه الصفحة", + "subtitle": "يمكنك طلب الوصول من مالك هذه الصفحة. بمجرد الموافقة، يمكنك عرض الصفحة.", + "requestAccess": "طلب الوصول", + "backToHome": "العودة إلى الرئيسية", + "tip": "لقد قمت بتسجيل الدخول حاليًا باسم .", + "mightBe": "قد تحتاج إلى مع حساب مختلف.", + "successful": "تم إرسال الطلب بنجاح", + "successfulMessage": "سيتم إعلامك بمجرد موافقة المالك على طلبك.", + "requestError": "فشل في طلب الوصول", + "repeatRequestError": "لقد طلبت بالفعل الوصول إلى هذه الصفحة" + }, + "approveAccess": { + "title": "الموافقة على طلب الانضمام إلى مساحة العمل", + "requestSummary": "طلبات الانضمام والوصول", + "upgrade": "ترقية", + "downloadApp": "تنزيل AppFlowy", + "approveButton": "موافقة", + "approveSuccess": "تمت الموافقة بنجاح", + "approveError": "فشل في الموافقة، تأكد من عدم تجاوز حد خطة مساحة العمل", + "getRequestInfoError": "فشل في الحصول على معلومات الطلب", + "memberCount": { + "zero": "لا يوجد أعضاء", + "one": "عضو واحد", + "many": "{count} عضوا", + "other": "{count} عضوا" + }, + "alreadyProTitle": "لقد وصلت إلى الحد الأقصى لخطة مساحة العمل", + "alreadyProMessage": "اطلب منهم الاتصال لإلغاء تأمين المزيد من الأعضاء", + "repeatApproveError": "لقد وافقت بالفعل على هذا الطلب", + "ensurePlanLimit": "تأكد من عدم تجاوز حد خطة مساحة العمل. إذا تم تجاوز الحد، ففكر في خطة مساحة العمل أو .", + "requestToJoin": "طلب الانضمام", + "asMember": "كعضو" + }, + "upgradePlanModal": { + "title": "الترقية إلى الإصدار الاحترافي", + "message": "وصل {name} إلى الحد الأقصى للعضوية المجانية. قم بالترقية إلى الخطة الاحترافية لدعوة المزيد من الأعضاء.", + "upgradeSteps": "كيفية ترقية خطتك على AppFlowy:", + "step1": "1. انتقل إلى الإعدادات", + "step2": "2. انقر فوق \"الخطة\"", + "step3": "3. حدد \"تغيير الخطة\"", + "appNote": "ملحوظة: ", + "actionButton": "ترقية", + "downloadLink": "تنزيل التطبيق", + "laterButton": "لاحقاً", + "refreshNote": "بعد الترقية الناجحة، انقر فوق لتفعيل ميزاتك الجديدة.", + "refresh": "هنا" + }, + "breadcrumbs": { + "label": "مسارات التنقل" + }, + "time": { + "justNow": "الآن", + "seconds": { + "one": "ثانية واحدة", + "other": "{count} من الثواني" + }, + "minutes": { + "one": "دقيقة واحدة", + "other": "{count} من الدقائق" + }, + "hours": { + "one": "ساعة واحدة", + "other": "{count} من الساعات" + }, + "days": { + "one": "يوم واحد", + "other": "{count} من الأيام" + }, + "weeks": { + "one": "اسبوع واحد", + "other": "{count} من الأسابيع" + }, + "months": { + "one": "شهر واحد", + "other": "{count} من الاشهر" + }, + "years": { + "one": "سنة واحدة", + "other": "{count} من السنوات" + }, + "ago": "منذ", + "yesterday": "أمس", + "today": "اليوم" + }, + "members": { + "zero": "لا يوجد أعضاء", + "one": "عضو واحد", + "many": "{count} من الأعضاء", + "other": "{count} من الأعضاء" + }, + "tabMenu": { + "close": "إغلاق", + "closeDisabledHint": "لا يمكن إغلاق علامة تبويب مثبتة، يرجى إلغاء التثبيت أولاً", + "closeOthers": "إغلاق علامات التبويب الأخرى", + "closeOthersHint": "سيؤدي هذا إلى إغلاق جميع علامات التبويب غير المثبتة باستثناء هذه العلامة", + "closeOthersDisabledHint": "تم تثبيت جميع علامات التبويب، ولا يمكن العثور على أي علامات تبويب لإغلاقها", + "favorite": "مفضل", + "unfavorite": "غير مفضل", + "favoriteDisabledHint": "لا يمكن إضافة هذا العرض إلى المفضلة", + "pinTab": "تثبيت", + "unpinTab": "إزالة التثبيت" + }, + "openFileMessage": { + "success": "تم فتح الملف بنجاح", + "fileNotFound": "لم يتم العثور على الملف", + "noAppToOpenFile": "لا يوجد تطبيق لفتح هذا الملف", + "permissionDenied": "لا يوجد إذن لفتح هذا الملف", + "unknownError": "فشل فتح الملف" + }, + "inviteMember": { + "requestInviteMembers": "دعوة إلى مكان مساحة عملك", + "inviteFailedMemberLimit": "لقد تم الوصول إلى الحد الأقصى للأعضاء، يرجى ", + "upgrade": "ترقية", + "addEmail": "email@example.com، email2@example.com...", + "requestInvites": "إرسال الدعوات", + "inviteAlready": "لقد قمت بالفعل بدعوة هذا البريد الإلكتروني: {email}", + "inviteSuccess": "تم إرسال الدعوة بنجاح", + "description": "أدخل رسائل البريد الإلكتروني أدناه مع وضع فاصلة بينها. تعتمد الرسوم على عدد الأعضاء.", + "emails": "بريد إلكتروني" + }, + "quickNote": { + "label": "ملاحظة سريعة", + "quickNotes": "ملاحظات سريعة", + "search": "بحث ملاحظات سريعة", + "collapseFullView": "طي العرض الكامل", + "expandFullView": "توسيع العرض الكامل", + "createFailed": "فشل في إنشاء ملاحظة سريعة", + "quickNotesEmpty": "لا توجد ملاحظات سريعة", + "emptyNote": "ملاحظة فارغة", + "deleteNotePrompt": "سيتم حذف الملاحظة المحددة بشكل دائم. هل أنت متأكد من أنك تريد حذفها؟", + "addNote": "ملاحظة جديدة", + "noAdditionalText": "لا يوجد نص إضافي" + }, + "subscribe": { + "upgradePlanTitle": "قارن واختر الخطة", + "yearly": "سنوي", + "save": "وفر {discount}%", + "monthly": "شهريا", + "priceIn": "السعر في ", + "free": "مجاني", + "pro": "احترافي", + "freeDescription": "للأفراد حتى عضوين لتنظيم كل شيء", + "proDescription": "للفرق الصغيرة لإدارة المشاريع ومعرفة الفريق", + "proDuration": { + "monthly": "لكل عضو شهريا\nيتم دفع الفاتورة شهريا", + "yearly": "لكل عضو شهريا\nيتم دفعها سنويا" + }, + "cancel": "يرجع إلى إصدار أقدم", + "changePlan": "الترقية إلى الخطة الاحترافية", + "everythingInFree": "كل شيء مجاني +", + "currentPlan": "الحالي", + "freeDuration": "للأبد", + "freePoints": { + "first": "مساحة عمل تعاونية واحدة تتسع لعضوين كحد أقصى", + "second": "صفحات وكتل غير محدودة", + "three": "سعة تخزين 5 جيجا بايت", + "four": "البحث الذكي", + "five": "20 استجابة الذكاء الاصطناعي", + "six": "تطبيق الجوال", + "seven": "التعاون في الوقت الحقيقي" + }, + "proPoints": { + "first": "تخزين غير محدود", + "second": "ما يصل إلى 10 أعضاء في مساحة العمل", + "three": "استجابات الذكاء الاصطناعي غير المحدودة", + "four": "رفع ملفات غير محدودة", + "five": "مساحة اسم مخصصة" + }, + "cancelPlan": { + "title": "نأسف على مغادرتك", + "success": "لقد تم إلغاء اشتراكك بنجاح", + "description": "يؤسفنا رحيلك. يسعدنا سماع تعليقاتك لمساعدتنا على تحسين AppFlowy. يُرجى تخصيص بعض الوقت للإجابة على بعض الأسئلة.", + "commonOther": "آخر", + "otherHint": "اكتب إجابتك هنا", + "questionOne": { + "question": "ما الذي دفعك إلى إلغاء اشتراكك في AppFlowy Pro؟", + "answerOne": "التكلفة مرتفعة للغاية", + "answerTwo": "الميزات لم ترق إلى مستوى التوقعات", + "answerThree": "وجدت بديلا أفضل", + "answerFour": "لم أستخدمه بشكل كافي لتبرير التكلفة", + "answerFive": "مشكلة في الخدمة أو صعوبات فنية" + }, + "questionTwo": { + "question": "ما مدى احتمالية تفكيرك في إعادة الاشتراك في AppFlowy Pro في المستقبل؟", + "answerOne": "من المرجح جدًا", + "answerTwo": "من المحتمل إلى حد ما", + "answerThree": "غير متأكد", + "answerFour": "من غير المحتمل", + "answerFive": "من غير المحتمل جدًا" + }, + "questionThree": { + "question": "ما هي الميزة الاحترافية التي تقدرها أكثر أثناء اشتراكك؟", + "answerOne": "التعاون بين المستخدمين المتعددين", + "answerTwo": "سجل تاريخ الإصدارات لفترة أطول", + "answerThree": "استجابات الذكاء الاصطناعي غير المحدودة", + "answerFour": "الوصول إلى نماذج الذكاء الاصطناعي المحلية" + }, + "questionFour": { + "question": "كيف تصف تجربتك الشاملة مع AppFlowy؟", + "answerOne": "عظيمة", + "answerTwo": "جيدة", + "answerThree": "متوسطة", + "answerFour": "أقل من المتوسط", + "answerFive": "غير راضٍ" + } + } + }, + "ai": { + "contentPolicyViolation": "فشل إنشاء الصورة بسبب محتوى حساس. يرجى إعادة صياغة إدخالك والمحاولة مرة أخرى", + "textLimitReachedDescription": "لقد نفدت الاستجابات المجانية للذكاء الاصطناعي من مساحة عملك. قم بالترقية إلى الخطة الاحترافية أو قم بشراء إضافة للذكاء الاصطناعي لفتح عدد غير محدود من الاستجابات", + "imageLimitReachedDescription": "لقد استنفدت حصتك المجانية من صور الذكاء الاصطناعي. يُرجى الترقية إلى الخطة الاحترافية أو شراء إضافة الذكاء الاصطناعي لفتح عدد غير محدود من الاستجابات", + "limitReachedAction": { + "textDescription": "لقد نفدت الاستجابات المجانية للذكاء الاصطناعي من مساحة عملك. للحصول على المزيد من الاستجابات، يرجى", + "imageDescription": "لقد استنفدت حصتك المجانية من صور الذكاء الاصطناعي. يرجى", + "upgrade": "ترقية", + "toThe": "الى", + "proPlan": "الخطة الاحترافية", + "orPurchaseAn": "أو شراء", + "aiAddon": "مَرافِق الذكاء الاصطناعي" + }, + "editing": "تحرير", + "analyzing": "تحليل", + "continueWritingEmptyDocumentTitle": "استمر في كتابة الخطأ", + "continueWritingEmptyDocumentDescription": "نواجه مشكلة في توسيع نطاق المحتوى في مستندك. اكتب مقدمة قصيرة وسنتولى الأمر من هناك!", + "more": "أكثر" + }, + "autoUpdate": { + "criticalUpdateTitle": "التحديث ضروري للمتابعة", + "criticalUpdateDescription": "لقد أجرينا تحسينات لتحسين تجربتك! يُرجى التحديث من {currentVersion} إلى {newVersion} لمواصلة استخدام التطبيق.", + "criticalUpdateButton": "تحديث", + "bannerUpdateTitle": "النسخة الجديدة متاحة!", + "bannerUpdateDescription": "احصل على أحدث الميزات والإصلاحات. انقر على \"تحديث\" للتثبيت الآن.", + "bannerUpdateButton": "تحديث", + "settingsUpdateTitle": "الإصدار الجديد ({newVersion}) متاح!", + "settingsUpdateDescription": "الإصدار الحالي: {currentVersion} (الإصدار الرسمي) → {newVersion}", + "settingsUpdateButton": "تحديث", + "settingsUpdateWhatsNew": "ما الجديد" + }, + "lockPage": { + "lockPage": "مقفل", + "reLockPage": "إعادة القفل", + "lockTooltip": "تم قفل الصفحة لمنع التعديل غير المقصود. انقر لفتح القفل.", + "pageLockedToast": "الصفحة مقفلة. التعديل معطل حتى يفتحها أحد.", + "lockedOperationTooltip": "تم قفل الصفحة لمنع التعديل غير المقصود." + }, + "suggestion": { + "accept": "يقبل", + "keep": "يحفظ", + "discard": "تجاهل", + "close": "يغلق", + "tryAgain": "حاول ثانية", + "rewrite": "إعادة كتابة", + "insertBelow": "أدخل أدناه" } } diff --git a/frontend/resources/translations/ca-ES.json b/frontend/resources/translations/ca-ES.json index dccb18dcf8..8d98cb5cbc 100644 --- a/frontend/resources/translations/ca-ES.json +++ b/frontend/resources/translations/ca-ES.json @@ -133,14 +133,14 @@ "questionBubble": { "shortcuts": "Dreceres", "whatsNew": "Què hi ha de nou?", - "help": "Ajuda i Suport", "markdown": "Reducció", "debug": { "name": "Informació de depuració", "success": "S'ha copiat la informació de depuració!", "fail": "No es pot copiar la informació de depuració" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Ajuda i Suport" }, "menuAppHeader": { "moreButtonToolTip": "Suprimeix, canvia el nom i més...", @@ -270,7 +270,7 @@ "invalidCloudURLScheme": "Esquema no vàlid", "cloudServerType": "Servidor al núvol", "cloudLocal": "Local", - "cloudAppFlowy": "AppFlowy Cloud Beta", + "cloudAppFlowy": "@:appName Cloud Beta", "clickToCopy": "Feu clic per copiar", "selfHostContent": "document", "cloudURLHint": "Introduïu l'URL base del vostre servidor", @@ -278,8 +278,7 @@ "inputEncryptPrompt": "Introduïu el vostre secret de xifratge per a", "clickToCopySecret": "Feu clic per copiar el secret", "inputTextFieldHint": "El teu secret", - "importSuccess": "S'ha importat correctament la carpeta de dades d'AppFlowy", - "supabaseSetting": "Configuració Supabase" + "importSuccess": "S'ha importat correctament la carpeta de dades d'@:appName" }, "notifications": { "enableNotifications": { @@ -319,7 +318,7 @@ "themeUpload": { "button": "Carrega", "uploadTheme": "Carrega el tema", - "description": "Carregueu el vostre propi tema AppFlowy amb el botó següent.", + "description": "Carregueu el vostre propi tema @:appName amb el botó següent.", "loading": "Si us plau, espereu mentre validem i carreguem el vostre tema...", "uploadSuccess": "El teu tema s'ha penjat correctament", "deletionFailure": "No s'ha pogut suprimir el tema. Intenta esborrar-lo manualment.", @@ -347,7 +346,7 @@ "defaultLocation": "Llegir fitxers i ubicació d'emmagatzematge de dades", "exportData": "Exporteu les vostres dades", "doubleTapToCopy": "Fes doble toc per copiar el camí", - "restoreLocation": "Restaura al camí predeterminat d'AppFlowy", + "restoreLocation": "Restaura al camí predeterminat d'@:appName", "customizeLocation": "Obriu una altra carpeta", "restartApp": "Si us plau, reinicieu l'aplicació perquè els canvis tinguin efecte.", "exportDatabase": "Exportar la base de dades", @@ -359,10 +358,10 @@ "defineWhereYourDataIsStored": "Definiu on s'emmagatzemen les vostres dades", "open": "Obert", "openFolder": "Obre una carpeta existent", - "openFolderDesc": "Llegiu-lo i escriviu-lo a la vostra carpeta AppFlowy existent", + "openFolderDesc": "Llegiu-lo i escriviu-lo a la vostra carpeta @:appName existent", "folderHintText": "nom de la carpeta", "location": "Creació d'una carpeta nova", - "locationDesc": "Trieu un nom per a la vostra carpeta de dades d'AppFlowy", + "locationDesc": "Trieu un nom per a la vostra carpeta de dades d'@:appName", "browser": "Navega", "create": "Crear", "set": "Conjunt", @@ -373,7 +372,7 @@ "change": "Canviar", "openLocationTooltips": "Obriu un altre directori de dades", "openCurrentDataFolder": "Obre el directori de dades actual", - "recoverLocationTooltips": "Restableix al directori de dades predeterminat d'AppFlowy", + "recoverLocationTooltips": "Restableix al directori de dades predeterminat d'@:appName", "exportFileSuccess": "Exporta el fitxer correctament!", "exportFileFail": "Ha fallat l'exportació del fitxer!", "export": "Exporta" @@ -383,11 +382,7 @@ "email": "Correu electrònic", "tooltipSelectIcon": "Seleccioneu la icona", "selectAnIcon": "Seleccioneu una icona", - "pleaseInputYourOpenAIKey": "si us plau, introduïu la vostra clau OpenAI" - }, - "shortcuts": { - "command": "Comandament", - "addNewCommand": "Afegeix una comanda nova" + "pleaseInputYourOpenAIKey": "si us plau, introduïu la vostra clau AI" }, "mobile": { "personalInfo": "Informació personal", @@ -402,6 +397,10 @@ "userprofileError": "No s'ha pogut carregar el perfil d'usuari", "selectStartingDay": "Seleccioneu el dia d'inici", "version": "Versió" + }, + "shortcuts": { + "command": "Comandament", + "addNewCommand": "Afegeix una comanda nova" } }, "grid": { @@ -602,23 +601,23 @@ "referencedBoard": "Junta de referència", "referencedGrid": "Quadrícula de referència", "referencedCalendar": "Calendari de referència", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Demana a AI que escrigui qualsevol cosa...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Demana a AI que escrigui qualsevol cosa...", "autoGeneratorLearnMore": "Aprèn més", "autoGeneratorGenerate": "Generar", - "autoGeneratorHintText": "Pregunta a OpenAI...", - "autoGeneratorCantGetOpenAIKey": "No es pot obtenir la clau OpenAI", + "autoGeneratorHintText": "Pregunta a AI...", + "autoGeneratorCantGetOpenAIKey": "No es pot obtenir la clau AI", "autoGeneratorRewrite": "Reescriure", "smartEdit": "Assistents d'IA", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Corregir l'ortografia", "warning": "⚠️ Les respostes de la IA poden ser inexactes o enganyoses.", "smartEditSummarize": "Resumir", "smartEditImproveWriting": "Millorar l'escriptura", "smartEditMakeLonger": "Fer més llarg", - "smartEditCouldNotFetchResult": "No s'ha pogut obtenir el resultat d'OpenAI", - "smartEditCouldNotFetchKey": "No s'ha pogut obtenir la clau OpenAI", - "smartEditDisabled": "Connecteu OpenAI a Configuració", + "smartEditCouldNotFetchResult": "No s'ha pogut obtenir el resultat d'AI", + "smartEditCouldNotFetchKey": "No s'ha pogut obtenir la clau AI", + "smartEditDisabled": "Connecteu AI a Configuració", "discardResponse": "Voleu descartar les respostes d'IA?", "createInlineMathEquation": "Crea una equació", "fonts": "Fonts", @@ -671,8 +670,8 @@ "defaultColor": "Per defecte" }, "image": { - "copiedToPasteBoard": "L'enllaç de la imatge s'ha copiat al porta-retalls", - "addAnImage": "Afegeix una imatge" + "addAnImage": "Afegeix una imatge", + "copiedToPasteBoard": "L'enllaç de la imatge s'ha copiat al porta-retalls" }, "outline": { "addHeadingToCreateOutline": "Afegiu títols per crear una taula de continguts." @@ -714,7 +713,7 @@ "placeholder": "Introduïu l'URL de la imatge" }, "ai": { - "label": "Generar imatge des d'OpenAI" + "label": "Generar imatge des d'AI" }, "support": "El límit de mida de la imatge és de 5 MB. Formats admesos: JPEG, PNG, GIF, SVG", "error": { @@ -791,7 +790,7 @@ "referencedCalendarPrefix": "Vista de" }, "errorDialog": { - "title": "Error d'AppFlowy", + "title": "Error d'@:appName", "howToFixFallback": "Lamentem les molèsties! Envieu un problema a la nostra pàgina de GitHub que descrigui el vostre error.", "github": "Veure a GitHub" }, diff --git a/frontend/resources/translations/ckb-KU.json b/frontend/resources/translations/ckb-KU.json index 28535be2c8..acfc571536 100644 --- a/frontend/resources/translations/ckb-KU.json +++ b/frontend/resources/translations/ckb-KU.json @@ -64,7 +64,7 @@ "resetWorkspacePrompt": "ڕێستکردنی شوێنی کارەکە هەموو لاپەڕە و داتاکانی ناوی دەسڕێتەوە. ئایا دڵنیای کە دەتەوێت شوێنی کارەکە ڕێست بکەیتەوە؟ یان دەتوانیت پەیوەندی بە تیمی پشتگیرییەوە بکەیت بۆ گەڕاندنەوەی شوێنی کارەکە", "hint": "شوێنی کارکردن", "notFoundError": "هیچ شوێنێکی کار نەدۆزراوە", - "failedToLoad": "هەندێ شت بە هەڵە ڕۆیشت! شکستی هێنا لە بارکردنی شوێنی کارکردن. هەوڵبدە هەر نموونەیەکی کراوەی AppFlowy دابخەیت و دووبارە هەوڵبدەرەوە.", + "failedToLoad": "هەندێ شت بە هەڵە ڕۆیشت! شکستی هێنا لە بارکردنی شوێنی کارکردن. هەوڵبدە هەر نموونەیەکی کراوەی @:appName دابخەیت و دووبارە هەوڵبدەرەوە.", "errorActions": { "reportIssue": "ڕاپۆرت کردنی کێشەیەک", "reportIssueOnGithub": "ڕاپۆرت کردنی کێشەیەک لەسەر گیتهابەوە ", @@ -170,14 +170,14 @@ "questionBubble": { "shortcuts": "کورتە ڕێگاکان", "whatsNew": "نوێترین", - "help": "پشتیوانی و یارمەتی", "markdown": "Markdown", "debug": { "name": "زانیاری دیباگ", "success": "زانیارییەکانی دیباگ کۆپی کراون بۆ کلیپبۆرد!", "fail": "ناتوانرێت زانیارییەکانی دیباگ کۆپی بکات بۆ کلیپبۆرد" }, - "feedback": "فیدباک" + "feedback": "فیدباک", + "help": "پشتیوانی و یارمەتی" }, "menuAppHeader": { "moreButtonToolTip": "سڕینەوە، گۆڕینی ناو، و زۆر شتی تر...", @@ -331,11 +331,6 @@ "cloudServerType": "ڕاژەکاری کڵاود", "cloudServerTypeTip": "تکایە ئاگاداربە کە لەوانەیە دوای گۆڕینی ڕاژەکاری کڵاودکە لە ئەکاونتی ئێستات دەربچێت", "cloudLocal": "خۆماڵی", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "Supabase URL", - "cloudSupabaseUrlCanNotBeEmpty": "url ی supabase ناتوانێت بەتاڵ بێت", - "cloudSupabaseAnonKey": "کلیلی شاراوەی Supabase", - "cloudSupabaseAnonKeyCanNotBeEmpty": "کلیلی anon ناتوانێت بەتاڵ بێت", "cloudAppFlowy": "ئەپفلۆوی کلاود بێتا", "cloudAppFlowySelfHost": "ئەپفلۆوی کلاود بە هۆستی خۆیی", "appFlowyCloudUrlCanNotBeEmpty": "url ی هەور ناتوانێت بەتاڵ بێت", @@ -358,12 +353,12 @@ "historicalUserList": "مێژووی چوونەژوورەوەی بەکارهێنەر", "historicalUserListTooltip": "ئەم لیستە ئەکاونتە بێناوەکانت پیشان دەدات. دەتوانیت کلیک لەسەر ئەکاونتێک بکەیت بۆ بینینی وردەکارییەکانی. ئەکاونتی بێناو بە کلیک کردن لەسەر دوگمەی دەستپێکردن دروست دەکرێت", "openHistoricalUser": "بۆ کردنەوەی ئەکاونتی بێناو کلیک بکە", - "customPathPrompt": "هەڵگرتنی فۆڵدەری داتاکانی AppFlowy لە فۆڵدەرێکی هاوکاتی کڵاود وەک گووگڵ درایڤ دەتوانێت مەترسی دروست بکات. ئەگەر بنکەدراوەی ناو ئەم فۆڵدەرە لە یەک کاتدا لە چەندین شوێنەوە دەستی پێ بگات یان دەستکاری بکرێت، لەوانەیە ببێتە هۆی ناکۆکی هاوکاتکردن و ئەگەری تێکچوونی داتاکان", - "importAppFlowyData": "هێنانی داتا لە فۆڵدەری دەرەکی AppFlowy", + "customPathPrompt": "هەڵگرتنی فۆڵدەری داتاکانی @:appName لە فۆڵدەرێکی هاوکاتی کڵاود وەک گووگڵ درایڤ دەتوانێت مەترسی دروست بکات. ئەگەر بنکەدراوەی ناو ئەم فۆڵدەرە لە یەک کاتدا لە چەندین شوێنەوە دەستی پێ بگات یان دەستکاری بکرێت، لەوانەیە ببێتە هۆی ناکۆکی هاوکاتکردن و ئەگەری تێکچوونی داتاکان", + "importAppFlowyData": "هێنانی داتا لە فۆڵدەری دەرەکی @:appName", "importingAppFlowyDataTip": "هێنانی داتا لە قۆناغی جێبەجێکردندایە. تکایە ئەپەکە دامەخە", - "importAppFlowyDataDescription": "داتا لە فۆڵدەری داتای دەرەکی AppFlowy کۆپی بکە و هاوردە بکە بۆ ناو فۆڵدەری داتاکانی AppFlowy ی ئێستا", - "importSuccess": "بە سەرکەوتوویی فۆڵدەری داتاکانی AppFlowy هاوردە کرد", - "importFailed": "هاوردەکردنی فۆڵدەری داتاکانی AppFlowy شکستی هێنا", + "importAppFlowyDataDescription": "داتا لە فۆڵدەری داتای دەرەکی @:appName کۆپی بکە و هاوردە بکە بۆ ناو فۆڵدەری داتاکانی @:appName ی ئێستا", + "importSuccess": "بە سەرکەوتوویی فۆڵدەری داتاکانی @:appName هاوردە کرد", + "importFailed": "هاوردەکردنی فۆڵدەری داتاکانی @:appName شکستی هێنا", "importGuide": "بۆ زانیاری زیاتر، تکایە بەڵگەنامەی ئاماژەپێکراو بپشکنە" }, "notifications": { @@ -413,7 +408,7 @@ "themeUpload": { "button": "بارکردن", "uploadTheme": "بارکردنی تێم", - "description": "بە بەکارهێنانی دوگمەی خوارەوە تێمی AppFlowy ـەکەت باربکە.", + "description": "بە بەکارهێنانی دوگمەی خوارەوە تێمی @:appName ـەکەت باربکە.", "loading": "تکایە چاوەڕوان بن تا ئێمە تێمی قاڵبەکەت پشتڕاست دەکەینەوە و بار دەکەین...", "uploadSuccess": "تێمی قاڵبەکەت بە سەرکەوتوویی بارکرا", "deletionFailure": "تێمەکە نەسڕدرایەوە. هەوڵبدە بە دەستی لابەریت.", @@ -444,7 +439,7 @@ "defaultLocation": "خوێندنەوەی پەڕگەکان و شوێنی هەڵگرتنی داتاکان", "exportData": "دەرچوون لە داتاکانتەوە بەدەست بهێنە", "doubleTapToCopy": "بۆ کۆپیکردن دووجار کلیک بکە", - "restoreLocation": "گەڕاندنەوە بۆ ڕێڕەوی پێشوەختەی AppFlowy", + "restoreLocation": "گەڕاندنەوە بۆ ڕێڕەوی پێشوەختەی @:appName", "customizeLocation": "فۆڵدەرێکی دیکە بکەرەوە", "restartApp": "تکایە ئەپەکە دابخە و بیکەرەوە بۆ ئەوەی گۆڕانکارییەکان جێبەجێ بکرێن.", "exportDatabase": "هەناردە کردنی بنکەدراوە", @@ -456,10 +451,10 @@ "defineWhereYourDataIsStored": "پێناسە بکە کە داتاکانت لە کوێ هەڵدەگیرێن", "open": "کردنەوە", "openFolder": "فۆڵدەرێکی هەبوو بکەرەوە", - "openFolderDesc": "خوێندن و نووسین بۆ فۆڵدەری AppFlowy ی ئێستات", + "openFolderDesc": "خوێندن و نووسین بۆ فۆڵدەری @:appName ی ئێستات", "folderHintText": "ناوی فۆڵدەر", "location": "دروستکردنی فۆڵدەرێکی نوێ", - "locationDesc": "ناوێک بۆ فۆڵدەری داتاکانی AppFlowy هەڵبژێرە", + "locationDesc": "ناوێک بۆ فۆڵدەری داتاکانی @:appName هەڵبژێرە", "browser": "وێبگەڕ", "create": "دروستکردن", "set": "دانان", @@ -480,20 +475,9 @@ "email": "ئیمەیڵ", "tooltipSelectIcon": "هەڵبژاەدنی وێنۆچكه‌", "selectAnIcon": "هەڵبژاردنی وێنۆچكه‌", - "pleaseInputYourOpenAIKey": "تکایە کلیلی OpenAI ـەکەت بنووسە", - "pleaseInputYourStabilityAIKey": "تکایە جێگیری کلیلی AI ـەکەت بنووسە", - "clickToLogout": "بۆ دەرچوون لە بەکارهێنەری ئێستا کلیک بکە" - }, - "shortcuts": { - "shortcutsLabel": "کورتە ڕێگاکان", - "command": "فەرمان", - "keyBinding": "کورتکراوەکانی تەختەکلیل", - "addNewCommand": "زیاد کردنی فەرمانێکی نوێ", - "updateShortcutStep": "تێکەڵەی کلیلی دڵخواز داگرە و ENTER داگرە", - "shortcutIsAlreadyUsed": "ئەم کورتە ڕێگایە پێشتر بۆ: {conflict} بەکارهاتووە.", - "resetToDefault": "گەڕاندنەوە بۆ کلیلەکانی بنه‌ڕه‌ت", - "couldNotLoadErrorMsg": "کورتە ڕێگاکان نەتوانرا باربکرێن، تکایە دووبارە هەوڵبدەرەوە", - "couldNotSaveErrorMsg": "کورتە ڕێگاکان نەتوانرا پاشەکەوت بکرێن، تکایە دووبارە هەوڵبدەرەوە" + "pleaseInputYourOpenAIKey": "تکایە کلیلی AI ـەکەت بنووسە", + "clickToLogout": "بۆ دەرچوون لە بەکارهێنەری ئێستا کلیک بکە", + "pleaseInputYourStabilityAIKey": "تکایە جێگیری کلیلی AI ـەکەت بنووسە" }, "mobile": { "personalInfo": "زانیاری کەسی", @@ -511,6 +495,17 @@ "selectLayout": "نەخشە هەڵبژێرە", "selectStartingDay": "ڕۆژی دەستپێکردنەکەت هەڵبژێرە", "version": "وەشان" + }, + "shortcuts": { + "shortcutsLabel": "کورتە ڕێگاکان", + "command": "فەرمان", + "keyBinding": "کورتکراوەکانی تەختەکلیل", + "addNewCommand": "زیاد کردنی فەرمانێکی نوێ", + "updateShortcutStep": "تێکەڵەی کلیلی دڵخواز داگرە و ENTER داگرە", + "shortcutIsAlreadyUsed": "ئەم کورتە ڕێگایە پێشتر بۆ: {conflict} بەکارهاتووە.", + "resetToDefault": "گەڕاندنەوە بۆ کلیلەکانی بنه‌ڕه‌ت", + "couldNotLoadErrorMsg": "کورتە ڕێگاکان نەتوانرا باربکرێن، تکایە دووبارە هەوڵبدەرەوە", + "couldNotSaveErrorMsg": "کورتە ڕێگاکان نەتوانرا پاشەکەوت بکرێن، تکایە دووبارە هەوڵبدەرەوە" } }, "grid": { @@ -734,23 +729,22 @@ "referencedGrid": "تۆڕی ئاماژەپێکراو", "referencedCalendar": "ساڵنامەی ئاماژەپێکراو", "referencedDocument": "بەڵگەنامەی ئاماژەپێکراو", - "autoGeneratorMenuItemName": "OpenAI نووسەری", + "autoGeneratorMenuItemName": "AI نووسەری", "autoGeneratorTitleName": "داوا لە AI بکە هەر شتێک بنووسێت...", "autoGeneratorLearnMore": "زیاتر زانین", "autoGeneratorGenerate": "بنووسە", - "autoGeneratorHintText": "لە OpenAI پرسیار بکە...", - "autoGeneratorCantGetOpenAIKey": "نەتوانرا کلیلی OpenAI بەدەست بهێنرێت", + "autoGeneratorHintText": "لە AI پرسیار بکە...", + "autoGeneratorCantGetOpenAIKey": "نەتوانرا کلیلی AI بەدەست بهێنرێت", "autoGeneratorRewrite": "دووبارە نووسینەوە", "smartEdit": "یاریدەدەری زیرەک", - "openAI": "OpenAI ژیری دەستکرد", "smartEditFixSpelling": "ڕاستکردنەوەی نووسین", "warning": "⚠️ وەڵامەکانی AI دەتوانن هەڵە یان چەواشەکارانە بن", "smartEditSummarize": "کورتەنووسی", "smartEditImproveWriting": "پێشخستن نوووسین", "smartEditMakeLonger": "درێژتری بکەرەوە", - "smartEditCouldNotFetchResult": "هیچ ئەنجامێک لە OpenAI وەرنەگیرا", - "smartEditCouldNotFetchKey": "نەتوانرا کلیلی OpenAI بهێنێتە ئاراوە", - "smartEditDisabled": "لە ڕێکخستنەکاندا پەیوەندی بە OpenAI بکە", + "smartEditCouldNotFetchResult": "هیچ ئەنجامێک لە AI وەرنەگیرا", + "smartEditCouldNotFetchKey": "نەتوانرا کلیلی AI بهێنێتە ئاراوە", + "smartEditDisabled": "لە ڕێکخستنەکاندا پەیوەندی بە AI بکە", "discardResponse": "ئایا دەتەوێت وەڵامەکانی AI بسڕیتەوە؟", "createInlineMathEquation": "درووست کردنی هاوکێشە", "fonts": "فۆنتەکان", @@ -805,7 +799,8 @@ }, "outline": { "addHeadingToCreateOutline": "بۆ دروستکردنی خشتەی ناوەڕۆک سەردێڕەکان داخڵ بکە" - } + }, + "openAI": "AI ژیری دەستکرد" }, "textBlock": { "placeholder": "بۆ فەرمانەکان '/' بنووسە" @@ -893,7 +888,7 @@ "referencedCalendarPrefix": "دیمەنی..." }, "errorDialog": { - "title": "هەڵەی⛔️ AppFlowy", + "title": "هەڵەی⛔️ @:appName", "howToFixFallback": "ببورن بۆ کێشەکە🥺️! پرسەکە و وەسفەکەی لە لاپەڕەی GitHub ـمان بنێرن.", "github": "بینین لە GitHub" }, diff --git a/frontend/resources/translations/cs-CZ.json b/frontend/resources/translations/cs-CZ.json index a5887f16b9..28750dd542 100644 --- a/frontend/resources/translations/cs-CZ.json +++ b/frontend/resources/translations/cs-CZ.json @@ -55,7 +55,7 @@ "resetWorkspacePrompt": "Obnovením pracovního prostoru smažete všechny stránky a data v nich. Opravdu chcete obnovit pracovní prostor? Pro obnovení pracovního prostoru můžete kontaktovat podporu.", "hint": "pracovní plocha", "notFoundError": "Pracovní prostor nenalezen", - "failedToLoad": "Něco se pokazilo! Nepodařilo se načíst pracovní prostor. Zkuste zavřít a znovu otevřít AppFlowy a zkuste to znovu.", + "failedToLoad": "Něco se pokazilo! Nepodařilo se načíst pracovní prostor. Zkuste zavřít a znovu otevřít @:appName a zkuste to znovu.", "errorActions": { "reportIssue": "Nahlásit problém", "reachOut": "Ozvat se na Discordu" @@ -134,14 +134,14 @@ "questionBubble": { "shortcuts": "Klávesové zkratky", "whatsNew": "Co je nového?", - "help": "Pomoc a podpora", "markdown": "Markdown", "debug": { "name": "Debug informace", "success": "Debug informace zkopírovány do schránky!", "fail": "Nepodařilo se zkopáí" }, - "feedback": "Zpětná vazba" + "feedback": "Zpětná vazba", + "help": "Pomoc a podpora" }, "menuAppHeader": { "moreButtonToolTip": "Smazat, přejmenovat, a další...", @@ -265,7 +265,7 @@ "enableSync": "Zapnout synchronizaci", "enableEncrypt": "Šifrovat data", "cloudURL": "URL adresa serveru", - "cloudAppFlowy": "AppFlowy Cloud Beta", + "cloudAppFlowy": "@:appName Cloud Beta", "enableEncryptPrompt": "Zapněte šifrování a zabezpečte svá ", "inputEncryptPrompt": "Vložte prosím Váš šifrovací klíč k", "clickToCopySecret": "Kliknutím zkopírujete šifrovací klíč", @@ -273,7 +273,7 @@ "historicalUserList": "Historie přihlášení uživatele", "historicalUserListTooltip": "V tomto seznamu vidíte anonymní účty. Kliknutím na účet zobrazíte jeho detaily. Anonymní účty vznikají kliknutím na tlačítko \"Začínáme\"", "openHistoricalUser": "Kliknutím založíte anonymní účet", - "customPathPrompt": "Uložením složky s daty AppFlowy ve složce synchronizovanéí jako např. Google Drive může nést rizika. Pokud se databáze v složce navštíví nebo změní ", + "customPathPrompt": "Uložením složky s daty @:appName ve složce synchronizovanéí jako např. Google Drive může nést rizika. Pokud se databáze v složce navštíví nebo změní ", "cloudSetting": "Nastavení cloudu" }, "notifications": { @@ -311,7 +311,7 @@ "themeUpload": { "button": "Nahrát", "uploadTheme": "Nahrát motiv vzhledu", - "description": "Nahrajte vlastní motiv vzhledu pro AppFlowy stisknutím tlačítka níže.", + "description": "Nahrajte vlastní motiv vzhledu pro @:appName stisknutím tlačítka níže.", "loading": "Prosím počkejte dokud nedokončíme kontrolu a nahrávání vašeho motivu vzhledu...", "uploadSuccess": "Váš motiv vzhledu byl úspěšně nahrán", "deletionFailure": "Nepodařilo se smazat motiv vzhledu. Zkuste ho smazat ručně.", @@ -342,7 +342,7 @@ "defaultLocation": "Umístění pro čtení a ukládání dat", "exportData": "Exportovat data", "doubleTapToCopy": "Dvojitým klepnutím zkopírujete cestu", - "restoreLocation": "Obnovit výchozí AppFlowy cestu", + "restoreLocation": "Obnovit výchozí @:appName cestu", "customizeLocation": "OtevřítProsím tre další složku", "restartApp": "Aby se projevily změny, restartujte prosím aplikaci.", "exportDatabase": "Exportovat databázi", @@ -354,10 +354,10 @@ "defineWhereYourDataIsStored": "Vyberte kde jsou ukládána Vaše data", "open": "Otevřít", "openFolder": "Otevřít existující složku", - "openFolderDesc": "Číst a zapisovat do existující AppFlowy složky", + "openFolderDesc": "Číst a zapisovat do existující @:appName složky", "folderHintText": "název složky", "location": "Vytváření nové složky", - "locationDesc": "Vyberte název pro složku, kam bude AppFlowy ukládat Vaše data", + "locationDesc": "Vyberte název pro složku, kam bude @:appName ukládat Vaše data", "browser": "Procházet", "create": "Vytvořit", "set": "Nastavit", @@ -378,20 +378,9 @@ "email": "E-mail", "tooltipSelectIcon": "Vyberte ikonu", "selectAnIcon": "Vyberte ikonu", - "pleaseInputYourOpenAIKey": "Prosím vložte svůj OpenAI klíč", - "pleaseInputYourStabilityAIKey": "Prosím vložte svůj Stability AI klíč", - "clickToLogout": "Klin" - }, - "shortcuts": { - "shortcutsLabel": "Klávesové zkratky", - "command": "Příkaz", - "keyBinding": "Přiřazená klávesa", - "addNewCommand": "Přidat nový příkaz", - "updateShortcutStep": "Stiskněte požadovanou kombinaci kláves a stiskněte ENTER", - "shortcutIsAlreadyUsed": "Tato zkratka je již použita pro: @@", - "resetToDefault": "Obnovit výchozí klávesové zkratky", - "couldNotLoadErrorMsg": "Nepodařilo se načíst klávesové zkratky, zkuste to znovu", - "couldNotSaveErrorMsg": "Nepodařilo seuložit klávesové zkta" + "pleaseInputYourOpenAIKey": "Prosím vložte svůj AI klíč", + "clickToLogout": "Klin", + "pleaseInputYourStabilityAIKey": "Prosím vložte svůj Stability AI klíč" }, "mobile": { "personalInfo": "Osobní informace", @@ -405,6 +394,17 @@ "userAgreement": "Uživatels", "userprofileError": "Nepodařilo se načíst uživatelský profil", "userprofileErrorDescription": "Prosím zkuste se odhlásit a znovu přihlásit a zkontrolujte, zda problém přetrvává" + }, + "shortcuts": { + "shortcutsLabel": "Klávesové zkratky", + "command": "Příkaz", + "keyBinding": "Přiřazená klávesa", + "addNewCommand": "Přidat nový příkaz", + "updateShortcutStep": "Stiskněte požadovanou kombinaci kláves a stiskněte ENTER", + "shortcutIsAlreadyUsed": "Tato zkratka je již použita pro: @@", + "resetToDefault": "Obnovit výchozí klávesové zkratky", + "couldNotLoadErrorMsg": "Nepodařilo se načíst klávesové zkratky, zkuste to znovu", + "couldNotSaveErrorMsg": "Nepodařilo seuložit klávesové zkta" } }, "grid": { @@ -606,23 +606,23 @@ "referencedGrid": "Odkazovaná mřížka", "referencedCalendar": "Odkazovaný kalendář", "referencedDocument": "Odkazovaný dokument", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Zeptej se AI na cokoliv...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Zeptej se AI na cokoliv...", "autoGeneratorLearnMore": "Zjistit více", "autoGeneratorGenerate": "Vygenerovat", - "autoGeneratorHintText": "Zeptat se OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Nepodařilo se získat klíč OpenAI", + "autoGeneratorHintText": "Zeptat se AI...", + "autoGeneratorCantGetOpenAIKey": "Nepodařilo se získat klíč AI", "autoGeneratorRewrite": "Přepsat", "smartEdit": "AI asistenti", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Opravit pravopis", "warning": "⚠️ odpovědi AI mohou být nepřesné nebo zavádějící.", "smartEditSummarize": "Shrnout", "smartEditImproveWriting": "Vylepšit styl psaní", "smartEditMakeLonger": "Prodloužit", - "smartEditCouldNotFetchResult": "Nepodařilo se stáhnout výsledek z OpenAI", - "smartEditCouldNotFetchKey": "Nepodařilo se stáhnout klíč OpenAI", - "smartEditDisabled": "Propojit s OpenAI v Nastavení", + "smartEditCouldNotFetchResult": "Nepodařilo se stáhnout výsledek z AI", + "smartEditCouldNotFetchKey": "Nepodařilo se stáhnout klíč AI", + "smartEditDisabled": "Propojit s AI v Nastavení", "discardResponse": "Opravdu chcete zahodit odpovědi od AI?", "createInlineMathEquation": "Vytvořit rovnici", "fonts": "Písma", @@ -678,8 +678,8 @@ "defaultColor": "Výchozí" }, "image": { - "copiedToPasteBoard": "Odkaz na obrázek byl zkopírován do schránky", - "addAnImage": "Přidat obrázek" + "addAnImage": "Přidat obrázek", + "copiedToPasteBoard": "Odkaz na obrázek byl zkopírován do schránky" }, "outline": { "addHeadingToCreateOutline": "Přidáním nadpisů vytvoříte obsah dokumentu" @@ -716,7 +716,7 @@ "placeholder": "Vlože URL adresu obrázku" }, "ai": { - "label": "Vygenerujte obrázek pomocí OpenAI", + "label": "Vygenerujte obrázek pomocí AI", "placeholder": "Prosím vlo" }, "stability_ai": { @@ -735,12 +735,12 @@ "placeholder": "Vložte nebo napište odkaz na obrázek" }, "searchForAnImage": "Hledat obrázek", - "pleaseInputYourOpenAIKey": "zadejte prosím svůj OpenAI klíč v Nastavení", - "pleaseInputYourStabilityAIKey": "prosím vložte svůjStability AI klíč v Nastavení", + "pleaseInputYourOpenAIKey": "zadejte prosím svůj AI klíč v Nastavení", "saveImageToGallery": "Uložit obrázek", "failedToAddImageToGallery": "Nepodařilo se přidat obrázek do galerie", "successToAddImageToGallery": "Obrázek byl úspěšně přidán do galerie", - "unableToLoadImage": "Nepodařilo se nahrát obrázek" + "unableToLoadImage": "Nepodařilo se nahrát obrázek", + "pleaseInputYourStabilityAIKey": "prosím vložte svůjStability AI klíč v Nastavení" }, "codeBlock": { "language": { @@ -836,7 +836,7 @@ "referencedCalendarPrefix": "Pohled na" }, "errorDialog": { - "title": "Chyba AppFlowy", + "title": "Chyba @:appName", "howToFixFallback": "Omlouváme se za nepříjemnost! Pošlete hlášení na náš GitHub, kde popíšete chybu na kterou jste narazili.", "github": "Zobrazit na GitHubu" }, diff --git a/frontend/resources/translations/de-DE.json b/frontend/resources/translations/de-DE.json index 6534abb8aa..65a7fbea05 100644 --- a/frontend/resources/translations/de-DE.json +++ b/frontend/resources/translations/de-DE.json @@ -36,6 +36,7 @@ "loginButtonText": "Anmelden", "loginStartWithAnonymous": "Anonyme Sitzung starten", "continueAnonymousUser": "in anonymer Sitzung fortfahren", + "anonymous": "Anonym", "buttonText": "Anmelden", "signingInText": "Anmelden...", "forgotPassword": "Passwort vergessen?", @@ -50,6 +51,8 @@ "signInWithGoogle": "Mit Google anmelden", "signInWithGithub": "Mit Github anmelden", "signInWithDiscord": "Mit Discord anmelden", + "signInWithApple": "Weiter mit Apple", + "continueAnotherWay": "Anders fortfahren", "signUpWithGoogle": "Mit Google registrieren", "signUpWithGithub": "Mit Github registrieren", "signUpWithDiscord": "Mit Discord registrieren", @@ -65,37 +68,40 @@ "logIn": "Anmeldung", "generalError": "Etwas ist schiefgelaufen. Bitte versuche es später noch einmal", "limitRateError": "Aus Sicherheitsgründen kannst du nur alle 60 Sekunden einen Authentifizierungslink anfordern", - "LogInWithGoogle": "Mit Google-Account anmelden", - "LogInWithGithub": "Mit GitHub-Account anmelden", - "LogInWithDiscord": "Mit Discord-Account anmelden" + "magicLinkSentDescription": "Ein Magic Link wurde an deine E-Mail-Adresse gesendet. Klicke auf den Link, um deine Anmeldung abzuschließen. Der Link läuft nach 5 Minuten ab." }, "workspace": { - "chooseWorkspace": "Workspace wählen", - "create": "Workspace erstellen", - "reset": "Workspace zurücksetzen", - "resetWorkspacePrompt": "Das Zurücksetzen des Workspace löscht alle enthaltenen Seiten und Daten. Bist du sicher, dass du den Arbeitsbereich zurücksetzen möchstest? ", - "hint": "Workspace", - "notFoundError": "Workspace nicht gefunden", - "failedToLoad": "Etwas ist schief gelaufen! Der Workspace konnte nicht geladen werden. Versuche, alle AppFlowy-Instanzen zu schließen & versuche es erneut.", + "chooseWorkspace": "Arbeitsbereich wählen", + "defaultName": "Mein Arbeitsbereich", + "create": "Arbeitsbereich erstellen", + "importFromNotion": "Von Notion importieren", + "learnMore": "Mehr erfahren", + "reset": "Arbeitsbereich zurücksetzen", + "renameWorkspace": "Arbeitsbereich umbenennen", + "workspaceNameCannotBeEmpty": "Arbeitsbereichname darf nicht leer sein", + "resetWorkspacePrompt": "Das Zurücksetzen des Arbeitsbereiches löscht alle enthaltenen Seiten und Daten. Bist du sicher, dass du den Arbeitsbereich zurücksetzen möchtest? ", + "hint": "Arbeitsbereich", + "notFoundError": "Arbeitsbereich nicht gefunden", + "failedToLoad": "Etwas ist schief gelaufen! Der Arbeitsbereich konnte nicht geladen werden. Versuche, alle @:appName Instanzen zu schließen und versuche es erneut.", "errorActions": { "reportIssue": "Problem melden", - "reportIssueOnGithub": "Melde ein Problem auf Github", + "reportIssueOnGithub": "Melde ein Problem auf GitHub", "exportLogFiles": "Exportiere Log-Dateien", "reachOut": "Kontaktiere uns auf Discord" }, "menuTitle": "Arbeitsbereiche", - "deleteWorkspaceHintText": "Sicher, dass du dein Workspace löschen möchtest?\nDies kann nicht mehr Rückgängig gemacht werden.", - "createSuccess": "Workspace erfolgreich erstellt", - "createFailed": "Der Workspace konnte nicht erstellt werden", - "createLimitExceeded": "Du hast die für dein Benutzerkonto maximal zulässige Anzahl an Arbeitsbereichen erreicht. Benötigst du zum fortsetzen deiner Arbeit noch weitere Arbeitsbereiche, erstelle auf Github bitte eine entsprechende Anfrage.", - "deleteSuccess": "Workspace erfolgreich gelöscht", - "deleteFailed": "Der Workspace konnte nicht gelöscht werden", - "openSuccess": "Workspace erfolgreich geöffnet", - "openFailed": "Der Workspace konnte nicht geöffnet werden", - "renameSuccess": "Workspace erfolgreich umbenannt", - "renameFailed": "Der Workspace konnte nicht umbenannt werden", - "updateIconSuccess": "Workspace erfolgreich zurückgesetzt", - "updateIconFailed": "Der Workspace konnte nicht zurückgesetzt werden", + "deleteWorkspaceHintText": "Sicher, dass du deinen Arbeitsbereich löschen möchtest? Dies kann nicht mehr Rückgängig gemacht werden.", + "createSuccess": "Arbeitsbereich erfolgreich erstellt", + "createFailed": "Der Arbeitsbereich konnte nicht erstellt werden", + "createLimitExceeded": "Du hast die für dein Benutzerkonto maximal zulässige Anzahl an Arbeitsbereichen erreicht. Benötigst du zum fortsetzen deiner Arbeit noch weitere Arbeitsbereiche, erstelle auf GitHub bitte eine entsprechende Anfrage.", + "deleteSuccess": "Arbeitsbereich erfolgreich gelöscht", + "deleteFailed": "Der Arbeitsbereich konnte nicht gelöscht werden", + "openSuccess": "Arbeitsbereich erfolgreich geöffnet", + "openFailed": "Der Arbeitsbereich konnte nicht geöffnet werden", + "renameSuccess": "Arbeitsbereich erfolgreich umbenannt", + "renameFailed": "Der Arbeitsbereich konnte nicht umbenannt werden", + "updateIconSuccess": "Arbeitsbereich erfolgreich zurückgesetzt", + "updateIconFailed": "Der Arbeitsbereich konnte nicht zurückgesetzt werden", "cannotDeleteTheOnlyWorkspace": "Der einzig vorhandene Arbeitsbereich kann nicht gelöscht werden", "fetchWorkspacesFailed": "Arbeitsbereiche konnten nicht abgerufen werden!", "leaveCurrentWorkspace": "Arbeitsbereich verlassen", @@ -108,7 +114,25 @@ "html": "HTML", "clipboard": "In die Zwischenablage kopieren", "csv": "CSV", - "copyLink": "Link kopieren" + "copyLink": "Link kopieren", + "publishToTheWeb": "Im Web veröffentlichen", + "publishToTheWebHint": "Erstelle eine Website mit AppFlowy", + "publish": "Veröffentlichen", + "unPublish": "Veröffentlichung aufheben", + "visitSite": "Seite aufrufen", + "exportAsTab": "Exportieren als", + "publishTab": "Veröffentlichen", + "shareTab": "Teilen", + "publishOnAppFlowy": "Auf AppFlowy veröffentlichen", + "shareTabTitle": "Zum Mitmachen einladen", + "shareTabDescription": "Für eine einfache Zusammenarbeit mit allen", + "copyLinkSuccess": "Link in die Zwischenablage kopiert", + "copyShareLink": "Link zum Teilen kopieren", + "copyLinkFailed": "Link konnte nicht in die Zwischenablage kopiert werden", + "copyLinkToBlockSuccess": "Blocklink in die Zwischenablage kopiert", + "copyLinkToBlockFailed": "Blocklink konnte nicht in die Zwischenablage kopiert werden", + "manageAllSites": "Alle Seiten verwalten", + "updatePathName": "Pfadnamen aktualisieren" }, "moreAction": { "small": "klein", @@ -127,6 +151,7 @@ "textAndMarkdown": "Text & Markdown", "documentFromV010": "Dokument ab v0.1.0", "databaseFromV010": "Datenbank ab v0.1.0", + "notionZip": "von Notion exportierte Zip-Datei", "csv": "CSV", "database": "Datenbank" }, @@ -139,17 +164,57 @@ "openNewTab": "In einem neuen Tab öffnen", "moveTo": "Verschieben nach", "addToFavorites": "Zu Favoriten hinzufügen", - "copyLink": "Link kopieren" + "copyLink": "Link kopieren", + "changeIcon": "Symbol ändern", + "collapseAllPages": "Alle Seiten einklappen" }, "blankPageTitle": "Leere Seite", "newPageText": "Neue Seite", "newDocumentText": "Neues Dokument", - "newGridText": "Neues Raster", + "newGridText": "Neue Datentabelle", "newCalendarText": "Neuer Kalender", "newBoardText": "Neues Board", + "chat": { + "newChat": "Neuer Chat", + "inputMessageHint": "Nachricht an @:appName AI", + "inputLocalAIMessageHint": "Nachricht an @:appName Lokale KI", + "unsupportedCloudPrompt": "Diese Funktion ist nur bei Verwendung der @:appName Cloud verfügbar", + "relatedQuestion": "Verwandt", + "serverUnavailable": "Dienst vorübergehend nicht verfügbar. Bitte versuche es später erneut.", + "aiServerUnavailable": "Beim Generieren einer Antwort ist ein Fehler aufgetreten.", + "retry": "Wiederholen", + "clickToRetry": "Erneut versuchen", + "regenerateAnswer": "Regenerieren", + "question1": "Wie verwendet man Kanban zur Aufgabenverwaltung?", + "question2": "Erkläre mir die GTD-Methode", + "question3": "Warum sollte ich Rust verwenden?", + "question4": "Gebe mir ein Rezept mit dem, was in meiner Küche ist", + "question5": "Eine Illustration für meine Seite erstellen", + "question6": "Erstelle eine To-Do-Liste für meine kommende Woche", + "aiMistakePrompt": "KI kann Fehler machen. Überprüfe wichtige Informationen.", + "chatWithFilePrompt": "Möchtest du mit der Datei chatten?", + "indexFileSuccess": "Datei erfolgreich indiziert", + "inputActionNoPages": "Keine Seitenergebnisse", + "referenceSource": { + "zero": "0 Quellen gefunden", + "one": "{count} Quelle gefunden", + "other": "{count} Quellen gefunden" + }, + "clickToMention": "Klicke hier, um eine Seite zu erwähnen", + "uploadFile": "Lade PDF-, md- oder txt-Dateien in den Chat hoch", + "questionDetail": "Hallo {}! Wie kann ich dir heute helfen?", + "indexingFile": "Indizierung {}", + "generatingResponse": "Antwort generieren", + "selectSources": "Quellen auswählen", + "regenerate": "Versuchen Sie es erneut", + "addToPageButton": "Zur Seite hinzufügen", + "addToPageTitle": "Nachricht hinzufügen an...", + "addToNewPage": "Zu einer neuen Seite hinzufügen" + }, "trash": { "text": "Papierkorb", "restoreAll": "Alles wiederherstellen", + "restore": "Wiederherstellen", "deleteAll": "Alles löschen", "pageHeader": { "fileName": "Dateiname", @@ -164,6 +229,10 @@ "title": "Möchtest du wirklich alle Seiten aus dem Papierkorb wiederherstellen?", "caption": "Diese Aktion kann nicht rückgängig gemacht werden." }, + "restorePage": { + "title": "Wiederherstellen: {}", + "caption": "Möchten Sie diese Seite wirklich wiederherstellen?" + }, "mobile": { "actions": "Papierkorb-Einstellungen", "empty": "Der Papierkorb ist leer.", @@ -176,20 +245,21 @@ "deletePagePrompt": { "text": "Diese Seite befindet sich im Papierkorb", "restore": "Seite wiederherstellen", - "deletePermanent": "Dauerhaft löschen" + "deletePermanent": "Dauerhaft löschen", + "deletePermanentDescription": "Möchten Sie diese Seite wirklich dauerhaft löschen? Dies kann nicht rückgängig gemacht werden." }, "dialogCreatePageNameHint": "Seitenname", "questionBubble": { "shortcuts": "Tastenkürzel", "whatsNew": "Was gibt es Neues?", - "help": "Hilfe & Support", "markdown": "Markdown", "debug": { "name": "Debug-Informationen", "success": "Debug-Informationen in die Zwischenablage kopiert!", "fail": "Debug-Informationen konnten nicht in die Zwischenablage kopiert werden" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Hilfe & Support" }, "menuAppHeader": { "moreButtonToolTip": "Entfernen, umbenennen und mehr...", @@ -226,8 +296,10 @@ "viewDataBase": "Datenbank ansehen", "referencePage": "Auf diesen {Name} wird verwiesen", "addBlockBelow": "Einen Block hinzufügen", + "aiGenerate": "Erzeugen", "urlLaunchAccessory": "Im Browser öffnen", - "urlCopyAccessory": "Webadresse kopieren." + "urlCopyAccessory": "Webadresse kopieren.", + "genSummary": "Zusammenfassung generieren" }, "sideBar": { "closeSidebar": "Seitenleiste schließen", @@ -243,10 +315,44 @@ "addAPage": "Seite hinzufügen", "addAPageToPrivate": "Eine Seite zum privaten Bereich hinzufügen.", "addAPageToWorkspace": "Eine Seite zum Arbeitsbereich hinzufügen", - "recent": "Zuletzt", + "recent": "Kürzlich", + "today": "Heute", + "thisWeek": "Diese Woche", + "others": "Andere", + "earlier": "Früher", + "justNow": "soeben", + "minutesAgo": "vor {count} Minuten", + "lastViewed": "Zuletzt angesehen", + "favoriteAt": "Zu Favoriten hinzugefügt bei", + "emptyRecent": "Keine aktuellen Dokumente", + "emptyRecentDescription": "Kürzlich aufgerufene Dokumente werden hier, zur vereinfachten Auffindbarkeit, angezeigt.", + "emptyFavorite": "Keine favorisierten Dokumente", + "emptyFavoriteDescription": "Beginne mit der Erkundung und markiere Dokumente als Favoriten. Diese werden hier für den schnellen Zugriff aufgelistet!", + "removePageFromRecent": "Diese Seite aus „Kürzlich“ entfernen?", + "removeSuccess": "Erfolgreich entfernt", + "favoriteSpace": "Favoriten", + "RecentSpace": "Kürzlich", + "Spaces": "Bereiche", + "upgradeToPro": "Upgrade auf Pro", + "upgradeToAIMax": "Schalte unbegrenzte KI frei", + "storageLimitDialogTitle": "Dein freier Speicherplatz ist aufgebraucht. Upgrade deinen Plan, um unbegrenzten Speicherplatz freizuschalten.", + "storageLimitDialogTitleIOS": "Ihr freier Speicherplatz ist aufgebraucht.", + "aiResponseLimitTitle": "Du hast keine kostenlosen KI-Antworten mehr. Upgrade auf den Pro-Plan oder kaufe ein KI-Add-on, um unbegrenzte Antworten freizuschalten", + "aiResponseLimitDialogTitle": "Limit für KI-Antworten erreicht", + "aiResponseLimit": "Du hast keine kostenlosen KI-Antworten mehr zur Verfügung.\n\nGehe zu Einstellungen -> Plan -> Klicke auf KI Max oder Pro Plan, um mehr KI-Antworten zu erhalten", + "askOwnerToUpgradeToPro": "Dein Arbeitsbereich hat nicht mehr genügend freien Speicherplatz. Bitte den Eigentümer deines Arbeitsbereichs, auf den Pro-Plan hochzustufen.", + "askOwnerToUpgradeToProIOS": "In Ihrem Arbeitsbereich ist nicht mehr genügend freier Speicherplatz verfügbar.", + "askOwnerToUpgradeToAIMax": "In deinem Arbeitsbereich sind die kostenlosen KI-Antworten aufgebraucht. Bitte den Eigentümer deines Arbeitsbereichs, den Plan zu wechseln oder KI-Add-ons zu erwerben.", + "askOwnerToUpgradeToAIMaxIOS": "In Ihrem Arbeitsbereich gehen die kostenlosen KI-Antworten aus.", + "purchaseStorageSpace": "Speicherplatz kaufen", + "singleFileProPlanLimitationDescription": "Sie haben die maximal zulässige Datei-Uploadgröße im kostenlosen Plan überschritten. Bitte aktualisieren Sie auf den Pro-Plan, um größere Dateien hochzuladen", + "purchaseAIResponse": "Kaufen ", + "askOwnerToUpgradeToLocalAI": "Bitte den Arbeitsbereichsbesitzer, KI auf dem Gerät zu aktivieren.", + "upgradeToAILocal": "KI offline auf Ihrem Gerät", + "upgradeToAILocalDesc": "Chatte mit PDFs, verbessere deine Schreibfähigkeiten und fülle Tabellen automatisch mithilfe lokaler KI aus.", "public": "Öffentlich", - "clickToHidePublic": "Hier klicken, um den öffentlichen Raum auszublenden.\nVon dir hier erstellte Seiten sind für jedes Mitglied sichtbar.", - "addAPageToPublic": "Eine Seite zum öffentlichen Bereich hinzufügen." + "clickToHidePublic": "Hier klicken, um den öffentlichen Bereich auszublenden.\nHier erstellte Seiten sind für jedes Mitglied sichtbar.", + "addAPageToPublic": "Eine Seite zur öffentlichen Domäne hinzufügen." }, "notifications": { "export": { @@ -280,16 +386,22 @@ "upload": "Hochladen", "edit": "Bearbeiten", "delete": "Löschen", + "copy": "kopieren", "duplicate": "Duplikat", "putback": "wieder zurückgeben", "update": "Update", "share": "Teilen", "removeFromFavorites": "Aus den Favoriten entfernen", + "removeFromRecent": "Aus „Kürzlich“ entfernen", "addToFavorites": "Zu den Favoriten hinzufügen", + "favoriteSuccessfully": "Erfolgreich favorisiert", + "unfavoriteSuccessfully": "Erfolgreich entfavorisiert", + "duplicateSuccessfully": "Erfolgreich dupliziert", "rename": "Umbenennen", "helpCenter": "Hilfe Center", "add": "Hinzufügen", "yes": "Ja", + "no": "Nein", "clear": "Leeren", "remove": "Entfernen", "dontRemove": "Nicht entfernen", @@ -301,7 +413,18 @@ "back": "Zurück", "signInGoogle": "Mit einem Google Benutzerkonto anmelden", "signInGithub": "Mit einem Github Benutzerkonto anmelden", - "signInDiscord": "Mit einem Discord Benutzerkonto anmelden" + "signInDiscord": "Mit einem Discord Benutzerkonto anmelden", + "more": "Mehr", + "create": "Erstellen", + "close": "Schließen", + "next": "Weiter", + "previous": "Zurück", + "submit": "Einreichen", + "download": "Herunterladen", + "backToHome": "Zurück zur Startseite", + "viewing": "anschauen", + "editing": "Bearbeiten", + "gotIt": "Verstanden" }, "label": { "welcome": "Willkommen!", @@ -325,13 +448,80 @@ }, "settings": { "title": "Einstellungen", + "popupMenuItem": { + "settings": "Einstellungen", + "members": "Mitglieder", + "trash": "Müll", + "helpAndSupport": "Hilfe & Unterstützung" + }, + "sites": { + "title": "Seiten", + "namespaceTitle": "Namensraum", + "namespaceDescription": "Verwalten Sie Ihren Namespace und Ihre Startseite", + "namespaceHeader": "Namensraum", + "homepageHeader": "Startseite", + "updateNamespace": "Namespace aktualisieren", + "removeHomepage": "Startseite entfernen", + "selectHomePage": "Seite auswählen", + "clearHomePage": "Löschen Sie die Startseite für diesen Namensraum", + "customUrl": "Benutzerdefinierte URL", + "namespace": { + "description": "Diese Änderung gilt für alle veröffentlichten Seiten in diesem Namespace.", + "tooltip": "Wir behalten uns das Recht vor, unangemessene Namespaces zu entfernen", + "updateExistingNamespace": "Vorhandenen Namensraum aktualisieren", + "upgradeToPro": "Aktualisieren Sie auf den Pro-Plan, um eine Startseite einzurichten", + "redirectToPayment": "Weiterleitung zur Zahlungsseite ...", + "onlyWorkspaceOwnerCanSetHomePage": "Nur der Arbeitsbereichsbesitzer kann eine Startseite festlegen", + "pleaseAskOwnerToSetHomePage": "Bitten Sie den Arbeitsbereichsbesitzer, auf den Pro-Plan zu aktualisieren" + }, + "publishedPage": { + "title": "Alle veröffentlichten Seiten", + "description": "Verwalten Sie Ihre veröffentlichten Seiten", + "page": "Seite", + "pathName": "Pfadname", + "date": "Veröffentlichungsdatum", + "emptyHinText": "Sie haben keine veröffentlichten Seiten in diesem Arbeitsbereich", + "noPublishedPages": "Keine veröffentlichten Seiten", + "settings": "Veröffentlichungseinstellungen", + "clickToOpenPageInApp": "Seite in App öffnen", + "clickToOpenPageInBrowser": "Seite im Browser öffnen" + }, + "error": { + "failedToGeneratePaymentLink": "Zahlungslink für Pro Plan konnte nicht generiert werden", + "failedToUpdateNamespace": "Namensraum konnte nicht aktualisiert werden", + "proPlanLimitation": "Sie müssen auf den Pro-Plan upgraden, um den Namespace zu aktualisieren", + "namespaceAlreadyInUse": "Der Namespace ist bereits vergeben, bitte versuchen Sie es mit einem anderen", + "invalidNamespace": "Ungültiger Namensraum, bitte versuchen Sie einen anderen", + "namespaceLengthAtLeast2Characters": "Der Namensraum muss mindestens 2 Zeichen lang sein", + "onlyWorkspaceOwnerCanUpdateNamespace": "Nur der Arbeitsbereichsbesitzer kann den Namespace aktualisieren", + "onlyWorkspaceOwnerCanRemoveHomepage": "Nur der Arbeitsbereichsbesitzer kann die Homepage entfernen", + "setHomepageFailed": "Startseite konnte nicht eingerichtet werden", + "namespaceTooLong": "Der Namensraum ist zu lang. Bitte versuchen Sie es mit einem anderen.", + "namespaceTooShort": "Der Namensraum ist zu kurz, bitte versuchen Sie es mit einem anderen", + "namespaceIsReserved": "Der Namensraum ist reserviert, bitte versuchen Sie es mit einem anderen", + "updatePathNameFailed": "Pfadname konnte nicht aktualisiert werden", + "removeHomePageFailed": "Startseite konnte nicht entfernt werden", + "publishNameContainsInvalidCharacters": "Der Pfadname enthält ungültige Zeichen. Bitte versuchen Sie es mit einem anderen.", + "publishNameTooShort": "Der Pfadname ist zu kurz, bitte versuchen Sie es mit einem anderen", + "publishNameTooLong": "Der Pfadname ist zu lang, bitte versuchen Sie es mit einem anderen", + "publishNameAlreadyInUse": "Der Pfadname wird bereits verwendet. Bitte versuchen Sie einen anderen.", + "namespaceContainsInvalidCharacters": "Der Namespace enthält ungültige Zeichen. Bitte versuchen Sie es mit einem anderen.", + "publishPermissionDenied": "Nur der Arbeitsbereichsbesitzer oder Seitenherausgeber kann die Veröffentlichungseinstellungen verwalten", + "publishNameCannotBeEmpty": "Der Pfadname darf nicht leer sein. Bitte versuchen Sie es mit einem anderen." + }, + "success": { + "namespaceUpdated": "Namensraum erfolgreich aktualisiert", + "setHomepageSuccess": "Startseite erfolgreich eingerichtet", + "updatePathNameSuccess": "Pfadname erfolgreich aktualisiert", + "removeHomePageSuccess": "Startseite erfolgreich entfernt" + } + }, "accountPage": { "menuLabel": "Mein Konto", "title": "Mein Konto", - "description": "Passe dein Profil an, verwalte deine Sicherheitseinstellungen und AI-API-Schlüssel oder melde dich bei deinem Konto an.", "general": { "title": "Kontoname und Profilbild", - "changeProfilePicture": "Ändern" + "changeProfilePicture": "Profilbild ändern" }, "email": { "title": "E-Mail", @@ -339,21 +529,544 @@ "change": "E-Mail ändern" } }, - "keys": { - "title": "KI API-Schlüssel", - "openAILabel": "OpenAI API-Schlüssel", - "openAITooltip": "Der für die KI-Modelle zu verwendende OpenAI-API-Schlüssel", - "openAIHint": "OpenAI API-Schlüssel eingeben", - "stabilityAILabel": "Stability API-Schlüssel", - "stabilityAITooltip": "Der für die KI-Modelle zu verwendende Stability API-Schlüssel", - "stabilityAIHint": "Stability API-Schlüssel eingeben" - }, "login": { "title": "Kontoanmeldung", "loginLabel": "Anmeldung", "logoutLabel": "Ausloggen" + }, + "description": "Passe dein Profil an, verwalte deine Sicherheitseinstellungen und KI API-Schlüssel oder melde dich bei deinem Konto an." + }, + "workspacePage": { + "menuLabel": "Arbeitsbereich", + "title": "Arbeitsbereich", + "description": "Passe das Erscheinungsbild, das Design, die Schriftart, das Textlayout, das Datums-/Zeitformat und die Sprache deines Arbeitsbereiches an.", + "workspaceName": { + "title": "Name des Arbeitsbereiches", + "savedMessage": "Name des Arbeitsbereiches gespeichert", + "editTooltip": "Name des Arbeitsbereiches ändern" + }, + "workspaceIcon": { + "title": "Symbol", + "description": "Lade ein Bild hoch oder verwende ein Emoji für deinen Arbeitsbereich. Das Symbol wird in deiner Seitenleiste und in deinen Benachrichtigungen angezeigt." + }, + "appearance": { + "title": "Aussehen", + "description": "Passe das Erscheinungsbild, das Design, die Schriftart, das Textlayout, das Datums-/Zeitformat und die Sprache deines Arbeitsbereiches an.", + "options": { + "system": "Auto", + "light": "Hell", + "dark": "Dunkel" + } + }, + "resetCursorColor": { + "title": "Farbe des Dokumentcursors zurücksetzen", + "description": "Möchtest du die Cursorfarbe wirklich zurücksetzen?" + }, + "resetSelectionColor": { + "title": "Dokumentauswahlfarbe zurücksetzen", + "description": "Möchtest du die Auswahlfarbe wirklich zurücksetzen?" + }, + "resetWidth": { + "resetSuccess": "Dokumentbreite erfolgreich zurückgesetzt" + }, + "theme": { + "title": "Design", + "description": "Wähle ein voreingestelltes Design aus oder lade dein eigenes benutzerdefiniertes Design hoch.", + "uploadCustomThemeTooltip": "Ein benutzerdefiniertes Theme hochladen" + }, + "workspaceFont": { + "title": "Schriftart", + "noFontHint": "Keine Schriftart gefunden, versuchen Sie einen anderen Begriff." + }, + "textDirection": { + "title": "Textrichtung", + "leftToRight": "Links nach rechts", + "rightToLeft": "Rechts nach links", + "auto": "Auto", + "enableRTLItems": "RTL-Symbolleistenelemente aktivieren" + }, + "layoutDirection": { + "title": "Layoutrichtung", + "leftToRight": "Links nach rechts", + "rightToLeft": "Rechts nach links" + }, + "dateTime": { + "title": "Datum & Zeit", + "example": "{} um {} ({})", + "24HourTime": "24-Stunden-Zeit", + "dateFormat": { + "label": "Datumsformat", + "local": "Lokal", + "us": "US", + "iso": "ISO", + "friendly": "Leserlich", + "dmy": "T/M/J" + } + }, + "language": { + "title": "Sprache" + }, + "deleteWorkspacePrompt": { + "title": "Arbeitsbereich löschen", + "content": "Möchtest du diesen Arbeitsbereich wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." + }, + "leaveWorkspacePrompt": { + "title": "Arbeitsbereich verlassen", + "content": "Möchtest du diesen Arbeitsbereich wirklich verlassen? Du verlierst den Zugriff auf alle darin enthaltenen Seiten und Daten.", + "success": "Sie haben den Arbeitsbereich erfolgreich verlassen.", + "fail": "Das Verlassen des Arbeitsbereichs ist fehlgeschlagen." + }, + "manageWorkspace": { + "title": "Arbeitsbereich verwalten", + "leaveWorkspace": "Arbeitsbereich verlassen", + "deleteWorkspace": "Arbeitsbereich löschen" } }, + "manageDataPage": { + "menuLabel": "Daten verwalten", + "title": "Daten verwalten", + "description": "Verwalte den lokalen Datenspeicher oder importiere deine vorhandenen Daten in @:appName. Du kannst deine Daten mit Ende-zu-Ende-Verschlüsselung absichern.", + "dataStorage": { + "title": "Speicherort", + "tooltip": "Das Verzeichnis, in dem deine Dateien gespeichert sind", + "actions": { + "change": "Pfad ändern", + "open": "Ordner öffnen", + "openTooltip": "Aktuellen Speicherort des Datenordners öffnen", + "copy": "Pfad kopieren", + "copiedHint": "Link kopiert!", + "resetTooltip": "Auf Standardspeicherort zurücksetzen" + }, + "resetDialog": { + "title": "Bist du sicher?", + "description": "Durch das Zurücksetzen des Pfads auf das Standardverzeichnis werden deine Daten nicht gelöscht. Wenn du deine aktuellen Daten erneut importieren möchtest, solltest du zuerst den Pfad deines aktuellen Speicherorts kopieren." + } + }, + "importData": { + "title": "Daten importieren", + "tooltip": "Daten aus @:appName Backups-/Datenordnern importieren", + "description": "Daten aus einem externen @:appName Datenordner kopieren und in den aktuellen @:appName Datenordner importieren", + "action": "Ordner durchsuchen" + }, + "encryption": { + "title": "Verschlüsselung", + "tooltip": "Verwalte, wie deine Daten gespeichert und verschlüsselt werden", + "descriptionNoEncryption": "Durch das Einschalten der Verschlüsselung werden alle Daten verschlüsselt. Dieser Vorgang kann nicht rückgängig gemacht werden.", + "descriptionEncrypted": "Deine Daten sind verschlüsselt.", + "action": "Daten verschlüsseln", + "dialog": { + "title": "Alle deine Daten verschlüsseln?", + "description": "Durch die Verschlüsselung all deiner Daten bleiben diese sicher und geschützt. Diese Aktion kann NICHT rückgängig gemacht werden. Möchtest du wirklich fortfahren?" + } + }, + "cache": { + "title": "Cache leeren", + "description": "Wenn Bilder nicht geladen werden oder Schriftarten nicht richtig angezeigt werden, versuche den Cache zu leeren. Deine Benutzerdaten werden dadurch nicht gelöscht.", + "dialog": { + "title": "Bist du sicher?", + "description": "Durch das Leeren des Caches werden Bilder und Schriftarten beim Laden erneut heruntergeladen. Deine Daten werden durch diese Aktion weder entfernt noch geändert.", + "successHint": "Cache geleert!" + } + }, + "data": { + "fixYourData": "Deine Daten korrigieren", + "fixButton": "Korrigieren", + "fixYourDataDescription": "Wenn du Probleme mit deinen Daten hast, kannst du hier versuchen, diese zu beheben." + } + }, + "shortcutsPage": { + "menuLabel": "Tastenkombinationen", + "title": "Shortcuts", + "editBindingHint": "Neue Verknüpfung eingeben", + "searchHint": "Suchen", + "actions": { + "resetDefault": "Standardeinstellung zurücksetzen" + }, + "errorPage": { + "message": "Shortcuts konnten nicht geladen werden: {}", + "howToFix": "Bitte versuche es erneut. Wenn das Problem weiterhin besteht, melde es bitte auf GitHub." + }, + "resetDialog": { + "title": "Shortcuts zurücksetzen", + "description": "Dies wird alle deine Shortcuts auf die Standardeinstellungen zurücksetzen, dies kann nicht rückgängig gemacht werden. Bist du dir sicher, dass du fortfahren möchtest?", + "buttonLabel": "Zurücksetzen" + }, + "conflictDialog": { + "title": "{} ist derzeit in Verwendung", + "descriptionPrefix": "Diese Tastenkombination wird derzeit verwendet von ", + "descriptionSuffix": ". Wenn du diese Tastaturbelegung ersetzt, wird sie aus {} entfernt.", + "confirmLabel": "Weiter" + }, + "editTooltip": "Zum Starten der Bearbeitung der Tastaturbelegung drücken.", + "keybindings": { + "toggleToDoList": "Aufgabenliste ein-/ausblenden", + "insertNewParagraphInCodeblock": "Neuen Absatz einfügen", + "pasteInCodeblock": "In Codeblock einfügen", + "selectAllCodeblock": "Alles auswählen", + "indentLineCodeblock": "Zwei Leerzeichen am Zeilenanfang einfügen", + "outdentLineCodeblock": "Zwei Leerzeichen am Zeilenanfang löschen", + "twoSpacesCursorCodeblock": "Zwei Leerzeichen am Cursor einfügen", + "copy": "Auswahl kopieren", + "paste": "Inhalt einfügen", + "cut": "Auswahl ausschneiden", + "alignLeft": "Text links ausrichten", + "alignCenter": "Text zentriert ausrichten", + "alignRight": "Text rechts ausrichten", + "undo": "Undo", + "redo": "Redo", + "convertToParagraph": "Block in Absatz umwandeln", + "backspace": "Löschen", + "deleteLeftWord": "Linkes Wort löschen", + "deleteLeftSentence": "Linken Satz löschen", + "delete": "Rechtes Zeichen löschen", + "deleteMacOS": "Linkes Zeichen löschen", + "deleteRightWord": "Rechtes Wort löschen", + "moveCursorLeft": "Cursor nach links bewegen", + "moveCursorBeginning": "Cursor an den Zeilenanfang bewegen", + "moveCursorLeftWord": "Cursor ein Wort nach links bewegen", + "moveCursorLeftSelect": "Auswählen und Cursor nach links bewegen", + "moveCursorBeginSelect": "Auswählen und Cursor an den Zeilenanfang bewegen", + "moveCursorLeftWordSelect": "Auswählen und Cursor ein Wort nach links bewegen", + "moveCursorRight": "Cursor nach rechts bewegen", + "moveCursorEnd": "Cursor an das Zeilenende bewegen", + "moveCursorRightWord": "Cursor ein Wort nach rechts bewegen", + "moveCursorRightSelect": "Auswählen und Cursor nach rechts bewegen", + "moveCursorEndSelect": "Auswählen und Cursor an das Zeilenende bewegen", + "moveCursorRightWordSelect": "Markiere das Wort und bewege den Cursor ein Wort nach rechts", + "moveCursorUp": "Cursor nach oben bewegen", + "moveCursorTopSelect": "Auswählen und Cursor zum Anfang bewegen", + "moveCursorTop": "Cursor zum Anfang bewegen", + "moveCursorUpSelect": "Auswählen und Cursor nach oben bewegen", + "moveCursorBottomSelect": "Auswählen und Cursor ans Ende bewegen", + "moveCursorBottom": "Cursor ans Ende bewegen", + "moveCursorDown": "Cursor nach unten bewegen", + "moveCursorDownSelect": "Auswählen und Cursor nach unten bewegen", + "home": "Zum Anfang scrollen", + "end": "Zum Ende scrollen", + "toggleBold": "Fett ein-/ausschalten", + "toggleItalic": "Kursivschrift ein-/ausschalten", + "toggleUnderline": "Unterstreichung ein-/ausschalten", + "toggleStrikethrough": "Durchgestrichen ein-/ausschalten", + "toggleCode": "Inline-Code ein-/ausschalten", + "toggleHighlight": "Hervorhebung ein-/ausschalten", + "showLinkMenu": "Linkmenü anzeigen", + "openInlineLink": "Inline-Link öffnen", + "openLinks": "Alle ausgewählten Links öffnen", + "indent": "Einzug", + "outdent": "Ausrücken", + "exit": "Bearbeitung beenden", + "pageUp": "Eine Seite nach oben scrollen", + "pageDown": "Eine Seite nach unten scrollen", + "selectAll": "Alles auswählen", + "pasteWithoutFormatting": "Inhalt ohne Formatierung einfügen", + "showEmojiPicker": "Emoji-Auswahl anzeigen", + "enterInTableCell": "Zeilenumbruch in Tabelle hinzufügen", + "leftInTableCell": "In der Tabelle eine Zelle nach links verschieben", + "rightInTableCell": "In der Tabelle eine Zelle nach rechts verschieben", + "upInTableCell": "In der Tabelle eine Zelle nach oben verschieben", + "downInTableCell": "In der Tabelle eine Zelle nach unten verschieben", + "tabInTableCell": "Zur nächsten verfügbaren Zelle in der Tabelle gehen", + "shiftTabInTableCell": "Zur zuvor verfügbaren Zelle in der Tabelle gehen", + "backSpaceInTableCell": "Am Anfang der Zelle anhalten" + }, + "commands": { + "codeBlockNewParagraph": "Füge einen neuen Absatz neben dem Codeblock ein", + "codeBlockIndentLines": "Füge am Zeilenanfang im Codeblock zwei Leerzeichen ein", + "codeBlockOutdentLines": "Lösche zwei Leerzeichen am Zeilenanfang im Codeblock", + "codeBlockAddTwoSpaces": "Einfügen von zwei Leerzeichen an der Cursorposition im Codeblock", + "codeBlockSelectAll": "Wähle den gesamten Inhalt innerhalb eines Codeblocks aus", + "codeBlockPasteText": "Text in Codeblock einfügen", + "textAlignLeft": "Text nach links ausrichten", + "textAlignCenter": "Text nach rechts ausrichten", + "textAlignRight": "Text rechtsbündig ausrichten" + }, + "couldNotLoadErrorMsg": "Konnte keine Shortcuts laden, versuche es erneut", + "couldNotSaveErrorMsg": "Shortcuts konnten nicht gespeichert werden, versuche es erneut" + }, + "aiPage": { + "title": "KI-Einstellungen", + "menuLabel": "KI-Einstellungen", + "keys": { + "enableAISearchTitle": "KI-Suche", + "aiSettingsDescription": "Wähle oder konfiguriere KI-Modelle, die in @:appName verwendet werden. Für eine optimale Leistung empfehlen wir die Verwendung der Standardmodelloptionen", + "loginToEnableAIFeature": "KI-Funktionen werden erst nach der Anmeldung bei @:appName Cloud aktiviert. Wenn du kein @:appName-Konto hast, gehe zu „Mein Konto“, um dich zu registrieren", + "llmModel": "Sprachmodell", + "llmModelType": "Sprachmodelltyp", + "downloadLLMPrompt": "Herunterladen {}", + "downloadAppFlowyOfflineAI": "Durch das Herunterladen des KI-Offlinepakets kann KI auf deinem Gerät ausgeführt werden. Möchtest du fortfahren?", + "downloadLLMPromptDetail": "Das Herunterladen des lokalen Modells {} beansprucht bis zu {} Speicherplatz. Möchtest du fortfahren?", + "downloadBigFilePrompt": "Der Download kann etwa 10 Minuten dauern", + "downloadAIModelButton": "KI-Modell herunterladen", + "downloadingModel": "wird heruntergeladen", + "localAILoaded": "Lokales KI-Modell erfolgreich hinzugefügt und einsatzbereit", + "localAIStart": "Der lokale KI-Chat beginnt …", + "localAILoading": "Das lokale KI-Chat-Modell wird geladen …", + "localAIStopped": "Lokale KI wurde gestoppt", + "failToLoadLocalAI": "Lokale KI konnte nicht gestartet werden", + "restartLocalAI": "Lokale KI neustarten", + "disableLocalAITitle": "Lokale KI deaktivieren", + "disableLocalAIDescription": "Möchtest du die lokale KI deaktivieren?", + "localAIToggleTitle": "Umschalten zum Aktivieren oder Deaktivieren der lokalen KI", + "offlineAIInstruction1": "Folge der", + "offlineAIInstruction2": "Anweisung", + "offlineAIInstruction3": "um Offline-KI zu aktivieren.", + "offlineAIDownload1": "Wenn du die AppFlowy KI noch nicht heruntergeladen hast,", + "offlineAIDownload2": "lade", + "offlineAIDownload3": "sie zuerst herunter", + "activeOfflineAI": "Aktiv", + "downloadOfflineAI": "Herunterladen", + "openModelDirectory": "Ordner öffnen", + "title": "KI-API-Schlüssel" + } + }, + "planPage": { + "menuLabel": "Plan", + "title": "Tarifplan", + "planUsage": { + "title": "Zusammenfassung der Plannutzung", + "storageLabel": "Speicher", + "storageUsage": "{} von {} GB", + "unlimitedStorageLabel": "Unbegrenzter Speicherplatz", + "collaboratorsLabel": "Gastmitarbeiter", + "collaboratorsUsage": "{} von {}", + "aiResponseLabel": "KI-Antworten", + "aiResponseUsage": "{} von {}", + "unlimitedAILabel": "Unbegrenzte Antworten", + "proBadge": "Pro", + "aiMaxBadge": "KI Max", + "aiOnDeviceBadge": "KI On-Device", + "memberProToggle": "Unbegrenzte Mitgliederzahl", + "aiMaxToggle": "Unbegrenzte KI-Antworten", + "aiOnDeviceToggle": "KI auf dem Gerät für ultimative Privatsphäre", + "aiCredit": { + "title": "@:appName KI-Guthaben hinzufügen", + "price": "{}", + "priceDescription": "für 1.000 Credits", + "purchase": "Kauf von KI", + "info": "Füge 1.000 KI-Credits pro Arbeitsbereich hinzu und integriere anpassbare KI nahtlos in deinen Arbeitsablauf für intelligentere, schnellere Ergebnisse mit bis zu:", + "infoItemOne": "10.000 Antworten pro Datenbank", + "infoItemTwo": "1.000 Antworten pro Arbeitsbereich" + }, + "currentPlan": { + "bannerLabel": "Derzeitiger Plan", + "freeTitle": "Kostenfrei", + "proTitle": "Pro", + "teamTitle": "Team", + "freeInfo": "Perfekt für Einzelpersonen oder kleine Teams mit bis zu 3 Mitgliedern.", + "proInfo": "Perfekt für kleine und mittlere Teams mit bis zu 10 Mitgliedern.", + "teamInfo": "Perfekt für alle produktiven und gut organisierten Teams.", + "upgrade": "Vergleichen &\n Upgraden", + "canceledInfo": "Dein Plan wurde gekündigt und du wirst am {} auf den kostenlosen Plan herabgestuft.", + "freeProOne": "Gemeinsamer Arbeitsbereich", + "freeProTwo": "Bis zu 3 Mitglieder (inkl. Eigentümer)", + "freeProThree": "Unbegrenzte Anzahl an Gästen (nur anzeigen)", + "freeProFour": "Speicher 5 GB", + "freeProFive": "30 Tage Änderungshistorie", + "freeConOne": "Gastmitarbeiter (Bearbeitungszugriff)", + "freeConTwo": "Unbegrenzter Speicherplatz", + "freeConThree": "6 Monate Änderungshistorie", + "professionalProOne": "Gemeinsamer Arbeitsbereich", + "professionalProTwo": "Unbegrenzte Mitgliederzahl", + "professionalProThree": "Unbegrenzte Anzahl an Gästen (nur anzeigen)", + "professionalProFour": "Unbegrenzter Speicherplatz", + "professionalProFive": "6 Monate Änderungshistorie", + "professionalConOne": "Unbegrenzte Anzahl an Gastmitarbeitern (Bearbeitungszugriff)", + "professionalConTwo": "Unbegrenzte KI-Antworten", + "professionalConThree": "1 Jahr Änderungshistorie" + }, + "addons": { + "title": "Add-ons", + "addLabel": "Hinzufügen", + "activeLabel": "Hinzugefügt", + "aiMax": { + "title": "KI Max", + "description": "Schalte unbegrenzte KI frei", + "price": "{}", + "priceInfo": "pro Benutzer und Monat", + "billingInfo": "jährliche Abrechnung oder {} bei monatlicher Abrechnung" + }, + "aiOnDevice": { + "title": "KI On-Device", + "description": "KI offline auf deinem Gerät", + "price": "{}", + "priceInfo": "pro Benutzer und Monat", + "recommend": "Empfohlen wird M1 oder neuer", + "billingInfo": "jährliche Abrechnung oder {} bei monatlicher Abrechnung" + } + }, + "deal": { + "bannerLabel": "Neujahrsangebot!", + "title": "Erweiter dein Team!", + "info": "Upgraden und 10 % auf Pro- und Team-Pläne sparen! Steiger die Produktivität deines Arbeitsplatzes mit leistungsstarken neuen Funktionen, einschließlich @:appName KI.", + "viewPlans": "Pläne anzeigen" + }, + "guestCollabToggle": "10 Gastmitarbeiter", + "storageUnlimited": "Unbegrenzter Speicherplatz mit deinem Pro-Plan" + } + }, + "billingPage": { + "menuLabel": "Abrechnung", + "title": "Abrechnung", + "plan": { + "title": "Plan", + "freeLabel": "Kostenfrei", + "proLabel": "Pro", + "planButtonLabel": "Plan ändern", + "billingPeriod": "Abrechnungszeitraum", + "periodButtonLabel": "Zeitraum bearbeiten" + }, + "paymentDetails": { + "title": "Zahlungsdetails", + "methodLabel": "Zahlungsmethode", + "methodButtonLabel": "Zahlungsmethode bearbeiten" + }, + "addons": { + "title": "Add-ons", + "addLabel": "Hinzufügen", + "removeLabel": "Entfernen", + "renewLabel": "Erneuern", + "aiMax": { + "label": "KI Max", + "description": "Schalte unbegrenzte KI Antworten und erweiterte Modelle frei", + "activeDescription": "Nächste Rechnung fällig am {}", + "canceledDescription": "KI Max ist verfügbar bis {}" + }, + "aiOnDevice": { + "label": "KI On-Device", + "description": "Schalte unbegrenzte KI Antworten offline auf deinem Gerät frei", + "activeDescription": "Nächste Rechnung fällig am {}", + "canceledDescription": "KI On-Device ist verfügbar bis {}" + }, + "removeDialog": { + "title": "Entfernen {}", + "description": "Möchtest du den {plan} wirklich entfernen? Du verlierst dann sofort den Zugriff auf die Funktionen und Vorteile des {plan}." + } + }, + "currentPeriodBadge": "AKTUELL", + "changePeriod": "Zeitraum ändern", + "planPeriod": "{} Zeitraum", + "monthlyInterval": "Monatlich", + "monthlyPriceInfo": "pro Sitzplatz, monatliche Abrechnung", + "annualInterval": "Jährlich", + "annualPriceInfo": "pro Sitzplatz, jährliche Abrechnung" + }, + "comparePlanDialog": { + "title": "Plan vergleichen & auswählen", + "planFeatures": "Plan\nFeatures", + "current": "Aktuell", + "actions": { + "upgrade": "Upgrade", + "downgrade": "Downgrade", + "current": "Aktuell", + "downgradeDisabledTooltip": "Du wirst am Ende des Abrechnungszeitraums automatisch herabgestuft" + }, + "freePlan": { + "title": "Kostenlos", + "description": "Für die Organisation jeder Ecke Ihres Lebens und Ihrer Arbeit.", + "price": "0€", + "priceInfo": "free forever" + }, + "proPlan": { + "title": "Professionell", + "description": "Ein Ort für kleine Gruppen zum Planen und Organisieren.", + "price": "{} /Monat", + "priceInfo": "jährlich abgerechnet" + }, + "planLabels": { + "itemOne": "Arbeitsbereiche", + "itemTwo": "Mitglieder", + "itemThree": "Gäste", + "itemFour": "Gäste", + "itemFive": "Speicher", + "itemSix": "Zusammenarbeit in Echtzeit", + "itemSeven": "Mobile App", + "itemFileUpload": "Datei-Uploads", + "customNamespace": "Benutzerdefinierter Namensraum", + "tooltipSix": "Lebenslang bedeutet, dass die Anzahl der Antworten nie zurückgesetzt wird", + "intelligentSearch": "Intelligente Suche", + "tooltipSeven": "Ermöglicht dir, einen Teil der URL für deinen Arbeitsbereich anzupassen", + "customNamespaceTooltip": "Benutzerdefinierte veröffentlichte Seiten-URL", + "tooltipThree": "Gäste haben nur Leserechte für die speziell freigegebenen Inhalte", + "tooltipFour": "Gäste werden als ein Sitzplatz abgerechnet", + "itemEight": "AI-Antworten", + "tooltipEight": "Lebenslang bedeutet, dass die Anzahl der Antworten nie zurückgesetzt wird." + }, + "freeLabels": { + "itemOne": "pro Arbeitsbereich berechnet", + "itemTwo": "3", + "itemThree": "5 GB", + "itemFour": "0", + "itemFive": "5 GB", + "itemSix": "10 Lebenszeiten", + "itemSeven": "ja", + "itemFileUpload": "Bis zu 7 MB", + "intelligentSearch": "Intelligente Suche", + "itemEight": "1.000 lebenslang" + }, + "proLabels": { + "itemOne": "pro Arbeitsbereich berechnet", + "itemTwo": "bis zu 10", + "itemThree": "unbegrenzt", + "itemFour": "10 Gäste werden als ein Sitzplatz berechnet", + "itemFive": "unbegrenzt", + "itemSix": "ja", + "itemSeven": "ja", + "itemFileUpload": "Unbegrenzt", + "intelligentSearch": "Intelligente Suche", + "itemEight": "10.000 monatlich" + }, + "paymentSuccess": { + "title": "Du bist jetzt im {} Plan!", + "description": "Deine Zahlung wurde erfolgreich verarbeitet und dein Plan wurde auf @:appName {} aktualisiert. Du kannst die Details deines Plans auf der Seite \"Plan\" einsehen" + }, + "downgradeDialog": { + "title": "Bist du sicher, dass du deinen Plan herabstufen willst?", + "description": "Wenn du deinen Plan herabstufst, kehrst du zum kostenfreien Plan zurück. Mitglieder können den Zugang zu Arbeitsbereichen verlieren und du musst möglicherweise Speicherplatz freigeben, um die Speichergrenzen des kostenfreien Tarifs einzuhalten.", + "downgradeLabel": "Downgrade Plan" + } + }, + "cancelSurveyDialog": { + "title": "Schade dich gehen zu sehen", + "description": "Wir bedauern, dass du gehst. Wir würden uns über dein Feedback freuen, das uns hilft @:appName zu verbessern. Bitte nehme dir einen Moment Zeit, um ein paar Fragen zu beantworten.", + "commonOther": "Andere", + "otherHint": "Schreibe deine Antwort hier", + "questionOne": { + "question": "Was hat dich dazu veranlasst, dein @:appName Pro-Abonnement zu kündigen?", + "answerOne": "Kosten zu hoch", + "answerTwo": "Die Funktionen entsprachen nicht den Erwartungen", + "answerThree": "Habe eine bessere Alternative gefunden", + "answerFour": "Habe es nicht oft genug genutzt, um die Kosten zu rechtfertigen", + "answerFive": "Serviceproblem oder technische Schwierigkeiten" + }, + "questionTwo": { + "question": "Wie wahrscheinlich ist es, dass du in Zukunft ein erneutes Abonnement von @:appName Pro in Betracht ziehst?", + "answerOne": "Sehr wahrscheinlich", + "answerTwo": "Ziemlich wahrscheinlich", + "answerThree": "Nicht sicher", + "answerFour": "Unwahrscheinlich", + "answerFive": "Sehr unwahrscheinlich" + }, + "questionThree": { + "question": "Welche Pro-Funktion hast du während deines Abonnements am meisten geschätzt?", + "answerOne": "Zusammenarbeit mehrerer Benutzer", + "answerTwo": "Längerer Versionsverlauf", + "answerThree": "Unbegrenzte KI-Antworten", + "answerFour": "Zugriff auf lokale KI-Modelle" + }, + "questionFour": { + "question": "Wie würdest du deine allgemeine Erfahrung mit @:appName beschreiben?", + "answerOne": "Großartig", + "answerTwo": "Gut", + "answerThree": "Durchschnitt", + "answerFour": "Unterdurchschnittlich", + "answerFive": "Nicht zufrieden" + } + }, + "common": { + "uploadingFile": "Datei wird hochgeladen. Bitte beenden Sie die App nicht.", + "uploadNotionSuccess": "Ihre Notion-ZIP-Datei wurde erfolgreich hochgeladen. Sobald der Import abgeschlossen ist, erhalten Sie eine Bestätigungs-E-Mail", + "reset": "Zurücksetzen" + }, "menu": { "appearance": "Oberfläche", "language": "Sprache", @@ -362,34 +1075,33 @@ "notifications": "Benachrichtigungen", "open": "Einstellungen öffnen", "logout": "Abmelden", - "logoutPrompt": "Wollen sie sich wirklich Abmelden?", + "logoutPrompt": "Willst du dich wirklich abmelden?", "selfEncryptionLogoutPrompt": "Willst du dich wirklich Abmelden? Bitte stelle sicher, dass der Encryption Secret Code kopiert wurde.", - "syncSetting": "Sync Einstellung", + "syncSetting": "Synchronisations-Einstellung", "cloudSettings": "Cloud Einstellungen", - "enableSync": "Sync aktivieren", + "enableSync": "Synchronisation aktivieren", + "enableSyncLog": "Synchronisation der Protokolldateien aktivieren", + "enableSyncLogWarning": "Vielen Dank für Ihre Hilfe bei der Diagnose von Synchronisierungsproblemen. Dadurch werden Ihre Dokumentänderungen in einer lokalen Datei protokolliert. Bitte beenden Sie die App und öffnen Sie sie erneut, nachdem Sie sie aktiviert haben.", "enableEncrypt": "Daten verschlüsseln", "cloudURL": "Basis URL", "invalidCloudURLScheme": "Ungültiges Format", "cloudServerType": "Cloud Server", "cloudServerTypeTip": "Bitte beachte, dass der aktuelle Benutzer ausgeloggt wird beim wechsel des Cloud-Servers", "cloudLocal": "Lokal", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "Supabase URL", - "cloudSupabaseUrlCanNotBeEmpty": "Die Supabase-URL darf nicht leer sein", - "cloudSupabaseAnonKey": "Supabase anonymer Schlüssel", - "cloudSupabaseAnonKeyCanNotBeEmpty": "Der anonyme Schlüssel darf nicht leer sein", - "cloudAppFlowy": "AppFlowy Cloud [BETA]", - "cloudAppFlowySelfHost": "AppFlowy Cloud Self-hosted", + "cloudAppFlowy": "@:appName Cloud Beta", + "cloudAppFlowySelfHost": "@:appName Cloud Self-hosted", "appFlowyCloudUrlCanNotBeEmpty": "Die Cloud-URL darf nicht leer sein", "clickToCopy": "Klicken, um zu kopieren", - "selfHostStart": "Falls du keinen Server hast, nehme lieber folgende", + "selfHostStart": "Falls du keinen Server hast, konsultiere bitte", "selfHostContent": "Dokument", - "selfHostEnd": "für Hilfe, um einen einen eigenen Server aufzusetzen", + "selfHostEnd": "um einen einen eigenen Server aufzusetzen", + "pleaseInputValidURL": "Bitte geben Sie eine gültige URL ein", + "changeUrl": "Ändern Sie die selbst gehostete URL in {}", "cloudURLHint": "Eingabe der Basis- URL Ihres Servers", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "Eingbe der Websocket Adresse Ihres Servers", "restartApp": "Neustart", - "restartAppTip": "Programm neustarten, um die Änderungen zu übernehmen. Bitte bachten, dass der aktuelle Account eventuell ausgeloggt wird.", + "restartAppTip": "Programm neustarten, um die Änderungen zu übernehmen. Bitte beachten, dass der aktuelle Account eventuell ausgeloggt wird.", "changeServerTip": "Nach dem Wechsel des Servers muss auf die Schaltfläche „Neustart“ geklickt werden, damit die Änderungen wirksam werden", "enableEncryptPrompt": "Verschlüsselung aktivieren, um deine Daten mit dem Secret Key zu verschlüsseln. Verwahre den Schlüssel sicher! \nEinmal aktiviert kann es nicht mehr rückgängig gemacht werden.\nFalls der Schlüssel verloren geht sind die Daten unwiderbringlich verloren.\nKlicken, um zu kopieren.", "inputEncryptPrompt": "Bitte den Encryption Secret Code eingeben", @@ -400,18 +1112,62 @@ "historicalUserList": "Anmeldeverlauf", "historicalUserListTooltip": "Diese Liste zeigt deine anonymen Accounts. Du kannst einen Account anklicken, um mehr Informationen zu sehen.\nAnonyme Accounts werden über den 'Erste Schritte' Button erstellt.", "openHistoricalUser": "Klicken, um einen anonymen Account zu öffnen", - "customPathPrompt": "Den AppFlowy Daten-Ordner in einem mit der Cloud synchronisierten Ordner (z.B. Google Drive) zu speichern, könnte Risiken bergen. Falls die Datenbank innerhalb dieses Ordners gleichzeitig von mehreren Orten zugegriffen oder verändert wird könnte es zu Synchronisationskonflikten und potentiellen Daten-Beschädigung führen", - "importAppFlowyData": "Daten von einem externen AppFlowy Ordner importieren.", + "customPathPrompt": "Den @:appName Daten-Ordner in einem mit der Cloud synchronisierten Ordner (z.B. Google Drive) zu speichern, könnte Risiken bergen. Falls die Datenbank innerhalb dieses Ordners gleichzeitig von mehreren Orten zugegriffen oder verändert wird könnte dies zu Synchronisationskonflikten und potentiellen Daten-Beschädigungen führen", + "importAppFlowyData": "Daten von einem externen @:appName Ordner importieren.", "importingAppFlowyDataTip": "Der Datenimport läuft. Bitte die App nicht schließen oder in den Hintergrund setzten", - "importAppFlowyDataDescription": "Daten von einem externen AppFlowy Ordner kopieren und in den aktuellen AppFlowy Datenordner importieren.", - "importSuccess": "Der AppFlowy Dateienordner wurde erfolgreich importiert", - "importFailed": "Der AppFlowy Dateienordner-Import ist fehlgeschlagen", + "importAppFlowyDataDescription": "Daten von einem externen @:appName Ordner kopieren und in den aktuellen @:appName Datenordner importieren.", + "importSuccess": "Der @:appName Dateienordner wurde erfolgreich importiert", + "importFailed": "Der @:appName Dateienordner-Import ist fehlgeschlagen", "importGuide": "Für weitere Details, bitte das verlinkte Dokument prüfen" }, "notifications": { "enableNotifications": { "label": "Benachrichtigungen aktivieren", "hint": "Wenn diese Funktion ausgeschaltet ist, werden keine lokalen Benachrichtigungen mehr angezeigt." + }, + "showNotificationsIcon": { + "label": "Benachrichtigungssymbol anzeigen", + "hint": "Deaktiviere diese Option, um das Benachrichtigungssymbol in der Seitenleiste auszublenden." + }, + "archiveNotifications": { + "allSuccess": "Alle Benachrichtigungen erfolgreich archiviert", + "success": "Benachrichtigung erfolgreich archiviert" + }, + "markAsReadNotifications": { + "allSuccess": "Alle erfolgreich als gelesen markiert", + "success": "Erfolgreich als gelesen markiert" + }, + "action": { + "markAsRead": "Als gelesen markieren", + "multipleChoice": "Mehrfachauswahl", + "archive": "Archiv" + }, + "settings": { + "settings": "Einstellungen", + "markAllAsRead": "Alles als gelesen markieren", + "archiveAll": "Alles archivieren" + }, + "emptyInbox": { + "title": "Noch keine Benachrichtigungen", + "description": "Du wirst hier über @Erwähnungen benachrichtigt" + }, + "emptyUnread": { + "title": "Keine ungelesenen Benachrichtigungen", + "description": "Du bist auf dem Laufenden!" + }, + "emptyArchived": { + "title": "Keine archivierten Benachrichtigungen", + "description": "Du hast noch keine Benachrichtigungen archiviert" + }, + "tabs": { + "inbox": "Posteingang", + "unread": "Ungelesen", + "archived": "Archiviert" + }, + "refreshSuccess": "Benachrichtigungen erfolgreich aktualisiert", + "titles": { + "notifications": "Benachrichtigungen", + "reminder": "Erinnerung" } }, "appearance": { @@ -429,8 +1185,13 @@ }, "fontScaleFactor": "Schriftgröße", "documentSettings": { - "cursorColor": "Dokument Cursor-Farbe", - "selectionColor": "Dokument Auswahl-Farbe", + "cursorColor": "Cursor-Farbe", + "selectionColor": "Auswahl-Farbe", + "width": "Dokumentbreite", + "changeWidth": "Ändern", + "pickColor": "Wähle eine Farbe", + "colorShade": "Farbschattierung", + "opacity": "Opazität", "hexEmptyError": "Hex-Farbe darf nicht leer sein", "hexLengthError": "Hex-Wert muss 6 Zeichen lang sein", "hexInvalidError": "Ungültiger Hex-Wert", @@ -457,13 +1218,13 @@ "themeUpload": { "button": "Hochladen", "uploadTheme": "Theme hochladen", - "description": "Lade eigenes AppFlowy-Theme über die Schaltfläche unten hoch.", + "description": "Lade eigenes @:appName-Theme über die untere Schaltfläche hoch.", "loading": "Bitte warte einen Moment . . .\nWir validieren gerade dein Theme und laden es hoch.", "uploadSuccess": "Das Theme wurde erfolgreich hochgeladen", "deletionFailure": "Das Theme konnte nicht gelöscht werden. Versuche, es manuell zu löschen.", "filePickerDialogTitle": "Wähle eine .flowy_plugin-Datei", "urlUploadFailure": "URL konnte nicht geöffnet werden: {}", - "failure": "Das hochgeladene Theme hatte ein ungültiges Format." + "failure": "Das hochgeladene Theme hat ein ungültiges Format." }, "theme": "Theme", "builtInsLabel": "Integrierte Theme", @@ -473,7 +1234,7 @@ "local": "Lokal", "us": "US", "iso": "ISO", - "friendly": "Freundlich", + "friendly": "Leserlich", "dmy": "TT/MM/JJJJ" }, "timeFormat": { @@ -482,16 +1243,19 @@ "twentyFourHour": "24 Stunden" }, "showNamingDialogWhenCreatingPage": "Zeige Bennenungsfenster, wenn eine neue Seite erstellt wird", - "enableRTLToolbarItems": "Aktivieren Sie RTL-Symbolleiste", + "enableRTLToolbarItems": "RTL-Symbolleistenelemente aktivieren", "members": { "title": "Mitglieder-Einstellungen", "inviteMembers": "Mitglieder einladen", + "inviteHint": "Per E-Mail einladen", "sendInvite": "Einladung senden", "copyInviteLink": "Kopiere Einladungslink", "label": "Mitglieder", "user": "Nutzer", "role": "Rolle", - "removeFromWorkspace": "Vom Workspace entfernen", + "removeFromWorkspace": "Vom Arbeitsbereich entfernen", + "removeFromWorkspaceSuccess": "Erfolgreich aus dem Arbeitsbereich entfernt", + "removeFromWorkspaceFailed": "Entfernen aus Arbeitsbereich fehlgeschlagen", "owner": "Besitzer", "guest": "Gast", "member": "Mitglied", @@ -505,23 +1269,31 @@ "one": "{} Mitglied", "other": "{} Mitglieder" }, + "inviteFailedDialogTitle": "Einladung konnte nicht gesendet werden", + "inviteFailedMemberLimit": "Das Mitgliederlimit wurde erreicht. Bitte führe ein Upgrade durch, um weitere Mitglieder einzuladen.", + "inviteFailedMemberLimitMobile": "Ihr Arbeitsbereich hat das Mitgliederlimit erreicht.", "memberLimitExceeded": "Du hast die maximal zulässige Mitgliederzahl für dein Benutzerkonto erreicht. Benötigst du weitere Mitglieder, um deine Arbeit fortsetzen zu können, erstelle auf Github bitte eine entsprechende Anfrage.", + "memberLimitExceededUpgrade": "upgrade", + "memberLimitExceededPro": "Mitgliederlimit erreicht. Wenn du mehr Mitglieder benötigst, kontaktiere", + "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Mitglied konnte nicht hinzugefügt werden!", "addMemberSuccess": "Mitglied erfolgreich hinzugefügt", "removeMember": "Mitglied entfernen", - "areYouSureToRemoveMember": "Möchten Sie dieses Mitglied wirklich entfernen?", + "areYouSureToRemoveMember": "Möchtest du dieses Mitglied wirklich entfernen?", "inviteMemberSuccess": "Die Einladung wurde erfolgreich versendet", - "failedToInviteMember": "Das Einladen des Mitglieds ist fehlgeschlagen" + "failedToInviteMember": "Das Einladen des Mitglieds ist fehlgeschlagen", + "workspaceMembersError": "Hoppla, da ist etwas schiefgelaufen", + "workspaceMembersErrorDescription": "Wir konnten die Mitgliederliste derzeit nicht laden. Bitte versuchen Sie es später noch einmal." } }, "files": { "copy": "Kopieren", - "defaultLocation": "Dateien und Speicherort", + "defaultLocation": "@:appName Datenverzeichnis", "exportData": "Daten exportieren", "doubleTapToCopy": "Zweimal tippen, um den Pfad zu kopieren", - "restoreLocation": "AppFlowy-Standardpfad wiederherstellen", + "restoreLocation": "@:appName-Standardpfad wiederherstellen", "customizeLocation": "Einen anderen Ordner öffnen", - "restartApp": "Bitte starten Sie die App neu, damit die Änderungen wirksam werden.", + "restartApp": "Bitte starte die App neu, damit die Änderungen wirksam werden.", "exportDatabase": "Datenbank exportieren", "selectFiles": "Dateien auswählen, die exportiert werden sollen", "selectAll": "Alle auswählen", @@ -531,10 +1303,10 @@ "defineWhereYourDataIsStored": "Wo sind die Daten gespeichert?", "open": "Offen", "openFolder": "Einen vorhandenen Ordner öffnen", - "openFolderDesc": "Öffnen und speichern im vorhandenen AppFlowy-Ordner", + "openFolderDesc": "Öffnen und speichern im vorhandenen @:appName-Ordner", "folderHintText": "Ordnernamen", "location": "Ein neuen Ordner erstellen", - "locationDesc": "Einen Namen für den AppFlowy-Datenordner festlegen", + "locationDesc": "Einen Namen für den @:appName Datenordner festlegen", "browser": "Durchsuchen", "create": "Erstellen", "set": "Festlegen", @@ -545,7 +1317,7 @@ "change": "Ändern", "openLocationTooltips": "Win anderes Datenverzeichnis öffnen", "openCurrentDataFolder": "Aktuelles Datenverzeichnis öffnen", - "recoverLocationTooltips": "Zurücksetzen auf das Standarddatenverzeichnis von AppFlowy", + "recoverLocationTooltips": "Zurücksetzen auf das Standarddatenverzeichnis von @:appName", "exportFileSuccess": "Datei erfolgreich exportiert!", "exportFileFail": "Datei-Export fehlgeschlagen!", "export": "Export", @@ -559,9 +1331,26 @@ "email": "E-Mail", "tooltipSelectIcon": "Symbol auswählen", "selectAnIcon": "Ein Symbol auswählen", - "pleaseInputYourOpenAIKey": "Bitte gebe den OpenAI-Schlüssel ein", - "pleaseInputYourStabilityAIKey": "Bitte gebe den Stability AI Schlüssel ein", - "clickToLogout": "Klicken, um den aktuellen Nutzer auszulogen" + "pleaseInputYourOpenAIKey": "Bitte gebe den AI-Schlüssel ein", + "clickToLogout": "Klicken, um den aktuellen Nutzer auszulogen", + "pleaseInputYourStabilityAIKey": "Bitte gebe den Stability AI Schlüssel ein" + }, + "mobile": { + "personalInfo": "Persönliche Informationen", + "username": "Nutzername", + "usernameEmptyError": "Der Nutzername darf nicht leer sein", + "about": "Über", + "pushNotifications": "Push Benachrichtigungen", + "support": "Support", + "joinDiscord": "Komm zu uns auf Discord", + "privacyPolicy": "Datenschutz", + "userAgreement": "Nutzungsbedingungen", + "termsAndConditions": "Geschäftsbedingungen", + "userprofileError": "Das Nutzerprofil konnte nicht geladen werden", + "userprofileErrorDescription": "Bitte abmelden und wieder anmelden, um zu prüfen ob das Problem weiterhin bestehen bleibt.", + "selectLayout": "Layout auswählen", + "selectStartingDay": "Ersten Tag auswählen", + "version": "Version" }, "shortcuts": { "shortcutsLabel": "Tastenkürzel", @@ -584,27 +1373,10 @@ "textAlignCenter": "Text zentriert ausrichten", "textAlignRight": "Text rechtsbündig ausrichten" } - }, - "mobile": { - "personalInfo": "Persönliche Informationen", - "username": "Nutzername", - "usernameEmptyError": "Der Nutzername darf nicht leer sein", - "about": "Über", - "pushNotifications": "Push Benachrichtigungen", - "support": "Support", - "joinDiscord": "Komm zu uns auf Discord", - "privacyPolicy": "Datenschutz", - "userAgreement": "Nutzungsbedingungen", - "termsAndConditions": "Geschäftsbedingungen", - "userprofileError": "Das Nutzerprofil konnte nicht geladen werden", - "userprofileErrorDescription": "Bitte abmelden und wieder anmelden, um zu prüfen ob das Problem weiterhin bestehen bleibt.", - "selectLayout": "Layout auswählen", - "selectStartingDay": "Ersten Tag auswählen", - "version": "Version" } }, "grid": { - "deleteView": "Möchten Sie diese Ansicht wirklich löschen?", + "deleteView": "Möchtest du diese Ansicht wirklich löschen?", "createView": "Neu", "title": { "placeholder": "Unbenannt" @@ -622,7 +1394,11 @@ "typeAValue": "Einen Wert eingeben...", "layout": "Layout", "databaseLayout": "Layout", - "viewList": "Datenbank-Ansichten", + "viewList": { + "zero": "0 Aufrufe", + "one": "{count} Aufruf", + "other": "{count} Aufrufe" + }, "editView": "Ansicht editieren", "boardSettings": "Board-Einstellungen", "calendarSettings": "Kalender-Einstellungen", @@ -631,6 +1407,13 @@ "deleteView": "Anslicht löschen", "numberOfVisibleFields": "{} angezeigt" }, + "filter": { + "empty": "Keine aktiven Filter", + "addFilter": "Filter hinzufügen", + "cannotFindCreatableField": "Es wurde kein geeignetes Feld zum Filtern gefunden.", + "conditon": "Bedingung", + "where": "Wo" + }, "textFilter": { "contains": "Enthält", "doesNotContain": "Beinhaltet nicht", @@ -676,9 +1459,12 @@ "between": "Ist zwischen", "empty": "Ist leer", "notEmpty": "Ist nicht leer", + "startDate": "Startdatum", + "endDate": "Enddatum", "choicechipPrefix": { "before": "Vorher", "after": "Danach", + "between": "Zwischen", "onOrBefore": "Am oder davor", "onOrAfter": "Während oder danach", "isEmpty": "leer", @@ -696,6 +1482,7 @@ "isNotEmpty": "nicht leer" }, "field": { + "label": "Eigenschaft", "hide": "Verstecken", "show": "Anzeigen", "insertLeft": "Links einfügen", @@ -704,17 +1491,23 @@ "delete": "Löschen", "wrapCellContent": "Zeilenumbruch", "clear": " Zelleninhalte löschen", + "switchPrimaryFieldTooltip": "Feldtyp des Primärfelds kann nicht geändert werden", "textFieldName": "Text", "checkboxFieldName": "Kontrollkästchen", "dateFieldName": "Datum", "updatedAtFieldName": "Letzte Änderungszeit", - "createdAtFieldName": "Zeit geschaffen", + "createdAtFieldName": "Erstellungsdatum", "numberFieldName": "Zahlen", "singleSelectFieldName": "Wählen", "multiSelectFieldName": "Mehrfachauswahl", "urlFieldName": "URL", "checklistFieldName": "Checkliste", "relationFieldName": "Beziehung", + "summaryFieldName": "KI-Zusammenfassung", + "timeFieldName": "Zeit", + "mediaFieldName": "Dateien und Medien", + "translateFieldName": "AI-Übersetzen", + "translateTo": "Übersetzen in", "numberFormat": "Zahlenformat", "dateFormat": "Datumsformat", "includeTime": "Zeitangabe", @@ -743,6 +1536,7 @@ "addOption": "Option hinzufügen", "editProperty": "Eigenschaft bearbeiten", "newProperty": "Neue Eigenschaft", + "openRowDocument": "Als Seite öffnen", "deleteFieldPromptMessage": "Sicher? Diese Eigenschaft wird gelöscht", "clearFieldPromptMessage": "Bist du dir sicher? Alle Zelleninhalte in dieser Spalte werden gelöscht!", "newColumn": "Neue Spalte", @@ -762,7 +1556,9 @@ "one": "Blende {count} verstecktes Feld aus", "many": "Blende {count} versteckte Felder aus", "other": "Blende {count} versteckte Felder aus" - } + }, + "openAsFullPage": "Als ganze Seite öffnen", + "moreRowActions": "Weitere Zeilenaktionen" }, "sort": { "ascending": "Aufsteigend", @@ -772,10 +1568,11 @@ "cannotFindCreatableField": "Es konnte kein geeignetes Feld zum Sortieren gefunden werden", "deleteAllSorts": "Alle Sortierungen entfernen", "addSort": "Sortierung hinzufügen", - "removeSorting": "Möchten Sie die Sortierung entfernen?", - "fieldInUse": "Sie sortieren bereits nach diesem Feld" + "removeSorting": "Möchtest du die Sortierung entfernen?", + "fieldInUse": "Du sortierst bereits nach diesem Feld" }, "row": { + "label": "Reihe", "duplicate": "Duplikat", "delete": "Löschen", "titlePlaceholder": "Unbenannt", @@ -783,12 +1580,19 @@ "copyProperty": "Eigenschaft in die Zwischenablage kopiert", "count": "Zählen", "newRow": "Neue Zeile", + "loadMore": "Mehr laden", "action": "Aktion", "add": "Klicken, um unten hinzuzufügen", "drag": "Ziehen, um zu verschieben", + "deleteRowPrompt": "Bist du sicher, dass du diese Zeile löschen willst? Diese Aktion kann nicht rückgängig gemacht werden", + "deleteCardPrompt": "Bist du sicher, dass du diese Karte löschen willst? Diese Aktion kann nicht rückgängig gemacht werden", "dragAndClick": "Ziehen, um zu verschieben. Klicke, um das Menü zu öffnen", "insertRecordAbove": "Füge Datensatz oben ein", - "insertRecordBelow": "Füge Datensatz unten ein" + "insertRecordBelow": "Füge Datensatz unten ein", + "noContent": "Kein Inhalt", + "reorderRowDescription": "Zeile neu anordnen", + "createRowAboveDescription": "Erstelle oben eine Zeile", + "createRowBelowDescription": "Unten eine Zeile einfügen" }, "selectOption": { "create": "Erstellen", @@ -812,8 +1616,8 @@ "tagName": "Tag-Name" }, "checklist": { - "taskHint": "Aufgbenbeschreibbung", - "addNew": "Fügen Sie einen Artikel hinzu", + "taskHint": "Aufgabenbeschreibung", + "addNew": "Füge eine Aufgabe hinzu", "submitNewTask": "Erstellen", "hideComplete": "Blende abgeschlossene Aufgaben aus", "showComplete": "Zeige alle Aufgaben" @@ -821,8 +1625,7 @@ "url": { "launch": "Im Browser öffnen", "copy": "Webadresse kopieren", - "textFieldHint": "Geben Sie eine URL ein", - "copiedNotification": "In die Zwischenablage kopiert!" + "textFieldHint": "Gebe eine URL ein" }, "relation": { "relatedDatabasePlaceLabel": "Verwandte Datenbank", @@ -834,7 +1637,7 @@ "linkedRowListLabel": "{count} verknüpfte Zeilen", "unlinkedRowListLabel": "Eine weitere Zeile verknüpfen" }, - "menuName": "Raster", + "menuName": "Datentabelle", "referencedGridPrefix": "Sicht von", "calculate": "berechnet", "calculationTypeLabel": { @@ -849,6 +1652,24 @@ "countEmptyShort": "leer", "countNonEmpty": "Zahl nicht leer", "countNonEmptyShort": "nicht leer" + }, + "media": { + "rename": "Umbenennen", + "download": "Herunterladen", + "expand": "Erweitern", + "delete": "Löschen", + "moreFilesHint": "+{}", + "addFileOrImage": "Datei oder Link hinzufügen", + "attachmentsHint": "{}", + "addFileMobile": "Datei hinzufügen", + "extraCount": "+{}", + "deleteFileDescription": "Möchten Sie diese Datei wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "showFileNames": "Dateinamen anzeigen", + "downloadSuccess": "Datei heruntergeladen", + "downloadFailedToken": "Datei konnte nicht heruntergeladen werden, Benutzertoken nicht verfügbar", + "setAsCover": "Als Cover festlegen", + "openInBrowser": "Im Browser öffnen", + "embedLink": "Dateilink einbetten" } }, "document": { @@ -863,15 +1684,55 @@ "createANewBoard": "Ein neues Board erstellen" }, "grid": { - "selectAGridToLinkTo": "Ein Raster zum Verknüpfen auswählen", - "createANewGrid": "Ein neues Raster erstellen" + "selectAGridToLinkTo": "Eine Datentabelle zum Verknüpfen auswählen", + "createANewGrid": "Eine neue Datentabelle erstellen" }, "calendar": { "selectACalendarToLinkTo": "Einen Kalender zum Verknüpfen auswählen", "createANewCalendar": "Einen neuen Kalender erstellen" }, "document": { - "selectADocumentToLinkTo": "Ein Raster zum Verknüpfen auswählen" + "selectADocumentToLinkTo": "Eine Datentabelle zum Verknüpfen auswählen" + }, + "name": { + "text": "Text", + "heading1": "Überschrift 1", + "heading2": "Überschrift 2", + "heading3": "Überschrift 3", + "image": "Bild", + "bulletedList": "Aufzählungsliste", + "numberedList": "Nummerierte Liste", + "todoList": "Aufgabenliste", + "doc": "Dokument", + "linkedDoc": "Link zur Seite", + "grid": "Raster", + "linkedGrid": "Verknüpftes Raster", + "kanban": "Kanban", + "linkedKanban": "Verknüpftes Kanban", + "calendar": "Kalender", + "linkedCalendar": "Verknüpfter Kalender", + "quote": "Zitat", + "divider": "Trenner", + "table": "Tabelle", + "outline": "Gliederung", + "mathEquation": "Mathematische Gleichung", + "code": "Code", + "toggleList": "Liste ein-/ausblenden", + "emoji": "Emoji", + "aiWriter": "KI-Autor", + "dateOrReminder": "Datum oder Erinnerung", + "photoGallery": "Fotogalerie", + "file": "Datei" + }, + "subPage": { + "name": "Dokument", + "keyword1": "Unterseite", + "keyword2": "Seite", + "keyword3": "untergeordnete Seite", + "keyword4": "Seite einfügen", + "keyword6": "neue Seite", + "keyword7": "Seite erstellen", + "keyword8": "Dokument" } }, "selectionMenu": { @@ -880,27 +1741,28 @@ }, "plugins": { "referencedBoard": "Referenziertes Board", - "referencedGrid": "Referenziertes Raster", + "referencedGrid": "Referenzierte Datentabelle", "referencedCalendar": "Referenzierter Kalender", "referencedDocument": "Referenziertes Dokument", - "autoGeneratorMenuItemName": "OpenAI-Autor", - "autoGeneratorTitleName": "OpenAI: Die KI bitten, etwas zu schreiben ...", + "autoGeneratorMenuItemName": "AI-Autor", + "autoGeneratorTitleName": "AI: Die KI bitten, etwas zu schreiben ...", "autoGeneratorLearnMore": "Mehr erfahren", "autoGeneratorGenerate": "Erstellen", - "autoGeneratorHintText": "OpenAI fragen ...", - "autoGeneratorCantGetOpenAIKey": "Der OpenAI-Schlüssel kann nicht abgerufen werden", + "autoGeneratorHintText": "AI fragen ...", + "autoGeneratorCantGetOpenAIKey": "Der AI-Schlüssel kann nicht abgerufen werden", "autoGeneratorRewrite": "Umschreiben", "smartEdit": "KI-Assistenten", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Korrigiere Rechtschreibung", "warning": "⚠️ KI-Antworten können ungenau oder irreführend sein.", "smartEditSummarize": "Zusammenfassen", "smartEditImproveWriting": "Das Geschriebene verbessern", "smartEditMakeLonger": "Länger machen", - "smartEditCouldNotFetchResult": "Das Ergebnis konnte nicht von OpenAI abgerufen werden", - "smartEditCouldNotFetchKey": "Der OpenAI-Schlüssel konnte nicht abgerufen werden", - "smartEditDisabled": "OpenAI in den Einstellungen verbinden", - "discardResponse": "Möchten Sie die KI-Antworten verwerfen?", + "smartEditCouldNotFetchResult": "Das Ergebnis konnte nicht von AI abgerufen werden", + "smartEditCouldNotFetchKey": "Der AI-Schlüssel konnte nicht abgerufen werden", + "smartEditDisabled": "AI in den Einstellungen verbinden", + "appflowyAIEditDisabled": "Melde dich an, um KI-Funktionen zu aktivieren", + "discardResponse": "Möchtest du die KI-Antworten verwerfen?", "createInlineMathEquation": "Formel erstellen", "fonts": "Schriftarten", "insertDate": "Datum einfügen", @@ -911,6 +1773,35 @@ "bulletedList": "Stichpunktliste", "todoList": "Offene Aufgaben Liste", "callout": "Hervorhebung", + "simpleTable": { + "moreActions": { + "color": "Farbe", + "align": "Ausrichten", + "delete": "Löschen", + "duplicate": "duplizieren", + "insertLeft": "Links einfügen", + "insertRight": "Rechts einfügen", + "insertAbove": "Oben einfügen", + "insertBelow": "Unten einfügen", + "headerColumn": "Kopfspalte", + "headerRow": "Kopfzeile", + "clearContents": "Klarer Inhalt", + "setToPageWidth": "Auf Seitenbreite einstellen", + "distributeColumnsWidth": "Spalten gleichmäßig verteilen", + "duplicateRow": "Zeile duplizieren", + "duplicateColumn": "Spalte duplizieren", + "textColor": "Textfarbe", + "cellBackgroundColor": "Zellen-Hintergrundfarbe", + "duplicateTable": "Tabelle duplizieren" + }, + "clickToAddNewRow": "Klicken Sie hier, um eine neue Zeile hinzuzufügen", + "clickToAddNewColumn": "Klicken Sie hier, um eine neue Spalte hinzuzufügen", + "clickToAddNewRowAndColumn": "Klicken Sie hier, um eine neue Zeile und Spalte hinzuzufügen", + "headerName": { + "table": "Tabelle", + "alignText": "Text ausrichten" + } + }, "cover": { "changeCover": "Titelbild wechseln", "colors": "Farben", @@ -926,6 +1817,7 @@ "back": "Zurück", "saveToGallery": "In der Galerie speichern", "removeIcon": "Symbol entfernen", + "removeCover": "Cover entfernen", "pasteImageUrl": "Bild-URL einfügen", "or": "ODER", "pickFromFiles": "Dateien auswählen", @@ -934,7 +1826,7 @@ "addIcon": "Symbol hinzufügen", "changeIcon": "Symbol wechseln", "coverRemoveAlert": "Nach dem Löschen wird es aus dem Titelbild entfernt.", - "alertDialogConfirmation": "Sicher, dass Sie weitermachen wollen?" + "alertDialogConfirmation": "Sicher, dass du weitermachen willst?" }, "mathEquation": { "name": "Mathematische Formel", @@ -944,9 +1836,11 @@ "optionAction": { "click": "Klicken", "toOpenMenu": " um das Menü zu öffnen", + "drag": "Ziehen", + "toMove": " bewegen", "delete": "Löschen", "duplicate": "Duplikat", - "turnInto": "Einbiegen in", + "turnInto": "Umwandeln in", "moveUp": "Nach oben verschieben", "moveDown": "Nach unten verschieben", "color": "Farbe", @@ -955,20 +1849,42 @@ "center": "Zentriert", "right": "Rechts", "defaultColor": "Standard", - "depth": "Tiefe" + "depth": "Tiefe", + "copyLinkToBlock": "Link zum Block kopieren" }, "image": { - "copiedToPasteBoard": "Der Bildlink wurde in die Zwischenablage kopiert", "addAnImage": "Ein Bild hinzufügen", + "copiedToPasteBoard": "Der Bildlink wurde in die Zwischenablage kopiert", + "addAnImageDesktop": "Bild(er) hier ablegen oder klicken, um Bild(er) hinzuzufügen", + "addAnImageMobile": "Klicke, um ein oder mehrere Bilder hinzuzufügen", + "dropImageToInsert": "Zum Einfügen Bilder hier ablegen", "imageUploadFailed": "Bild hochladen gescheitert", + "imageDownloadFailed": "Das Hochladen des Bilds ist fehlgeschlagen. Bitte versuche es erneut.", + "imageDownloadFailedToken": "Das Hochladen des Bilds ist aufgrund eines fehlenden Benutzertokens fehlgeschlagen. Bitte versuche es erneut.", "errorCode": "Fehlercode" }, + "photoGallery": { + "name": "Fotogallerie", + "imageKeyword": "Bild", + "imageGalleryKeyword": "Bildergalerie", + "photoKeyword": "Foto", + "photoBrowserKeyword": "Fotobrowser", + "galleryKeyword": "Galerie", + "addImageTooltip": "Bild hinzufügen", + "changeLayoutTooltip": "Layout ändern", + "browserLayout": "Browser", + "gridLayout": "Datentabelle", + "deleteBlockTooltip": "Ganze Galerie löschen" + }, + "math": { + "copiedToPasteBoard": "Die mathematische Gleichung wurde in die Zwischenablage kopiert" + }, "urlPreview": { "copiedToPasteBoard": "Der Link wurde in die Zwischenablage kopiert", "convertToLink": "Konvertieren zum eingebetteten Link" }, "outline": { - "addHeadingToCreateOutline": "Fügen Sie Überschriften hinzu, um ein Inhaltsverzeichnis zu erstellen.", + "addHeadingToCreateOutline": "Füge Überschriften hinzu, um ein Inhaltsverzeichnis zu erstellen.", "noMatchHeadings": "Keine passenden Überschriften gefunden." }, "table": { @@ -982,7 +1898,8 @@ "contextMenu": { "copy": "Kopieren", "cut": "Ausschneiden", - "paste": "Einfügen" + "paste": "Einfügen", + "pasteAsPlainText": "Als einfachen Text einfügen" }, "action": "Aktionen", "database": { @@ -993,13 +1910,56 @@ "newDatabase": "Neue Datenbank", "linkToDatabase": "Verknüpfung zur Datenbank" }, - "date": "Datum" + "date": "Datum", + "video": { + "label": "Video", + "emptyLabel": "Video hinzufügen", + "placeholder": "Videolink einfügen", + "copiedToPasteBoard": "Der Videolink wurde in die Zwischenablage kopiert", + "insertVideo": "Video hinzufügen", + "invalidVideoUrl": "Die Quell-URL wird noch nicht unterstützt.", + "invalidVideoUrlYouTube": "YouTube wird noch nicht unterstützt.", + "supportedFormats": "Unterstützte Formate: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" + }, + "file": { + "name": "Datei", + "uploadTab": "Hochladen", + "uploadMobile": "Wählen Sie eine Datei", + "uploadMobileGallery": "Aus der Fotogalerie", + "networkTab": "Link integrieren", + "placeholderText": "Klicke oder ziehe eine Datei hoch, um sie hochzuladen", + "placeholderDragging": "Lege die hochzuladende Datei hier ab", + "dropFileToUpload": "Lege die hochzuladende Datei hier ab", + "fileUploadHint": "Lege die hochzuladende Datei hier ab\noder klicke, um eine Datei auszuwählen.", + "fileUploadHintSuffix": "Durchsuchen", + "networkHint": "Gebe einen Link zu einer Datei ein", + "networkUrlInvalid": "Ungültige URL. Bitte korrigiere die URL und versuche es erneut.", + "networkAction": "Dateilink einbetten", + "fileTooBigError": "Die Dateigröße ist zu groß. Bitte lade eine Datei mit einer Größe von weniger als 10 MB hoch.", + "renameFile": { + "title": "Datei umbenennen", + "description": "Gebe den neuen Namen für diese Datei ein", + "nameEmptyError": "Der Dateiname darf nicht leer bleiben." + }, + "uploadedAt": "Hochgeladen am {}", + "linkedAt": "Link hinzugefügt am {}", + "failedToOpenMsg": "Öffnen fehlgeschlagen, Datei nicht gefunden" + }, + "subPage": { + "errors": { + "failedDeletePage": "Seite konnte nicht gelöscht werden", + "failedCreatePage": "Seite konnte nicht erstellt werden", + "failedMovePage": "Seite konnte nicht in dieses Dokument verschoben werden", + "failedDuplicatePage": "Seite konnte nicht dupliziert werden", + "failedDuplicateFindView": "Seite konnte nicht dupliziert werden - Originalansicht nicht gefunden" + } + } }, "outlineBlock": { "placeholder": "Inhaltsverzeichnis" }, "textBlock": { - "placeholder": "Geben Sie „/“ für Inhaltsblöcke ein" + "placeholder": "Gebe „/“ für Inhaltsblöcke ein" }, "title": { "placeholder": "Ohne Titel" @@ -1015,8 +1975,8 @@ "placeholder": "Bild-URL eingeben" }, "ai": { - "label": "Bild mit OpenAI erstellen", - "placeholder": "Bitte den Prompt für OpenAI eingeben, um ein Bild zu erstellen" + "label": "Bild mit AI erstellen", + "placeholder": "Bitte den Prompt für AI eingeben, um ein Bild zu erstellen" }, "stability_ai": { "label": "Bild mit Stability AI erstellen", @@ -1028,7 +1988,8 @@ "invalidImageSize": "Die Bildgröße muss kleiner als 5 MB sein", "invalidImageFormat": "Das Bildformat wird nicht unterstützt. Unterstützte Formate: JPEG, PNG, GIF, SVG", "invalidImageUrl": "Ungültige Bild-URL", - "noImage": "Keine Datei oder Verzeichnis" + "noImage": "Keine Datei oder Verzeichnis", + "multipleImagesFailed": "Ein oder mehrere Bilder konnten nicht hochgeladen werden. Bitte versuche es erneut." }, "embedLink": { "label": "Eingebetteter Link", @@ -1038,15 +1999,30 @@ "label": "Unsplash" }, "searchForAnImage": "Nach einem Bild suchen", - "pleaseInputYourOpenAIKey": "biitte den OpenAI Schlüssel in der Einstellungsseite eingeben", - "pleaseInputYourStabilityAIKey": "biitte den Stability AI Schlüssel in der Einstellungsseite eingeben", + "pleaseInputYourOpenAIKey": "biitte den AI Schlüssel in der Einstellungsseite eingeben", "saveImageToGallery": "Bild speichern", "failedToAddImageToGallery": "Das Bild konnte nicht zur Galerie hinzugefügt werden", "successToAddImageToGallery": "Das Bild wurde zur Galerie hinzugefügt werden", "unableToLoadImage": "Das Bild konnte nicht geladen werden", "maximumImageSize": "Die maximal unterstützte Upload-Bildgröße beträgt 10 MB", "uploadImageErrorImageSizeTooBig": "Die Bildgröße muss weniger als 10 MB betragen", - "imageIsUploading": "Bild wird hochgeladen" + "imageIsUploading": "Bild wird hochgeladen", + "openFullScreen": "Im Vollbildmodus öffnen", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "Vorheriges Bild", + "nextImageTooltip": "Nächstes Bild", + "zoomOutTooltip": "Rauszoomen", + "zoomInTooltip": "Hineinzoomen", + "changeZoomLevelTooltip": "Zoomstufe ändern", + "openLocalImage": "Bild öffnen", + "downloadImage": "Bild herunterladen", + "closeViewer": "Interaktive Anzeige schließen", + "scalePercentage": "{}%", + "deleteImageTooltip": "Bild löschen" + } + }, + "pleaseInputYourStabilityAIKey": "biitte den Stability AI Schlüssel in der Einstellungsseite eingeben" }, "codeBlock": { "language": { @@ -1079,18 +2055,34 @@ "tooltip": "Klicken, um die Seite zu öffnen" }, "deleted": "gelöscht", - "deletedContent": "Dieser Inhalt existiert nicht oder wurde gelöscht" + "deletedContent": "Dieser Inhalt existiert nicht oder wurde gelöscht", + "noAccess": "Kein Zugriff", + "deletedPage": "Gelöschte Seite", + "trashHint": " - im Papierkorb" }, "toolbar": { "resetToDefaultFont": "Auf den Standard zurücksetzen" }, "errorBlock": { "theBlockIsNotSupported": "Die aktuelle Version unterstützt diesen Block nicht.", - "blockContentHasBeenCopied": "Der Blockinhalt wurde kopiert." + "clickToCopyTheBlockContent": "Hier klicken, um den Blockinhalt zu kopieren", + "blockContentHasBeenCopied": "Der Blockinhalt wurde kopiert.", + "copyBlockContent": "Blockinhalt kopieren" + }, + "mobilePageSelector": { + "title": "Seite auswählen", + "failedToLoad": "Seitenliste konnte nicht geladen werden", + "noPagesFound": "Keine Seiten gefunden" + }, + "attachmentMenu": { + "choosePhoto": "Foto auswählen", + "takePicture": "Ein Foto machen", + "chooseFile": "Datei auswählen" } }, "board": { "column": { + "label": "Spalte", "createNewCard": "Neu", "renameGroupTooltip": "Drücken, um die Gruppe umzubenennen", "createNewColumn": "Eine neue Gruppe hinzufügen", @@ -1121,6 +2113,7 @@ "ungroupedButtonTooltip": "Enthält Karten, die keiner Gruppe zugeordnet sind", "ungroupedItemsTitle": "Klicke, um dem Board hinzuzufügen", "groupBy": "Gruppiert nach", + "groupCondition": "Gruppenbedingung", "referencedBoardPrefix": "Sicht von", "notesTooltip": "Notizen vorhanden", "mobile": { @@ -1128,6 +2121,22 @@ "showGroup": "Zeige die Gruppe", "showGroupContent": "Sicher, dass diese Gruppe auf dem Board angezeigt werden soll?", "failedToLoad": "Boardansicht konnte nicht geladen werden" + }, + "dateCondition": { + "weekOf": "Woche von {} - {}", + "today": "Heute", + "yesterday": "Gestern", + "tomorrow": "Morgen", + "lastSevenDays": "Letzte 7 Tage", + "nextSevenDays": "Nächste 7 Tage", + "lastThirtyDays": "Letzte 30 Tage", + "nextThirtyDays": "Nächste 30 Tage" + }, + "noGroup": "Keine Gruppierung nach Eigenschaft", + "noGroupDesc": "Board-Ansichten benötigen eine Eigenschaft zum Gruppieren, um angezeigt zu werden", + "media": { + "cardText": "{} {}", + "fallbackName": "Dateien" } }, "calendar": { @@ -1138,7 +2147,13 @@ "today": "Heute", "jumpToday": "Springe zu Heute", "previousMonth": "Vorheriger Monat", - "nextMonth": "Nächster Monat" + "nextMonth": "Nächster Monat", + "views": { + "day": "Tag", + "week": "Woche", + "month": "Monat", + "year": "Jahr" + } }, "mobileEventScreen": { "emptyTitle": "Noch keine Events", @@ -1157,20 +2172,24 @@ "other": "{count} Ereignisse ohne Datum" }, "unscheduledEventsTitle": "Ungeplante Events", - "clickToAdd": "Klicken Sie, um es zum Kalender hinzuzufügen", - "name": "Kalendereinstellungen" + "clickToAdd": "Klicken zum hinzufügen im Kalender", + "name": "Kalendereinstellungen", + "clickToOpen": "Hier klicken, um den Eintrag zu öffnen" }, "referencedCalendarPrefix": "Sicht von", "quickJumpYear": "Spring zu", "duplicateEvent": "Doppeltes Ereignis" }, "errorDialog": { - "title": "AppFlowy-Fehler", - "howToFixFallback": "Wir entschuldigen uns für die Unannehmlichkeiten! Reiche auf unserer GitHub-Seite ein Problem ein, das Ihren Fehler beschreibt.", + "title": "@:appName-Fehler", + "howToFixFallback": "Wir entschuldigen uns für die Unannehmlichkeiten! Reiche auf unserer GitHub-Seite ein Problem ein, das deinen Fehler beschreibt.", + "howToFixFallbackHint1": "Wir entschuldigen uns für die Unannehmlichkeiten! Melden Sie ein Problem auf unserer ", + "howToFixFallbackHint2": " Seite, die Ihren Fehler beschreibt.", "github": "Auf GitHub ansehen" }, "search": { "label": "Suchen", + "sidebarSearchIcon": "Suchen und schnell zu einer Seite springen", "placeholder": { "actions": "Suchaktionen..." } @@ -1183,8 +2202,8 @@ }, "unSupportBlock": "Die aktuelle Version unterstützt diesen Block nicht.", "views": { - "deleteContentTitle": "Möchten Sie den {pageType} wirklich löschen?", - "deleteContentCaption": "Wenn Sie diesen {pageType} löschen, können Sie ihn aus dem Papierkorb wiederherstellen." + "deleteContentTitle": "Möchtest du den {pageType} wirklich löschen?", + "deleteContentCaption": "Wenn du diesen {pageType} löschst, kannst du ihn aus dem Papierkorb wiederherstellen." }, "colors": { "custom": "Individuell", @@ -1228,11 +2247,12 @@ "medium": "Mittel", "mediumDark": "Mitteldunkel", "dark": "Dunkel" - } + }, + "openSourceIconsFrom": "Open-Source-Icons von" }, "inlineActions": { "noResults": "Keine Ergebnisse", - "recentPages": "Letzte Seiten", + "recentPages": "Kürzliche Seiten", "pageReference": "Seitenreferenz", "docReference": "Dokumentverweis", "boardReference": "Board-Referenz", @@ -1319,7 +2339,10 @@ }, "error": { "weAreSorry": "Das tut uns leid", - "loadingViewError": "Wir haben Schwierigkeiten diese Ansicht zu laden. Bitte prüfe die Internetverbindung, lade die App neu und zögere Sie nicht, dass Team zu kontaktieren, falls das Problem weiterhin besteht." + "loadingViewError": "Wir haben Schwierigkeiten diese Ansicht zu laden. Bitte prüfe die Internetverbindung, lade die App neu und zögere nicht, das Team zu kontaktieren, falls das Problem weiterhin besteht.", + "syncError": "Daten werden nicht von einem anderen Gerät synchronisiert", + "syncErrorHint": "Bitte öffnen Sie diese Seite erneut auf dem Gerät, auf dem sie zuletzt bearbeitet wurde, und öffnen Sie die Seite dann erneut auf dem aktuellen Gerät.", + "clickToCopy": "Klicken Sie hier, um den Fehlercode zu kopieren" }, "editor": { "bold": "Fett", @@ -1334,6 +2357,7 @@ "color": "Farbe", "image": "Bild", "date": "Datum", + "page": "Seite", "italic": "Kursiv", "link": "Link", "numberedList": "Nummerierte Liste", @@ -1405,7 +2429,7 @@ "opacity": "Transparenz", "resetToDefaultColor": "Auf Standardfarben zurücksetzen", "ltr": "LTR (Links nach rechts)", - "rtl": "RTL (Rechts nach lins)", + "rtl": "RTL (rechts nach links)", "auto": "Auto", "cut": "Ausschneiden", "copy": "Kopieren", @@ -1448,7 +2472,9 @@ }, "favorite": { "noFavorite": "Leere Favoritenseite", - "noFavoriteHintText": "Nach links wischen, um es den Favoriten hinzuzufügen" + "noFavoriteHintText": "Nach links wischen, um es den Favoriten hinzuzufügen", + "removeFromSidebar": "Aus der Seitenleiste entfernen", + "addToSidebar": "An Seitenleiste anheften" }, "cardDetails": { "notesPlaceholder": "'/'-Taste, um einen Block einzufügen oder Text eingeben" @@ -1485,10 +2511,17 @@ "deleteAccount": { "title": "Benutzerkonto löschen", "subtitle": "Benutzerkonto inkl. deiner persönlicher Daten unwiderruflich löschen.", + "description": "Löschen Sie Ihr Konto dauerhaft und entfernen Sie den Zugriff auf alle Arbeitsbereiche.", "deleteMyAccount": "Mein Benutzerkonto löschen", "dialogTitle": "Benutzerkonto löschen", "dialogContent1": "Bist du sicher, dass du dein Benutzerkonto unwiderruflich löschen möchtest?", - "dialogContent2": "Diese Aktion kann nicht rückgängig gemacht werden und führt dazu, dass der Zugriff auf alle Teambereiche aufgehoben wird, Ihr gesamtes Benutzerkonto, einschließlich privater Arbeitsbereiche, gelöscht wird und Sie aus allen freigegebenen Arbeitsbereichen entfernt werden." + "dialogContent2": "Diese Aktion kann nicht rückgängig gemacht werden und führt dazu, dass der Zugriff auf alle Teambereiche aufgehoben wird, dein gesamtes Benutzerkonto, einschließlich privater Arbeitsbereiche, gelöscht wird und du aus allen freigegebenen Arbeitsbereichen entfernt wirst.", + "confirmHint1": "Geben Sie zur Bestätigung bitte „@:newSettings.myAccount.deleteAccount.confirmHint3“ ein.", + "confirmHint3": "MEIN KONTO LÖSCHEN", + "checkToConfirmError": "Sie müssen das Kontrollkästchen aktivieren, um das Löschen zu bestätigen", + "failedToGetCurrentUser": "Aktuelle Benutzer-E-Mail konnte nicht abgerufen werden.", + "confirmTextValidationFailed": "Ihr Bestätigungstext stimmt nicht mit „@:newSettings.myAccount.deleteAccount.confirmHint3“ überein.", + "deleteAccountSuccess": "Konto erfolgreich gelöscht" } }, "workplace": { @@ -1497,10 +2530,11 @@ "subtitle": "Passe das Erscheinungsbild, das Design, die Schriftart, das Textlayout, das Datum, die Uhrzeit und die Sprache deines Arbeitsbereiches an.", "workplaceName": "Name des Arbeitsbereiches", "workplaceNamePlaceholder": "Gib den Namen des Arbeitsbereiches ein", - "workplaceIcon": "Symbol für den Arbeitsbereich", - "workplaceIconSubtitle": "Lade ein Bild hoch oder verwende ein Emoji für deinen Arbeitsbereich. Das Symbol wird in deiner Seitenleiste und in deinen Benachrichtigungen angezeigt", + "workplaceIcon": "Symbol", + "workplaceIconSubtitle": "Lade ein Bild hoch oder verwende ein Emoji für deinen Arbeitsbereich. Das Symbol wird in deiner Seitenleiste und in deinen Benachrichtigungen angezeigt.", "renameError": "Umbenennen des Arbeitsbereiches fehlgeschlagen", "updateIconError": "Symbol konnte nicht aktualisiert werden", + "chooseAnIcon": "Symbol auswählen", "appearance": { "name": "Aussehen", "themeMode": { @@ -1529,7 +2563,14 @@ "photo": "Foto", "unsplash": "Unsplash", "pageCover": "Deckblatt", - "none": "Keines" + "none": "Keines", + "openSettings": "Einstellungen öffnen", + "photoPermissionTitle": "@:appName möchte auf deine Fotobibliothek zugreifen", + "photoPermissionDescription": "Erlaube den Zugriff auf die Fotobibliothek zum Hochladen von Bildern.", + "cameraPermissionTitle": "@:appName möchte auf Ihre Kamera zugreifen", + "cameraPermissionDescription": "@:appName benötigt Zugriff auf Ihre Kamera, damit Sie Bilder von der Kamera zu Ihren Dokumenten hinzufügen können.", + "doNotAllow": "Nicht zulassen", + "image": "Bild" }, "commandPalette": { "placeholder": "Tippe, um nach Ansichten zu suchen...", @@ -1539,6 +2580,288 @@ "loadingTooltip": "Wir suchen nach Ergebnissen ...", "betaLabel": "BETA", "betaTooltip": "Wir unterstützen derzeit nur die Suche nach Seiten", - "fromTrashHint": "Aus dem Mülleimer" + "fromTrashHint": "Aus dem Mülleimer", + "noResultsHint": "Wir haben nicht gefunden, wonach du gesucht hast. Versuche nach einem anderen Begriff zu suchen.", + "clearSearchTooltip": "Suchfeld löschen" + }, + "space": { + "delete": "Löschen", + "deleteConfirmation": "Löschen:", + "deleteConfirmationDescription": "Alle Seiten innerhalb dieses Space werden gelöscht und in den Papierkorb verschoben.", + "rename": "Domäne umbennen", + "changeIcon": "Symbol ändern", + "manage": "Domäne verwalten", + "addNewSpace": "Domäne erstellen", + "collapseAllSubPages": "Alle Unterseiten einklappen", + "createNewSpace": "Eine neue Domäne erstellen", + "createSpaceDescription": "Erstellen mehrere öffentliche und private Domänen, um deine Arbeit besser zu organisieren.", + "spaceName": "Name der Domäne", + "spaceNamePlaceholder": "z.B. Marketing, Entwicklung, Personalabteilung", + "permission": "Berechtigung", + "publicPermission": "Öffentlich", + "publicPermissionDescription": "Alle Mitglieder des Arbeitsbereichs mit Vollzugriff", + "privatePermission": "Privat", + "privatePermissionDescription": "Nur du hast Zugang zu dieser Domäne", + "spaceIconBackground": "Hintergrundfarbe", + "spaceIcon": "Symbol", + "dangerZone": "Gefahrenzone", + "unableToDeleteLastSpace": "Die letzte Domäne kann nicht gelöscht werden", + "unableToDeleteSpaceNotCreatedByYou": "Von anderen erstellte Domänen können nicht gelöscht werden", + "enableSpacesForYourWorkspace": "Domänen für deinen Arbeitsbereich aktivieren", + "title": "Domänen", + "defaultSpaceName": "Allgemein", + "upgradeSpaceTitle": "Domänen aktivieren", + "upgradeSpaceDescription": "Erstelle mehrere öffentliche und private Domänen, um deinen Arbeitsbereich besser zu organisieren.", + "upgrade": "Update", + "upgradeYourSpace": "Mehrere Domänen erstellen", + "quicklySwitch": "Schnell zur nächsten Domäne wechseln", + "duplicate": "Domäne duplizieren", + "movePageToSpace": "Seite in die Domäne verschieben", + "cannotMovePageToDatabase": "Seite kann nicht in die Datenbank verschoben werden", + "switchSpace": "Domäne wechseln", + "spaceNameCannotBeEmpty": "Der Space-Name darf nicht leer sein", + "success": { + "deleteSpace": "Domäne erfolgreich gelöscht", + "renameSpace": "Domäne erfolgreich umbenannt", + "duplicateSpace": "Domäne erfolgreich dupliziert", + "updateSpace": "Domäne erfolgreich angepasst" + }, + "error": { + "deleteSpace": "Löschung der Domäne fehlgeschlagen", + "renameSpace": "Umbenennung der Domäne fehlgeschlagen", + "duplicateSpace": "Duplizierung der Domäne fehlgeschlagen", + "updateSpace": "Anpassung der Domäne fehlgeschlagen" + }, + "createSpace": "Erstelle Domäne", + "manageSpace": "Verwalte Domäne", + "renameSpace": "Domäne umbenennen", + "mSpaceIconColor": "Domänen-Iconfarbe", + "mSpaceIcon": "Domänen-Icon" + }, + "publish": { + "hasNotBeenPublished": "Diese Seite wurde noch nicht veröffentlicht", + "reportPage": "Berichtsseite", + "databaseHasNotBeenPublished": "Das Veröffentlichen einer Datenbank wird noch nicht unterstützt.", + "createdWith": "Erstellt mit", + "downloadApp": "AppFlowy herunterladen", + "copy": { + "codeBlock": "Der Inhalt des Codeblocks wurde in die Zwischenablage kopiert", + "imageBlock": "Der Bildlink wurde in die Zwischenablage kopiert", + "mathBlock": "Die mathematische Gleichung wurde in die Zwischenablage kopiert" + }, + "containsPublishedPage": "Diese Seite enthält eine oder mehrere veröffentlichte Seiten. Wenn du fortfährst, werden sie nicht mehr veröffentlicht. Möchtest du mit dem Löschen fortfahren?", + "publishSuccessfully": "Erfolgreich veröffentlicht", + "unpublishSuccessfully": "Veröffentlichung erfolgreich aufgehoben", + "publishFailed": "Veröffentlichung fehlgeschlagen", + "unpublishFailed": "Die Veröffentlichung konnte nicht rückgängig gemacht werden.", + "noAccessToVisit": "Kein Zugriff auf diese Seite...", + "createWithAppFlowy": "Erstelle eine Website mit AppFlowy", + "fastWithAI": "Schnell und einfach mit KI.", + "tryItNow": "Probiere es jetzt", + "onlyGridViewCanBePublished": "Nur die Datentabellenansicht kann veröffentlicht werden", + "database": { + "zero": "{} ausgewählte Ansichten veröffentlichen", + "one": "{} ausgewählte Ansicht veröffentlichen", + "many": "{} ausgewählte Ansichten veröffentlichen", + "other": "{} ausgewählte Ansichten veröffentlichen" + }, + "mustSelectPrimaryDatabase": "Die primäre Ansicht muss ausgewählt sein", + "noDatabaseSelected": "Keine Datenbank ausgewählt, bitte wähle mindestens eine Datenbank aus.", + "unableToDeselectPrimaryDatabase": "Die Auswahl der primären Datenbank kann nicht aufgehoben werden.", + "saveThisPage": "Diese Seite speichern", + "duplicateTitle": "Wo möchten Sie hinzufügen", + "selectWorkspace": "Einen Arbeitsbereich auswählen", + "addTo": "Hinzufügen zu", + "duplicateSuccessfully": "Duplizierung erfolgreich. Möchtest du die Dokumente anzeigen?", + "duplicateSuccessfullyDescription": "Du hast die App nicht? Dein Download beginnt automatisch, nachdem du auf „Herunterladen“ geklickt hast.", + "downloadIt": "Herunterladen", + "openApp": "In App öffnen", + "duplicateFailed": "Duplizierung fehlgeschlagen", + "membersCount": { + "zero": "Keine Mitglieder", + "one": "1 Mitglied", + "many": "{count} Mitglieder", + "other": "{count} Mitglieder" + }, + "useThisTemplate": "Verwenden Sie die Vorlage" + }, + "web": { + "continue": "Weiter", + "or": "oder", + "continueWithGoogle": "Weiter mit Google", + "continueWithGithub": "Weiter mit GitHub", + "continueWithDiscord": "Weiter mit Discord", + "continueWithApple": "Weiter mit Apple ", + "moreOptions": "Weitere Optionen", + "signInAgreement": "Wenn du oben auf \"Weiter\" klickst, bestätigst du, dass\ndu folgende Dokumente gelesen, verstanden und akzeptiert hast:\nAppFlowys", + "and": "und", + "termOfUse": "Bedingungen", + "privacyPolicy": "Datenschutzrichtlinie", + "signInError": "Anmeldefehler", + "login": "Registrieren oder anmelden", + "fileBlock": { + "uploadedAt": "Hochgeladen am {Zeit}", + "linkedAt": "Link hinzugefügt am {Zeit}", + "empty": "Hochladen oder Einbetten einer Datei" + }, + "importNotion": "Importieren aus Notion", + "import": "Import", + "importSuccess": "Erfolgreich hochgeladen", + "importFailed": "Import fehlgeschlagen, bitte überprüfen Sie das Dateiformat" + }, + "globalComment": { + "comments": "Kommentare", + "addComment": "Einen Kommentar hinzufügen", + "reactedBy": "Reaktion von", + "addReaction": "Reaktion hinzufügen", + "reactedByMore": "und {count} andere", + "showSeconds": { + "one": "vor 1 Sekunde", + "other": "vor {count} Sekunden", + "zero": "Soeben", + "many": "vor {count} Sekunden" + }, + "showMinutes": { + "one": "vor 1 Minute", + "other": "vor {count} Minuten", + "many": "vor {count} Minuten" + }, + "showHours": { + "one": "vor 1 Stunde", + "other": "vor {count} Stunden", + "many": "vor {count} Stunden" + }, + "showDays": { + "one": "vor 1 Tag", + "other": "vor {count} Tagen", + "many": "vor {count} Tagen" + }, + "showMonths": { + "one": "vor 1 Monat", + "other": "vor {count} Monaten", + "many": "vor {count} Monaten" + }, + "showYears": { + "one": "vor 1 Jahr", + "other": "vor {count} Jahren", + "many": "vor {count} Jahren" + }, + "reply": "Antworten", + "deleteComment": "Kommentar löschen", + "youAreNotOwner": "Du bist nicht der Verfasser dieses Kommentars", + "confirmDeleteDescription": "Möchtest du diesen Kommentar wirklich löschen?", + "hasBeenDeleted": "Gelöscht", + "replyingTo": "Antwort auf", + "noAccessDeleteComment": "Du bist nicht berechtigt, diesen Kommentar zu löschen", + "collapse": "Zusammenklappen", + "readMore": "Mehr lesen", + "failedToAddComment": "Kommentar konnte nicht hinzugefügt werden", + "commentAddedSuccessfully": "Kommentar erfolgreich hinzugefügt.", + "commentAddedSuccessTip": "Du hast gerade einen Kommentar hinzugefügt oder darauf geantwortet. Möchtest du nach oben springen, um die neuesten Kommentare zu sehen?" + }, + "template": { + "asTemplate": "Als Vorlage speichern", + "name": "Vorlagenname", + "description": "Vorlagenbeschreibung", + "requiredField": "{field} ist erforderlich", + "addCategory": "\"{category}\" hinzufügen", + "addNewCategory": "Neue Kategorie hinzufügen", + "deleteCategory": "Lösche Kategorie", + "editCategory": "Bearbeite Kategorie", + "category": { + "name": "Kategoriename", + "icon": "Kategorie-Icon", + "bgColor": "Kategorie-Hintergrundfarbe", + "priority": "Kategoriepriorität", + "desc": "Kategoriebeschreibung", + "type": "Kategorie-Typ", + "icons": "Kategorie-Icons", + "colors": "Kategoriefarbe", + "deleteCategory": "Lösche Kategorie", + "deleteCategoryDescription": "Möchten Sie diese Kategorie wirklich löschen?", + "typeToSearch": "Tippen, um nach Kategorien zu suchen..." + } + }, + "fileDropzone": { + "dropFile": "Klicken oder ziehen Sie die Datei zum Hochladen in diesen Bereich", + "uploading": "Hochladen...", + "uploadFailed": "Hochladen fehlgeschlagen", + "uploadSuccess": "Hochladen erfolgreich", + "uploadSuccessDescription": "Die Datei wurde erfolgreich hochgeladen", + "uploadFailedDescription": "Das Hochladen der Datei ist fehlgeschlagen", + "uploadingDescription": "Die Datei wird hochgeladen" + }, + "gallery": { + "preview": "Im Vollbildmodus öffnen", + "copy": "Kopiere", + "prev": "Vorherige", + "next": "Nächste", + "resetZoom": "Setze Zoom zurück", + "zoomIn": "Vergrößern", + "zoomOut": "Verkleinern" + }, + "invitation": { + "joinWorkspace": "Arbeitsbereich beitreten", + "success": "Sie sind dem Arbeitsbereich erfolgreich beigetreten", + "successMessage": "Sie können nun auf alle darin enthaltenen Seiten und Arbeitsbereiche zugreifen.", + "openWorkspace": "Öffne AppFlowy", + "errorModal": { + "description": "Ihr aktuelles Konto {email} hat möglicherweise keinen Zugriff auf diesen Arbeitsbereich. Bitte melden Sie sich mit dem richtigen Konto an oder wenden Sie sich an den Eigentümer des Arbeitsbereichs, um Hilfe zu erhalten." + } + }, + "approveAccess": { + "title": "Anfrage zum Beitritt zum Arbeitsbereich genehmigen", + "requestSummary": " fragt den Beitritt zu und den Zugriff auf an." + }, + "time": { + "justNow": "Soeben", + "seconds": { + "one": "1 Sekunde", + "other": "{count} Sekunden" + }, + "minutes": { + "one": "1 Minute", + "other": "{count} Minuten" + }, + "hours": { + "one": "1 Stunde", + "other": "{count} Stunden" + }, + "days": { + "one": "1 Tag", + "other": "{count} Tage" + }, + "weeks": { + "one": "1 Woche", + "other": "{count} Wochen" + }, + "months": { + "one": "1 Monat", + "other": "{count} Monate" + }, + "years": { + "one": "1 Jahr", + "other": "{count} Jahre" + }, + "ago": "vor", + "yesterday": "Gestern", + "today": "Heute" + }, + "members": { + "zero": "Keine Mitglieder", + "one": "1 Mitglied", + "many": "{count} Mitglieder", + "other": "{count} Mitglieder" + }, + "tabMenu": { + "close": "Schließen", + "closeOthers": "Schließe andere Tabs", + "favoriteDisabledHint": "Diese Ansicht kann nicht als Favorit markiert werden" + }, + "openFileMessage": { + "success": "Datei erfolgreich geöffnet", + "fileNotFound": "Datei nicht gefunden", + "permissionDenied": "Keine Berechtigung zum Öffnen dieser Datei", + "unknownError": "Öffnen der Datei fehlgeschlagen" } } diff --git a/frontend/resources/translations/el-GR.json b/frontend/resources/translations/el-GR.json index 0ee0bc10cd..a329a8998c 100644 --- a/frontend/resources/translations/el-GR.json +++ b/frontend/resources/translations/el-GR.json @@ -179,7 +179,7 @@ "underline": "Υπογράμμιση", "strike": "Διακριτή διαγραφή", "numList": "Αριθμημένη λίστα", - "bulletList": "Bulleted List", + "bulletList": "Bulleted list", "checkList": "Check List", "inlineCode": "Inline Code", "quote": "Quote Block", @@ -201,8 +201,8 @@ "addBlockBelow": "Add a block below" }, "sideBar": { - "closeSidebar": "Close side bar", - "openSidebar": "Open side bar", + "closeSidebar": "Close sidebar", + "openSidebar": "Open sidebar", "personal": "Personal", "favorites": "Favorites", "clickToHidePersonal": "Click to hide personal section", @@ -305,12 +305,7 @@ "cloudServerType": "Cloud server", "cloudServerTypeTip": "Please note that it might log out your current account after switching the cloud server", "cloudLocal": "Local", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "Supabase URL", - "cloudSupabaseUrlCanNotBeEmpty": "The supabase url can't be empty", - "cloudSupabaseAnonKey": "Supabase anon key", - "cloudSupabaseAnonKeyCanNotBeEmpty": "The anon key can't be empty", - "cloudAppFlowy": "AppFlowy Cloud Beta", + "cloudAppFlowy": "AppFlowy Cloud", "cloudAppFlowySelfHost": "AppFlowy Cloud Self-hosted", "appFlowyCloudUrlCanNotBeEmpty": "The cloud url can't be empty", "clickToCopy": "Click to copy", @@ -477,7 +472,7 @@ "email": "Email", "tooltipSelectIcon": "Select icon", "selectAnIcon": "Select an icon", - "pleaseInputYourOpenAIKey": "παρακαλώ εισάγετε το OpenAI κλειδί σας", + "pleaseInputYourOpenAIKey": "παρακαλώ εισάγετε το AI κλειδί σας", "pleaseInputYourStabilityAIKey": "παρακαλώ εισάγετε το Stability AI κλειδί σας", "clickToLogout": "Κάντε κλικ για αποσύνδεση του τρέχοντος χρήστη" }, @@ -729,8 +724,7 @@ "url": { "launch": "Άνοιγμα συνδέσμου στο πρόγραμμα περιήγησης", "copy": "Copy link to clipboard", - "textFieldHint": "Enter a URL", - "copiedNotification": "Copied to clipboard!" + "textFieldHint": "Enter a URL" }, "relation": { "relatedDatabasePlaceLabel": "Related Database", @@ -789,23 +783,23 @@ "referencedGrid": "Referenced Grid", "referencedCalendar": "Referenced Calendar", "referencedDocument": "Referenced Document", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Ask AI to write anything...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Ask AI to write anything...", "autoGeneratorLearnMore": "Μάθετε περισσότερα", "autoGeneratorGenerate": "Generate", - "autoGeneratorHintText": "Ρωτήστε Το OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "Αδυναμία λήψης κλειδιού OpenAI", + "autoGeneratorHintText": "Ρωτήστε Το AI ...", + "autoGeneratorCantGetOpenAIKey": "Αδυναμία λήψης κλειδιού AI", "autoGeneratorRewrite": "Rewrite", "smartEdit": "AI Assistants", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Διόρθωση ορθογραφίας", "warning": "⚠️ Οι απαντήσεις AI μπορεί να είναι ανακριβείς ή παραπλανητικές.", "smartEditSummarize": "Summarize", "smartEditImproveWriting": "Improve writing", "smartEditMakeLonger": "Make longer", - "smartEditCouldNotFetchResult": "Could not fetch result from OpenAI", - "smartEditCouldNotFetchKey": "Could not fetch OpenAI key", - "smartEditDisabled": "Connect OpenAI in Settings", + "smartEditCouldNotFetchResult": "Could not fetch result from AI", + "smartEditCouldNotFetchKey": "Could not fetch AI key", + "smartEditDisabled": "Connect AI in Settings", "discardResponse": "Do you want to discard the AI responses?", "createInlineMathEquation": "Create equation", "fonts": "Γραμματοσειρές", @@ -813,7 +807,7 @@ "quoteList": "Quote list", "numberedList": "Αριθμημένη λίστα", "bulletedList": "Bulleted list", - "todoList": "Todo List", + "todoList": "Todo list", "callout": "Callout", "cover": { "changeCover": "Change Cover", @@ -919,8 +913,8 @@ "placeholder": "Enter image URL" }, "ai": { - "label": "Generate image from OpenAI", - "placeholder": "Please input the prompt for OpenAI to generate image" + "label": "Generate image from AI", + "placeholder": "Please input the prompt for AI to generate image" }, "stability_ai": { "label": "Generate image from Stability AI", @@ -942,7 +936,7 @@ "label": "Unsplash" }, "searchForAnImage": "Search for an image", - "pleaseInputYourOpenAIKey": "please input your OpenAI key in Settings page", + "pleaseInputYourOpenAIKey": "please input your AI key in Settings page", "pleaseInputYourStabilityAIKey": "please input your Stability AI key in Settings page", "saveImageToGallery": "Save image", "failedToAddImageToGallery": "Failed to add image to gallery", @@ -1221,7 +1215,7 @@ }, "editor": { "bold": "Bold", - "bulletedList": "Bulleted List", + "bulletedList": "Bulleted list", "bulletedListShortForm": "Bulleted", "checkbox": "Checkbox", "embedCode": "Embed Code", @@ -1234,7 +1228,7 @@ "date": "Date", "italic": "Italic", "link": "Link", - "numberedList": "Numbered List", + "numberedList": "Numbered list", "numberedListShortForm": "Numbered", "quote": "Quote", "strikethrough": "Strikethrough", @@ -1408,4 +1402,4 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index b1b35ee0d4..30e8c476ae 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -34,8 +34,11 @@ "signIn": { "loginTitle": "Login to @:appName", "loginButtonText": "Login", - "loginStartWithAnonymous": "Start with an anonymous session", + "loginStartWithAnonymous": "Continue with an anonymous session", "continueAnonymousUser": "Continue with an anonymous session", + "continueWithLocalModel": "Continue with local model", + "switchToAppFlowyCloud": "AppFlowy Cloud", + "anonymousMode": "Anonymous mode", "buttonText": "Sign In", "signingInText": "Signing in...", "forgotPassword": "Forgot Password?", @@ -46,34 +49,58 @@ "repeatPasswordEmptyError": "Repeat password can't be empty", "unmatchedPasswordError": "Repeat password is not the same as password", "syncPromptMessage": "Syncing the data might take a while. Please don't close this page", - "or": "OR", - "signInWithGoogle": "Log in with Google", - "signInWithGithub": "Log in with Github", - "signInWithDiscord": "Log in with Discord", + "or": "or", + "signInWithGoogle": "Continue with Google", + "signInWithGithub": "Continue with GitHub", + "signInWithDiscord": "Continue with Discord", + "signInWithApple": "Continue with Apple", + "continueAnotherWay": "Continue another way", "signUpWithGoogle": "Sign up with Google", "signUpWithGithub": "Sign up with Github", "signUpWithDiscord": "Sign up with Discord", - "signInWith": "Sign in with:", - "signInWithEmail": "Sign in with Email", - "signInWithMagicLink": "Log in with Magic Link", + "signInWith": "Continue with:", + "signInWithEmail": "Continue with Email", + "signInWithMagicLink": "Continue", "signUpWithMagicLink": "Sign up with Magic Link", "pleaseInputYourEmail": "Please enter your email address", "settings": "Settings", - "magicLinkSent": "We emailed a magic link. Click the link to log in.", + "magicLinkSent": "Magic Link sent!", "invalidEmail": "Please enter a valid email address", "alreadyHaveAnAccount": "Already have an account?", "logIn": "Log in", "generalError": "Something went wrong. Please try again later", - "limitRateError": "For security reasons, you can only request a magic link every 60 seconds" + "limitRateError": "For security reasons, you can only request a magic link every 60 seconds", + "magicLinkSentDescription": "A Magic Link was sent to your email. Click the link to complete your login. The link will expire after 5 minutes.", + "tokenHasExpiredOrInvalid": "The code has expired or is invalid. Please try again.", + "signingIn": "Signing in...", + "checkYourEmail": "Check your email", + "temporaryVerificationLinkSent": "A temporary verification link has been sent.\nPlease check your inbox at", + "temporaryVerificationCodeSent": "A temporary verification code has been sent.\nPlease check your inbox at", + "continueToSignIn": "Continue to sign in", + "backToLogin": "Back to login", + "enterCode": "Enter code", + "enterCodeManually": "Enter code manually", + "continueWithEmail": "Continue with email", + "enterPassword": "Enter password", + "loginAs": "Login as", + "invalidVerificationCode": "Please enter a valid verification code", + "tooFrequentVerificationCodeRequest": "You have made too many requests. Please try again later.", + "invalidLoginCredentials": "Your password is incorrect, please try again" }, "workspace": { "chooseWorkspace": "Choose your workspace", + "defaultName": "My Workspace", "create": "Create workspace", + "new": "New workspace", + "importFromNotion": "Import from Notion", + "learnMore": "Learn more", "reset": "Reset workspace", + "renameWorkspace": "Rename workspace", + "workspaceNameCannotBeEmpty": "Workspace name cannot be empty", "resetWorkspacePrompt": "Resetting the workspace will delete all pages and data within it. Are you sure you want to reset the workspace? Alternatively, you can contact the support team to restore the workspace", "hint": "workspace", "notFoundError": "Workspace not found", - "failedToLoad": "Something went wrong! Failed to load the workspace. Try to close any open instance of AppFlowy and try again.", + "failedToLoad": "Something went wrong! Failed to load the workspace. Try to close any open instance of @:appName and try again.", "errorActions": { "reportIssue": "Report an issue", "reportIssueOnGithub": "Report an issue on Github", @@ -81,7 +108,7 @@ "reachOut": "Reach out on Discord" }, "menuTitle": "Workspaces", - "deleteWorkspaceHintText": "Are you sure you want to delete the workspace? This action cannot be undone.", + "deleteWorkspaceHintText": "Are you sure you want to delete the workspace? This action cannot be undone, and any pages you have published will be unpublished.", "createSuccess": "Workspace created successfully", "createFailed": "Failed to create workspace", "createLimitExceeded": "You've reached the maximum workspace limit allowed for your account. If you need additional workspaces to continue your work, please request on Github", @@ -105,7 +132,25 @@ "html": "HTML", "clipboard": "Copy to clipboard", "csv": "CSV", - "copyLink": "Copy Link" + "copyLink": "Copy link", + "publishToTheWeb": "Publish to Web", + "publishToTheWebHint": "Create a website with AppFlowy", + "publish": "Publish", + "unPublish": "Unpublish", + "visitSite": "Visit site", + "exportAsTab": "Export as", + "publishTab": "Publish", + "shareTab": "Share", + "publishOnAppFlowy": "Publish on AppFlowy", + "shareTabTitle": "Invite to collaborate", + "shareTabDescription": "For easy collaboration with anyone", + "copyLinkSuccess": "Copied link to clipboard", + "copyShareLink": "Copy share link", + "copyLinkFailed": "Failed to copy link to clipboard", + "copyLinkToBlockSuccess": "Copied block link to clipboard", + "copyLinkToBlockFailed": "Failed to copy block link to clipboard", + "manageAllSites": "Manage all sites", + "updatePathName": "Update path name" }, "moreAction": { "small": "small", @@ -118,25 +163,46 @@ "charCount": "Character count: {}", "createdAt": "Created: {}", "deleteView": "Delete", - "duplicateView": "Duplicate" + "duplicateView": "Duplicate", + "wordCountLabel": "Word count: ", + "charCountLabel": "Character count: ", + "createdAtLabel": "Created: ", + "syncedAtLabel": "Synced: ", + "saveAsNewPage": "Add messages to page", + "saveAsNewPageDisabled": "No messages available" }, "importPanel": { "textAndMarkdown": "Text & Markdown", "documentFromV010": "Document from v0.1.0", "databaseFromV010": "Database from v0.1.0", + "notionZip": "Notion Exported Zip File", "csv": "CSV", "database": "Database" }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "Drag & drop a file, click to ", + "placeholderUpload": "Upload", + "placeholderRight": ", or paste an image link.", + "dropToUpload": "Drop a file to upload", + "change": "Change" + } + }, "disclosureAction": { "rename": "Rename", "delete": "Delete", "duplicate": "Duplicate", - "unfavorite": "Remove from favorites", - "favorite": "Add to favorites", + "unfavorite": "Remove from Favorites", + "favorite": "Add to Favorites", "openNewTab": "Open in a new tab", "moveTo": "Move to", "addToFavorites": "Add to Favorites", - "copyLink": "Copy Link" + "copyLink": "Copy link", + "changeIcon": "Change icon", + "collapseAllPages": "Collapse all subpages", + "movePageTo": "Move page to", + "move": "Move", + "lockPage": "Lock page" }, "blankPageTitle": "Blank page", "newPageText": "New page", @@ -144,9 +210,83 @@ "newGridText": "New grid", "newCalendarText": "New calendar", "newBoardText": "New board", + "chat": { + "newChat": "AI Chat", + "inputMessageHint": "Ask @:appName AI", + "inputLocalAIMessageHint": "Ask @:appName Local AI", + "unsupportedCloudPrompt": "This feature is only available when using @:appName Cloud", + "relatedQuestion": "Suggested", + "serverUnavailable": "Connection lost. Please check your internet and", + "aiServerUnavailable": "The AI service is temporarily unavailable. Please try again later.", + "retry": "Retry", + "clickToRetry": "Click to retry", + "regenerateAnswer": "Regenerate", + "question1": "How to use Kanban to manage tasks", + "question2": "Explain the GTD method", + "question3": "Why use Rust", + "question4": "Recipe with what's in my kitchen", + "question5": "Create an illustration for my page", + "question6": "Draw up a to-do list for my upcoming week", + "aiMistakePrompt": "AI can make mistakes. Check important info.", + "chatWithFilePrompt": "Do you want to chat with the file?", + "indexFileSuccess": "Indexing file successfully", + "inputActionNoPages": "No page results", + "referenceSource": { + "zero": "0 sources found", + "one": "{count} source found", + "other": "{count} sources found" + }, + "clickToMention": "Mention a page", + "uploadFile": "Attach PDFs, text or markdown files", + "questionDetail": "Hi {}! How can I help you today?", + "indexingFile": "Indexing {}", + "generatingResponse": "Generating response", + "selectSources": "Select Sources", + "currentPage": "Current page", + "sourcesLimitReached": "You can only select up to 3 top-level documents and its children", + "sourceUnsupported": "We don't support chatting with databases at this time", + "regenerate": "Try again", + "addToPageButton": "Add message to page", + "addToPageTitle": "Add message to...", + "addToNewPage": "Create new page", + "addToNewPageName": "Messages extracted from \"{}\"", + "addToNewPageSuccessToast": "Message added to", + "openPagePreviewFailedToast": "Failed to open page", + "changeFormat": { + "actionButton": "Change format", + "confirmButton": "Regenerate with this format", + "textOnly": "Text", + "imageOnly": "Image only", + "textAndImage": "Text and Image", + "text": "Paragraph", + "bullet": "Bullet list", + "number": "Numbered list", + "table": "Table", + "blankDescription": "Format response", + "defaultDescription": "Auto response format", + "textWithImageDescription": "@:chat.changeFormat.text with image", + "numberWithImageDescription": "@:chat.changeFormat.number with image", + "bulletWithImageDescription": "@:chat.changeFormat.bullet with image", + "tableWithImageDescription": "@:chat.changeFormat.table with image" + }, + "switchModel": { + "label": "Switch model", + "localModel": "Local Model", + "cloudModel": "Cloud Model", + "autoModel": "Auto" + }, + "selectBanner": { + "saveButton": "Add to …", + "selectMessages": "Select messages", + "nSelected": "{} selected", + "allSelected": "All selected" + }, + "stopTooltip": "Stop generating" + }, "trash": { "text": "Trash", "restoreAll": "Restore All", + "restore": "Restore", "deleteAll": "Delete All", "pageHeader": { "fileName": "File name", @@ -154,17 +294,21 @@ "created": "Created" }, "confirmDeleteAll": { - "title": "Are you sure to delete all pages in Trash?", - "caption": "This action cannot be undone." + "title": "All pages in trash", + "caption": "Are you sure you want to delete everything in Trash? This action cannot be undone." }, "confirmRestoreAll": { - "title": "Are you sure to restore all pages in Trash?", + "title": "Restore all pages in trash", "caption": "This action cannot be undone." }, + "restorePage": { + "title": "Restore: {}", + "caption": "Are you sure you want to restore this page?" + }, "mobile": { "actions": "Trash Actions", - "empty": "Trash Bin is Empty", - "emptyDescription": "You don't have any deleted file", + "empty": "No pages or spaces in Trash", + "emptyDescription": "Move things you don't need to the Trash.", "isDeleted": "is deleted", "isRestored": "is restored" }, @@ -173,13 +317,15 @@ "deletePagePrompt": { "text": "This page is in Trash", "restore": "Restore page", - "deletePermanent": "Delete permanently" + "deletePermanent": "Delete permanently", + "deletePermanentDescription": "Are you sure you want to delete this page permanently? This is irreversible." }, "dialogCreatePageNameHint": "Page name", "questionBubble": { "shortcuts": "Shortcuts", "whatsNew": "What's new?", - "help": "Help & Support", + "helpAndDocumentation": "Help & documentation", + "getSupport": "Get support", "markdown": "Markdown", "debug": { "name": "Debug Info", @@ -192,7 +338,8 @@ "moreButtonToolTip": "Remove, rename, and more...", "addPageTooltip": "Quickly add a page inside", "defaultNewPageName": "Untitled", - "renameDialog": "Rename" + "renameDialog": "Rename", + "pageNameSuffix": "Copy" }, "noPagesInside": "No pages inside", "toolbar": { @@ -202,16 +349,15 @@ "italic": "Italic", "underline": "Underline", "strike": "Strikethrough", - "numList": "Numbered List", - "bulletList": "Bulleted List", + "numList": "Numbered list", + "bulletList": "Bulleted list", "checkList": "Check List", "inlineCode": "Inline Code", "quote": "Quote Block", "header": "Header", "highlight": "Highlight", "color": "Color", - "addLink": "Add Link", - "link": "Link" + "addLink": "Add Link" }, "tooltip": { "lightMode": "Switch to Light mode", @@ -219,15 +365,16 @@ "openAsPage": "Open as a Page", "addNewRow": "Add a new row", "openMenu": "Click to open menu", - "dragRow": "Long press to reorder the row", + "dragRow": "Drag to reorder the row", "viewDataBase": "View database", "referencePage": "This {name} is referenced", "addBlockBelow": "Add a block below", - "genSummary": "Generate summary" + "aiGenerate": "Generate" }, "sideBar": { - "closeSidebar": "Close side bar", - "openSidebar": "Open side bar", + "closeSidebar": "Close sidebar", + "openSidebar": "Open sidebar", + "expandSidebar": "Expand as full page", "personal": "Personal", "private": "Private", "workspace": "Workspace", @@ -236,10 +383,46 @@ "clickToHideWorkspace": "Click to hide workspace\nPages you created here are visible to every member", "clickToHidePersonal": "Click to hide personal space", "clickToHideFavorites": "Click to hide favorite space", - "addAPage": "Add a page", + "addAPage": "Add a new page", "addAPageToPrivate": "Add a page to private space", "addAPageToWorkspace": "Add a page to workspace", - "recent": "Recent" + "recent": "Recent", + "today": "Today", + "thisWeek": "This week", + "others": "Earlier favorites", + "earlier": "Earlier", + "justNow": "just now", + "minutesAgo": "{count} minutes ago", + "lastViewed": "Last viewed", + "favoriteAt": "Favorited", + "emptyRecent": "No Recent Pages", + "emptyRecentDescription": "As you view pages, they will appear here for easy retrieval.", + "emptyFavorite": "No Favorite Pages", + "emptyFavoriteDescription": "Mark pages as favorites—they'll be listed here for quick access!", + "removePageFromRecent": "Remove this page from the Recent?", + "removeSuccess": "Removed successfully", + "favoriteSpace": "Favorites", + "RecentSpace": "Recent", + "Spaces": "Spaces", + "upgradeToPro": "Upgrade to Pro", + "upgradeToAIMax": "Unlock unlimited AI", + "storageLimitDialogTitle": "You have run out of free storage. Upgrade to unlock unlimited storage", + "storageLimitDialogTitleIOS": "You have run out of free storage.", + "aiResponseLimitTitle": "You have run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", + "aiResponseLimitDialogTitle": "AI Responses limit reached", + "aiResponseLimit": "You have run out of free AI responses.\n\nGo to Settings -> Plan -> Click AI Max or Pro Plan to get more AI responses", + "askOwnerToUpgradeToPro": "Your workspace is running out of free storage. Please ask your workspace owner to upgrade to the Pro Plan", + "askOwnerToUpgradeToProIOS": "Your workspace is running out of free storage.", + "askOwnerToUpgradeToAIMax": "Your workspace has ran out of free AI responses. Please ask your workspace owner to upgrade the plan or purchase AI add-ons", + "askOwnerToUpgradeToAIMaxIOS": "Your workspace is running out of free AI responses.", + "purchaseAIMax": "Your workspace has ran out of AI Image responses. Please ask your workspace owner to purchase AI Max", + "aiImageResponseLimit": "You have run out of AI image responses.\n\nGo to Settings -> Plan -> Click AI Max to get more AI image responses", + "purchaseStorageSpace": "Purchase Storage Space", + "singleFileProPlanLimitationDescription": "You have exceeded the maximum file upload size allowed in the free plan. Please upgrade to the Pro Plan to upload larger files", + "purchaseAIResponse": "Purchase ", + "askOwnerToUpgradeToLocalAI": "Ask workspace owner to enable AI On-device", + "upgradeToAILocal": "Run local models on your device for ultimate privacy", + "upgradeToAILocalDesc": "Chat with PDFs, improve your writing, and auto-fill tables using local AI" }, "notifications": { "export": { @@ -273,16 +456,22 @@ "upload": "Upload", "edit": "Edit", "delete": "Delete", + "copy": "Copy", "duplicate": "Duplicate", "putback": "Put Back", "update": "Update", "share": "Share", - "removeFromFavorites": "Remove from favorites", - "addToFavorites": "Add to favorites", + "removeFromFavorites": "Remove from Favorites", + "removeFromRecent": "Remove from Recent", + "addToFavorites": "Add to Favorites", + "favoriteSuccessfully": "Favorited success", + "unfavoriteSuccessfully": "Unfavorited success", + "duplicateSuccessfully": "Duplicated successfully", "rename": "Rename", "helpCenter": "Help Center", "add": "Add", "yes": "Yes", + "no": "No", "clear": "Clear", "remove": "Remove", "dontRemove": "Don't remove", @@ -292,9 +481,23 @@ "logout": "Log out", "deleteAccount": "Delete account", "back": "Back", - "signInGoogle": "Sign in with Google", - "signInGithub": "Sign in with Github", - "signInDiscord": "Sign in with Discord" + "signInGoogle": "Continue with Google", + "signInGithub": "Continue with GitHub", + "signInDiscord": "Continue with Discord", + "more": "More", + "create": "Create", + "close": "Close", + "next": "Next", + "previous": "Previous", + "submit": "Submit", + "download": "Download", + "backToHome": "Back to Home", + "viewing": "Viewing", + "editing": "Editing", + "gotIt": "Got it", + "retry": "Retry", + "uploadFailed": "Upload failed.", + "copyLinkOriginal": "Copy link to original" }, "label": { "welcome": "Welcome!", @@ -318,10 +521,78 @@ }, "settings": { "title": "Settings", + "popupMenuItem": { + "settings": "Settings", + "members": "Members", + "trash": "Trash", + "helpAndDocumentation": "Help & documentation", + "getSupport": "Get Support" + }, + "sites": { + "title": "Sites", + "namespaceTitle": "Namespace", + "namespaceDescription": "Manage your namespace and homepage", + "namespaceHeader": "Namespace", + "homepageHeader": "Homepage", + "updateNamespace": "Update namespace", + "removeHomepage": "Remove homepage", + "selectHomePage": "Select a page", + "clearHomePage": "Clear the home page for this namespace", + "customUrl": "Custom URL", + "namespace": { + "description": "This change will apply to all the published pages live on this namespace", + "tooltip": "We reserve the rights to remove any inappropriate namespaces", + "updateExistingNamespace": "Update existing namespace", + "upgradeToPro": "Upgrade to Pro Plan to set a homepage", + "redirectToPayment": "Redirecting to payment page...", + "onlyWorkspaceOwnerCanSetHomePage": "Only the workspace owner can set a homepage", + "pleaseAskOwnerToSetHomePage": "Please ask the workspace owner to upgrade to Pro Plan" + }, + "publishedPage": { + "title": "All published pages", + "description": "Manage your published pages", + "page": "Page", + "pathName": "Path name", + "date": "Published date", + "emptyHinText": "You have no published pages in this workspace", + "noPublishedPages": "No published pages", + "settings": "Publish settings", + "clickToOpenPageInApp": "Open page in app", + "clickToOpenPageInBrowser": "Open page in browser" + }, + "error": { + "failedToGeneratePaymentLink": "Failed to generate payment link for Pro Plan", + "failedToUpdateNamespace": "Failed to update namespace", + "proPlanLimitation": "You need to upgrade to Pro Plan to update the namespace", + "namespaceAlreadyInUse": "The namespace is already taken, please try another one", + "invalidNamespace": "Invalid namespace, please try another one", + "namespaceLengthAtLeast2Characters": "The namespace must be at least 2 characters long", + "onlyWorkspaceOwnerCanUpdateNamespace": "Only workspace owner can update the namespace", + "onlyWorkspaceOwnerCanRemoveHomepage": "Only workspace owner can remove the homepage", + "setHomepageFailed": "Failed to set homepage", + "namespaceTooLong": "The namespace is too long, please try another one", + "namespaceTooShort": "The namespace is too short, please try another one", + "namespaceIsReserved": "The namespace is reserved, please try another one", + "updatePathNameFailed": "Failed to update path name", + "removeHomePageFailed": "Failed to remove homepage", + "publishNameContainsInvalidCharacters": "The path name contains invalid character(s), please try another one", + "publishNameTooShort": "The path name is too short, please try another one", + "publishNameTooLong": "The path name is too long, please try another one", + "publishNameAlreadyInUse": "The path name is already in use, please try another one", + "namespaceContainsInvalidCharacters": "The namespace contains invalid character(s), please try another one", + "publishPermissionDenied": "Only the workspace owner or page publisher can manage the publish settings", + "publishNameCannotBeEmpty": "The path name cannot be empty, please try another one" + }, + "success": { + "namespaceUpdated": "Updated namespace successfully", + "setHomepageSuccess": "Set homepage successfully", + "updatePathNameSuccess": "Updated path name successfully", + "removeHomePageSuccess": "Remove homepage successfully" + } + }, "accountPage": { - "menuLabel": "My account", + "menuLabel": "Account & App", "title": "My account", - "description": "Customize your profile, manage account security and AI API keys, or login into your account.", "general": { "title": "Account name & profile image", "changeProfilePicture": "Change profile picture" @@ -332,21 +603,530 @@ "change": "Change email" } }, - "keys": { - "title": "AI API Keys", - "openAILabel": "OpenAI API key", - "openAITooltip": "The OpenAI API key to use for the AI models", - "openAIHint": "Input your OpenAI API Key", - "stabilityAILabel": "Stability API key", - "stabilityAITooltip": "The Stability API key to use for the AI models", - "stabilityAIHint": "Input your Stability API Key" - }, "login": { "title": "Account login", "loginLabel": "Log in", "logoutLabel": "Log out" + }, + "isUpToDate": "@:appName is up to date!", + "officialVersion": "Version {version} (Official build)" + }, + "workspacePage": { + "menuLabel": "Workspace", + "title": "Workspace", + "description": "Customize your workspace appearance, theme, font, text layout, date-/time-format, and language.", + "workspaceName": { + "title": "Workspace name" + }, + "workspaceIcon": { + "title": "Workspace icon", + "description": "Upload an image or use an emoji for your workspace. Icon will show in your sidebar and notifications." + }, + "appearance": { + "title": "Appearance", + "description": "Customize your workspace appearance, theme, font, text layout, date, time, and language.", + "options": { + "system": "Auto", + "light": "Light", + "dark": "Dark" + } + }, + "resetCursorColor": { + "title": "Reset document cursor color", + "description": "Are you sure you want to reset the cursor color?" + }, + "resetSelectionColor": { + "title": "Reset document selection color", + "description": "Are you sure you want to reset the selection color?" + }, + "resetWidth": { + "resetSuccess": "Reset document width successfully" + }, + "theme": { + "title": "Theme", + "description": "Select a preset theme, or upload your own custom theme.", + "uploadCustomThemeTooltip": "Upload a custom theme", + "failedToLoadThemes": "Failed to load themes, please check your permission settings in System Settings > Privacy and Security > Files and Folders > @:appName" + }, + "workspaceFont": { + "title": "Workspace font", + "noFontHint": "No font found, try another term." + }, + "textDirection": { + "title": "Text direction", + "leftToRight": "Left to right", + "rightToLeft": "Right to left", + "auto": "Auto", + "enableRTLItems": "Enable RTL toolbar items" + }, + "layoutDirection": { + "title": "Layout direction", + "leftToRight": "Left to right", + "rightToLeft": "Right to left" + }, + "dateTime": { + "title": "Date & time", + "example": "{} at {} ({})", + "24HourTime": "24-hour time", + "dateFormat": { + "label": "Date format", + "local": "Local", + "us": "US", + "iso": "ISO", + "friendly": "Friendly", + "dmy": "D/M/Y" + } + }, + "language": { + "title": "Language" + }, + "deleteWorkspacePrompt": { + "title": "Delete workspace", + "content": "Are you sure you want to delete this workspace? This action cannot be undone, and any pages you have published will be unpublished." + }, + "leaveWorkspacePrompt": { + "title": "Leave workspace", + "content": "Are you sure you want to leave this workspace? You will lose access to all pages and data within it.", + "success": "You have left the workspace successfully.", + "fail": "Failed to leave the workspace." + }, + "manageWorkspace": { + "title": "Manage workspace", + "leaveWorkspace": "Leave workspace", + "deleteWorkspace": "Delete workspace" } }, + "manageDataPage": { + "menuLabel": "Manage data", + "title": "Manage data", + "description": "Manage data local storage or Import your existing data into @:appName.", + "dataStorage": { + "title": "File storage location", + "tooltip": "The location where your files are stored", + "actions": { + "change": "Change path", + "open": "Open folder", + "openTooltip": "Open current data folder location", + "copy": "Copy path", + "copiedHint": "Path copied!", + "resetTooltip": "Reset to default location" + }, + "resetDialog": { + "title": "Are you sure?", + "description": "Resetting the path to the default data location will not delete your data. If you want to re-import your current data, you should copy the path of your current location first." + } + }, + "importData": { + "title": "Import data", + "tooltip": "Import data from @:appName backups/data folders", + "description": "Copy data from an external @:appName data folder", + "action": "Browse file" + }, + "encryption": { + "title": "Encryption", + "tooltip": "Manage how your data is stored and encrypted", + "descriptionNoEncryption": "Turning on encryption will encrypt all data. This can not be undone.", + "descriptionEncrypted": "Your data is encrypted.", + "action": "Encrypt data", + "dialog": { + "title": "Encrypt all your data?", + "description": "Encrypting all your data will keep your data safe and secure. This action can NOT be undone. Are you sure you want to continue?" + } + }, + "cache": { + "title": "Clear cache", + "description": "Help resolve issues like image not loading, missing pages in a space, and fonts not loading. This will not affect your data.", + "dialog": { + "title": "Clear cache", + "description": "Help resolve issues like image not loading, missing pages in a space, and fonts not loading. This will not affect your data.", + "successHint": "Cache cleared!" + } + }, + "data": { + "fixYourData": "Fix your data", + "fixButton": "Fix", + "fixYourDataDescription": "If you're experiencing issues with your data, you can try to fix it here." + } + }, + "shortcutsPage": { + "menuLabel": "Shortcuts", + "title": "Shortcuts", + "editBindingHint": "Input new binding", + "searchHint": "Search", + "actions": { + "resetDefault": "Reset default" + }, + "errorPage": { + "message": "Failed to load shortcuts: {}", + "howToFix": "Please try again, if the issue persists please reach out on GitHub." + }, + "resetDialog": { + "title": "Reset shortcuts", + "description": "This will reset all of your keybindings to the default, you cannot undo this later, are you sure you want to proceed?", + "buttonLabel": "Reset" + }, + "conflictDialog": { + "title": "{} is currently in use", + "descriptionPrefix": "This keybinding is currently being used by ", + "descriptionSuffix": ". If you replace this keybinding, it will be removed from {}.", + "confirmLabel": "Continue" + }, + "editTooltip": "Press to start editing the keybinding", + "keybindings": { + "toggleToDoList": "Toggle to do list", + "insertNewParagraphInCodeblock": "Insert new paragraph", + "pasteInCodeblock": "Paste in codeblock", + "selectAllCodeblock": "Select all", + "indentLineCodeblock": "Insert two spaces at line start", + "outdentLineCodeblock": "Delete two spaces at line start", + "twoSpacesCursorCodeblock": "Insert two spaces at cursor", + "copy": "Copy selection", + "paste": "Paste in content", + "cut": "Cut selection", + "alignLeft": "Align text left", + "alignCenter": "Align text center", + "alignRight": "Align text right", + "insertInlineMathEquation": "Insert inline math eqaution", + "undo": "Undo", + "redo": "Redo", + "convertToParagraph": "Convert block to paragraph", + "backspace": "Delete", + "deleteLeftWord": "Delete left word", + "deleteLeftSentence": "Delete left sentence", + "delete": "Delete right character", + "deleteMacOS": "Delete left character", + "deleteRightWord": "Delete right word", + "moveCursorLeft": "Move cursor left", + "moveCursorBeginning": "Move cursor to the beginning", + "moveCursorLeftWord": "Move cursor left one word", + "moveCursorLeftSelect": "Select and move cursor left", + "moveCursorBeginSelect": "Select and move cursor to the beginning", + "moveCursorLeftWordSelect": "Select and move cursor left one word", + "moveCursorRight": "Move cursor right", + "moveCursorEnd": "Move cursor to the end", + "moveCursorRightWord": "Move cursor right one word", + "moveCursorRightSelect": "Select and move cursor right one", + "moveCursorEndSelect": "Select and move cursor to the end", + "moveCursorRightWordSelect": "Select and move cursor to the right one word", + "moveCursorUp": "Move cursor up", + "moveCursorTopSelect": "Select and move cursor to the top", + "moveCursorTop": "Move cursor to the top", + "moveCursorUpSelect": "Select and move cursor up", + "moveCursorBottomSelect": "Select and move cursor to the bottom", + "moveCursorBottom": "Move cursor to the bottom", + "moveCursorDown": "Move cursor down", + "moveCursorDownSelect": "Select and move cursor down", + "home": "Scroll to the top", + "end": "Scroll to the bottom", + "toggleBold": "Toggle bold", + "toggleItalic": "Toggle italic", + "toggleUnderline": "Toggle underline", + "toggleStrikethrough": "Toggle strikethrough", + "toggleCode": "Toggle in-line code", + "toggleHighlight": "Toggle highlight", + "showLinkMenu": "Show link menu", + "openInlineLink": "Open in-line link", + "openLinks": "Open all selected links", + "indent": "Indent", + "outdent": "Outdent", + "exit": "Exit editing", + "pageUp": "Scroll one page up", + "pageDown": "Scroll one page down", + "selectAll": "Select all", + "pasteWithoutFormatting": "Paste content without formatting", + "showEmojiPicker": "Show emoji picker", + "enterInTableCell": "Add linebreak in table", + "leftInTableCell": "Move left one cell in table", + "rightInTableCell": "Move right one cell in table", + "upInTableCell": "Move up one cell in table", + "downInTableCell": "Move down one cell in table", + "tabInTableCell": "Go to next available cell in table", + "shiftTabInTableCell": "Go to previously available cell in table", + "backSpaceInTableCell": "Stop at the beginning of the cell" + }, + "commands": { + "codeBlockNewParagraph": "Insert a new paragraph next to the code block", + "codeBlockIndentLines": "Insert two spaces at the line start in code block", + "codeBlockOutdentLines": "Delete two spaces at the line start in code block", + "codeBlockAddTwoSpaces": "Insert two spaces at the cursor position in code block", + "codeBlockSelectAll": "Select all content inside a code block", + "codeBlockPasteText": "Paste text in codeblock", + "textAlignLeft": "Align text to the left", + "textAlignCenter": "Align text to the center", + "textAlignRight": "Align text to the right" + }, + "couldNotLoadErrorMsg": "Could not load shortcuts, Try again", + "couldNotSaveErrorMsg": "Could not save shortcuts, Try again" + }, + "aiPage": { + "title": "AI Settings", + "menuLabel": "AI Settings", + "keys": { + "enableAISearchTitle": "AI Search", + "aiSettingsDescription": "Choose your preferred model to power AppFlowy AI. Now includes GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet, and models available in Ollama", + "loginToEnableAIFeature": "AI features are only enabled after logging in with @:appName Cloud. If you don't have an @:appName account, go to 'My Account' to sign up", + "llmModel": "Language Model", + "llmModelType": "Language Model Type", + "downloadLLMPrompt": "Download {}", + "downloadAppFlowyOfflineAI": "Downloading AI offline package will enable AI to run on your device. Do you want to continue?", + "downloadLLMPromptDetail": "Downloading {} local model will take up to {} of storage. Do you want to continue?", + "downloadBigFilePrompt": "It may take around 10 minutes to complete the download", + "downloadAIModelButton": "Download", + "downloadingModel": "Downloading", + "localAILoaded": "Local AI Model successfully added and ready to use", + "localAIStart": "Local AI is starting. If it's slow, try toggling it off and on", + "localAILoading": "Local AI Chat Model is loading...", + "localAIStopped": "Local AI stopped", + "localAIRunning": "Local AI is running", + "localAINotReadyRetryLater": "Local AI is initializing, please retry later", + "localAIDisabled": "You are using local AI, but it is disabled. Please go to settings to enable it or try different model", + "localAIInitializing": "Local AI is loading. This may take a few seconds depending on your device", + "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", + "failToLoadLocalAI": "Failed to start local AI.", + "restartLocalAI": "Restart", + "disableLocalAITitle": "Disable local AI", + "disableLocalAIDescription": "Do you want to disable local AI?", + "localAIToggleTitle": "AppFlowy Local AI (LAI)", + "localAIToggleSubTitle": "Run the most advanced local AI models within AppFlowy for ultimate privacy and security", + "offlineAIInstruction1": "Follow the", + "offlineAIInstruction2": "instruction", + "offlineAIInstruction3": "to enable offline AI.", + "offlineAIDownload1": "If you have not downloaded the AppFlowy AI, please", + "offlineAIDownload2": "download", + "offlineAIDownload3": "it first", + "activeOfflineAI": "Active", + "downloadOfflineAI": "Download", + "openModelDirectory": "Open folder", + "laiNotReady": "The Local AI app was not installed correctly.", + "ollamaNotReady": "The Ollama server is not ready.", + "pleaseFollowThese": "Please follow these", + "instructions": "instructions", + "installOllamaLai": "to set up Ollama and AppFlowy Local AI.", + "modelsMissing": "Cannot find the required models: ", + "downloadModel": "to download them." + } + }, + "planPage": { + "menuLabel": "Plan", + "title": "Pricing plan", + "planUsage": { + "title": "Plan usage summary", + "storageLabel": "Storage", + "storageUsage": "{} of {} GB", + "unlimitedStorageLabel": "Unlimited storage", + "collaboratorsLabel": "Members", + "collaboratorsUsage": "{} of {}", + "aiResponseLabel": "AI Responses", + "aiResponseUsage": "{} of {}", + "unlimitedAILabel": "Unlimited responses", + "proBadge": "Pro", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "AI On-device for Mac", + "memberProToggle": "More members & unlimited AI", + "aiMaxToggle": "Unlimited AI and access to advanced models", + "aiOnDeviceToggle": "Local AI for ultimate privacy", + "aiCredit": { + "title": "Add @:appName AI Credit", + "price": "{}", + "priceDescription": "for 1,000 credits", + "purchase": "Purchase AI", + "info": "Add 1,000 Ai credits per workspace and seamlessly integrate customizable AI into your workflow for smarter, faster results with up to:", + "infoItemOne": "10,000 responses per database", + "infoItemTwo": "1,000 responses per workspace" + }, + "currentPlan": { + "bannerLabel": "Current plan", + "freeTitle": "Free", + "proTitle": "Pro", + "teamTitle": "Team", + "freeInfo": "Perfect for individuals up to 2 members to organize everything", + "proInfo": "Perfect for small and medium teams up to 10 members.", + "teamInfo": "Perfect for all productive and well-organized teams..", + "upgrade": "Change plan", + "canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}." + }, + "addons": { + "title": "Add-ons", + "addLabel": "Add", + "activeLabel": "Added", + "aiMax": { + "title": "AI Max", + "description": "Unlimited AI responses powered by advanced AI models, and 50 AI images per month", + "price": "{}", + "priceInfo": "Per user per month billed annually" + }, + "aiOnDevice": { + "title": "AI On-device for Mac", + "description": "Run Mistral 7B, LLAMA 3, and more local models on your machine", + "price": "{}", + "priceInfo": "Per user per month billed annually", + "recommend": "Recommend M1 or newer" + } + }, + "deal": { + "bannerLabel": "New year deal!", + "title": "Grow your team!", + "info": "Upgrade and save 10% off Pro and Team plans! Boost your workspace productivity with powerful new features including @:appName AI.", + "viewPlans": "View plans" + } + } + }, + "billingPage": { + "menuLabel": "Billing", + "title": "Billing", + "plan": { + "title": "Plan", + "freeLabel": "Free", + "proLabel": "Pro", + "planButtonLabel": "Change plan", + "billingPeriod": "Billing period", + "periodButtonLabel": "Edit period" + }, + "paymentDetails": { + "title": "Payment details", + "methodLabel": "Payment method", + "methodButtonLabel": "Edit method" + }, + "addons": { + "title": "Add-ons", + "addLabel": "Add", + "removeLabel": "Remove", + "renewLabel": "Renew", + "aiMax": { + "label": "AI Max", + "description": "Unlock unlimited AI and advanced models", + "activeDescription": "Next invoice due on {}", + "canceledDescription": "AI Max will be available until {}" + }, + "aiOnDevice": { + "label": "AI On-device for Mac", + "description": "Unlock unlimited AI On-device on your device", + "activeDescription": "Next invoice due on {}", + "canceledDescription": "AI On-device for Mac will be available until {}" + }, + "removeDialog": { + "title": "Remove {}", + "description": "Are you sure you want to remove {plan}? You will lose access to the features and benefits of {plan} immediately." + } + }, + "currentPeriodBadge": "CURRENT", + "changePeriod": "Change period", + "planPeriod": "{} period", + "monthlyInterval": "Monthly", + "monthlyPriceInfo": "per seat billed monthly", + "annualInterval": "Annually", + "annualPriceInfo": "per seat billed annually" + }, + "comparePlanDialog": { + "title": "Compare & select plan", + "planFeatures": "Plan\nFeatures", + "current": "Current", + "actions": { + "upgrade": "Upgrade", + "downgrade": "Downgrade", + "current": "Current" + }, + "freePlan": { + "title": "Free", + "description": "For individuals up to 2 members to organize everything", + "price": "{}", + "priceInfo": "Free forever" + }, + "proPlan": { + "title": "Pro", + "description": "For small teams to manage projects and team knowledge", + "price": "{}", + "priceInfo": "Per user per month \nbilled annually\n\n{} billed monthly" + }, + "planLabels": { + "itemOne": "Workspaces", + "itemTwo": "Members", + "itemThree": "Storage", + "itemFour": "Real-time collaboration", + "itemFive": "Mobile app", + "itemSix": "AI Responses", + "itemSeven": "AI Images", + "itemFileUpload": "File uploads", + "customNamespace": "Custom namespace", + "tooltipSix": "Lifetime means the number of responses never reset", + "intelligentSearch": "Intelligent search", + "tooltipSeven": "Allows you to customize part of the URL for your workspace", + "customNamespaceTooltip": "Custom published site URL" + }, + "freeLabels": { + "itemOne": "Charged per workspace", + "itemTwo": "Up to 2", + "itemThree": "5 GB", + "itemFour": "yes", + "itemFive": "yes", + "itemSix": "10 lifetime", + "itemSeven": "2 lifetime", + "itemFileUpload": "Up to 7 MB", + "intelligentSearch": "Intelligent search" + }, + "proLabels": { + "itemOne": "Charged per workspace", + "itemTwo": "Up to 10", + "itemThree": "Unlimited", + "itemFour": "yes", + "itemFive": "yes", + "itemSix": "Unlimited", + "itemSeven": "10 images per month", + "itemFileUpload": "Unlimited", + "intelligentSearch": "Intelligent search" + }, + "paymentSuccess": { + "title": "You are now on the {} plan!", + "description": "Your payment has been successfully processed and your plan is upgraded to @:appName {}. You can view your plan details on the Plan page" + }, + "downgradeDialog": { + "title": "Are you sure you want to downgrade your plan?", + "description": "Downgrading your plan will revert you back to the Free plan. Members may lose access to this workspace and you may need to free up space to meet the storage limits of the Free plan.", + "downgradeLabel": "Downgrade plan" + } + }, + "cancelSurveyDialog": { + "title": "Sorry to see you go", + "description": "We're sorry to see you go. We'd love to hear your feedback to help us improve @:appName. Please take a moment to answer a few questions.", + "commonOther": "Other", + "otherHint": "Write your answer here", + "questionOne": { + "question": "What prompted you to cancel your @:appName Pro subscription?", + "answerOne": "Cost too high", + "answerTwo": "Features did not meet expectations", + "answerThree": "Found a better alternative", + "answerFour": "Did not use it enough to justify the expense", + "answerFive": "Service issue or technical difficulties" + }, + "questionTwo": { + "question": "How likely are you to consider re-subscribing to @:appName Pro in the future?", + "answerOne": "Very likely", + "answerTwo": "Somewhat likely", + "answerThree": "Not sure", + "answerFour": "Unlikely", + "answerFive": "Very unlikely" + }, + "questionThree": { + "question": "Which Pro feature did you value the most during your subscription?", + "answerOne": "Multi-user collaboration", + "answerTwo": "Longer time version history", + "answerThree": "Unlimited AI responses", + "answerFour": "Access to local AI models" + }, + "questionFour": { + "question": "How would you describe your overall experience with @:appName?", + "answerOne": "Great", + "answerTwo": "Good", + "answerThree": "Average", + "answerFour": "Below average", + "answerFive": "Unsatisfied" + } + }, + "common": { + "uploadingFile": "File is uploading. Please do not quit the app", + "uploadNotionSuccess": "Your Notion zip file has been uploaded successfully. Once the import is complete, you will receive a confirmation email", + "reset": "Reset" + }, "menu": { "appearance": "Appearance", "language": "Language", @@ -355,34 +1135,35 @@ "notifications": "Notifications", "open": "Open Settings", "logout": "Logout", - "logoutPrompt": "Are you sure to logout?", + "logoutPrompt": "Are you sure you want to logout?", "selfEncryptionLogoutPrompt": "Are you sure you want to log out? Please ensure you have copied the encryption secret", "syncSetting": "Sync Setting", "cloudSettings": "Cloud Settings", "enableSync": "Enable sync", + "enableSyncLog": "Enable sync logging", + "enableSyncLogWarning": "Thank you for helping diagnose sync issues. This will log your document edits to a local file. Please quit and reopen the app after enabling", "enableEncrypt": "Encrypt data", "cloudURL": "Base URL", + "webURL": "Web URL", "invalidCloudURLScheme": "Invalid Scheme", "cloudServerType": "Cloud server", "cloudServerTypeTip": "Please note that it might log out your current account after switching the cloud server", "cloudLocal": "Local", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "Supabase URL", - "cloudSupabaseUrlCanNotBeEmpty": "The supabase url can't be empty", - "cloudSupabaseAnonKey": "Supabase anon key", - "cloudSupabaseAnonKeyCanNotBeEmpty": "The anon key can't be empty", - "cloudAppFlowy": "AppFlowy Cloud Beta", - "cloudAppFlowySelfHost": "AppFlowy Cloud Self-hosted", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName Cloud Self-hosted", "appFlowyCloudUrlCanNotBeEmpty": "The cloud url can't be empty", - "clickToCopy": "Click to copy", + "clickToCopy": "Copy to clipboard", "selfHostStart": "If you don't have a server, please refer to the", "selfHostContent": "document", "selfHostEnd": "for guidance on how to self-host your own server", + "pleaseInputValidURL": "Please input a valid URL", + "changeUrl": "Change self-hosted url to {}", "cloudURLHint": "Input the base URL of your server", + "webURLHint": "Input the base URL of your web server", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "Input the websocket address of your server", "restartApp": "Restart", - "restartAppTip": "Restart the application for the changes to take effect. Please note that this might log out your current account", + "restartAppTip": "Restart the application for the changes to take effect. Please note that this might log out your current account.", "changeServerTip": "After changing the server, you must click the restart button for the changes to take effect", "enableEncryptPrompt": "Activate encryption to secure your data with this secret. Store it safely; once enabled, it can't be turned off. If lost, your data becomes irretrievable. Click to copy", "inputEncryptPrompt": "Please enter your encryption secret for", @@ -393,18 +1174,62 @@ "historicalUserList": "User login history", "historicalUserListTooltip": "This list displays your anonymous accounts. You can click on an account to view its details. Anonymous accounts are created by clicking the 'Get Started' button", "openHistoricalUser": "Click to open the anonymous account", - "customPathPrompt": "Storing the AppFlowy data folder in a cloud-synced folder such as Google Drive can pose risks. If the database within this folder is accessed or modified from multiple locations at the same time, it may result in synchronization conflicts and potential data corruption", - "importAppFlowyData": "Import Data from External AppFlowy Folder", + "customPathPrompt": "Storing the @:appName data folder in a cloud-synced folder such as Google Drive can pose risks. If the database within this folder is accessed or modified from multiple locations at the same time, it may result in synchronization conflicts and potential data corruption", + "importAppFlowyData": "Import Data from External @:appName Folder", "importingAppFlowyDataTip": "Data import is in progress. Please do not close the app", - "importAppFlowyDataDescription": "Copy data from an external AppFlowy data folder and import it into the current AppFlowy data folder", - "importSuccess": "Successfully imported the AppFlowy data folder", - "importFailed": "Importing the AppFlowy data folder failed", + "importAppFlowyDataDescription": "Copy data from an external @:appName data folder and import it into the current AppFlowy data folder", + "importSuccess": "Successfully imported the @:appName data folder", + "importFailed": "Importing the @:appName data folder failed", "importGuide": "For further details, please check the referenced document" }, "notifications": { "enableNotifications": { "label": "Enable notifications", "hint": "Turn off to stop local notifications from appearing." + }, + "showNotificationsIcon": { + "label": "Show notifications icon", + "hint": "Toggle off to hide the notification icon in the sidebar." + }, + "archiveNotifications": { + "allSuccess": "Archived all notifications successfully", + "success": "Archived notification successfully" + }, + "markAsReadNotifications": { + "allSuccess": "Marked all as read successfully", + "success": "Marked as read successfully" + }, + "action": { + "markAsRead": "Mark as read", + "multipleChoice": "Select more", + "archive": "Archive" + }, + "settings": { + "settings": "Settings", + "markAllAsRead": "Mark all as read", + "archiveAll": "Archive all" + }, + "emptyInbox": { + "title": "Inbox Zero!", + "description": "Set reminders to receive notifications here." + }, + "emptyUnread": { + "title": "No unread notifications", + "description": "You're all caught up!" + }, + "emptyArchived": { + "title": "No archived", + "description": "Archived notifications will appear here." + }, + "tabs": { + "inbox": "Inbox", + "unread": "Unread", + "archived": "Archived" + }, + "refreshSuccess": "Notifications refreshed successfully", + "titles": { + "notifications": "Notifications", + "reminder": "Reminder" } }, "appearance": { @@ -421,9 +1246,15 @@ "system": "Adapt to System" }, "fontScaleFactor": "Font Scale Factor", + "displaySize": "Display Size", "documentSettings": { "cursorColor": "Document cursor color", "selectionColor": "Document selection color", + "width": "Document width", + "changeWidth": "Change", + "pickColor": "Select a color", + "colorShade": "Color shade", + "opacity": "Opacity", "hexEmptyError": "Hex color cannot be empty", "hexLengthError": "Hex value must be 6 digits long", "hexInvalidError": "Invalid hex value", @@ -450,7 +1281,7 @@ "themeUpload": { "button": "Upload", "uploadTheme": "Upload theme", - "description": "Upload your own AppFlowy theme using the button below.", + "description": "Upload your own @:appName theme using the button below.", "loading": "Please wait while we validate and upload your theme...", "uploadSuccess": "Your theme was uploaded successfully", "deletionFailure": "Failed to delete the theme. Try to delete it manually.", @@ -476,18 +1307,21 @@ "showNamingDialogWhenCreatingPage": "Show naming dialog when creating a page", "enableRTLToolbarItems": "Enable RTL toolbar items", "members": { - "title": "Members Settings", - "inviteMembers": "Invite Members", - "sendInvite": "Send Invite", - "copyInviteLink": "Copy Invite Link", + "title": "Members settings", + "inviteMembers": "Invite members", + "inviteHint": "Invite by email", + "sendInvite": "Send invite", + "copyInviteLink": "Copy invite link", "label": "Members", "user": "User", "role": "Role", "removeFromWorkspace": "Remove from Workspace", + "removeFromWorkspaceSuccess": "Remove from workspace successfully", + "removeFromWorkspaceFailed": "Remove from workspace failed", "owner": "Owner", "guest": "Guest", "member": "Member", - "memberHintText": "A member can read, comment, and edit pages. Invite members and guests.", + "memberHintText": "A member can read and edit pages", "guestHintText": "A Guest can read, react, comment, and can edit certain pages with permission.", "emailInvalidError": "Invalid email, please check and try again", "emailSent": "Email sent, please check the inbox", @@ -497,13 +1331,21 @@ "one": "{} member", "other": "{} members" }, - "memberLimitExceeded": "You've reached the maximum member limit allowed for your account. If you want to add more additional members to continue your work, please request on Github", + "inviteFailedDialogTitle": "Failed to send invite", + "inviteFailedMemberLimit": "Member limit has been reached, please upgrade to invite more members.", + "inviteFailedMemberLimitMobile": "Your workspace has reached the member limit.", + "memberLimitExceeded": "Member limit reached, to invite more members, please ", + "memberLimitExceededUpgrade": "upgrade", + "memberLimitExceededPro": "Member limit reached, if you require more members contact ", + "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Failed to add member", "addMemberSuccess": "Member added successfully", "removeMember": "Remove Member", "areYouSureToRemoveMember": "Are you sure you want to remove this member?", "inviteMemberSuccess": "The invitation has been sent successfully", - "failedToInviteMember": "Failed to invite member" + "failedToInviteMember": "Failed to invite member", + "workspaceMembersError": "Oops, something went wrong", + "workspaceMembersErrorDescription": "We couldn't load the member list at this time. Please try again later" } }, "files": { @@ -511,7 +1353,7 @@ "defaultLocation": "Read files and data storage location", "exportData": "Export your data", "doubleTapToCopy": "Double tap to copy the path", - "restoreLocation": "Restore to AppFlowy default path", + "restoreLocation": "Restore to @:appName default path", "customizeLocation": "Open another folder", "restartApp": "Please restart app for the changes to take effect.", "exportDatabase": "Export database", @@ -523,10 +1365,10 @@ "defineWhereYourDataIsStored": "Define where your data is stored", "open": "Open", "openFolder": "Open an existing folder", - "openFolderDesc": "Read and write it to your existing AppFlowy folder", + "openFolderDesc": "Read and write it to your existing @:appName folder", "folderHintText": "folder name", "location": "Creating a new folder", - "locationDesc": "Pick a name for your AppFlowy data folder", + "locationDesc": "Pick a name for your @:appName data folder", "browser": "Browse", "create": "Create", "set": "Set", @@ -537,7 +1379,7 @@ "change": "Change", "openLocationTooltips": "Open another data directory", "openCurrentDataFolder": "Open current data directory", - "recoverLocationTooltips": "Reset to AppFlowy's default data directory", + "recoverLocationTooltips": "Reset to @:appName's default data directory", "exportFileSuccess": "Export file successfully!", "exportFileFail": "Export file failed!", "export": "Export", @@ -551,32 +1393,9 @@ "email": "Email", "tooltipSelectIcon": "Select icon", "selectAnIcon": "Select an icon", - "pleaseInputYourOpenAIKey": "please input your OpenAI key", - "pleaseInputYourStabilityAIKey": "please input your Stability AI key", + "pleaseInputYourOpenAIKey": "please input your AI key", "clickToLogout": "Click to logout the current user" }, - "shortcuts": { - "shortcutsLabel": "Shortcuts", - "command": "Command", - "keyBinding": "Keybinding", - "addNewCommand": "Add New Command", - "updateShortcutStep": "Press desired key combination and press ENTER", - "shortcutIsAlreadyUsed": "This shortcut is already used for: {conflict}", - "resetToDefault": "Reset to default keybindings", - "couldNotLoadErrorMsg": "Could not load shortcuts, Try again", - "couldNotSaveErrorMsg": "Could not save shortcuts, Try again", - "commands": { - "codeBlockNewParagraph": "Insert a new paragraph next to the code block", - "codeBlockIndentLines": "Insert two spaces at the line start in code block", - "codeBlockOutdentLines": "Delete two spaces at the line start in code block", - "codeBlockAddTwoSpaces": "Insert two spaces at the cursor position in code block", - "codeBlockSelectAll": "Select all content inside a code block", - "codeBlockPasteText": "Paste text in codeblock", - "textAlignLeft": "Align text to the left", - "textAlignCenter": "Align text to the center", - "textAlignRight": "Align text to the right" - } - }, "mobile": { "personalInfo": "Personal Information", "username": "User Name", @@ -610,9 +1429,10 @@ "group": "Group", "addFilter": "Add Filter", "deleteFilter": "Delete filter", - "filterBy": "Filter by...", + "filterBy": "Filter by", "typeAValue": "Type a value...", "layout": "Layout", + "compactMode": "Compact mode", "databaseLayout": "Layout", "viewList": { "zero": "0 views", @@ -627,6 +1447,13 @@ "deleteView": "Delete view", "numberOfVisibleFields": "{} shown" }, + "filter": { + "empty": "No active filters", + "addFilter": "Add filter", + "cannotFindCreatableField": "Cannot find a suitable field to filter by", + "conditon": "Condition", + "where": "Where" + }, "textFilter": { "contains": "Contains", "doesNotContain": "Does not contain", @@ -652,8 +1479,8 @@ } }, "checklistFilter": { - "isComplete": "is complete", - "isIncomplted": "is incomplete" + "isComplete": "Is complete", + "isIncomplted": "Is incomplete" }, "selectOptionFilter": { "is": "Is", @@ -664,7 +1491,7 @@ "isNotEmpty": "Is not empty" }, "dateFilter": { - "is": "Is", + "is": "Is on", "before": "Is before", "after": "Is after", "onOrBefore": "Is on or before", @@ -672,9 +1499,12 @@ "between": "Is between", "empty": "Is empty", "notEmpty": "Is not empty", + "startDate": "Start date", + "endDate": "End date", "choicechipPrefix": { "before": "Before", "after": "After", + "between": "Between", "onOrBefore": "On or before", "onOrAfter": "On or after", "isEmpty": "Is empty", @@ -692,14 +1522,16 @@ "isNotEmpty": "Is not empty" }, "field": { - "hide": "Hide", - "show": "Show", - "insertLeft": "Insert Left", - "insertRight": "Insert Right", + "label": "Property", + "hide": "Hide property", + "show": "Show property", + "insertLeft": "Insert left", + "insertRight": "Insert right", "duplicate": "Duplicate", "delete": "Delete", "wrapCellContent": "Wrap text", "clear": "Clear cells", + "switchPrimaryFieldTooltip": "Cannot change field type of primary field", "textFieldName": "Text", "checkboxFieldName": "Checkbox", "dateFieldName": "Date", @@ -712,6 +1544,10 @@ "checklistFieldName": "Checklist", "relationFieldName": "Relation", "summaryFieldName": "AI Summary", + "timeFieldName": "Time", + "mediaFieldName": "Files & media", + "translateFieldName": "AI Translate", + "translateTo": "Translate to", "numberFormat": "Number format", "dateFormat": "Date format", "includeTime": "Include time", @@ -740,9 +1576,10 @@ "addOption": "Add option", "editProperty": "Edit property", "newProperty": "New property", - "deleteFieldPromptMessage": "Are you sure? This property will be deleted", + "openRowDocument": "Open as a page", + "deleteFieldPromptMessage": "Are you sure? This property and all its data will be deleted", "clearFieldPromptMessage": "Are you sure? All cells in this column will be emptied", - "newColumn": "New Column", + "newColumn": "New column", "format": "Format", "reminderOnDateTooltip": "This cell has a scheduled reminder", "optionAlreadyExist": "Option already exists" @@ -770,11 +1607,13 @@ "empty": "No active sorts", "cannotFindCreatableField": "Cannot find a suitable field to sort by", "deleteAllSorts": "Delete all sorts", - "addSort": "Add new sort", - "removeSorting": "Would you like to remove sorting?", + "addSort": "Add sort", + "sortsActive": "Cannot {intention} while sorting", + "removeSorting": "Would you like to remove all the sorts in this view and continue?", "fieldInUse": "You are already sorting by this field" }, "row": { + "label": "Row", "duplicate": "Duplicate", "delete": "Delete", "titlePlaceholder": "Untitled", @@ -782,13 +1621,19 @@ "copyProperty": "Copied property to clipboard", "count": "Count", "newRow": "New row", + "loadMore": "Load more", "action": "Action", "add": "Click add to below", "drag": "Drag to move", + "deleteRowPrompt": "Are you sure you want to delete this row? This action cannot be undone.", + "deleteCardPrompt": "Are you sure you want to delete this card? This action cannot be undone.", "dragAndClick": "Drag to move, click to open menu", "insertRecordAbove": "Insert record above", "insertRecordBelow": "Insert record below", - "noContent": "No content" + "noContent": "No content", + "reorderRowDescription": "reorder row", + "createRowAboveDescription": "create a row above", + "createRowBelowDescription": "insert a row below" }, "selectOption": { "create": "Create", @@ -821,8 +1666,7 @@ "url": { "launch": "Open link in browser", "copy": "Copy link to clipboard", - "textFieldHint": "Enter a URL", - "copiedNotification": "Copied to clipboard!" + "textFieldHint": "Enter a URL" }, "relation": { "relatedDatabasePlaceLabel": "Related Database", @@ -849,6 +1693,24 @@ "countEmptyShort": "EMPTY", "countNonEmpty": "Count not empty", "countNonEmptyShort": "FILLED" + }, + "media": { + "rename": "Rename", + "download": "Download", + "expand": "Expand", + "delete": "Delete", + "moreFilesHint": "+{}", + "addFileOrImage": "Add file or link", + "attachmentsHint": "{}", + "addFileMobile": "Add file", + "extraCount": "+{}", + "deleteFileDescription": "Are you sure you want to delete this file? This action is irreversible.", + "showFileNames": "Show file name", + "downloadSuccess": "File downloaded", + "downloadFailedToken": "Failed to download file, user token unavailable", + "setAsCover": "Set as cover", + "openInBrowser": "Open in browser", + "embedLink": "Embed file link" } }, "document": { @@ -857,6 +1719,7 @@ "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, + "creating": "Creating...", "slashMenu": { "board": { "selectABoardToLinkTo": "Select a Board to link to", @@ -872,6 +1735,62 @@ }, "document": { "selectADocumentToLinkTo": "Select a Document to link to" + }, + "name": { + "textStyle": "Text Style", + "list": "List", + "toggle": "Toggle", + "fileAndMedia": "File & Media", + "simpleTable": "Simple Table", + "visuals": "Visuals", + "document": "Document", + "advanced": "Advanced", + "text": "Text", + "heading1": "Heading 1", + "heading2": "Heading 2", + "heading3": "Heading 3", + "image": "Image", + "bulletedList": "Bulleted list", + "numberedList": "Numbered list", + "todoList": "To-do list", + "doc": "Doc", + "linkedDoc": "Link to page", + "grid": "Grid", + "linkedGrid": "Linked Grid", + "kanban": "Kanban", + "linkedKanban": "Linked Kanban", + "calendar": "Calendar", + "linkedCalendar": "Linked Calendar", + "quote": "Quote", + "divider": "Divider", + "table": "Table", + "callout": "Callout", + "outline": "Outline", + "mathEquation": "Math Equation", + "code": "Code", + "toggleList": "Toggle list", + "toggleHeading1": "Toggle heading 1", + "toggleHeading2": "Toggle heading 2", + "toggleHeading3": "Toggle heading 3", + "emoji": "Emoji", + "aiWriter": "Ask AI Anything", + "dateOrReminder": "Date or Reminder", + "photoGallery": "Photo Gallery", + "file": "File", + "twoColumns": "2 Columns", + "threeColumns": "3 Columns", + "fourColumns": "4 Columns" + }, + "subPage": { + "name": "Document", + "keyword1": "sub page", + "keyword2": "page", + "keyword3": "child page", + "keyword4": "insert page", + "keyword5": "embed page", + "keyword6": "new page", + "keyword7": "create page", + "keyword8": "document" } }, "selectionMenu": { @@ -883,34 +1802,77 @@ "referencedGrid": "Referenced Grid", "referencedCalendar": "Referenced Calendar", "referencedDocument": "Referenced Document", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Ask AI to write anything...", + "aiWriter": { + "userQuestion": "Ask AI anything", + "continueWriting": "Continue writing", + "fixSpelling": "Fix spelling & grammar", + "improveWriting": "Improve writing", + "summarize": "Summarize", + "explain": "Explain", + "makeShorter": "Make shorter", + "makeLonger": "Make longer" + }, + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Ask AI to write anything...", "autoGeneratorLearnMore": "Learn more", "autoGeneratorGenerate": "Generate", - "autoGeneratorHintText": "Ask OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key", + "autoGeneratorHintText": "Ask AI ...", + "autoGeneratorCantGetOpenAIKey": "Can't get AI key", "autoGeneratorRewrite": "Rewrite", - "smartEdit": "AI Assistants", - "openAI": "OpenAI", - "smartEditFixSpelling": "Fix spelling", + "smartEdit": "Ask AI", + "aI": "AI", + "smartEditFixSpelling": "Fix spelling & grammar", "warning": "⚠️ AI responses can be inaccurate or misleading.", "smartEditSummarize": "Summarize", "smartEditImproveWriting": "Improve writing", "smartEditMakeLonger": "Make longer", - "smartEditCouldNotFetchResult": "Could not fetch result from OpenAI", - "smartEditCouldNotFetchKey": "Could not fetch OpenAI key", - "smartEditDisabled": "Connect OpenAI in Settings", - "discardResponse": "Do you want to discard the AI responses?", + "smartEditCouldNotFetchResult": "Could not fetch result from AI", + "smartEditCouldNotFetchKey": "Could not fetch AI key", + "smartEditDisabled": "Connect AI in Settings", + "appflowyAIEditDisabled": "Sign in to enable AI features", + "discardResponse": "Are you sure you want to discard the AI response?", "createInlineMathEquation": "Create equation", "fonts": "Fonts", "insertDate": "Insert date", "emoji": "Emoji", "toggleList": "Toggle list", + "emptyToggleHeading": "Empty toggle h{}. Click to add content.", + "emptyToggleList": "Empty toggle list. Click to add content.", + "emptyToggleHeadingWeb": "Empty toggle h{level}. Click to add content", "quoteList": "Quote list", "numberedList": "Numbered list", "bulletedList": "Bulleted list", - "todoList": "Todo List", + "todoList": "Todo list", "callout": "Callout", + "simpleTable": { + "moreActions": { + "color": "Color", + "align": "Align", + "delete": "Delete", + "duplicate": "Duplicate", + "insertLeft": "Insert left", + "insertRight": "Insert right", + "insertAbove": "Insert above", + "insertBelow": "Insert below", + "headerColumn": "Header column", + "headerRow": "Header row", + "clearContents": "Clear contents", + "setToPageWidth": "Set to page width", + "distributeColumnsWidth": "Distribute columns evenly", + "duplicateRow": "Duplicate row", + "duplicateColumn": "Duplicate column", + "textColor": "Text color", + "cellBackgroundColor": "Cell background color", + "duplicateTable": "Duplicate table" + }, + "clickToAddNewRow": "Click to add a new row", + "clickToAddNewColumn": "Click to add a new column", + "clickToAddNewRowAndColumn": "Click to add a new row and column", + "headerName": { + "table": "Table", + "alignText": "Align text" + } + }, "cover": { "changeCover": "Change Cover", "colors": "Colors", @@ -926,6 +1888,7 @@ "back": "Back", "saveToGallery": "Save to gallery", "removeIcon": "Remove icon", + "removeCover": "Remove cover", "pasteImageUrl": "Paste image URL", "or": "OR", "pickFromFiles": "Pick from files", @@ -944,6 +1907,8 @@ "optionAction": { "click": "Click", "toOpenMenu": " to open menu", + "drag": "Drag", + "toMove": " to move", "delete": "Delete", "duplicate": "Duplicate", "turnInto": "Turn into", @@ -955,14 +1920,36 @@ "center": "Center", "right": "Right", "defaultColor": "Default", - "depth": "Depth" + "depth": "Depth", + "copyLinkToBlock": "Copy link to block" }, "image": { + "addAnImage": "Add images", "copiedToPasteBoard": "The image link has been copied to the clipboard", - "addAnImage": "Add an image", + "addAnImageDesktop": "Add an image", + "addAnImageMobile": "Click to add one or more images", + "dropImageToInsert": "Drop images to insert", "imageUploadFailed": "Image upload failed", + "imageDownloadFailed": "Image download failed, please try again", + "imageDownloadFailedToken": "Image download failed due to missing user token, please try again", "errorCode": "Error code" }, + "photoGallery": { + "name": "Photo gallery", + "imageKeyword": "image", + "imageGalleryKeyword": "image gallery", + "photoKeyword": "photo", + "photoBrowserKeyword": "photo browser", + "galleryKeyword": "gallery", + "addImageTooltip": "Add image", + "changeLayoutTooltip": "Change layout", + "browserLayout": "Browser", + "gridLayout": "Grid", + "deleteBlockTooltip": "Delete whole gallery" + }, + "math": { + "copiedToPasteBoard": "The math equation has been copied to the clipboard" + }, "urlPreview": { "copiedToPasteBoard": "The link has been copied to the clipboard", "convertToLink": "Convert to embed link" @@ -982,7 +1969,8 @@ "contextMenu": { "copy": "Copy", "cut": "Cut", - "paste": "Paste" + "paste": "Paste", + "pasteAsPlainText": "Paste as plain text" }, "action": "Actions", "database": { @@ -993,7 +1981,73 @@ "newDatabase": "New Database", "linkToDatabase": "Link to Database" }, - "date": "Date" + "date": "Date", + "video": { + "label": "Video", + "emptyLabel": "Add a video", + "placeholder": "Paste the video link", + "copiedToPasteBoard": "The video link has been copied to the clipboard", + "insertVideo": "Add video", + "invalidVideoUrl": "The source URL is not supported yet.", + "invalidVideoUrlYouTube": "YouTube is not supported yet.", + "supportedFormats": "Supported formats: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" + }, + "file": { + "name": "File", + "uploadTab": "Upload", + "uploadMobile": "Choose a file", + "uploadMobileGallery": "From Photo Gallery", + "networkTab": "Embed link", + "placeholderText": "Upload or embed a file", + "placeholderDragging": "Drop the file to upload", + "dropFileToUpload": "Drop a file to upload", + "fileUploadHint": "Drag & drop a file or click to ", + "fileUploadHintSuffix": "Browse", + "networkHint": "Paste a file link", + "networkUrlInvalid": "Invalid URL. Check the URL and try again.", + "networkAction": "Embed", + "fileTooBigError": "File size is too big, please upload a file with size less than 10MB", + "renameFile": { + "title": "Rename file", + "description": "Enter the new name for this file", + "nameEmptyError": "File name cannot be left empty." + }, + "uploadedAt": "Uploaded on {}", + "linkedAt": "Link added on {}", + "failedToOpenMsg": "Failed to open, file not found" + }, + "subPage": { + "handlingPasteHint": " - (handling paste)", + "errors": { + "failedDeletePage": "Failed to delete page", + "failedCreatePage": "Failed to create page", + "failedMovePage": "Failed to move page to this document", + "failedDuplicatePage": "Failed to duplicate page", + "failedDuplicateFindView": "Failed to duplicate page - original view not found" + } + }, + "cannotMoveToItsChildren": "Cannot move to its children", + "linkPreview": { + "typeSelection": { + "pasteAs": "Paste as", + "mention": "Mention", + "URL": "URL", + "bookmark": "Bookmark", + "embed": "Embed" + }, + "linkPreviewMenu": { + "toMetion": "Convert to Mention", + "toUrl": "Convert to URL", + "toEmbed": "Convert to Embed", + "toBookmark": "Convert to Bookmark", + "copyLink": "Copy Link", + "replace": "Replace", + "reload": "Reload", + "removeLink": "Remove Link", + "pasteHint": "Paste in https://...", + "unableToDisplay": "unable to display" + } + } }, "outlineBlock": { "placeholder": "Table of Contents" @@ -1005,7 +2059,7 @@ "placeholder": "Untitled" }, "imageBlock": { - "placeholder": "Click to add image", + "placeholder": "Click to add image(s)", "upload": { "label": "Upload", "placeholder": "Click to upload image" @@ -1015,8 +2069,8 @@ "placeholder": "Enter image URL" }, "ai": { - "label": "Generate image from OpenAI", - "placeholder": "Please input the prompt for OpenAI to generate image" + "label": "Generate image from AI", + "placeholder": "Please input the prompt for AI to generate image" }, "stability_ai": { "label": "Generate image from Stability AI", @@ -1028,7 +2082,8 @@ "invalidImageSize": "Image size must be less than 5MB", "invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "Invalid image URL", - "noImage": "No such file or directory" + "noImage": "No such file or directory", + "multipleImagesFailed": "One or more images failed to upload, please try again" }, "embedLink": { "label": "Embed link", @@ -1038,15 +2093,29 @@ "label": "Unsplash" }, "searchForAnImage": "Search for an image", - "pleaseInputYourOpenAIKey": "please input your OpenAI key in Settings page", - "pleaseInputYourStabilityAIKey": "please input your Stability AI key in Settings page", + "pleaseInputYourOpenAIKey": "please input your AI key in Settings page", "saveImageToGallery": "Save image", - "failedToAddImageToGallery": "Failed to add image to gallery", - "successToAddImageToGallery": "Image added to gallery successfully", + "failedToAddImageToGallery": "Failed to save image", + "successToAddImageToGallery": "Saved image to Photos", "unableToLoadImage": "Unable to load image", "maximumImageSize": "Maximum supported upload image size is 10MB", "uploadImageErrorImageSizeTooBig": "Image size must be less than 10MB", - "imageIsUploading": "Image is uploading" + "imageIsUploading": "Image is uploading", + "openFullScreen": "Open in full screen", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "Previous image", + "nextImageTooltip": "Next image", + "zoomOutTooltip": "Zoom out", + "zoomInTooltip": "Zoom in", + "changeZoomLevelTooltip": "Change zoom level", + "openLocalImage": "Open image", + "downloadImage": "Download image", + "closeViewer": "Close interactive viewer", + "scalePercentage": "{}%", + "deleteImageTooltip": "Delete image" + } + } }, "codeBlock": { "language": { @@ -1054,7 +2123,7 @@ "placeholder": "Select language", "auto": "Auto" }, - "copyTooltip": "Copy contents of the code block", + "copyTooltip": "Copy", "searchLanguageHint": "Search for a language", "codeCopiedSnackbar": "Code copied to clipboard!" }, @@ -1079,23 +2148,57 @@ "tooltip": "Click to open page" }, "deleted": "Deleted", - "deletedContent": "This content does not exist or has been deleted" + "deletedContent": "This content does not exist or has been deleted", + "noAccess": "No Access", + "deletedPage": "Deleted page", + "trashHint": " - in trash", + "morePages": "more pages" }, "toolbar": { - "resetToDefaultFont": "Reset to default" + "resetToDefaultFont": "Reset to default", + "textSize": "Text size", + "textColor": "Text color", + "h1": "Heading 1", + "h2": "Heading 2", + "h3": "Heading 3", + "alignLeft": "Align left", + "alignRight": "Align right", + "alignCenter": "Align center", + "link": "Link", + "textAlign": "Text align", + "moreOptions": "More options", + "font": "Font", + "inlineCode": "Inline code", + "suggestions": "Suggestions", + "turnInto": "Turn into", + "equation": "Equation", + "insert": "Insert", + "linkInputHint": "Paste link or search pages", + "pageOrURL": "Page or URL", + "linkName": "Link Name", + "linkNameHint": "Input link name" }, "errorBlock": { - "theBlockIsNotSupported": "The current version does not support this block.", - "blockContentHasBeenCopied": "The block content has been copied." + "theBlockIsNotSupported": "Unable to parse the block content", + "clickToCopyTheBlockContent": "Click to copy the block content", + "blockContentHasBeenCopied": "The block content has been copied.", + "parseError": "An error occurred while parsing the {} block.", + "copyBlockContent": "Copy block content" }, "mobilePageSelector": { "title": "Select page", "failedToLoad": "Failed to load page list", "noPagesFound": "No pages found" + }, + "attachmentMenu": { + "choosePhoto": "Choose photo", + "takePicture": "Take a picture", + "chooseFile": "Choose file" } }, "board": { "column": { + "label": "Column", "createNewCard": "New", "renameGroupTooltip": "Press to rename group", "createNewColumn": "Add a new group", @@ -1105,7 +2208,7 @@ "hideColumn": "Hide", "newGroup": "New group", "deleteColumn": "Delete", - "deleteColumnConfirmation": "This will delete this group and all the cards in it.\nAre you sure you want to continue?" + "deleteColumnConfirmation": "This will delete this group and all the cards in it. Are you sure you want to continue?" }, "hiddenGroupSection": { "sectionTitle": "Hidden Groups", @@ -1125,6 +2228,7 @@ "ungroupedButtonTooltip": "Contains cards that don't belong in any group", "ungroupedItemsTitle": "Click to add to the board", "groupBy": "Group by", + "groupCondition": "Group condition", "referencedBoardPrefix": "View of", "notesTooltip": "Notes inside", "mobile": { @@ -1132,6 +2236,22 @@ "showGroup": "Show group", "showGroupContent": "Are you sure you want to show this group on the board?", "failedToLoad": "Failed to load board view" + }, + "dateCondition": { + "weekOf": "Week of {} - {}", + "today": "Today", + "yesterday": "Yesterday", + "tomorrow": "Tomorrow", + "lastSevenDays": "Last 7 days", + "nextSevenDays": "Next 7 days", + "lastThirtyDays": "Last 30 days", + "nextThirtyDays": "Next 30 days" + }, + "noGroup": "No group by property", + "noGroupDesc": "Board views require a property to group by in order to display", + "media": { + "cardText": "{} {}", + "fallbackName": "files" } }, "calendar": { @@ -1142,7 +2262,13 @@ "today": "Today", "jumpToday": "Jump to Today", "previousMonth": "Previous Month", - "nextMonth": "Next Month" + "nextMonth": "Next Month", + "views": { + "day": "Day", + "week": "Week", + "month": "Month", + "year": "Year" + } }, "mobileEventScreen": { "emptyTitle": "No events yet", @@ -1162,26 +2288,30 @@ }, "unscheduledEventsTitle": "Unscheduled events", "clickToAdd": "Click to add to the calendar", - "name": "Calendar settings" + "name": "Calendar settings", + "clickToOpen": "Click to open the record" }, "referencedCalendarPrefix": "View of", "quickJumpYear": "Jump to", "duplicateEvent": "Duplicate event" }, "errorDialog": { - "title": "AppFlowy Error", + "title": "@:appName Error", "howToFixFallback": "We're sorry for the inconvenience! Submit an issue on our GitHub page that describes your error.", + "howToFixFallbackHint1": "We're sorry for the inconvenience! Submit an issue on our ", + "howToFixFallbackHint2": " page that describes your error.", "github": "View on GitHub" }, "search": { "label": "Search", + "sidebarSearchIcon": "Search and quickly jump to a page", "placeholder": { "actions": "Search actions..." } }, "message": { "copy": { - "success": "Copied!", + "success": "Copied to clipboard", "fail": "Unable to copy" } }, @@ -1214,16 +2344,16 @@ "remove": "Remove emoji", "categories": { "smileys": "Smileys & Emotion", - "people": "People & Body", - "animals": "Animals & Nature", - "food": "Food & Drink", - "activities": "Activities", - "places": "Travel & Places", - "objects": "Objects", - "symbols": "Symbols", - "flags": "Flags", - "nature": "Nature", - "frequentlyUsed": "Frequently Used" + "people": "people", + "animals": "nature", + "food": "foods", + "activities": "activities", + "places": "places", + "objects": "objects", + "symbols": "symbols", + "flags": "flags", + "nature": "nature", + "frequentlyUsed": "frequently Used" }, "skinTone": { "default": "Default", @@ -1232,7 +2362,8 @@ "medium": "Medium", "mediumDark": "Medium-Dark", "dark": "Dark" - } + }, + "openSourceIconsFrom": "Open source icons from" }, "inlineActions": { "noResults": "No results", @@ -1246,7 +2377,8 @@ "reminder": { "groupTitle": "Reminder", "shortKeyword": "remind" - } + }, + "createPage": "Create \"{}\" sub-page" }, "datePicker": { "dateTimeFormatTooltip": "Change the date and time format in settings", @@ -1323,11 +2455,14 @@ }, "error": { "weAreSorry": "We're sorry", - "loadingViewError": "We're having trouble loading this view. Please check your internet connection, refresh the app, and do not hesitate to reach out to the team if the issue continues." + "loadingViewError": "We're having trouble loading this view. Please check your internet connection, refresh the app, and do not hesitate to reach out to the team if the issue continues.", + "syncError": "Data is not synced from another device", + "syncErrorHint": "Please reopen this page on the device where it was last edited, then open it again on the current device.", + "clickToCopy": "Click to copy error code" }, "editor": { "bold": "Bold", - "bulletedList": "Bulleted List", + "bulletedList": "Bulleted list", "bulletedListShortForm": "Bulleted", "checkbox": "Checkbox", "embedCode": "Embed Code", @@ -1341,8 +2476,11 @@ "page": "Page", "italic": "Italic", "link": "Link", - "numberedList": "Numbered List", + "numberedList": "Numbered list", "numberedListShortForm": "Numbered", + "toggleHeading1ShortForm": "Toggle H1", + "toggleHeading2ShortForm": "Toggle H2", + "toggleHeading3ShortForm": "Toggle H3", "quote": "Quote", "strikethrough": "Strikethrough", "text": "Text", @@ -1393,6 +2531,9 @@ "mobileHeading1": "Heading 1", "mobileHeading2": "Heading 2", "mobileHeading3": "Heading 3", + "mobileHeading4": "Heading 4", + "mobileHeading5": "Heading 5", + "mobileHeading6": "Heading 6", "textColor": "Text Color", "backgroundColor": "Background Color", "addYourLink": "Add your link", @@ -1400,6 +2541,7 @@ "copyLink": "Copy link", "removeLink": "Remove link", "editLink": "Edit link", + "convertTo": "Convert to", "linkText": "Text", "linkTextHint": "Please enter text", "linkAddressHint": "Please enter URL", @@ -1453,7 +2595,9 @@ }, "favorite": { "noFavorite": "No favorite page", - "noFavoriteHintText": "Swipe the page to the left to add it to your favorites" + "noFavoriteHintText": "Swipe the page to the left to add it to your favorites", + "removeFromSidebar": "Remove from sidebar", + "addToSidebar": "Pin to sidebar" }, "cardDetails": { "notesPlaceholder": "Enter a / to insert a block, or start typing" @@ -1477,7 +2621,7 @@ "noLogFiles": "There're no log files", "newSettings": { "myAccount": { - "title": "My account", + "title": "Account & App", "subtitle": "Customize your profile, manage account security, open AI keys, or login into your account.", "profileLabel": "Account name & Profile image", "profileNamePlaceholder": "Enter your name", @@ -1487,14 +2631,50 @@ "accountLogin": "Account Login", "updateNameError": "Failed to update name", "updateIconError": "Failed to update icon", + "aboutAppFlowy": "About @:appName", "deleteAccount": { "title": "Delete Account", "subtitle": "Permanently delete your account and all of your data.", + "description": "Permanently delete your account and remove access from all workspaces.", "deleteMyAccount": "Delete my account", "dialogTitle": "Delete account", "dialogContent1": "Are you sure you want to permanently delete your account?", - "dialogContent2": "This action cannot be undone, and will remove access from all teamspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces." - } + "dialogContent2": "This action cannot be undone, and will remove access from all workspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces.", + "confirmHint1": "Please type \"@:newSettings.myAccount.deleteAccount.confirmHint3\" to confirm.", + "confirmHint2": "I understand that this action is irreversible and will permanently delete my account and all associated data.", + "confirmHint3": "DELETE MY ACCOUNT", + "checkToConfirmError": "You must check the box to confirm deletion", + "failedToGetCurrentUser": "Failed to get current user email", + "confirmTextValidationFailed": "Your confirmation text does not match \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", + "deleteAccountSuccess": "Account deleted successfully" + }, + "password": { + "title": "Password", + "changePassword": "Change password", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmNewPassword": "Confirm new password", + "setupPassword": "Setup password", + "error": { + "newPasswordIsRequired": "New password is required", + "confirmPasswordIsRequired": "Confirm password is required", + "passwordsDoNotMatch": "Passwords do not match", + "newPasswordIsSameAsCurrent": "New password is same as current password" + }, + "toast": { + "passwordUpdatedSuccessfully": "Password updated successfully", + "passwordUpdatedFailed": "Failed to update password", + "passwordSetupSuccessfully": "Password setup successfully", + "passwordSetupFailed": "Failed to setup password" + }, + "hint": { + "enterYourCurrentPassword": "Enter your current password", + "enterYourNewPassword": "Enter your new password", + "confirmYourNewPassword": "Confirm your new password" + } + }, + "myAccount": "My Account", + "myProfile": "My Profile" }, "workplace": { "name": "Workplace", @@ -1503,9 +2683,10 @@ "workplaceName": "Workplace name", "workplaceNamePlaceholder": "Enter workplace name", "workplaceIcon": "Workplace icon", - "workplaceIconSubtitle": "Upload an image or use an emoji for your workspace. Icon will show in your sidebar and notifications", + "workplaceIconSubtitle": "Upload an image or use an emoji for your workspace. Icon will show in your sidebar and notifications.", "renameError": "Failed to rename workplace", "updateIconError": "Failed to update icon", + "chooseAnIcon": "Choose an icon", "appearance": { "name": "Appearance", "themeMode": { @@ -1535,19 +2716,568 @@ "unsplash": "Unsplash", "pageCover": "Page cover", "none": "None", - "photoPermissionDescription": "Allow access to the photo library for uploading images.", "openSettings": "Open Settings", - "photoPermissionTitle": "AppFlowy Would Like to Access Your Photo Library", - "doNotAllow": "Don't Allow" + "photoPermissionTitle": "@:appName would like to access your photo library", + "photoPermissionDescription": "@:appName needs access to your photos to let you add images to your documents", + "cameraPermissionTitle": "@:appName would like to access your camera", + "cameraPermissionDescription": "@:appName needs access to your camera to let you add images to your documents from the camera", + "doNotAllow": "Don't Allow", + "image": "Image" }, "commandPalette": { - "placeholder": "Type to search for views...", + "placeholder": "Search or ask a question...", "bestMatches": "Best matches", + "aiOverview": "AI overview", + "aiOverviewSource": "Reference sources", + "aiOverviewMoreDetails": "More details", + "pagePreview": "Content preview", + "clickToOpenPage": "Click to open page", "recentHistory": "Recent history", "navigateHint": "to navigate", "loadingTooltip": "We are looking for results...", "betaLabel": "BETA", - "betaTooltip": "We currently only support searching for pages", - "fromTrashHint": "From trash" + "betaTooltip": "We currently only support searching for pages and content in documents", + "fromTrashHint": "From trash", + "noResultsHint": "We didn't find what you're looking for, try searching for another term.", + "clearSearchTooltip": "Clear search field" + }, + "space": { + "delete": "Delete", + "deleteConfirmation": "Delete: ", + "deleteConfirmationDescription": "All pages within this Space will be deleted and moved to the Trash, and any published pages will be unpublished.", + "rename": "Rename Space", + "changeIcon": "Change icon", + "manage": "Manage Space", + "addNewSpace": "Create Space", + "collapseAllSubPages": "Collapse all subpages", + "createNewSpace": "Create a new space", + "createSpaceDescription": "Create multiple public and private spaces to better organize your work.", + "spaceName": "Space name", + "spaceNamePlaceholder": "e.g. Marketing, Engineering, HR", + "permission": "Space permission", + "publicPermission": "Public", + "publicPermissionDescription": "All workspace members with full access", + "privatePermission": "Private", + "privatePermissionDescription": "Only you can access this space", + "spaceIconBackground": "Background color", + "spaceIcon": "Icon", + "dangerZone": "Danger Zone", + "unableToDeleteLastSpace": "Unable to delete the last Space", + "unableToDeleteSpaceNotCreatedByYou": "Unable to delete spaces created by others", + "enableSpacesForYourWorkspace": "Enable Spaces for your workspace", + "title": "Spaces", + "defaultSpaceName": "General", + "upgradeSpaceTitle": "Enable Spaces", + "upgradeSpaceDescription": "Create multiple public and private Spaces to better organize your workspace.", + "upgrade": "Update", + "upgradeYourSpace": "Create multiple Spaces", + "quicklySwitch": "Quickly switch to the next space", + "duplicate": "Duplicate Space", + "movePageToSpace": "Move page to space", + "cannotMovePageToDatabase": "Cannot move page to database", + "switchSpace": "Switch space", + "spaceNameCannotBeEmpty": "Space name cannot be empty", + "success": { + "deleteSpace": "Space deleted successfully", + "renameSpace": "Space renamed successfully", + "duplicateSpace": "Space duplicated successfully", + "updateSpace": "Space updated successfully" + }, + "error": { + "deleteSpace": "Failed to delete space", + "renameSpace": "Failed to rename space", + "duplicateSpace": "Failed to duplicate space", + "updateSpace": "Failed to update space" + }, + "createSpace": "Create space", + "manageSpace": "Manage space", + "renameSpace": "Rename space", + "mSpaceIconColor": "Space icon color", + "mSpaceIcon": "Space icon" + }, + "publish": { + "hasNotBeenPublished": "This page hasn't been published yet", + "spaceHasNotBeenPublished": "Haven't supported publishing a space yet", + "reportPage": "Report page", + "databaseHasNotBeenPublished": "Publishing a database is not supported yet.", + "createdWith": "Created with", + "downloadApp": "Download AppFlowy", + "copy": { + "codeBlock": "The content of code block has been copied to the clipboard", + "imageBlock": "The image link has been copied to the clipboard", + "mathBlock": "The math equation has been copied to the clipboard", + "fileBlock": "The file link has been copied to the clipboard" + }, + "containsPublishedPage": "This page contains one or more published pages. If you continue, they will be unpublished. Do you want to proceed with deletion?", + "publishSuccessfully": "Published successfully", + "unpublishSuccessfully": "Unpublished successfully", + "publishFailed": "Failed to publish", + "unpublishFailed": "Failed to unpublish", + "noAccessToVisit": "No access to this page...", + "createWithAppFlowy": "Create a website with AppFlowy", + "fastWithAI": "Fast and easy with AI.", + "tryItNow": "Try it now", + "onlyGridViewCanBePublished": "Only Grid view can be published", + "database": { + "zero": "Publish {} selected view", + "one": "Publish {} selected views", + "many": "Publish {} selected views", + "other": "Publish {} selected views" + }, + "mustSelectPrimaryDatabase": "The primary view must be selected", + "noDatabaseSelected": "No database selected, please select at least one database.", + "unableToDeselectPrimaryDatabase": "Unable to deselect primary database", + "saveThisPage": "Start with this template", + "duplicateTitle": "Where would you like to add", + "selectWorkspace": "Select a workspace", + "addTo": "Add to", + "duplicateSuccessfully": "Added to your workspace", + "duplicateSuccessfullyDescription": "Don't have AppFlowy installed? The download will start automatically after you click 'Download'.", + "downloadIt": "Download", + "openApp": "Open in app", + "duplicateFailed": "Duplicated failed", + "membersCount": { + "zero": "No members", + "one": "1 member", + "many": "{count} members", + "other": "{count} members" + }, + "useThisTemplate": "Use the template" + }, + "web": { + "continue": "Continue", + "or": "or", + "continueWithGoogle": "Continue with Google", + "continueWithGithub": "Continue with GitHub", + "continueWithDiscord": "Continue with Discord", + "continueWithApple": "Continue with Apple ", + "moreOptions": "More options", + "collapse": "Collapse", + "signInAgreement": "By clicking \"Continue\" above, you agreed to \nAppFlowy's ", + "signInLocalAgreement": "By clicking \"Get Started\" above, you agreed to \nAppFlowy's ", + "and": "and", + "termOfUse": "Terms", + "privacyPolicy": "Privacy Policy", + "signInError": "Sign in error", + "login": "Sign up or log in", + "fileBlock": { + "uploadedAt": "Uploaded on {time}", + "linkedAt": "Link added on {time}", + "empty": "Upload or embed a file", + "uploadFailed": "Upload failed, please try again", + "retry": "Retry" + }, + "importNotion": "Import from Notion", + "import": "Import", + "importSuccess": "Uploaded successfully", + "importSuccessMessage": "We'll notify you when the import is complete. After that, you can view your imported pages in the sidebar.", + "importFailed": "Import failed, please check the file format", + "dropNotionFile": "Drop your Notion zip file here to upload, or click to browse", + "error": { + "pageNameIsEmpty": "The page name is empty, please try another one" + } + }, + "globalComment": { + "comments": "Comments", + "addComment": "Add a comment", + "reactedBy": "reacted by", + "addReaction": "Add reaction", + "reactedByMore": "and {count} others", + "showSeconds": { + "one": "1 second ago", + "other": "{count} seconds ago", + "zero": "Just now", + "many": "{count} seconds ago" + }, + "showMinutes": { + "one": "1 minute ago", + "other": "{count} minutes ago", + "many": "{count} minutes ago" + }, + "showHours": { + "one": "1 hour ago", + "other": "{count} hours ago", + "many": "{count} hours ago" + }, + "showDays": { + "one": "1 day ago", + "other": "{count} days ago", + "many": "{count} days ago" + }, + "showMonths": { + "one": "1 month ago", + "other": "{count} months ago", + "many": "{count} months ago" + }, + "showYears": { + "one": "1 year ago", + "other": "{count} years ago", + "many": "{count} years ago" + }, + "reply": "Reply", + "deleteComment": "Delete comment", + "youAreNotOwner": "You are not the owner of this comment", + "confirmDeleteDescription": "Are you sure you want to delete this comment?", + "hasBeenDeleted": "Deleted", + "replyingTo": "Replying to", + "noAccessDeleteComment": "You're not allowed to delete this comment", + "collapse": "Collapse", + "readMore": "Read more", + "failedToAddComment": "Failed to add comment", + "commentAddedSuccessfully": "Comment added successfully.", + "commentAddedSuccessTip": "You've just added or replied to a comment. Would you like to jump to the top to see the latest comments?" + }, + "template": { + "asTemplate": "Save as template", + "name": "Template name", + "description": "Template Description", + "about": "Template About", + "deleteFromTemplate": "Delete from templates", + "preview": "Template Preview", + "categories": "Template Categories", + "isNewTemplate": "PIN to New template", + "featured": "PIN to Featured", + "relatedTemplates": "Related Templates", + "requiredField": "{field} is required", + "addCategory": "Add \"{category}\"", + "addNewCategory": "Add new category", + "addNewCreator": "Add new creator", + "deleteCategory": "Delete category", + "editCategory": "Edit category", + "editCreator": "Edit creator", + "category": { + "name": "Category name", + "icon": "Category icon", + "bgColor": "Category background color", + "priority": "Category priority", + "desc": "Category description", + "type": "Category type", + "icons": "Category Icons", + "colors": "Category Colors", + "byUseCase": "By Use Case", + "byFeature": "By Feature", + "deleteCategory": "Delete category", + "deleteCategoryDescription": "Are you sure you want to delete this category?", + "typeToSearch": "Type to search categories..." + }, + "creator": { + "label": "Template Creator", + "name": "Creator name", + "avatar": "Creator avatar", + "accountLinks": "Creator account links", + "uploadAvatar": "Click to upload avatar", + "deleteCreator": "Delete creator", + "deleteCreatorDescription": "Are you sure you want to delete this creator?", + "typeToSearch": "Type to search creators..." + }, + "uploadSuccess": "Template uploaded successfully", + "uploadSuccessDescription": "Your template has been uploaded successfully. You can now view it in the template gallery.", + "viewTemplate": "View template", + "deleteTemplate": "Delete template", + "deleteSuccess": "Template deleted successfully", + "deleteTemplateDescription": "This won't affect the current page or published status. Are you sure you want to delete this template?", + "addRelatedTemplate": "Add related template", + "removeRelatedTemplate": "Remove related template", + "uploadAvatar": "Upload avatar", + "searchInCategory": "Search in {category}", + "label": "Templates" + }, + "fileDropzone": { + "dropFile": "Click or drag file to this area to upload", + "uploading": "Uploading...", + "uploadFailed": "Upload failed", + "uploadSuccess": "Upload success", + "uploadSuccessDescription": "The file has been uploaded successfully", + "uploadFailedDescription": "The file upload failed", + "uploadingDescription": "The file is being uploaded" + }, + "gallery": { + "preview": "Open in full screen", + "copy": "Copy", + "download": "Download", + "prev": "Previous", + "next": "Next", + "resetZoom": "Reset zoom", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out" + }, + "invitation": { + "join": "Join", + "on": "on", + "invitedBy": "Invited by", + "membersCount": { + "zero": "{count} members", + "one": "{count} member", + "many": "{count} members", + "other": "{count} members" + }, + "tip": "You’ve been invited to Join this workspace with the contact information below. If this is incorrect, contact your administrator to resend the invite.", + "joinWorkspace": "Join workspace", + "success": "You've successfully joined the workspace", + "successMessage": "You can now access all the pages and workspaces within it.", + "openWorkspace": "Open AppFlowy", + "alreadyAccepted": "You've already accepted the invitation", + "errorModal": { + "title": "Something went wrong", + "description": "Your current account {email} may not have access to this workspace. Please log in with the correct account or contact the workspace owner for help.", + "contactOwner": "Contact owner", + "close": "Back to home", + "changeAccount": "Change account" + } + }, + "requestAccess": { + "title": "No access to this page", + "subtitle": "You can request access from the owner of this page. Once approved, you can view the page.", + "requestAccess": "Request access", + "backToHome": "Back to home", + "tip": "You're currently logged in as .", + "mightBe": "You might need to with a different account.", + "successful": "Request sent successfully", + "successfulMessage": "You will be notified once the owner approves your request.", + "requestError": "Failed to request access", + "repeatRequestError": "You've already requested access to this page" + }, + "approveAccess": { + "title": "Approve Workspace Join Request", + "requestSummary": " requests to join and access ", + "upgrade": "upgrade", + "downloadApp": "Download AppFlowy", + "approveButton": "Approve", + "approveSuccess": "Approved successfully", + "approveError": "Failed to approve, ensure the workspace plan limit is not exceeded", + "getRequestInfoError": "Failed to get request info", + "memberCount": { + "zero": "No members", + "one": "1 member", + "many": "{count} members", + "other": "{count} members" + }, + "alreadyProTitle": "You've reached the workspace plan limit", + "alreadyProMessage": "Ask them to contact to unlock more members", + "repeatApproveError": "You've already approved this request", + "ensurePlanLimit": "Ensure the workspace plan limit is not exceeded. If the limit is exceeded, consider the workspace plan or .", + "requestToJoin": "requested to join", + "asMember": "as a member" + }, + "upgradePlanModal": { + "title": "Upgrade to Pro", + "message": "{name} has reached the free member limit. Upgrade to the Pro Plan to invite more members.", + "upgradeSteps": "How to upgrade your plan on AppFlowy:", + "step1": "1. Go to Settings", + "step2": "2. Click on 'Plan'", + "step3": "3. Select 'Change Plan'", + "appNote": "Note: ", + "actionButton": "Upgrade", + "downloadLink": "Download App", + "laterButton": "Later", + "refreshNote": "After successful upgrade, click to activate your new features.", + "refresh": "here" + }, + "breadcrumbs": { + "label": "Breadcrumbs" + }, + "time": { + "justNow": "Just now", + "seconds": { + "one": "1 second", + "other": "{count} seconds" + }, + "minutes": { + "one": "1 minute", + "other": "{count} minutes" + }, + "hours": { + "one": "1 hour", + "other": "{count} hours" + }, + "days": { + "one": "1 day", + "other": "{count} days" + }, + "weeks": { + "one": "1 week", + "other": "{count} weeks" + }, + "months": { + "one": "1 month", + "other": "{count} months" + }, + "years": { + "one": "1 year", + "other": "{count} years" + }, + "ago": "ago", + "yesterday": "Yesterday", + "today": "Today" + }, + "members": { + "zero": "No members", + "one": "1 member", + "many": "{count} members", + "other": "{count} members" + }, + "tabMenu": { + "close": "Close", + "closeDisabledHint": "Cannot close a pinned tab, please unpin first", + "closeOthers": "Close other tabs", + "closeOthersHint": "This will close all unpinned tabs except this one", + "closeOthersDisabledHint": "All tabs are pinned, cannot find any tabs to close", + "favorite": "Favorite", + "unfavorite": "Unfavorite", + "favoriteDisabledHint": "Cannot favorite this view", + "pinTab": "Pin", + "unpinTab": "Unpin" + }, + "openFileMessage": { + "success": "File opened successfully", + "fileNotFound": "File not found", + "noAppToOpenFile": "No app to open this file", + "permissionDenied": "No permission to open this file", + "unknownError": "File open failed" + }, + "inviteMember": { + "requestInviteMembers": "Invite to your workspace", + "inviteFailedMemberLimit": "Member limit has been reached, please ", + "upgrade": "upgrade", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "Send invites", + "inviteAlready": "You've already invited this email: {email}", + "inviteSuccess": "Invitation sent successfully", + "description": "Input emails below with commas between them. Charges are based on member count.", + "emails": "Email" + }, + "quickNote": { + "label": "Quick Note", + "quickNotes": "Quick Notes", + "search": "Search Quick Notes", + "collapseFullView": "Collapse full view", + "expandFullView": "Expand full view", + "createFailed": "Failed to create Quick Note", + "quickNotesEmpty": "No Quick Notes", + "emptyNote": "Empty note", + "deleteNotePrompt": "The selected note will be deleted permanently. Are you sure you want to delete it?", + "addNote": "New Note", + "noAdditionalText": "No additional text" + }, + "subscribe": { + "upgradePlanTitle": "Compare & select plan", + "yearly": "Yearly", + "save": "Save {discount}%", + "monthly": "Monthly", + "priceIn": "Price in ", + "free": "Free", + "pro": "Pro", + "freeDescription": "For individuals up to 2 members to organize everything", + "proDescription": "For small teams to manage projects and team knowledge", + "proDuration": { + "monthly": "per member per month\nbilled monthly", + "yearly": "per member per month\nbilled annually" + }, + "cancel": "Downgrade", + "changePlan": "Upgrade to Pro Plan", + "everythingInFree": "Everything in Free +", + "currentPlan": "Current", + "freeDuration": "forever", + "freePoints": { + "first": "1 collaborative workspace up to 2 members", + "second": "Unlimited pages & blocks", + "three": "5 GB storage", + "four": "Intelligent search", + "five": "20 AI responses", + "six": "Mobile app", + "seven": "Real-time collaboration" + }, + "proPoints": { + "first": "Unlimited storage", + "second": "Up to 10 workspace members", + "three": "Unlimited AI responses", + "four": "Unlimited file uploads", + "five": "Custom namespace" + }, + "cancelPlan": { + "title": "Sorry to see you go", + "success": "Your subscription has been canceled successfully", + "description": "We're sorry to see you go. We'd love to hear your feedback to help us improve AppFlowy. Please take a moment to answer a few questions.", + "commonOther": "Other", + "otherHint": "Write your answer here", + "questionOne": { + "question": "What prompted you to cancel your AppFlowy Pro subscription?", + "answerOne": "Cost too high", + "answerTwo": "Features did not meet expectations", + "answerThree": "Found a better alternative", + "answerFour": "Did not use it enough to justify the expense", + "answerFive": "Service issue or technical difficulties" + }, + "questionTwo": { + "question": "How likely are you to consider re-subscribing to AppFlowy Pro in the future?", + "answerOne": "Very likely", + "answerTwo": "Somewhat likely", + "answerThree": "Not sure", + "answerFour": "Unlikely", + "answerFive": "Very unlikely" + }, + "questionThree": { + "question": "Which Pro feature did you value the most during your subscription?", + "answerOne": "Multi-user collaboration", + "answerTwo": "Longer time version history", + "answerThree": "Unlimited AI responses", + "answerFour": "Access to local AI models" + }, + "questionFour": { + "question": "How would you describe your overall experience with AppFlowy?", + "answerOne": "Great", + "answerTwo": "Good", + "answerThree": "Average", + "answerFour": "Below average", + "answerFive": "Unsatisfied" + } + } + }, + "ai": { + "contentPolicyViolation": "Image generation failed due to sensitive content. Please rephrase your input and try again", + "textLimitReachedDescription": "Your workspace has run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", + "imageLimitReachedDescription": "You've used up your free AI image quota. Please upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", + "limitReachedAction": { + "textDescription": "Your workspace has run out of free AI responses. To get more responses, please", + "imageDescription": "You've used up your free AI image quota. Please", + "upgrade": "upgrade", + "toThe": "to the", + "proPlan": "Pro Plan", + "orPurchaseAn": "or purchase an", + "aiAddon": "AI add-on" + }, + "editing": "Editing", + "analyzing": "Analyzing", + "continueWritingEmptyDocumentTitle": "Continue writing error", + "continueWritingEmptyDocumentDescription": "We are having trouble expanding on the content in your document. Write a small intro and we can take it from there!", + "more": "More" + }, + "autoUpdate": { + "criticalUpdateTitle": "Update required to continue", + "criticalUpdateDescription": "We've made improvements to enhance your experience! Please update from {currentVersion} to {newVersion} to keep using the app.", + "criticalUpdateButton": "Update", + "bannerUpdateTitle": "New Version Available!", + "bannerUpdateDescription": "Get the latest features and fixes. Click \"Update\" to install now", + "bannerUpdateButton": "Update", + "settingsUpdateTitle": "New Version ({newVersion}) Available!", + "settingsUpdateDescription": "Current version: {currentVersion} (Official build) → {newVersion}", + "settingsUpdateButton": "Update", + "settingsUpdateWhatsNew": "What's new" + }, + "lockPage": { + "lockPage": "Locked", + "reLockPage": "Re-lock", + "lockTooltip": "Page locked to prevent accidental editing. Click to unlock.", + "pageLockedToast": "Page locked. Editing is disabled until someone unlocks it.", + "lockedOperationTooltip": "Page locked to prevent accidental editing." + }, + "suggestion": { + "accept": "Accept", + "keep": "Keep", + "discard": "Discard", + "close": "Close", + "tryAgain": "Try again", + "rewrite": "Rewrite", + "insertBelow": "Insert below" } } \ No newline at end of file diff --git a/frontend/resources/translations/es-VE.json b/frontend/resources/translations/es-VE.json index ff651f5ccd..5f947ea015 100644 --- a/frontend/resources/translations/es-VE.json +++ b/frontend/resources/translations/es-VE.json @@ -5,7 +5,7 @@ "welcomeTo": "Bienvenido a", "githubStarText": "Favorito en GitHub", "subscribeNewsletterText": "Suscribir al boletín", - "letsGoButtonText": "Vamos", + "letsGoButtonText": "Inicio rápido", "title": "Título", "youCanAlso": "Tú también puedes", "and": "y", @@ -19,52 +19,68 @@ "openMenuTooltip": "Haga clic para abrir el menú" }, "signUp": { - "buttonText": "Registrar", - "title": "Registrar en @:appName", + "buttonText": "Registro", + "title": "Registro en @:appName", "getStartedText": "Empezar", "emptyPasswordError": "La contraseña no puede estar en blanco", - "repeatPasswordEmptyError": "La contraseña no puede estar en blanco", + "repeatPasswordEmptyError": "La contraseña repetida no puede estar vacía", "unmatchedPasswordError": "Las contraseñas no coinciden", - "alreadyHaveAnAccount": "¿Posee credenciales?", - "emailHint": "Correo", + "alreadyHaveAnAccount": "¿Ya posee una cuenta?", + "emailHint": "Correo electrónico", "passwordHint": "Contraseña", "repeatPasswordHint": "Repetir contraseña", - "signUpWith": "Registrarte con:" + "signUpWith": "Registro con:" }, "signIn": { "loginTitle": "Ingresa a @:appName", "loginButtonText": "Ingresar", "loginStartWithAnonymous": "Comience una sesión anónima", "continueAnonymousUser": "Continuar con una sesión anónima", + "anonymous": "Anónimo", "buttonText": "Ingresar", "signingInText": "Iniciando sesión...", "forgotPassword": "¿Olvidó su contraseña?", "emailHint": "Correo", "passwordHint": "Contraseña", "dontHaveAnAccount": "¿No posee credenciales?", + "createAccount": "Crear una cuenta", "repeatPasswordEmptyError": "La contraseña no puede estar en blanco", "unmatchedPasswordError": "Las contraseñas no coinciden", "syncPromptMessage": "La sincronización de los datos puede tardar un poco. Por favor no cierres esta página", "or": "O", + "signInWithGoogle": "Iniciar sesión con Google", + "signInWithGithub": "Iniciar sesión con Github", + "signInWithDiscord": "Iniciar sesión con Discord", + "signInWithApple": "Continuar con Apple", + "continueAnotherWay": "Continuar por otro camino", + "signUpWithGoogle": "Registrarse con Google", + "signUpWithGithub": "Registrarse con Github", + "signUpWithDiscord": "Registrarse con Discord", "signInWith": "Inicia sesión con:", "signInWithEmail": "Iniciar sesión con correo electrónico", + "signInWithMagicLink": "Iniciar sesión con enlace mágico", + "signUpWithMagicLink": "Registrarse con enlace mágico", "pleaseInputYourEmail": "Por favor, introduzca su dirección de correo electrónico", + "settings": "Configuración", "magicLinkSent": "Enlace mágico enviado a tu correo electrónico, por favor revisa tu bandeja de entrada", "invalidEmail": "Por favor, introduce una dirección de correo electrónico válida", - "LogInWithGoogle": "Iniciar sesión con Google", - "LogInWithGithub": "Iniciar sesión con Github", - "LogInWithDiscord": "Iniciar sesión con Discord", - "loginAsGuestButtonText": "Empezar", - "logInWithMagicLink": "Iniciar sesión con Enlace Mágico" + "alreadyHaveAnAccount": "¿Ya tienes cuenta?", + "logIn": "Iniciar sesión", + "generalError": "Algo ha salido mal. Por favor, inténtalo más tarde", + "limitRateError": "Por razones de seguridad, solo puedes solicitar un enlace mágico cada 60 segundos" }, "workspace": { "chooseWorkspace": "Elige tu espacio de trabajo", + "defaultName": "Mi espacio de trabajo", "create": "Crear espacio de trabajo", + "new": "Nuevo espacio de trabajo", + "learnMore": "Más información", "reset": "Restablecer espacio de trabajo", + "renameWorkspace": "Cambiar el nombre del espacio de trabajo", "resetWorkspacePrompt": "Al restablecer el espacio de trabajo se eliminarán todas las páginas y datos que contiene. ¿Está seguro de que desea restablecer el espacio de trabajo? Alternativamente, puede comunicarse con el equipo de soporte para restaurar el espacio de trabajo.", "hint": "Espacio de trabajo", "notFoundError": "Espacio de trabajo no encontrado", - "failedToLoad": "¡Algo salió mal! No se pudo cargar el espacio de trabajo. Intente cerrar cualquier instancia abierta de AppFlowy y vuelva a intentarlo.", + "failedToLoad": "¡Algo salió mal! No se pudo cargar el espacio de trabajo. Intente cerrar cualquier instancia abierta de @:appName y vuelva a intentarlo.", "errorActions": { "reportIssue": "Reportar un problema", "reportIssueOnGithub": "Informar un problema en Github", @@ -78,11 +94,14 @@ "createLimitExceeded": "Has alcanzado el límite máximo de espacio de trabajo permitido para su cuenta. Si necesita espacios de trabajo adicionales para continuar su trabajo, solicítelos en Github", "deleteSuccess": "Espacio de trabajo eliminado correctamente", "deleteFailed": "No se pudo eliminar el espacio de trabajo", + "openSuccess": "Espacio de trabajo abierto correctamente", "openFailed": "No se pudo abrir el espacio de trabajo", "renameSuccess": "Espacio de trabajo renombrado exitosamente", "renameFailed": "No se pudo cambiar el nombre del espacio de trabajo", "updateIconSuccess": "Icono de espacio de trabajo actualizado correctamente", + "updateIconFailed": "Fallo actualizando el icono del espacio de trabajo", "cannotDeleteTheOnlyWorkspace": "No se puede eliminar el único espacio de trabajo", + "fetchWorkspacesFailed": "No se pudieron recuperar los espacios de trabajo", "leaveCurrentWorkspace": "Salir del espacio de trabajo", "leaveCurrentWorkspacePrompt": "¿Está seguro de que desea abandonar el espacio de trabajo actual?" }, @@ -93,7 +112,9 @@ "html": "HTML", "clipboard": "Copiar al portapapeles", "csv": "CSV", - "copyLink": "Copiar enlace" + "copyLink": "Copiar enlace", + "publish": "Publicar", + "publishTab": "Publicar" }, "moreAction": { "small": "pequeño", @@ -101,15 +122,17 @@ "large": "grande", "fontSize": "Tamaño de fuente", "import": "Importar", - "moreOptions": "Mas opciones", + "moreOptions": "Más opciones", "wordCount": "El recuento de palabras: {}", "charCount": "Número de caracteres : {}", "createdAt": "Creado: {}", "deleteView": "Borrar", - "duplicateView": "Duplicar" + "duplicateView": "Duplicar", + "createdAtLabel": "Creado: ", + "syncedAtLabel": "Sincronizado: " }, "importPanel": { - "textAndMarkdown": "Texto y descuento", + "textAndMarkdown": "Texto y Markdown", "documentFromV010": "Documento de v0.1.0", "databaseFromV010": "Base de datos desde v0.1.0", "csv": "CSV", @@ -124,7 +147,8 @@ "openNewTab": "Abrir en una pestaña nueva", "moveTo": "Mover a", "addToFavorites": "Añadira los favoritos", - "copyLink": "Copiar Enlace" + "copyLink": "Copiar Enlace", + "move": "Mover" }, "blankPageTitle": "Página en blanco", "newPageText": "Nueva página", @@ -132,9 +156,36 @@ "newGridText": "Nueva patrón", "newCalendarText": "Nuevo calendario", "newBoardText": "Nuevo tablero", + "chat": { + "newChat": "Chat de IA", + "relatedQuestion": "Relacionado", + "serverUnavailable": "Servicio temporalmente no disponible. Por favor, inténtelo de nuevo más tarde.", + "aiServerUnavailable": "🌈 ¡Uh-oh! 🌈. Un unicornio se comió nuestra respuesta. ¡Por favor, intenta de nuevo!", + "retry": "Rever", + "clickToRetry": "Haga clic para volver a intentarlo", + "regenerateAnswer": "Regenerar", + "question1": "Cómo utilizar Kanban para gestionar tareas", + "question2": "Explica el método GTD", + "question3": "¿Por qué usar Rust?", + "aiMistakePrompt": "La IA puede cometer errores. Consulta información importante.", + "referenceSource": { + "one": "Se encontró {count} fuente", + "other": "Se encontraron {count} fuentes" + }, + "regenerate": "Intentar otra vez", + "addToNewPage": "Crear nueva página", + "changeFormat": { + "textOnly": "Texto", + "text": "Párrafo" + }, + "selectBanner": { + "saveButton": "Añadir …" + } + }, "trash": { "text": "Papelera", "restoreAll": "Recuperar todo", + "restore": "Restaurar", "deleteAll": "Eliminar todo", "pageHeader": { "fileName": "Nombre de archivo", @@ -167,20 +218,21 @@ "questionBubble": { "shortcuts": "Atajos", "whatsNew": "¿Qué hay de nuevo?", - "help": "Ayuda y Soporte", "markdown": "Reducción", "debug": { "name": "Información de depuración", "success": "¡Información copiada!", "fail": "No fue posible copiar la información" }, - "feedback": "Comentario" + "feedback": "Comentario", + "help": "Ayuda y Soporte" }, "menuAppHeader": { "moreButtonToolTip": "Eliminar, renombrar y más...", "addPageTooltip": "Inserta una página", "defaultNewPageName": "Sin Título", - "renameDialog": "Renombrar" + "renameDialog": "Renombrar", + "pageNameSuffix": "Copiar" }, "noPagesInside": "No hay páginas dentro", "toolbar": { @@ -210,7 +262,8 @@ "dragRow": "Pulsación larga para reordenar la fila", "viewDataBase": "Ver base de datos", "referencePage": "Se hace referencia a este {nombre}", - "addBlockBelow": "Añadir un bloque a continuación" + "addBlockBelow": "Añadir un bloque a continuación", + "aiGenerate": "Generar" }, "sideBar": { "closeSidebar": "Cerrar panel lateral", @@ -219,12 +272,26 @@ "private": "Privado", "workspace": "Espacio de trabajo", "favorites": "Favoritos", + "clickToHidePrivate": "Haz clic para ocultar el espacio privado\nLas páginas que creaste aquí solo son visibles para ti", + "clickToHideWorkspace": "Haga clic para ocultar el espacio de trabajo\nLas páginas que creaste aquí son visibles para todos los miembros", "clickToHidePersonal": "Haga clic para ocultar la sección personal", "clickToHideFavorites": "Haga clic para ocultar la sección de favoritos", "addAPage": "Añadir una página", "addAPageToPrivate": "Agregar una página al espacio privado", "addAPageToWorkspace": "Agregar una página al espacio de trabajo", - "recent": "Reciente" + "recent": "Reciente", + "today": "Hoy", + "thisWeek": "Esta semana", + "others": "Otros favoritos", + "justNow": "En este momento", + "lastViewed": "Visto por última vez", + "emptyRecent": "Sin documentos recientes", + "favoriteSpace": "Favoritos", + "RecentSpace": "Reciente", + "Spaces": "Espacios", + "aiImageResponseLimit": "Se ha quedado sin respuestas de imágenes de IA.\n\nVaya a Configuración -> Plan -> Haga clic en AI Max para obtener más respuestas de imágenes de IA", + "purchaseStorageSpace": "Comprar espacio de almacenamiento", + "purchaseAIResponse": "Compra " }, "notifications": { "export": { @@ -240,6 +307,7 @@ }, "button": { "ok": "OK", + "confirm": "Confirmar", "done": "Hecho", "cancel": "Cancelar", "signIn": "Ingresar", @@ -257,16 +325,20 @@ "upload": "Subir", "edit": "Editar", "delete": "Borrar", + "copy": "Copiar", "duplicate": "Duplicar", "putback": "Volver", "update": "Actualizar", "share": "Compartir", "removeFromFavorites": "Quitar de favoritos", + "removeFromRecent": "Eliminar de los recientes", "addToFavorites": "Añadir a favoritos", "rename": "Renombrar", "helpCenter": "Centro de ayuda", "add": "Añadir", "yes": "Si", + "no": "No", + "clear": "Limpiar", "remove": "Eliminar", "dontRemove": "no quitar", "copyLink": "Copiar enlace", @@ -277,7 +349,15 @@ "back": "Atrás", "signInGoogle": "Inicia sesión con Google", "signInGithub": "Iniciar sesión con Github", - "signInDiscord": "Iniciar sesión con discordia" + "signInDiscord": "Iniciar sesión con discordia", + "more": "Más", + "create": "Crear", + "close": "Cerca", + "next": "Próximo", + "previous": "Anterior", + "submit": "Entregar", + "download": "Descargar", + "backToHome": "Volver a Inicio" }, "label": { "welcome": "¡Bienvenido!", @@ -301,6 +381,29 @@ }, "settings": { "title": "Ajustes", + "popupMenuItem": { + "settings": "Ajustes" + }, + "accountPage": { + "menuLabel": "Mi cuenta", + "title": "Mi cuenta", + "general": { + "title": "Nombre de cuenta e imagen de perfil", + "changeProfilePicture": "Cambiar" + }, + "email": { + "title": "Email", + "actions": { + "change": "Cambiar email" + } + }, + "login": { + "title": "Inicio de sesión en la cuenta", + "loginLabel": "Inicio de sesión", + "logoutLabel": "Cerrar sesión" + }, + "description": "Personaliza tu perfil, administra la seguridad de la cuenta y las claves API de IA, o inicia sesión en tu cuenta." + }, "menu": { "appearance": "Apariencia", "language": "Lenguaje", @@ -320,12 +423,9 @@ "cloudServerType": "servidor en la nube", "cloudServerTypeTip": "Tenga en cuenta que es posible que se cierre la sesión de su cuenta actual después de cambiar el servidor en la nube.", "cloudLocal": "Local", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "URL de la base de datos", - "cloudSupabaseAnonKey": "Supabase clave anon", - "cloudSupabaseAnonKeyCanNotBeEmpty": "La clave anon no puede estar vacía si la URL de supabase no está vacía", - "cloudAppFlowy": "Nube AppFlowy", - "cloudAppFlowySelfHost": "AppFlowy Cloud autohospedado", + "cloudAppFlowy": "Nube @:appName", + "cloudAppFlowySelfHost": "@:appName Cloud autohospedado", + "appFlowyCloudUrlCanNotBeEmpty": "La URL de la nube no puede estar vacía", "clickToCopy": "Haga clic para copiar", "selfHostStart": "Si no tiene un servidor, consulte la", "selfHostContent": "documento", @@ -335,18 +435,23 @@ "cloudWSURLHint": "Ingrese la dirección websocket de su servidor", "restartApp": "Reiniciar", "restartAppTip": "Reinicie la aplicación para que se apliquen los cambios. Tenga en cuenta que esto podría cerrar la sesión de su cuenta actual.", + "changeServerTip": "Después de cambiar el servidor, debes hacer clic en el botón reiniciar para que los cambios surtan efecto", + "enableEncryptPrompt": "Activa el cifrado para proteger tus datos con esta clave. Guárdalo de forma segura; una vez habilitado, no se puede desactivar. Si se pierden, tus datos se vuelven irrecuperables. Haz clic para copiar", "inputEncryptPrompt": "Introduzca su secreto de cifrado para", "clickToCopySecret": "Haga clic para copiar el código secreto", "configServerSetting": "Configure los ajustes de su servidor", + "configServerGuide": "Después de seleccionar \"Inicio rápido\", navega hasta \"Configuración\" y luego \"Configuración de la nube\" para configurar tu servidor autoalojado.", "inputTextFieldHint": "Su código secreto", "historicalUserList": "Historial de inicio de sesión del usuario", + "historicalUserListTooltip": "Esta lista muestra tus cuentas anónimas. Puedes hacer clic en una cuenta para ver sus detalles. Las cuentas anónimas se crean haciendo clic en el botón \"Comenzar\".", "openHistoricalUser": "Haga clic para abrir la cuenta anónima", - "importAppFlowyData": "Importar datos desde una carpeta externa de AppFlowy", + "customPathPrompt": "Almacenar la carpeta de datos de @:appName en una carpeta sincronizada en la nube, como Google Drive, puede presentar riesgos. Si se accede a la base de datos dentro de esta carpeta o se modifica desde varias ubicaciones al mismo tiempo, se pueden producir conflictos de sincronización y posibles daños en los datos", + "importAppFlowyData": "Importar datos desde una carpeta externa de @:appName", "importingAppFlowyDataTip": "La importación de datos está en curso. Por favor no cierres la aplicación.", - "importSuccess": "Importó exitosamente la carpeta de datos de AppFlowy", - "importFailed": "Error al importar la carpeta de datos de AppFlowy", - "importGuide": "Para obtener más detalles, consulte el documento de referencia.", - "supabaseSetting": "Ajuste de base superior" + "importAppFlowyDataDescription": "Copia los datos de una carpeta de datos externa de @:appName e impórtalos a la carpeta de datos actual de @:appName", + "importSuccess": "Importó exitosamente la carpeta de datos de @:appName", + "importFailed": "Error al importar la carpeta de datos de @:appName", + "importGuide": "Para obtener más detalles, consulte el documento de referencia." }, "notifications": { "enableNotifications": { @@ -358,7 +463,8 @@ "resetSetting": "restaurar", "fontFamily": { "label": "Familia tipográfica", - "search": "Buscar" + "search": "Buscar", + "defaultFont": "Fuente predeterminada" }, "themeMode": { "label": "Theme Mode", @@ -366,6 +472,7 @@ "dark": "Modo Oscuro", "system": "Adapt to System" }, + "fontScaleFactor": "Factor de escala de fuente", "documentSettings": { "cursorColor": "Color del cursor del documento", "selectionColor": "Color de selección de documento", @@ -386,6 +493,7 @@ }, "textDirection": { "label": "Dirección de texto predeterminada", + "hint": "Especifica si el texto debe comenzar desde la izquierda o desde la derecha de forma predeterminada.", "ltr": "LTR (de izquierda hacia derecha)", "rtl": "RTL (de derecha hacia izquierda)", "auto": "AUTO", @@ -394,7 +502,7 @@ "themeUpload": { "button": "Subir", "uploadTheme": "Subir tema", - "description": "Cargue su propio tema AppFlowy usando el botón de abajo.", + "description": "Cargue su propio tema @:appName usando el botón de abajo.", "loading": "Espere mientras validamos y cargamos su tema...", "uploadSuccess": "Su tema se ha subido con éxito", "deletionFailure": "No se pudo eliminar el tema. Intenta eliminarlo manualmente.", @@ -418,10 +526,36 @@ "twelveHour": "doce horas", "twentyFourHour": "veinticuatro horas" }, + "showNamingDialogWhenCreatingPage": "Mostrar diálogo de nombres al crear una página", + "enableRTLToolbarItems": "Habilitar elementos de la barra de herramientas RTL", "members": { "title": "Configuración de miembros", + "inviteMembers": "Invitar a los miembros", "sendInvite": "Enviar invitación", - "user": "Usuario" + "copyInviteLink": "Copiar enlace de invitación", + "label": "Miembros", + "user": "Usuario", + "role": "Rol", + "removeFromWorkspace": "Quitar del espacio de trabajo", + "owner": "Dueño", + "guest": "Invitado", + "member": "Miembro", + "memberHintText": "Un miembro puede leer, comentar y editar páginas. Invitar a miembros e invitados.", + "emailInvalidError": "Correo electrónico no válido, compruébalo y vuelve a intentarlo.", + "emailSent": "Email enviado, por favor revisa la bandeja de entrada", + "members": "miembros", + "membersCount": { + "zero": "{} miembros", + "one": "{} miembro", + "other": "{} miembros" + }, + "memberLimitExceeded": "Has alcanzado el límite máximo de miembros permitidos para tu cuenta. Si deseas agregar más miembros adicionales para continuar con tu trabajo, solicítalo en Github.", + "failedToAddMember": "No se pudo agregar el miembro", + "addMemberSuccess": "Miembro agregado con éxito", + "removeMember": "Eliminar miembro", + "areYouSureToRemoveMember": "¿Estás seguro de que deseas eliminar a este miembro?", + "inviteMemberSuccess": "La invitación ha sido enviada con éxito", + "failedToInviteMember": "No se pudo invitar al miembro" } }, "files": { @@ -429,7 +563,7 @@ "defaultLocation": "Leer archivos y ubicación de almacenamiento de datos", "exportData": "Exporta tus datos", "doubleTapToCopy": "Toca dos veces para copiar la ruta", - "restoreLocation": "Restaurar a la ruta predeterminada de AppFlowy", + "restoreLocation": "Restaurar a la ruta predeterminada de @:appName", "customizeLocation": "Abrir otra carpeta", "restartApp": "Reinicie la aplicación para que los cambios surtan efecto.", "exportDatabase": "Exportar base de datos", @@ -441,10 +575,10 @@ "defineWhereYourDataIsStored": "Defina dónde se almacenan sus datos", "open": "Abierto", "openFolder": "Abrir una carpeta existente", - "openFolderDesc": "Léalo y escríbalo en su carpeta AppFlowy existente", + "openFolderDesc": "Léalo y escríbalo en su carpeta @:appName existente", "folderHintText": "nombre de la carpeta", "location": "Creando una nueva carpeta", - "locationDesc": "Elija un nombre para su carpeta de datos de AppFlowy", + "locationDesc": "Elija un nombre para su carpeta de datos de @:appName", "browser": "Navegar", "create": "Crear", "set": "Colocar", @@ -455,27 +589,23 @@ "change": "Cambiar", "openLocationTooltips": "Abrir otro directorio de datos", "openCurrentDataFolder": "Abrir el directorio de datos actual", - "recoverLocationTooltips": "Restablecer al directorio de datos predeterminado de AppFlowy", + "recoverLocationTooltips": "Restablecer al directorio de datos predeterminado de @:appName", "exportFileSuccess": "¡Exportar archivo con éxito!", "exportFileFail": "¡Error en la exportación del archivo!", - "export": "Exportar" + "export": "Exportar", + "clearCache": "Limpiar caché", + "clearCacheDesc": "Si tienes problemas con las imágenes que no cargan o las fuentes no se muestran correctamente, intenta limpiar la caché. Esta acción no eliminará tus datos de usuario.", + "areYouSureToClearCache": "¿Estás seguro de limpiar el caché?", + "clearCacheSuccess": "¡Caché limpiada exitosamente!" }, "user": { "name": "Nombre", "email": "Correo electrónico", "tooltipSelectIcon": "Seleccionar icono", "selectAnIcon": "Seleccione un icono", - "pleaseInputYourOpenAIKey": "por favor ingrese su clave OpenAI", - "pleaseInputYourStabilityAIKey": "por favor ingrese su clave de estabilidad AI", - "clickToLogout": "Haga clic para cerrar la sesión del usuario actual." - }, - "shortcuts": { - "shortcutsLabel": "Atajos", - "command": "Commando", - "addNewCommand": "Añadir nuevo comando", - "updateShortcutStep": "Presione la combinación de teclas deseada y presione ENTER", - "couldNotLoadErrorMsg": "No se pudieron cargar los atajos. Inténtalo de nuevo.", - "couldNotSaveErrorMsg": "No se pudieron guardar los atajos. Inténtalo de nuevo." + "pleaseInputYourOpenAIKey": "por favor ingrese su clave AI", + "clickToLogout": "Haga clic para cerrar la sesión del usuario actual.", + "pleaseInputYourStabilityAIKey": "por favor ingrese su clave de estabilidad AI" }, "mobile": { "personalInfo": "Informacion personal", @@ -489,9 +619,31 @@ "userAgreement": "Acuerdo del Usuario", "termsAndConditions": "Términos y condiciones", "userprofileError": "No se pudo cargar el perfil de usuario", + "userprofileErrorDescription": "Intente cerrar sesión y volver a iniciarla para comprobar si el problema persiste.", "selectLayout": "Seleccionar diseño", "selectStartingDay": "Seleccione el día de inicio", "version": "Versión" + }, + "shortcuts": { + "shortcutsLabel": "Atajos", + "command": "Commando", + "keyBinding": "Atajos", + "addNewCommand": "Añadir nuevo comando", + "updateShortcutStep": "Presione la combinación de teclas deseada y presione ENTER", + "shortcutIsAlreadyUsed": "Este atajo ya se utiliza para: {conflict}", + "resetToDefault": "Restablecer los atajos predeterminados", + "couldNotLoadErrorMsg": "No se pudieron cargar los atajos. Inténtalo de nuevo.", + "couldNotSaveErrorMsg": "No se pudieron guardar los atajos. Inténtalo de nuevo.", + "commands": { + "codeBlockNewParagraph": "Insertar un nuevo párrafo al lado del bloque de código", + "codeBlockIndentLines": "Insertar dos espacios al inicio de la línea en el bloque de código", + "codeBlockOutdentLines": "Eliminar dos espacios al inicio de la línea en el bloque de código", + "codeBlockAddTwoSpaces": "Insertar dos espacios en la posición del cursor en el bloque de código", + "codeBlockSelectAll": "Seleccionar todo el contenido dentro de un bloque de código", + "textAlignLeft": "Alinear texto a la izquierda", + "textAlignCenter": "Alinear el texto al centro", + "textAlignRight": "Alinear el texto a la derecha" + } } }, "grid": { @@ -519,6 +671,7 @@ "createView": "Nueva vista", "duplicateView": "Duplicar vista", "deleteView": "Eliminar vista", + "numberOfVisibleFields": "{} mostrado", "Properties": "Propiedades", "viewList": "Vistas de base de datos" }, @@ -566,7 +719,25 @@ "onOrAfter": "Es en o después", "between": "Está entre", "empty": "Esta vacio", - "notEmpty": "No está vacío" + "notEmpty": "No está vacío", + "choicechipPrefix": { + "before": "Antes", + "after": "Después", + "onOrBefore": "En o antes", + "onOrAfter": "Sobre o después", + "isEmpty": "Está vacio", + "isNotEmpty": "No está vacío" + } + }, + "numberFilter": { + "equal": "Es igual", + "notEqual": "No es igual", + "lessThan": "Es menor que", + "greaterThan": "Es mayor que", + "lessThanOrEqualTo": "Es menor o igual que", + "greaterThanOrEqualTo": "Es mayor o igual que", + "isEmpty": "Está vacío", + "isNotEmpty": "No está vacío" }, "field": { "hide": "Ocultar", @@ -575,6 +746,8 @@ "insertRight": "Insertar a la Derecha", "duplicate": "Duplicar", "delete": "Eliminar", + "wrapCellContent": "Ajustar texto", + "clear": "Borrar celdas", "textFieldName": "Texto", "checkboxFieldName": "Casilla de verificación", "dateFieldName": "Fecha", @@ -585,6 +758,7 @@ "multiSelectFieldName": "Selección múltiple", "urlFieldName": "URL", "checklistFieldName": "Lista de Verificación", + "relationFieldName": "Relación", "numberFormat": "Formato numérico", "dateFormat": "Formato de fecha", "includeTime": "Incluir tiempo", @@ -614,18 +788,36 @@ "editProperty": "Editar propiedad", "newProperty": "Nueva propiedad", "deleteFieldPromptMessage": "¿Está seguro? Esta propiedad será eliminada", + "clearFieldPromptMessage": "¿Estás seguro? Se vaciarán todas las celdas de esta columna.", "newColumn": "Nueva columna", - "format": "Formato" + "format": "Formato", + "reminderOnDateTooltip": "Esta celda tiene un recordatorio programado", + "optionAlreadyExist": "La opción ya existe" }, "rowPage": { "newField": "Agregar un nuevo campo", - "fieldDragElementTooltip": "Haga clic para abrir el menú" + "fieldDragElementTooltip": "Haga clic para abrir el menú", + "showHiddenFields": { + "one": "Mostrar {count} campo oculto", + "many": "Mostrar {count} campos ocultos", + "other": "Mostrar {count} campos ocultos" + }, + "hideHiddenFields": { + "one": "Ocultar {count} campo oculto", + "many": "Ocultar {count} campos ocultos", + "other": "Ocultar {count} campos ocultos" + } }, "sort": { "ascending": "ascendente", "descending": "Descendente", + "by": "Por", + "empty": "Sin ordenamiento activo", + "cannotFindCreatableField": "No se encuentra un campo adecuado para ordenar", "deleteAllSorts": "Eliminar todos filtros", "addSort": "Agregar clasificación", + "removeSorting": "¿Le gustaría eliminar la ordenación?", + "fieldInUse": "Ya estás ordenando por este campo", "deleteSort": "Borrar ordenar" }, "row": { @@ -673,10 +865,35 @@ }, "url": { "launch": "Abrir en el navegador", - "copy": "Copiar URL" + "copy": "Copiar URL", + "textFieldHint": "Introduce una URL" + }, + "relation": { + "relatedDatabasePlaceLabel": "Base de datos relacionada", + "relatedDatabasePlaceholder": "Ninguno", + "inRelatedDatabase": "En", + "rowSearchTextFieldPlaceholder": "Buscar", + "noDatabaseSelected": "No se seleccionó ninguna base de datos, seleccione una primero de la lista a continuación:", + "emptySearchResult": "No se encontraron registros", + "linkedRowListLabel": "{count} filas vinculadas", + "unlinkedRowListLabel": "Vincular otra fila" }, "menuName": "Cuadrícula", - "referencedGridPrefix": "Vista de" + "referencedGridPrefix": "Vista de", + "calculate": "Calcular", + "calculationTypeLabel": { + "none": "Ninguno", + "average": "Promedio", + "max": "Max", + "median": "Media", + "min": "Min", + "sum": "Suma", + "count": "Contar", + "countEmpty": "Contar vacío", + "countEmptyShort": "VACÍO", + "countNonEmpty": "Contar no vacíos", + "countNonEmptyShort": "RELLENO" + } }, "document": { "menuName": "Documento", @@ -710,30 +927,33 @@ "referencedGrid": "Cuadrícula referenciada", "referencedCalendar": "Calendario referenciado", "referencedDocument": "Documento referenciado", - "autoGeneratorMenuItemName": "Escritor de OpenAI", - "autoGeneratorTitleName": "OpenAI: Pídele a AI que escriba cualquier cosa...", + "autoGeneratorMenuItemName": "Escritor de AI", + "autoGeneratorTitleName": "AI: Pídele a AI que escriba cualquier cosa...", "autoGeneratorLearnMore": "Aprende más", "autoGeneratorGenerate": "Generar", - "autoGeneratorHintText": "Pregúntale a OpenAI...", - "autoGeneratorCantGetOpenAIKey": "No puedo obtener la clave de OpenAI", + "autoGeneratorHintText": "Pregúntale a AI...", + "autoGeneratorCantGetOpenAIKey": "No puedo obtener la clave de AI", "autoGeneratorRewrite": "Volver a escribir", "smartEdit": "Asistentes de IA", - "openAI": "IA abierta", "smartEditFixSpelling": "Corregir ortografía", "warning": "⚠️ Las respuestas de la IA pueden ser inexactas o engañosas.", "smartEditSummarize": "Resumir", "smartEditImproveWriting": "Mejorar la escritura", "smartEditMakeLonger": "hacer más largo", - "smartEditCouldNotFetchResult": "No se pudo obtener el resultado de OpenAI", - "smartEditCouldNotFetchKey": "No se pudo obtener la clave de OpenAI", - "smartEditDisabled": "Conectar OpenAI en Configuración", + "smartEditCouldNotFetchResult": "No se pudo obtener el resultado de AI", + "smartEditCouldNotFetchKey": "No se pudo obtener la clave de AI", + "smartEditDisabled": "Conectar AI en Configuración", "discardResponse": "¿Quieres descartar las respuestas de IA?", "createInlineMathEquation": "Crear ecuación", "fonts": "Tipo de letra", + "insertDate": "Insertar fecha", + "emoji": "Emoji", "toggleList": "Alternar lista", + "quoteList": "Lista de citas", "numberedList": "lista numerada", "bulletedList": "Lista con viñetas", "todoList": "Lista de tareas", + "callout": "Callout", "cover": { "changeCover": "Cubierta de cambio", "colors": "Colores", @@ -777,17 +997,22 @@ "left": "Izquierda", "center": "Centro", "right": "Bien", - "defaultColor": "Por defecto" + "defaultColor": "Por defecto", + "depth": "Profundidad" }, "image": { + "addAnImage": "Añadir una imagen", "copiedToPasteBoard": "El enlace de la imagen se ha copiado en el portapapeles.", - "addAnImage": "Añadir una imagen" + "imageUploadFailed": "Error al subir la imagen", + "errorCode": "Código de error" }, "urlPreview": { - "copiedToPasteBoard": "El enlace ha sido copiado al portapapeles." + "copiedToPasteBoard": "El enlace ha sido copiado al portapapeles.", + "convertToLink": "Convertir en enlace incrustado" }, "outline": { - "addHeadingToCreateOutline": "Agregue encabezados para crear una tabla de contenido." + "addHeadingToCreateOutline": "Agregue encabezados para crear una tabla de contenido.", + "noMatchHeadings": "No se han encontrado títulos coincidentes." }, "table": { "addAfter": "Agregar después", @@ -810,7 +1035,12 @@ "toContinue": "continuar", "newDatabase": "Nueva base de datos", "linkToDatabase": "Enlace a la base de datos" - } + }, + "date": "Fecha", + "openAI": "IA abierta" + }, + "outlineBlock": { + "placeholder": "Tabla de contenidos" }, "textBlock": { "placeholder": "Escriba '/' para comandos" @@ -829,27 +1059,48 @@ "placeholder": "Introduce la URL de la imagen" }, "ai": { - "label": "Generar imagen desde OpenAI" + "label": "Generar imagen desde AI", + "placeholder": "Ingrese el prompt para que AI genere una imagen" + }, + "stability_ai": { + "label": "Generar imagen desde Stability AI", + "placeholder": "Ingrese el prompt para que Stability AI genere una imagen" }, "support": "El límite de tamaño de la imagen es de 5 MB. Formatos admitidos: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Imagen inválida", "invalidImageSize": "El tamaño de la imagen debe ser inferior a 5 MB", "invalidImageFormat": "El formato de imagen no es compatible. Formatos admitidos: JPEG, PNG, GIF, SVG", - "invalidImageUrl": "URL de imagen no válida" + "invalidImageUrl": "URL de imagen no válida", + "noImage": "El fichero o directorio no existe" }, "embedLink": { "label": "Insertar enlace", "placeholder": "Pega o escribe el enlace de una imagen" }, + "unsplash": { + "label": "Desempaquetar" + }, "searchForAnImage": "Buscar una imagen", - "saveImageToGallery": "Guardar imagen" + "pleaseInputYourOpenAIKey": "ingresa tu clave AI en la página de Configuración", + "saveImageToGallery": "Guardar imagen", + "failedToAddImageToGallery": "No se pudo agregar la imagen a la galería", + "successToAddImageToGallery": "Imagen agregada a la galería con éxito", + "unableToLoadImage": "No se puede cargar la imagen", + "maximumImageSize": "El tamaño máximo de imagen es de 10 MB", + "uploadImageErrorImageSizeTooBig": "El tamaño de la imagen debe ser inferior a 10 MB.", + "imageIsUploading": "La imagen se está subiendo", + "pleaseInputYourStabilityAIKey": "ingresa tu clave de Stability AI en la página de configuración" }, "codeBlock": { "language": { "label": "Idioma", - "placeholder": "Seleccione el idioma" - } + "placeholder": "Seleccione el idioma", + "auto": "Auto" + }, + "copyTooltip": "Copiar el contenido del bloque de código.", + "searchLanguageHint": "Buscar un idioma", + "codeCopiedSnackbar": "¡Código copiado al portapapeles!" }, "inlineLink": { "placeholder": "Pegar o escribir un enlace", @@ -866,13 +1117,25 @@ } }, "mention": { + "placeholder": "Menciona una persona, una página o fecha...", "page": { "label": "Enlace a la página", "tooltip": "Haga clic para abrir la página" - } + }, + "deleted": "Eliminado", + "deletedContent": "Este contenido no existe o ha sido eliminado." }, "toolbar": { "resetToDefaultFont": "Restablecer a los predeterminados" + }, + "errorBlock": { + "theBlockIsNotSupported": "La versión actual no admite este bloque.", + "blockContentHasBeenCopied": "El contenido del bloque ha sido copiado." + }, + "mobilePageSelector": { + "title": "Seleccionar página", + "failedToLoad": "No se pudo cargar la lista de páginas", + "noPagesFound": "No se encontraron páginas" } }, "board": { @@ -880,14 +1143,19 @@ "createNewCard": "Nuevo", "renameGroupTooltip": "Presione para cambiar el nombre del grupo", "createNewColumn": "Agregar un nuevo grupo", + "addToColumnTopTooltip": "Añade una nueva tarjeta en la parte superior", + "addToColumnBottomTooltip": "Añade una nueva tarjeta en la parte inferior.", "renameColumn": "Renombrar", "hideColumn": "Ocultar", "newGroup": "Nuevo grupo", "deleteColumn": "Borrar", + "deleteColumnConfirmation": "Esto eliminará este grupo y todas las tarjetas que contiene.\n¿Estás seguro de que quieres continuar?", "groupActions": "Acciones grupales" }, "hiddenGroupSection": { - "sectionTitle": "Grupos ocultos" + "sectionTitle": "Grupos ocultos", + "collapseTooltip": "Ocultar los grupos ocultos", + "expandTooltip": "Ver los grupos ocultos" }, "cardDetail": "Detalle de la tarjeta", "cardActions": "Acciones de tarjeta", @@ -921,6 +1189,10 @@ "previousMonth": "Mes anterior", "nextMonth": "Próximo mes" }, + "mobileEventScreen": { + "emptyTitle": "No hay eventos", + "emptyBody": "Presiona el botón más para crear un evento en este día." + }, "settings": { "showWeekNumbers": "Mostrar números de semana", "showWeekends": "Mostrar fines de semana", @@ -928,12 +1200,14 @@ "layoutDateField": "Diseño de calendario por", "changeLayoutDateField": "Cambiar campo de diseño", "noDateTitle": "Sin cita", + "unscheduledEventsTitle": "Eventos no programados", "clickToAdd": "Haga clic para agregar al calendario", "name": "Diseño de calendario", "noDateHint": "Los eventos no programados se mostrarán aquí" }, "referencedCalendarPrefix": "Vista de", - "quickJumpYear": "Ir a" + "quickJumpYear": "Ir a", + "duplicateEvent": "duplicar evento" }, "errorDialog": { "title": "Error de flujo de aplicación", @@ -1003,6 +1277,7 @@ }, "inlineActions": { "noResults": "No hay resultados", + "recentPages": "Paginas recientes", "pageReference": "Referencia de página", "docReference": "Referencia de documento", "boardReference": "Referencia del tablero", @@ -1020,7 +1295,24 @@ "includeTime": "incluir tiempo", "isRange": "Fecha final", "timeFormat": "Formato de tiempo", - "clearDate": "Borrar fecha" + "clearDate": "Borrar fecha", + "reminderLabel": "Recordatorio", + "selectReminder": "Seleccionar recordatorio", + "reminderOptions": { + "none": "Ninguno", + "atTimeOfEvent": "Hora del evento", + "fiveMinsBefore": "5 minutos antes", + "tenMinsBefore": "10 minutos antes", + "fifteenMinsBefore": "15 minutos antes", + "thirtyMinsBefore": "30 minutos antes", + "oneHourBefore": "1 hora antes", + "twoHoursBefore": "2 horas antes", + "onDayOfEvent": "El día del evento", + "oneDayBefore": "1 dia antes", + "twoDaysBefore": "2 dias antes", + "oneWeekBefore": "1 semana antes", + "custom": "Personalizado" + } }, "relativeDates": { "yesterday": "Ayer", @@ -1033,6 +1325,7 @@ "mobile": { "title": "Actualizaciones" }, + "emptyTitle": "¡Todo al día!", "emptyBody": "No hay notificaciones ni acciones pendientes. Disfruta de la calma.", "tabs": { "inbox": "Bandeja de entrada", @@ -1066,14 +1359,18 @@ "replace": "Reemplazar", "replaceAll": "Reemplaza todo", "noResult": "No hay resultados", - "caseSensitive": "Distingue mayúsculas y minúsculas" + "caseSensitive": "Distingue mayúsculas y minúsculas", + "searchMore": "Busca para encontrar más resultados" }, "error": { - "weAreSorry": "Lo lamentamos" + "weAreSorry": "Lo lamentamos", + "loadingViewError": "Estamos teniendo problemas para cargar esta vista. Verifica tu conexión a Internet, actualiza la aplicación y no dudes en comunicarte con el equipo si el problema continúa." }, "editor": { "bold": "Negrita", "bulletedList": "Lista con viñetas", + "bulletedListShortForm": "Con viñetas", + "checkbox": "Checkbox", "embedCode": "Código de inserción", "heading1": "H1", "heading2": "H2", @@ -1081,9 +1378,12 @@ "highlight": "Destacar", "color": "Color", "image": "Imagen", + "date": "Fecha", + "page": "Página", "italic": "Itálico", "link": "Enlace", "numberedList": "Lista numerada", + "numberedListShortForm": "Numerado", "quote": "Cita", "strikethrough": "Tachado", "text": "Texto", @@ -1107,6 +1407,184 @@ "backgroundColorBlue": "Fondo azul", "backgroundColorPurple": "fondo morado", "backgroundColorPink": "fondo rosa", - "backgroundColorRed": "fondo rojo" + "backgroundColorRed": "fondo rojo", + "backgroundColorLime": "Fondo lima", + "backgroundColorAqua": "Fondo aguamarina", + "done": "Hecho", + "cancel": "Cancelar", + "tint1": "Tono 1", + "tint2": "Tono 2", + "tint3": "Tono 3", + "tint4": "Tono 4", + "tint5": "Tono 5", + "tint6": "Tono 6", + "tint7": "Tono 7", + "tint8": "Tono 8", + "tint9": "Tono 9", + "lightLightTint1": "Morado", + "lightLightTint2": "Rosa", + "lightLightTint3": "Rosa claro", + "lightLightTint4": "Naranja", + "lightLightTint5": "Amarillo", + "lightLightTint6": "Lima", + "lightLightTint7": "Verde", + "lightLightTint8": "Aqua", + "lightLightTint9": "Azul", + "urlHint": "URL", + "mobileHeading1": "Encabezado 1", + "mobileHeading2": "Encabezado 2", + "mobileHeading3": "Encabezado 3", + "textColor": "Color de texto", + "backgroundColor": "Color de fondo", + "addYourLink": "Añadir enlace", + "openLink": "Abrir enlace", + "copyLink": "Copiar enlace", + "removeLink": "Quitar enlace", + "editLink": "Editar enlace", + "linkText": "Texto", + "linkTextHint": "Introduce un texto", + "linkAddressHint": "Introduce una URL", + "highlightColor": "Color de resaltado", + "clearHighlightColor": "Quitar color de resaltado", + "customColor": "Color personalizado", + "hexValue": "Valor Hex", + "opacity": "Transparencia", + "resetToDefaultColor": "Reestablecer color predeterminado", + "ltr": "LTR", + "rtl": "RTL", + "auto": "Auto", + "cut": "Cortar", + "copy": "Copiar", + "paste": "Pegar", + "find": "Buscar", + "select": "Seleccionar", + "selectAll": "Seleccionar todo", + "previousMatch": "Resultado anterior", + "nextMatch": "Siguiente resultado", + "closeFind": "Cerrar", + "replace": "Reemplazar", + "replaceAll": "Reemplazar todo", + "regex": "Expresión regular", + "caseSensitive": "Distingue mayúsculas y minúsculas", + "uploadImage": "Subir imagen", + "urlImage": "URL de la Imagen", + "incorrectLink": "Enlace incorrecto", + "upload": "Subir", + "chooseImage": "Elige una imagen", + "loading": "Cargando", + "imageLoadFailed": "Error al subir la imagen", + "divider": "Divisor", + "table": "Tabla", + "colAddBefore": "Añadir antes", + "rowAddBefore": "Añadir antes", + "colAddAfter": "Añadir después", + "rowAddAfter": "Añadir después", + "colRemove": "Quitar", + "rowRemove": "Quitar", + "colDuplicate": "Duplicar", + "rowDuplicate": "Duplicar", + "colClear": "Borrar contenido", + "rowClear": "Borrar contenido", + "slashPlaceHolder": "Escribe '/' para insertar un bloque o comienza a escribir", + "typeSomething": "Escribe algo...", + "toggleListShortForm": "Alternar", + "quoteListShortForm": "Cita", + "mathEquationShortForm": "Fórmula", + "codeBlockShortForm": "Código" + }, + "favorite": { + "noFavorite": "Ninguna página favorita", + "noFavoriteHintText": "Desliza la página hacia la izquierda para agregarla a tus favoritos" + }, + "cardDetails": { + "notesPlaceholder": "Escribe una / para insertar un bloque o comienza a escribir" + }, + "blockPlaceholders": { + "todoList": "Por hacer", + "bulletList": "Lista", + "numberList": "Lista", + "quote": "Cita", + "heading": "Título {}" + }, + "titleBar": { + "pageIcon": "Icono de página", + "language": "Idioma", + "font": "Fuente", + "actions": "Acciones", + "date": "Fecha", + "addField": "Añadir campo", + "userIcon": "Icono de usuario" + }, + "noLogFiles": "No hay archivos de registro", + "newSettings": { + "myAccount": { + "title": "Mi cuenta", + "subtitle": "Personaliza tu perfil, administra la seguridad de la cuenta, abre claves IA o inicia sesión en tu cuenta.", + "profileLabel": "Nombre de cuenta e imagen de perfil", + "profileNamePlaceholder": "Introduce tu nombre", + "accountSecurity": "Seguridad de la cuenta", + "2FA": "Autenticación de 2 pasos", + "aiKeys": "Claves IA", + "accountLogin": "Inicio de sesión de la cuenta", + "updateNameError": "No se pudo actualizar el nombre", + "updateIconError": "No se pudo actualizar el ícono", + "deleteAccount": { + "title": "Borrar cuenta", + "subtitle": "Elimina permanentemente tu cuenta y todos tus datos.", + "deleteMyAccount": "Borrar mi cuenta", + "dialogTitle": "Borrar cuenta", + "dialogContent1": "¿Estás seguro de que deseas eliminar permanentemente tu cuenta?", + "dialogContent2": "Esta acción no se puede deshacer y eliminará el acceso a todos los espacios de equipo, borrará toda tu cuenta, incluidos los espacios de trabajo privados, y lo eliminará de todos los espacios de trabajo compartidos." + } + }, + "workplace": { + "name": "Espacio de trabajo", + "title": "Configuración del espacio de trabajo", + "subtitle": "Personaliza la apariencia, el tema, la fuente, el diseño del texto, la fecha, la hora y el idioma de tu espacio de trabajo.", + "workplaceName": "Nombre del espacio de trabajo", + "workplaceNamePlaceholder": "Introduce el nombre del espacio de trabajo", + "workplaceIcon": "Icono del espacio de trabajo", + "workplaceIconSubtitle": "Sube una imagen o usa un emoji para tu espacio de trabajo. El icono se mostrará en la barra lateral y en las notificaciones.", + "renameError": "Error al renombrar el espacio de trabajo", + "updateIconError": "Error al actualizar el ícono", + "appearance": { + "name": "Apariencia", + "themeMode": { + "auto": "Auto", + "light": "Claro", + "dark": "Oscuro" + }, + "language": "Idioma" + } + }, + "syncState": { + "syncing": "Sincronización", + "synced": "Sincronizado", + "noNetworkConnected": "Ninguna red conectada" + } + }, + "pageStyle": { + "title": "Estilo de página", + "layout": "Disposición", + "coverImage": "Imagen de portada", + "pageIcon": "Icono de página", + "colors": "Colores", + "gradient": "Degradado", + "backgroundImage": "Imagen de fondo", + "presets": "Preajustes", + "photo": "Foto", + "unsplash": "Desempaquetar", + "pageCover": "Portada de página", + "none": "Ninguno" + }, + "commandPalette": { + "placeholder": "Escribe para buscar vistas...", + "bestMatches": "Mejores resultados", + "recentHistory": "Historial reciente", + "navigateHint": "para navegar", + "loadingTooltip": "Buscando resultados...", + "betaLabel": "BETA", + "betaTooltip": "Actualmente solo admitimos la búsqueda de páginas.", + "fromTrashHint": "De la papelera" } } diff --git a/frontend/resources/translations/eu-ES.json b/frontend/resources/translations/eu-ES.json index d3c25fa3aa..2e52231f7c 100644 --- a/frontend/resources/translations/eu-ES.json +++ b/frontend/resources/translations/eu-ES.json @@ -99,14 +99,14 @@ "questionBubble": { "shortcuts": "Lasterbideak", "whatsNew": "Ze berri?", - "help": "Laguntza", "markdown": "Markdown", "debug": { "name": "Debug informazioa", "success": "Debug informazioa kopiatu da!", "fail": "Ezin izan da debug informazioa kopiatu" }, - "feedback": "Iritzia" + "feedback": "Iritzia", + "help": "Laguntza" }, "menuAppHeader": { "addPageTooltip": "Gehitu orri bat", @@ -206,8 +206,7 @@ "language": "Hizkuntza", "user": "Erabiltzailea", "files": "Fitxategiak", - "open": "Ezarpenak ireki", - "supabaseSetting": "Supabase ezarpena" + "open": "Ezarpenak ireki" }, "appearance": { "fontFamily": { @@ -222,7 +221,7 @@ }, "themeUpload": { "button": "Kargatu", - "description": "Kargatu zure AppFlowy gaia beheko botoia erabiliz.", + "description": "Kargatu zure @:appName gaia beheko botoia erabiliz.", "loading": "Mesedez, itxaron zure gaia balioztatzen eta kargatzen dugun bitartean...", "uploadSuccess": "Zure gaia behar bezala kargatu da", "deletionFailure": "Ezin izan da gaia ezabatu. Saiatu eskuz ezabatzen.", @@ -239,7 +238,7 @@ "defaultLocation": "Non gordetzen diren zure datuak", "exportData": "Esportatu zure datuak", "doubleTapToCopy": "Sakatu birritan bidea kopiatzeko", - "restoreLocation": "Berrezarri AppFlowy-ren biden lehenetsira", + "restoreLocation": "Berrezarri @:appName-ren biden lehenetsira", "customizeLocation": "Beste karpeta bat ireki", "restartApp": "Mesedez, berrabiarazi aplikazioa aldaketak indarrean egon daitezen.", "exportDatabase": "Datubasea exportatu", @@ -251,10 +250,10 @@ "defineWhereYourDataIsStored": "Zure datuak non gordetzen diren zehaztu", "open": "Oreki", "openFolder": "Ireki karpeta bat", - "openFolderDesc": "Irakurri eta idatzi zure AppFlowy karpetan...", + "openFolderDesc": "Irakurri eta idatzi zure @:appName karpetan...", "folderHintText": "karpetaren izena", "location": "Karpeta berria sortzen", - "locationDesc": "Aukeratu izen bat AppFlowy datuen karpetarako", + "locationDesc": "Aukeratu izen bat @:appName datuen karpetarako", "browser": "Bilatu", "create": "Sortu", "set": "Ezarri", @@ -265,7 +264,7 @@ "change": "Aldatu", "openLocationTooltips": "Ireki beste datu-direktorio bat", "openCurrentDataFolder": "Ireki uneko datuen direktorioa", - "recoverLocationTooltips": "Berrezarri AppFlowyren datu-direktorio lehenetsira", + "recoverLocationTooltips": "Berrezarri @:appNameren datu-direktorio lehenetsira", "exportFileSuccess": "Esportatu fitxategia behar bezala!", "exportFileFail": "Ezin izan da esportatu fitxategia!", "export": "Esportatu" @@ -273,7 +272,7 @@ "user": { "name": "Izena", "selectAnIcon": "Hautatu ikono bat", - "pleaseInputYourOpenAIKey": "mesedez sartu zure OpenAI gakoa" + "pleaseInputYourOpenAIKey": "mesedez sartu zure AI gakoa" } }, "grid": { @@ -430,23 +429,23 @@ "referencedBoard": "Erreferentziazko Batzordea", "referencedGrid": "Erreferentziazko Sarea", "referencedCalendar": "Erreferentziazko Egutegia", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Eskatu AIri edozer idazteko...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Eskatu AIri edozer idazteko...", "autoGeneratorLearnMore": "Gehiago ikasi", "autoGeneratorGenerate": "Sortu", - "autoGeneratorHintText": "Galdetu OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Ezin da lortu OpenAI gakoa", + "autoGeneratorHintText": "Galdetu AI...", + "autoGeneratorCantGetOpenAIKey": "Ezin da lortu AI gakoa", "autoGeneratorRewrite": "Berridatzi", "smartEdit": "AI Laguntzaileak", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Ortografia konpondu", "warning": "⚠️ AI erantzunak okerrak edo engainagarriak izan daitezke.", "smartEditSummarize": "Laburtu", "smartEditImproveWriting": "Hobetu idazkera", "smartEditMakeLonger": "Luzatu", - "smartEditCouldNotFetchResult": "Ezin izan da emaitzarik eskuratu OpenAI-tik", - "smartEditCouldNotFetchKey": "Ezin izan da OpenAI gakoa eskuratu", - "smartEditDisabled": "Konektatu OpenAI Ezarpenetan", + "smartEditCouldNotFetchResult": "Ezin izan da emaitzarik eskuratu AI-tik", + "smartEditCouldNotFetchKey": "Ezin izan da AI gakoa eskuratu", + "smartEditDisabled": "Konektatu AI Ezarpenetan", "discardResponse": "AI erantzunak baztertu nahi dituzu?", "createInlineMathEquation": "Sortu ekuazioa", "toggleList": "Aldatu zerrenda", @@ -580,7 +579,7 @@ "referencedCalendarPrefix": "-ren ikuspegia" }, "errorDialog": { - "title": "AppFlowy errorea", + "title": "@:appName errorea", "howToFixFallback": "Sentitzen dugu eragozpenak! Bidali zure errorea deskribatzen duen arazo bat gure GitHub orrian.", "github": "Ikusi GitHub-en" }, diff --git a/frontend/resources/translations/fa.json b/frontend/resources/translations/fa.json index 0bb112d168..cc93c17d64 100644 --- a/frontend/resources/translations/fa.json +++ b/frontend/resources/translations/fa.json @@ -2,12 +2,14 @@ "appName": "AppFlowy", "defaultUsername": "من", "welcomeText": "به @:appName خوش آمدید", + "welcomeTo": "خوش آمدید به", "githubStarText": "به گیت‌هاب ما ستاره دهید", "subscribeNewsletterText": "اشتراک در خبرنامه", "letsGoButtonText": "شروع کنید", "title": "عنوان", "youCanAlso": "همچنین می‌توانید", "and": "و", + "failedToOpenUrl": "خطا در بازکردن نشانی وب: {}", "blockActions": { "addBelowTooltip": "برای افزودن در زیر کلیک کنید", "addAboveCmd": "Alt+click", @@ -32,19 +34,47 @@ "signIn": { "loginTitle": "ورود به @:appName", "loginButtonText": "ورود", + "loginStartWithAnonymous": "ادامه دادن با یک جلسه ناشناس", "continueAnonymousUser": "ادامه دادن به صورت کاربر مهمان", + "anonymous": "ناشناس", "buttonText": "ورود", + "signingInText": "در حال ورود...", "forgotPassword": "رمز عبور را فراموش کرده اید؟", "emailHint": "ایمیل", "passwordHint": "رمز عبور", "dontHaveAnAccount": "آیا حساب کاربری ندارید؟", + "createAccount": "ساخت حساب کاربری", "repeatPasswordEmptyError": "تکرار رمز عبور نمی‌تواند خالی باشد", "unmatchedPasswordError": "تکرار رمز عبور مشابه رمز عبور نیست", + "syncPromptMessage": "همگام سازی داده ها ممکن است کمی طول بکشد. لطفا این صفحه را نبندید", + "or": "یا", + "signInWithGoogle": "ادامه دادن با گوگل", + "signInWithGithub": "ادامه دادن با گیتهاب", + "signInWithDiscord": "ادامه دادن با دیسکورد", + "signInWithApple": "ادامه دادن با اپل", + "continueAnotherWay": "ادامه دادن از طریق دیگر", + "signUpWithGoogle": "ثبت نام با گوگل", + "signUpWithGithub": "ثبت نام با گیتهاب", + "signUpWithDiscord": "ثبت نام با دیسکورد", "signInWith": "ثبت نام با:", + "signInWithEmail": "ادامه دادن با ایمیل", + "signInWithMagicLink": "ادامه", + "pleaseInputYourEmail": "لطفا آدرس ایمیل خود را وارد کنید", + "settings": "تنظیمات", + "invalidEmail": "لطفا یک آدرس ایمیل معتبر وارد کنید", + "alreadyHaveAnAccount": "حساب کاربری دارید؟", + "logIn": "ورود", + "generalError": "مشکلی پیش آمد. لطفاً بعداً دوباره امتحان کنید", "loginAsGuestButtonText": "شروع کنید" }, "workspace": { + "chooseWorkspace": "فضای کار خود را انتخاب کنید", + "defaultName": "فضای کار من", "create": "ایجاد فضای کار", + "new": "فضای کار جدید", + "learnMore": "بیشتر بدانید", + "renameWorkspace": "حذف فضای کار", + "workspaceNameCannotBeEmpty": "اسم فضای کار نمی‌تواند خالی باشد", "hint": "فضای کار", "notFoundError": "فضای کاری پیدا نشد" }, @@ -109,14 +139,14 @@ "questionBubble": { "shortcuts": "میانبرها", "whatsNew": "تازه‌ترین‌ها", - "help": "پشتیبانی و مستندات", "markdown": "Markdown", "debug": { "name": "اطلاعات اشکال‌زدایی", "success": "طلاعات اشکال زدایی در کلیپ بورد کپی شد!", "fail": "نمی توان اطلاعات اشکال زدایی را در کلیپ بورد کپی کرد" }, - "feedback": "بازخورد" + "feedback": "بازخورد", + "help": "پشتیبانی و مستندات" }, "menuAppHeader": { "moreButtonToolTip": "حذف، تغییر نام، و موارد دیگر...", @@ -245,7 +275,7 @@ }, "themeUpload": { "button": "بارگذاری", - "description": "تم قالب AppFlowy خود را با استفاده از دکمه زیر آپلود کنید.", + "description": "تم قالب @:appName خود را با استفاده از دکمه زیر آپلود کنید.", "loading": "لطفاً منتظر بمانید تا تم قالب شما را اعتبارسنجی و آپلود کنیم...", "uploadSuccess": "تم قالب شما با موفقیت آپلود شد", "deletionFailure": "تم حذف نشد. سعی کنید آن را به صورت دستی حذف کنید.", @@ -262,7 +292,7 @@ "defaultLocation": "خواندن فایل‌ها و مکان ذخیره داده‌ها", "exportData": "از داده‌های خود خروجی بگیرید", "doubleTapToCopy": "برای کپی کردن دوبار کلیک کنید", - "restoreLocation": "بازیابی به مسیر پیش فرض AppFlowy", + "restoreLocation": "بازیابی به مسیر پیش فرض @:appName", "customizeLocation": "پوشه دیگری باز کنید", "restartApp": "لطفاً برنامه را مجدداً راه اندازی کنید تا تغییرات اعمال شوند.", "exportDatabase": "از پایگاه داده‌ها خروجی بگیرید", @@ -274,10 +304,10 @@ "defineWhereYourDataIsStored": "محل ذخیره داده های خود را مشخص کنید", "open": "باز کردن", "openFolder": "باز کردن یک پوشه موجود", - "openFolderDesc": "خواندن و نوشتن آن در یک پوشه AppFlowy موجود", + "openFolderDesc": "خواندن و نوشتن آن در یک پوشه @:appName موجود", "folderHintText": "نام پوشه", "location": "ایجاد یک پوشه جدید", - "locationDesc": "یک نام برای پوشه داده AppFlowy خود انتخاب کنید", + "locationDesc": "یک نام برای پوشه داده @:appName خود انتخاب کنید", "browser": "مرورگر", "create": "ایجاد کردن", "set": "تنظیم کردن", @@ -288,7 +318,7 @@ "change": "تغییر", "openLocationTooltips": "باز کردن یک فهرست پوشه دیگر", "openCurrentDataFolder": "باز کردن فهرست پوشه فعلی", - "recoverLocationTooltips": "بازنشانی به فهرست داده های پیش فرض AppFlowy", + "recoverLocationTooltips": "بازنشانی به فهرست داده های پیش فرض @:appName", "exportFileSuccess": "خروجی گرفتن از فایل با موفقیت انجام شد.", "exportFileFail": "خروجی گرفتن از فایل انجام نشد!", "export": "خروجی گرفتن" @@ -296,7 +326,7 @@ "user": { "name": "نام", "selectAnIcon": "انتخاب یک آیکون", - "pleaseInputYourOpenAIKey": "لطفا کلید OpenAI خود را وارد کنید", + "pleaseInputYourOpenAIKey": "لطفا کلید AI خود را وارد کنید", "clickToLogout": "برای خروج از کاربر فعلی کلیک کنید" }, "shortcuts": { @@ -465,23 +495,22 @@ "referencedBoard": "بورد مرجع", "referencedGrid": "شبکه‌نمایش مرجع", "referencedCalendar": "تقویم مرجع", - "autoGeneratorMenuItemName": "OpenAI نویسنده", + "autoGeneratorMenuItemName": "AI نویسنده", "autoGeneratorTitleName": "از هوش مصنوعی بخواهید هر چیزی بنویسد...", "autoGeneratorLearnMore": "بیشتر بدانید", "autoGeneratorGenerate": "بنویس", - "autoGeneratorHintText": "از OpenAI بپرسید ...", - "autoGeneratorCantGetOpenAIKey": "کلید OpenAI را نمی توان دریافت کرد", + "autoGeneratorHintText": "از AI بپرسید ...", + "autoGeneratorCantGetOpenAIKey": "کلید AI را نمی توان دریافت کرد", "autoGeneratorRewrite": "بازنویس", "smartEdit": "دستیاران هوشمند", - "openAI": "OpenAI", "smartEditFixSpelling": "اصلاح نگارش", "warning": "⚠️ پاسخ‌های هوش مصنوعی می‌توانند نادرست یا گمراه‌کننده باشند", "smartEditSummarize": "خلاصه‌نویسی", "smartEditImproveWriting": "بهبود نگارش", "smartEditMakeLonger": "به نوشته اضافه کن", - "smartEditCouldNotFetchResult": "نتیجه‌ای از OpenAI گرفته نشد", - "smartEditCouldNotFetchKey": "کلید OpenAI واکشی نشد", - "smartEditDisabled": "به OpenAI در تنظیمات وصل شوید", + "smartEditCouldNotFetchResult": "نتیجه‌ای از AI گرفته نشد", + "smartEditCouldNotFetchKey": "کلید AI واکشی نشد", + "smartEditDisabled": "به AI در تنظیمات وصل شوید", "discardResponse": "آیا می خواهید پاسخ های هوش مصنوعی را حذف کنید؟", "createInlineMathEquation": "ایجاد معادله", "toggleList": "Toggle لیست", @@ -533,7 +562,8 @@ }, "outline": { "addHeadingToCreateOutline": "برای ایجاد فهرست مطالب سر‌فصل‌ها را وارد کنید" - } + }, + "openAI": "AI" }, "textBlock": { "placeholder": "برای دستورات '/' را تایپ کنید" @@ -621,7 +651,7 @@ "referencedCalendarPrefix": "نمای" }, "errorDialog": { - "title": "خطای AppFlowy", + "title": "خطای @:appName", "howToFixFallback": "بابت مشکل پیش آمده متأسفیم! مشکل و شرح آن را در صفحه GitHub ما ارسال کنید.", "github": "مشاهده در GitHub" }, diff --git a/frontend/resources/translations/fr-CA.json b/frontend/resources/translations/fr-CA.json index 958762094e..589d2dfe18 100644 --- a/frontend/resources/translations/fr-CA.json +++ b/frontend/resources/translations/fr-CA.json @@ -9,6 +9,7 @@ "title": "Titre", "youCanAlso": "Vous pouvez aussi", "and": "et", + "failedToOpenUrl": "Échec de l'ouverture de l'URL : {}", "blockActions": { "addBelowTooltip": "Cliquez pour ajouter ci-dessous", "addAboveCmd": "Alt+clic", @@ -35,17 +36,39 @@ "loginButtonText": "Connexion", "loginStartWithAnonymous": "Lancer avec une session anonyme", "continueAnonymousUser": "Continuer avec une session anonyme", + "anonymous": "Anonyme", "buttonText": "Se connecter", "signingInText": "Connexion en cours...", "forgotPassword": "Mot de passe oublié?", "emailHint": "Courriel", "passwordHint": "Mot de passe", "dontHaveAnAccount": "Vous n'avez pas encore de compte?", + "createAccount": "Créer un compte", "repeatPasswordEmptyError": "Vous n'avez pas ressaisi votre mot de passe", "unmatchedPasswordError": "Les deux mots de passe ne sont pas identiques", "syncPromptMessage": "La synchronisation des données peut prendre un certain temps. Merci de ne pas fermer pas cette page.", "or": "OU", + "signInWithGoogle": "Continuer avec Google", + "signInWithGithub": "Continuer avec Github", + "signInWithDiscord": "Continuer avec Discord", + "signInWithApple": "Se connecter avec Apple", + "continueAnotherWay": "Continuer avec une autre méthode", + "signUpWithGoogle": "S'inscrire avec Google", + "signUpWithGithub": "S'inscrire avec Github", + "signUpWithDiscord": "S'inscrire avec Discord", "signInWith": "Se connecter avec:", + "signInWithEmail": "Se connecter avec e-mail", + "signInWithMagicLink": "Continuer", + "signUpWithMagicLink": "S'inscrire avec un lien spécial", + "pleaseInputYourEmail": "Veuillez entrer votre adresse e-mail", + "settings": "Paramètres", + "magicLinkSent": "Lien spécial envoyé à votre email, veuillez vérifier votre boîte de réception", + "invalidEmail": "S'il vous plaît, mettez une adresse email valide", + "alreadyHaveAnAccount": "Déjà un compte ?", + "logIn": "Connexion", + "generalError": "Une erreur s'est produite. Veuillez réessayer plus tard", + "limitRateError": "Pour des raisons de sécurité, vous ne pouvez demander un lien spécial que toutes les 60 secondes", + "magicLinkSentDescription": "Un lien spécial vous a été envoyé par e-mail. Cliquez sur le lien pour vous connecter. Le lien expirera dans 5 minutes.", "LogInWithGoogle": "Se connecter avec Google", "LogInWithGithub": "Se connecter avec Github", "LogInWithDiscord": "Se connecter avec Discord", @@ -53,25 +76,59 @@ }, "workspace": { "chooseWorkspace": "Choisissez votre espace de travail", + "defaultName": "Mon espace de travail", "create": "Créer un espace de travail", + "new": "Nouveau espace de travail", + "importFromNotion": "Importer depuis Notion", + "learnMore": "En savoir plus", "reset": "Réinitialiser l'espace de travail", + "renameWorkspace": "Renommer l'espace de travail", + "workspaceNameCannotBeEmpty": "Le nom de l'espace de travail ne peut être vide", "resetWorkspacePrompt": "La réinitialisation de l'espace de travail supprimera toutes les pages et données qu'elles contiennent. Êtes-vous sûr de vouloir réinitialiser l'espace de travail ? Alternativement, vous pouvez contacter l'équipe d'assistance pour restaurer l'espace de travail", "hint": "Espace de travail", "notFoundError": "Espace de travail introuvable", - "failedToLoad": "Quelque chose s'est mal passé ! Échec du chargement de l'espace de travail. Essayez de fermer toute instance ouverte d'AppFlowy et réessayez.", + "failedToLoad": "Quelque chose s'est mal passé ! Échec du chargement de l'espace de travail. Essayez de fermer toute instance ouverte d'@:appName et réessayez.", "errorActions": { "reportIssue": "Signaler un problème", "reportIssueOnGithub": "Signaler un bug sur Github", "exportLogFiles": "Exporter les logs", "reachOut": "Contactez-nous sur Discord" - } + }, + "menuTitle": "Espaces de travail", + "deleteWorkspaceHintText": "Êtes-vous sûr de vouloir supprimer l'espace de travail ? Cette action ne peut pas être annulée.", + "createSuccess": "Espace de travail créé avec succès", + "createFailed": "Échec de la création de l'espace de travail", + "createLimitExceeded": "Vous avez atteint la limite maximale d'espace de travail autorisée pour votre compte. Si vous avez besoin d'espaces de travail supplémentaires pour continuer votre travail, veuillez en faire la demande sur Github.", + "deleteSuccess": "Espace de travail supprimé avec succès", + "deleteFailed": "Échec de la suppression de l'espace de travail", + "openSuccess": "Ouverture de l'espace de travail réussie", + "openFailed": "Échec de l'ouverture de l'espace de travail", + "renameSuccess": "Espace de travail renommé avec succès", + "renameFailed": "Échec du renommage de l'espace de travail", + "updateIconSuccess": "L'icône de l'espace de travail a été mise à jour avec succès", + "updateIconFailed": "La mise a jour de l'icône de l'espace de travail a échoué", + "cannotDeleteTheOnlyWorkspace": "Impossible de supprimer le seul espace de travail", + "fetchWorkspacesFailed": "Échec de la récupération des espaces de travail", + "leaveCurrentWorkspace": "Quitter l'espace de travail", + "leaveCurrentWorkspacePrompt": "Êtes-vous sûr de vouloir quitter l'espace de travail actuel ?" }, "shareAction": { "buttonText": "Partager", "workInProgress": "Bientôt disponible", "markdown": "Markdown", + "html": "HTML", + "clipboard": "Copier dans le presse-papier", "csv": "CSV", - "copyLink": "Copier le lien" + "copyLink": "Copier le lien", + "publishToTheWeb": "Publier sur le Web", + "publishToTheWebHint": "Créer un site Internet avec AppFlowy", + "publish": "Partager", + "unPublish": "Annuler la publication", + "visitSite": "Visitez le site", + "exportAsTab": "Exporter en tant que", + "publishTab": "Partager", + "shareTab": "Partager", + "publishOnAppFlowy": "Partager sur AppFlowy" }, "moreAction": { "small": "petit", @@ -139,14 +196,14 @@ "questionBubble": { "shortcuts": "Raccourcis", "whatsNew": "Nouveautés", - "help": "Aide et Support Technique", "markdown": "Réduction", "debug": { "name": "Infos du système", "success": "Informations de débogage copiées dans le presse-papiers !", "fail": "Impossible de copier les informations de débogage dans le presse-papiers" }, - "feedback": "Retour" + "feedback": "Retour", + "help": "Aide et Support Technique" }, "menuAppHeader": { "moreButtonToolTip": "Supprimer, renommer et plus...", @@ -278,13 +335,8 @@ "cloudServerType": "Serveur cloud", "cloudServerTypeTip": "Veuillez noter qu'il est possible que votre compte actuel soit déconnecté après avoir changé de serveur cloud.", "cloudLocal": "Local", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "URL de Supabase", - "cloudSupabaseUrlCanNotBeEmpty": "L'URL Supabase ne peut pas être vide", - "cloudSupabaseAnonKey": "Clé anonyme Supabase", - "cloudSupabaseAnonKeyCanNotBeEmpty": "La clé anonyme ne peut pas être vide si l'URL de Supabase n'est pas vide", - "cloudAppFlowy": "AppFlowy Cloud Bêta", - "cloudAppFlowySelfHost": "AppFlowy Cloud auto-hébergé", + "cloudAppFlowy": "@:appName Cloud Bêta", + "cloudAppFlowySelfHost": "@:appName Cloud auto-hébergé", "appFlowyCloudUrlCanNotBeEmpty": "L'URL cloud ne peut pas être vide", "clickToCopy": "Cliquez pour copier", "selfHostStart": "Si vous n'avez pas de serveur, veuillez vous référer au", @@ -305,14 +357,13 @@ "historicalUserList": "Historique de connexion d'utilisateurs", "historicalUserListTooltip": "Cette liste affiche vos comptes anonymes. Vous pouvez cliquer sur un compte pour afficher ses détails. Les comptes anonymes sont créés en cliquant sur le bouton « Commencer »", "openHistoricalUser": "Cliquez pour ouvrir le compte anonyme", - "customPathPrompt": "Le stockage du dossier de données AppFlowy dans un dossier synchronisé avec le cloud tel que Google Drive peut présenter des risques. Si la base de données de ce dossier est consultée ou modifiée à partir de plusieurs emplacements en même temps, cela peut entraîner des conflits de synchronisation et une corruption potentielle des données.", - "importAppFlowyData": "Importer des données à partir du dossier AppFlowy externe", + "customPathPrompt": "Le stockage du dossier de données @:appName dans un dossier synchronisé avec le cloud tel que Google Drive peut présenter des risques. Si la base de données de ce dossier est consultée ou modifiée à partir de plusieurs emplacements en même temps, cela peut entraîner des conflits de synchronisation et une corruption potentielle des données.", + "importAppFlowyData": "Importer des données à partir du dossier @:appName externe", "importingAppFlowyDataTip": "L'importation des données est en cours. Veuillez ne pas fermer l'application", - "importAppFlowyDataDescription": "Copiez les données d'un dossier de données AppFlowy externe et importez-les dans le dossier de données AppFlowy actuel", - "importSuccess": "Importation réussie du dossier de données AppFlowy", - "importFailed": "L'importation du dossier de données AppFlowy a échoué", - "importGuide": "Pour plus de détails, veuillez consulter le document référencé", - "supabaseSetting": "Paramètre Supabase" + "importAppFlowyDataDescription": "Copiez les données d'un dossier de données @:appName externe et importez-les dans le dossier de données @:appName actuel", + "importSuccess": "Importation réussie du dossier de données @:appName", + "importFailed": "L'importation du dossier de données @:appName a échoué", + "importGuide": "Pour plus de détails, veuillez consulter le document référencé" }, "notifications": { "enableNotifications": { @@ -361,7 +412,7 @@ "themeUpload": { "button": "Téléverser", "uploadTheme": "Téléverser le thème", - "description": "Téléversez votre propre thème AppFlowy en utilisant le bouton ci-dessous.", + "description": "Téléversez votre propre thème @:appName en utilisant le bouton ci-dessous.", "loading": "Veuillez patienter pendant que nous validons et téléchargeons votre thème...", "uploadSuccess": "Votre thème a été téléversé avec succès", "deletionFailure": "Échec de la suppression du thème. Essayez de le supprimer manuellement.", @@ -392,7 +443,7 @@ "defaultLocation": "Lire les fichiers et l'emplacement de stockage des données", "exportData": "Exportez vos données", "doubleTapToCopy": "Appuyez deux fois pour copier le chemin", - "restoreLocation": "Restaurer le chemin par défaut d'AppFlowy", + "restoreLocation": "Restaurer le chemin par défaut d'@:appName", "customizeLocation": "Ouvrir un autre dossier", "restartApp": "Veuillez redémarrer l'application pour que les modifications prennent effet.", "exportDatabase": "Exporter la base de données", @@ -404,10 +455,10 @@ "defineWhereYourDataIsStored": "Définissez où vos données sont stockées", "open": "Ouvrir", "openFolder": "Ouvrir un dossier existant", - "openFolderDesc": "Lisez-le et écrivez-le dans votre dossier AppFlowy existant", + "openFolderDesc": "Lisez-le et écrivez-le dans votre dossier @:appName existant", "folderHintText": "Nom de dossier", "location": "Création d'un nouveau dossier", - "locationDesc": "Choisissez un nom pour votre dossier de données AppFlowy", + "locationDesc": "Choisissez un nom pour votre dossier de données @:appName", "browser": "Parcourir", "create": "Créer", "set": "Définir", @@ -418,7 +469,7 @@ "change": "Changer", "openLocationTooltips": "Ouvrir un autre répertoire de données", "openCurrentDataFolder": "Ouvrir le répertoire de données actuel", - "recoverLocationTooltips": "Réinitialiser au répertoire de données par défaut d'AppFlowy", + "recoverLocationTooltips": "Réinitialiser au répertoire de données par défaut d'@:appName", "exportFileSuccess": "Exporter le fichier avec succès !", "exportFileFail": "Échec de l'export du fichier !", "export": "Exporter" @@ -428,20 +479,9 @@ "email": "Courriel", "tooltipSelectIcon": "Sélectionner l'icône", "selectAnIcon": "Sélectionnez une icône", - "pleaseInputYourOpenAIKey": "Veuillez entrer votre clé OpenAI", - "pleaseInputYourStabilityAIKey": "Veuillez saisir votre clé de Stability AI", - "clickToLogout": "Cliquez pour déconnecter l'utilisateur actuel" - }, - "shortcuts": { - "shortcutsLabel": "Raccourcis", - "command": "Commande", - "keyBinding": "Racourcis clavier", - "addNewCommand": "Ajouter une Nouvelle Commande", - "updateShortcutStep": "Appuyez sur la combinaison de touches souhaitée et appuyez sur ENTER", - "shortcutIsAlreadyUsed": "Ce raccourci est déjà utilisé pour : {conflict}", - "resetToDefault": "Réinitialiser les raccourcis clavier par défaut", - "couldNotLoadErrorMsg": "Impossible de charger les raccourcis, réessayez", - "couldNotSaveErrorMsg": "Impossible d'enregistrer les raccourcis. Réessayez" + "pleaseInputYourOpenAIKey": "Veuillez entrer votre clé AI", + "clickToLogout": "Cliquez pour déconnecter l'utilisateur actuel", + "pleaseInputYourStabilityAIKey": "Veuillez saisir votre clé de Stability AI" }, "mobile": { "personalInfo": "Informations personnelles", @@ -459,6 +499,17 @@ "selectLayout": "Sélectionner la mise en page", "selectStartingDay": "Sélectionnez le jour de début", "version": "Version" + }, + "shortcuts": { + "shortcutsLabel": "Raccourcis", + "command": "Commande", + "keyBinding": "Racourcis clavier", + "addNewCommand": "Ajouter une Nouvelle Commande", + "updateShortcutStep": "Appuyez sur la combinaison de touches souhaitée et appuyez sur ENTER", + "shortcutIsAlreadyUsed": "Ce raccourci est déjà utilisé pour : {conflict}", + "resetToDefault": "Réinitialiser les raccourcis clavier par défaut", + "couldNotLoadErrorMsg": "Impossible de charger les raccourcis, réessayez", + "couldNotSaveErrorMsg": "Impossible d'enregistrer les raccourcis. Réessayez" } }, "grid": { @@ -701,23 +752,23 @@ "referencedGrid": "Grille référencée", "referencedCalendar": "Calendrier référencé", "referencedDocument": "Document référencé", - "autoGeneratorMenuItemName": "Rédacteur OpenAI", - "autoGeneratorTitleName": "OpenAI : Demandez à l'IA d'écrire quelque chose...", + "autoGeneratorMenuItemName": "Rédacteur AI", + "autoGeneratorTitleName": "AI : Demandez à l'IA d'écrire quelque chose...", "autoGeneratorLearnMore": "Apprendre encore plus", "autoGeneratorGenerate": "Générer", - "autoGeneratorHintText": "Demandez à OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Impossible d'obtenir la clé OpenAI", + "autoGeneratorHintText": "Demandez à AI...", + "autoGeneratorCantGetOpenAIKey": "Impossible d'obtenir la clé AI", "autoGeneratorRewrite": "Réécrire", "smartEdit": "Assistants IA", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Corriger l'orthographe", "warning": "⚠️ Les réponses de l'IA peuvent être inexactes ou trompeuses.", "smartEditSummarize": "Résumer", "smartEditImproveWriting": "Améliorer l'écriture", "smartEditMakeLonger": "Rallonger", - "smartEditCouldNotFetchResult": "Impossible de récupérer le résultat d'OpenAI", - "smartEditCouldNotFetchKey": "Impossible de récupérer la clé OpenAI", - "smartEditDisabled": "Connectez OpenAI dans les paramètres", + "smartEditCouldNotFetchResult": "Impossible de récupérer le résultat d'AI", + "smartEditCouldNotFetchKey": "Impossible de récupérer la clé AI", + "smartEditDisabled": "Connectez AI dans les paramètres", "discardResponse": "Voulez-vous supprimer les réponses de l'IA ?", "createInlineMathEquation": "Créer une équation", "fonts": "Polices", @@ -774,8 +825,8 @@ "defaultColor": "Défaut" }, "image": { - "copiedToPasteBoard": "Le lien de l'image a été copié dans le presse-papiers", - "addAnImage": "Ajouter une image" + "addAnImage": "Ajouter une image", + "copiedToPasteBoard": "Le lien de l'image a été copié dans le presse-papiers" }, "urlPreview": { "copiedToPasteBoard": "Le lien a été copié dans le presse-papier" @@ -824,8 +875,8 @@ "placeholder": "Entrez l'URL de l'image" }, "ai": { - "label": "Générer une image à partir d'OpenAI", - "placeholder": "Veuillez saisir l'invite pour qu'OpenAI génère l'image" + "label": "Générer une image à partir d'AI", + "placeholder": "Veuillez saisir l'invite pour qu'AI génère l'image" }, "stability_ai": { "label": "Générer une image à partir de Stability AI", @@ -846,14 +897,14 @@ "label": "Unsplash" }, "searchForAnImage": "Rechercher une image", - "pleaseInputYourOpenAIKey": "veuillez saisir votre clé OpenAI dans la page Paramètres", - "pleaseInputYourStabilityAIKey": "veuillez saisir votre clé Stability AI dans la page Paramètres", + "pleaseInputYourOpenAIKey": "veuillez saisir votre clé AI dans la page Paramètres", "saveImageToGallery": "Enregistrer l'image", "failedToAddImageToGallery": "Échec de l'ajout d'une image à la galerie", "successToAddImageToGallery": "Image ajoutée à la galerie avec succès", "unableToLoadImage": "Impossible de charger l'image", "maximumImageSize": "La taille d'image maximale est 10Mo", - "uploadImageErrorImageSizeTooBig": "L'image doit faire moins de 10Mo" + "uploadImageErrorImageSizeTooBig": "L'image doit faire moins de 10Mo", + "pleaseInputYourStabilityAIKey": "veuillez saisir votre clé Stability AI dans la page Paramètres" }, "codeBlock": { "language": { @@ -963,7 +1014,7 @@ "quickJumpYear": "Sauter à" }, "errorDialog": { - "title": "Erreur AppFlowy", + "title": "Erreur @:appName", "howToFixFallback": "Nous sommes désolés pour le désagrément ! Soumettez un problème sur notre page GitHub qui décrit votre erreur.", "github": "Afficher sur GitHub" }, diff --git a/frontend/resources/translations/fr-FR.json b/frontend/resources/translations/fr-FR.json index db1a933b28..989e21f349 100644 --- a/frontend/resources/translations/fr-FR.json +++ b/frontend/resources/translations/fr-FR.json @@ -36,21 +36,39 @@ "loginButtonText": "Connexion", "loginStartWithAnonymous": "Lancer avec une session anonyme", "continueAnonymousUser": "Continuer avec une session anonyme", + "anonymous": "Anonyme", "buttonText": "Se connecter", "signingInText": "Connexion en cours...", "forgotPassword": "Mot de passe oublié ?", "emailHint": "Courriel", "passwordHint": "Mot de passe", "dontHaveAnAccount": "Vous n'avez pas encore de compte ?", + "createAccount": "Créer un compte", "repeatPasswordEmptyError": "Vous n'avez pas ressaisi votre mot de passe", "unmatchedPasswordError": "Les deux mots de passe ne sont pas identiques", "syncPromptMessage": "La synchronisation des données peut prendre un certain temps. Merci de ne pas fermer pas cette page.", "or": "OU", + "signInWithGoogle": "Continuer avec Google", + "signInWithGithub": "Continuer avec Github", + "signInWithDiscord": "Continuer avec Discord", + "signInWithApple": "Se connecter via Apple", + "continueAnotherWay": "Continuer via une autre méthode", + "signUpWithGoogle": "S'inscrire avec Google", + "signUpWithGithub": "S'inscrire avec Github", + "signUpWithDiscord": "S'inscrire avec Discord", "signInWith": "Se connecter avec :", "signInWithEmail": "Se connecter via e-mail", + "signInWithMagicLink": "Continuer", + "signUpWithMagicLink": "S'inscrire avec un lien magique", "pleaseInputYourEmail": "Veuillez entrer votre adresse e-mail", + "settings": "Paramètres", "magicLinkSent": "Lien magique envoyé à votre email, veuillez vérifier votre boîte de réception", "invalidEmail": "S'il vous plaît, mettez une adresse email valide", + "alreadyHaveAnAccount": "Déjà un compte ?", + "logIn": "Connexion", + "generalError": "Une erreur s'est produite. Veuillez réessayer plus tard", + "limitRateError": "Pour des raisons de sécurité, vous ne pouvez demander un lien magique que toutes les 60 secondes", + "magicLinkSentDescription": "Un lien magique vous a été envoyé par e-mail. Cliquez sur le lien pour vous connecter. Le lien expirera dans 5 minutes.", "LogInWithGoogle": "Se connecter avec Google", "LogInWithGithub": "Se connecter avec Github", "LogInWithDiscord": "Se connecter avec Discord", @@ -59,12 +77,18 @@ }, "workspace": { "chooseWorkspace": "Choisissez votre espace de travail", + "defaultName": "Mon espace de travail", "create": "Créer un espace de travail", + "new": "Nouveau espace de travail", + "importFromNotion": "Importer depuis Notion", + "learnMore": "En savoir plus", "reset": "Réinitialiser l'espace de travail", + "renameWorkspace": "Renommer l'espace de travail", + "workspaceNameCannotBeEmpty": "Le nom de l'espace de travail ne peut être vide", "resetWorkspacePrompt": "La réinitialisation de l'espace de travail supprimera toutes les pages et données qu'elles contiennent. Êtes-vous sûr de vouloir réinitialiser l'espace de travail ? Alternativement, vous pouvez contacter l'équipe d'assistance pour restaurer l'espace de travail", "hint": "Espace de travail", "notFoundError": "Espace de travail introuvable", - "failedToLoad": "Quelque chose s'est mal passé ! Échec du chargement de l'espace de travail. Essayez de fermer toute instance ouverte d'AppFlowy et réessayez.", + "failedToLoad": "Quelque chose s'est mal passé ! Échec du chargement de l'espace de travail. Essayez de fermer toute instance ouverte d'@:appName et réessayez.", "errorActions": { "reportIssue": "Signaler un problème", "reportIssueOnGithub": "Signaler un bug sur Github", @@ -93,9 +117,28 @@ "buttonText": "Partager", "workInProgress": "Bientôt disponible", "markdown": "Markdown", + "html": "HTML", "clipboard": "Copier dans le presse-papier", "csv": "CSV", - "copyLink": "Copier le lien" + "copyLink": "Copier le lien", + "publishToTheWeb": "Publier sur le Web", + "publishToTheWebHint": "Créer un site Web avec AppFlowy", + "publish": "Publier", + "unPublish": "Annuler la publication", + "visitSite": "Visitez le site", + "exportAsTab": "Exporter en tant que", + "publishTab": "Publier", + "shareTab": "Partager", + "publishOnAppFlowy": "Publier sur AppFlowy", + "shareTabTitle": "Inviter à collaborer", + "shareTabDescription": "Pour faciliter la collaboration avec n'importe qui", + "copyLinkSuccess": "Lien copié", + "copyShareLink": "Copier le lien de partage", + "copyLinkFailed": "Impossible de copier le lien dans le presse-papiers", + "copyLinkToBlockSuccess": "Lien de bloc copié dans le presse-papiers", + "copyLinkToBlockFailed": "Impossible de copier le lien du bloc dans le presse-papiers", + "manageAllSites": "Gérer tous les sites", + "updatePathName": "Mettre à jour le nom du chemin" }, "moreAction": { "small": "petit", @@ -108,12 +151,17 @@ "charCount": "Compteur de caractère: {}", "createdAt": "Créé à: {}", "deleteView": "Supprimer", - "duplicateView": "Dupliquer" + "duplicateView": "Dupliquer", + "wordCountLabel": "Mots:", + "charCountLabel": "Charactères: ", + "createdAtLabel": "Créé:", + "syncedAtLabel": "Synchronisé" }, "importPanel": { "textAndMarkdown": "Texte et Markdown", "documentFromV010": "Document de la v0.1.0", "databaseFromV010": "Base de données à partir de la v0.1.0", + "notionZip": "Fichier ZIP exporté depuis Notion", "csv": "CSV", "database": "Base de données" }, @@ -126,7 +174,11 @@ "openNewTab": "Ouvrir dans un nouvel onglet", "moveTo": "Déplacer vers", "addToFavorites": "Ajouter aux Favoris", - "copyLink": "Copier le lien" + "copyLink": "Copier le lien", + "changeIcon": "Changer d'icône", + "collapseAllPages": "Réduire toutes les sous-pages", + "movePageTo": "Déplacer vers", + "move": "Déplacer" }, "blankPageTitle": "Page vierge", "newPageText": "Nouvelle page", @@ -134,9 +186,52 @@ "newGridText": "Nouvelle grille", "newCalendarText": "Nouveau calendrier", "newBoardText": "Nouveau tableau", + "chat": { + "newChat": "Chat IA", + "inputMessageHint": "Demandez à l'IA @:appName", + "inputLocalAIMessageHint": "Demander l'IA locale @:appName", + "unsupportedCloudPrompt": "Cette fonctionnalité n'est disponible que lors de l'utilisation du cloud @:appName", + "relatedQuestion": "Questions Associées", + "serverUnavailable": "Service temporairement indisponible. Veuillez réessayer ultérieurement.", + "aiServerUnavailable": "🌈 Oh-oh ! 🌈. Une licorne a mangé notre réponse. Veuillez réessayer !", + "retry": "Réessayer", + "clickToRetry": "Cliquez pour réessayer", + "regenerateAnswer": "Régénérer", + "question1": "Comment utiliser Kanban pour gérer les tâches", + "question2": "Expliquez la méthode GTD", + "question3": "Pourquoi utiliser Rust", + "question4": "Recette avec ce qu'il y a dans ma cuisine", + "question5": "Créer une illustration pour ma page", + "question6": "Dresser une liste de choses à faire pour ma semaine à venir", + "aiMistakePrompt": "L'IA peut faire des erreurs. Vérifiez les informations importantes.", + "chatWithFilePrompt": "Voulez-vous discuter avec le fichier ?", + "indexFileSuccess": "Indexation du fichier réussie", + "inputActionNoPages": "Aucun résultat de page", + "referenceSource": { + "zero": "0 sources trouvées", + "one": "{count} source trouvée", + "other": "{count} sources trouvées" + }, + "clickToMention": "Cliquez pour mentionner une page", + "uploadFile": "Téléchargez des fichiers PDF, MD ou TXT pour discuter avec", + "questionDetail": "Bonjour {}! Comment puis-je vous aider aujourd'hui?", + "indexingFile": "Indexation {}", + "generatingResponse": "Générer une réponse", + "selectSources": "Sélectionner Sources", + "sourcesLimitReached": "Vous ne pouvez sélectionner que jusqu'à 3 documents de niveau supérieur et leurs enfants", + "sourceUnsupported": "Nous ne prenons pas en charge le chat avec des bases de données pour le moment", + "regenerate": "Réessayer", + "addToPageButton": "Ajouter à la page", + "addToPageTitle": "Ajouter un message à...", + "addToNewPage": "Ajouter à une nouvelle page", + "addToNewPageName": "Messages extraits de \"{}\"", + "addToNewPageSuccessToast": "Message ajouté à", + "openPagePreviewFailedToast": "Échec de l'ouverture de la page" + }, "trash": { "text": "Corbeille", "restoreAll": "Tout restaurer", + "restore": "Restaurer", "deleteAll": "Tout supprimer", "pageHeader": { "fileName": "Nom de fichier", @@ -151,6 +246,10 @@ "title": "Êtes-vous sûr de vouloir restaurer toutes les pages dans la corbeille ?", "caption": "Cette action ne peut pas être annulée." }, + "restorePage": { + "title": "Restaurer: {}", + "caption": "Etes-vous sûr de vouloir restaurer cette page ?" + }, "mobile": { "actions": "Actions de la corbeille", "empty": "La corbeille est vide", @@ -163,26 +262,28 @@ "deletePagePrompt": { "text": "Cette page se trouve dans la corbeille", "restore": "Restaurer la page", - "deletePermanent": "Supprimer définitivement" + "deletePermanent": "Supprimer définitivement", + "deletePermanentDescription": "Etes-vous sûr de vouloir supprimer définitivement cette page ? Cette action est irréversible." }, "dialogCreatePageNameHint": "Nom de la page", "questionBubble": { "shortcuts": "Raccourcis", "whatsNew": "Nouveautés", - "help": "Aide et Support", - "markdown": "Réduction", + "markdown": "Rédaction", "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" }, - "feedback": "Retour" + "feedback": "Retour", + "help": "Aide et Support" }, "menuAppHeader": { "moreButtonToolTip": "Supprimer, renommer et plus...", "addPageTooltip": "Ajoutez rapidement une page à l'intérieur", "defaultNewPageName": "Sans-titre", - "renameDialog": "Renommer" + "renameDialog": "Renommer", + "pageNameSuffix": "Copier" }, "noPagesInside": "Aucune page à l'intérieur", "toolbar": { @@ -194,7 +295,7 @@ "strike": "Barré", "numList": "Liste numérotée", "bulletList": "Liste à puces", - "checkList": "To-Do List", + "checkList": "To-Do list", "inlineCode": "Code en ligne", "quote": "Citation", "header": "En-tête", @@ -212,11 +313,13 @@ "dragRow": "Appuyez longuement pour réorganiser la ligne", "viewDataBase": "Voir la base de données", "referencePage": "Ce {nom} est référencé", - "addBlockBelow": "Ajouter un bloc ci-dessous" + "addBlockBelow": "Ajouter un bloc ci-dessous", + "aiGenerate": "Générer" }, "sideBar": { "closeSidebar": "Fermer le menu latéral", "openSidebar": "Ouvrir le menu latéral", + "expandSidebar": "Agrandir la page", "personal": "Personnel", "private": "Privé", "workspace": "Espace de travail", @@ -229,6 +332,40 @@ "addAPageToPrivate": "Ajouter une page à l'espace privé", "addAPageToWorkspace": "Ajouter une page à l'espace de travail", "recent": "Récent", + "today": "Aujourd'hui", + "thisWeek": "Cette semaine", + "others": "Favoris précédents", + "earlier": "Plus tôt", + "justNow": "tout à l' heure", + "minutesAgo": "Il y a {count} minutes", + "lastViewed": "Dernière consultation", + "favoriteAt": "Favoris", + "emptyRecent": "Aucun document récent", + "emptyRecentDescription": "Quand vous consultez des documents, ils apparaîtront ici pour les retrouver facilement", + "emptyFavorite": "Aucun document favori", + "emptyFavoriteDescription": "Commencez à explorer et marquez les documents comme favoris. Ils seront répertoriés ici pour un accès rapide !", + "removePageFromRecent": "Supprimer cette page des Récents ?", + "removeSuccess": "Supprimé avec succès", + "favoriteSpace": "Favoris", + "RecentSpace": "Récent", + "Spaces": "Espaces", + "upgradeToPro": "Passer à Pro", + "upgradeToAIMax": "Débloquez une l'IA illimitée", + "storageLimitDialogTitle": "Vous n'avez plus d'espace de stockage gratuit. Effectuez une mise à niveau pour débloquer un espace de stockage illimité", + "storageLimitDialogTitleIOS": "Vous n'avez plus d'espace de stockage gratuit.", + "aiResponseLimitTitle": "Vous n'avez plus de réponses d'IA gratuites. Passez au plan Pro ou achetez un module complémentaire d'IA pour débloquer des réponses illimitées", + "aiResponseLimitDialogTitle": "La limite des réponses de l'IA a été atteinte", + "aiResponseLimit": "Vous n'avez plus de réponses IA gratuites.\n\nAccédez à Paramètres -> Plans -> Cliquez sur AI Max ou Pro Plan pour obtenir plus de réponses AI", + "askOwnerToUpgradeToPro": "Votre espace de stockage gratuit est presque plein. Demandez au propriétaire de votre espace de travail de passer au plan Pro", + "askOwnerToUpgradeToProIOS": "Votre espace de travail manque d’espace de stockage gratuit.", + "askOwnerToUpgradeToAIMax": "Votre espace de travail est à court de réponses d'IA gratuites. Demandez au propriétaire de votre espace de travail de mettre à niveau le plan ou d'acheter des modules complémentaires d'IA", + "askOwnerToUpgradeToAIMaxIOS": "Votre espace de travail est à court de réponses IA gratuites.", + "purchaseStorageSpace": "Acheter un espace de stockage", + "singleFileProPlanLimitationDescription": "Vous avez dépassé la taille maximale de téléchargement de fichiers autorisée dans le plan gratuit. Veuillez passer au plan Pro pour télécharger des fichiers plus volumineux", + "purchaseAIResponse": "Acheter", + "askOwnerToUpgradeToLocalAI": "Demander au propriétaire de l'espace de travail d'activer l'IA locale", + "upgradeToAILocal": "Exécutez des modèles locaux sur votre appareil pour une confidentialité optimale", + "upgradeToAILocalDesc": "Discutez avec des PDF, améliorez votre écriture et remplissez automatiquement des tableaux à l'aide de l'IA locale", "public": "Publique", "clickToHidePublic": "Cliquez pour masquer l'espace public\nLes pages que vous avez créées ici sont visibles par tous les membres", "addAPageToPublic": "Ajouter une page à l'espace public" @@ -247,6 +384,7 @@ }, "button": { "ok": "OK", + "confirm": "Confirmer", "done": "Fait", "cancel": "Annuler", "signIn": "Se connecter", @@ -264,16 +402,22 @@ "upload": "Télécharger", "edit": "Modifier", "delete": "Supprimer", + "copy": "Copier", "duplicate": "Dupliquer", "putback": "Remettre", "update": "Mettre à jour", "share": "Partager", "removeFromFavorites": "Retirer des favoris", + "removeFromRecent": "Supprimer des récents", "addToFavorites": "Ajouter aux favoris", + "favoriteSuccessfully": "Succès en favoris", + "unfavoriteSuccessfully": "Succès retiré des favoris", + "duplicateSuccessfully": "Dupliqué avec succès", "rename": "Renommer", "helpCenter": "Centre d'aide", "add": "Ajouter", "yes": "Oui", + "no": "Non", "clear": "Nettoyer", "remove": "Retirer", "dontRemove": "Ne pas retirer", @@ -286,6 +430,20 @@ "signInGoogle": "Se connecter avec Google", "signInGithub": "Se connecter avec Github", "signInDiscord": "Se connecter avec Discord", + "more": "Plus", + "create": "Créer", + "close": "Fermer", + "next": "Suivant", + "previous": "Précédent", + "submit": "Soumettre", + "download": "Télécharger", + "backToHome": "Retour à l'accueil", + "viewing": "Affichage", + "editing": "Édition", + "gotIt": "Compris", + "retry": "Réessayer ", + "uploadFailed": "Échec du téléchargement.", + "copyLinkOriginal": "Copier le lien vers l'original", "tryAGain": "Réessayer" }, "label": { @@ -310,6 +468,592 @@ }, "settings": { "title": "Paramètres", + "popupMenuItem": { + "settings": "Paramètres", + "members": "Membres", + "trash": "Corbeille", + "helpAndSupport": "Aide & Support" + }, + "sites": { + "title": "Sites", + "namespaceTitle": "Espace", + "namespaceDescription": "Gérez votre espace et votre page d'accueil", + "namespaceHeader": "Espace de nom", + "homepageHeader": "Page d'accueil", + "updateNamespace": "Mettre à jour l'espace", + "removeHomepage": "Supprimer la page d'accueil", + "selectHomePage": "Sélectionnez une page", + "clearHomePage": "Effacer la page d'accueil pour cet espace", + "customUrl": "URL personnalisée", + "namespace": { + "description": "Ce changement s'appliquera à toutes les pages publiées en direct sur cet espace", + "tooltip": "Nous nous réservons le droit de supprimer tout espace inapproprié", + "updateExistingNamespace": "Mettre à jour l'espace existant", + "upgradeToPro": "Passez au plan Pro pour définir une page d'accueil", + "redirectToPayment": "Redirection vers la page de paiement...", + "onlyWorkspaceOwnerCanSetHomePage": "Seul le propriétaire de l'espace de travail peut définir une page d'accueil", + "pleaseAskOwnerToSetHomePage": "Veuillez demander au propriétaire de l'espace de travail de passer au plan Pro" + }, + "publishedPage": { + "title": "Toutes les pages publiées", + "description": "Gérez vos pages publiées", + "page": "Page", + "pathName": "Nom du chemin", + "date": "Date de publication", + "emptyHinText": "Vous n'avez aucune page publiée dans cet espace de travail", + "noPublishedPages": "Aucune page publiée", + "settings": "Paramètres de publication", + "clickToOpenPageInApp": "Ouvrir la page dans l'application", + "clickToOpenPageInBrowser": "Ouvrir la page dans le navigateur" + }, + "error": { + "failedToGeneratePaymentLink": "Impossible de générer le lien de paiement pour le plan Pro", + "failedToUpdateNamespace": "Échec de la mise à jour de l'espace", + "proPlanLimitation": "Vous devez effectuer une mise à niveau vers le plan Pro pour mettre à jour l'espace", + "namespaceAlreadyInUse": "Ce nom d'espace déjà pris, veuillez en essayer un autre", + "invalidNamespace": "Nom d'espace invalide, veuillez en essayer un autre", + "namespaceLengthAtLeast2Characters": "Le nom de l'espace doit comporter au moins 2 caractères", + "onlyWorkspaceOwnerCanUpdateNamespace": "Seul le propriétaire de l'espace de travail peut mettre à jour l'espace", + "onlyWorkspaceOwnerCanRemoveHomepage": "Seul le propriétaire de l'espace de travail peut supprimer la page d'accueil", + "setHomepageFailed": "Impossible de définir la page d'accueil", + "namespaceTooLong": "Le nom de l'espace est trop long, veuillez en essayer un autre", + "namespaceTooShort": "Le nom de l'espace est trop court, veuillez en essayer un autre", + "namespaceIsReserved": "Ce nom d'espace est réservé, veuillez en essayer un autre", + "updatePathNameFailed": "Échec de la mise à jour du nom du chemin", + "removeHomePageFailed": "Impossible de supprimer la page d'accueil", + "publishNameContainsInvalidCharacters": "Le nom du chemin contient des caractères non valides, veuillez en essayer un autre", + "publishNameTooShort": "Le nom du chemin est trop court, veuillez en essayer un autre", + "publishNameTooLong": "Le nom du chemin est trop long, veuillez en essayer un autre", + "publishNameAlreadyInUse": "Le nom du chemin est déjà utilisé, veuillez en essayer un autre", + "namespaceContainsInvalidCharacters": "Le nom d'espace contient des caractères non valides, veuillez en essayer un autre", + "publishPermissionDenied": "Seul le propriétaire de l'espace de travail ou l'éditeur de la page peut gérer les paramètres de publication", + "publishNameCannotBeEmpty": "Le nom du chemin ne peut pas être vide, veuillez en essayer un autre" + }, + "success": { + "namespaceUpdated": "Espace mis à jour avec succès", + "setHomepageSuccess": "Définir la page d'accueil avec succès", + "updatePathNameSuccess": "Nom du chemin mis à jour avec succès", + "removeHomePageSuccess": "Supprimer la page d'accueil avec succès" + } + }, + "accountPage": { + "menuLabel": "Mon compte", + "title": "Mon compte", + "general": { + "title": "Nom du compte et image de profil", + "changeProfilePicture": "Changer la photo de profil" + }, + "email": { + "title": "Email", + "actions": { + "change": "Modifier l'email" + } + }, + "login": { + "title": "Connexion au compte", + "loginLabel": "Connexion", + "logoutLabel": "Déconnexion" + } + }, + "workspacePage": { + "menuLabel": "Espace de travail", + "title": "Espace de travail", + "description": "Personnalisez l'apparence, le thème, la police, la disposition du texte, le format de la date/heure et la langue de votre espace de travail.", + "workspaceName": { + "title": "Nom de l'espace de travail", + "savedMessage": "Nom de l'espace de travail enregistré" + }, + "workspaceIcon": { + "title": "Icône de l'espace de travail", + "description": "Personnalisez l'apparence, le thème, la police, la disposition du texte, la date, l'heure et la langue de votre espace de travail." + }, + "appearance": { + "title": "Apparence", + "description": "Personnalisez l'apparence, le thème, la police, la disposition du texte, la date, l'heure et la langue de votre espace de travail.", + "options": { + "system": "Auto", + "light": "Clair", + "dark": "Foncé" + } + }, + "resetCursorColor": { + "title": "Réinitialiser la couleur du curseur du document", + "description": "Êtes-vous sûr de vouloir réinitialiser la couleur du curseur ?" + }, + "resetSelectionColor": { + "title": "Réinitialiser la couleur de sélection du document", + "description": "Êtes-vous sûr de vouloir réinitialiser la couleur de sélection ?" + }, + "resetWidth": { + "resetSuccess": "Réinitialisation réussie de la largeur du document" + }, + "theme": { + "title": "Thème", + "description": "Sélectionnez un thème prédéfini ou téléchargez votre propre thème personnalisé.", + "uploadCustomThemeTooltip": "Télécharger un thème personnalisé" + }, + "workspaceFont": { + "title": "Police de caractère de l'espace de travail", + "noFontHint": "Aucune police trouvée, essayez un autre terme." + }, + "textDirection": { + "title": "Sens du texte", + "leftToRight": "De gauche à droite", + "rightToLeft": "De droite à gauche", + "auto": "Auto", + "enableRTLItems": "Activer les éléments de la barre d'outils RTL" + }, + "layoutDirection": { + "title": "Sens de mise en page", + "leftToRight": "De gauche à droite", + "rightToLeft": "De droite à gauche" + }, + "dateTime": { + "title": "Date et heure", + "example": "{} à {} ({})", + "24HourTime": "Heure sur 24 heures", + "dateFormat": { + "label": "Format de date", + "local": "Locale", + "us": "US", + "iso": "ISO", + "friendly": "Facile à lire", + "dmy": "J/M/A" + } + }, + "language": { + "title": "Langue" + }, + "deleteWorkspacePrompt": { + "title": "Supprimer l'espace de travail", + "content": "Êtes-vous sûr de vouloir supprimer cet espace de travail ? Cette action ne peut pas être annulée." + }, + "leaveWorkspacePrompt": { + "title": "Quitter l'espace de travail", + "content": "Êtes-vous sûr de vouloir quitter cet espace de travail ? Vous allez perdre l’accès à toutes les pages et données qu’il contient.", + "success": "Vous avez quitté l'espace de travail avec succès.", + "fail": "Impossible de quitter l'espace de travail." + }, + "manageWorkspace": { + "title": "Gérer l'espace de travail", + "leaveWorkspace": "Quitter l'espace de travail", + "deleteWorkspace": "Supprimer l'espace de travail" + } + }, + "manageDataPage": { + "menuLabel": "Gérer les données", + "title": "Gérer les données", + "description": "Gérez le stockage local des données ou importez vos données existantes dans @:appName .", + "dataStorage": { + "title": "Emplacement de stockage des fichiers", + "tooltip": "L'emplacement où vos fichiers sont stockés", + "actions": { + "change": "Changer de chemin", + "open": "Ouvrir le répertoire", + "openTooltip": "Ouvrir l’emplacement actuel du dossier de données", + "copy": "Copier le chemin", + "copiedHint": "Lien copié !", + "resetTooltip": "Réinitialiser à l'emplacement par défaut" + }, + "resetDialog": { + "title": "Êtes-vous sûr ?", + "description": "La réinitialisation du chemin d'accès à l'emplacement de données par défaut ne supprimera pas vos données. Si vous souhaitez réimporter vos données actuelles, vous devriez sauvegarder le chemin d'accès actuel." + } + }, + "importData": { + "title": "Importer des données", + "tooltip": "Importer des données depuis les dossiers de sauvegarde/données @:appName", + "description": "Copier les données à partir d'un dossier de données externe @:appName", + "action": "Parcourir le dossier" + }, + "encryption": { + "title": "Chiffrement", + "tooltip": "Gérez la manière dont vos données sont stockées et cryptées", + "descriptionNoEncryption": "L'activation du cryptage crypte toutes les données. Cette opération ne peut pas être annulée.", + "descriptionEncrypted": "Vos données sont cryptées.", + "action": "Crypter les données", + "dialog": { + "title": "Crypter toutes vos données ?", + "description": "Le cryptage de toutes vos données permettra de les protéger et de les sécuriser. Cette action NE PEUT PAS être annulée. Êtes-vous sûr de vouloir continuer ?" + } + }, + "cache": { + "title": "Vider le cache", + "description": "Aide à résoudre des problèmes tels que des image qui ne se chargent pas, des pages manquantes dans un espace ou les polices qui ne se chargent pas. Cela n'affectera pas vos données.", + "dialog": { + "title": "Vider le cache", + "description": "Aide à résoudre des problèmes tels que des image qui ne se chargent pas, des pages manquantes dans un espace ou les polices qui ne se chargent pas. Cela n'affectera pas vos données.", + "successHint": "Cache vidé !" + } + }, + "data": { + "fixYourData": "Corrigez vos données", + "fixButton": "Réparer", + "fixYourDataDescription": "Si vous rencontrez des problèmes avec vos données, vous pouvez essayer de les résoudre ici." + } + }, + "shortcutsPage": { + "menuLabel": "Raccourcis", + "title": "Raccourcis", + "editBindingHint": "Saisir une nouvelle liaison", + "searchHint": "Rechercher", + "actions": { + "resetDefault": "Réinitialiser les paramètres par défaut" + }, + "errorPage": { + "message": "Échec du chargement des raccourcis : {}", + "howToFix": "Veuillez réessayer. Si le problème persiste, veuillez nous contacter sur GitHub." + }, + "resetDialog": { + "title": "Réinitialiser les raccourcis", + "description": "Cela réinitialisera tous vos raccourcis clavier aux valeurs par défaut, vous ne pourrez pas annuler cette opération plus tard, êtes-vous sûr de vouloir continuer ?", + "buttonLabel": "Réinitialiser" + }, + "conflictDialog": { + "title": "{} est actuellement utilisé", + "descriptionPrefix": "Ce raccourci clavier est actuellement utilisé par ", + "descriptionSuffix": ". Si vous remplacez ce raccourci clavier, il sera supprimé de {}.", + "confirmLabel": "Continuer" + }, + "editTooltip": "Appuyez pour commencer à modifier le raccourci clavier", + "keybindings": { + "toggleToDoList": "Basculer vers la liste des tâches", + "insertNewParagraphInCodeblock": "Insérer un nouveau paragraphe", + "pasteInCodeblock": "Coller dans le bloc de code", + "selectAllCodeblock": "Sélectionner tout", + "indentLineCodeblock": "Insérer deux espaces au début de la ligne", + "outdentLineCodeblock": "Supprimer deux espaces au début de la ligne", + "twoSpacesCursorCodeblock": "Insérer deux espaces au niveau du curseur", + "copy": "Copier la sélection", + "paste": "Coller le contenu", + "cut": "Couper la sélection", + "alignLeft": "Aligner le texte à gauche", + "alignCenter": "Aligner le texte au centre", + "alignRight": "Aligner le texte à droite", + "undo": "Annuler", + "redo": "Rétablir", + "convertToParagraph": "Convertir un bloc en paragraphe", + "backspace": "Supprimer", + "deleteLeftWord": "Supprimer le mot de gauche", + "deleteLeftSentence": "Supprimer la phrase de gauche", + "delete": "Supprimer le caractère de droite", + "deleteMacOS": "Supprimer le caractère de gauche", + "deleteRightWord": "Supprimer le mot de droite", + "moveCursorLeft": "Déplacer le curseur vers la gauche", + "moveCursorBeginning": "Déplacer le curseur au début", + "moveCursorLeftWord": "Déplacer le curseur d'un mot vers la gauche", + "moveCursorLeftSelect": "Sélectionnez et déplacez le curseur vers la gauche", + "moveCursorBeginSelect": "Sélectionnez et déplacez le curseur au début", + "moveCursorLeftWordSelect": "Sélectionnez et déplacez le curseur d'un mot vers la gauche", + "moveCursorRight": "Déplacer le curseur vers la droite", + "moveCursorEnd": "Déplacer le curseur jusqu'à la fin", + "moveCursorRightWord": "Déplacer le curseur d'un mot vers la droite", + "moveCursorRightSelect": "Sélectionnez et déplacez le curseur vers la droite", + "moveCursorEndSelect": "Sélectionnez et déplacez le curseur jusqu'à la fin", + "moveCursorRightWordSelect": "Sélectionnez et déplacez le curseur vers la droite d'un mot", + "moveCursorUp": "Déplacer le curseur vers le haut", + "moveCursorTopSelect": "Sélectionnez et déplacez le curseur vers le haut", + "moveCursorTop": "Déplacer le curseur vers le haut", + "moveCursorUpSelect": "Sélectionnez et déplacez le curseur vers le haut", + "moveCursorBottomSelect": "Sélectionnez et déplacez le curseur vers le bas", + "moveCursorBottom": "Déplacer le curseur vers le bas", + "moveCursorDown": "Déplacer le curseur vers le bas", + "moveCursorDownSelect": "Sélectionnez et déplacez le curseur vers le bas", + "home": "Faites défiler vers le haut", + "end": "Faites défiler vers le bas", + "toggleBold": "Inverser le gras", + "toggleItalic": "Inverser l'italique", + "toggleUnderline": "Inverser le soulignement", + "toggleStrikethrough": "Inverser le barré", + "toggleCode": "Inverser la mise en forme code", + "toggleHighlight": "Inverser la surbrillance", + "showLinkMenu": "Afficher le menu des liens", + "openInlineLink": "Ouvrir le lien en ligne", + "openLinks": "Ouvrir tous les liens sélectionnés", + "indent": "Augmenter le retrait", + "outdent": "Diminuer le retrait", + "exit": "Quitter l'édition", + "pageUp": "Faites défiler une page vers le haut", + "pageDown": "Faites défiler une page vers le bas", + "selectAll": "Sélectionner tout", + "pasteWithoutFormatting": "Coller le contenu sans formatage", + "showEmojiPicker": "Afficher le sélecteur d'emoji", + "enterInTableCell": "Ajouter un saut de ligne dans le tableau", + "leftInTableCell": "Déplacer d'une cellule vers la gauche dans le tableau", + "rightInTableCell": "Déplacer d'une cellule vers la droite dans le tableau", + "upInTableCell": "Déplacer d'une cellule vers le haut dans le tableau", + "downInTableCell": "Déplacer d'une cellule vers le bas dans le tableau", + "tabInTableCell": "Aller à la prochaine cellule vide dans le tableau", + "shiftTabInTableCell": "Aller à la précédente cellule vide dans le tableau", + "backSpaceInTableCell": "S'arrêter au début de la cellule" + }, + "commands": { + "codeBlockNewParagraph": "Insérer un nouveau paragraphe à côté du bloc de code", + "codeBlockIndentLines": "Insérer deux retraits au début du bloc de code", + "codeBlockOutdentLines": "Supprimer deux retraits au début du bloc de code", + "codeBlockAddTwoSpaces": "Insérer deux retraits au niveau du curseur dans le bloc de code", + "codeBlockSelectAll": "Sélectionner tout le contenu d'un bloc de code", + "codeBlockPasteText": "Coller du texte dans le bloc de code", + "textAlignLeft": "Aligner le texte à gauche", + "textAlignCenter": "Aligner le texte au centre", + "textAlignRight": "Aligner le texte à droite" + }, + "couldNotLoadErrorMsg": "Impossible de charger les raccourcis, réessayez", + "couldNotSaveErrorMsg": "Impossible d'enregistrer les raccourcis, réessayez" + }, + "aiPage": { + "title": "Paramètres de l'IA", + "menuLabel": "Paramètres IA", + "keys": { + "enableAISearchTitle": "Recherche IA", + "aiSettingsDescription": "Choisissez votre modèle préféré pour alimenter AppFlowy AI. Inclut désormais GPT 4-o, Claude 3,5, Llama 3.1 et Mistral 7B", + "loginToEnableAIFeature": "Les fonctionnalités d'IA sont accessibles uniquement après s'être connecté avec @:appName Cloud. Pour créer un compte @:appName, voir dans la rubrique 'Mon Compte'.", + "llmModel": "Modèle de langage", + "llmModelType": "Type de modèle de langue", + "downloadLLMPrompt": "Télécharger {}", + "downloadAppFlowyOfflineAI": "Le téléchargement du package hors ligne AI permettra à AI de fonctionner sur votre appareil. Voulez-vous continuer ?", + "downloadLLMPromptDetail": "Le téléchargement du modèle local {} prendra jusqu'à {} d'espace de stockage. Voulez-vous continuer ?", + "downloadBigFilePrompt": "Le téléchargement peut prendre environ 10 minutes.", + "downloadAIModelButton": "Télécharger", + "downloadingModel": "Téléchargement", + "localAILoaded": "Modèle d'IA local ajouté avec succès et prêt à être utilisé", + "localAIStart": "Démarrage du chat avec l'IA locale...", + "localAILoading": "Chargement du modèle d'IA locale...", + "localAIStopped": "IA locale arrêtée", + "failToLoadLocalAI": "Impossible de démarrer l'IA locale", + "restartLocalAI": "Redémarrer l'IA locale", + "disableLocalAITitle": "Désactiver l'IA locale", + "disableLocalAIDescription": "Voulez-vous désactiver l'IA locale ?", + "localAIToggleTitle": "Basculer pour activer ou désactiver l'IA locale", + "offlineAIInstruction1": "Suivre les", + "offlineAIInstruction2": "instructions", + "offlineAIInstruction3": "pour activer l'IA hors ligne.", + "offlineAIDownload1": "Si vous n'avez pas téléchargé l'IA AppFlowy, veuillez", + "offlineAIDownload2": "télécharger", + "offlineAIDownload3": "d'abord", + "activeOfflineAI": "Activer", + "downloadOfflineAI": "Télécharger", + "openModelDirectory": "Ouvrir le dossier" + } + }, + "planPage": { + "menuLabel": "Offre", + "title": "Tarif de l'offre", + "planUsage": { + "title": "Résumé de l'utilisation du plan", + "storageLabel": "Stockage", + "storageUsage": "{} sur {} GB", + "unlimitedStorageLabel": "Stockage illimité", + "collaboratorsLabel": "Membres", + "collaboratorsUsage": "{} sur {}", + "aiResponseLabel": "Réponses de l'IA", + "aiResponseUsage": "{} sur {}", + "unlimitedAILabel": "Réponses illimitées", + "proBadge": "Pro", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "IA sur appareil pour Mac", + "memberProToggle": "Plus de membres et une IA illimitée", + "aiMaxToggle": "IA illimitée et accès à des modèles avancés", + "aiOnDeviceToggle": "IA locale pour une confidentialité ultime", + "aiCredit": { + "title": "Ajoutez des crédit IA @:appName ", + "price": "{}", + "priceDescription": "pour 1 000 crédits", + "purchase": "Acheter l'IA", + "info": "Ajoutez 1 000 crédits d'IA par espace de travail et intégrez de manière transparente une IA personnalisable dans votre flux de travail pour des résultats plus intelligents et plus rapides avec jusqu'à :", + "infoItemOne": "10 000 réponses par base de données", + "infoItemTwo": "1 000 réponses par espace de travail" + }, + "currentPlan": { + "bannerLabel": "Plan actuel", + "freeTitle": "Gratuit", + "proTitle": "Pro", + "teamTitle": "Équipe", + "freeInfo": "Idéal pour les particuliers jusqu'à 2 membres pour tout organiser", + "proInfo": "Idéal pour les petites et moyennes équipes jusqu'à 10 membres.", + "teamInfo": "Parfait pour toutes les équipes productives et bien organisées.", + "upgrade": "Changer de plan", + "canceledInfo": "Votre forfait est annulé, vous serez rétrogradé au plan gratuit le {}." + }, + "addons": { + "title": "Compléments", + "addLabel": "Ajouter", + "activeLabel": "Ajouté", + "aiMax": { + "title": "AI Max", + "description": "Réponses IA illimitées alimentées par GPT-4o, Claude 3.5 Sonnet et plus", + "price": "{}", + "priceInfo": "par utilisateur et par mois, facturé annuellement" + }, + "aiOnDevice": { + "title": "IA sur appareil pour Mac", + "description": "Exécutez Mistral 7B, LLAMA 3 et d'autres modèles locaux sur votre machine", + "price": "{}", + "priceInfo": "par utilisateur et par mois, facturé annuellement", + "recommend": "Recommand2 M1 ou plus récent" + } + }, + "deal": { + "bannerLabel": "Offre de nouvelle année !", + "title": "Développez votre équipe !", + "info": "Effectuez une mise à niveau et économisez 10 % sur les forfaits Pro et Team ! Boostez la productivité de votre espace de travail avec de nouvelles fonctionnalités puissantes, notamment l'IA @:appName .", + "viewPlans": "Voir les plans" + } + } + }, + "billingPage": { + "menuLabel": "Facturation", + "title": "Facturation", + "plan": { + "title": "Plan", + "freeLabel": "Gratuit", + "proLabel": "Pro", + "planButtonLabel": "Changer de plan", + "billingPeriod": "Période de facturation", + "periodButtonLabel": "Changer la période " + }, + "paymentDetails": { + "title": "Détails de paiement", + "methodLabel": "Mode de paiement", + "methodButtonLabel": "Changer de mode de paiement" + }, + "addons": { + "title": "Compléments", + "addLabel": "Ajouter", + "removeLabel": "Retirer", + "renewLabel": "Renouveler", + "aiMax": { + "label": "IA Max", + "description": "Débloquez une IA illimitée et des modèles avancés", + "activeDescription": "Prochaine facture due le {}", + "canceledDescription": "IA Max sera disponible jusqu'au {}" + }, + "aiOnDevice": { + "label": "IA local pour Mac", + "description": "Débloquez une IA illimitée locale sur votre appareil", + "activeDescription": "Prochaine facture due le {}", + "canceledDescription": "IA locale pour Mac sera disponible jusqu'au {}" + }, + "removeDialog": { + "title": "Supprimer", + "description": "Êtes-vous sûr de vouloir supprimer {plan}? Vous perdrez l'accès aux fonctionnalités et bénéfices de {plan} de manière immédiate." + } + }, + "currentPeriodBadge": "ACTUEL", + "changePeriod": "Changer de période", + "planPeriod": "{} période", + "monthlyInterval": "Mensuel", + "monthlyPriceInfo": "par personne, facturé mensuellement", + "annualInterval": "Annuellement", + "annualPriceInfo": "par personne, facturé annuellement" + }, + "comparePlanDialog": { + "title": "Comparer et sélectionner un plan", + "planFeatures": "Plan\nCaractéristiques", + "current": "Actuel", + "actions": { + "upgrade": "Améliorer", + "downgrade": "Rétrograder", + "current": "Actuel" + }, + "freePlan": { + "title": "Gratuit", + "description": "Pour les particuliers jusqu'à 2 membres pour tout organiser", + "price": "{}", + "priceInfo": "gratuit pour toujours" + }, + "proPlan": { + "title": "Pro", + "description": "Pour les petites équipes pour gérer les projets et les bases de connaissance", + "price": "{}", + "priceInfo": "par utilisateur et par mois\nfacturé annuellement\n\n{} facturé mensuellement" + }, + "planLabels": { + "itemOne": "Espaces de travail", + "itemTwo": "Membres", + "itemThree": "Stockage", + "itemFour": "Collaboration en temps réel", + "itemFive": "Application mobile", + "itemSix": "Réponses de l'IA", + "itemFileUpload": "Téléchargements de fichiers", + "customNamespace": "Nom d'espace personnalisé", + "tooltipSix": "La durée de vie signifie que le nombre de réponses n'est jamais réinitialisé", + "intelligentSearch": "Recherche intelligente", + "tooltipSeven": "Vous permet de personnaliser une partie de l'URL de votre espace de travail", + "customNamespaceTooltip": "URL de site publiée personnalisée" + }, + "freeLabels": { + "itemOne": "facturé par espace de travail", + "itemTwo": "jusqu'à 2", + "itemThree": "5 Go", + "itemFour": "Oui", + "itemFive": "Oui", + "itemSix": "10 à vie", + "itemFileUpload": "Jusqu'à 7 Mo", + "intelligentSearch": "Recherche intelligente" + }, + "proLabels": { + "itemOne": "facturé par espace de travail", + "itemTwo": "jusqu'à 10", + "itemThree": "illimité", + "itemFour": "Oui", + "itemFive": "Oui", + "itemSix": "illimité", + "itemFileUpload": "Illimité", + "intelligentSearch": "Recherche intelligente" + }, + "paymentSuccess": { + "title": "Vous êtes maintenant sur le plan {} !", + "description": "Votre paiement a été traité avec succès et votre forfait est mis à niveau vers @:appName {}. Vous pouvez consulter les détails de votre forfait sur la page Forfait" + }, + "downgradeDialog": { + "title": "Êtes-vous sûr de vouloir rétrograder votre forfait ?", + "description": "La baisse de votre offre vous ramènera au forfait gratuit. Les membres peuvent perdre l'accès à cet espace de travail et vous devrez peut-être libérer de l'espace pour respecter les limites de stockage du forfait gratuit.", + "downgradeLabel": "Baisser l'offre" + } + }, + "cancelSurveyDialog": { + "title": "Désolé de vous voir partir", + "description": "Nous sommes désolés de vous voir partir. Nous aimerions connaître votre avis pour nous aider à améliorer @:appName . Veuillez prendre un moment pour répondre à quelques questions.", + "commonOther": "Autre", + "otherHint": "Écrivez votre réponse ici", + "questionOne": { + "question": "Qu'est-ce qui vous a poussé à annuler votre @:appName Pro ?", + "answerOne": "Coût trop élevé", + "answerTwo": "Les fonctionnalités ne répondent pas à mes attentes", + "answerThree": "J'ai trouvé une meilleure alternative", + "answerFour": "Je ne l'ai pas suffisamment utilisé pour justifier la dépense", + "answerFive": "Problème de service ou difficultés techniques" + }, + "questionTwo": { + "question": "Quelle est la probabilité que vous envisagiez de vous réabonner à @:appName Pro à l'avenir ?", + "answerOne": "Très probablement", + "answerTwo": "Assez probable", + "answerThree": "Pas sûr", + "answerFour": "Peu probable", + "answerFive": "Très peu probable" + }, + "questionThree": { + "question": "Quelle fonctionnalité Pro avez-vous le plus appréciée lors de votre abonnement ?", + "answerOne": "Collaboration multi-utilisateurs", + "answerTwo": "Historique des versions plus long", + "answerThree": "Réponses IA illimitées", + "answerFour": "Accès aux modèles d'IA locaux" + }, + "questionFour": { + "question": "Comment décririez-vous votre expérience globale avec @:appName ?", + "answerOne": "Super", + "answerTwo": "Bien", + "answerThree": "Moyenne", + "answerFour": "En dessous de la moyenne", + "answerFive": "Insatisfait" + } + }, + "common": { + "uploadingFile": "Le fichier est en cours de téléchargement. Veuillez ne pas quitter l'application", + "uploadNotionSuccess": "Votre fichier zip Notion a été téléchargé avec succès. Une fois l'importation terminée, vous recevrez un e-mail de confirmation", + "reset": "Réinitialiser" + }, "menu": { "appearance": "Apparence", "language": "Langue", @@ -323,28 +1067,29 @@ "syncSetting": "Paramètres de synchronisation", "cloudSettings": "Paramètres cloud", "enableSync": "Activer la synchronisation", + "enableSyncLog": "Activer la journalisation de synchronisation", + "enableSyncLogWarning": "Merci de nous aider à diagnostiquer les problèmes de synchronisation. Cela enregistrera les modifications de votre document dans un fichier local. Veuillez quitter et rouvrir l'application après l'avoir activée", "enableEncrypt": "Chiffrer les données", "cloudURL": "URL de base", + "webURL": "Web URL", "invalidCloudURLScheme": "Schéma invalide", "cloudServerType": "Serveur cloud", "cloudServerTypeTip": "Veuillez noter qu'il est possible que votre compte actuel soit déconnecté après avoir changé de serveur cloud.", "cloudLocal": "Local", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "URL de Supabase", - "cloudSupabaseUrlCanNotBeEmpty": "L'URL Supabase ne peut pas être vide", - "cloudSupabaseAnonKey": "Clé anonyme Supabase", - "cloudSupabaseAnonKeyCanNotBeEmpty": "La clé anonyme ne peut pas être vide si l'URL de Supabase n'est pas vide", - "cloudAppFlowy": "AppFlowy Cloud Bêta", - "cloudAppFlowySelfHost": "AppFlowy Cloud auto-hébergé", + "cloudAppFlowy": "@:appName Cloud Bêta", + "cloudAppFlowySelfHost": "@:appName Cloud auto-hébergé", "appFlowyCloudUrlCanNotBeEmpty": "L'URL cloud ne peut pas être vide", "clickToCopy": "Cliquez pour copier", "selfHostStart": "Si vous n'avez pas de serveur, veuillez vous référer au", "selfHostContent": "document", "selfHostEnd": "pour obtenir des conseils sur la façon d'auto-héberger votre propre serveur", + "pleaseInputValidURL": "Veuillez saisir une URL valide", + "changeUrl": "Changer l'URL auto-hébergée en {}", "cloudURLHint": "Saisissez l'URL de base de votre serveur", + "webURLHint": "Saisissez l'URL de base de votre serveur web", "cloudWSURL": "URL du websocket", "cloudWSURLHint": "Saisissez l'adresse websocket de votre serveur", - "restartApp": "Redémarer", + "restartApp": "Redémarrer", "restartAppTip": "Redémarrez l'application pour que les modifications prennent effet. Veuillez noter que cela pourrait déconnecter votre compte actuel.", "changeServerTip": "Après avoir changé de serveur, vous devez cliquer sur le bouton de redémarrer pour que les modifications prennent effet", "enableEncryptPrompt": "Activez le chiffrement pour sécuriser vos données avec cette clé. Rangez-la en toute sécurité ; une fois activé, il ne peut pas être désactivé. En cas de perte, vos données deviennent irrécupérables. Cliquez pour copier", @@ -356,26 +1101,70 @@ "historicalUserList": "Historique de connexion d'utilisateurs", "historicalUserListTooltip": "Cette liste affiche vos comptes anonymes. Vous pouvez cliquer sur un compte pour afficher ses détails. Les comptes anonymes sont créés en cliquant sur le bouton « Commencer »", "openHistoricalUser": "Cliquez pour ouvrir le compte anonyme", - "customPathPrompt": "Le stockage du dossier de données AppFlowy dans un dossier synchronisé avec le cloud tel que Google Drive peut présenter des risques. Si la base de données de ce dossier est consultée ou modifiée à partir de plusieurs emplacements en même temps, cela peut entraîner des conflits de synchronisation et une corruption potentielle des données.", - "importAppFlowyData": "Importer des données à partir du dossier AppFlowy externe", + "customPathPrompt": "Le stockage du dossier de données @:appName dans un dossier synchronisé avec le cloud tel que Google Drive peut présenter des risques. Si la base de données de ce dossier est consultée ou modifiée à partir de plusieurs emplacements en même temps, cela peut entraîner des conflits de synchronisation et une corruption potentielle des données.", + "importAppFlowyData": "Importer des données à partir du dossier @:appName externe", "importingAppFlowyDataTip": "L'importation des données est en cours. Veuillez ne pas fermer l'application", - "importAppFlowyDataDescription": "Copiez les données d'un dossier de données AppFlowy externe et importez-les dans le dossier de données AppFlowy actuel", - "importSuccess": "Importation réussie du dossier de données AppFlowy", - "importFailed": "L'importation du dossier de données AppFlowy a échoué", - "importGuide": "Pour plus de détails, veuillez consulter le document référencé", - "supabaseSetting": "Paramètre Supabase" + "importAppFlowyDataDescription": "Copiez les données d'un dossier de données @:appName externe et importez-les dans le dossier de données @:appName actuel", + "importSuccess": "Importation réussie du dossier de données @:appName", + "importFailed": "L'importation du dossier de données @:appName a échoué", + "importGuide": "Pour plus de détails, veuillez consulter le document référencé" }, "notifications": { "enableNotifications": { "label": "Activer les notifications", "hint": "Désactivez-la pour empêcher l'affichage des notifications locales." + }, + "showNotificationsIcon": { + "label": "Afficher l'icône des notifications", + "hint": "Désactiver pour masquer l'icône de notification dans la barre latérale." + }, + "archiveNotifications": { + "allSuccess": "Toutes les notifications ont été archivées avec succès", + "success": "Notification archivée avec succès" + }, + "markAsReadNotifications": { + "allSuccess": "Tout a été marqué comme lu avec succès", + "success": "Marqué comme lu avec succès" + }, + "action": { + "markAsRead": "Marquer comme lu", + "multipleChoice": "Sélectionnez plus", + "archive": "Archiver" + }, + "settings": { + "settings": "Paramètres", + "markAllAsRead": "Marquer tout comme lu", + "archiveAll": "Archiver tout" + }, + "emptyInbox": { + "title": "Aucune notification pour le moment", + "description": "Vous serez averti ici des @mentions" + }, + "emptyUnread": { + "title": "Aucune notification non lue", + "description": "Vous êtes à jour !" + }, + "emptyArchived": { + "title": "Aucune notification archivée", + "description": "Vous n'avez pas encore archivé de notifications" + }, + "tabs": { + "inbox": "Boîte de réception", + "unread": "Non lu", + "archived": "Archivé" + }, + "refreshSuccess": "Les notifications ont été actualisées avec succès", + "titles": { + "notifications": "Notifications", + "reminder": "Rappel" } }, "appearance": { "resetSetting": "Réinitialiser ce paramètre", "fontFamily": { "label": "Famille de polices", - "search": "Recherche" + "search": "Recherche", + "defaultFont": "Système" }, "themeMode": { "label": " Mode du Thème", @@ -387,6 +1176,11 @@ "documentSettings": { "cursorColor": "Couleur du curseur du document", "selectionColor": "Couleur de sélection du document", + "width": "Largeur du document", + "changeWidth": "Changement", + "pickColor": "Sélectionnez une couleur", + "colorShade": "Nuance de couleur", + "opacity": "Opacité", "hexEmptyError": "La couleur hexadécimale ne peut pas être vide", "hexLengthError": "La valeur hexadécimale doit comporter 6 chiffres", "hexInvalidError": "Valeur hexadécimale invalide", @@ -413,7 +1207,7 @@ "themeUpload": { "button": "Téléverser", "uploadTheme": "Téléverser le thème", - "description": "Téléversez votre propre thème AppFlowy en utilisant le bouton ci-dessous.", + "description": "Téléversez votre propre thème @:appName en utilisant le bouton ci-dessous.", "loading": "Veuillez patienter pendant que nous validons et téléchargeons votre thème...", "uploadSuccess": "Votre thème a été téléversé avec succès", "deletionFailure": "Échec de la suppression du thème. Essayez de le supprimer manuellement.", @@ -442,12 +1236,15 @@ "members": { "title": "Paramètres des membres", "inviteMembers": "Inviter des membres", + "inviteHint": "Invitation par email", "sendInvite": "Envoyer une invitation", "copyInviteLink": "Copier le lien d'invitation", "label": "Membres", "user": "Utilisateur", "role": "Rôle", "removeFromWorkspace": "Retirer de l'espace de travail", + "removeFromWorkspaceSuccess": "Retiré de l'espace de travail avec succès", + "removeFromWorkspaceFailed": "Suppression du membre échouée ", "owner": "Propriétaire", "guest": "Invité", "member": "Membre", @@ -461,11 +1258,21 @@ "one": "{} membre", "other": "{} membres" }, + "inviteFailedDialogTitle": "Échec de l'envoi de l'invitation", + "inviteFailedMemberLimit": "La limite de membres a été atteinte, veuillez effectuer une mise à niveau pour inviter plus de membres.", + "inviteFailedMemberLimitMobile": "Votre espace de travail a atteint la limite de membres. Utilisez l'application sur PC pour effectuez une mise à niveau et débloquer plus de fonctionnalités.", "memberLimitExceeded": "Vous avez atteint la limite maximale de membres autorisée pour votre compte. Si vous souhaitez ajouter d'autres membres pour continuer votre travail, veuillez en faire la demande sur Github.", + "memberLimitExceededUpgrade": "mise à niveau", + "memberLimitExceededPro": "Limite de membres atteinte, si vous avez besoin de plus de membres, contactez ", + "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Échec de l'ajout d'un membre", "addMemberSuccess": "Membre ajouté avec succès", "removeMember": "Supprimer un membre", - "areYouSureToRemoveMember": "Êtes-vous sûr de vouloir supprimer ce membre ?" + "areYouSureToRemoveMember": "Êtes-vous sûr de vouloir supprimer ce membre ?", + "inviteMemberSuccess": "L'invitation a été envoyée avec succès", + "failedToInviteMember": "Impossible d'inviter un membre", + "workspaceMembersError": "Une erreur s'est produite", + "workspaceMembersErrorDescription": "Nous n'avons pas pu charger la liste des membres. Veuillez essayer plus tard s'il vous plait" } }, "files": { @@ -473,7 +1280,7 @@ "defaultLocation": "Lire les fichiers et l'emplacement de stockage des données", "exportData": "Exportez vos données", "doubleTapToCopy": "Appuyez deux fois pour copier le chemin", - "restoreLocation": "Restaurer le chemin par défaut d'AppFlowy", + "restoreLocation": "Restaurer le chemin par défaut d'@:appName", "customizeLocation": "Ouvrir un autre dossier", "restartApp": "Veuillez redémarrer l'application pour que les modifications prennent effet.", "exportDatabase": "Exporter la base de données", @@ -485,10 +1292,10 @@ "defineWhereYourDataIsStored": "Définissez où vos données sont stockées", "open": "Ouvrir", "openFolder": "Ouvrir un dossier existant", - "openFolderDesc": "Lisez-le et écrivez-le dans votre dossier AppFlowy existant", + "openFolderDesc": "Lisez-le et écrivez-le dans votre dossier @:appName existant", "folderHintText": "Nom de dossier", "location": "Création d'un nouveau dossier", - "locationDesc": "Choisissez un nom pour votre dossier de données AppFlowy", + "locationDesc": "Choisissez un nom pour votre dossier de données @:appName", "browser": "Parcourir", "create": "Créer", "set": "Définir", @@ -499,7 +1306,7 @@ "change": "Changer", "openLocationTooltips": "Ouvrir un autre répertoire de données", "openCurrentDataFolder": "Ouvrir le répertoire de données actuel", - "recoverLocationTooltips": "Réinitialiser au répertoire de données par défaut d'AppFlowy", + "recoverLocationTooltips": "Réinitialiser au répertoire de données par défaut d'@:appName", "exportFileSuccess": "Exporter le fichier avec succès !", "exportFileFail": "Échec de l'export du fichier !", "export": "Exporter", @@ -513,9 +1320,26 @@ "email": "Courriel", "tooltipSelectIcon": "Sélectionner l'icône", "selectAnIcon": "Sélectionnez une icône", - "pleaseInputYourOpenAIKey": "Veuillez entrer votre clé OpenAI", - "pleaseInputYourStabilityAIKey": "Veuillez saisir votre clé de Stability AI", - "clickToLogout": "Cliquez pour déconnecter l'utilisateur actuel" + "pleaseInputYourOpenAIKey": "Veuillez entrer votre clé AI", + "clickToLogout": "Cliquez pour déconnecter l'utilisateur actuel", + "pleaseInputYourStabilityAIKey": "Veuillez saisir votre clé de Stability AI" + }, + "mobile": { + "personalInfo": "Informations personnelles", + "username": "Nom d'utilisateur", + "usernameEmptyError": "Le nom d'utilisateur ne peut pas être vide", + "about": "À propos", + "pushNotifications": "Notifications push", + "support": "Support", + "joinDiscord": "Rejoignez-nous sur Discord", + "privacyPolicy": "Politique de Confidentialité", + "userAgreement": "Accord de l'utilisateur", + "termsAndConditions": "Termes et conditions", + "userprofileError": "Échec du chargement du profil utilisateur", + "userprofileErrorDescription": "Veuillez essayer de vous déconnecter et de vous reconnecter pour vérifier si le problème persiste.", + "selectLayout": "Sélectionner la mise en page", + "selectStartingDay": "Sélectionnez le jour de début", + "version": "Version" }, "shortcuts": { "shortcutsLabel": "Raccourcis", @@ -537,23 +1361,6 @@ "textAlignRight": "Aligner le texte à droite", "codeBlockDeleteTwoSpaces": "Supprimez deux espaces au début de la ligne dans le bloc de code" } - }, - "mobile": { - "personalInfo": "Informations personnelles", - "username": "Nom d'utilisateur", - "usernameEmptyError": "Le nom d'utilisateur ne peut pas être vide", - "about": "À propos", - "pushNotifications": "Notifications push", - "support": "Support", - "joinDiscord": "Rejoignez-nous sur Discord", - "privacyPolicy": "Politique de Confidentialité", - "userAgreement": "Accord de l'utilisateur", - "termsAndConditions": "Termes et conditions", - "userprofileError": "Échec du chargement du profil utilisateur", - "userprofileErrorDescription": "Veuillez essayer de vous déconnecter et de vous reconnecter pour vérifier si le problème persiste.", - "selectLayout": "Sélectionner la mise en page", - "selectStartingDay": "Sélectionnez le jour de début", - "version": "Version" } }, "grid": { @@ -585,6 +1392,13 @@ "Properties": "Propriétés", "viewList": "Vues de base de données" }, + "filter": { + "empty": "Aucun filtre actif", + "addFilter": "Ajouter un filtre", + "cannotFindCreatableField": "Impossible de trouver un champ approprié pour filtrer", + "conditon": "Condition", + "where": "Où" + }, "textFilter": { "contains": "Contient", "doesNotContain": "Ne contient pas", @@ -630,9 +1444,12 @@ "between": "Est entre", "empty": "Est vide", "notEmpty": "N'est pas vide", + "startDate": "Date de début", + "endDate": "Date de fin", "choicechipPrefix": { "before": "Avant", "after": "Après", + "between": "Entre", "onOrBefore": "Pendant ou avant", "onOrAfter": "Pendant ou après", "isEmpty": "Est vide", @@ -650,13 +1467,16 @@ "isNotEmpty": "N'est pas vide" }, "field": { + "label": "Propriété", "hide": "Cacher", "show": "Afficher", "insertLeft": "Insérer à gauche", "insertRight": "Insérer à droite", "duplicate": "Dupliquer", "delete": "Supprimer", + "wrapCellContent": "Envelopper le texte", "clear": "Effacer les cellules", + "switchPrimaryFieldTooltip": "Impossible de modifier le type de champ du champ principal", "textFieldName": "Texte", "checkboxFieldName": "Case à cocher", "dateFieldName": "Date", @@ -668,6 +1488,11 @@ "urlFieldName": "URL", "checklistFieldName": "Check-list", "relationFieldName": "Relation", + "summaryFieldName": "Résume IA", + "timeFieldName": "Horaire", + "mediaFieldName": "Fichiers et médias", + "translateFieldName": "Traduction IA", + "translateTo": "Traduire en", "numberFormat": "Format du nombre", "dateFormat": "Format de la date", "includeTime": "Inclure l'heure", @@ -696,6 +1521,7 @@ "addOption": "Ajouter une option", "editProperty": "Modifier la propriété", "newProperty": "Nouvelle colonne", + "openRowDocument": "Ouvrir en tant que page", "deleteFieldPromptMessage": "Vous voulez supprimer cette propriété ?", "clearFieldPromptMessage": "Es-tu sûr? Toutes les cellules de cette colonne seront vidées", "newColumn": "Nouvelle colonne", @@ -715,7 +1541,9 @@ "one": "Cacher {count} champ caché", "many": "Cacher {count} champs masqués", "other": "Cacher {count} champs masqués" - } + }, + "openAsFullPage": "Ouvrir en pleine page", + "moreRowActions": "Plus d'actions de ligne" }, "sort": { "ascending": "Ascendant", @@ -725,11 +1553,13 @@ "cannotFindCreatableField": "Impossible de trouver un champ approprié pour trier", "deleteAllSorts": "Supprimer tous les tris", "addSort": "Ajouter un tri", + "sortsActive": "Impossible {intention} lors du tri", "removeSorting": "Voulez-vous supprimer le tri ?", "fieldInUse": "Vous êtes déjà en train de trier par ce champ", "deleteSort": "Supprimer le tri" }, "row": { + "label": "Ligne", "duplicate": "Dupliquer", "delete": "Supprimer", "titlePlaceholder": "Sans titre", @@ -737,12 +1567,19 @@ "copyProperty": "Copie de la propriété dans le presse-papiers", "count": "Compte", "newRow": "Nouvelle ligne", + "loadMore": "Charger plus", "action": "Action", "add": "Cliquez sur ajouter ci-dessous", "drag": "Glisser pour déplacer", + "deleteRowPrompt": "Etes-vous sûr de vouloir supprimer cette ligne ? Cette action ne peut pas être annulée", + "deleteCardPrompt": "Etes-vous sûr de vouloir supprimer cette carte ? Cette action ne peut pas être annulée", "dragAndClick": "Faites glisser pour déplacer, cliquez pour ouvrir le menu", "insertRecordAbove": "Insérer l'enregistrement ci-dessus", - "insertRecordBelow": "Insérer l'enregistrement ci-dessous" + "insertRecordBelow": "Insérer l'enregistrement ci-dessous", + "noContent": "Aucun contenu", + "reorderRowDescription": "réorganiser la ligne", + "createRowAboveDescription": "créer une ligne au dessus", + "createRowBelowDescription": "insérer une ligne ci-dessous" }, "selectOption": { "create": "Créer", @@ -775,8 +1612,7 @@ "url": { "launch": "Ouvrir dans le navigateur", "copy": "Copier l'URL", - "textFieldHint": "Entrez une URL", - "copiedNotification": "Copié dans le presse-papier!" + "textFieldHint": "Entrez une URL" }, "relation": { "relatedDatabasePlaceLabel": "Base de données associée", @@ -803,6 +1639,24 @@ "countEmptyShort": "VIDE", "countNonEmpty": "Compter les cellules non vides", "countNonEmptyShort": "REMPLI" + }, + "media": { + "rename": "Rebaptiser", + "download": "Télécharger", + "expand": "Développer", + "delete": "Supprimer", + "moreFilesHint": "+{}", + "addFileOrImage": "Ajouter un fichier ou un lien", + "attachmentsHint": "{}", + "addFileMobile": "Ajouter un fichier", + "extraCount": "+{}", + "deleteFileDescription": "Etes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible.", + "showFileNames": "Afficher le nom du fichier", + "downloadSuccess": "Fichier téléchargé", + "downloadFailedToken": "Échec du téléchargement du fichier, jeton utilisateur indisponible", + "setAsCover": "Définir comme couverture", + "openInBrowser": "Ouvrir dans le navigateur", + "embedLink": "Intégrer le lien du fichier" } }, "document": { @@ -811,6 +1665,7 @@ "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, + "creating": "Création...", "slashMenu": { "board": { "selectABoardToLinkTo": "Sélectionnez un tableau à lier", @@ -826,6 +1681,51 @@ }, "document": { "selectADocumentToLinkTo": "Sélectionnez un Document vers lequel créer un lien" + }, + "name": { + "text": "Texte", + "heading1": "Titre 1", + "heading2": "Titre 2", + "heading3": "Titre 3", + "image": "Image", + "bulletedList": "Liste à puces", + "numberedList": "Liste numérotée", + "todoList": "Liste de choses à faire", + "doc": "Doc", + "linkedDoc": "Lien vers la page", + "grid": "Grille", + "linkedGrid": "Grille liée", + "kanban": "Kanban", + "linkedKanban": "Kanban lié", + "calendar": "Calendrier", + "linkedCalendar": "Calendrier lié", + "quote": "Citation", + "divider": "Diviseur", + "table": "Tableau", + "outline": "Table des matières", + "mathEquation": "Équation mathématique", + "code": "Code", + "toggleList": "Menu dépliant", + "toggleHeading1": "Basculer en titre 1", + "toggleHeading2": "Basculer en titre 2", + "toggleHeading3": "Basculer en titre 3", + "emoji": "Émoji", + "aiWriter": "Rédacteur IA", + "dateOrReminder": "Date ou rappel", + "photoGallery": "Galerie de photos", + "file": "Fichier", + "checkbox": "Case à cocher" + }, + "subPage": { + "name": "Document", + "keyword1": "sous-page", + "keyword2": "page", + "keyword3": "page enfant", + "keyword4": "insérer une page", + "keyword5": "page intégrée", + "keyword6": "nouvelle page", + "keyword7": "créer une page", + "keyword8": "document" } }, "selectionMenu": { @@ -837,34 +1737,67 @@ "referencedGrid": "Grille référencée", "referencedCalendar": "Calendrier référencé", "referencedDocument": "Document référencé", - "autoGeneratorMenuItemName": "Rédacteur OpenAI", - "autoGeneratorTitleName": "OpenAI : Demandez à l'IA d'écrire quelque chose...", + "autoGeneratorMenuItemName": "Rédacteur AI", + "autoGeneratorTitleName": "AI : Demandez à l'IA d'écrire quelque chose...", "autoGeneratorLearnMore": "Apprendre encore plus", "autoGeneratorGenerate": "Générer", - "autoGeneratorHintText": "Demandez à OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Impossible d'obtenir la clé OpenAI", + "autoGeneratorHintText": "Demandez à AI...", + "autoGeneratorCantGetOpenAIKey": "Impossible d'obtenir la clé AI", "autoGeneratorRewrite": "Réécrire", "smartEdit": "Assistants IA", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Corriger l'orthographe", "warning": "⚠️ Les réponses de l'IA peuvent être inexactes ou trompeuses.", "smartEditSummarize": "Résumer", "smartEditImproveWriting": "Améliorer l'écriture", "smartEditMakeLonger": "Rallonger", - "smartEditCouldNotFetchResult": "Impossible de récupérer le résultat d'OpenAI", - "smartEditCouldNotFetchKey": "Impossible de récupérer la clé OpenAI", - "smartEditDisabled": "Connectez OpenAI dans les paramètres", + "smartEditCouldNotFetchResult": "Impossible de récupérer le résultat d'AI", + "smartEditCouldNotFetchKey": "Impossible de récupérer la clé AI", + "smartEditDisabled": "Connectez AI dans les paramètres", + "appflowyAIEditDisabled": "Connectez-vous pour activer les fonctionnalités de l'IA", "discardResponse": "Voulez-vous supprimer les réponses de l'IA ?", "createInlineMathEquation": "Créer une équation", "fonts": "Polices", "insertDate": "Insérer la date", "emoji": "Emoji", "toggleList": "Liste pliable", + "emptyToggleHeading": "Bouton vide h{}. Cliquez pour ajouter du contenu.", + "emptyToggleList": "Liste vide. Cliquez pour ajouter du contenu.", + "emptyToggleHeadingWeb": "Bascule h{level} vide. Cliquer pour ajouter du contenu ", "quoteList": "Liste de citations", "numberedList": "Liste numérotée", "bulletedList": "Liste à puces", "todoList": "Liste de tâches", "callout": "Encadré", + "simpleTable": { + "moreActions": { + "color": "Couleur", + "align": "Aligner", + "delete": "Supprimer", + "duplicate": "Dupliquer", + "insertLeft": "Insérer à gauche", + "insertRight": "Insérer à droite", + "insertAbove": "Insérer ci-dessus", + "insertBelow": "Insérer ci-dessous", + "headerColumn": "Colonne d'en-tête", + "headerRow": "Ligne d'en-tête", + "clearContents": "Supprimer le contenu", + "setToPageWidth": "Définir sur la largeur de la page", + "distributeColumnsWidth": "Répartir les colonnes uniformément", + "duplicateRow": "Ligne dupliquée", + "duplicateColumn": "Colonne dupliquée", + "textColor": "Couleur du texte", + "cellBackgroundColor": "Couleur d'arrière-plan de la cellule", + "duplicateTable": "Tableau dupliqué" + }, + "clickToAddNewRow": "Cliquez pour ajouter une nouvelle ligne", + "clickToAddNewColumn": "Cliquez pour ajouter une nouvelle colonne", + "clickToAddNewRowAndColumn": "Cliquez pour ajouter une nouvelle ligne et une nouvelle colonne", + "headerName": { + "table": "Tableau", + "alignText": "Aligner le texte" + } + }, "cover": { "changeCover": "Changer la couverture", "colors": "Couleurs", @@ -880,6 +1813,7 @@ "back": "Dos", "saveToGallery": "Sauvegarder dans la gallerie", "removeIcon": "Supprimer l'icône", + "removeCover": "Supprimer la couverture", "pasteImageUrl": "Coller l'URL de l'image", "or": "OU", "pickFromFiles": "Choisissez parmi les fichiers", @@ -898,6 +1832,8 @@ "optionAction": { "click": "Cliquez sur", "toOpenMenu": " pour ouvrir le menu", + "drag": "Glisser", + "toMove": " à déplacer", "delete": "Supprimer", "duplicate": "Dupliquer", "turnInto": "Changer en", @@ -909,12 +1845,35 @@ "center": "Centre", "right": "Droite", "defaultColor": "Défaut", - "depth": "Profond" + "depth": "Profond", + "copyLinkToBlock": "Copier le lien pour bloquer" }, "image": { - "copiedToPasteBoard": "Le lien de l'image a été copié dans le presse-papiers", "addAnImage": "Ajouter une image", - "imageUploadFailed": "Téléchargement de l'image échoué" + "copiedToPasteBoard": "Le lien de l'image a été copié dans le presse-papiers", + "addAnImageDesktop": "Ajouter une image", + "addAnImageMobile": "Cliquez pour ajouter une ou plusieurs images", + "dropImageToInsert": "Déposez les images à insérer", + "imageUploadFailed": "Téléchargement de l'image échoué", + "imageDownloadFailed": "Le téléchargement de l'image a échoué, veuillez réessayer", + "imageDownloadFailedToken": "Le téléchargement de l'image a échoué en raison d'un jeton d'utilisateur manquant, veuillez réessayer", + "errorCode": "Code erreur" + }, + "photoGallery": { + "name": "Galerie de photos", + "imageKeyword": "image", + "imageGalleryKeyword": "Galerie d'images", + "photoKeyword": "photo", + "photoBrowserKeyword": "navigateur de photos", + "galleryKeyword": "galerie", + "addImageTooltip": "Ajouter une image", + "changeLayoutTooltip": "Changer la mise en page", + "browserLayout": "Navigateur", + "gridLayout": "Grille", + "deleteBlockTooltip": "Supprimer toute la galerie" + }, + "math": { + "copiedToPasteBoard": "L'équation mathématique a été copiée dans le presse-papiers" }, "urlPreview": { "copiedToPasteBoard": "Le lien a été copié dans le presse-papier", @@ -935,7 +1894,8 @@ "contextMenu": { "copy": "Copier", "cut": "Couper", - "paste": "Coller" + "paste": "Coller", + "pasteAsPlainText": "Coller en tant que texte brut" }, "action": "Actions", "database": { @@ -946,7 +1906,51 @@ "newDatabase": "Nouvelle Base de données", "linkToDatabase": "Lien vers la Base de données" }, - "date": "Date" + "date": "Date", + "video": { + "label": "Vidéo", + "emptyLabel": "Ajouter une vidéo", + "placeholder": "Collez le lien vidéo", + "copiedToPasteBoard": "Le lien vidéo a été copié dans le presse-papiers", + "insertVideo": "Ajouter une vidéo", + "invalidVideoUrl": "L'URL source n'est pas encore prise en charge.", + "invalidVideoUrlYouTube": "YouTube n'est pas encore pris en charge.", + "supportedFormats": "Formats pris en charge : MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" + }, + "file": { + "name": "Fichier", + "uploadTab": "Télécharger", + "uploadMobile": "Choisissez un fichier", + "uploadMobileGallery": "Depuis la galerie photo", + "networkTab": "Intégrer un lien", + "placeholderText": "Télécharger ou intégrer un fichier", + "placeholderDragging": "Glisser le fichier à télécharger", + "dropFileToUpload": "Glisser le fichier à télécharger", + "fileUploadHint": "Glisser un fichier ici pour le télécharger\nou cliquez pour parcourir", + "fileUploadHintSuffix": "Parcourir", + "networkHint": "Coller un lien de fichier", + "networkUrlInvalid": "URL non valide, veuillez corriger l'URL et réessayer", + "networkAction": "Intégrer", + "fileTooBigError": "La taille du fichier est trop grande, veuillez télécharger un fichier d'une taille inférieure à 10 Mo", + "renameFile": { + "title": "Renommer le fichier", + "description": "Entrez le nouveau nom pour ce fichier", + "nameEmptyError": "Le nom du fichier ne peut pas être laissé vide." + }, + "uploadedAt": "Mis en ligne le {}", + "linkedAt": "Lien ajouté le {}", + "failedToOpenMsg": "Impossible d'ouvrir, fichier non trouvé" + }, + "subPage": { + "errors": { + "failedDeletePage": "Impossible de supprimer la page", + "failedCreatePage": "Échec de la création de la page", + "failedMovePage": "Impossible de déplacer la page vers ce document", + "failedDuplicatePage": "Impossible de dupliquer la page", + "failedDuplicateFindView": "Impossible de dupliquer la page - vue d'origine non trouvée" + } + }, + "cannotMoveToItsChildren": "Ne peut pas se déplacer vers ses enfants" }, "outlineBlock": { "placeholder": "Table de contenu" @@ -968,8 +1972,8 @@ "placeholder": "Entrez l'URL de l'image" }, "ai": { - "label": "Générer une image à partir d'OpenAI", - "placeholder": "Veuillez saisir l'invite pour qu'OpenAI génère l'image" + "label": "Générer une image à partir d'AI", + "placeholder": "Veuillez saisir l'invite pour qu'AI génère l'image" }, "stability_ai": { "label": "Générer une image à partir de Stability AI", @@ -981,7 +1985,8 @@ "invalidImageSize": "La taille de l'image doit être inférieure à 5 Mo", "invalidImageFormat": "Le format d'image n'est pas pris en charge. Formats pris en charge : JPEG, PNG, GIF, SVG", "invalidImageUrl": "URL d'image non valide", - "noImage": "Aucun fichier ou répertoire de ce nom" + "noImage": "Aucun fichier ou répertoire de ce nom", + "multipleImagesFailed": "Une ou plusieurs images n'ont pas pu être téléchargées, veuillez réessayer" }, "embedLink": { "label": "Lien intégré", @@ -991,20 +1996,36 @@ "label": "Unsplash" }, "searchForAnImage": "Rechercher une image", - "pleaseInputYourOpenAIKey": "veuillez saisir votre clé OpenAI dans la page Paramètres", - "pleaseInputYourStabilityAIKey": "veuillez saisir votre clé Stability AI dans la page Paramètres", + "pleaseInputYourOpenAIKey": "veuillez saisir votre clé AI dans la page Paramètres", "saveImageToGallery": "Enregistrer l'image", "failedToAddImageToGallery": "Échec de l'ajout d'une image à la galerie", "successToAddImageToGallery": "Image ajoutée à la galerie avec succès", "unableToLoadImage": "Impossible de charger l'image", "maximumImageSize": "La taille d'image maximale est 10Mo", "uploadImageErrorImageSizeTooBig": "L'image doit faire moins de 10Mo", - "imageIsUploading": "L'image est en cours de téléchargement" + "imageIsUploading": "L'image est en cours de téléchargement", + "openFullScreen": "Ouvrir en plein écran", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "Image précédente", + "nextImageTooltip": "Image suivante", + "zoomOutTooltip": "Zoom arrière", + "zoomInTooltip": "Agrandir", + "changeZoomLevelTooltip": "Changer le niveau de zoom", + "openLocalImage": "Ouvrir l'image", + "downloadImage": "Télécharger l'image", + "closeViewer": "Fermer la visionneuse", + "scalePercentage": "{}%", + "deleteImageTooltip": "Supprimer l'image" + } + }, + "pleaseInputYourStabilityAIKey": "veuillez saisir votre clé Stability AI dans la page Paramètres" }, "codeBlock": { "language": { "label": "Langue", - "placeholder": "Choisir la langue" + "placeholder": "Choisir la langue", + "auto": "Auto" }, "copyTooltip": "Copier le contenu du bloc de code", "searchLanguageHint": "Rechercher une langue", @@ -1031,18 +2052,36 @@ "tooltip": "Cliquez pour ouvrir la page" }, "deleted": "Supprimer", - "deletedContent": "Ce document n'existe pas ou a été supprimé" + "deletedContent": "Ce document n'existe pas ou a été supprimé", + "noAccess": "Pas d'accès", + "deletedPage": "Page supprimée", + "trashHint": " - à la corbeille", + "morePages": "plus de pages" }, "toolbar": { "resetToDefaultFont": "Réinitialiser aux valeurs par défaut" }, "errorBlock": { "theBlockIsNotSupported": "La version actuelle ne prend pas en charge ce bloc.", - "blockContentHasBeenCopied": "Le contenu du bloc a été copié." + "clickToCopyTheBlockContent": "Cliquez pour copier le contenu du bloc", + "blockContentHasBeenCopied": "Le contenu du bloc a été copié.", + "parseError": "Une erreur s'est produite lors de l'analyse du bloc {}.", + "copyBlockContent": "Copier le contenu du bloc" + }, + "mobilePageSelector": { + "title": "Sélectionner une page", + "failedToLoad": "Impossible de charger la liste des pages", + "noPagesFound": "Aucune page trouvée" + }, + "attachmentMenu": { + "choosePhoto": "Choisir une photo", + "takePicture": "Prendre une photo", + "chooseFile": "Choisir le fichier" } }, "board": { "column": { + "label": "Colonne", "createNewCard": "Nouveau", "renameGroupTooltip": "Appuyez pour renommer le groupe", "createNewColumn": "Ajouter un nouveau groupe", @@ -1073,6 +2112,7 @@ "ungroupedButtonTooltip": "Contient des cartes qui n'appartiennent à aucun groupe", "ungroupedItemsTitle": "Cliquez pour ajouter au tableau", "groupBy": "Regrouper par", + "groupCondition": "Condition de groupe", "referencedBoardPrefix": "Vue", "notesTooltip": "Notes à l'intérieur", "mobile": { @@ -1080,6 +2120,22 @@ "showGroup": "Afficher le groupe", "showGroupContent": "Êtes-vous sûr de vouloir afficher ce groupe sur le tableau ?", "failedToLoad": "Échec du chargement de la vue du tableau" + }, + "dateCondition": { + "weekOf": "Semaine de {} - {}", + "today": "Aujourd'hui", + "yesterday": "Hier", + "tomorrow": "Demain", + "lastSevenDays": "7 derniers jours", + "nextSevenDays": "7 prochains jours", + "lastThirtyDays": "30 derniers jours", + "nextThirtyDays": "30 prochains jours" + }, + "noGroup": "Pas de groupe par propriété", + "noGroupDesc": "Les vues du tableau nécessitent une propriété de regroupement pour pouvoir s'afficher", + "media": { + "cardText": "{} {}", + "fallbackName": "fichiers" } }, "calendar": { @@ -1090,7 +2146,13 @@ "today": "Aujourd'hui", "jumpToday": "Aller à aujourd'hui", "previousMonth": "Mois précédent", - "nextMonth": "Mois prochain" + "nextMonth": "Mois prochain", + "views": { + "day": "Jour", + "week": "Semaine", + "month": "Mois", + "year": "Année" + } }, "mobileEventScreen": { "emptyTitle": "Pas d'événements", @@ -1106,6 +2168,7 @@ "unscheduledEventsTitle": "Événements non planifiés", "clickToAdd": "Cliquez pour ajouter au calendrier", "name": "Disposition du calendrier", + "clickToOpen": "Cliquez pour ouvrir l'évènement", "noDateHint": "Les événements non planifiés s'afficheront ici" }, "referencedCalendarPrefix": "Vue", @@ -1113,12 +2176,15 @@ "duplicateEvent": "Événement en double" }, "errorDialog": { - "title": "Erreur AppFlowy", + "title": "Erreur @:appName", "howToFixFallback": "Nous sommes désolés pour le désagrément ! Soumettez un problème sur notre page GitHub qui décrit votre erreur.", + "howToFixFallbackHint1": "Nous sommes désolés pour la gêne occasionnée ! Soumettez un problème sur notre ", + "howToFixFallbackHint2": " page qui décrit votre erreur.", "github": "Afficher sur GitHub" }, "search": { "label": "Recherche", + "sidebarSearchIcon": "Rechercher et accéder rapidement à une page", "placeholder": { "actions": "Actions de recherche..." } @@ -1176,10 +2242,12 @@ "medium": "Moyen", "mediumDark": "Moyennement foncé", "dark": "Foncé" - } + }, + "openSourceIconsFrom": "Icônes open source de" }, "inlineActions": { "noResults": "Aucun résultat", + "recentPages": "Pages récentes", "pageReference": "Référence de page", "docReference": "Référence de document", "boardReference": "Référence du tableau", @@ -1189,7 +2257,8 @@ "reminder": { "groupTitle": "Rappel", "shortKeyword": "rappeler" - } + }, + "createPage": "Créer une sous-page « {} »" }, "datePicker": { "dateTimeFormatTooltip": "Modifier le format de la date et de l'heure dans les paramètres", @@ -1266,7 +2335,10 @@ }, "error": { "weAreSorry": "Nous sommes désolés", - "loadingViewError": "Nous rencontrons des difficultés pour charger cette vue. Veuillez vérifier votre connexion Internet, actualiser l'application et n'hésitez pas à contacter l'équipe si le problème persiste." + "loadingViewError": "Nous rencontrons des difficultés pour charger cette vue. Veuillez vérifier votre connexion Internet, actualiser l'application et n'hésitez pas à contacter l'équipe si le problème persiste.", + "syncError": "Les données ne sont pas synchronisées depuis un autre appareil", + "syncErrorHint": "Veuillez rouvrir cette page sur l'appareil sur lequel elle a été modifiée pour la dernière fois, puis l'ouvrir à nouveau sur l'appareil actuel.", + "clickToCopy": "Cliquez pour copier le code d'erreur" }, "editor": { "bold": "Gras", @@ -1281,10 +2353,14 @@ "color": "Couleur", "image": "Image", "date": "Date", + "page": "Page", "italic": "Italique", "link": "Lien", "numberedList": "Liste numérotée", "numberedListShortForm": "Numéroté", + "toggleHeading1ShortForm": "Bascule h1", + "toggleHeading2ShortForm": "Bascule h2", + "toggleHeading3ShortForm": "Bascule h3", "quote": "Citation", "strikethrough": "Barré", "text": "Texte", @@ -1335,6 +2411,9 @@ "mobileHeading1": "Titre 1", "mobileHeading2": "Titre 2", "mobileHeading3": "Titre 3", + "mobileHeading4": "Titre 4", + "mobileHeading5": "Titre 5 ", + "mobileHeading6": "Titre 6", "textColor": "Couleur du texte", "backgroundColor": "Couleur du fond", "addYourLink": "Ajoutez votre lien", @@ -1356,7 +2435,7 @@ "auto": "Auto", "cut": "Couper", "copy": "Copier", - "paste": "Color", + "paste": "Coller", "find": "Chercher", "select": "Sélectionner", "selectAll": "Tout sélectionner", @@ -1395,7 +2474,9 @@ }, "favorite": { "noFavorite": "Aucune page favorite", - "noFavoriteHintText": "Faites glisser la page vers la gauche pour l'ajouter à vos favoris" + "noFavoriteHintText": "Faites glisser la page vers la gauche pour l'ajouter à vos favoris", + "removeFromSidebar": "Supprimer de la barre latérale", + "addToSidebar": "Épingler sur la barre latérale" }, "cardDetails": { "notesPlaceholder": "Entrez un / pour insérer un bloc ou commencez à taper" @@ -1432,10 +2513,18 @@ "deleteAccount": { "title": "Supprimer le compte", "subtitle": "Supprimez définitivement votre compte et toutes vos données.", + "description": "Supprimez définitivement votre compte et supprimez l'accès à tous les espaces de travail.", "deleteMyAccount": "Supprimer mon compte", "dialogTitle": "Supprimer le compte", "dialogContent1": "Êtes-vous sûr de vouloir supprimer définitivement votre compte ?", - "dialogContent2": "Cette action ne peut pas être annulée et supprimera l'accès à tous les espaces d'équipe, effaçant l'intégralité de votre compte, y compris les espaces de travail privés, et vous supprimant de tous les espaces de travail partagés." + "dialogContent2": "Cette action ne peut pas être annulée et supprimera l'accès à tous les espaces d'équipe, effaçant l'intégralité de votre compte, y compris les espaces de travail privés, et vous supprimant de tous les espaces de travail partagés.", + "confirmHint1": "Veuillez taper « @:newSettings.myAccount.deleteAccount.confirmHint3 » pour confirmer.", + "confirmHint2": "Je comprends que cette action est irréversible et supprimera définitivement mon compte et toutes les données associées.", + "confirmHint3": "SUPPRIMER MON COMPTE", + "checkToConfirmError": "Vous devez cocher la case pour confirmer la suppression", + "failedToGetCurrentUser": "Impossible d'obtenir l'e-mail de l'utilisateur actuel", + "confirmTextValidationFailed": "Votre texte de confirmation ne correspond pas à « @:newSettings.myAccount.deleteAccount.confirmHint3 »", + "deleteAccountSuccess": "Compte supprimé avec succès" } }, "workplace": { @@ -1448,10 +2537,13 @@ "workplaceIconSubtitle": "Téléchargez une image ou utilisez un emoji pour votre espace de travail. L'icône s'affichera dans votre barre latérale et dans vos notifications", "renameError": "Échec du changement de nom du lieu de travail", "updateIconError": "Échec de la mise à jour de l'icône", + "chooseAnIcon": "Choisissez une icône", "appearance": { "name": "Apparence", "themeMode": { - "auto": "Auto" + "auto": "Auto", + "light": "Claire", + "dark": "Sombre" }, "language": "Langue" } @@ -1462,10 +2554,451 @@ "noNetworkConnected": "Aucun réseau connecté" } }, + "pageStyle": { + "title": "Style de page", + "layout": "Mise en page", + "coverImage": "Image de couverture", + "pageIcon": "Icône de page", + "colors": "Couleurs", + "gradient": "Dégradé", + "backgroundImage": "Image d'arrière-plan", + "presets": "Préréglages", + "photo": "Photo", + "unsplash": "Unsplash", + "pageCover": "Couverture de page", + "none": "Aucun", + "openSettings": "Ouvrir les paramètres", + "photoPermissionTitle": "@:appName souhaite accéder à votre photothèque", + "photoPermissionDescription": "Autoriser l'accès à la photothèque pour le téléchargement d'images.", + "cameraPermissionTitle": "@:appName souhaite accéder à votre caméra", + "cameraPermissionDescription": "@:appName a besoin d'accéder à votre appareil photo pour vous permettre d'ajouter des images à vos documents à partir de l'appareil photo", + "doNotAllow": "Ne pas autoriser", + "image": "Image" + }, "commandPalette": { "placeholder": "Tapez pour rechercher des vues...", + "bestMatches": "Meilleurs résultats", + "recentHistory": "Historique récent", "navigateHint": "naviguer", "loadingTooltip": "Nous recherchons des résultats...", - "betaTooltip": "Nous ne prenons actuellement en charge que la recherche de pages" + "betaLabel": "BÊTA", + "betaTooltip": "Nous ne prenons actuellement en charge que la recherche de pages", + "fromTrashHint": "Depuis la poubelle", + "noResultsHint": "Nous n'avons pas trouvé ce que vous cherchez, essayez avec un autre terme.", + "clearSearchTooltip": "Effacer le champ de recherche" + }, + "space": { + "delete": "Supprimer", + "deleteConfirmation": "Supprimer: ", + "deleteConfirmationDescription": "Toutes les pages de cet espace seront supprimées et déplacées vers la corbeille, et toutes les pages publiées seront dépubliées.", + "rename": "Renommer l'espace", + "changeIcon": "Changer d'icône", + "manage": "Gérer l'espace", + "addNewSpace": "Créer un espace", + "collapseAllSubPages": "Réduire toutes les sous-pages", + "createNewSpace": "Créer un nouvel espace", + "createSpaceDescription": "Créez plusieurs espaces publics et privés pour mieux organiser votre travail.", + "spaceName": "Nom de l'espace", + "spaceNamePlaceholder": "par exemple, marketing, ingénierie, ressources humaines", + "permission": "Autorisation", + "publicPermission": "Publique", + "publicPermissionDescription": "Tous les membres de l'espace de travail avec un accès complet", + "privatePermission": "Privé", + "privatePermissionDescription": "Vous seul pouvez accéder à cet espace", + "spaceIconBackground": "Couleur d'arrière-plan", + "spaceIcon": "Icône", + "dangerZone": "Zone de danger", + "unableToDeleteLastSpace": "Impossible de supprimer le dernier espace", + "unableToDeleteSpaceNotCreatedByYou": "Impossible de supprimer les espaces créés par d'autres", + "enableSpacesForYourWorkspace": "Activer les espaces pour votre espace de travail", + "title": "Espaces", + "defaultSpaceName": "Général", + "upgradeSpaceTitle": "Activer les espaces", + "upgradeSpaceDescription": "Créez plusieurs espaces publics et privés pour mieux organiser votre espace de travail.", + "upgrade": "Mettre à jour", + "upgradeYourSpace": "Créer plusieurs espaces", + "quicklySwitch": "Passer rapidement à l’espace suivant", + "duplicate": "dupliquer l'espace ", + "movePageToSpace": "Déplacer la page vers l'espace", + "cannotMovePageToDatabase": "Impossible de déplacer la page vers la base de données", + "switchSpace": "Changer d'espace", + "spaceNameCannotBeEmpty": "Le nom de l'espace ne peut pas être vide", + "success": { + "deleteSpace": "Espace supprimé avec succès", + "renameSpace": "Espace renommé avec succès", + "duplicateSpace": "Espace dupliqué avec succès", + "updateSpace": "Espace mis à jour avec succès" + }, + "error": { + "deleteSpace": "Impossible de supprimer l'espace", + "renameSpace": "Impossible de renommer l'espace", + "duplicateSpace": "Impossible de dupliquer l'espace", + "updateSpace": "Échec de la mise à jour de l'espace" + }, + "createSpace": "Créer de l'espace", + "manageSpace": "Gérer l'espace", + "renameSpace": "Renommer l'espace", + "mSpaceIconColor": "Couleur de l'icône de l'espace", + "mSpaceIcon": "Icône de l'espace" + }, + "publish": { + "hasNotBeenPublished": "Cette page n'a pas encore été publiée", + "spaceHasNotBeenPublished": "Je n'ai pas encore pris en charge la publication d'un espace", + "reportPage": "Page de rapport", + "databaseHasNotBeenPublished": "La publication d'une base de données n'est pas encore prise en charge.", + "createdWith": "Créé avec", + "downloadApp": "Télécharger AppFlowy", + "copy": { + "codeBlock": "Le contenu du bloc de code a été copié dans le presse-papiers", + "imageBlock": "Le lien de l'image a été copié dans le presse-papiers", + "mathBlock": "L'équation mathématique a été copiée dans le presse-papiers", + "fileBlock": "Le lien du fichier a été copié dans le presse-papiers" + }, + "containsPublishedPage": "Cette page contient une ou plusieurs pages publiées. Si vous continuez, elles ne seront plus publiées. Voulez-vous procéder à la suppression ?", + "publishSuccessfully": "Publié avec succès", + "unpublishSuccessfully": "Dépublié avec succès", + "publishFailed": "Impossible de publier", + "unpublishFailed": "Impossible de dépublier", + "noAccessToVisit": "Pas d'accès à cette page...", + "createWithAppFlowy": "Créer un site Web avec AppFlowy", + "fastWithAI": "Rapide et facile avec l'IA.", + "tryItNow": "Essayez maintenant", + "onlyGridViewCanBePublished": "Seule la vue Grille peut être publiée", + "database": { + "zero": "Publier {} vue sélectionné", + "one": "Publier {} vues sélectionnées", + "many": "Publier {} vues sélectionnées", + "other": "Publier {} vues sélectionnées" + }, + "mustSelectPrimaryDatabase": "La vue principale doit être sélectionnée", + "noDatabaseSelected": "Aucune base de données sélectionnée, veuillez sélectionner au moins une base de données.", + "unableToDeselectPrimaryDatabase": "Impossible de désélectionner la base de données principale", + "saveThisPage": "Sauvegarder cette page", + "duplicateTitle": "Où souhaitez-vous ajouter", + "selectWorkspace": "Sélectionnez un espace de travail", + "addTo": "Ajouter à", + "duplicateSuccessfully": "Dupliqué avec succès. Vous souhaitez consulter les documents ?", + "duplicateSuccessfullyDescription": "Vous n'avez pas l'application ? Le téléchargement commencera automatiquement après avoir cliqué sur « Télécharger ».", + "downloadIt": "Télécharger", + "openApp": "Ouvrir dans l'application", + "duplicateFailed": "Duplication échouée", + "membersCount": { + "zero": "Aucun membre", + "one": "1 membre", + "many": "{count} membres", + "other": "{count} membres" + }, + "useThisTemplate": "Utiliser le modèle" + }, + "web": { + "continue": "Continuer", + "or": "ou", + "continueWithGoogle": "Continuer avec Google", + "continueWithGithub": "Continuer avec GitHub", + "continueWithDiscord": "Continuer avec Discord", + "continueWithApple": "Continuer avec Apple ", + "moreOptions": "Plus d'options", + "collapse": "Réduire", + "signInAgreement": "En cliquant sur « Continuer » ci-dessus, vous avez accepté les conditions d'utilisation d'AppFlowy.", + "and": "et", + "termOfUse": "Termes", + "privacyPolicy": "politique de confidentialité", + "signInError": "Erreur de connexion", + "login": "Inscrivez-vous ou connectez-vous", + "fileBlock": { + "uploadedAt": "Mis en ligne le {time}", + "linkedAt": "Lien ajouté le {time}", + "empty": "Envoyer ou intégrer un fichier", + "uploadFailed": "Échec du téléchargement, veuillez réessayer", + "retry": "Réessayer" + }, + "importNotion": "Importer depuis Notion", + "import": "Importer", + "importSuccess": "Téléchargé avec succès", + "importSuccessMessage": "Nous vous informerons lorsque l'importation sera terminée. Vous pourrez ensuite visualiser vos pages importées dans la barre latérale.", + "importFailed": "L'importation a échoué, veuillez vérifier le format du fichier", + "dropNotionFile": "Déposez votre fichier zip Notion ici pour le télécharger, ou cliquez pour parcourir", + "error": { + "pageNameIsEmpty": "Le nom de la page est vide, veuillez réessayer" + } + }, + "globalComment": { + "comments": "Commentaires", + "addComment": "Ajouter un commentaire", + "reactedBy": "réagi par", + "addReaction": "Ajouter une réaction", + "reactedByMore": "et {count} autres", + "showSeconds": { + "one": "Il y a 1 seconde", + "other": "Il y a {count} secondes", + "zero": "Tout à l' heure", + "many": "Il y a {count} secondes" + }, + "showMinutes": { + "one": "Il y a 1 minute", + "other": "Il y a {count} minutes", + "many": "Il y a {count} minutes" + }, + "showHours": { + "one": "il y a 1 heure", + "other": "Il y a {count} heures", + "many": "Il y a {count} heures" + }, + "showDays": { + "one": "Il y a 1 jour", + "other": "Il y a {count} jours", + "many": "Il y a {count} jours" + }, + "showMonths": { + "one": "Il y a 1 mois", + "other": "Il y a {count} mois", + "many": "Il y a {count} mois" + }, + "showYears": { + "one": "Il y a 1 an", + "other": "Il y a {count} ans", + "many": "Il y a {count} ans" + }, + "reply": "Répondre", + "deleteComment": "Supprimer le commentaire", + "youAreNotOwner": "Vous n'êtes pas le propriétaire de ce commentaire", + "confirmDeleteDescription": "Etes-vous sûr de vouloir supprimer ce commentaire ?", + "hasBeenDeleted": "Supprimé", + "replyingTo": "En réponse à", + "noAccessDeleteComment": "Vous n'êtes pas autorisé à supprimer ce commentaire", + "collapse": "Réduire", + "readMore": "En savoir plus", + "failedToAddComment": "Problème lors de l'ajout du commentaire", + "commentAddedSuccessfully": "Commentaire ajouté avec succès.", + "commentAddedSuccessTip": "Vous venez d'ajouter ou de répondre à un commentaire. Souhaitez-vous passer en haut de la page pour voir les derniers commentaires ?" + }, + "template": { + "asTemplate": "En tant que modèle", + "name": "Nom du modèle", + "description": "Description du modèle", + "about": "À propos du modèle", + "deleteFromTemplate": "Supprimer des modèles", + "preview": "Aperçu du modèle", + "categories": "Catégories de modèles", + "relatedTemplates": "Modèles associés", + "requiredField": "{field} est requis", + "addCategory": "Ajouter \"{category}\"", + "addNewCategory": "Ajouter une nouvelle catégorie", + "addNewCreator": "Ajouter un nouvel auteur", + "deleteCategory": "Supprimer la catégorie", + "editCategory": "Modifier la catégorie", + "editCreator": "Modifier le créateur", + "category": { + "name": "Nom de la catégorie", + "icon": "Icône de la catégorie", + "bgColor": "Couleur d'arrière-plan de la catégorie", + "priority": "Priorité de la catégorie", + "desc": "Description de la catégorie", + "type": "Type de catégorie", + "icons": "Icônes de catégorie", + "colors": "Couleurs de catégorie ", + "byUseCase": "Par cas d'utilisation", + "byFeature": "Par fonctionnalité", + "deleteCategory": "Supprimer la catégorie", + "deleteCategoryDescription": "Êtes-vous sûr de vouloir supprimer cette catégorie ?", + "typeToSearch": "Tapez pour rechercher des catégories..." + }, + "creator": { + "label": "Auteur du modèle", + "name": "Nom de l'auteur", + "avatar": "Avatar de l'auteur", + "accountLinks": "Liens vers le compte de l'auteur", + "uploadAvatar": "Cliquez pour ajouter un avatar", + "deleteCreator": "Supprimer l'auteur", + "deleteCreatorDescription": "Êtes-vous sûr de vouloir supprimer cet auteur ?", + "typeToSearch": "Tapez pour rechercher des auteurs..." + }, + "uploadSuccess": "Modèle envoyé avec succès", + "uploadSuccessDescription": "Votre modèle a été envoyé avec succès. Vous pouvez maintenant le visualiser dans la galerie de modèles.", + "viewTemplate": "Voir le modèle", + "deleteTemplate": "Supprimer le modèle", + "deleteSuccess": "Modèle supprimé avec succès", + "deleteTemplateDescription": "Êtes-vous sûr de vouloir supprimer ce modèle ?", + "addRelatedTemplate": "Ajouter un modèle associé", + "removeRelatedTemplate": "Supprimer le modèle associé", + "uploadAvatar": "Envoyer l'avatar", + "searchInCategory": "Rechercher dans {category}", + "label": "Modèle" + }, + "fileDropzone": { + "dropFile": "Cliquez ou faites glisser le fichier vers cette zone pour l'envoyer", + "uploading": "Envoi en cours...", + "uploadFailed": "Envoi échoué", + "uploadSuccess": "Envoi réussi", + "uploadSuccessDescription": "Le fichier a été envoyé avec succès", + "uploadFailedDescription": "L'envoi du fichier a échoué", + "uploadingDescription": "Le fichier est en cours d'envoi" + }, + "gallery": { + "preview": "Ouvrir en plein écran", + "copy": "Copier", + "download": "Télécharger", + "prev": "Précédent", + "next": "Suivant", + "resetZoom": "Réinitialiser le zoom", + "zoomIn": "Agrandir", + "zoomOut": "Rétrécir" + }, + "invitation": { + "join": "Rejoindre", + "on": "sur", + "invitedBy": "Invité par", + "membersCount": { + "zero": "{count} membres", + "one": "{count} membre", + "many": "{count} membres", + "other": "{count} membres" + }, + "tip": "Vous avez été invité à rejoindre cet espace de travail avec les coordonnées ci-dessous. Si celles-ci sont incorrectes, contactez votre administrateur pour renvoyer l'invitation.", + "joinWorkspace": "Rejoindre l'espace de travail", + "success": "Vous avez rejoint avec succès l'espace de travail", + "successMessage": "Vous pouvez désormais accéder à toutes les pages et espaces de travail qu'il contient.", + "openWorkspace": "Ouvrir AppFlowy", + "alreadyAccepted": "Vous avez déjà accepté l'invitation", + "errorModal": { + "title": "Quelque chose s'est mal passé", + "description": "Il est possible que votre compte actuel {email} n'ait pas accès à cet espace de travail. Veuillez vous connecter avec le compte approprié ou contacter le propriétaire de l'espace de travail pour obtenir de l'aide.", + "contactOwner": "Contacter le propriétaire", + "close": "Retour à l'accueil", + "changeAccount": "Changer de compte" + } + }, + "requestAccess": { + "title": "Pas d'accès à cette page", + "subtitle": "Vous pouvez demander l'accès au propriétaire de cette page. Une fois approuvé, vous pourrez consulter la page.", + "requestAccess": "Demande d'accès", + "backToHome": "Retour à l'accueil", + "tip": "Vous êtes actuellement connecté en tant que .", + "mightBe": "Vous pourriez avoir besoin de avec un compte différent.", + "successful": "Demande envoyée avec succès", + "successfulMessage": "Vous serez averti une fois que le propriétaire aura approuvé votre demande.", + "requestError": "Échec de la demande d'accès", + "repeatRequestError": "Vous avez déjà demandé l'accès à cette page" + }, + "approveAccess": { + "title": "Approuver la demande d'adhésion à l'espace de travail", + "requestSummary": "demandes d'adhésion et accès", + "upgrade": "mise à niveau", + "downloadApp": "Télécharger AppFlowy", + "approveButton": "Approuver", + "approveSuccess": "Approuvé avec succès", + "approveError": "Échec de l'approbation, assurez-vous que la limite du plan d'espace de travail n'est pas dépassée", + "getRequestInfoError": "Impossible d'obtenir les informations de la demande", + "memberCount": { + "zero": "Aucun membre", + "one": "1 membre", + "many": "{count} membres", + "other": "{count} membres" + }, + "alreadyProTitle": "Vous avez atteint la limite du plan d'espace de travail", + "alreadyProMessage": "Demandez-leur de contacter pour débloquer plus de membres", + "repeatApproveError": "Vous avez déjà approuvé cette demande", + "ensurePlanLimit": "Assurez-vous que la limite du plan d'espace de travail n'est pas dépassée. Si la limite est dépassée, envisagez le plan de l'espace de travail ou .", + "requestToJoin": "demandé à rejoindre", + "asMember": "en tant que membre" + }, + "upgradePlanModal": { + "title": "Passer à Pro", + "message": "{name} a atteint la limite de membres gratuits. Passez au plan Pro pour inviter plus de membres.", + "upgradeSteps": "Comment mettre à niveau votre plan sur AppFlowy :", + "step1": "1. Accédez aux paramètres", + "step2": "2. Cliquez sur « Offre »", + "step3": "3. Sélectionnez « Changer d'offre »", + "appNote": "Note: ", + "actionButton": "Mettre à niveau", + "downloadLink": "Télécharger l'application", + "laterButton": "Plus tard", + "refreshNote": "Après une mise à niveau réussie, cliquez sur pour activer vos nouvelles fonctionnalités.", + "refresh": "ici" + }, + "breadcrumbs": { + "label": "Fil d'Ariane" + }, + "time": { + "justNow": "A l'instant", + "seconds": { + "one": "1 seconde", + "other": "{count} secondes" + }, + "minutes": { + "one": "1 minute", + "other": "{compter} minutes" + }, + "hours": { + "one": "1 heure", + "other": "{count} heures" + }, + "days": { + "one": "1 jour", + "other": "{count} jours" + }, + "weeks": { + "one": "1 semaine", + "other": "{count} semaines" + }, + "months": { + "one": "1 mois", + "other": "{count} mois" + }, + "years": { + "one": "1 an", + "other": "{count} années" + }, + "ago": "il y a", + "yesterday": "Hier", + "today": "Aujourd'hui" + }, + "members": { + "zero": "Aucun membre", + "one": "1 membre", + "many": "{count} membres", + "other": "{count} membres" + }, + "tabMenu": { + "close": "Fermer", + "closeDisabledHint": "Impossible de fermer un onglet épinglé, veuillez d'abord le désépingler", + "closeOthers": "Fermer les autres onglets", + "closeOthersHint": "Cela fermera tous les onglets non épinglés sauf celui-ci", + "closeOthersDisabledHint": "Tous les onglets sont épinglés, je ne trouve aucun onglet à fermer", + "favorite": "Favori", + "unfavorite": "Non favori", + "favoriteDisabledHint": "Impossible de mettre en favori cette vue", + "pinTab": "Épingler", + "unpinTab": "Désépingler" + }, + "openFileMessage": { + "success": "Fichier ouvert avec succès", + "fileNotFound": "Fichier introuvable", + "noAppToOpenFile": "Aucune application pour ouvrir ce fichier", + "permissionDenied": "Aucune autorisation d'ouvrir ce fichier", + "unknownError": "Échec de l'ouverture du fichier" + }, + "inviteMember": { + "requestInviteMembers": "Inviter à votre espace de travail", + "inviteFailedMemberLimit": "La limite de membres a été atteinte, veuillez ", + "upgrade": "mettre à niveau", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "Envoyer des invitations", + "inviteAlready": "Vous avez déjà inviter cet email: {email}", + "inviteSuccess": "Invitation envoyée avec succès", + "description": "Saisissez les adresses emails en les séparant par une virgule. Les frais sont basés sur le nombre de membres.", + "emails": "Email" + }, + "quickNote": { + "label": "Note Rapide", + "quickNotes": "Notes Rapides", + "search": "Chercher les Notes Rapides", + "collapseFullView": "Fermer la vue d'ensemble", + "expandFullView": "Ouvrir la vue d'ensemble", + "createFailed": "Échec de la création de la Note Rapide", + "quickNotesEmpty": "Aucune Notes Rapides", + "emptyNote": "Note vide", + "deleteNotePrompt": "La note sélectionnée sera supprimée définitivement. Êtes-vous sûr de vouloir la supprimer ?", + "addNote": "Nouvelle Note" } } diff --git a/frontend/resources/translations/ga-IE.json b/frontend/resources/translations/ga-IE.json new file mode 100644 index 0000000000..1520e46fea --- /dev/null +++ b/frontend/resources/translations/ga-IE.json @@ -0,0 +1,119 @@ +{ + "appName": "Appflowy", + "defaultUsername": "Liom", + "welcomeText": "Fáilte go @:appName", + "welcomeTo": "Fáilte chuig", + "githubStarText": "Réalta ar GitHub", + "subscribeNewsletterText": "Liostáil le Nuachtlitir", + "letsGoButtonText": "Tús Tapa", + "title": "Teideal", + "youCanAlso": "Is féidir leat freisin", + "and": "agus", + "failedToOpenUrl": "Theip ar oscailt an url: {}", + "blockActions": { + "addBelowTooltip": "Cliceáil chun cur leis thíos", + "addAboveCmd": "Alt+cliceáil", + "addAboveMacCmd": "Option+cliceáil", + "addAboveTooltip": "a chur thuas", + "dragTooltip": "Tarraing chun bogadh", + "openMenuTooltip": "Cliceáil chun an roghchlár a oscailt" + }, + "signUp": { + "buttonText": "Cláraigh", + "title": "Cláraigh le @:appName", + "getStartedText": "Faigh Tosaigh", + "emptyPasswordError": "Ní féidir le pasfhocal a bheith folamh", + "repeatPasswordEmptyError": "Ní féidir an pasfhocal athdhéanta a bheith folamh", + "unmatchedPasswordError": "Ní ionann pasfhocal athdhéanta agus pasfhocal", + "alreadyHaveAnAccount": "An bhfuil cuntas agat cheana féin?", + "emailHint": "Ríomhphost", + "passwordHint": "Pasfhocal", + "repeatPasswordHint": "Déan pasfhocal arís", + "signUpWith": "Cláraigh le:" + }, + "signIn": { + "loginTitle": "Logáil isteach ar @:appName", + "loginButtonText": "Logáil isteach", + "loginStartWithAnonymous": "Lean ar aghaidh le seisiún gan ainm", + "continueAnonymousUser": "Lean ar aghaidh le seisiún gan ainm", + "anonymous": "Gan ainm", + "buttonText": "Sínigh Isteach", + "signingInText": "Ag síniú isteach...", + "forgotPassword": "Pasfhocal Dearmadta?", + "emailHint": "Ríomhphost", + "passwordHint": "Pasfhocal", + "dontHaveAnAccount": "Nach bhfuil cuntas agat?", + "createAccount": "Cruthaigh cuntas", + "repeatPasswordEmptyError": "Ní féidir an pasfhocal athdhéanta a bheith folamh", + "unmatchedPasswordError": "Ní hionann pasfhocal athdhéanta agus pasfhocal", + "syncPromptMessage": "Seans go dtógfaidh sé tamall na sonraí a shioncronú. Ná dún an leathanach seo, le do thoil", + "or": "NÓ", + "signInWithGoogle": "Lean ar aghaidh le Google", + "signInWithGithub": "Lean ar aghaidh le GitHub", + "signInWithDiscord": "Lean ar aghaidh le Discord", + "signInWithApple": "Lean ar aghaidh le Apple", + "continueAnotherWay": "Lean ar aghaidh ar bhealach eile", + "signUpWithGoogle": "Cláraigh le Google", + "signUpWithGithub": "Cláraigh le Github", + "signUpWithDiscord": "Cláraigh le Discord", + "signInWith": "Lean ar aghaidh le:", + "signInWithEmail": "Lean ar aghaidh le Ríomhphost", + "signInWithMagicLink": "Lean ort", + "signUpWithMagicLink": "Cláraigh le Magic Link", + "pleaseInputYourEmail": "Cuir isteach do sheoladh ríomhphoist", + "settings": "Socruithe", + "magicLinkSent": "Magic Link seolta!", + "invalidEmail": "Cuir isteach seoladh ríomhphoist bailí", + "alreadyHaveAnAccount": "An bhfuil cuntas agat cheana féin?", + "logIn": "Logáil isteach", + "generalError": "Chuaigh rud éigin mícheart. Bain triail eile as ar ball", + "limitRateError": "Ar chúiseanna slándála, ní féidir leat nasc draíochta a iarraidh ach gach 60 soicind" + }, + "workspace": { + "chooseWorkspace": "Roghnaigh do spás oibre", + "defaultName": "Mo Spás Oibre", + "create": "Cruthaigh spás oibre", + "new": "Spás oibre nua", + "importFromNotion": "Iompórtáil ó Notion", + "learnMore": "Foghlaim níos mó", + "reset": "Athshocraigh spás oibre", + "renameWorkspace": "Athainmnigh spás oibre", + "workspaceNameCannotBeEmpty": "Ní féidir leis an ainm spás oibre a bheith folamh", + "hint": "spás oibre", + "notFoundError": "Spás oibre gan aimsiú", + "errorActions": { + "reportIssue": "Tuairiscigh saincheist", + "reportIssueOnGithub": "Tuairiscigh ceist faoi Github", + "exportLogFiles": "Easpórtáil comhaid logáil", + "reachOut": "Bhaint amach le Discord" + }, + "menuTitle": "Spásanna oibre", + "createSuccess": "Cruthaíodh spás oibre go rathúil", + "leaveCurrentWorkspace": "Fág spás óibre" + }, + "shareAction": { + "buttonText": "Comhroinn", + "workInProgress": "Ag teacht go luath", + "markdown": "Markdown", + "html": "HTML", + "clipboard": "Cóipeáil chuig an ngearrthaisce", + "csv": "CSV", + "copyLink": "Cóipeáil nasc", + "publishToTheWeb": "Foilsigh don Ghréasán", + "publishToTheWebHint": "Cruthaigh suíomh Gréasáin le AppFlowy", + "publish": "Foilsigh", + "unPublish": "Dífhoilsiú", + "visitSite": "Tabhair cuairt ar an suíomh", + "publishTab": "Foilsigh", + "shareTab": "Comhroinn" + }, + "moreAction": { + "small": "beag", + "medium": "meánach", + "large": "mór", + "fontSize": "Clómhéid", + "import": "Iompórtáil", + "createdAt": "Cruthaithe: {}", + "deleteView": "Scrios" + } +} diff --git a/frontend/resources/translations/he.json b/frontend/resources/translations/he.json new file mode 100644 index 0000000000..6c40f88947 --- /dev/null +++ b/frontend/resources/translations/he.json @@ -0,0 +1,2083 @@ +{ + "appName": "AppFlowy", + "defaultUsername": "אני", + "welcomeText": "ברוך בואך אל @:appName", + "welcomeTo": "ברוך בואך אל", + "githubStarText": "כוכב ב־GitHub", + "subscribeNewsletterText": "הרשמה לרשימת הדיוור", + "letsGoButtonText": "התחלה זריזה", + "title": "כותרת", + "youCanAlso": "אפשר גם", + "and": "וגם", + "failedToOpenUrl": "פתיחת הכתובת נכשלה: {}", + "blockActions": { + "addBelowTooltip": "יש ללחוץ כדי להוסיף להלן", + "addAboveCmd": "Alt+לחיצה", + "addAboveMacCmd": "Option+לחיצה", + "addAboveTooltip": "כדי להוסיף מעל", + "dragTooltip": "יש לגרור כדי להזיז", + "openMenuTooltip": "יש ללחוץ כדי לפתוח תפריט" + }, + "signUp": { + "buttonText": "הרשמה", + "title": "הרשמה ל־@:appName", + "getStartedText": "מאיפה מתחילים", + "emptyPasswordError": "הסיסמה לא יכולה להיות ריקה", + "repeatPasswordEmptyError": "הסיסמה החוזרת לא יכולה להיות ריקה", + "unmatchedPasswordError": "הסיסמה החוזרת לא זהה לסיסמה", + "alreadyHaveAnAccount": "כבר יש לך חשבון?", + "emailHint": "דוא״ל", + "passwordHint": "סיסמה", + "repeatPasswordHint": "סיסמה חוזרת", + "signUpWith": "הרשמה עם:" + }, + "signIn": { + "loginTitle": "כניסה אל @:appName", + "loginButtonText": "כניסה", + "loginStartWithAnonymous": "התחלה אלמונית", + "continueAnonymousUser": "המשך התהליך בצורה אלמונית", + "buttonText": "כניסה", + "signingInText": "מתבצעת כניסה…", + "forgotPassword": "שכחת סיסמה?", + "emailHint": "דוא״ל", + "passwordHint": "סיסמה", + "dontHaveAnAccount": "אין לך חשבון?", + "createAccount": "ליצור חשבון", + "repeatPasswordEmptyError": "הסיסמה החוזרת לא יכולה להיות ריקה", + "unmatchedPasswordError": "הסיסמה החוזרת לא זהה לסיסמה", + "syncPromptMessage": "סנכרון הנתונים עלול לארוך זמן מה. נא לא לסגור את העמוד הזה", + "or": "או", + "signInWithGoogle": "להיכנס עם Google", + "signInWithGithub": "להיכנס עם Github", + "signInWithDiscord": "להיכנס עם Discord", + "signUpWithGoogle": "להירשם עם Google", + "signUpWithGithub": "להירשם עם Github", + "signUpWithDiscord": "להירשם עם Discord", + "signInWith": "להיכנס עם:", + "signInWithEmail": "להיכנס עם דוא״ל", + "signInWithMagicLink": "כניסה עם קישור קסם", + "signUpWithMagicLink": "הרשמה עם קישור קסם", + "pleaseInputYourEmail": "נא למלא את כתובת הדוא״ל שלך", + "settings": "הגדרות", + "magicLinkSent": "קישור קסם נשלח!", + "invalidEmail": "נא למלא כתובת דוא״ל תקפה", + "alreadyHaveAnAccount": "כבר יש לך חשבון?", + "logIn": "להיכנס", + "generalError": "משהו השתבש. נא לנסות שוב מאוחר יותר", + "limitRateError": "מטעמי אבטחת מידע, אפשר לבקש קישור קסם כל 60 שניות", + "magicLinkSentDescription": "קישור קסם נשלח לדוא״ל שלך. יש ללחוץ על הקישור כדי להשלים את הכניסה שלך למערכת.הקישור יפוג תוך 5 דקות." + }, + "workspace": { + "chooseWorkspace": "נא לבחור את מרחב העבודה שלך", + "create": "יצירת מרחב עבודה", + "reset": "איפוס מרחב עבודה", + "renameWorkspace": "שינוי שם מרחב עבודה", + "resetWorkspacePrompt": "איפוס מרחב העבודה ימחק את כל הדפים והנתונים שבו. לאפס את מרחב העבודה? לחלופין, אפשר ליצור קשר עם צוות התמיכה לשחזור מרחב העבודה", + "hint": "מרחב עבודה", + "notFoundError": "לא נמצא מרחב עבודה", + "failedToLoad": "משהו השתבש! טעינת מרחב העבודה נכשלה. כדאי לנסות לסגור את כל העותקים הפתוחים של @:appName ולנסות שוב.", + "errorActions": { + "reportIssue": "דיווח על תקלה", + "reportIssueOnGithub": "דיווח על תקלה דרך GitHub", + "exportLogFiles": "ייצוא קובצי יומנים", + "reachOut": "פנייה אלינו דרך Discord" + }, + "menuTitle": "מרחבי עבודה", + "deleteWorkspaceHintText": "למחוק את מרחב העבודה? הפעולה הזאת היא בלתי הפיכה, ועמודים שפרסמת יוסתרו.", + "createSuccess": "מרחב העבודה נוצר בהצלחה", + "createFailed": "יצירת מרחב העבודה נכשלה", + "createLimitExceeded": "הגעת לכמות מרחבי העבודה המרבית המותרת לחשבון שלך. כדי להוסיף מרחבי עבודה נוספים ולהמשיך בעבודה שלך, נא לבקש ב־GitHub", + "deleteSuccess": "מרחב העבודה נמחק בהצלחה", + "deleteFailed": "מחיקת מרחב העבודה נכשלה", + "openSuccess": "פתיחת מרחב העבודה הצליחה", + "openFailed": "פתיחת מרחב העבודה נכשלה", + "renameSuccess": "שם מרחב העבודה השתנה בהצלחה", + "renameFailed": "שינוי שם מרחב העבודה נכשל", + "updateIconSuccess": "סמל מרחב העבודה עודכן בהצלחה", + "updateIconFailed": "עדכון סמל מרחב העבודה נכשל", + "cannotDeleteTheOnlyWorkspace": "לא ניתן למחוק את מרחב העבודה היחיד", + "fetchWorkspacesFailed": "משיכת מרחבי העבודה נכשלה", + "leaveCurrentWorkspace": "יציאה ממרחב העבודה", + "leaveCurrentWorkspacePrompt": "לעזוב את סביבת העבודה הנוכחית?" + }, + "shareAction": { + "buttonText": "שיתוף", + "workInProgress": "בקרוב", + "markdown": "Markdown", + "html": "HTML", + "clipboard": "העתקה ללוח הגזירים", + "csv": "CSV", + "copyLink": "העתקת קישור", + "publishToTheWeb": "פרסום לאינטרנט", + "publishToTheWebHint": "יצירת אתר עם", + "publish": "פרסום", + "unPublish": "הסתרה", + "visitSite": "ביקור באתר", + "exportAsTab": "ייצוא בתור", + "publishTab": "פרסום", + "shareTab": "שיתוף" + }, + "moreAction": { + "small": "קטן", + "medium": "בינוני", + "large": "גדול", + "fontSize": "גודל גופן", + "import": "ייבוא", + "moreOptions": "אפשרויות נוספות", + "wordCount": "כמות מילים: {}", + "charCount": "כמות תווים: {}", + "createdAt": "מועד יצירה: {}", + "deleteView": "מחיקה", + "duplicateView": "שכפול" + }, + "importPanel": { + "textAndMarkdown": "טקסט ו־Markdown", + "documentFromV010": "מסמך מגרסה 0.1.0", + "databaseFromV010": "מסד נתונים מגרסה 0.1.0", + "csv": "CSV", + "database": "מסד נתונים" + }, + "disclosureAction": { + "rename": "שינוי שם", + "delete": "מחיקה", + "duplicate": "שכפול", + "unfavorite": "הסרה מהמועדפים", + "favorite": "הוספה למועדפים", + "openNewTab": "פתיחה בלשונית חדשה", + "moveTo": "העברה אל", + "addToFavorites": "הוספה למועדפים", + "copyLink": "העתקת קישור", + "changeIcon": "החלפת סמל", + "collapseAllPages": "צמצום כל תת־העמודים" + }, + "blankPageTitle": "עמוד ריק", + "newPageText": "עמוד חדש", + "newDocumentText": "מסמך חדש", + "newGridText": "טבלה חדשה", + "newCalendarText": "לוח שנה חדש", + "newBoardText": "לוח חדש", + "chat": { + "newChat": "שיחה עם בינה מלאכותית", + "inputMessageHint": "שליחת הודעה לבינה המלאכותית של @:appName", + "unsupportedCloudPrompt": "היכולת הזאת זמינה רק עם @:appName בענן", + "relatedQuestion": "קשורים", + "serverUnavailable": "השירות אינו זמין באופן זמני. נא לנסות שוב מאוחר יותר.", + "aiServerUnavailable": "🌈 אוי לא! 🌈. חד־קרן אכל לנו את התגובה. נא לנסות שוב!", + "clickToRetry": "נא ללחוץ לניסיון חוזר", + "regenerateAnswer": "יצירה מחדש", + "question1": "איך להשתמש בקנבן לניהול משימות", + "question2": "הסבר על שיטת החתימה למטרה", + "question3": "למה להשתמש ב־Rust", + "question4": "מתכון ממה שיש לי במטבח", + "aiMistakePrompt": "בינה מלאכותית יכולה לטעות. כדאי לאשש את המידע." + }, + "trash": { + "text": "אשפה", + "restoreAll": "שחזור של הכול", + "deleteAll": "מחיקה של הכול", + "pageHeader": { + "fileName": "שם קובץ", + "lastModified": "שינוי אחרון", + "created": "נוצר" + }, + "confirmDeleteAll": { + "title": "למחוק את כל העמודים שבאשפה?", + "caption": "זאת פעולה בלתי הפיכה." + }, + "confirmRestoreAll": { + "title": "לשחזר את כל העמודים מהאשפה?", + "caption": "זאת פעולה בלתי הפיכה." + }, + "mobile": { + "actions": "פעולות אשפה", + "empty": "סל האשפה ריק", + "emptyDescription": "אין לך קבצים שנמחקו", + "isDeleted": "נמחק", + "isRestored": "משוחזר" + }, + "confirmDeleteTitle": "למחוק את העמוד הזה לצמיתות?" + }, + "deletePagePrompt": { + "text": "העמוד הזה הוא באשפה", + "restore": "שחזור עמוד", + "deletePermanent": "למחוק לצמיתות" + }, + "dialogCreatePageNameHint": "שם העמוד", + "questionBubble": { + "shortcuts": "מקשי קיצור", + "whatsNew": "מה חדש?", + "markdown": "Markdown", + "debug": { + "name": "פרטי ניפוי שגיאות", + "success": "פרטי ניפוי השגיאות הועתקו ללוח הגזירים!", + "fail": "לא ניתן להעתיק את פרטי ניפוי השגיאות ללוח הגזירים" + }, + "feedback": "משוב", + "help": "עזרה ותמיכה" + }, + "menuAppHeader": { + "moreButtonToolTip": "הסרה, שינוי שם ועוד…", + "addPageTooltip": "הוספת עמוד בפנים במהירות", + "defaultNewPageName": "ללא שם", + "renameDialog": "שינוי שם" + }, + "noPagesInside": "אין עמודים בפנים", + "toolbar": { + "undo": "הסגה", + "redo": "ביצוע מחדש", + "bold": "מודגש", + "italic": "נטוי", + "underline": "קו תחתי", + "strike": "קו חוצה", + "numList": "רשימה ממוספרת", + "bulletList": "רשימת תבליטים", + "checkList": "רשימת סימונים", + "inlineCode": "קוד מוטבע", + "quote": "מקטע ציטוט", + "header": "כותרת עליונה", + "highlight": "הדגשה", + "color": "צבע", + "addLink": "הוספת קישור", + "link": "קישור" + }, + "tooltip": { + "lightMode": "מעבר למצב בהיר", + "darkMode": "מעבר למצב כהה", + "openAsPage": "פתיחה כעמוד", + "addNewRow": "הוספת שורה חדשה", + "openMenu": "לחיצה תפתח את התפריט", + "dragRow": "לחיצה ארוכה תסדר את השורה מחדש", + "viewDataBase": "הצגת מסד הנתונים", + "referencePage": "זאת הפנייה אל {name}", + "addBlockBelow": "הוספת מקטע למטה", + "aiGenerate": "יצירה" + }, + "sideBar": { + "closeSidebar": "סגירת סרגל צד", + "openSidebar": "פתיחת סרגל צד", + "personal": "אישי", + "private": "פרטי", + "workspace": "מרחב עבודה", + "favorites": "מועדפים", + "clickToHidePrivate": "לחיצה תסתיר את המרחב הפרטי\nעמודים שיצרת כאן הם לעיניך בלבד", + "clickToHideWorkspace": "לחיצה תסתיר את מרחב העבודה\nעמודים שיצרת כאן יהיו גלויים בפני כל החברים", + "clickToHidePersonal": "לחיצה תסתיר את המרחב האישי", + "clickToHideFavorites": "לחיצה תסתיר מרחב מועדף", + "addAPage": "הוספת עמוד חדש", + "addAPageToPrivate": "הוספת עמוד למרחב פרטי", + "addAPageToWorkspace": "הוספת עמוד למרחב עבודה פרטי", + "recent": "אחרונים", + "today": "היום", + "thisWeek": "השבוע", + "others": "מועדפים אחרים", + "justNow": "כרגע", + "minutesAgo": "לפני {count} דקות", + "lastViewed": "צפייה אחרונה", + "favoriteAt": "הוספה למועדפים ב־", + "emptyRecent": "אין מסמכים אחרונים", + "emptyRecentDescription": "בעת צפייה במסמכים הם יופיעו כאן לאיתור פשוט יותר", + "emptyFavorite": "אין מסמכים מועדפים", + "emptyFavoriteDescription": "אפשר להתחיל לעיין ולסמן מסמכים כמועדפים. הם יופיעו כאן כדי להקל על הגישה אליהם!", + "removePageFromRecent": "להסיר את העמוד הזה מהאחרונים?", + "removeSuccess": "הוסר בהצלחה", + "favoriteSpace": "מועדפים", + "RecentSpace": "אחרונים", + "Spaces": "מרחבים" + }, + "notifications": { + "export": { + "markdown": "פתקית יוצאה ל־Markdown", + "path": "מסמכיםflowy" + } + }, + "contactsPage": { + "title": "אנשי קשר", + "whatsHappening": "מה קורה השבוע?", + "addContact": "הוספת איש קשר", + "editContact": "עריכת איש קשר" + }, + "button": { + "ok": "אישור", + "confirm": "אישור", + "done": "בוצע", + "cancel": "ביטול", + "signIn": "כניסה", + "signOut": "יציאה", + "complete": "השלמה", + "save": "שמירה", + "generate": "יצירה", + "esc": "ESC", + "keep": "להשאיר", + "tryAgain": "לנסות שוב", + "discard": "התעלמות", + "replace": "החלפה", + "insertBelow": "הוספה מתחת", + "insertAbove": "הוספה מעל", + "upload": "העלאה", + "edit": "עריכה", + "delete": "מחיקה", + "duplicate": "שכפול", + "putback": "החזרה למקום", + "update": "עדכון", + "share": "שיתוף", + "removeFromFavorites": "הסרה מהמועדפים", + "removeFromRecent": "הסרה מהאחרונים", + "addToFavorites": "הוספה למועדפים", + "rename": "שינוי שם", + "helpCenter": "מרכז העזרה", + "add": "הוספה", + "yes": "כן", + "clear": "פינוי", + "remove": "להסיר", + "dontRemove": "לא להסיר", + "copyLink": "העתקת קישור", + "align": "יישור", + "login": "כניסה", + "logout": "יציאה", + "deleteAccount": "מחיקת חשבון", + "back": "חזרה", + "signInGoogle": "המשך עם Google", + "signInGithub": "המשך עם GitHub", + "signInDiscord": "המשך עם Discord", + "more": "עוד", + "create": "יצירה", + "close": "סגירה" + }, + "label": { + "welcome": "ברוך בואך!", + "firstName": "שם פרטי", + "middleName": "שם אמצעי", + "lastName": "שם משפחה", + "stepX": "שלב {X}" + }, + "oAuth": { + "err": { + "failedTitle": "לא ניתן להתחבר לחשבון שלך.", + "failedMsg": "נא לוודא שהשלמת את תהליך הכניסה בדפדפן שלך." + }, + "google": { + "title": "כניסה עם GOOGLE", + "instruction1": "כדי לייבא את אנשי הקשר שלך מ־Google, צריך לאמת את היישום הזה בעזרת הדפדפן שלך.", + "instruction2": "יש להעתיק את הקוד ללוח הגזירים שלך בלחיצה על הסמל או על ידי בחירת הטקסט:", + "instruction3": "יש לנווט לקישור הבא בדפדפן שלך ולמלא את הקוד הבא:", + "instruction4": "יש ללחוץ על הכפתור שלהלן לאחר השלמת ההרשמה:" + } + }, + "settings": { + "title": "הגדרות", + "accountPage": { + "menuLabel": "החשבון שלי", + "title": "החשבון שלי", + "general": { + "title": "שם חשבון ותמונת פרופיל", + "changeProfilePicture": "החלפת תמונת פרופיל" + }, + "email": { + "title": "דוא״ל", + "actions": { + "change": "החלפת כתובת דוא״ל" + } + }, + "login": { + "title": "כניסה לחשבון", + "loginLabel": "כניסה", + "logoutLabel": "יציאה" + } + }, + "workspacePage": { + "menuLabel": "מרחב עבודה", + "title": "מרחב עבודה", + "description": "התאמת מראה, ערכת העיצוב, הגופן, תבנית התאריך והשעה והשפה של מרחב העבודה שלך.", + "workspaceName": { + "title": "שם מרחב העבודה" + }, + "workspaceIcon": { + "title": "סמל מרחב העבודה", + "description": "אפשר להעלות תמונה או להשתמש באמוג׳י למרחב העבודה שלך. הסמל יופיע בסרגל הצד ובהתראות שלך." + }, + "appearance": { + "title": "מראה", + "description": "התאמת המראה, ערכת העיצוב, גופן, פריסת הטקסט, התאריך, השעה והשפה של מרחב העבודה שלך.", + "options": { + "system": "אוטו׳", + "light": "בהיר", + "dark": "כהה" + } + }, + "theme": { + "title": "ערכת עיצוב", + "description": "נא לבחור ערכת עיצוב מוגדרת מראש או להעלות ערכת עיצוב משופרת משלך.", + "uploadCustomThemeTooltip": "העלאת ערכת עיצוב משופרת" + }, + "workspaceFont": { + "title": "גופן מרחב עבודה", + "noFontHint": "הגופן לא נמצא, נא לנסות ביטוי אחר." + }, + "textDirection": { + "title": "כיוון טקסט", + "leftToRight": "משמאל לימין", + "rightToLeft": "מימין לשמאל", + "auto": "אוטו׳", + "enableRTLItems": "הפעלת פריטי סרגל כלים לכתיבה מימין לשמאל" + }, + "layoutDirection": { + "title": "כיוון פריסה", + "leftToRight": "משמאל לימין", + "rightToLeft": "מימין לשמאל" + }, + "dateTime": { + "title": "תאריך ושעה", + "example": "{} ב־{} ({})", + "24HourTime": "שעון 24 שעות", + "dateFormat": { + "label": "תבנית תאריך", + "local": "מקומית", + "us": "אמריקאית", + "iso": "ISO", + "friendly": "ידידותית", + "dmy": "D/M/Y" + } + }, + "language": { + "title": "שפה" + }, + "deleteWorkspacePrompt": { + "title": "מחיקת מרחב עבודה", + "content": "למחוק את מרחב העבודה הזה? זאת פעולה בלתי הפיכה וכל הדפים שפרסמת יוסתרו." + }, + "leaveWorkspacePrompt": { + "title": "עזיבת מרחב העבודה", + "content": "לעזוב את מרחב העבודה הזה? הגישה שלך לכל העמודים והנתונים שבתוכו תלך לאיבוד." + }, + "manageWorkspace": { + "title": "ניהול מרחב עבודה", + "leaveWorkspace": "עזיבת מרחב עבודה", + "deleteWorkspace": "מחיקת מרחב עבודה" + } + }, + "manageDataPage": { + "menuLabel": "ניהול נתונים", + "title": "ניהול נתונים", + "description": "ניהול אחסון הנתונים מקומית או ייבוא הנתונים הקיימים שלך אל @:appName.", + "dataStorage": { + "title": "מקום אחסון קבצים", + "tooltip": "המקום בו יאוחסנו הקבצים שלך", + "actions": { + "change": "החלפת נתיב", + "open": "פתיחת תיקייה", + "openTooltip": "פתיחת מקום תיקיית הנתונים הנוכחית", + "copy": "העתקת נתיב", + "copiedHint": "הנתיב הועתק!", + "resetTooltip": "איפוס למקום ברירת המחדל" + }, + "resetDialog": { + "title": "להמשיך?", + "description": "איפוס הנתיב למקום ברירת המחדל לאחסון הנתונים שלך. כדי לייבא את הנתונים הנוכחיים שלך מחדש, יש להעתיק את נתיב המקום הנוכחי שלך תחילה." + } + }, + "importData": { + "title": "ייבוא נתונים", + "tooltip": "ייבוא נתונים מתיקיות גיבויים/נתונים של @:appName", + "description": "העתקת נתונים מתיקיית נתונים חיצונית של @:appName", + "action": "עיון בקובץ" + }, + "encryption": { + "title": "הצפנה", + "tooltip": "ניהול אופן האחסון וההצפנה של הנתוים שלך", + "descriptionNoEncryption": "הפעלת הצפנה תצפין את כל הנתונים. זה תהליך בלתי הפיך.", + "descriptionEncrypted": "הנתונים שלך מוצפנים.", + "action": "הצפנת נתונים", + "dialog": { + "title": "להצפין את כל הנתונים שלך?", + "description": "להצפין את כל הנתונים שלך ישמור עליהם בבטחה ובצורה מאובטחת. זאת פעולה בלתי הפיכה. להמשיך?" + } + }, + "cache": { + "title": "ביטול זיכרון המטמון", + "description": "פינוי זיכרון המטמון של היישום, הפינוי יכול לסייע בפתרון תקלות כמו תמונות או גופנים שלא נטענים. אין לזה השפעה על הנתונים שלך.", + "dialog": { + "title": "להמשיך?", + "description": "פינוי זיכרון המטמון יגרום להורדת כל התמונות מחדש עם הטעינה. הפעולה הזאת לא תסיר או תשנה את הנתונים שלך.", + "successHint": "זיכרון המטמון התפנה!" + } + }, + "data": { + "fixYourData": "תיקון הנתונים שלך", + "fixButton": "תיקון", + "fixYourDataDescription": "אם נתקלת בבעיות עם הנתונים שלך, אפשר לנסות לתקן אותן כאן." + } + }, + "shortcutsPage": { + "menuLabel": "מקשי קיצור", + "title": "מקשי קיצור", + "editBindingHint": "נא להקליד צירוף חדש", + "searchHint": "חיפוש", + "actions": { + "resetDefault": "איפוס ברירת מחדל" + }, + "errorPage": { + "message": "טעינת מקשי הקיצור נכשלה: {}", + "howToFix": "נא לנסות שוב, אם הבעיה נמשכת נא לפנות אלינו דרך GitHub." + }, + "resetDialog": { + "title": "איפוס מקשי קיצור", + "description": "הפעולה הזאת תאפס את כל צירופי המקשים שלך לברירת המחדל, היא בלתי הפיכה, להמשיך?", + "buttonLabel": "איפוס" + }, + "conflictDialog": { + "title": "{} נמצא בשימוש כרגע", + "descriptionPrefix": "צירוף המקשים הזה כבר משמש לטובת ", + "descriptionSuffix": ". החלפת צירוף המקשים הזה יסיר אותו מהשליטה ב־{}.", + "confirmLabel": "המשך" + }, + "editTooltip": "יש ללחוץ כדי להתחיל לערוך את צירוף המקשים", + "keybindings": { + "toggleToDoList": "הצגת/הסתרת רשימת מטלות", + "insertNewParagraphInCodeblock": "הוספת פסקה חדשה", + "pasteInCodeblock": "הדבקה במקטע קוד", + "selectAllCodeblock": "בחירה בהכול", + "indentLineCodeblock": "הוספת שני רווחים בתחילת השורה", + "outdentLineCodeblock": "מחיקת שני רווחים בתחילת השורה", + "twoSpacesCursorCodeblock": "הוספת שני רווחים איפה שהסמן", + "copy": "העתקת בחירה", + "paste": "הדבקה בתוכן", + "cut": "גזירת הבחירה", + "alignLeft": "יישור הטקסט לשמאל", + "alignCenter": "מירכוז הטקסט", + "alignRight": "יישור הטקסט לימין", + "undo": "הסגה", + "redo": "ביצוע מחדש", + "convertToParagraph": "המרת מקטע לפסקה", + "backspace": "מחיקה", + "deleteLeftWord": "מחיקת המילה משמאל", + "deleteLeftSentence": "מחיקת המשפט משמאל", + "delete": "מחיקת התו מימין", + "deleteMacOS": "מחיקת השתו משמאל", + "deleteRightWord": "מחיקת המילה מימין", + "moveCursorLeft": "הזזת הסמל שמאלה", + "moveCursorBeginning": "הזזת הסמן להתחלה", + "moveCursorLeftWord": "הזזת הסמן מילה שמאלה", + "moveCursorLeftSelect": "בחירה והזזת הסמן שמאלה", + "moveCursorBeginSelect": "בחירה והזזת הסמן להתחלה", + "moveCursorLeftWordSelect": "בחירה והזזת הסמן שמאלה במילה", + "moveCursorRight": "הזזת הסמן ימינה", + "moveCursorEnd": "הזזת הסמן לסוף", + "moveCursorRightWord": "הזזת הסמן ימינה במילה", + "moveCursorRightSelect": "בחירה והזזת הסמן אחד ימינה", + "moveCursorEndSelect": "בחירה והזזת הסמן לסוף", + "moveCursorRightWordSelect": "בחירה והזזת הסמן ימינה במילה", + "moveCursorUp": "הזזת הסמן למעלה", + "moveCursorTopSelect": "בחירה והזזת הסמן לראש", + "moveCursorTop": "הזזת הסמן לראש", + "moveCursorUpSelect": "בחירה והזזת הסמן למעלה", + "moveCursorBottomSelect": "בחירה והזזת הסמן לתחתית", + "moveCursorBottom": "הזזת הסמן לתחתית", + "moveCursorDown": "הזזת הסמן למטה", + "moveCursorDownSelect": "בחירה והזזת הסמן למטה", + "home": "גלילה למעלה", + "end": "גלילה לתחתית", + "toggleBold": "החלפת מצב מודגש", + "toggleItalic": "החלפת מצב הטייה", + "toggleUnderline": "החלפת מצב קו תחתי", + "toggleStrikethrough": "החלפת קו חוצה", + "toggleCode": "החלפת קוד כחלק מהשורה", + "toggleHighlight": "החלפת מצב הדגשה", + "showLinkMenu": "הצגת תפריט קישורים", + "openInlineLink": "פתיחת קישור מובנה", + "openLinks": "פתיחת כל הקישורים הנבחרים", + "indent": "הגדלת הזחה", + "outdent": "הקטנת הזחה", + "exit": "יציאה מעריכה", + "pageUp": "גלילה עמוד למעלה", + "pageDown": "גלילה עמוד למטה", + "selectAll": "בחירה בהכול", + "pasteWithoutFormatting": "הדבקת תוכן בלי עיצוב", + "showEmojiPicker": "הצגת בורר אמוג׳י", + "enterInTableCell": "הוספת מעבר שורה בטבלה", + "leftInTableCell": "מעבר תא שמאלה בטבלה", + "rightInTableCell": "מעבר תא ימינה בטבלה", + "upInTableCell": "מעבר תא למעלה בטבלה", + "downInTableCell": "מעבר תא למטה בטבלה", + "tabInTableCell": "מעבר לתא הזמין הבא בטבלה", + "shiftTabInTableCell": "מעבר לתא הזמין הקודם בטבלה", + "backSpaceInTableCell": "עצירה בתחילת התא" + }, + "commands": { + "codeBlockNewParagraph": "הוספת פסקה חדשה ליד מקטע הקוד", + "codeBlockIndentLines": "הוספת שני רווחים בתחילת השורה במקטע קוד", + "codeBlockOutdentLines": "מחיקת שני רווחים מתחילת השורה במקטע קוד", + "codeBlockAddTwoSpaces": "הוספת שני רווחים במקום של הסמן במקטע קוד", + "codeBlockSelectAll": "בחירת כל התוכן בתוך מקטע קוד", + "codeBlockPasteText": "הדבקת טקסט במקטע קוד", + "textAlignLeft": "יישור טקסט לשמאל", + "textAlignCenter": "מירכוז טקסט", + "textAlignRight": "יישור טקסט לימין" + }, + "couldNotLoadErrorMsg": "לא ניתן לטעון את קיצורי המקשים, נא לנסות שוב", + "couldNotSaveErrorMsg": "לא ניתן לשמור את קיצורי המקשים, נא לנסות שוב" + }, + "aiPage": { + "title": "הגדרת בינה מלאכותית", + "menuLabel": "הגדרות בינה מלאכותית", + "keys": { + "enableAISearchTitle": "חיפוש בינה מלאכותית", + "aiSettingsDescription": "בחירת או הגדרת מודלים של בינה מלאכותית לשימוש עם @:appName. לביצועים מיטביים אנו ממליצים להשתמש באפשרויות ברירת המחדל של המודל", + "loginToEnableAIFeature": "יכולות בינה מלאכותית מופעלות רק לאחר כניסה עם @:appName בענן. אם אין לך חשבון של @:appName, יש לגשת אל ‚החשבון שלי’ כדי להירשם", + "llmModel": "מודל שפה", + "llmModelType": "סוג מודל שפה", + "downloadLLMPrompt": "הורדת {}", + "downloadLLMPromptDetail": "הורדת המודל המקומי {} תתפוס {} מהאחסון. להמשיך?", + "downloadAIModelButton": "הורדת מודל בינה מלאכותית", + "downloadingModel": "מתבצעת הורדה", + "localAILoaded": "מודל הבינה המלאכותית המקומי נוסף והוא מוכן לשימוש", + "localAILoading": "מודל הבינה המלאכותית המקומי נטען…", + "localAIStopped": "מודל הבינה המלאכותית המקומי נעצר", + "title": "מפתחות API לבינה מלאכותית", + "openAILabel": "מפתח API ל־OpenAI", + "openAITooltip": "אפשר למצוא את מפתח ה־API הסודי שלך בעמוד מפתח ה־API", + "openAIHint": "מילוי מפתח ה־API שלך ב־OpenAI", + "stabilityAILabel": "מפתח API של Stability", + "stabilityAITooltip": "מפתח ה־API שלך ב־Stability, משמש לאימות הבקשות שלך", + "stabilityAIHint": "נא למלא את מפתח ה־API שלך ב־Stability" + } + }, + "planPage": { + "menuLabel": "תוכנית", + "title": "תוכנית חיוב", + "planUsage": { + "title": "תקציר שימוש בתוכנית", + "storageLabel": "אחסון", + "storageUsage": "{} מתוך {} ג״ב", + "collaboratorsLabel": "שותפים", + "collaboratorsUsage": "{} מתוך {}", + "aiResponseLabel": "תגובות בינה מלאכותית", + "aiResponseUsage": "{} מתוך {}", + "proBadge": "פרו", + "memberProToggle": "אין הגבלה על כמות חברים", + "aiCredit": { + "title": "הוספת קרדיט בינה מלאכותית ל־@:appName", + "price": "{}", + "priceDescription": "ל־1,000 קרדיטים", + "purchase": "רכישת בינה מלאכותית", + "info": "הוספת 1,000 קרדיטים של בינה מלאכותית לכל סביבת עבודה ושילוב בינה מלאכותית מותאמת למרחב העבודה שלך לתוצאות חכמות ומהירות יותר עם עד:", + "infoItemOne": "10,000 תגובות למסד נתונים", + "infoItemTwo": "1,000 תגובות לסביבת עבודה" + }, + "currentPlan": { + "bannerLabel": "תוכנית נוכחית", + "freeTitle": "חינם", + "proTitle": "פרו", + "teamTitle": "צוות", + "freeInfo": "מעולה לעצמאיים או צוותים קטנים של עד 3 חברים.", + "proInfo": "מעולה לצוות קטנים ובינוניים עד 10 חברים.", + "teamInfo": "מעולה לכל סוג של צוות משימתי שמאורגן היטב.", + "upgrade": "השוואה\n ושדרוג", + "canceledInfo": "התוכנית שלך בוטלה, יבוצע שנמוך לתוכנית החופשית ב־{}.", + "freeProOne": "מרחב עבודה שיתופי", + "freeProTwo": "עד 3 חברים (כולל הבעלים)", + "freeProThree": "כמות אורחים בלתי מוגבלת (צפייה בלבד)", + "freeProFour": "אחסון של 5 ג״ב", + "freeProFive": "30 ימי היסטוריית מהדורות", + "freeConOne": "משתתפי אורח (גישת עריכה)", + "freeConTwo": "ללא הגבלת אחסון", + "freeConThree": "6 חודשי היסטוריית מהדורות", + "professionalProOne": "מרחב עבודה שיתופי", + "professionalProTwo": "כמות חברים בלתי מוגבלת", + "professionalProThree": "כמות אורחים בלתי מוגבלת (צפייה בלבד)", + "professionalProFour": "אחסון ללא הגבלה", + "professionalProFive": "6 חודשי היסטוריית מהדורות", + "professionalConOne": "משתתפי אורח ללא הגבלה (גישת עריכה)", + "professionalConTwo": "תגובות מבינה מלאכותית ללא הגבלה", + "professionalConThree": "שנה של היסטוריית מהדורות" + }, + "deal": { + "bannerLabel": "מבצע לשנה החדשה!", + "title": "הגדלת הצוות שלך!", + "info": "שדרוג כעת יזכה אותך ב־10% הנחה מתוכניות פרו ולצוותים! אפשר לחזק את יעילות סביבת העבודה שלך עם יכולות חדשות ורבות עוצמה כולל הבינה המלאכותית של @:appName.", + "viewPlans": "הצגת תוכניות" + }, + "guestCollabToggle": "10 משתתפי אורח", + "storageUnlimited": "אחסון ללא הגבלה עם תוכנית הפרו שלך" + } + }, + "billingPage": { + "menuLabel": "חיוב", + "title": "חיוב", + "plan": { + "title": "תוכנית", + "freeLabel": "חינם", + "proLabel": "פרו", + "planButtonLabel": "החלפת תוכנית", + "billingPeriod": "תקופת חיוב", + "periodButtonLabel": "עריכת תקופה" + }, + "paymentDetails": { + "title": "פרטי תשלום", + "methodLabel": "שיטת תשלום", + "methodButtonLabel": "עריכת שיטה" + } + }, + "comparePlanDialog": { + "title": "השוואה ובחירת תוכנית", + "planFeatures": "יכולות\nהתוכנית", + "current": "נוכחית", + "actions": { + "upgrade": "שדרוג", + "downgrade": "שנמוך", + "current": "נוכחית", + "downgradeDisabledTooltip": "השנמוך יתבצע בסוף מחזור החיוב" + }, + "freePlan": { + "title": "חינם", + "description": "לארגון כל פינה בעבודה ובחיים שלך.", + "price": "{}", + "priceInfo": "חינם לנצח" + }, + "proPlan": { + "title": "מקצועי", + "description": "מקום קטן לתכנות והתארגנות של קבוצות קטנות.", + "price": "{} לחודש", + "priceInfo": "בחיוב שנתי" + }, + "planLabels": { + "itemOne": "מרחבי עבודה", + "itemTwo": "חברים", + "itemThree": "אורחים", + "itemFour": "משתתפי אורח", + "itemFive": "אחסון", + "itemSix": "שיתוף בזמן אמת", + "itemSeven": "יישומון לניידים", + "tooltipThree": "לאורחים יש הרשאות לקריאה בלבד לתוכן המסוים ששותף", + "tooltipFour": "משתתפי אורח מחויבים כמושב אחד", + "itemEight": "תגובות בינה מלאכותית", + "tooltipEight": "הכוונה בביטוי „לכל החיים” כלומר שמספר התגובות לעולם לא יתאפס" + }, + "freeLabels": { + "itemOne": "חיוב לפני מרחבי עבודה", + "itemTwo": "3", + "itemFour": "0", + "itemFive": "5 ג״ב", + "itemSix": "כן", + "itemSeven": "כן", + "itemEight": "1,000 לכל החיים" + }, + "proLabels": { + "itemOne": "מחויב לפי מרחב עבודה", + "itemTwo": "עד 10", + "itemFour": "10 אורחים חויבו כמושב אחד", + "itemFive": "ללא הגבלה", + "itemSix": "כן", + "itemSeven": "כן", + "itemEight": "10,000 בחודש" + }, + "paymentSuccess": { + "title": "עברת לתוכנית {}!", + "description": "התשלום שלך עבר עיבוד והתוכנית שלך שודרגה ל־@:appName {}. אפשר לצפות בפרטי התוכנית שלך בעמוד התוכנית" + }, + "downgradeDialog": { + "title": "לשנמך את התוכנית שלך?", + "description": "שנמוך התוכנית שלך יחזיר אותך לתוכנית החינמית. חברים עלולים לאבד גישה למרחבי העבודה ויהיה עליך לפנות מקום אחסון כדי לעמוד במגבלות האחסון של התוכנית החינמית.", + "downgradeLabel": "שנמוך תוכנית" + } + }, + "common": { + "reset": "איפוס" + }, + "menu": { + "appearance": "מראה", + "language": "שפה", + "user": "משתמש", + "files": "קבצים", + "notifications": "התראות", + "open": "פתיחת ההגדרות", + "logout": "יציאה", + "logoutPrompt": "לצאת?", + "selfEncryptionLogoutPrompt": "לצאת מהמערכת? נא לוודא שהעתקת את סוד ההצפנה", + "syncSetting": "סנכרון הגדרות", + "cloudSettings": "הגדרות ענן", + "enableSync": "הפעלת סנכרון", + "enableEncrypt": "הצפנת נתונים", + "cloudURL": "כתובת בסיס", + "invalidCloudURLScheme": "סכמה שגויה", + "cloudServerType": "שרת ענן", + "cloudServerTypeTip": "נא לשים לב שהפעולה הזאת עלולה להוציא אותך מהחשבון הנוכחי שלך לאחר מעבר לשרת הענן", + "cloudLocal": "מקומי", + "cloudAppFlowy": "@:appName בענן בטא", + "cloudAppFlowySelfHost": "@:appName בענן באירוח עצמי", + "appFlowyCloudUrlCanNotBeEmpty": "כתובת הענן לא יכולה להישאר ריקה", + "clickToCopy": "לחיצה להעתקה", + "selfHostStart": "אם אין לך שרת, נא לפנות אל", + "selfHostContent": "המסמך", + "selfHostEnd": "להנחיה בנוגע לאירוח בשרת משלך", + "cloudURLHint": "נא למלא את כתובת הבסיס של השרת שלך", + "cloudWSURL": "כתובת Websocket", + "cloudWSURLHint": "נא למלא את כתובת השקע המקוון (websocket) של השרת שלך", + "restartApp": "הפעלה מחדש", + "restartAppTip": "יש להפעיל את היישום מחדש כדי להחיל את השינויים. נא לשים לב שיכול להיות שתתבצע יציאה מהחשבון הנוכחי שלך.", + "changeServerTip": "לאחר החלפת השרת, יש ללחוץ על כפתור ההפעלה מחדש כדי שהשינויים ייכנסו לתוקף", + "enableEncryptPrompt": "אפשר להפעיל הצפנה כדי להגן על הנתונים שלך עם הסוד הזה. יש לאחסן אותו בצורה בטוחה, לאחר שהופעלה, אי אפשר לכבות אותה. אם הסוד אבד, לא תהיה עוד גישה לנתונים שלך. לחיצה להעתקה", + "inputEncryptPrompt": "נא למלא את סוד ההצפנה שלך עבור", + "clickToCopySecret": "לחיצה להעתקת סוד", + "configServerSetting": "הגדרת השרת שלך", + "configServerGuide": "לאחר בחירה ב`התחלה מהירה`, יש לנווט אל `הגדרות` ואז ל„הגדרות ענן” כדי להגדיר שרת באירוח עצמי.", + "inputTextFieldHint": "הסוד שלך", + "historicalUserList": "היסטוריית כניסת משתמשים", + "historicalUserListTooltip": "הרשימה הזאת מציגה את החשבונות האלמוניים שלך. אפשר ללחוץ על חשבון כדי לצפות בפרטים שלו. חשבונות אלמוניים נוצרים בלחיצה על הכפתור ‚איך מתחילים’", + "openHistoricalUser": "נא ללחוץ כדי לפתוח את החשבון האלמוני", + "customPathPrompt": "אחסון תיקיית הנתונים של @:appName בתיקייה שמסונכרנת עם הענן כגון Google Drive יכול להוות סיכון. אם למסד הנתונים בתיקייה הזאת ניגשים או עורכים ממגוון מקומות בו־זמנית, יכולות לקרות סתירות סנכרון והנתונים יכולים להינזק", + "importAppFlowyData": "ייבוא נתונים מתיקיית @:appName חיצונית", + "importingAppFlowyDataTip": "מתבצע ייבוא נתונים. נא לא לסגור את היישום", + "importAppFlowyDataDescription": "העתקת נתונים מתיקיית נתונים חיצונית של @:appName ולייבא אותה לתיקיית הנתונים הנוכחית של AppFlowy", + "importSuccess": "תיקיית הנתונים של @:appName יובאה בהצלחה", + "importFailed": "ייבוא תיקיית הנתונים של @:appName נכשל", + "importGuide": "לפרטים נוספים, נא לעיין במסמך המוזכר" + }, + "notifications": { + "enableNotifications": { + "label": "הפעלת התראות", + "hint": "יש לכבות כדי לעצור הצגת התראות מקומיות." + }, + "showNotificationsIcon": { + "label": "הצגת סמל התראות", + "hint": "יש לכבות כדי להסתיר את סמל ההתראות בסרגל הצד." + } + }, + "appearance": { + "resetSetting": "איפוס", + "fontFamily": { + "label": "משפחת גופנים", + "search": "חיפוש", + "defaultFont": "מערכת" + }, + "themeMode": { + "label": "מצב ערכת עיצוב", + "light": "מצב בהיר", + "dark": "מצב כהה", + "system": "התאמה למערכת" + }, + "fontScaleFactor": "מקדם קנה מידה לגופנים", + "documentSettings": { + "cursorColor": "צבע סמן מסמך", + "selectionColor": "צבע בחירת מסמך", + "pickColor": "נא לבחור צבע", + "colorShade": "צללית צבע", + "opacity": "שקיפות", + "hexEmptyError": "צבע הקס׳ לא יכול להיות ריק", + "hexLengthError": "ערך הקס׳ חייב להיות באורך 6 תווים לפחות", + "hexInvalidError": "ערך הקס׳ שגוי", + "opacityEmptyError": "האטימות לא יכולה להיות ריקה", + "opacityRangeError": "האטימות חייבת להיות בין 1 ל־100", + "app": "יישום", + "flowy": "Flowy", + "apply": "החלה" + }, + "layoutDirection": { + "label": "כיוון פריסה", + "hint": "שליטה בזרימת התוכן על המסך שלך, משמאל לימין או מימין לשמאל.", + "ltr": "משמאל לימין", + "rtl": "מימין לשמאל" + }, + "textDirection": { + "label": "כיוון ברירת המחדל של טקסט", + "hint": "נא לציין האם טקסט אמור להתחיל משמאל או מימין כברירת מחדל.", + "ltr": "משמאל לימין", + "rtl": "מימין לשמאל", + "auto": "אוטו׳", + "fallback": "כמו כיוון הפריסה" + }, + "themeUpload": { + "button": "העלאה", + "uploadTheme": "העלאת ערכת עיצוב", + "description": "אפשר להעלות ערכת עיצוב משלך ל־@:appName בעזרת הכפתור שלהלן.", + "loading": "נא להמתין בזמן תיקוף והעלאת ערכת העיצוב שלך…", + "uploadSuccess": "ערכת העיצוב שלך הועלתה בהצלחה", + "deletionFailure": "מחיקת ערכת העיצוב נכשלה. נא לנסות למחוק אותה ידנית.", + "filePickerDialogTitle": "נא לבחור קובץ ‎.flowy_plugin", + "urlUploadFailure": "פתיחת הכתובת נכשלה: {}" + }, + "theme": "ערכת עיצוב", + "builtInsLabel": "ערכות עיצוב מובנות", + "pluginsLabel": "תוספים", + "dateFormat": { + "label": "תבנית תאריך", + "local": "מקומית", + "us": "אמריקאית", + "iso": "ISO", + "friendly": "ידידותית", + "dmy": "D/M/Y" + }, + "timeFormat": { + "label": "תבנית שעה", + "twelveHour": "12 שעות", + "twentyFourHour": "24 שעות" + }, + "showNamingDialogWhenCreatingPage": "הצגת חלונית מתן שם בעת יצירת עמוד", + "enableRTLToolbarItems": "הפעלת פריטי ימין לשמאל בסרגל הכלים", + "members": { + "title": "הגדרות חברים", + "inviteMembers": "הזמנת חברים", + "inviteHint": "הזמנה בדוא״ל", + "sendInvite": "שליחת הזמנה", + "copyInviteLink": "העתקת קישור הזמנה", + "label": "חברים", + "user": "משתמש", + "role": "תפקיד", + "removeFromWorkspace": "הסרה ממרחב העבודה", + "owner": "בעלים", + "guest": "אורח", + "member": "חבר", + "memberHintText": "חברים יכולים לקרוא ולערוך עמודים", + "guestHintText": "אורחים יכולים לקרוא, להגיב עם סמל, לכתוב הערות ולערוך עמודים מסוימים לפי ההרשאה.", + "emailInvalidError": "כתובת דוא״ל שגויה, נא לבדוק ולנסות שוב", + "emailSent": "ההודעה נשלחה בדוא״ל, נא לבדוק את תיבת הדוא״ל הנכנס", + "members": "חברים", + "membersCount": { + "zero": "{} חברים", + "one": "חבר", + "other": "{} חברים" + }, + "memberLimitExceeded": "הגעת למגבלת החברים המרבית לחשבון שלך. כדי להוסיף חברים נוספים ולהמשיך בעבודתך, נא להגיש בקשה ב־GitHub", + "failedToAddMember": "הוספת החבר נכשלה", + "addMemberSuccess": "החבר נוסף בהצלחה", + "removeMember": "הסרת חבר", + "areYouSureToRemoveMember": "להסיר את החבר הזה?", + "inviteMemberSuccess": "ההזמנה נשלחה בהצלחה", + "failedToInviteMember": "הזמנת החבר נכשלה" + } + }, + "files": { + "copy": "העתקה", + "defaultLocation": "קריאת הקבצים ומקום אחסון הנתונים", + "exportData": "ייצוא הנתונים שלך", + "doubleTapToCopy": "הנתיב יועתק בלחיצה כפולה", + "restoreLocation": "שחזור לנתיב ברירת המחדל של @:appName", + "customizeLocation": "פתיחת תיקייה אחרת", + "restartApp": "נא להפעיל את היישום מחדש כדי שהשינויים ייכנסו לתוקף.", + "exportDatabase": "ייצוא מסד נתונים", + "selectFiles": "נא לבחור את הקבצים לייצור", + "selectAll": "בחירה בהכול", + "deselectAll": "ביטול בחירה", + "createNewFolder": "יצירת תיקייה חדשה", + "createNewFolderDesc": "נא הגדיר לנו איפה לאחסן את הנתונים שלך", + "defineWhereYourDataIsStored": "הגדרת מקום אחסון הנתונים שלך", + "open": "פתיחה", + "openFolder": "פתיחת תיקייה קיימת", + "openFolderDesc": "קריאה וכתיבה אל תיקיית ה־@:appName הקיימת שלך", + "folderHintText": "שם התיקייה", + "location": "יצירת תיקייה חדשה", + "locationDesc": "נא לבחור שם לתיקיית הנתונים של @:appName", + "browser": "עיון", + "create": "יצירה", + "set": "הגדרה", + "folderPath": "נתיב לאחסון התיקייה שלך", + "locationCannotBeEmpty": "הנתיב לא יכול להיות ריק", + "pathCopiedSnackbar": "נתיב אחסון הקבצים הועתק ללוח הגזירים!", + "changeLocationTooltips": "החלפת תיקיית הנתונים", + "change": "שינוי", + "openLocationTooltips": "פתיחת תיקיית נתונים אחרת", + "openCurrentDataFolder": "פתיחת תיקיית הנתונים הנוכחית", + "recoverLocationTooltips": "איפוס לתיקיית הנתונים כברירת המחדל של @:appName", + "exportFileSuccess": "ייצוא הקובץ הצליח!", + "exportFileFail": "ייצוא הקובץ נכשל!", + "export": "ייצוא", + "clearCache": "פינוי זיכרון המטמון", + "clearCacheDesc": "אם נתקלת בבעיות עם תמונות שלא נטענות או גופנים שלא מופיעים כראוי, כדאי לנסות לפנות את זיכרון המטמון. הפעולה הזאת לא תסיר את נתוני המשתמש שלך.", + "areYouSureToClearCache": "לפנות את זיכרון המטמון?", + "clearCacheSuccess": "זיכרון המטמון התפנה בהצלחה!" + }, + "user": { + "name": "שם", + "email": "דוא״ל", + "tooltipSelectIcon": "בחירת סמל", + "selectAnIcon": "נא לבחור סמל", + "pleaseInputYourOpenAIKey": "נא למלא את המפתח שלך ב־OpenAI", + "clickToLogout": "לחיצה תוציא את המשתמש הנוכחי", + "pleaseInputYourStabilityAIKey": "נא למלא את המפתח שלך ב־Stability AI" + }, + "mobile": { + "personalInfo": "פרטים אישיים", + "username": "שם משתמש", + "usernameEmptyError": "שם שמשתמש לא יכול להישאר ריק", + "about": "על אודות", + "pushNotifications": "התראות בדחיפה", + "support": "תמיכה", + "joinDiscord": "להצטרף אלינו ב־Discord", + "privacyPolicy": "מדיניות פרטיות", + "userAgreement": "הסכם שימוש", + "termsAndConditions": "תנאים והתניות", + "userprofileError": "טעינת פרופיל המשתמש נכשלה", + "userprofileErrorDescription": "נא לנסות לצאת ולהיכנס בחזרה אם הבעיה נמשכת.", + "selectLayout": "בחירת פריסה", + "selectStartingDay": "בחירת יום ההתחלה", + "version": "גרסה" + } + }, + "grid": { + "deleteView": "למחוק את התצוגה הזאת?", + "createView": "חדש", + "title": { + "placeholder": "ללא שם" + }, + "settings": { + "filter": "מסנן", + "sort": "מיון", + "sortBy": "מיון לפי", + "properties": "מאפיינים", + "reorderPropertiesTooltip": "אפשר לשנות את סדר המאפיינים בגרירה", + "group": "קבוצה", + "addFilter": "הוספת מסנן", + "deleteFilter": "מחיקת מסנן", + "filterBy": "סינון לפי…", + "typeAValue": "נא להקליד ערך…", + "layout": "פריסה", + "databaseLayout": "פריסה", + "viewList": { + "zero": "0 צפיות", + "one": "צפייה", + "other": "{count} צפיות" + }, + "editView": "עריכת תצוגה", + "boardSettings": "הגדרות לוח", + "calendarSettings": "הגדרות לוח שנה", + "createView": "תצוגה חדשה", + "duplicateView": "שכפול תצוגה", + "deleteView": "מחיקת תצוגה", + "numberOfVisibleFields": "{} מופיע" + }, + "textFilter": { + "contains": "מכיל", + "doesNotContain": "לא מכיל", + "endsWith": "מסתיים ב־", + "startWith": "נפתח ב־", + "is": "הוא", + "isNot": "אינו", + "isEmpty": "ריק", + "isNotEmpty": "לא ריק", + "choicechipPrefix": { + "isNot": "לא", + "startWith": "נפתח ב־", + "endWith": "מסתיים ב־", + "isEmpty": "ריק", + "isNotEmpty": "לא ריק" + } + }, + "checkboxFilter": { + "isChecked": "מסומן", + "isUnchecked": "לא מסומן", + "choicechipPrefix": { + "is": "הוא" + } + }, + "checklistFilter": { + "isComplete": "הושלם", + "isIncomplted": "טרם הושלם" + }, + "selectOptionFilter": { + "is": "הוא", + "isNot": "אינו", + "contains": "מכיל", + "doesNotContain": "לא מכיל", + "isEmpty": "ריק", + "isNotEmpty": "לא ריק" + }, + "dateFilter": { + "is": "הוא", + "before": "הוא לפני", + "after": "הוא אחרי", + "onOrBefore": "בתאריך או לפני", + "onOrAfter": "בתאריך או אחרי", + "between": "בין", + "empty": "ריק", + "notEmpty": "לא ריק", + "choicechipPrefix": { + "before": "לפני", + "after": "אחרי", + "onOrBefore": "בתאריך או לפני", + "onOrAfter": "בתאריך או אחרי", + "isEmpty": "ריק", + "isNotEmpty": "לא ריק" + } + }, + "numberFilter": { + "equal": "שווה ל־", + "notEqual": "לא שווה ל־", + "lessThan": "קטן מ־", + "greaterThan": "גדול מ־", + "lessThanOrEqualTo": "קטן או שווה ל־", + "greaterThanOrEqualTo": "גדול או שווה ל־", + "isEmpty": "ריק", + "isNotEmpty": "לא ריק" + }, + "field": { + "hide": "הסתרה", + "show": "הצגה", + "insertLeft": "הוספה משמאל", + "insertRight": "הוספה מימין", + "duplicate": "שכפול", + "delete": "מחיקה", + "wrapCellContent": "גלישת טקסט", + "clear": "פינוי תאים", + "textFieldName": "טקסט", + "checkboxFieldName": "תיבת סימון", + "dateFieldName": "תאריך", + "updatedAtFieldName": "שינוי אחרון", + "createdAtFieldName": "יצירה", + "numberFieldName": "מספרים", + "singleSelectFieldName": "בחירה", + "multiSelectFieldName": "ריבוי בחירות", + "urlFieldName": "כתובת", + "checklistFieldName": "רשימת סימונים", + "relationFieldName": "יחס", + "summaryFieldName": "תקציר בינה מלאכותית", + "timeFieldName": "שעה", + "translateFieldName": "תרגום בינה מלאכותית", + "translateTo": "תרגום ל־", + "numberFormat": "תבנית מספר", + "dateFormat": "תבנית תאריך", + "includeTime": "כולל השעה", + "isRange": "תאריך סיום", + "dateFormatFriendly": "חודש יום, שנה", + "dateFormatISO": "שנה-חודש-יום", + "dateFormatLocal": "חודש/יום/שנה", + "dateFormatUS": "שנה/חודש/יום", + "dateFormatDayMonthYear": "יום/חודש/שנה", + "timeFormat": "תבנית שעה", + "invalidTimeFormat": "תבנית שגויה", + "timeFormatTwelveHour": "12 שעות", + "timeFormatTwentyFourHour": "24 שעות", + "clearDate": "פינוי התאריך", + "dateTime": "תאריך שעה", + "startDateTime": "תאריך ושעת התחלה", + "endDateTime": "תאריך ושעת סיום", + "failedToLoadDate": "טעינת ערך התאריך נכשלה", + "selectTime": "נא לבחור שעה", + "selectDate": "נא לבחור תאריך", + "visibility": "חשיפה", + "propertyType": "סוג המאפיין", + "addSelectOption": "הוספת אפשרות", + "typeANewOption": "נא להקליד אפשרות חדשה", + "optionTitle": "אפשרויות", + "addOption": "הוספת אפשרות", + "editProperty": "עריכת מאפיין", + "newProperty": "מאפיין חדש", + "deleteFieldPromptMessage": "להמשיך? המאפיין יימחק", + "clearFieldPromptMessage": "להמשיך? כל התאים בעמודה הזאת יתרוקנו", + "newColumn": "עמודה חדשה", + "format": "תבנית", + "reminderOnDateTooltip": "בתא הזה יש תזכורת מתוזמנת", + "optionAlreadyExist": "האפשרות כבר קיימת" + }, + "rowPage": { + "newField": "הוספת שדה חדש", + "fieldDragElementTooltip": "נא ללחוץ לפתיחת התפריט", + "showHiddenFields": { + "one": "הצגת שדה מוסתר", + "many": "הצגת {count} שדות מוסתרים", + "other": "הצגת {count} שדות מוסתרים" + }, + "hideHiddenFields": { + "one": "הסתרת שדה מוסתר", + "many": "הסתרת {count} שדות מוסתרים", + "other": "הסתרת {count} שדות מוסתרים" + }, + "openAsFullPage": "Open as full page", + "moreRowActions": "פעולות שורה נוספות" + }, + "sort": { + "ascending": "עולה", + "descending": "יורד", + "by": "לפי", + "empty": "אין מיונים פעילים", + "cannotFindCreatableField": "לא ניתן למצוא שדה מתאים למיין לפיו", + "deleteAllSorts": "מחיקת כל המיונים", + "addSort": "הוספת מיון חדש", + "removeSorting": "להסיר מיון?", + "fieldInUse": "כבר בחרת למיין לפי השדה הזה" + }, + "row": { + "duplicate": "שכפול", + "delete": "מחיקה", + "titlePlaceholder": "ללא שם", + "textPlaceholder": "ריק", + "copyProperty": "המאפיין הועתק ללוח הגזירים", + "count": "ספירה", + "newRow": "שורה חדשה", + "action": "פעולה", + "add": "לחיצה תוסיף להלן", + "drag": "גרירה להזזה", + "deleteRowPrompt": "למחוק את השורה הזאת? זאת פעולה בלתי הפיכה", + "deleteCardPrompt": "למחוק את הכרטיס הזה? זאת פעולה בלתי הפיכה", + "dragAndClick": "גרירה להזזה, לחיצה לפתיחת התפריט", + "insertRecordAbove": "הוספת רשומה למעלה", + "insertRecordBelow": "הוספת רשומה מתחת", + "noContent": "אין תוכן" + }, + "selectOption": { + "create": "יצירה", + "purpleColor": "סגול", + "pinkColor": "ורוד", + "lightPinkColor": "ורוד בהיר", + "orangeColor": "כתום", + "yellowColor": "צהוב", + "limeColor": "ליים", + "greenColor": "ירוק", + "aquaColor": "אוקיינוס", + "blueColor": "כחול", + "deleteTag": "מחיקת תגית", + "colorPanelTitle": "צבע", + "panelTitle": "נא לבחור אפשרות או ליצור אחת", + "searchOption": "חיפוש אפשרות", + "searchOrCreateOption": "חיפוש אפשרות או ליצור אחת", + "createNew": "ליצור חדשה", + "orSelectOne": "או לבחור אפשרות", + "typeANewOption": "נא למלא אפשרות חדשה", + "tagName": "שם תגית" + }, + "checklist": { + "taskHint": "תיאור משימה", + "addNew": "הוספת משימה חדשה", + "submitNewTask": "יצירה", + "hideComplete": "הסתרת משימות שהושלמו", + "showComplete": "הצגת כל המשימות" + }, + "url": { + "launch": "פתיחת קישור בדפדפן", + "copy": "העתקת קישור ללוח הגזירים", + "textFieldHint": "נא למלא כתובת" + }, + "relation": { + "relatedDatabasePlaceLabel": "מסד נתונים קשור", + "relatedDatabasePlaceholder": "אין", + "inRelatedDatabase": "בתוך", + "rowSearchTextFieldPlaceholder": "חיפוש", + "noDatabaseSelected": "לא נבחר מסד נתונים, נא לבחור אחד מהרשימה להלן תחילה:", + "emptySearchResult": "לא נמצאו רשומות", + "linkedRowListLabel": "{count} שורות מקושרות", + "unlinkedRowListLabel": "קישור שורה נוספת" + }, + "menuName": "רשת", + "referencedGridPrefix": "View of", + "calculate": "חישוב", + "calculationTypeLabel": { + "none": "אין", + "average": "ממוצע", + "max": "מרבי", + "median": "חציוני", + "min": "מזערי", + "sum": "סכום", + "count": "ספירה", + "countEmpty": "ספירת הריקים", + "countEmptyShort": "ריק", + "countNonEmpty": "ספירת הלא ריקים", + "countNonEmptyShort": "מלאים" + } + }, + "document": { + "menuName": "מסמך", + "date": { + "timeHintTextInTwelveHour": "‎01:00 PM", + "timeHintTextInTwentyFourHour": "13:00" + }, + "slashMenu": { + "board": { + "selectABoardToLinkTo": "נא לבחור לוח לקשר אליו", + "createANewBoard": "יצירת לוח חדש" + }, + "grid": { + "selectAGridToLinkTo": "נא לבחור רשת לקשר אליה", + "createANewGrid": "יצירת רשת חדשה" + }, + "calendar": { + "selectACalendarToLinkTo": "נא לבחור לוח שנה לקשר אליו", + "createANewCalendar": "יצירת לוח שנה חדש" + }, + "document": { + "selectADocumentToLinkTo": "נא לבחור מסמך לקשר אליו" + } + }, + "selectionMenu": { + "outline": "קו מתאר", + "codeBlock": "מקטע קוד" + }, + "plugins": { + "referencedBoard": "לוח מופנה", + "referencedGrid": "טבלה מופנית", + "referencedCalendar": "לוח שנה מופנה", + "referencedDocument": "מסמך מופנה", + "autoGeneratorMenuItemName": "כותב OpenAI", + "autoGeneratorTitleName": "OpenAI: לבקש מהבינה המלאכותית לכתוב כל דבר שהוא…", + "autoGeneratorLearnMore": "מידע נוסף", + "autoGeneratorGenerate": "יצירה", + "autoGeneratorHintText": "לבקש מ־OpenAI…", + "autoGeneratorCantGetOpenAIKey": "לא ניתן למשוך את המפתח של OpenAI", + "autoGeneratorRewrite": "שכתוב", + "smartEdit": "סייעני בינה מלאכותית", + "smartEditFixSpelling": "תיקון איות", + "warning": "⚠️ תגובות הבינה המלאכותית יכולות להיות מסולפות או מטעות.", + "smartEditSummarize": "סיכום", + "smartEditImproveWriting": "שיפור הכתיבה", + "smartEditMakeLonger": "הארכה", + "smartEditCouldNotFetchResult": "לא ניתן למשוך את התוצאה מ־OpenAI", + "smartEditCouldNotFetchKey": "לא ניתן למשוך מפתח OpenAI", + "smartEditDisabled": "חיבור OpenAI בהגדרות", + "appflowyAIEditDisabled": "יש להיכנס כדי להפעיל יכולות בינה מלאכותית", + "discardResponse": "להתעלם מתגובות הבינה המלאכותית?", + "createInlineMathEquation": "יצירת משוואה", + "fonts": "גופנים", + "insertDate": "הוספת תאריך", + "emoji": "אמוג׳י", + "toggleList": "רשימת מתגים", + "quoteList": "רשימת ציטוטים", + "numberedList": "רשימה ממוספרת", + "bulletedList": "רשימת תבליטים", + "todoList": "רשימת מטלות", + "callout": "מסר", + "cover": { + "changeCover": "החלפת עטיפה", + "colors": "צבעים", + "images": "תמונות", + "clearAll": "פינוי של הכול", + "abstract": "מופשט", + "addCover": "הוספת עטיפה", + "addLocalImage": "הוספת תמונה מקומית", + "invalidImageUrl": "כתובת תמונה שגויה", + "failedToAddImageToGallery": "הוספת תמונה לגלריה נכשלה", + "enterImageUrl": "נא למלא כתובת תמונה", + "add": "הוספה", + "back": "חזרה", + "saveToGallery": "שמירה לגלריה", + "removeIcon": "הסרת סמל", + "pasteImageUrl": "הדבקת כתובת תמונה", + "or": "או", + "pickFromFiles": "בחירה מהקבצים", + "couldNotFetchImage": "לא ניתן למשוך תמונה", + "imageSavingFailed": "שמירת התמונה נכשלה", + "addIcon": "הוספת סמל", + "changeIcon": "החלפת סמל", + "coverRemoveAlert": "הוא יוסר מהעטיפה לאחר מחיקתו.", + "alertDialogConfirmation": "להמשיך?" + }, + "mathEquation": { + "name": "משוואה מתמטית", + "addMathEquation": "הוספת משוואת TeX", + "editMathEquation": "עריכת משוואה מתמטית" + }, + "optionAction": { + "click": "יש ללחוץ על", + "toOpenMenu": " כדי לפתוח תפריט", + "delete": "מחיקה", + "duplicate": "שכפול", + "turnInto": "הפיכה ל־", + "moveUp": "העלאה למעלה", + "moveDown": "הורדה למטה", + "color": "צבע", + "align": "יישור", + "left": "שמאל", + "center": "מרכז", + "right": "ימין", + "defaultColor": "ברירת מחדל", + "depth": "עומק" + }, + "image": { + "addAnImage": "הוספת תמונה", + "copiedToPasteBoard": "קישור התמונה הועתק ללוח הגזירים", + "imageUploadFailed": "העלאת התמונה נכשלה", + "errorCode": "קוד שגיאה" + }, + "math": { + "copiedToPasteBoard": "המשוואה המתמטית הועתקה ללוח הגזירים" + }, + "urlPreview": { + "copiedToPasteBoard": "הקישור הועתק ללוח הגזירים", + "convertToLink": "המרה לקישור להטמעה" + }, + "outline": { + "addHeadingToCreateOutline": "יש להוסיף כותרות ראשיות כדי ליצור תוכן עניינים.", + "noMatchHeadings": "לא נמצאו כותרות ראשויות תואמות." + }, + "table": { + "addAfter": "הוספה לאחר", + "addBefore": "הוספה לפני", + "delete": "מחיקה", + "clear": "פינוי התוכן", + "duplicate": "שכפול", + "bgColor": "צבע רקע" + }, + "contextMenu": { + "copy": "העתקה", + "cut": "גזירה", + "paste": "הדבקה" + }, + "action": "פעולות", + "database": { + "selectDataSource": "בחירת מקור נתונים", + "noDataSource": "אין מקור נתונים", + "selectADataSource": "נא לבחור מקור נתונים", + "toContinue": "כדי להמשיך", + "newDatabase": "מסד נתונים חדש", + "linkToDatabase": "קישור למסד נתונים" + }, + "date": "תאריך", + "video": { + "label": "סרטון", + "emptyLabel": "הוספת סרטון", + "placeholder": "הדבקת הקישור לסרטון", + "copiedToPasteBoard": "הקישור לסרטון הועתק ללוח הגזירים", + "insertVideo": "הוספת סרטון", + "invalidVideoUrl": "כתובת המקור לא נתמכת עדיין.", + "invalidVideoUrlYouTube": "עדיין אין תמיכה ב־YouTube.", + "supportedFormats": "סוגים נתמכים: MP4,‏ WebM,‏ MOV,‏ AVI,‏ FLV,‏ MPEG/M4V,‏ H.264" + }, + "openAI": "OpenAI" + }, + "outlineBlock": { + "placeholder": "תוכן עניינים" + }, + "textBlock": { + "placeholder": "נא להקליד ‚/’ לקבלת פקודות" + }, + "title": { + "placeholder": "ללא שם" + }, + "imageBlock": { + "placeholder": "נא ללחוץ כדי להוסיף תמונה", + "upload": { + "label": "העלאה", + "placeholder": "נא ללחוץ כדי להעלות תמונה" + }, + "url": { + "label": "כתובת תמונה", + "placeholder": "נא למלא כתובת תמונה" + }, + "ai": { + "label": "יצירת תמונה מ־OpenAI", + "placeholder": "נא למלא את הקלט ל־OpenAI כדי לייצר תמונה" + }, + "stability_ai": { + "label": "יצירת תמונה מ־Stability AI", + "placeholder": "נא למלא את הבקשה ל־Stability AI כדי לייצר תמונה" + }, + "support": "מגבלת גודל התמונה היא 5 מ״ב. הסוגים הנתמכים הם: JPEG,‏ PNG,‏ GIF,‏ SVG", + "error": { + "invalidImage": "תמונה שגויה", + "invalidImageSize": "גודל התמונה חייב להיות קטן מ־5 מ״ב", + "invalidImageFormat": "סוג התמונה לא נתמך. הסוגים הנתמכים: JPEG,‏ PNG,‏ JPG,‏ GIF,‏ SVG,‏ WEBP", + "invalidImageUrl": "כתובת תמונה שגויה", + "noImage": "אין קובץ או תיקייה כאלה" + }, + "embedLink": { + "label": "קישור להטמעה", + "placeholder": "נא להדביק או להקליד קישור לתמונה" + }, + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "חיפוש אחר תמונה", + "pleaseInputYourOpenAIKey": "נא למלא את מפתח ה־OpenAI שלך בעמוד ההגדרות", + "saveImageToGallery": "שמירת תמונה", + "failedToAddImageToGallery": "הוספת התמונה לגלריה נכשלה", + "successToAddImageToGallery": "התמונה נוספה לגלריה בהצלחה", + "unableToLoadImage": "לא ניתן לטעון את התמונה", + "maximumImageSize": "גודל ההעלאה המרבי הנתמך הוא 10 מ״ב", + "uploadImageErrorImageSizeTooBig": "גודל התמונה חייב להיות גדול מ־10 מ״ב", + "imageIsUploading": "התמונה נשלחת", + "pleaseInputYourStabilityAIKey": "נא למלא את המפתח ל־Stability AI בעמוד ההגדרות" + }, + "codeBlock": { + "language": { + "label": "שפה", + "placeholder": "בחירת שפה", + "auto": "אוטו׳" + }, + "copyTooltip": "העתקת תוכן למקטע קוד", + "searchLanguageHint": "חיפוש אחר שפה", + "codeCopiedSnackbar": "הקוד הועתק ללוח הגזירים!" + }, + "inlineLink": { + "placeholder": "נא להדביק או להקליד קישור", + "openInNewTab": "פתיחה בלשונית חדשה", + "copyLink": "העתקת קישור", + "removeLink": "הסרת קישור", + "url": { + "label": "כתובת קישור", + "placeholder": "נא למלא כתובת לקישור" + }, + "title": { + "label": "כותרת קישור", + "placeholder": "נא למלא כותרת לקישור" + } + }, + "mention": { + "placeholder": "אזכור משתמשים או עמוד או תאריך…", + "page": { + "label": "קישור לעמוד", + "tooltip": "לחיצה תפתח את העמוד" + }, + "deleted": "נמחק", + "deletedContent": "התוכן הזה לא קיים או שנמחק" + }, + "toolbar": { + "resetToDefaultFont": "איפוס לברירת מחדל" + }, + "errorBlock": { + "theBlockIsNotSupported": "לא ניתן לפענח את תוכן המקטע", + "clickToCopyTheBlockContent": "לחיצה תעתיק את תוכן המקטע", + "blockContentHasBeenCopied": "תוכן המקטע הועתק." + }, + "mobilePageSelector": { + "title": "נא לבחור עמוד", + "failedToLoad": "טעינת רשימת העמודים נכשלה", + "noPagesFound": "לא נמצאו עמודים" + } + }, + "board": { + "column": { + "createNewCard": "חדש", + "renameGroupTooltip": "נא ללחוץ לשינוי שם הקבוצה", + "createNewColumn": "הוספת קבוצה חדשה", + "addToColumnTopTooltip": "הוספת כרטיס חדש בראש", + "addToColumnBottomTooltip": "הוספת כרטיס חדש בתחתית", + "renameColumn": "שינוי שם", + "hideColumn": "הסתרה", + "newGroup": "קבוצה חדשה", + "deleteColumn": "מחיקה", + "deleteColumnConfirmation": "הפעולה הזאת תמחק את הקבוצה הזאת ואת כל הכרטיסים שבה.\nלהמשיך?" + }, + "hiddenGroupSection": { + "sectionTitle": "קבוצות מוסתרות", + "collapseTooltip": "הסתרת הקבוצות המוסתרות", + "expandTooltip": "הצגת הקבוצות המוסתרות" + }, + "cardDetail": "פרטי הכרטיס", + "cardActions": "פעולות על הכרטיס", + "cardDuplicated": "הכרטיס שוכפל", + "cardDeleted": "הכרטיס נמחק", + "showOnCard": "הצגה על פרט הכרטיס", + "setting": "הגדרה", + "propertyName": "שם מאפיין", + "menuName": "לוח", + "showUngrouped": "הצגת פריטים מחוץ לקבוצות", + "ungroupedButtonText": "מחוץ לקבוצה", + "ungroupedButtonTooltip": "מכיל כרטיסים שלא שייכים לאף קבוצה", + "ungroupedItemsTitle": "נא ללחוץ כדי להוסיף ללוח", + "groupBy": "קיבוץ לפי", + "groupCondition": "תנאי קיבוץ", + "referencedBoardPrefix": "View of", + "notesTooltip": "הערות בפנים", + "mobile": { + "editURL": "עריכת כתובת", + "showGroup": "הצגת קבוצה", + "showGroupContent": "להציג את הקבוצה הזאת בלוח?", + "failedToLoad": "טעינת תצוגת הלוח נכשלה" + }, + "dateCondition": { + "weekOf": "שבוע {} - {}", + "today": "היום", + "yesterday": "אתמול", + "tomorrow": "מחר", + "lastSevenDays": "7 הימים האחרונים", + "nextSevenDays": "7 הימים הבאים", + "lastThirtyDays": "30 הימים האחרונים", + "nextThirtyDays": "30 הימים הבאים" + }, + "noGroup": "אין קבוצה לפי מאפיין", + "noGroupDesc": "תצוגות הלוח דורשות מאפיין לקיבוץ כדי שתוצגנה" + }, + "calendar": { + "menuName": "לוח שנה", + "defaultNewCalendarTitle": "ללא שם", + "newEventButtonTooltip": "הוספת אירוע חדש", + "navigation": { + "today": "היום", + "jumpToday": "דילוג להיום", + "previousMonth": "החודש הקודם", + "nextMonth": "החודש הבא", + "views": { + "day": "יום", + "week": "שבוע", + "month": "חודש", + "year": "שנה" + } + }, + "mobileEventScreen": { + "emptyTitle": "אין אירועים עדיין", + "emptyBody": "יש ללחוץ על כפתור הפלוס כדי להוסיף אירוע ביום הזה." + }, + "settings": { + "showWeekNumbers": "הצגת מספרי השבועות", + "showWeekends": "הצגת סופי שבוע", + "firstDayOfWeek": "השבוע מתחיל ב־", + "layoutDateField": "פריסת לוח השנה לפי", + "changeLayoutDateField": "החלפת שדה פריסה", + "noDateTitle": "אין תאריך", + "noDateHint": { + "zero": "אירועים לא מתוזמנים יופיעו כאן", + "one": "אירוע לא מתוזמן", + "other": "{count} אירועים לא מתוזמנים" + }, + "unscheduledEventsTitle": "אירועים לא מתוזמנים", + "clickToAdd": "נא ללחוץ כדי להוסיף ללוח השנה", + "name": "הגדרות לוח שנה", + "clickToOpen": "לחיצה תפתח את הרשומה" + }, + "referencedCalendarPrefix": "תצוגה של", + "quickJumpYear": "דילוג אל", + "duplicateEvent": "שכפול אירוע" + }, + "errorDialog": { + "title": "שגיאה ב־@:appName", + "howToFixFallback": "סליחה על חוסר הנעימות! נא להגיש תיעוד תקלה בעמוד ה־GitHub שלנו שמתאר את השגיאה שלך.", + "github": "הצגה ב־GitHub" + }, + "search": { + "label": "חיפוש", + "placeholder": { + "actions": "חיפוש פעולות…" + } + }, + "message": { + "copy": { + "success": "הועתק!", + "fail": "לא ניתן להעתיק" + } + }, + "unSupportBlock": "הגרסה הנוכחית לא תומכת במקטע הזה.", + "views": { + "deleteContentTitle": "למחוק את {pageType}?", + "deleteContentCaption": "ניתן יהיה לשחזר את ה{pageType} הזה מהאשפה אם בחרת למחוק." + }, + "colors": { + "custom": "משלך", + "default": "ברירת מחדל", + "red": "אדום", + "orange": "כתום", + "yellow": "צהוב", + "green": "ירוק", + "blue": "כחול", + "purple": "סגול", + "pink": "ורוד", + "brown": "חום", + "gray": "אפור" + }, + "emoji": { + "emojiTab": "אמוג׳י", + "search": "חיפוש אמוג׳י", + "noRecent": "אין אמוג׳י אחרונים", + "noEmojiFound": "לא נמצא אמוג׳י", + "filter": "מסנן", + "random": "אקראי", + "selectSkinTone": "בחירת גוון עור", + "remove": "הסרת אמוג׳י", + "categories": { + "smileys": "חייכנים וסמלי רגש", + "people": "אנשים וגוף", + "animals": "חיות וטבע", + "food": "מזון ומשקאות", + "activities": "פעילויות", + "places": "טיול ומקומות", + "objects": "חפצים", + "symbols": "סמלים", + "flags": "דגלים", + "nature": "טבע", + "frequentlyUsed": "בשימוש תדיר" + }, + "skinTone": { + "default": "ברירת מחדל", + "light": "בהיר", + "mediumLight": "בהיר ממוצע", + "medium": "ממוצע", + "mediumDark": "כהה ממוצע", + "dark": "כהה" + } + }, + "inlineActions": { + "noResults": "אין תוצאות", + "recentPages": "עמודים אחרונים", + "pageReference": "הפנייה לעמוד", + "docReference": "הפנייה למסמך", + "boardReference": "הפנייה ללוח", + "calReference": "הפנייה ללוח שנה", + "gridReference": "הפנייה לטבלה", + "date": "תאריך", + "reminder": { + "groupTitle": "תזכורת", + "shortKeyword": "להזכיר" + } + }, + "datePicker": { + "dateTimeFormatTooltip": "אפשר להחליף את התאריך והשעה בהגדרות", + "dateFormat": "תבנית תאריך", + "includeTime": "כולל השעה", + "isRange": "תאריך סיום", + "timeFormat": "תבנית שעה", + "clearDate": "פינוי התאריך", + "reminderLabel": "תזכורת", + "selectReminder": "בחירת תזכורת", + "reminderOptions": { + "none": "בלי", + "atTimeOfEvent": "בזמן האירוע", + "fiveMinsBefore": "5 דק׳ לפני", + "tenMinsBefore": "10 דק׳ לפני", + "fifteenMinsBefore": "15 דק׳ לפני", + "thirtyMinsBefore": "30 דק׳ לפני", + "oneHourBefore": "שעה לפני", + "twoHoursBefore": "שעתיים לפני", + "onDayOfEvent": "ביום האירוע", + "oneDayBefore": "יום לפני", + "twoDaysBefore": "יומיים לפני", + "oneWeekBefore": "שבוע לפני", + "custom": "אחר" + } + }, + "relativeDates": { + "yesterday": "אתמול", + "today": "היום", + "tomorrow": "מחר", + "oneWeek": "שבוע" + }, + "notificationHub": { + "title": "התראות", + "mobile": { + "title": "עדכונים" + }, + "emptyTitle": "התעדכנת בהכול!", + "emptyBody": "אין התראות ממתינות לפעולות. אפשר פשוט להירגע.", + "tabs": { + "inbox": "הודעות נכנסות", + "upcoming": "בקרוב" + }, + "actions": { + "markAllRead": "סימון של כולם כנקראו", + "showAll": "הכול", + "showUnreads": "לא נקראו" + }, + "filters": { + "ascending": "עולה", + "descending": "יורד", + "groupByDate": "קיבוץ לפי תאריך", + "showUnreadsOnly": "להציג כאלו שלא נקראו בלבד", + "resetToDefault": "איפוס לברירת המחדל" + } + }, + "reminderNotification": { + "title": "תזכורת", + "message": "חשוב לסמן את זה לפני ששוכחים!", + "tooltipDelete": "מחיקה", + "tooltipMarkRead": "סימון כנקראה", + "tooltipMarkUnread": "סימון כלא נקראה" + }, + "findAndReplace": { + "find": "איתור", + "previousMatch": "התוצאה הקודמת", + "nextMatch": "התוצאה הבאה", + "close": "סגירה", + "replace": "החלפה", + "replaceAll": "החלפה של הכול", + "noResult": "אין תוצאות", + "caseSensitive": "תלוי רישיות", + "searchMore": "אפשר לחפש לאיתור תוצאות נוספות" + }, + "error": { + "weAreSorry": "אנו מתנצלים", + "loadingViewError": "טעינת התצוגה הזאת נתקלת בקשיים. נא לבדוק שהחיבור שלך לאינטרנט תקין, לאחר מכן לרענן את היישום ולא להסס לפנות לצוות אם המצב הזה נמשך." + }, + "editor": { + "bold": "מודגש", + "bulletedList": "רשימת תבליטים", + "bulletedListShortForm": "תבליטים", + "checkbox": "תיבת סימון", + "embedCode": "הטמעת קוד", + "heading1": "כ1", + "heading2": "כ2", + "heading3": "כ3", + "highlight": "הדגשה", + "color": "צבע", + "image": "תמונה", + "date": "תאריך", + "page": "עמוד", + "italic": "נטוי", + "link": "קישור", + "numberedList": "רשימה ממוספרת", + "numberedListShortForm": "ממוספר", + "quote": "ציטוט", + "strikethrough": "קו חוצה", + "text": "טקסט", + "underline": "קו תחתי", + "fontColorDefault": "ברירת מחדל", + "fontColorGray": "אפור", + "fontColorBrown": "חום", + "fontColorOrange": "כתום", + "fontColorYellow": "צהוב", + "fontColorGreen": "ירוק", + "fontColorBlue": "כחול", + "fontColorPurple": "סגול", + "fontColorPink": "ורוד", + "fontColorRed": "אדום", + "backgroundColorDefault": "רקע ברירת מחדל", + "backgroundColorGray": "רקע אפור", + "backgroundColorBrown": "רקע חום", + "backgroundColorOrange": "רקע כתום", + "backgroundColorYellow": "רקע צהוב", + "backgroundColorGreen": "רקע ירוק", + "backgroundColorBlue": "רקע כחול", + "backgroundColorPurple": "רקע סגול", + "backgroundColorPink": "רקע ורוד", + "backgroundColorRed": "רקע אדום", + "backgroundColorLime": "רקע ליים", + "backgroundColorAqua": "רקע אוקיינוס", + "done": "בוצע", + "cancel": "ביטול", + "tint1": "גוון 1", + "tint2": "גוון 2", + "tint3": "גוון 3", + "tint4": "גוון 4", + "tint5": "גוון 5", + "tint6": "גוון 6", + "tint7": "גוון 7", + "tint8": "גוון 8", + "tint9": "גוון 9", + "lightLightTint1": "סגול", + "lightLightTint2": "ורוד", + "lightLightTint3": "ורוד בהיר", + "lightLightTint4": "כתום", + "lightLightTint5": "צהוב", + "lightLightTint6": "ליים", + "lightLightTint7": "ירוק", + "lightLightTint8": "אוקיינוס", + "lightLightTint9": "כחול", + "urlHint": "כתובת", + "mobileHeading1": "כותרת 1", + "mobileHeading2": "כותרת 2", + "mobileHeading3": "כותרת 3", + "textColor": "צבע טקסט", + "backgroundColor": "צבע רקע", + "addYourLink": "הוספת הקישור שלך", + "openLink": "פתיחת קישור", + "copyLink": "העתקת קישור", + "removeLink": "הסרת קישור", + "editLink": "עריכת קישור", + "linkText": "טקסט", + "linkTextHint": "נא למלא טקסט", + "linkAddressHint": "נא למלא כתובת", + "highlightColor": "צבע הדגשה", + "clearHighlightColor": "איפוס צבע הדגשה", + "customColor": "צבע משלך", + "hexValue": "ערך הקס׳", + "opacity": "אטימות", + "resetToDefaultColor": "איפוס לצבע ברירת המחדל", + "ltr": "משמאל לימין", + "rtl": "מימין לשמאל", + "auto": "אוטו׳", + "cut": "גזירה", + "copy": "העתקה", + "paste": "הדבקה", + "find": "איתור", + "select": "בחירה", + "selectAll": "בחירה בהכול", + "previousMatch": "התוצאה הקודמת", + "nextMatch": "התוצאה הבאה", + "closeFind": "סגירה", + "replace": "החלפה", + "replaceAll": "החלפה של הכול", + "regex": "ביטוי רגולרי", + "caseSensitive": "תלוי רישיות", + "uploadImage": "העלאת תמונה", + "urlImage": "קישור לתמונה", + "incorrectLink": "קישור שגוי", + "upload": "העלאה", + "chooseImage": "בחירת תמונה", + "loading": "בטעינה", + "imageLoadFailed": "טעינת התמונה נכשלה", + "divider": "מפריד", + "table": "טבלה", + "colAddBefore": "הוספה לפני", + "rowAddBefore": "הוספה לפני", + "colAddAfter": "הוספה אחרי", + "rowAddAfter": "הוספה אחרי", + "colRemove": "הסרה", + "rowRemove": "הסרה", + "colDuplicate": "שכפול", + "rowDuplicate": "שכפול", + "colClear": "פינוי תוכן", + "rowClear": "פינוי תוכן", + "slashPlaceHolder": "נא להקליד ‚/’ כדי להוסיף מקטע או להתחיל להקליד", + "typeSomething": "נא להקליד משהו…", + "toggleListShortForm": "מתג", + "quoteListShortForm": "ציטוט", + "mathEquationShortForm": "נוסחה", + "codeBlockShortForm": "קוד" + }, + "favorite": { + "noFavorite": "לא עמוד מועדף", + "noFavoriteHintText": "החלקת העמוד שמאלה תוסיף אותו למועדפים שלך", + "removeFromSidebar": "הסרה מסרגל הצד", + "addToSidebar": "הצמדה לסרגל צד" + }, + "cardDetails": { + "notesPlaceholder": "יש להקליד / כדי להוסיף מקטע, או להתחיל להקליד" + }, + "blockPlaceholders": { + "todoList": "מטלה", + "bulletList": "רשימה", + "numberList": "רשימה", + "quote": "ציטוט", + "heading": "כותרת {}" + }, + "titleBar": { + "pageIcon": "סמל עמוד", + "language": "שפה", + "font": "גופן", + "actions": "פעולות", + "date": "תאריך", + "addField": "הוספת שדה", + "userIcon": "סמל משתמש" + }, + "noLogFiles": "אין קובצי יומנים", + "newSettings": { + "myAccount": { + "title": "החשבון שלי", + "subtitle": "התאמת הפרופיל שלך, ניהול אבטחת החשבון, מפתחות בינה מלאכותית או כניסה לחשבון שלך.", + "profileLabel": "שם חשבון ותמונת פרופיל", + "profileNamePlaceholder": "נא למלא את השם שלך", + "accountSecurity": "אבטחת חשבון", + "2FA": "אימות דו־שלבי", + "aiKeys": "מפתחות בינה מלאכותית", + "accountLogin": "כניסה לחשבון", + "updateNameError": "עדכון השם נכשל", + "updateIconError": "עדכון הסמל נכשל", + "deleteAccount": { + "title": "מחיקת חשבון", + "subtitle": "מחיקת החשבון וכל הנתונים שלך לצמיתות.", + "deleteMyAccount": "מחיקת החשבון שלי", + "dialogTitle": "מחיקת חשבון", + "dialogContent1": "למחוק את החשבון שלך לצמיתות?", + "dialogContent2": "זאת פעולה בלתי הפיכה והיא תסיר את הגישה מכל מרחבי הצוותים תוך מחיקת החשבון כולו לרבות מרחבי עבודה פרטיים והסרתך מכל מרחבי העבודה המשותפים." + } + }, + "workplace": { + "name": "מקום עבודה", + "title": "הגדרות מקום עבודה", + "subtitle": "התאמת המראה, ערכת העיצוב, גופן, פריסת הטקסט, התאריך, השעה והשפה של מרחב העבודה שלך.", + "workplaceName": "שם מקום העבודה", + "workplaceNamePlaceholder": "נא למלא שם למקום העבודה", + "workplaceIcon": "סמל מקום עבודה", + "workplaceIconSubtitle": "אפשר להעלות תמונה או להשתמש באמוג׳י למרחב העבודה שלך. הסמל יופיע בסרגל הצד ובהתראות שלך.", + "renameError": "שינוי שם מקום העבודה נכשל", + "updateIconError": "עדכון הסמל נכשל", + "chooseAnIcon": "נא לבחור סמל", + "appearance": { + "name": "מראה", + "themeMode": { + "auto": "אוטו׳", + "light": "בהיר", + "dark": "כהה" + }, + "language": "שפה" + } + }, + "syncState": { + "syncing": "סנכרון", + "synced": "מסונכרן", + "noNetworkConnected": "אין רשת מחוברת" + } + }, + "pageStyle": { + "title": "סגנון עמוד", + "layout": "פריסה", + "coverImage": "תמונת כריכה", + "pageIcon": "סמל עמוד", + "colors": "צבעים", + "gradient": "מדרג", + "backgroundImage": "תמונת רקע", + "presets": "ערכות מוגדרות", + "photo": "צילום", + "unsplash": "Unsplash", + "pageCover": "כריכת עמוד", + "none": "אין", + "openSettings": "פתיחת הגדרות", + "photoPermissionTitle": "@:appName רוצה לגשת לספריית התמונות שלך", + "photoPermissionDescription": "לאפשר גישה לספריית התמונות כדי להעלות תמונות.", + "doNotAllow": "לא להרשות", + "image": "תמונה" + }, + "commandPalette": { + "placeholder": "נא להקליד כדי לחפש…", + "bestMatches": "התוצאות הטובות ביותר", + "recentHistory": "היסטוריה עדכנית", + "navigateHint": "לניווט", + "loadingTooltip": "אנו מחפשים תוצאות…", + "betaLabel": "בטא", + "betaTooltip": "אנו תומכים רק בחיפוש אחר עמודים ותוכן במסמכים", + "fromTrashHint": "מהאשפה", + "noResultsHint": "לא מצאנו מה שחיפשת, כדאי לנסות לחפש ביטוי אחר.", + "clearSearchTooltip": "פינוי שדה חיפוש" + }, + "space": { + "delete": "מחיקה", + "deleteConfirmation": "מחיקה: ", + "deleteConfirmationDescription": "כל העמודים במרחב הזה יימחקו ויעברו לאשפה, ועמודים שפורסמו יוסתרו.", + "rename": "שינוי שם מרחב", + "changeIcon": "החלפת סמל", + "manage": "ניהול מרחב", + "addNewSpace": "יצירת מרחב", + "collapseAllSubPages": "צמצום כל תת־העמודים", + "createNewSpace": "יצירת מרחב חדש", + "createSpaceDescription": "אפשר ליצור מגוון מרחבים ציבוריים ופרטיים כדי לארגן את העבודה שלך בצורה טובה יותר.", + "spaceName": "שם המרחב", + "permission": "הרשאה", + "publicPermission": "ציבורי", + "publicPermissionDescription": "כל החברים במרחב העבודה שיש להם גישה מלאה", + "privatePermission": "פרטי", + "privatePermissionDescription": "רק לך יש גישה למרחב הזה", + "spaceIconBackground": "צבע הרקע", + "spaceIcon": "סמל", + "dangerZone": "אזור הסכנה", + "unableToDeleteLastSpace": "לא ניתן למחוק את המרחב האחרון", + "unableToDeleteSpaceNotCreatedByYou": "לא ניתן למחוק מרחבים שנוצרו על ידי אחרים", + "enableSpacesForYourWorkspace": "הפעלת מרחבים למרחב העבודה שלך", + "title": "מרחבים", + "defaultSpaceName": "כללי", + "upgradeSpaceTitle": "הפעלת מרחבים", + "upgradeSpaceDescription": "אפשר ליצור מגוון מרחבים ציבוריים ופרטיים כדי לארגן את מרחב העבודה שלך בצורה טובה יותר.", + "upgrade": "עדכון", + "upgradeYourSpace": "יצירת מרחבים במרוכז", + "quicklySwitch": "מעבר למרחב הבא במהירות", + "duplicate": "שכפול מרחב", + "movePageToSpace": "העבר עמוד למרחב", + "switchSpace": "החלפת מרחב" + }, + "publish": { + "hasNotBeenPublished": "העמוד הזה לא פורסם עדיין", + "reportPage": "דיווח על עמוד", + "databaseHasNotBeenPublished": "עדיין אין תמיכה בפרסום למסדי נתונים.", + "createdWith": "נוצר עם", + "downloadApp": "הורדת AppFlowy", + "copy": { + "codeBlock": "תוכן מקטע הקוד הועתק ללוח הגזירים", + "imageBlock": "קישור התמונה הועתק ללוח הגזירים", + "mathBlock": "הנוסחה המתמטית הועתקה ללוח הגזירים" + }, + "containsPublishedPage": "העמוד הזה מכיל עמוד או יותר שפורסמו. המשך בתהליך יסתיר אותם. להמשיך במחיקה?", + "publishSuccessfully": "הפרסום הצליח", + "unpublishSuccessfully": "ההסתרה הצליחה", + "publishFailed": "הפרסום נכשל", + "unpublishFailed": "ההסתרה נכשלה", + "noAccessToVisit": "אין גישה לעמוד הזה…", + "createWithAppFlowy": "יצירת אתר עם AppFlowy", + "fastWithAI": "מהיר וקל עם בינה מלאכותית.", + "tryItNow": "לנסות כעת" + }, + "web": { + "continue": "המשך", + "or": "או", + "continueWithGoogle": "להמשיך עם Google", + "continueWithGithub": "להמשיך עם GitHub", + "continueWithDiscord": "להמשיך עם Discord", + "signInAgreement": "לחיצה על „המשך” להלן, מהווה את אישורך\nלכך שקראת, הבנת והסכמת לתנאים של AppFlowy", + "and": "וגם", + "termOfUse": "תנאים", + "privacyPolicy": "מדיניות פרטיות", + "signInError": "שגיאת כניסה", + "login": "הרשמה או כניסה" + } +} diff --git a/frontend/resources/translations/hin.json b/frontend/resources/translations/hin.json index 8ce86ed96b..7351d119c2 100644 --- a/frontend/resources/translations/hin.json +++ b/frontend/resources/translations/hin.json @@ -333,7 +333,7 @@ "email": "ईमेल", "tooltipSelectIcon": "आइकन चुनें", "selectAnIcon": "एक आइकन चुनें", - "pleaseInputYourOpenAIKey": "कृपया अपनी OpenAI key इनपुट करें", + "pleaseInputYourOpenAIKey": "कृपया अपनी AI key इनपुट करें", "clickToLogout": "वर्तमान उपयोगकर्ता को लॉगआउट करने के लिए क्लिक करें" }, "shortcuts": { @@ -515,23 +515,23 @@ "referencedBoard": "रेफेरेंस बोर्ड", "referencedGrid": "रेफेरेंस ग्रिड", "referencedCalendar": "रेफेरेंस कैलेंडर", - "autoGeneratorMenuItemName": "OpenAI लेखक", - "autoGeneratorTitleName": "OpenAI: AI को कुछ भी लिखने के लिए कहें...", + "autoGeneratorMenuItemName": "AI लेखक", + "autoGeneratorTitleName": "AI: AI को कुछ भी लिखने के लिए कहें...", "autoGeneratorLearnMore": "और जानें", "autoGeneratorGenerate": "उत्पन्न करें", - "autoGeneratorHintText": "OpenAI से पूछें...", - "autoGeneratorCantGetOpenAIKey": "OpenAI key नहीं मिल सकी", + "autoGeneratorHintText": "AI से पूछें...", + "autoGeneratorCantGetOpenAIKey": "AI key नहीं मिल सकी", "autoGeneratorRewrite": "पुनः लिखें", "smartEdit": "AI सहायक", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "वर्तनी ठीक करें", "warning": "⚠️ AI प्रतिक्रियाएँ गलत या भ्रामक हो सकती हैं।", "smartEditSummarize": "सारांश", "smartEditImproveWriting": "लेख में सुधार करें", "smartEditMakeLonger": "लंबा बनाएं", - "smartEditCouldNotFetchResult": "OpenAI से परिणाम प्राप्त नहीं किया जा सका", - "smartEditCouldNotFetchKey": "OpenAI key नहीं लायी जा सकी", - "smartEditDisabled": "सेटिंग्स में OpenAI कनेक्ट करें", + "smartEditCouldNotFetchResult": "AI से परिणाम प्राप्त नहीं किया जा सका", + "smartEditCouldNotFetchKey": "AI key नहीं लायी जा सकी", + "smartEditDisabled": "सेटिंग्स में AI कनेक्ट करें", "discardResponse": "क्या आप AI प्रतिक्रियाओं को छोड़ना चाहते हैं?", "createInlineMathEquation": "समीकरण बनाएं", "toggleList": "सूची टॉगल करें", diff --git a/frontend/resources/translations/hu-HU.json b/frontend/resources/translations/hu-HU.json index 3a0d464d5a..1c10e40da4 100644 --- a/frontend/resources/translations/hu-HU.json +++ b/frontend/resources/translations/hu-HU.json @@ -103,14 +103,14 @@ "questionBubble": { "shortcuts": "Parancsikonok", "whatsNew": "Újdonságok", - "help": "Segítség & Támogatás", "markdown": "Markdown", "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" }, - "feedback": "Visszacsatolás" + "feedback": "Visszacsatolás", + "help": "Segítség & Támogatás" }, "menuAppHeader": { "addPageTooltip": "Belső oldal hozzáadása", @@ -210,8 +210,7 @@ "language": "Nyelv", "user": "Felhasználó", "files": "Fájlok", - "open": "Beállítások megnyitása", - "supabaseSetting": "Supabase beállítás" + "open": "Beállítások megnyitása" }, "appearance": { "fontFamily": { @@ -226,7 +225,7 @@ }, "themeUpload": { "button": "Feltöltés", - "description": "Töltse fel saját AppFlowy témáját az alábbi gomb segítségével.", + "description": "Töltse fel saját @:appName témáját az alábbi gomb segítségével.", "loading": "Kérjük, várjon, amíg ellenőrizzük és feltöltjük a témát...", "uploadSuccess": "A témát sikeresen feltöltötte", "deletionFailure": "Nem sikerült törölni a témát. Próbálja meg manuálisan törölni.", @@ -243,7 +242,7 @@ "defaultLocation": "Fájlok és adattárolási hely olvasása", "exportData": "Exportálja adatait", "doubleTapToCopy": "Koppintson duplán az útvonal másolásához", - "restoreLocation": "Visszaállítás az AppFlowy alapértelmezett elérési útjára", + "restoreLocation": "Visszaállítás az @:appName alapértelmezett elérési útjára", "customizeLocation": "Nyisson meg egy másik mappát", "restartApp": "Kérjük, indítsa újra az alkalmazást, hogy a változtatások életbe lépjenek.", "exportDatabase": "Adatbázis exportálása", @@ -255,10 +254,10 @@ "defineWhereYourDataIsStored": "Határozza meg, hol tárolják adatait", "open": "Nyisd ki", "openFolder": "Nyisson meg egy meglévő mappát", - "openFolderDesc": "Olvassa el és írja be a meglévő AppFlowy mappájába", + "openFolderDesc": "Olvassa el és írja be a meglévő @:appName mappájába", "folderHintText": "mappa neve", "location": "Új mappa létrehozása", - "locationDesc": "Válasszon nevet az AppFlowy adatmappájának", + "locationDesc": "Válasszon nevet az @:appName adatmappájának", "browser": "Tallózás", "create": "Teremt", "set": "Készlet", @@ -269,7 +268,7 @@ "change": "változás", "openLocationTooltips": "Nyisson meg egy másik adatkönyvtárat", "openCurrentDataFolder": "Nyissa meg az aktuális adatkönyvtárat", - "recoverLocationTooltips": "Állítsa vissza az AppFlowy alapértelmezett adatkönyvtárát", + "recoverLocationTooltips": "Állítsa vissza az @:appName alapértelmezett adatkönyvtárát", "exportFileSuccess": "A fájl exportálása sikeres volt!", "exportFileFail": "A fájl exportálása nem sikerült!", "export": "Export" @@ -277,7 +276,7 @@ "user": { "name": "Név", "selectAnIcon": "Válasszon ki egy ikont", - "pleaseInputYourOpenAIKey": "kérjük, adja meg OpenAI kulcsát" + "pleaseInputYourOpenAIKey": "kérjük, adja meg AI kulcsát" } }, "grid": { @@ -432,23 +431,23 @@ "referencedBoard": "Hivatkozott feladat tábla", "referencedGrid": "Hivatkozott táblázat", "referencedCalendar": "Hivatkozott naptár", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Kérd meg az AI-t, hogy írjon bármit...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Kérd meg az AI-t, hogy írjon bármit...", "autoGeneratorLearnMore": "Tudj meg többet", "autoGeneratorGenerate": "generál", - "autoGeneratorHintText": "Kérdezd meg az OpenAI-t...", - "autoGeneratorCantGetOpenAIKey": "Nem lehet beszerezni az OpenAI kulcsot", + "autoGeneratorHintText": "Kérdezd meg az AI-t...", + "autoGeneratorCantGetOpenAIKey": "Nem lehet beszerezni az AI kulcsot", "autoGeneratorRewrite": "Újraírni", "smartEdit": "AI asszisztensek", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Helyesírás javítása", "warning": "⚠️ Az AI-válaszok pontatlanok vagy félrevezetőek lehetnek.", "smartEditSummarize": "Összesít", "smartEditImproveWriting": "Az írás javítása", "smartEditMakeLonger": "Hosszabb legyen", - "smartEditCouldNotFetchResult": "Nem sikerült lekérni az eredményt az OpenAI-ból", - "smartEditCouldNotFetchKey": "Nem sikerült lekérni az OpenAI kulcsot", - "smartEditDisabled": "Csatlakoztassa az OpenAI-t a Beállításokban", + "smartEditCouldNotFetchResult": "Nem sikerült lekérni az eredményt az AI-ból", + "smartEditCouldNotFetchKey": "Nem sikerült lekérni az AI kulcsot", + "smartEditDisabled": "Csatlakoztassa az AI-t a Beállításokban", "discardResponse": "El szeretné vetni az AI-válaszokat?", "createInlineMathEquation": "Hozzon létre egyenletet", "toggleList": "Lista váltása", @@ -578,7 +577,7 @@ "referencedCalendarPrefix": "Nézet" }, "errorDialog": { - "title": "AppFlowy hiba", + "title": "@:appName hiba", "howToFixFallback": "Elnézést kérünk a kellemetlenségért! Nyújtsa be a problémát a GitHub-oldalunkon, amely leírja a hibát.", "github": "Megtekintés a GitHubon" }, diff --git a/frontend/resources/translations/id-ID.json b/frontend/resources/translations/id-ID.json index 4aa1e71038..b900929966 100644 --- a/frontend/resources/translations/id-ID.json +++ b/frontend/resources/translations/id-ID.json @@ -9,6 +9,7 @@ "title": "Judul", "youCanAlso": "Anda juga bisa", "and": "Dan", + "failedToOpenUrl": "Gagal membuka url: {}", "blockActions": { "addBelowTooltip": "Klik untuk menambahkan di bawah", "addAboveCmd": "Alt+klik", @@ -35,17 +36,39 @@ "loginButtonText": "Masuk", "loginStartWithAnonymous": "Mulai dengan sesi anonim", "continueAnonymousUser": "Lanjutkan dengan sesi anonim", + "anonymous": "Anonim", "buttonText": "Masuk", "signingInText": "Sedang masuk", "forgotPassword": "Lupa kata sandi?", "emailHint": "Surat elektronik", "passwordHint": "Kata sandi", "dontHaveAnAccount": "Belum punya akun?", + "createAccount": "Membuat akun", "repeatPasswordEmptyError": "Sandi ulang tidak boleh kosong", "unmatchedPasswordError": "Kata sandi konfirmasi tidak sesuai dengan kata sandi awal", "syncPromptMessage": "Menyinkronkan data mungkin memerlukan waktu beberapa saat. Mohon jangan tutup halaman ini", "or": "ATAU", + "signInWithGoogle": "Lanjutkan dengan Google", + "signInWithGithub": "Lanjutkan dengan GitHub", + "signInWithDiscord": "Lanjutkan dengan Discord", + "signInWithApple": "Lanjutkan dengan Apple", + "continueAnotherWay": "Lanjutkan dengan cara lain", + "signUpWithGoogle": "Mendaftar dengan Google", + "signUpWithGithub": "Mendaftar dengan GitHub", + "signUpWithDiscord": "Mendaftar dengan Discord", "signInWith": "Masuk dengan:", + "signInWithEmail": "Lanjutkan dengan Email", + "signInWithMagicLink": "Lanjut", + "signUpWithMagicLink": "Mendaftar dengan Magic Link", + "pleaseInputYourEmail": "Masukkan alamat email Anda", + "settings": "Pengaturan", + "magicLinkSent": "Magic Link terkirim!", + "invalidEmail": "Masukkan alamat email yang valid", + "alreadyHaveAnAccount": "Sudah memiliki akun?", + "logIn": "Masuk", + "generalError": "Ada yang salah. Mohon coba kembali nanti", + "limitRateError": "Demi keamanan, Anda hanya dapat meminta magic link setiap 60 detik", + "magicLinkSentDescription": "Magic Link sudah terkirim ke email Anda. Klik pada tautan untuk menyelesaikan proses masuk Anda. Tautan akan kedaluwarsa setelah 5 menit.", "LogInWithGoogle": "Masuk dengan Google", "LogInWithGithub": "Masuk dengan Github", "LogInWithDiscord": "Masuk dengan Discord", @@ -58,7 +81,7 @@ "resetWorkspacePrompt": "Mengatur ulang area kerja akan menghapus semua halaman dan data di dalamnya. Apakah anda yakin ingin Mengatur ulang area kerja? Selain itu, anda bisa menghubungi tim dukungan untuk mengembalikan area kerja", "hint": "Area kerja", "notFoundError": "Area kerja tidak ditemukan", - "failedToLoad": "Ada yang tidak beres! Gagal memuat area kerja. Coba tutup AppFlowy yang terbuka dan coba lagi.", + "failedToLoad": "Ada yang tidak beres! Gagal memuat area kerja. Coba tutup @:appName yang terbuka dan coba lagi.", "errorActions": { "reportIssue": "Melaporkan isu", "reachOut": "Hubungi di Discord" @@ -137,14 +160,14 @@ "questionBubble": { "shortcuts": "Pintasan", "whatsNew": "Apa yang baru?", - "help": "Bantuan & Dukungan", "markdown": "Penurunan harga", "debug": { "name": "Info debug", "success": "Info debug disalin ke papan klip!", "fail": "Tidak dapat menyalin info debug ke papan klip" }, - "feedback": "Masukan" + "feedback": "Masukan", + "help": "Bantuan & Dukungan" }, "menuAppHeader": { "moreButtonToolTip": "Menghapus, merubah nama, dan banyak lagi...", @@ -183,13 +206,13 @@ "addBlockBelow": "Tambahkan blok di bawah ini" }, "sideBar": { - "closeSidebar": "Close sidebar", - "openSidebar": "Open sidebar", + "closeSidebar": "Tutup sidebar", + "openSidebar": "Buka sidebar", "personal": "Pribadi", "favorites": "Favorit", - "clickToHidePersonal": "Klik untuk menutup seksi pribadi", - "clickToHideFavorites": "Klik untuk menutup seksi favorit", - "addAPage": "Tambah sebuah page" + "clickToHidePersonal": "Klik untuk menutup Pribadi", + "clickToHideFavorites": "Klik untuk menutup Favorit", + "addAPage": "Tambah halaman baru" }, "notifications": { "export": { @@ -271,8 +294,7 @@ "inputTextFieldHint": "Rahasia anda", "historicalUserList": "History masuk user", "historicalUserListTooltip": "Daftar ini menampilkan akun anonim Anda. Anda dapat mengeklik salah satu akun untuk melihat detailnya. Akun anonim dibuat dengan mengeklik tombol 'Memulai'", - "openHistoricalUser": "Klik untuk membuka akun anonim", - "supabaseSetting": "Pengaturan Supabase" + "openHistoricalUser": "Klik untuk membuka akun anonim" }, "notifications": { "enableNotifications": { @@ -283,33 +305,33 @@ "appearance": { "resetSetting": "Mengatur ulang pengaturan ini", "fontFamily": { - "label": "Keluarga Fon", - "search": "Mencari" + "label": "Jenis Font", + "search": "Cari" }, "themeMode": { - "label": "Theme Mode", - "light": "Mode Terang", - "dark": "Mode Gelap", - "system": "Adapt to System" + "label": "Tema", + "light": "Terang", + "dark": "Gelap", + "system": "Sesuai Sistem" }, "layoutDirection": { - "label": "Arah Layout", - "hint": "Mengontrol aliran konten pada layar Anda, dari kiri ke kanan atau kanan ke kiri.", - "ltr": "LTR", - "rtl": "RTL" + "label": "Arah Tampilan", + "hint": "Mengatur arah tampilan konten, apakah dari kiri ke kanan atau kanan ke kiri.", + "ltr": "Kiri ke Kanan", + "rtl": "Kanan ke Kiri" }, "textDirection": { - "label": "Arah teks default", - "hint": "Menentukan apakah teks harus dimulai dari kiri atau kanan sebagai default.", - "ltr": "LTR", - "rtl": "RTL", - "auto": "AUTO", - "fallback": "Sama seperti arah layout" + "label": "Arah Teks Bawaan", + "hint": "Atur arah teks bawaan, apakah dari kiri ke kanan atau kanan ke kiri.", + "ltr": "Kiri ke Kanan", + "rtl": "Kanan ke Kiri", + "auto": "Otomatis", + "fallback": "Sesuai Arah Tampilan" }, "themeUpload": { "button": "Mengunggah", "uploadTheme": "Unggah tema", - "description": "Unggah tema AppFlowy Anda sendiri menggunakan tombol di bawah ini.", + "description": "Unggah tema @:appName Anda sendiri menggunakan tombol di bawah ini.", "loading": "Harap tunggu sementara kami memvalidasi dan mengunggah tema Anda...", "uploadSuccess": "Tema Anda berhasil diunggah", "deletionFailure": "Gagal menghapus tema. Cobalah untuk menghapusnya secara manual.", @@ -340,7 +362,7 @@ "defaultLocation": "Baca file dan lokasi penyimpanan data", "exportData": "Ekspor data Anda", "doubleTapToCopy": "Ketuk dua kali untuk menyalin jalur", - "restoreLocation": "Pulihkan ke jalur default AppFlowy", + "restoreLocation": "Pulihkan ke jalur default @:appName", "customizeLocation": "Buka folder lain", "restartApp": "Harap mulai ulang aplikasi agar perubahan diterapkan.", "exportDatabase": "Ekspor basis data", @@ -352,10 +374,10 @@ "defineWhereYourDataIsStored": "Tentukan di mana data Anda disimpan", "open": "Membuka", "openFolder": "Buka folder yang ada", - "openFolderDesc": "Baca dan tulis ke folder AppFlowy Anda yang sudah ada", + "openFolderDesc": "Baca dan tulis ke folder @:appName Anda yang sudah ada", "folderHintText": "nama folder", "location": "Membuat folder baru", - "locationDesc": "Pilih nama untuk folder data AppFlowy Anda", + "locationDesc": "Pilih nama untuk folder data @:appName Anda", "browser": "Jelajahi", "create": "Membuat", "set": "Mengatur", @@ -366,7 +388,7 @@ "change": "Mengubah", "openLocationTooltips": "Buka direktori data lain", "openCurrentDataFolder": "Buka direktori data saat ini", - "recoverLocationTooltips": "Setel ulang ke direktori data default AppFlowy", + "recoverLocationTooltips": "Setel ulang ke direktori data default @:appName", "exportFileSuccess": "Ekspor file berhasil!", "exportFileFail": "File ekspor gagal!", "export": "Ekspor" @@ -376,20 +398,9 @@ "email": "Surel", "tooltipSelectIcon": "Pilih ikon", "selectAnIcon": "Pilih ikon", - "pleaseInputYourOpenAIKey": "silakan masukkan kunci OpenAI Anda", - "pleaseInputYourStabilityAIKey": "Masukkan kunci Stability AI anda", - "clickToLogout": "Klik untuk keluar dari pengguna saat ini" - }, - "shortcuts": { - "shortcutsLabel": "Pintasan", - "command": "Perintah", - "keyBinding": "Keybindin", - "addNewCommand": "Tambah Perintah Baru", - "updateShortcutStep": "Tekan kombinasi tombol yang diinginkan dan tekan ENTER", - "shortcutIsAlreadyUsed": "Pintasan ini sudah digunakan untuk: {conflict}", - "resetToDefault": "Mengatur ulang ke keybinding default", - "couldNotLoadErrorMsg": "Tidak dapat memuat pintasan, Coba lagi", - "couldNotSaveErrorMsg": "Tidak dapat menyimpan pintasan, Coba lagi" + "pleaseInputYourOpenAIKey": "silakan masukkan kunci AI Anda", + "clickToLogout": "Klik untuk keluar dari pengguna saat ini", + "pleaseInputYourStabilityAIKey": "Masukkan kunci Stability AI anda" }, "mobile": { "personalInfo": "Informasi pribadi", @@ -401,6 +412,17 @@ "joinDiscord": "Bergabunglah dengan kami di Discord", "privacyPolicy": "Kebijakan Privasi", "userAgreement": "Perjanjian Pengguna" + }, + "shortcuts": { + "shortcutsLabel": "Pintasan", + "command": "Perintah", + "keyBinding": "Keybindin", + "addNewCommand": "Tambah Perintah Baru", + "updateShortcutStep": "Tekan kombinasi tombol yang diinginkan dan tekan ENTER", + "shortcutIsAlreadyUsed": "Pintasan ini sudah digunakan untuk: {conflict}", + "resetToDefault": "Mengatur ulang ke keybinding default", + "couldNotLoadErrorMsg": "Tidak dapat memuat pintasan, Coba lagi", + "couldNotSaveErrorMsg": "Tidak dapat menyimpan pintasan, Coba lagi" } }, "grid": { @@ -589,23 +611,23 @@ "referencedBoard": "Papan Referensi", "referencedGrid": "Kisi yang Direferensikan", "referencedCalendar": "Kalender Referensi", - "autoGeneratorMenuItemName": "Penulis OpenAI", - "autoGeneratorTitleName": "OpenAI: Minta AI untuk menulis apa saja...", + "autoGeneratorMenuItemName": "Penulis AI", + "autoGeneratorTitleName": "AI: Minta AI untuk menulis apa saja...", "autoGeneratorLearnMore": "Belajarlah lagi", "autoGeneratorGenerate": "Menghasilkan", - "autoGeneratorHintText": "Tanya OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Tidak bisa mendapatkan kunci OpenAI", + "autoGeneratorHintText": "Tanya AI...", + "autoGeneratorCantGetOpenAIKey": "Tidak bisa mendapatkan kunci AI", "autoGeneratorRewrite": "Menulis kembali", "smartEdit": "Asisten AI", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Perbaiki ejaan", "warning": "⚠️ Respons AI bisa jadi tidak akurat atau menyesatkan.", "smartEditSummarize": "Meringkaskan", "smartEditImproveWriting": "Perbaiki tulisan", "smartEditMakeLonger": "Buat lebih lama", - "smartEditCouldNotFetchResult": "Tidak dapat mengambil hasil dari OpenAI", - "smartEditCouldNotFetchKey": "Tidak dapat mengambil kunci OpenAI", - "smartEditDisabled": "Hubungkan OpenAI di Pengaturan", + "smartEditCouldNotFetchResult": "Tidak dapat mengambil hasil dari AI", + "smartEditCouldNotFetchKey": "Tidak dapat mengambil kunci AI", + "smartEditDisabled": "Hubungkan AI di Pengaturan", "discardResponse": "Apakah Anda ingin membuang respons AI?", "createInlineMathEquation": "Buat persamaan", "toggleList": "Beralih Daftar", @@ -653,8 +675,8 @@ "defaultColor": "Bawaan" }, "image": { - "copiedToPasteBoard": "Tautan gambar telah disalin ke papan klip", - "addAnImage": "Tambah gambar" + "addAnImage": "Tambah gambar", + "copiedToPasteBoard": "Tautan gambar telah disalin ke papan klip" }, "outline": { "addHeadingToCreateOutline": "Tambahkan judul untuk membuat daftar isi." @@ -690,8 +712,8 @@ "placeholder": "Masukkan URL gambar" }, "ai": { - "label": "Buat gambar dari OpenAI", - "placeholder": "Masukkan perintah agar OpenAI menghasilkan gambar" + "label": "Buat gambar dari AI", + "placeholder": "Masukkan perintah agar AI menghasilkan gambar" }, "stability_ai": { "label": "Buat gambar dari Stability AI", @@ -709,7 +731,7 @@ "placeholder": "Tempel atau ketik tautan gambar" }, "searchForAnImage": "Mencari gambar", - "pleaseInputYourOpenAIKey": "masukkan kunci OpenAI Anda di halaman Pengaturan", + "pleaseInputYourOpenAIKey": "masukkan kunci AI Anda di halaman Pengaturan", "pleaseInputYourStabilityAIKey": "masukkan kunci AI Stabilitas Anda di halaman Pengaturan" }, "codeBlock": { @@ -788,7 +810,7 @@ "referencedCalendarPrefix": "Pemandangan dari" }, "errorDialog": { - "title": "Kesalahan AppFlowy", + "title": "Kesalahan @:appName", "howToFixFallback": "Kami mohon maaf atas ketidaknyamanan ini! Kirimkan masalah di halaman GitHub kami yang menjelaskan kesalahan Anda.", "github": "Lihat di GitHub" }, diff --git a/frontend/resources/translations/it-IT.json b/frontend/resources/translations/it-IT.json index 47e90eaa87..7fb463da20 100644 --- a/frontend/resources/translations/it-IT.json +++ b/frontend/resources/translations/it-IT.json @@ -2,12 +2,14 @@ "appName": "AppFlowy", "defaultUsername": "Me", "welcomeText": "Benvenuto in @:appName", + "welcomeTo": "Benvenuto a", "githubStarText": "Vota su GitHub", "subscribeNewsletterText": "Sottoscrivi la Newsletter", "letsGoButtonText": "Andiamo", "title": "Titolo", "youCanAlso": "Puoi anche", "and": "E", + "failedToOpenUrl": "Apertura URL fallita: {}", "blockActions": { "addBelowTooltip": "Fare clic per aggiungere di seguito", "addAboveCmd": "Alt+clic", @@ -35,15 +37,35 @@ "loginStartWithAnonymous": "Inizia con una sessione anonima", "continueAnonymousUser": "Continua con una sessione anonima", "buttonText": "Accedi", + "signingInText": "Accesso in corso...", "forgotPassword": "Password Dimentica?", "emailHint": "Email", "passwordHint": "Password", "dontHaveAnAccount": "Non hai un account?", + "createAccount": "Crea account", "repeatPasswordEmptyError": "La password ripetuta non può essere vuota", "unmatchedPasswordError": "La password ripetuta non è uguale alla password", "syncPromptMessage": "La sincronizzazione dei dati potrebbe richiedere del tempo. Per favore, non chiudere questa pagina", "or": "O", + "signInWithGoogle": "Continua con Google", + "signInWithGithub": "Continua con Github", + "signInWithDiscord": "Continua con Discord", + "signUpWithGoogle": "Registrati con Google", + "signUpWithGithub": "Registrati con Github", + "signUpWithDiscord": "Registrati con Discord", "signInWith": "Loggati con:", + "signInWithEmail": "Continua con Email", + "signInWithMagicLink": "Continua", + "signUpWithMagicLink": "Registrati con un Link Magico", + "pleaseInputYourEmail": "Per favore, inserisci il tuo indirizzo email", + "settings": "Impostazioni", + "magicLinkSent": "Link Magico inviato!", + "invalidEmail": "Per favore, inserisci un indirizzo email valido", + "alreadyHaveAnAccount": "Hai già un account?", + "logIn": "Accedi", + "generalError": "Qualcosa è andato storto. Per favore, riprova più tardi", + "limitRateError": "Per ragioni di sicurezza, puoi richiedere un link magico ogni 60 secondi", + "magicLinkSentDescription": "Un Link Magico è stato inviato alla tua email. Clicca il link per completare il tuo accesso. Il link scadrà dopo 5 minuti.", "LogInWithGoogle": "Accedi con Google", "LogInWithGithub": "Accedi con Github", "LogInWithDiscord": "Accedi con Discord", @@ -53,23 +75,49 @@ "chooseWorkspace": "Scegli il tuo spazio di lavoro", "create": "Crea spazio di lavoro", "reset": "Ripristina lo spazio di lavoro", + "renameWorkspace": "Rinomina workspace", "resetWorkspacePrompt": "Il ripristino dello spazio di lavoro eliminerà tutte le pagine e i dati al suo interno. Sei sicuro di voler ripristinare lo spazio di lavoro? In alternativa, puoi contattare il team di supporto per ristabilire lo spazio di lavoro", "hint": "spazio di lavoro", "notFoundError": "Spazio di lavoro non trovato", - "failedToLoad": "Qualcosa è andato storto! Impossibile caricare lo spazio di lavoro. Prova a chiudere qualsiasi istanza aperta di AppFlowy e riprova.", + "failedToLoad": "Qualcosa è andato storto! Impossibile caricare lo spazio di lavoro. Prova a chiudere qualsiasi istanza aperta di @:appName e riprova.", "errorActions": { "reportIssue": "Segnala un problema", "reportIssueOnGithub": "Segnalate un problema su Github", "exportLogFiles": "Esporta i file di log", "reachOut": "Contattaci su Discord" - } + }, + "deleteWorkspaceHintText": "Sei sicuro di voler cancellare la workspace? Questa azione non è reversibile, e ogni pagina che hai pubblicato sarà rimossa.", + "createSuccess": "Workspace creata con successo", + "createFailed": "Creazione workspace fallita", + "createLimitExceeded": "Hai raggiunto il numero massimo di workspace permesse per il tuo account. Se hai bisogno di ulteriori workspace per continuare il tuo lavoro, per favore fai richiesta su Github", + "deleteSuccess": "Workspace cancellata con successo", + "deleteFailed": "Cancellazione workspace fallita", + "openSuccess": "Workspace aperta con successo", + "openFailed": "Apertura workspace fallita", + "renameSuccess": "Workspace rinominata con successo", + "renameFailed": "Rinomina workspace fallita", + "updateIconSuccess": "Icona della workspace aggiornata con successo", + "updateIconFailed": "Aggiornamento icona della workspace fallito", + "cannotDeleteTheOnlyWorkspace": "Impossibile cancellare l'unica workspace", + "fetchWorkspacesFailed": "Recupero workspaces fallito", + "leaveCurrentWorkspace": "Lascia workspace", + "leaveCurrentWorkspacePrompt": "Sei sicuro di voler lasciare la workspace corrente?" }, "shareAction": { "buttonText": "Condividi", "workInProgress": "Prossimamente", "markdown": "Markdown", + "clipboard": "Copia", "csv": "CSV", - "copyLink": "Copia Link" + "copyLink": "Copia Link", + "publishToTheWeb": "Pubblica sul Web", + "publishToTheWebHint": "Crea un sito con AppFlowy", + "publish": "Pubblica", + "unPublish": "Annulla la pubblicazione", + "visitSite": "Visita sito", + "exportAsTab": "Esporta come", + "publishTab": "Pubblica", + "shareTab": "Condividi" }, "moreAction": { "small": "piccolo", @@ -80,6 +128,7 @@ "moreOptions": "Più opzioni", "wordCount": "Conteggio parole: {}", "charCount": "Numero di caratteri: {}", + "createdAt": "Creata: {}", "deleteView": "Cancella", "duplicateView": "Duplica" }, @@ -99,7 +148,9 @@ "openNewTab": "Apri in una nuova scheda", "moveTo": "Sposta in", "addToFavorites": "Aggiungi ai preferiti", - "copyLink": "Copia link" + "copyLink": "Copia link", + "changeIcon": "Cambia icona", + "collapseAllPages": "Comprimi le sottopagine" }, "blankPageTitle": "Pagina vuota", "newPageText": "Nuova pagina", @@ -107,6 +158,34 @@ "newGridText": "Nuova griglia", "newCalendarText": "Nuovo calendario", "newBoardText": "Nuova bacheca", + "chat": { + "newChat": "Chat AI", + "inputMessageHint": "Chiedi a @:appName AI", + "inputLocalAIMessageHint": "Chiedi a @:appName Local AI", + "unsupportedCloudPrompt": "Questa funzione è disponibile solo quando si usa @:appName Cloud", + "relatedQuestion": "Correlato", + "serverUnavailable": "Servizio temporaneamente non disponibile. Per favore, riprova più tardi.", + "aiServerUnavailable": "🌈 Uh-oh! 🌈. Un unicorno ha mangiato la nostra risposta. Per favore, riprova!", + "clickToRetry": "Clicca per riprovare", + "regenerateAnswer": "Rigenera", + "question1": "Come usare Kanban per organizzare le attività", + "question2": "Spiega il metodo GTD", + "question3": "Perché usare Rust", + "question4": "Ricetta con cos'è presente nella mia cucina", + "aiMistakePrompt": "Le IA possono fare errori. Controlla le informazioni importanti.", + "chatWithFilePrompt": "Vuoi chattare col file?", + "indexFileSuccess": "Indicizzazione file completata con successo", + "inputActionNoPages": "Nessuna pagina risultante", + "referenceSource": { + "zero": "0 fonti trovate", + "one": "{count} fonte trovata", + "other": "{count} fonti trovate" + }, + "clickToMention": "Clicca per menzionare una pagina", + "uploadFile": "Carica file PDF, MD o TXT con la quale chattare", + "questionDetail": "Salve {}! Come posso aiutarti oggi?", + "indexingFile": "Indicizzazione {}" + }, "trash": { "text": "Cestino", "restoreAll": "Ripristina Tutto", @@ -125,11 +204,13 @@ "caption": "Questa azione non può essere annullata." }, "mobile": { + "actions": "Azioni del cestino", "empty": "Il cestino è vuoto", "emptyDescription": "Non hai alcun file eliminato", "isDeleted": "è stato cancellato", "isRestored": "è stato ripristinato" - } + }, + "confirmDeleteTitle": "Sei sicuro di voler eliminare questa pagina permanentemente?" }, "deletePagePrompt": { "text": "Questa pagina è nel Cestino", @@ -140,14 +221,14 @@ "questionBubble": { "shortcuts": "Scorciatoie", "whatsNew": "Cosa c'è di nuovo?", - "help": "Aiuto & Supporto", "markdown": "Markdown", "debug": { "name": "Informazioni di debug", "success": "Informazioni di debug copiate negli appunti!", "fail": "Impossibile copiare le informazioni di debug negli appunti" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Aiuto & Supporto" }, "menuAppHeader": { "moreButtonToolTip": "Rimuovi, rinomina e altro...", @@ -183,17 +264,44 @@ "dragRow": "Premere a lungo per riordinare la riga", "viewDataBase": "Visualizza banca dati", "referencePage": "Questo {nome} è referenziato", - "addBlockBelow": "Aggiungi un blocco qui sotto" + "addBlockBelow": "Aggiungi un blocco qui sotto", + "aiGenerate": "Genera" }, "sideBar": { "closeSidebar": "Close sidebar", "openSidebar": "Open sidebar", "personal": "Personale", + "private": "Privato", "favorites": "Preferiti", + "clickToHidePrivate": "Clicca per nascondere l'area privata\nLe pagine create qui sono visibili solo a te", + "clickToHideWorkspace": "Clicca per nascondere la workspace\nLe pagine che crei qui sono visibili a ogni membro", "clickToHidePersonal": "Fare clic per nascondere la sezione personale", "clickToHideFavorites": "Fare clic per nascondere la sezione dei preferiti", "addAPage": "Aggiungi una pagina", - "recent": "Recente" + "addAPageToPrivate": "Aggiungi pagina all'area privata", + "addAPageToWorkspace": "Aggiungi pagina alla workspace", + "recent": "Recente", + "today": "Oggi", + "thisWeek": "Questa settimana", + "others": "Preferiti precedenti", + "justNow": "poco fa", + "minutesAgo": "{count} minuti fa", + "lastViewed": "Visto per ultimo", + "favoriteAt": "Aggiunto tra ai preferiti", + "emptyRecent": "Nessun documento recente", + "emptyRecentDescription": "Quando vedrai documenti, appariranno qui per accesso facilitato", + "emptyFavorite": "Nessun documento preferito", + "emptyFavoriteDescription": "Comincia a esplorare e marchia documenti come preferiti. Verranno elencati qui per accesso rapido!", + "removePageFromRecent": "Rimuovere questa pagina da Recenti?", + "removeSuccess": "Rimozione effettuata con successo", + "favoriteSpace": "Preferiti", + "RecentSpace": "Recenti", + "Spaces": "Aree", + "upgradeToPro": "Aggiorna a Pro", + "upgradeToAIMax": "Sblocca AI illimitata", + "storageLimitDialogTitle": "Hai esaurito lo spazio d'archiviazione gratuito. Aggiorna per avere spazio d'archiviazione illimitato!", + "aiResponseLimitTitle": "Hai esaurito le risposte AI gratuite. Aggiorna al Piano Pro per acquistare un add-on AI per avere risposte illimitate", + "aiResponseLimitDialogTitle": "Numero di riposte AI raggiunto" }, "notifications": { "export": { @@ -280,11 +388,8 @@ "cloudServerType": "Server cloud", "cloudServerTypeTip": "Tieni presente che dopo aver cambiato il server cloud il tuo account corrente potrebbe disconnettersi", "cloudLocal": "Locale", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "URL di Supabase", - "cloudSupabaseUrlCanNotBeEmpty": "L'url di supabase non può essere vuoto", - "cloudAppFlowy": "AppFlowy Cloud", - "cloudAppFlowySelfHost": "AppFlowy Cloud Self-hosted (autogestito)", + "cloudAppFlowy": "@:appName Cloud Beta", + "cloudAppFlowySelfHost": "@:appName Cloud Self-hosted (autogestito)", "appFlowyCloudUrlCanNotBeEmpty": "L'url del cloud non può essere vuoto", "clickToCopy": "Fare clic per copiare", "selfHostStart": "Se non disponi di un server, fai riferimento a", @@ -305,14 +410,13 @@ "historicalUserList": "Cronologia di accesso dell'utente", "historicalUserListTooltip": "Questo elenco mostra i tuoi account anonimi. È possibile fare clic su un account per visualizzarne i dettagli. Gli account anonimi vengono creati facendo clic sul pulsante \"Inizia\".", "openHistoricalUser": "Fare clic per aprire l'account anonimo", - "customPathPrompt": "L'archiviazione della cartella dati di AppFlowy in una cartella sincronizzata sul cloud come Google Drive può comportare rischi. Se si accede o si modifica il database all'interno di questa cartella da più posizioni contemporaneamente, potrebbero verificarsi conflitti di sincronizzazione e potenziale danneggiamento dei dati", - "importAppFlowyData": "Importa dati dalla cartella AppFlowy esterna", + "customPathPrompt": "L'archiviazione della cartella dati di @:appName in una cartella sincronizzata sul cloud come Google Drive può comportare rischi. Se si accede o si modifica il database all'interno di questa cartella da più posizioni contemporaneamente, potrebbero verificarsi conflitti di sincronizzazione e potenziale danneggiamento dei dati", + "importAppFlowyData": "Importa dati dalla cartella @:appName esterna", "importingAppFlowyDataTip": "L'importazione dei dati è in corso. Non chiudere l'applicazione", - "importAppFlowyDataDescription": "Copia i dati da una cartella dati AppFlowy esterna e importali nella cartella dati AppFlowy corrente", - "importSuccess": "Importazione della cartella dati AppFlowy riuscita", - "importFailed": "L'importazione della cartella dati di AppFlowy non è riuscita", - "importGuide": "Per ulteriori dettagli si prega di consultare il documento di riferimento", - "supabaseSetting": "Impostazione Supabase" + "importAppFlowyDataDescription": "Copia i dati da una cartella dati @:appName esterna e importali nella cartella dati @:appName corrente", + "importSuccess": "Importazione della cartella dati @:appName riuscita", + "importFailed": "L'importazione della cartella dati di @:appName non è riuscita", + "importGuide": "Per ulteriori dettagli si prega di consultare il documento di riferimento" }, "notifications": { "enableNotifications": { @@ -360,7 +464,7 @@ "themeUpload": { "button": "Caricamento", "uploadTheme": "Carica tema", - "description": "Carica il tuo tema AppFlowy utilizzando il pulsante in basso.", + "description": "Carica il tuo tema @:appName utilizzando il pulsante in basso.", "loading": "Attendi mentre convalidiamo e carichiamo il tuo tema...", "uploadSuccess": "Il tuo tema è stato caricato correttamente", "deletionFailure": "Impossibile eliminare il tema. Prova a eliminarlo manualmente.", @@ -391,7 +495,7 @@ "defaultLocation": "Leggi i file e la posizione di archiviazione dei dati", "exportData": "Esporta i tuoi dati", "doubleTapToCopy": "Tocca due volte per copiare il percorso", - "restoreLocation": "Ripristina nel percorso predefinito di AppFlowy", + "restoreLocation": "Ripristina nel percorso predefinito di @:appName", "customizeLocation": "Apri un'altra cartella", "restartApp": "Riavvia l'app per rendere effettive le modifiche.", "exportDatabase": "Esporta banca dati", @@ -403,10 +507,10 @@ "defineWhereYourDataIsStored": "Definisci dove sono archiviati i tuoi dati", "open": "Aprire", "openFolder": "Apri una cartella esistente", - "openFolderDesc": "Leggilo e scrivilo nella tua cartella AppFlowy esistente", + "openFolderDesc": "Leggilo e scrivilo nella tua cartella @:appName esistente", "folderHintText": "nome della cartella", "location": "Creazione di una nuova cartella", - "locationDesc": "Scegli un nome per la cartella dei dati di AppFlowy", + "locationDesc": "Scegli un nome per la cartella dei dati di @:appName", "browser": "Navigare", "create": "Creare", "set": "Impostato", @@ -417,7 +521,7 @@ "change": "Modifica", "openLocationTooltips": "Apri un'altra directory di dati", "openCurrentDataFolder": "Apre la directory dei dati corrente", - "recoverLocationTooltips": "Ripristina la directory dei dati predefinita di AppFlowy", + "recoverLocationTooltips": "Ripristina la directory dei dati predefinita di @:appName", "exportFileSuccess": "Esporta file con successo!", "exportFileFail": "File di esportazione non riuscito!", "export": "Esportare" @@ -427,20 +531,9 @@ "email": "E-mail", "tooltipSelectIcon": "Seleziona l'icona", "selectAnIcon": "Seleziona un'icona", - "pleaseInputYourOpenAIKey": "inserisci la tua chiave OpenAI", - "pleaseInputYourStabilityAIKey": "per favore inserisci la tua chiave Stability AI", - "clickToLogout": "Fare clic per disconnettere l'utente corrente" - }, - "shortcuts": { - "shortcutsLabel": "Scorciatoie", - "command": "Comando", - "keyBinding": "Combinazione", - "addNewCommand": "Aggiungi un nuovo comando", - "updateShortcutStep": "Premi la combinazione di tasti e poi premi INVIO", - "shortcutIsAlreadyUsed": "Questa scorciatoia è già usata per: {conflict}", - "resetToDefault": "Ripristina le combinazioni di default", - "couldNotLoadErrorMsg": "Impossibile caricare le scorciatoie, Riprova.", - "couldNotSaveErrorMsg": "Impossibile salvare le scorciatoie, Riprova." + "pleaseInputYourOpenAIKey": "inserisci la tua chiave AI", + "clickToLogout": "Fare clic per disconnettere l'utente corrente", + "pleaseInputYourStabilityAIKey": "per favore inserisci la tua chiave Stability AI" }, "mobile": { "personalInfo": "Informazione personale", @@ -458,6 +551,17 @@ "selectLayout": "Seleziona disposizione", "selectStartingDay": "Seleziona il giorno di inizio", "version": "Versione" + }, + "shortcuts": { + "shortcutsLabel": "Scorciatoie", + "command": "Comando", + "keyBinding": "Combinazione", + "addNewCommand": "Aggiungi un nuovo comando", + "updateShortcutStep": "Premi la combinazione di tasti e poi premi INVIO", + "shortcutIsAlreadyUsed": "Questa scorciatoia è già usata per: {conflict}", + "resetToDefault": "Ripristina le combinazioni di default", + "couldNotLoadErrorMsg": "Impossibile caricare le scorciatoie, Riprova.", + "couldNotSaveErrorMsg": "Impossibile salvare le scorciatoie, Riprova." } }, "grid": { @@ -706,23 +810,23 @@ "referencedGrid": "Griglia di riferimento", "referencedCalendar": "Calendario referenziato", "referencedDocument": "Documento riferito", - "autoGeneratorMenuItemName": "Scrittore OpenAI", - "autoGeneratorTitleName": "OpenAI: chiedi all'AI di scrivere qualsiasi cosa...", + "autoGeneratorMenuItemName": "Scrittore AI", + "autoGeneratorTitleName": "AI: chiedi all'AI di scrivere qualsiasi cosa...", "autoGeneratorLearnMore": "Saperne di più", "autoGeneratorGenerate": "creare", - "autoGeneratorHintText": "Chiedi a OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Impossibile ottenere la chiave OpenAI", + "autoGeneratorHintText": "Chiedi a AI...", + "autoGeneratorCantGetOpenAIKey": "Impossibile ottenere la chiave AI", "autoGeneratorRewrite": "Riscrivere", "smartEdit": "Assistenti AI", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Correggi l'ortografia", "warning": "⚠️ Le risposte AI possono essere imprecise o fuorvianti.", "smartEditSummarize": "Riassumere", "smartEditImproveWriting": "Migliora la scrittura", "smartEditMakeLonger": "Rendi più lungo", - "smartEditCouldNotFetchResult": "Impossibile recuperare il risultato da OpenAI", - "smartEditCouldNotFetchKey": "Impossibile recuperare la chiave OpenAI", - "smartEditDisabled": "Connetti OpenAI in Impostazioni", + "smartEditCouldNotFetchResult": "Impossibile recuperare il risultato da AI", + "smartEditCouldNotFetchKey": "Impossibile recuperare la chiave AI", + "smartEditDisabled": "Connetti AI in Impostazioni", "discardResponse": "Vuoi scartare le risposte AI?", "createInlineMathEquation": "Crea un'equazione", "fonts": "Caratteri", @@ -778,8 +882,8 @@ "defaultColor": "Predefinito" }, "image": { - "copiedToPasteBoard": "Il link dell'immagine è stato copiato negli appunti", "addAnImage": "Aggiungi un'immagine", + "copiedToPasteBoard": "Il link dell'immagine è stato copiato negli appunti", "imageUploadFailed": "Caricamento dell'immagine non riuscito" }, "urlPreview": { @@ -831,8 +935,8 @@ "placeholder": "Inserisci l'URL dell'immagine" }, "ai": { - "label": "Genera immagine da OpenAI", - "placeholder": "Inserisci la richiesta affinché OpenAI generi l'immagine" + "label": "Genera immagine da AI", + "placeholder": "Inserisci la richiesta affinché AI generi l'immagine" }, "stability_ai": { "label": "Genera immagine da Stability AI", @@ -853,15 +957,15 @@ "label": "Unsplash" }, "searchForAnImage": "Cerca un'immagine", - "pleaseInputYourOpenAIKey": "inserisci la tua chiave OpenAI nella pagina Impostazioni", - "pleaseInputYourStabilityAIKey": "inserisci la chiave Stability AI nella pagina Impostazioni", + "pleaseInputYourOpenAIKey": "inserisci la tua chiave AI nella pagina Impostazioni", "saveImageToGallery": "Salva immagine", "failedToAddImageToGallery": "Impossibile aggiungere l'immagine alla galleria", "successToAddImageToGallery": "Immagine aggiunta alla galleria con successo", "unableToLoadImage": "Impossibile caricare l'immagine", "maximumImageSize": "La dimensione massima supportata per il caricamento delle immagini è di 10 MB", "uploadImageErrorImageSizeTooBig": "Le dimensioni dell'immagine devono essere inferiori a 10 MB", - "imageIsUploading": "L'immagine si sta caricando" + "imageIsUploading": "L'immagine si sta caricando", + "pleaseInputYourStabilityAIKey": "inserisci la chiave Stability AI nella pagina Impostazioni" }, "codeBlock": { "language": { @@ -969,7 +1073,7 @@ "quickJumpYear": "Salta a" }, "errorDialog": { - "title": "Errore AppFlow", + "title": "Errore @:appName", "howToFixFallback": "Ci scusiamo per l'inconveniente! Invia un problema sulla nostra pagina GitHub che descriva il tuo errore.", "github": "Visualizza su GitHub" }, diff --git a/frontend/resources/translations/ja-JP.json b/frontend/resources/translations/ja-JP.json index 4388f9c3a9..ebe679ad84 100644 --- a/frontend/resources/translations/ja-JP.json +++ b/frontend/resources/translations/ja-JP.json @@ -2,12 +2,14 @@ "appName": "AppFlowy", "defaultUsername": "ユーザー", "welcomeText": "Welcome to @:appName", + "welcomeTo": "ようこそ", "githubStarText": "Star on GitHub", "subscribeNewsletterText": "新着情報を受け取る", "letsGoButtonText": "Let's Go", "title": "タイトル", "youCanAlso": "他にもこんなことができます", "and": "と", + "failedToOpenUrl": "URLを開けませんでした: {}", "blockActions": { "addBelowTooltip": "クリックして下に追加してください", "addAboveCmd": "Alt+クリック", @@ -26,118 +28,258 @@ "alreadyHaveAnAccount": "すでにアカウントを登録済ですか?", "emailHint": "メールアドレス", "passwordHint": "パスワード", - "repeatPasswordHint": "パスワード(確認用)" + "repeatPasswordHint": "パスワード(確認用)", + "signUpWith": "サインアップ:" }, "signIn": { - "loginTitle": "@:appName にログイン", + "loginTitle": "@:appNameにログイン", "loginButtonText": "ログイン", + "loginStartWithAnonymous": "匿名で続行", + "continueAnonymousUser": "匿名で続行", + "anonymous": "匿名", "buttonText": "サインイン", "signingInText": "サインイン中...", - "forgotPassword": "パスワードをお忘れですか?", + "forgotPassword": "パスワードを忘れましたか?", "emailHint": "メールアドレス", "passwordHint": "パスワード", - "dontHaveAnAccount": "まだアカウントをお持ちではないですか?", - "repeatPasswordEmptyError": "パスワード(確認用)を空にはできません", - "unmatchedPasswordError": "パスワード(確認用)が一致しません", - "LogInWithGoogle": "Googleでログイン", - "LogInWithGithub": "GitHubでログイン", - "LogInWithDiscord": "Discordでログイン", - "loginAsGuestButtonText": "始めましょう" + "dontHaveAnAccount": "アカウントをお持ちでないですか?", + "createAccount": "アカウントを作成", + "repeatPasswordEmptyError": "パスワードを再入力してください", + "unmatchedPasswordError": "パスワードが一致しません", + "syncPromptMessage": "データの同期には時間がかかる場合があります。このページを閉じないでください", + "or": "または", + "signInWithGoogle": "Googleで続行", + "signInWithGithub": "GitHubで続行", + "signInWithDiscord": "Discordで続行", + "signInWithApple": "Appleで続行", + "continueAnotherWay": "別の方法で続行", + "signUpWithGoogle": "Googleで登録", + "signUpWithGithub": "GitHubで登録", + "signUpWithDiscord": "Discordで登録", + "signInWith": "続行方法:", + "signInWithEmail": "メールで続行", + "signInWithMagicLink": "続行", + "signUpWithMagicLink": "Magic Linkで登録", + "pleaseInputYourEmail": "メールアドレスを入力してください", + "settings": "設定", + "magicLinkSent": "Magic Linkが送信されました!", + "invalidEmail": "有効なメールアドレスを入力してください", + "alreadyHaveAnAccount": "すでにアカウントをお持ちですか?", + "logIn": "ログイン", + "generalError": "エラーが発生しました。後でもう一度お試しください", + "limitRateError": "セキュリティのため、Magic Linkは60秒に1回しかリクエストできません", + "magicLinkSentDescription": "Magic Linkがあなたのメールアドレスに送信されました。リンクをクリックしてログインを完了してください。リンクは5分後に無効になります。" }, "workspace": { - "chooseWorkspace": "ワークスベースを選択", - "create": "ワークスペースを作成する", + "chooseWorkspace": "ワークスペースを選択", + "defaultName": "私のワークスペース", + "create": "ワークスペースを作成", + "new": "新しいワークスペース", + "importFromNotion": "Notionからインポート", + "learnMore": "もっと詳しく知る", + "reset": "ワークスペースをリセット", + "renameWorkspace": "ワークスペースの名前を変更", + "workspaceNameCannotBeEmpty": "ワークスペース名は空にできません", + "resetWorkspacePrompt": "ワークスペースをリセットすると、すべてのページとデータが削除されます。本当にリセットしますか?代わりに、サポートチームに連絡してワークスペースを復元することもできます。", "hint": "ワークスペース", - "notFoundError": "ワークスペースがみつかりません", + "notFoundError": "ワークスペースが見つかりません", + "failedToLoad": "問題が発生しました!ワークスペースの読み込みに失敗しました。@:appNameの開いているインスタンスをすべて閉じて、再試行してください。", "errorActions": { "reportIssue": "問題を報告", - "reportIssueOnGithub": "GitHubで問題を報告", - "exportLogFiles": "ログファイルを出力" - } + "reportIssueOnGithub": "Githubで問題を報告", + "exportLogFiles": "ログファイルをエクスポート", + "reachOut": "Discordで連絡" + }, + "menuTitle": "ワークスペース", + "deleteWorkspaceHintText": "ワークスペースを削除してもよろしいですか?この操作は元に戻せません。公開したページはすべて非公開になります。", + "createSuccess": "ワークスペースが正常に作成されました", + "createFailed": "ワークスペースの作成に失敗しました", + "createLimitExceeded": "アカウントで許可されている最大数のワークスペースに達しました。追加のワークスペースが必要な場合は、Githubでリクエストしてください。", + "deleteSuccess": "ワークスペースが正常に削除されました", + "deleteFailed": "ワークスペースの削除に失敗しました", + "openSuccess": "ワークスペースを正常に開きました", + "openFailed": "ワークスペースを開くことができませんでした", + "renameSuccess": "ワークスペース名が正常に変更されました", + "renameFailed": "ワークスペース名の変更に失敗しました", + "updateIconSuccess": "ワークスペースアイコンを正常に更新しました", + "updateIconFailed": "ワークスペースアイコンの更新に失敗しました", + "cannotDeleteTheOnlyWorkspace": "ワークスペースは削除できません", + "fetchWorkspacesFailed": "ワークスペースの取得に失敗しました", + "leaveCurrentWorkspace": "ワークスペースを離れる", + "leaveCurrentWorkspacePrompt": "現在のワークスペースから退出してもよろしいですか?" }, "shareAction": { - "buttonText": "共有する", - "workInProgress": "Coming soon", + "buttonText": "共有", + "workInProgress": "近日公開", "markdown": "Markdown", + "html": "HTML", + "clipboard": "クリップボードにコピー", "csv": "CSV", - "copyLink": "リンクをコピー" + "copyLink": "リンクをコピー", + "publishToTheWeb": "Webに公開", + "publishToTheWebHint": "AppFlowyでウェブサイトを作成", + "publish": "公開", + "unPublish": "非公開", + "visitSite": "サイトを訪問", + "exportAsTab": "エクスポート形式", + "publishTab": "公開", + "shareTab": "共有", + "publishOnAppFlowy": "AppFlowyで公開", + "shareTabTitle": "コラボレーションに招待", + "shareTabDescription": "誰とでも簡単にコラボレーション", + "copyLinkSuccess": "リンクをクリップボードにコピーしました", + "copyShareLink": "共有リンクをコピー", + "copyLinkFailed": "リンクをクリップボードにコピーできませんでした", + "copyLinkToBlockSuccess": "ブロックリンクをクリップボードにコピーしました", + "copyLinkToBlockFailed": "ブロックリンクをクリップボードにコピーできませんでした", + "manageAllSites": "すべてのサイトを管理する", + "updatePathName": "パス名を更新" }, "moreAction": { - "small": "小さい", - "medium": "中くらい", - "large": "大きい", + "small": "小", + "medium": "中", + "large": "大", "fontSize": "フォントサイズ", - "import": "取り込む", - "moreOptions": "より多くのオプション" + "import": "インポート", + "moreOptions": "その他のオプション", + "wordCount": "単語数: {}", + "charCount": "文字数: {}", + "createdAt": "作成日: {}", + "deleteView": "削除", + "duplicateView": "複製", + "wordCountLabel": "単語数:", + "charCountLabel": "文字数:", + "createdAtLabel": "作成日:", + "syncedAtLabel": "同期済み:" }, "importPanel": { "textAndMarkdown": "テキストとマークダウン", - "documentFromV010": "v0.1.0 のドキュメント", + "documentFromV010": "v0.1.0 以降のドキュメント", "databaseFromV010": "v0.1.0 以降のデータベース", + "notionZip": "Notion エクスポートされた Zip ファイル", "csv": "CSV", "database": "データベース" }, "disclosureAction": { "rename": "名前を変更", "delete": "削除", - "duplicate": "コピーを作成", + "duplicate": "複製", "unfavorite": "お気に入りから削除", "favorite": "お気に入りに追加", "openNewTab": "新しいタブで開く", + "moveTo": "移動", "addToFavorites": "お気に入りに追加", - "copyLink": "リンクをコピー" + "copyLink": "リンクをコピー", + "changeIcon": "アイコンを変更", + "collapseAllPages": "すべてのサブページを折りたたむ", + "movePageTo": "ページを移動", + "move": "動く" }, - "blankPageTitle": "空のページ", + "blankPageTitle": "空白のページ", "newPageText": "新しいページ", "newDocumentText": "新しいドキュメント", + "newGridText": "新しいグリッド", "newCalendarText": "新しいカレンダー", "newBoardText": "新しいボード", + "chat": { + "newChat": "AIチャット", + "inputMessageHint": "@:appName AIに質問", + "inputLocalAIMessageHint": "@:appName ローカルAIに質問", + "unsupportedCloudPrompt": "この機能は@:appName Cloudでのみ利用可能です", + "relatedQuestion": "関連質問", + "serverUnavailable": "サービスが一時的に利用できません。後でもう一度お試しください。", + "aiServerUnavailable": "🌈 おっと! 🌈 ユニコーンが返答を食べちゃいました。再試行してください!", + "retry": "リトライ", + "clickToRetry": "再試行をクリック", + "regenerateAnswer": "回答を再生成", + "question1": "タスク管理にKanbanを使用する方法", + "question2": "GTDメソッドを説明してください", + "question3": "なぜRustを使うのか", + "question4": "キッチンにある材料のレシピ", + "question5": "マイページ用のイラストを作成する", + "question6": "来週のやることリストを作成する", + "aiMistakePrompt": "AIは間違いを犯すことがあります。重要な情報は確認してください。", + "chatWithFilePrompt": "ファイルとチャットしますか?", + "indexFileSuccess": "ファイルのインデックス作成が成功しました", + "inputActionNoPages": "ページ結果がありません", + "referenceSource": { + "zero": "0 件のソースが見つかりました", + "one": "{count} 件のソースが見つかりました", + "other": "{count} 件のソースが見つかりました" + }, + "clickToMention": "ページをメンションするにはクリック", + "uploadFile": "PDF、md、txtファイルをアップロードしてチャット", + "questionDetail": "こんにちは、{}!今日はどんなお手伝いをしましょうか?", + "indexingFile": "{}をインデックス作成中", + "generatingResponse": "レスポンスの作成", + "selectSources": "ソースを選択", + "sourcesLimitReached": "選択できるのは最上位のドキュメントとその子ドキュメントの3つまでです。", + "sourceUnsupported": "現時点ではデータベースとのチャットはサポートされていません", + "regenerate": "もう一度やり直してください", + "addToPageButton": "ページにメッセージを追加", + "addToPageTitle": "メッセージを追加...", + "addToNewPageName": "\"{}\"から抽出されたメッセージ", + "addToNewPageSuccessToast": "メッセージが追加されました", + "openPagePreviewFailedToast": "ページを開けませんでした" + }, "trash": { - "text": "ごみ箱", - "restoreAll": "全て復元", - "deleteAll": "全て削除", + "text": "ゴミ箱", + "restoreAll": "すべて復元", + "restore": "復元する", + "deleteAll": "すべて削除", "pageHeader": { "fileName": "ファイル名", - "lastModified": "最終更新日時", - "created": "作成日時" + "lastModified": "最終更新日", + "created": "作成日" }, "confirmDeleteAll": { - "title": "ゴミ箱内のすべてのページを削除してもよろしいですか?", - "caption": "この操作は元に戻すことができません。" + "title": "ゴミ箱内のすべてのページを削除してもよろしいですか?", + "caption": "この操作は元に戻せません。" }, "confirmRestoreAll": { - "title": "ゴミ箱内のすべてのページを復元してもよろしいですか?", - "caption": "この操作は元に戻すことができません。" + "title": "ゴミ箱内のすべてのページを復元してもよろしいですか?", + "caption": "この操作は元に戻せません。" + }, + "restorePage": { + "title": "復元する: {}", + "caption": "このページを復元してもよろしいですか?" }, "mobile": { - "empty": "ゴミ箱を空にする", - "emptyDescription": "削除されたファイルはありません", - "isDeleted": "削除済み" - } + "actions": "ゴミ箱の操作", + "empty": "ゴミ箱にページやスペースはありません", + "emptyDescription": "不要なものをゴミ箱に移動します。", + "isDeleted": "が削除されました", + "isRestored": "が復元されました" + }, + "confirmDeleteTitle": "このページを完全に削除してもよろしいですか?" }, "deletePagePrompt": { "text": "このページはごみ箱にあります", "restore": "ページを元に戻す", - "deletePermanent": "削除する" + "deletePermanent": "削除する", + "deletePermanentDescription": "このページを完全に削除してもよろしいですか? 削除すると元に戻せません。" }, "dialogCreatePageNameHint": "ページ名", "questionBubble": { "shortcuts": "ショートカット", - "whatsNew": "What's new?", - "help": "ヘルプとサポート", - "markdown": "マークダウン", + "whatsNew": "新着情報", + "markdown": "Markdown", "debug": { "name": "デバッグ情報", "success": "デバッグ情報をクリップボードにコピーしました!", "fail": "デバッグ情報をクリップボードにコピーできませんでした" }, - "feedback": "フィードバック" + "feedback": "フィードバック", + "help": "ヘルプ & サポート" }, "menuAppHeader": { - "addPageTooltip": "内部ページを追加", - "defaultNewPageName": "Untitled", - "renameDialog": "名前を変更" + "moreButtonToolTip": "削除、名前の変更、その他...", + "addPageTooltip": "ページを追加", + "defaultNewPageName": "無題", + "renameDialog": "名前を変更", + "pageNameSuffix": "コピー" }, + "noPagesInside": "中にページがありません", "toolbar": { "undo": "元に戻す", "redo": "やり直し", @@ -146,13 +288,13 @@ "underline": "下線", "strike": "取り消し線", "numList": "番号付きリスト", - "bulletList": "箇条書き", - "checkList": "チェックボックス", + "bulletList": "箇条書きリスト", + "checkList": "チェックリスト", "inlineCode": "インラインコード", - "quote": "引用文", - "header": "見出し", - "highlight": "文字の背景色", - "color": "色", + "quote": "引用ブロック", + "header": "ヘッダー", + "highlight": "ハイライト", + "color": "カラー", "addLink": "リンクを追加", "link": "リンク" }, @@ -161,35 +303,79 @@ "darkMode": "ダークモードに切り替える", "openAsPage": "ページとして開く", "addNewRow": "新しい行を追加", - "openMenu": "クリックしてメニューを開きます", - "dragRow": "長押しして行を並べ替えます", - "viewDataBase": "データベースを見る", + "openMenu": "クリックしてメニューを開く", + "dragRow": "長押しして行を並べ替える", + "viewDataBase": "データベースを表示", "referencePage": "この {name} は参照されています", - "addBlockBelow": "以下にブロックを追加します" + "addBlockBelow": "下にブロックを追加", + "aiGenerate": "生成する" }, "sideBar": { - "closeSidebar": "Close sidebar", - "openSidebar": "Open sidebar", + "closeSidebar": "サイドバーを閉じる", + "openSidebar": "サイドバーを開く", + "expandSidebar": "全ページ展開", + "personal": "個人", + "private": "プライベート", + "workspace": "ワークスペース", "favorites": "お気に入り", - "clickToHideFavorites": "クリックしてお気に入りを隠す", - "addAPage": "ページを追加", - "recent": "最近" + "clickToHidePrivate": "クリックしてプライベートスペースを非表示にする\nここで作成したページはあなただけが見えます", + "clickToHideWorkspace": "クリックしてワークスペースを非表示にする\nここで作成したページはすべてのメンバーが閲覧できます", + "clickToHidePersonal": "クリックして個人スペースを非表示にする", + "clickToHideFavorites": "クリックしてお気に入りスペースを非表示にする", + "addAPage": "新しいページを追加", + "addAPageToPrivate": "プライベートスペースにページを追加", + "addAPageToWorkspace": "ワークスペースにページを追加", + "recent": "最近", + "today": "今日", + "thisWeek": "今週", + "others": "以前のお気に入り", + "earlier": "以前", + "justNow": "たった今", + "minutesAgo": "{count}分前", + "lastViewed": "最後に表示したページ", + "favoriteAt": "お気に入り登録", + "emptyRecent": "最近表示したページはありません", + "emptyRecentDescription": "ページを閲覧すると、ここに簡単にアクセスできるよう表示されます。", + "emptyFavorite": "お気に入りのページはありません", + "emptyFavoriteDescription": "ページをお気に入りに追加すると、ここに表示されて素早くアクセスできます!", + "removePageFromRecent": "最近表示したページから削除しますか?", + "removeSuccess": "削除に成功しました", + "favoriteSpace": "お気に入り", + "RecentSpace": "最近", + "Spaces": "スペース", + "upgradeToPro": "Proプランにアップグレード", + "upgradeToAIMax": "無制限のAIを解放", + "storageLimitDialogTitle": "無料のストレージが不足しています。無制限のストレージを解放するにはアップグレードしてください", + "aiResponseLimitTitle": "無料のAIレスポンスが不足しています。Proプランにアップグレードするか、AIアドオンを購入して無制限のレスポンスを解放してください", + "aiResponseLimitDialogTitle": "AIレスポンスの制限に達しました", + "aiResponseLimit": "無料のAIレスポンスが不足しています。\n\n設定 -> プラン -> AI MaxまたはProプランをクリックして、さらにAIレスポンスを取得してください", + "askOwnerToUpgradeToPro": "ワークスペースの無料ストレージが不足しています。ワークスペースのオーナーにProプランへのアップグレードを依頼してください", + "askOwnerToUpgradeToProIOS": "ワークスペースの無料ストレージが不足しています。", + "askOwnerToUpgradeToAIMax": "ワークスペースのAIレスポンスが不足しています。ワークスペースのオーナーにプランのアップグレードかAIアドオンの購入を依頼してください", + "askOwnerToUpgradeToAIMaxIOS": "ワークスペースの無料AIレスポンスが不足しています。", + "purchaseStorageSpace": "ストレージスペースを購入", + "singleFileProPlanLimitationDescription": "無料プランで許可されている最大ファイルアップロードサイズを超えました。より大きなファイルをアップロードするには、プロプランにアップグレードしてください。", + "purchaseAIResponse": "AIレスポンスを購入", + "askOwnerToUpgradeToLocalAI": "ワークスペースのオーナーにオンデバイスAIを有効にするよう依頼してください", + "upgradeToAILocal": "デバイス上でローカルモデルを実行して究極のプライバシーを実現", + "upgradeToAILocalDesc": "PDFとチャットしたり、文章を改善したり、ローカルAIでテーブルを自動入力したりできます" }, "notifications": { "export": { - "markdown": "マークダウン形式のノート", + "markdown": "ノートをMarkdownにエクスポート", "path": "Documents/flowy" } }, "contactsPage": { "title": "連絡先", - "whatsHappening": "今週はどんなことがありましたか?", - "addContact": "連絡先を追加する", - "editContact": "連絡先を編集する" + "whatsHappening": "今週は何が起こっている?", + "addContact": "連絡先を追加", + "editContact": "連絡先を編集" }, "button": { "ok": "OK", - "done": "終わり", + "confirm": "確認", + "done": "完了", "cancel": "キャンセル", "signIn": "サインイン", "signOut": "サインアウト", @@ -197,31 +383,64 @@ "save": "保存", "generate": "生成", "esc": "ESC", - "keep": "保つ", - "tryAgain": "再試行する", + "keep": "保持", + "tryAgain": "再試行", "discard": "破棄", - "replace": "交換", + "replace": "置き換え", "insertBelow": "下に挿入", "insertAbove": "上に挿入", "upload": "アップロード", "edit": "編集", - "delete": "消去", + "delete": "削除", + "copy": "コピー", "duplicate": "複製", - "putback": "戻す", + "putback": "元に戻す", "update": "更新", "share": "共有", "removeFromFavorites": "お気に入りから削除", - "addToFavorites": "お気に入りへ追加", + "removeFromRecent": "最近の項目から削除", + "addToFavorites": "お気に入りに追加", + "favoriteSuccessfully": "お気に入りに追加しました", + "unfavoriteSuccessfully": "お気に入りから削除しました", + "duplicateSuccessfully": "複製が成功しました", "rename": "名前を変更", "helpCenter": "ヘルプセンター", - "add": "追加" + "add": "追加", + "yes": "はい", + "no": "いいえ", + "clear": "クリア", + "remove": "削除", + "dontRemove": "削除しない", + "copyLink": "リンクをコピー", + "align": "整列", + "login": "ログイン", + "logout": "ログアウト", + "deleteAccount": "アカウントを削除", + "back": "戻る", + "signInGoogle": "Googleで続行", + "signInGithub": "GitHubで続行", + "signInDiscord": "Discordで続行", + "more": "もっと見る", + "create": "作成", + "close": "閉じる", + "next": "次へ", + "previous": "前へ", + "submit": "送信", + "download": "ダウンロード", + "backToHome": "Homeに戻る", + "viewing": "閲覧", + "editing": "編集", + "gotIt": "わかった", + "retry": "リトライ", + "uploadFailed": "アップロードに失敗しました。", + "copyLinkOriginal": "元のリンクをコピー" }, "label": { "welcome": "ようこそ!", "firstName": "名", "middleName": "ミドルネーム", "lastName": "姓", - "stepX": "Step {X}" + "stepX": "ステップ {X}" }, "oAuth": { "err": { @@ -230,182 +449,944 @@ }, "google": { "title": "Googleでサインイン", - "instruction1": "Googleでのサインインを有効にするためには、Webブラウザーを使ってこのアプリケーションを認証する必要があります。", - "instruction2": "アイコンをクリックするか、以下のテキストを選択して、このコードをクリップボードにコピーします。", + "instruction1": "Googleでのサインインを有効にするためには、Webブラウザーを使用してこのアプリケーションを認証する必要があります。", + "instruction2": "アイコンをクリックするかテキストを選択して、このコードをクリップボードにコピーします。", "instruction3": "以下のリンク先をブラウザーで開いて、次のコードを入力します。", "instruction4": "登録が完了したら以下のボタンを押してください。" } }, "settings": { "title": "設定", + "popupMenuItem": { + "settings": "設定", + "members": "メンバー", + "trash": "ゴミ箱", + "helpAndSupport": "ヘルプ & サポート" + }, + "sites": { + "title": "サイト", + "namespaceTitle": "名前空間", + "namespaceDescription": "名前空間とホームページを管理する", + "namespaceHeader": "名前空間", + "homepageHeader": "ホームページ", + "updateNamespace": "名前空間を更新する", + "removeHomepage": "ホームページを削除する", + "selectHomePage": "ページを選択", + "clearHomePage": "この名前空間のホームページをクリアする", + "customUrl": "カスタム URL", + "namespace": { + "description": "この変更は、この名前空間で公開されているすべてのページに適用されます。", + "tooltip": "不適切な名前空間を削除する権利を留保します", + "updateExistingNamespace": "既存の名前空間を更新する", + "upgradeToPro": "ホームページを設定するにはプロプランにアップグレードしてください", + "redirectToPayment": "支払いページにリダイレクトしています...", + "onlyWorkspaceOwnerCanSetHomePage": "ワークスペースの所有者のみがホームページを設定できます", + "pleaseAskOwnerToSetHomePage": "ワークスペースのオーナーにプロプランへのアップグレードを依頼してください" + }, + "publishedPage": { + "title": "公開されたすべてのページ", + "description": "公開したページを管理する", + "page": "ページ", + "pathName": "パス名", + "date": "公開日", + "emptyHinText": "このワークスペースには公開されたページはありません", + "noPublishedPages": "公開ページはありません", + "settings": "公開設定", + "clickToOpenPageInApp": "アプリでページを開く", + "clickToOpenPageInBrowser": "ブラウザでページを開く" + }, + "error": { + "failedToGeneratePaymentLink": "プロプランの支払いリンクを生成できませんでした", + "failedToUpdateNamespace": "名前空間の更新に失敗しました", + "proPlanLimitation": "名前空間を更新するには、Proプランにアップグレードする必要があります", + "namespaceAlreadyInUse": "名前空間はすでに使用されています。別の名前空間を試してください", + "invalidNamespace": "名前空間が無効です。別の名前空間を試してください", + "namespaceLengthAtLeast2Characters": "名前空間は少なくとも 2 文字の長さである必要があります", + "onlyWorkspaceOwnerCanUpdateNamespace": "ワークスペースの所有者のみが名前空間を更新できます", + "onlyWorkspaceOwnerCanRemoveHomepage": "ワークスペースの所有者のみがホームページを削除できます", + "setHomepageFailed": "ホームページの設定に失敗しました", + "namespaceTooLong": "名前空間が長すぎます。別の名前空間を試してください", + "namespaceTooShort": "名前空間が短すぎます。別の名前空間を試してください。", + "namespaceIsReserved": "名前空間は予約されています。別の名前空間を試してください", + "updatePathNameFailed": "パス名の更新に失敗しました", + "removeHomePageFailed": "ホームページを削除できませんでした", + "publishNameContainsInvalidCharacters": "パス名に無効な文字が含まれています。別のパス名を試してください。", + "publishNameTooShort": "パス名が短すぎます。別のパス名を試してください。", + "publishNameTooLong": "パス名が長すぎます。別のパス名を試してください。", + "publishNameAlreadyInUse": "パス名はすでに使用されています。別のパス名を試してください", + "namespaceContainsInvalidCharacters": "名前空間に無効な文字が含まれています。別の名前空間を試してください。", + "publishPermissionDenied": "公開設定を管理できるのはワークスペースの所有者またはページ発行者のみです。", + "publishNameCannotBeEmpty": "パス名は空にできません。別のパス名を試してください。" + }, + "success": { + "namespaceUpdated": "名前空間が正常に更新されました", + "setHomepageSuccess": "ホームページの設定に成功しました", + "updatePathNameSuccess": "パス名が正常に更新されました", + "removeHomePageSuccess": "ホームページを削除しました" + } + }, + "accountPage": { + "menuLabel": "マイアカウント", + "title": "マイアカウント", + "general": { + "title": "アカウント名 & プロフィール画像", + "changeProfilePicture": "プロフィール画像を変更" + }, + "email": { + "title": "メールアドレス", + "actions": { + "change": "メールアドレスを変更" + } + }, + "login": { + "title": "アカウントログイン", + "loginLabel": "ログイン", + "logoutLabel": "ログアウト" + } + }, + "workspacePage": { + "menuLabel": "ワークスペース", + "title": "ワークスペース", + "description": "ワークスペースの外観、テーマ、フォント、テキストレイアウト、日付/時間形式、言語をカスタマイズします。", + "workspaceName": { + "title": "ワークスペース名" + }, + "workspaceIcon": { + "title": "ワークスペースアイコン", + "description": "画像をアップロードするか、絵文字を使用してワークスペースにアイコンを設定します。アイコンはサイドバーや通知に表示されます。" + }, + "appearance": { + "title": "外観", + "description": "ワークスペースの外観、テーマ、フォント、テキストレイアウト、日付、時間、言語をカスタマイズします。", + "options": { + "system": "自動", + "light": "ライト", + "dark": "ダーク" + } + }, + "resetCursorColor": { + "title": "ドキュメントのカーソル色をリセット", + "description": "カーソルの色をリセットしてもよろしいですか?" + }, + "resetSelectionColor": { + "title": "ドキュメントの選択色をリセット", + "description": "選択色をリセットしてもよろしいですか?" + }, + "resetWidth": { + "resetSuccess": "ドキュメントの幅を正常にリセットしました" + }, + "theme": { + "title": "テーマ", + "description": "プリセットテーマを選択するか、独自のカスタムテーマをアップロードします。", + "uploadCustomThemeTooltip": "カスタムテーマをアップロード" + }, + "workspaceFont": { + "title": "ワークスペースのフォント", + "noFontHint": "フォントが見つかりません。別のキーワードをお試しください。" + }, + "textDirection": { + "title": "テキストの方向", + "leftToRight": "左から右", + "rightToLeft": "右から左", + "auto": "自動", + "enableRTLItems": "RTLツールバー項目を有効にする" + }, + "layoutDirection": { + "title": "レイアウトの方向", + "leftToRight": "左から右", + "rightToLeft": "右から左" + }, + "dateTime": { + "title": "日付と時間", + "example": "{} at {} ({})", + "24HourTime": "24時間表記", + "dateFormat": { + "label": "日付形式", + "local": "ローカル", + "us": "米国", + "iso": "ISO", + "friendly": "読み易さ", + "dmy": "日/月/年" + } + }, + "language": { + "title": "言語" + }, + "deleteWorkspacePrompt": { + "title": "ワークスペースの削除", + "content": "このワークスペースを削除してもよろしいですか?この操作は元に戻せません。公開しているページはすべて非公開になります。" + }, + "leaveWorkspacePrompt": { + "title": "ワークスペースを退出", + "content": "このワークスペースを退出してもよろしいですか?ワークスペース内のすべてのページやデータへのアクセスを失います。" + }, + "manageWorkspace": { + "title": "ワークスペースを管理", + "leaveWorkspace": "ワークスペースを退出", + "deleteWorkspace": "ワークスペースを削除" + } + }, + "manageDataPage": { + "menuLabel": "データ管理", + "title": "データ管理", + "description": "ローカルストレージデータの管理または既存のデータを@:appNameにインポートします。", + "dataStorage": { + "title": "ファイル保存場所", + "tooltip": "ファイルが保存されている場所", + "actions": { + "change": "パスを変更", + "open": "フォルダを開く", + "openTooltip": "現在のデータフォルダの場所を開く", + "copy": "パスをコピー", + "copiedHint": "パスをコピーしました!", + "resetTooltip": "デフォルトの場所にリセット" + }, + "resetDialog": { + "title": "本当にリセットしますか?", + "description": "パスをデフォルトのデータ保存場所にリセットしてもデータは削除されません。現在のデータを再インポートする場合、最初に現在の場所のパスをコピーしてください。" + } + }, + "importData": { + "title": "データのインポート", + "tooltip": "@:appNameのバックアップ/データフォルダからデータをインポート", + "description": "外部の@:appNameデータフォルダからデータをコピー", + "action": "ファイルを参照" + }, + "encryption": { + "title": "暗号化", + "tooltip": "データの保存と暗号化方法を管理", + "descriptionNoEncryption": "暗号化をオンにするとすべてのデータが暗号化されます。この操作は元に戻せません。", + "descriptionEncrypted": "データは暗号化されています。", + "action": "データを暗号化", + "dialog": { + "title": "すべてのデータを暗号化しますか?", + "description": "すべてのデータを暗号化すると、データは安全かつ保護されます。この操作は元に戻せません。本当に続行しますか?" + } + }, + "cache": { + "title": "キャッシュをクリア", + "description": "画像が読み込まれない、スペース内のページが見つからない、フォントが読み込まれないなどの問題を解決します。データには影響しません。", + "dialog": { + "title": "キャッシュをクリア", + "description": "画像が読み込まれない、スペース内のページが見つからない、フォントが読み込まれないなどの問題を解決します。データには影響しません。", + "successHint": "キャッシュをクリアしました!" + } + }, + "data": { + "fixYourData": "データを修復", + "fixButton": "修復", + "fixYourDataDescription": "データに問題がある場合、ここで修復を試みることができます。" + } + }, + "shortcutsPage": { + "menuLabel": "ショートカット", + "title": "ショートカット", + "editBindingHint": "新しいバインディングを入力", + "searchHint": "検索", + "actions": { + "resetDefault": "デフォルトにリセット" + }, + "errorPage": { + "message": "ショートカットの読み込みに失敗しました: {}", + "howToFix": "もう一度お試しください。問題が続く場合はGitHubでお問い合わせください。" + }, + "resetDialog": { + "title": "ショートカットをリセット", + "description": "これにより、すべてのキー設定がデフォルトにリセットされます。この操作は元に戻せません。続行してもよろしいですか?", + "buttonLabel": "リセット" + }, + "conflictDialog": { + "title": "{}は現在使用されています", + "descriptionPrefix": "このキー設定は現在 ", + "descriptionSuffix": " によって使用されています。このキー設定を置き換えると、{} から削除されます。", + "confirmLabel": "続行" + }, + "editTooltip": "キー設定の編集を開始するには押してください", + "keybindings": { + "toggleToDoList": "To-Doリストを切り替え", + "insertNewParagraphInCodeblock": "新しい段落を挿入", + "pasteInCodeblock": "コードブロックに貼り付け", + "selectAllCodeblock": "すべて選択", + "indentLineCodeblock": "行の先頭にスペースを2つ挿入", + "outdentLineCodeblock": "行の先頭のスペースを2つ削除", + "twoSpacesCursorCodeblock": "カーソル位置にスペースを2つ挿入", + "copy": "選択をコピー", + "paste": "コンテンツに貼り付け", + "cut": "選択をカット", + "alignLeft": "テキストを左揃え", + "alignCenter": "テキストを中央揃え", + "alignRight": "テキストを右揃え", + "undo": "元に戻す", + "redo": "やり直し", + "convertToParagraph": "ブロックを段落に変換", + "backspace": "削除", + "deleteLeftWord": "左の単語を削除", + "deleteLeftSentence": "左の文を削除", + "delete": "右の文字を削除", + "deleteMacOS": "左の文字を削除", + "deleteRightWord": "右の単語を削除", + "moveCursorLeft": "カーソルを左に移動", + "moveCursorBeginning": "カーソルを先頭に移動", + "moveCursorLeftWord": "カーソルを左に1単語移動", + "moveCursorLeftSelect": "選択してカーソルを左に移動", + "moveCursorBeginSelect": "選択してカーソルを先頭に移動", + "moveCursorLeftWordSelect": "選択してカーソルを左に1単語移動", + "moveCursorRight": "カーソルを右に移動", + "moveCursorEnd": "カーソルを末尾に移動", + "moveCursorRightWord": "カーソルを右に1単語移動", + "moveCursorRightSelect": "選択してカーソルを右に1つ移動", + "moveCursorEndSelect": "選択してカーソルを末尾に移動", + "moveCursorRightWordSelect": "選択してカーソルを右に1単語移動", + "moveCursorUp": "カーソルを上に移動", + "moveCursorTopSelect": "選択してカーソルを最上部に移動", + "moveCursorTop": "カーソルを最上部に移動", + "moveCursorUpSelect": "選択してカーソルを上に移動", + "moveCursorBottomSelect": "選択してカーソルを最下部に移動", + "moveCursorBottom": "カーソルを最下部に移動", + "moveCursorDown": "カーソルを下に移動", + "moveCursorDownSelect": "選択してカーソルを下に移動", + "home": "最上部にスクロール", + "end": "最下部にスクロール", + "toggleBold": "太字を切り替え", + "toggleItalic": "斜体を切り替え", + "toggleUnderline": "下線を切り替え", + "toggleStrikethrough": "取り消し線を切り替え", + "toggleCode": "インラインコードを切り替え", + "toggleHighlight": "ハイライトを切り替え", + "showLinkMenu": "リンクメニューを表示", + "openInlineLink": "インラインリンクを開く", + "openLinks": "選択されたすべてのリンクを開く", + "indent": "インデント", + "outdent": "アウトデント", + "exit": "編集を終了", + "pageUp": "1ページ上にスクロール", + "pageDown": "1ページ下にスクロール", + "selectAll": "すべて選択", + "pasteWithoutFormatting": "フォーマットなしで貼り付け", + "showEmojiPicker": "絵文字ピッカーを表示", + "enterInTableCell": "表に改行を追加", + "leftInTableCell": "表の左隣のセルに移動", + "rightInTableCell": "表の右隣のセルに移動", + "upInTableCell": "表の上隣のセルに移動", + "downInTableCell": "表の下隣のセルに移動", + "tabInTableCell": "表の次の利用可能なセルに移動", + "shiftTabInTableCell": "表の前の利用可能なセルに移動", + "backSpaceInTableCell": "セルの先頭で停止" + }, + "commands": { + "codeBlockNewParagraph": "コードブロックの隣に新しい段落を挿入", + "codeBlockIndentLines": "コードブロック内の行の先頭にスペースを2つ挿入", + "codeBlockOutdentLines": "コードブロック内の行の先頭のスペースを2つ削除", + "codeBlockAddTwoSpaces": "コードブロック内のカーソル位置にスペースを2つ挿入", + "codeBlockSelectAll": "コードブロック内のすべてのコンテンツを選択", + "codeBlockPasteText": "コードブロック内にテキストを貼り付け", + "textAlignLeft": "テキストを左揃え", + "textAlignCenter": "テキストを中央揃え", + "textAlignRight": "テキストを右揃え" + }, + "couldNotLoadErrorMsg": "ショートカットを読み込めませんでした。もう一度お試しください。", + "couldNotSaveErrorMsg": "ショートカットを保存できませんでした。もう一度お試しください。" + }, + "aiPage": { + "title": "AI設定", + "menuLabel": "AI設定", + "keys": { + "enableAISearchTitle": "AI検索", + "aiSettingsDescription": "AppFlowy AIを駆動するモデルを選択します。GPT 4-o、Claude 3.5、Llama 3.1、Mistral 7Bが含まれます。", + "loginToEnableAIFeature": "AI機能は@:appName Cloudにログインした後に有効になります。@:appNameアカウントがない場合は、「マイアカウント」でサインアップしてください。", + "llmModel": "言語モデル", + "llmModelType": "言語モデルのタイプ", + "downloadLLMPrompt": "{}をダウンロード", + "downloadAppFlowyOfflineAI": "AIオフラインパッケージをダウンロードすると、デバイス上でAIが動作します。続行しますか?", + "downloadLLMPromptDetail": "{}のローカルモデルをダウンロードすると、{}のストレージを使用します。続行しますか?", + "downloadBigFilePrompt": "ダウンロードが完了するまで約10分かかることがあります", + "downloadAIModelButton": "ダウンロード", + "downloadingModel": "ダウンロード中", + "localAILoaded": "ローカルAIモデルが正常に追加され、使用可能です", + "localAIStart": "ローカルAIチャットが開始しています...", + "localAILoading": "ローカルAIチャットモデルを読み込み中...", + "localAIStopped": "ローカルAIが停止しました", + "failToLoadLocalAI": "ローカルAIの起動に失敗しました", + "restartLocalAI": "ローカルAIを再起動", + "disableLocalAITitle": "ローカルAIを無効化", + "disableLocalAIDescription": "ローカルAIを無効にしますか?", + "localAIToggleTitle": "ローカルAIを有効化または無効化", + "offlineAIInstruction1": "次の手順に従って", + "offlineAIInstruction2": "指示", + "offlineAIInstruction3": "でオフラインAIを有効にします。", + "offlineAIDownload1": "AppFlowy AIをまだダウンロードしていない場合は、", + "offlineAIDownload2": "ダウンロード", + "offlineAIDownload3": "してください", + "activeOfflineAI": "アクティブ", + "downloadOfflineAI": "ダウンロード", + "openModelDirectory": "フォルダを開く" + } + }, + "planPage": { + "menuLabel": "プラン", + "title": "料金プラン", + "planUsage": { + "title": "プラン使用状況の概要", + "storageLabel": "ストレージ", + "storageUsage": "{} / {} GB", + "unlimitedStorageLabel": "無制限のストレージ", + "collaboratorsLabel": "メンバー", + "collaboratorsUsage": "{} / {}", + "aiResponseLabel": "AIレスポンス", + "aiResponseUsage": "{} / {}", + "unlimitedAILabel": "無制限のAIレスポンス", + "proBadge": "プロ", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "Mac用AIオンデバイス", + "memberProToggle": "メンバーを追加 & 無制限のAI", + "aiMaxToggle": "無制限のAI & 高度なモデルにアクセス", + "aiOnDeviceToggle": "究極のプライバシーのためのローカルAI", + "aiCredit": { + "title": "@:appName AIクレジットを追加", + "price": "{}", + "priceDescription": "1,000クレジットの価格", + "purchase": "AIを購入", + "info": "ワークスペースごとに1,000 AIクレジットを追加し、より賢く迅速な結果を得るためにAIをワークフローにシームレスに統合します。最大で:", + "infoItemOne": "データベースごとに10,000レスポンス", + "infoItemTwo": "ワークスペースごとに1,000レスポンス" + }, + "currentPlan": { + "bannerLabel": "現在のプラン", + "freeTitle": "無料", + "proTitle": "プロ", + "teamTitle": "チーム", + "freeInfo": "個人や2名までのメンバーに最適", + "proInfo": "小規模・中規模チームに最適(最大10名)", + "teamInfo": "生産的かつ整理されたチームに最適", + "upgrade": "プランを変更", + "canceledInfo": "プランはキャンセルされました。{}に無料プランにダウングレードされます。" + }, + "addons": { + "title": "アドオン", + "addLabel": "追加", + "activeLabel": "追加済み", + "aiMax": { + "title": "AI Max", + "description": "GPT-4o、Claude 3.5 Sonnetなどによる無制限のAIレスポンス", + "price": "{}", + "priceInfo": "1ユーザーあたり月額、年間請求" + }, + "aiOnDevice": { + "title": "Mac用AIオンデバイス", + "description": "Mistral 7B、LLAMA 3、その他のローカルモデルをマシンで実行", + "price": "{}", + "priceInfo": "1ユーザーあたり月額、年間請求", + "recommend": "M1以上を推奨" + } + }, + "deal": { + "bannerLabel": "新年キャンペーン!", + "title": "チームを成長させましょう!", + "info": "Proプランとチームプランを10%オフでアップグレード!@:appName AIを含む強力な新機能でワークスペースの生産性を向上させましょう。", + "viewPlans": "プランを表示" + } + } + }, + "billingPage": { + "menuLabel": "請求", + "title": "請求", + "plan": { + "title": "プラン", + "freeLabel": "無料", + "proLabel": "プロ", + "planButtonLabel": "プランを変更", + "billingPeriod": "請求期間", + "periodButtonLabel": "期間を編集" + }, + "paymentDetails": { + "title": "支払い詳細", + "methodLabel": "支払い方法", + "methodButtonLabel": "方法を編集" + }, + "addons": { + "title": "アドオン", + "addLabel": "追加", + "removeLabel": "削除", + "renewLabel": "更新", + "aiMax": { + "label": "AI Max", + "description": "無制限のAIと高度なモデルを解放", + "activeDescription": "次の請求日は{}", + "canceledDescription": "AI Maxは{}まで利用可能です" + }, + "aiOnDevice": { + "label": "Mac用AIオンデバイス", + "description": "デバイス上で無制限のAIを解放", + "activeDescription": "次の請求日は{}", + "canceledDescription": "Mac用AIオンデバイスは{}まで利用可能です" + }, + "removeDialog": { + "title": "{}を削除", + "description": "{plan}を削除してもよろしいですか?{plan}の機能と特典へのアクセスをすぐに失います。" + } + }, + "currentPeriodBadge": "現在の期間", + "changePeriod": "期間を変更", + "planPeriod": "{}期間", + "monthlyInterval": "毎月", + "monthlyPriceInfo": "毎月請求される座席あたりの料金", + "annualInterval": "年次", + "annualPriceInfo": "年間請求される座席あたりの料金" + }, + "comparePlanDialog": { + "title": "プランの比較と選択", + "planFeatures": "プラン\n機能", + "current": "現在のプラン", + "actions": { + "upgrade": "アップグレード", + "downgrade": "ダウングレード", + "current": "現在のプラン" + }, + "freePlan": { + "title": "無料", + "description": "個人や2名までのメンバーに最適", + "price": "{}", + "priceInfo": "永久に無料" + }, + "proPlan": { + "title": "プロ", + "description": "小規模チームのプロジェクト管理とチーム知識の管理に最適", + "price": "{}", + "priceInfo": "1ユーザーあたり月額 \n年間請求\n\n{} 毎月請求" + }, + "planLabels": { + "itemOne": "ワークスペース", + "itemTwo": "メンバー", + "itemThree": "ストレージ", + "itemFour": "リアルタイムコラボレーション", + "itemFive": "モバイルアプリ", + "itemSix": "AIレスポンス", + "itemFileUpload": "ファイルアップロード", + "customNamespace": "カスタム名前空間", + "tooltipSix": "ライフタイムはレスポンスの数がリセットされないことを意味します", + "intelligentSearch": "インテリジェント検索", + "tooltipSeven": "ワークスペースのURLの一部をカスタマイズできます", + "customNamespaceTooltip": "カスタム公開サイト URL" + }, + "freeLabels": { + "itemOne": "ワークスペースごとの料金", + "itemTwo": "最大2名", + "itemThree": "5 GB", + "itemFour": "はい", + "itemFive": "はい", + "itemSix": "10回のライフタイムレスポンス", + "itemFileUpload": "最大7 MB", + "intelligentSearch": "インテリジェント検索" + }, + "proLabels": { + "itemOne": "ワークスペースごとの料金", + "itemTwo": "最大10名", + "itemThree": "無制限", + "itemFour": "はい", + "itemFive": "はい", + "itemSix": "無制限", + "itemFileUpload": "無制限", + "intelligentSearch": "インテリジェント検索" + }, + "paymentSuccess": { + "title": "あなたは現在{}プランに加入しています!", + "description": "お支払いが正常に処理され、プランが@:appName {}にアップグレードされました。プランの詳細はプランページで確認できます。" + }, + "downgradeDialog": { + "title": "本当にプランをダウングレードしますか?", + "description": "プランをダウングレードすると、無料プランに戻ります。メンバーがこのワークスペースにアクセスできなくなり、無料プランのストレージ制限を満たすためにスペースを空ける必要がある場合があります。", + "downgradeLabel": "プランをダウングレード" + } + }, + "cancelSurveyDialog": { + "title": "ご利用いただきありがとうございました", + "description": "ご解約されるのは残念です。@:appNameを改善するために、お客様のフィードバックをお聞かせください。いくつかの質問にお答えいただけますと幸いです。", + "commonOther": "その他", + "otherHint": "ここに回答を入力してください", + "questionOne": { + "question": "@:appName Proサブスクリプションをキャンセルした理由は何ですか?", + "answerOne": "料金が高すぎる", + "answerTwo": "機能が期待に応えなかった", + "answerThree": "より良い代替案を見つけた", + "answerFour": "コストに見合うほど使用しなかった", + "answerFive": "サービスや技術的な問題があった" + }, + "questionTwo": { + "question": "将来的に@:appName Proに再加入する可能性はどのくらいですか?", + "answerOne": "非常に高い", + "answerTwo": "やや高い", + "answerThree": "わからない", + "answerFour": "低い", + "answerFive": "非常に低い" + }, + "questionThree": { + "question": "サブスクリプション中に最も価値を感じたPro機能は何ですか?", + "answerOne": "複数ユーザーのコラボレーション", + "answerTwo": "長期間のバージョン履歴", + "answerThree": "無制限のAIレスポンス", + "answerFour": "ローカルAIモデルへのアクセス" + }, + "questionFour": { + "question": "全体的な@:appNameの体験はどのようなものでしたか?", + "answerOne": "素晴らしい", + "answerTwo": "良い", + "answerThree": "普通", + "answerFour": "平均以下", + "answerFive": "満足していない" + } + }, + "common": { + "uploadingFile": "ファイルをアップロード中です。アプリを終了しないでください", + "uploadNotionSuccess": "Notion zipファイルが正常にアップロードされました。インポートが完了すると、確認メールが届きます。", + "reset": "リセット" + }, "menu": { "appearance": "外観", "language": "言語", "user": "ユーザー", "files": "ファイル", "notifications": "通知", - "open": "設定", + "open": "設定を開く", "logout": "ログアウト", "logoutPrompt": "本当にログアウトしますか?", - "syncSetting": "設定を同期", - "enableSync": "同期を有効可", - "enableEncrypt": "暗号化されたデータ", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "Supabase URL", - "cloudSupabaseUrlCanNotBeEmpty": "Supabase URLは空白にはできません", - "cloudSupabaseAnonKey": "Supabase anon key", - "cloudSupabaseAnonKeyCanNotBeEmpty": "Supabase anon keyは空白にはできません", - "cloudAppFlowy": "AppFlowy Cloud Beta", - "cloudAppFlowySelfHost": "AppFlowy Cloud セルフホスト", - "appFlowyCloudUrlCanNotBeEmpty": "クラウドURLは空白にはできません", + "selfEncryptionLogoutPrompt": "ログアウトしますか?暗号化キーをコピーしていることを確認してください。", + "syncSetting": "同期設定", + "cloudSettings": "クラウド設定", + "enableSync": "同期を有効化", + "enableSyncLog": "同期ログを有効にする", + "enableSyncLogWarning": "同期の問題の診断にご協力いただきありがとうございます。これにより、ドキュメントの編集内容がローカルファイルに記録されます。有効にした後、アプリを終了して再度開いてください。", + "enableEncrypt": "データを暗号化", + "cloudURL": "基本URL", + "webURL": "ウェブURL", + "invalidCloudURLScheme": "無効なスキーム", + "cloudServerType": "クラウドサーバー", + "cloudServerTypeTip": "クラウドサーバーを変更すると現在のアカウントがログアウトされる可能性があります。", + "cloudLocal": "ローカル", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName クラウドセルフホスト", + "appFlowyCloudUrlCanNotBeEmpty": "クラウドのURLを空にすることはできません", "clickToCopy": "クリックしてコピー", - "selfHostStart": "サーバーが準備できていない場合、", + "selfHostStart": "サーバーをお持ちでない場合は、", "selfHostContent": "ドキュメント", - "selfHostEnd": "を参照してセルフホストサーバーのセットアップ手順を確認してください", - "cloudURLHint": "サーバーのURLを入力", + "selfHostEnd": "を参照してセルフホストサーバーの設定方法をご確認ください", + "pleaseInputValidURL": "有効なURLを入力してください", + "changeUrl": "セルフホスト URL を {} に変更します", + "cloudURLHint": "サーバーの基本URLを入力してください", + "webURLHint": "ウェブサーバーのベースURLを入力してください", "cloudWSURL": "Websocket URL", - "cloudWSURLHint": "Websocketサーバーのアドレスを入力", + "cloudWSURLHint": "サーバーのWebsocketアドレスを入力してください", "restartApp": "再起動", - "clickToCopySecret": "クリックしてシークレットをコピー", - "historicalUserList": "ログイン履歴", - "supabaseSetting": "Supabaseの設定" + "restartAppTip": "変更を反映するにはアプリケーションを再起動してください。再起動すると現在のアカウントがログアウトされる可能性があります。", + "changeServerTip": "サーバーを変更後、変更を反映するには再起動ボタンをクリックしてください。", + "enableEncryptPrompt": "この秘密キーでデータを暗号化します。安全に保管してください。有効にすると解除できません。キーを紛失するとデータが回復不可能になります。コピーするにはクリックしてください。", + "inputEncryptPrompt": "暗号化キーを入力してください", + "clickToCopySecret": "クリックして秘密キーをコピー", + "configServerSetting": "サーバー設定を構成", + "configServerGuide": "「クイックスタート」を選択後、「設定」→「クラウド設定」に進み、セルフホストサーバーを構成します。", + "inputTextFieldHint": "秘密キー", + "historicalUserList": "ユーザーログイン履歴", + "historicalUserListTooltip": "このリストには匿名アカウントが表示されます。アカウントをクリックすると詳細が表示されます。匿名アカウントは「始める」ボタンをクリックすることで作成されます。", + "openHistoricalUser": "匿名アカウントを開くにはクリック", + "customPathPrompt": "Google Driveなどのクラウド同期フォルダに@:appNameデータフォルダを保存することはリスクを伴います。このフォルダ内のデータベースが複数の場所から同時にアクセスまたは変更されると、同期の競合やデータ破損が発生する可能性があります。", + "importAppFlowyData": "外部@:appNameフォルダからデータをインポート", + "importingAppFlowyDataTip": "データインポート中です。アプリを閉じないでください。", + "importAppFlowyDataDescription": "外部@:appNameデータフォルダからデータをコピーし、現在のAppFlowyデータフォルダにインポートします。", + "importSuccess": "@:appNameデータフォルダのインポートに成功しました", + "importFailed": "@:appNameデータフォルダのインポートに失敗しました", + "importGuide": "詳細については、参照されたドキュメントをご確認ください。" + }, + "notifications": { + "enableNotifications": { + "label": "通知を有効化", + "hint": "ローカル通知の表示を停止するにはオフにします。" + }, + "showNotificationsIcon": { + "label": "通知アイコンを表示", + "hint": "サイドバーに通知アイコンを表示するにはオンにします。" + }, + "archiveNotifications": { + "allSuccess": "すべての通知を正常にアーカイブしました", + "success": "通知を正常にアーカイブしました" + }, + "markAsReadNotifications": { + "allSuccess": "すべて既読にしました", + "success": "正常に既読にしました" + }, + "action": { + "markAsRead": "既読にする", + "multipleChoice": "複数選択", + "archive": "アーカイブ" + }, + "settings": { + "settings": "設定", + "markAllAsRead": "すべて既読にする", + "archiveAll": "すべてアーカイブ" + }, + "emptyInbox": { + "title": "インボックスゼロ!", + "description": "リマインダーを設定してここで通知を受け取ります。" + }, + "emptyUnread": { + "title": "未読の通知はありません", + "description": "すべてが完了しました!" + }, + "emptyArchived": { + "title": "アーカイブなし", + "description": "アーカイブされた通知がここに表示されます。" + }, + "tabs": { + "inbox": "受信箱", + "unread": "未読", + "archived": "アーカイブ" + }, + "refreshSuccess": "通知が正常に更新されました", + "titles": { + "notifications": "通知", + "reminder": "リマインダー" + } }, "appearance": { + "resetSetting": "リセット", "fontFamily": { "label": "フォントファミリー", - "search": "検索" + "search": "検索", + "defaultFont": "システム" }, "themeMode": { - "label": "外観テーマ", + "label": "テーマモード", "light": "ライトモード", "dark": "ダークモード", - "system": "システムと同期" + "system": "システムに適応" + }, + "fontScaleFactor": "フォントの拡大率", + "documentSettings": { + "cursorColor": "ドキュメントのカーソル色", + "selectionColor": "ドキュメントの選択色", + "width": "文書の幅", + "changeWidth": "変更", + "pickColor": "色を選択", + "colorShade": "色の濃淡", + "opacity": "不透明度", + "hexEmptyError": "Hexカラーコードは空にできません", + "hexLengthError": "Hex値は6桁である必要があります", + "hexInvalidError": "無効なHex値です", + "opacityEmptyError": "不透明度は空にできません", + "opacityRangeError": "不透明度は1から100の間である必要があります", + "app": "アプリ", + "flowy": "Flowy", + "apply": "適用" + }, + "layoutDirection": { + "label": "レイアウト方向", + "hint": "画面上のコンテンツの流れを、左から右、または右から左に制御します。", + "ltr": "LTR", + "rtl": "RTL" + }, + "textDirection": { + "label": "デフォルトのテキスト方向", + "hint": "テキストがデフォルトで左から始まるか右から始まるかを指定します。", + "ltr": "LTR", + "rtl": "RTL", + "auto": "自動", + "fallback": "レイアウト方向と同じ" }, "themeUpload": { "button": "アップロード", "uploadTheme": "テーマをアップロード", - "description": "下のボタンを使用して、独自のAppFlowyテーマをアップロードします。", - "loading": "テーマを検証してアップロードするまでお待ちください...", - "uploadSuccess": "テーマは正常にアップロードされました", - "deletionFailure": "テーマの削除に失敗しました。手動で削除してみてください。", - "filePickerDialogTitle": ".flowy_plugin ファイルを選択します", - "urlUploadFailure": "URLを開けませんでした: {}", - "failure": "アップロードされたテーマの形式が無効でした。" + "description": "下のボタンを使用して独自の@:appNameテーマをアップロードします。", + "loading": "テーマの検証とアップロード中です。しばらくお待ちください...", + "uploadSuccess": "テーマが正常にアップロードされました", + "deletionFailure": "テーマの削除に失敗しました。手動で削除をお試しください。", + "filePickerDialogTitle": ".flowy_pluginファイルを選択", + "urlUploadFailure": "URLのオープンに失敗しました: {}" }, "theme": "テーマ", "builtInsLabel": "組み込みテーマ", "pluginsLabel": "プラグイン", "dateFormat": { - "label": "日付フォーマット" + "label": "日付形式", + "local": "ローカル", + "us": "US", + "iso": "ISO", + "friendly": "読み易さ", + "dmy": "日/月/年" }, "timeFormat": { - "label": "時刻フォーマット", - "twelveHour": "12時間表記", - "twentyFourHour": "24時間表記" + "label": "時間形式", + "twelveHour": "12時間制", + "twentyFourHour": "24時間制" + }, + "showNamingDialogWhenCreatingPage": "ページ作成時に名前付けダイアログを表示", + "enableRTLToolbarItems": "RTLツールバー項目を有効にする", + "members": { + "title": "メンバー設定", + "inviteMembers": "メンバーを招待", + "inviteHint": "メールで招待", + "sendInvite": "招待を送信", + "copyInviteLink": "招待リンクをコピー", + "label": "メンバー", + "user": "ユーザー", + "role": "役割", + "removeFromWorkspace": "ワークスペースから削除", + "removeFromWorkspaceSuccess": "ワークスペースから正常に削除されました", + "removeFromWorkspaceFailed": "ワークスペースからの削除に失敗しました", + "owner": "オーナー", + "guest": "ゲスト", + "member": "メンバー", + "memberHintText": "メンバーはページを閲覧および編集できます", + "guestHintText": "ゲストはページを閲覧、反応、コメントし、許可があれば一部のページを編集できます。", + "emailInvalidError": "無効なメールアドレスです。確認してもう一度お試しください。", + "emailSent": "メールが送信されました。受信箱を確認してください。", + "members": "メンバー", + "membersCount": { + "zero": "{} 人のメンバー", + "one": "{} 人のメンバー", + "other": "{} 人のメンバー" + }, + "inviteFailedDialogTitle": "招待の送信に失敗しました", + "inviteFailedMemberLimit": "メンバー制限に達しました。メンバーを追加するにはアップグレードしてください。", + "inviteFailedMemberLimitMobile": "ワークスペースのメンバー制限に達しました。", + "memberLimitExceeded": "メンバー制限に達しました。さらにメンバーを招待するには ", + "memberLimitExceededUpgrade": "アップグレード", + "memberLimitExceededPro": "メンバー制限に達しました。さらにメンバーが必要な場合は ", + "memberLimitExceededProContact": "support@appflowy.io にご連絡ください", + "failedToAddMember": "メンバーの追加に失敗しました", + "addMemberSuccess": "メンバーが正常に追加されました", + "removeMember": "メンバーを削除", + "areYouSureToRemoveMember": "本当にこのメンバーを削除しますか?", + "inviteMemberSuccess": "招待が正常に送信されました", + "failedToInviteMember": "メンバーの招待に失敗しました", + "workspaceMembersError": "問題が発生しました", + "workspaceMembersErrorDescription": "現在、メンバーリストを読み込むことができません。後でもう一度お試しください" } }, "files": { "copy": "コピー", - "defaultLocation": "ファイルの読み取りとデータの保存場所", - "exportData": "データをエクスポートする", - "doubleTapToCopy": "ダブルタップしてパスをコピーします", - "restoreLocation": "AppFlowyのデフォルトパスに戻す", - "customizeLocation": "別のフォルダーを開く", - "restartApp": "変更を有効にするには、アプリを再起動してください。", - "exportDatabase": "データベースのエクスポート", - "selectFiles": "エクスポートする必要があるファイルを選択します", + "defaultLocation": "ファイルとデータ保存場所を読み取る", + "exportData": "データをエクスポート", + "doubleTapToCopy": "パスをコピーするにはダブルタップ", + "restoreLocation": "@:appName のデフォルトパスに復元", + "customizeLocation": "別のフォルダを開く", + "restartApp": "変更を反映するにはアプリを再起動してください。", + "exportDatabase": "データベースをエクスポート", + "selectFiles": "エクスポートするファイルを選択", "selectAll": "すべて選択", - "deselectAll": "すべての選択を解除", - "createNewFolder": "新しいフォルダーを作成する", - "createNewFolderDesc": "データの保存場所を教えてください", - "defineWhereYourDataIsStored": "データの保存場所を定義する", - "open": "開ける", - "openFolder": "既存のフォルダーを開く", - "openFolderDesc": "既存のAppFlowyフォルダに読み書きします", + "deselectAll": "すべて選択解除", + "createNewFolder": "新しいフォルダを作成", + "createNewFolderDesc": "データを保存する場所を指定してください", + "defineWhereYourDataIsStored": "データが保存される場所を設定", + "open": "開く", + "openFolder": "既存のフォルダを開く", + "openFolderDesc": "既存の@:appName フォルダに読み書き", "folderHintText": "フォルダ名", - "location": "新しいフォルダーの作成", - "locationDesc": "AppFlowyデータフォルダーの名前を選択します", - "browser": "ブラウズ", + "location": "新しいフォルダを作成", + "locationDesc": "@:appName データフォルダの名前を指定", + "browser": "参照", "create": "作成", "set": "設定", - "folderPath": "フォルダーを保存するパス", + "folderPath": "フォルダを保存するパス", "locationCannotBeEmpty": "パスを空にすることはできません", - "pathCopiedSnackbar": "ファイルの保存パスがクリップボードにコピーされました。", - "changeLocationTooltips": "データディレクトリを変更する", - "change": "変化", - "openLocationTooltips": "別のデータ ディレクトリを開く", + "pathCopiedSnackbar": "ファイル保存パスがクリップボードにコピーされました!", + "changeLocationTooltips": "データディレクトリを変更", + "change": "変更", + "openLocationTooltips": "別のデータディレクトリを開く", "openCurrentDataFolder": "現在のデータディレクトリを開く", - "recoverLocationTooltips": "AppFlowyのデフォルトのデータディレクトリにリセットします", - "exportFileSuccess": "ファイルのエクスポートに成功しました。", - "exportFileFail": "ファイルのエクスポートに失敗しました!", - "export": "書き出す" + "recoverLocationTooltips": "@:appName のデフォルトデータディレクトリにリセット", + "exportFileSuccess": "ファイルのエクスポートに成功しました!", + "exportFileFail": "ファイルのエクスポートに失敗しました!", + "export": "エクスポート", + "clearCache": "キャッシュをクリア", + "clearCacheDesc": "画像が読み込まれない、フォントが表示されないなどの問題がある場合は、キャッシュをクリアしてください。この操作はユーザーデータを削除しません。", + "areYouSureToClearCache": "キャッシュをクリアしますか?", + "clearCacheSuccess": "キャッシュが正常にクリアされました!" }, "user": { "name": "名前", - "selectAnIcon": "アイコンを選択してください", - "pleaseInputYourOpenAIKey": "OpenAI キーを入力してください" - }, - "shortcuts": { - "shortcutsLabel": "ショートカット", - "command": "コマンド", - "keyBinding": "キーバインディング", - "addNewCommand": "新しいコマンドを追加", - "resetToDefault": "キーバインディングをデフォルトに戻す" + "email": "メールアドレス", + "tooltipSelectIcon": "アイコンを選択", + "selectAnIcon": "アイコンを選択", + "pleaseInputYourOpenAIKey": "AIキーを入力してください", + "clickToLogout": "現在のユーザーをログアウトするにはクリック" }, "mobile": { + "personalInfo": "個人情報", "username": "ユーザー名", - "usernameEmptyError": "ユーザー名は空白にはできません", + "usernameEmptyError": "ユーザー名は空にできません", + "about": "概要", "pushNotifications": "プッシュ通知", "support": "サポート", + "joinDiscord": "Discordに参加", "privacyPolicy": "プライバシーポリシー", - "userAgreement": "ユーザー同意", - "termsAndConditions": "利用規約", + "userAgreement": "利用規約", + "termsAndConditions": "利用条件", + "userprofileError": "ユーザープロフィールの読み込みに失敗しました", + "userprofileErrorDescription": "ログアウトして再度ログインして、問題が解決するか確認してください。", + "selectLayout": "レイアウトを選択", + "selectStartingDay": "開始日を選択", "version": "バージョン" } }, "grid": { - "deleteView": "このビューを削除してもよろしいですか?", - "createView": "新しい", + "deleteView": "このビューを削除してもよろしいですか?", + "createView": "新規", "title": { - "placeholder": "Untitled" + "placeholder": "無題" }, "settings": { - "filter": "絞り込み", - "sort": "選別", - "sortBy": "並び替え", + "filter": "フィルター", + "sort": "並べ替え", + "sortBy": "並べ替え基準", "properties": "プロパティ", - "reorderPropertiesTooltip": "ドラッグしてプロパティを並べ替えます", + "reorderPropertiesTooltip": "プロパティをドラッグして並べ替え", "group": "グループ", - "addFilter": "フィルターの追加", - "deleteFilter": "フィルタの削除", + "addFilter": "フィルターを追加", + "deleteFilter": "フィルターを削除", "filterBy": "フィルター条件...", - "typeAValue": "値を入力してください...", + "typeAValue": "値を入力...", "layout": "レイアウト", "databaseLayout": "レイアウト", - "Properties": "プロパティ" + "viewList": { + "zero": "0 ビュー", + "one": "{count} ビュー", + "other": "{count} ビュー" + }, + "editView": "ビューを編集", + "boardSettings": "ボード設定", + "calendarSettings": "カレンダー設定", + "createView": "新しいビュー", + "duplicateView": "ビューを複製", + "deleteView": "ビューを削除", + "numberOfVisibleFields": "{} 表示中" + }, + "filter": { + "empty": "アクティブなフィルターはありません", + "addFilter": "フィルターを追加", + "cannotFindCreatableField": "フィルタリングに適したフィールドが見つかりません", + "conditon": "状態", + "where": "どこ" }, "textFilter": { - "contains": "含まれています", - "doesNotContain": "含まれていない", + "contains": "含む", + "doesNotContain": "含まない", "endsWith": "で終わる", "startWith": "で始まる", - "is": "は", - "isNot": "ではありません", - "isEmpty": "空である", - "isNotEmpty": "空ではない", + "is": "である", + "isNot": "でない", + "isEmpty": "空", + "isNotEmpty": "空でない", "choicechipPrefix": { - "isNot": "いいえ", + "isNot": "ではない", "startWith": "で始まる", "endWith": "で終わる", - "isEmpty": "空である", - "isNotEmpty": "空ではない" + "isEmpty": "空", + "isNotEmpty": "空でない" } }, "checkboxFilter": { "isChecked": "チェック済み", "isUnchecked": "未チェック", "choicechipPrefix": { - "is": "は" + "is": "である" } }, "checklistFilter": { @@ -413,184 +1394,537 @@ "isIncomplted": "未完了" }, "selectOptionFilter": { - "is": "等しい", - "isNot": "等しくない", - "contains": "を含む", - "doesNotContain": "を含まない", - "isEmpty": "空である", - "isNotEmpty": "空ではない" + "is": "である", + "isNot": "ではない", + "contains": "含む", + "doesNotContain": "含まない", + "isEmpty": "空", + "isNotEmpty": "空でない" + }, + "dateFilter": { + "is": "である", + "before": "以前", + "after": "以後", + "onOrBefore": "以前またはその日", + "onOrAfter": "以後またはその日", + "between": "の間", + "empty": "空", + "notEmpty": "空でない", + "startDate": "開始日", + "endDate": "終了日", + "choicechipPrefix": { + "before": "以前", + "after": "以後", + "between": "間", + "onOrBefore": "以前またはその日", + "onOrAfter": "以後またはその日", + "isEmpty": "空", + "isNotEmpty": "空でない" + } + }, + "numberFilter": { + "equal": "等しい", + "notEqual": "等しくない", + "lessThan": "より小さい", + "greaterThan": "より大きい", + "lessThanOrEqualTo": "以下", + "greaterThanOrEqualTo": "以上", + "isEmpty": "空", + "isNotEmpty": "空でない" }, "field": { - "hide": "隠す", + "label": "プロパティ", + "hide": "非表示", + "show": "表示", "insertLeft": "左に挿入", "insertRight": "右に挿入", - "duplicate": "コピーを作成", + "duplicate": "複製", "delete": "削除", + "wrapCellContent": "テキストを折り返し", + "clear": "セルをクリア", + "switchPrimaryFieldTooltip": "プライマリフィールドのフィールドタイプを変更できません", "textFieldName": "テキスト", "checkboxFieldName": "チェックボックス", "dateFieldName": "日付", - "updatedAtFieldName": "最終変更時刻", - "createdAtFieldName": "作成時間", - "numberFieldName": "数値", - "singleSelectFieldName": "単一選択", - "multiSelectFieldName": "複数選択", + "updatedAtFieldName": "最終更新日", + "createdAtFieldName": "作成日", + "numberFieldName": "数字", + "singleSelectFieldName": "選択", + "multiSelectFieldName": "マルチセレクト", "urlFieldName": "URL", "checklistFieldName": "チェックリスト", - "numberFormat": "数値書式", - "dateFormat": "日付書式", - "includeTime": "時刻を含める", + "relationFieldName": "リレーション", + "summaryFieldName": "AIサマリー", + "timeFieldName": "時間", + "mediaFieldName": "ファイルとメディア", + "translateFieldName": "AI翻訳", + "translateTo": "翻訳先", + "numberFormat": "数字の形式", + "dateFormat": "日付の形式", + "includeTime": "時間を含む", + "isRange": "終了日", "dateFormatFriendly": "月 日, 年", "dateFormatISO": "年-月-日", "dateFormatLocal": "月/日/年", "dateFormatUS": "年/月/日", - "dateFormatDayMonthYear": "日月年", - "timeFormat": "時刻書式", + "dateFormatDayMonthYear": "日/月/年", + "timeFormat": "時間形式", "invalidTimeFormat": "無効な形式", - "timeFormatTwelveHour": "12 時間表記", - "timeFormatTwentyFourHour": "24 時間表記", - "addSelectOption": "選択候補追加", - "optionTitle": "選択候補", - "addOption": "選択候補追加", - "editProperty": "プロパティの編集", + "timeFormatTwelveHour": "12時間制", + "timeFormatTwentyFourHour": "24時間制", + "clearDate": "日付をクリア", + "dateTime": "日時", + "startDateTime": "開始日時", + "endDateTime": "終了日時", + "failedToLoadDate": "日付の読み込みに失敗しました", + "selectTime": "時間を選択", + "selectDate": "日付を選択", + "visibility": "表示", + "propertyType": "プロパティの種類", + "addSelectOption": "オプションを追加", + "typeANewOption": "新しいオプションを入力", + "optionTitle": "オプション", + "addOption": "オプションを追加", + "editProperty": "プロパティを編集", "newProperty": "新しいプロパティ", - "deleteFieldPromptMessage": "本当にこのプロパティを削除してもよろしいですか?" + "openRowDocument": "ページとして開く", + "deleteFieldPromptMessage": "本当によろしいですか?このプロパティとそのすべてのデータが削除されます", + "clearFieldPromptMessage": "本当に空にしますか?この列のすべてのセルが空になります", + "newColumn": "新しい列", + "format": "形式", + "reminderOnDateTooltip": "このセルにはリマインダーが設定されています", + "optionAlreadyExist": "オプションはすでに存在します" + }, + "rowPage": { + "newField": "新しいフィールドを追加", + "fieldDragElementTooltip": "メニューを開くにはクリック", + "showHiddenFields": { + "one": "{count} 非表示フィールドを表示", + "many": "{count} 非表示フィールドを表示", + "other": "{count} 非表示フィールドを表示" + }, + "hideHiddenFields": { + "one": "{count} 非表示フィールドを隠す", + "many": "{count} 非表示フィールドを隠す", + "other": "{count} 非表示フィールドを隠す" + }, + "openAsFullPage": "全画面で開く", + "moreRowActions": "その他の行アクション" }, "sort": { "ascending": "昇順", "descending": "降順", - "addSort": "並べ替えの追加", - "deleteSort": "ソートの削除" + "by": "基準", + "empty": "アクティブな並べ替えがありません", + "cannotFindCreatableField": "並べ替え可能なフィールドが見つかりません", + "deleteAllSorts": "すべての並べ替えを削除", + "addSort": "新しい並べ替えを追加", + "sortsActive": "並べ替え中に{intention}できません", + "removeSorting": "並べ替えを削除しますか?", + "fieldInUse": "このフィールドですでに並べ替えが行われています" }, "row": { - "duplicate": "コピーを作成", + "label": "行", + "duplicate": "複製", "delete": "削除", - "textPlaceholder": "空白", - "copyProperty": "プロパティをクリップボードにコピーしました", + "titlePlaceholder": "無題", + "textPlaceholder": "空", + "copyProperty": "プロパティをクリップボードにコピー", "count": "カウント", "newRow": "新しい行", - "action": "アクション" + "loadMore": "さらに読み込む", + "action": "アクション", + "add": "下に追加をクリック", + "drag": "ドラッグして移動", + "deleteRowPrompt": "この行を削除してもよろしいですか?この操作は元に戻せません", + "deleteCardPrompt": "このカードを削除してもよろしいですか?この操作は元に戻せません", + "dragAndClick": "ドラッグして移動、クリックしてメニューを開く", + "insertRecordAbove": "上にレコードを挿入", + "insertRecordBelow": "下にレコードを挿入", + "noContent": "コンテンツなし", + "reorderRowDescription": "行を並べ替える", + "createRowAboveDescription": "上に行を作成", + "createRowBelowDescription": "下に行を挿入" }, "selectOption": { "create": "作成", - "purpleColor": "紫", + "purpleColor": "パープル", "pinkColor": "ピンク", "lightPinkColor": "ライトピンク", "orangeColor": "オレンジ", - "yellowColor": "黄色", + "yellowColor": "イエロー", "limeColor": "ライム", - "greenColor": "緑", - "aquaColor": "水色", - "blueColor": "青", - "deleteTag": "選択候補を削除", - "colorPanelTitle": "色", - "panelTitle": "選択候補を検索 または 作成する", - "searchOption": "選択候補を検索" + "greenColor": "グリーン", + "aquaColor": "アクア", + "blueColor": "ブルー", + "deleteTag": "タグを削除", + "colorPanelTitle": "カラー", + "panelTitle": "オプションを選択するか作成", + "searchOption": "オプションを検索", + "searchOrCreateOption": "オプションを検索するか作成", + "createNew": "新しいものを作成", + "orSelectOne": "またはオプションを選択", + "typeANewOption": "新しいオプションを入力", + "tagName": "タグ名" }, "checklist": { - "addNew": "アイテムを追加する" + "taskHint": "タスクの説明", + "addNew": "新しいタスクを追加", + "submitNewTask": "作成", + "hideComplete": "完了したタスクを非表示", + "showComplete": "すべてのタスクを表示" + }, + "url": { + "launch": "リンクをブラウザで開く", + "copy": "リンクをクリップボードにコピー", + "textFieldHint": "URLを入力" + }, + "relation": { + "relatedDatabasePlaceLabel": "関連データベース", + "relatedDatabasePlaceholder": "なし", + "inRelatedDatabase": "に", + "rowSearchTextFieldPlaceholder": "検索", + "noDatabaseSelected": "データベースが選択されていません。まず以下のリストから1つ選択してください:", + "emptySearchResult": "レコードが見つかりません", + "linkedRowListLabel": "{count} リンクされた行", + "unlinkedRowListLabel": "他の行をリンク" }, "menuName": "グリッド", - "referencedGridPrefix": "のビュー" + "referencedGridPrefix": "ビュー", + "calculate": "計算", + "calculationTypeLabel": { + "none": "なし", + "average": "平均", + "max": "最大", + "median": "中央値", + "min": "最小", + "sum": "合計", + "count": "カウント", + "countEmpty": "空のカウント", + "countEmptyShort": "空", + "countNonEmpty": "空でないカウント", + "countNonEmptyShort": "入力済み" + }, + "media": { + "rename": "名前を変更", + "download": "ダウンロード", + "expand": "拡大", + "delete": "削除", + "moreFilesHint": "+{} ファイル", + "addFileOrImage": "ファイル、画像、またはリンクを追加", + "attachmentsHint": "{} 添付ファイル", + "addFileMobile": "ファイルを追加", + "extraCount": "+{}", + "deleteFileDescription": "このファイルを削除してもよろしいですか? この操作は元に戻せません。", + "showFileNames": "ファイル名を表示", + "downloadSuccess": "ファイルをダウンロードしました", + "downloadFailedToken": "ファイルのダウンロードに失敗しました。ユーザー トークンが利用できません", + "setAsCover": "カバーとして設定", + "openInBrowser": "ブラウザで開く", + "embedLink": "ファイルリンクを埋め込む", + "open": "開く", + "showMore": "{} ファイルがさらにあります。クリックして表示" + } }, "document": { - "menuName": "書類", + "menuName": "ドキュメント", "date": { - "timeHintTextInTwelveHour": "午後1時", + "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, + "creating": "作成...", "slashMenu": { "board": { - "selectABoardToLinkTo": "リンク先のボードを選択してください", - "createANewBoard": "新しいボードを作成する" + "selectABoardToLinkTo": "リンクするボードを選択", + "createANewBoard": "新しいボードを作成" }, "grid": { - "selectAGridToLinkTo": "リンク先のグリッドを選択してください", - "createANewGrid": "新しいグリッドを作成する" + "selectAGridToLinkTo": "リンクするグリッドを選択", + "createANewGrid": "新しいグリッドを作成" }, "calendar": { - "selectACalendarToLinkTo": "リンク先のカレンダーを選択してください", - "createANewCalendar": "新しいカレンダーを作成する" + "selectACalendarToLinkTo": "リンクするカレンダーを選択", + "createANewCalendar": "新しいカレンダーを作成" + }, + "document": { + "selectADocumentToLinkTo": "リンクするドキュメントを選択" + }, + "name": { + "text": "テキスト", + "heading1": "見出し1", + "heading2": "見出し2", + "heading3": "見出し3", + "image": "画像", + "bulletedList": "箇条書きリスト", + "numberedList": "番号付きリスト", + "todoList": "To-do リスト", + "doc": "ドキュメント", + "linkedDoc": "ページへのリンク", + "grid": "グリッド", + "linkedGrid": "リンクされたグリッド", + "kanban": "カンバン", + "linkedKanban": "リンクされたカンバン", + "calendar": "カレンダー", + "linkedCalendar": "リンクされたカレンダー", + "quote": "引用", + "divider": "区切り線", + "table": "テーブル", + "callout": "呼び出し", + "outline": "アウトライン", + "mathEquation": "数式", + "code": "コード", + "toggleList": "折りたたみリスト", + "toggleHeading1": "見出し 1 を切り替える", + "toggleHeading2": "見出し 2 を切り替える", + "toggleHeading3": "見出し 3 を切り替える", + "emoji": "絵文字", + "aiWriter": "AIライター", + "dateOrReminder": "日付またはリマインダー", + "photoGallery": "フォトギャラリー", + "file": "ファイル" + }, + "subPage": { + "name": "書類", + "keyword1": "サブページ", + "keyword2": "ページ", + "keyword3": "子ページ", + "keyword4": "ページを挿入", + "keyword5": "埋め込みページ", + "keyword6": "新しいページ", + "keyword7": "ページを作成", + "keyword8": "書類" } }, "selectionMenu": { - "outline": "概要" + "outline": "アウトライン", + "codeBlock": "コードブロック" }, "plugins": { - "referencedBoard": "参照ボード", - "referencedGrid": "参照されるグリッド", - "referencedCalendar": "参照カレンダー", - "autoGeneratorMenuItemName": "OpenAI ライター", - "autoGeneratorTitleName": "OpenAI: AI に何でも書いてもらいます...", - "autoGeneratorLearnMore": "もっと詳しく知る", + "referencedBoard": "参照されたボード", + "referencedGrid": "参照されたグリッド", + "referencedCalendar": "参照されたカレンダー", + "referencedDocument": "参照されたドキュメント", + "autoGeneratorMenuItemName": "AIライター", + "autoGeneratorTitleName": "AI: 任意の文章をAIに依頼...", + "autoGeneratorLearnMore": "詳細を読む", "autoGeneratorGenerate": "生成", - "autoGeneratorHintText": "OpenAIに質問してください...", - "autoGeneratorCantGetOpenAIKey": "OpenAI キーを取得できません", - "autoGeneratorRewrite": "リライト", - "smartEdit": "AIアシスタント", - "openAI": "OpenAI", - "smartEditFixSpelling": "スペルを修正", - "warning": "⚠️ AI の応答は不正確または誤解を招く可能性があります。", - "smartEditSummarize": "要約する", - "smartEditImproveWriting": "ライティングを改善する", - "smartEditMakeLonger": "もっと長くする", - "smartEditCouldNotFetchResult": "OpenAIから結果を取得できませんでした", - "smartEditCouldNotFetchKey": "OpenAI キーを取得できませんでした", - "smartEditDisabled": "設定で OpenAI に接続する", - "discardResponse": "AI の応答を破棄しますか?", - "createInlineMathEquation": "方程式の作成", - "toggleList": "リストの切り替え", + "autoGeneratorHintText": "AIに質問 ...", + "autoGeneratorCantGetOpenAIKey": "AIキーを取得できません", + "autoGeneratorRewrite": "書き直し", + "smartEdit": "AIに依頼", + "aI": "AI", + "smartEditFixSpelling": "スペルと文法を修正", + "warning": "⚠️ AIの回答は不正確または誤解を招く可能性があります。", + "smartEditSummarize": "要約", + "smartEditImproveWriting": "文章を改善", + "smartEditMakeLonger": "長くする", + "smartEditCouldNotFetchResult": "AIから結果を取得できませんでした", + "smartEditCouldNotFetchKey": "AIキーを取得できませんでした", + "smartEditDisabled": "設定でAIを接続してください", + "appflowyAIEditDisabled": "AI機能を有効にするにはサインインしてください", + "discardResponse": "AIの回答を破棄しますか?", + "createInlineMathEquation": "数式を作成", + "fonts": "フォント", + "insertDate": "日付を挿入", + "emoji": "絵文字", + "toggleList": "折りたたみリスト", + "emptyToggleHeading": "トグル h{} が空です。クリックしてコンテンツを追加します。", + "emptyToggleList": "トグル リストが空です。クリックしてコンテンツを追加します。", + "emptyToggleHeadingWeb": "トグル h{level} が空です。クリックしてコンテンツを追加してください", + "quoteList": "引用リスト", + "numberedList": "番号付きリスト", + "bulletedList": "箇条書きリスト", + "todoList": "To-do リスト", + "callout": "呼び出し", + "simpleTable": { + "moreActions": { + "color": "色", + "align": "整列", + "delete": "消去", + "duplicate": "重複", + "insertLeft": "左に挿入", + "insertRight": "右に挿入", + "insertAbove": "上に挿入", + "insertBelow": "以下に挿入", + "headerColumn": "ヘッダー列", + "headerRow": "ヘッダー行", + "clearContents": "内容をクリア", + "setToPageWidth": "ページ幅に設定", + "distributeColumnsWidth": "列を均等に分散する", + "duplicateRow": "重複行", + "duplicateColumn": "重複した列", + "textColor": "テキストの色", + "cellBackgroundColor": "セルの背景色", + "duplicateTable": "重複テーブル" + }, + "clickToAddNewRow": "クリックして新しい行を追加します", + "clickToAddNewColumn": "クリックして新しい列を追加します", + "clickToAddNewRowAndColumn": "クリックして新しい行と列を追加します", + "headerName": { + "table": "テーブル", + "alignText": "テキストを揃える" + } + }, "cover": { - "changeCover": "表紙を変える", + "changeCover": "カバーを変更", "colors": "色", "images": "画像", "clearAll": "すべてクリア", - "abstract": "概要", - "addCover": "カバーを追加する", - "addLocalImage": "ローカルイメージを追加", - "invalidImageUrl": "無効な画像 URL", - "failedToAddImageToGallery": "画像をギャラリーに追加できませんでした", - "enterImageUrl": "画像のURLを入力してください", + "abstract": "抽象的", + "addCover": "カバーを追加", + "addLocalImage": "ローカル画像を追加", + "invalidImageUrl": "無効な画像URL", + "failedToAddImageToGallery": "ギャラリーに画像を追加できませんでした", + "enterImageUrl": "画像URLを入力", "add": "追加", "back": "戻る", "saveToGallery": "ギャラリーに保存", "removeIcon": "アイコンを削除", - "pasteImageUrl": "画像のURLを貼り付けます", - "or": "また", + "removeCover": "カバーを削除", + "pasteImageUrl": "画像URLを貼り付け", + "or": "または", "pickFromFiles": "ファイルから選択", "couldNotFetchImage": "画像を取得できませんでした", "imageSavingFailed": "画像の保存に失敗しました", "addIcon": "アイコンを追加", - "coverRemoveAlert": "削除後は表紙からも外されます。", - "alertDialogConfirmation": "続行しますか?" + "changeIcon": "アイコンを変更", + "coverRemoveAlert": "削除するとカバーからも削除されます。", + "alertDialogConfirmation": "本当に続けますか?" }, "mathEquation": { - "addMathEquation": "数式を追加", - "editMathEquation": "数式の編集" + "name": "数式", + "addMathEquation": "TeX数式を追加", + "editMathEquation": "数式を編集" }, "optionAction": { "click": "クリック", - "toOpenMenu": " メニューを開く", - "delete": "消去", + "toOpenMenu": " でメニューを開く", + "drag": "ドラッグ", + "toMove": " 移動する", + "delete": "削除", "duplicate": "複製", - "turnInto": "へ変更", + "turnInto": "変換", "moveUp": "上に移動", "moveDown": "下に移動", "color": "色", "align": "整列", "left": "左", - "center": "中心", + "center": "中央", "right": "右", - "defaultColor": "デフォルト" + "defaultColor": "デフォルト", + "depth": "深さ", + "copyLinkToBlock": "リンクをブロックにコピーする" }, "image": { - "copiedToPasteBoard": "画像リンクがクリップボードにコピーされました" + "addAnImage": "画像を追加", + "copiedToPasteBoard": "画像リンクがクリップボードにコピーされました", + "addAnImageDesktop": "画像を追加", + "addAnImageMobile": "クリックして画像を追加", + "dropImageToInsert": "挿入する画像をドロップ", + "imageUploadFailed": "画像のアップロードに失敗しました", + "imageDownloadFailed": "画像のダウンロードに失敗しました。もう一度お試しください", + "imageDownloadFailedToken": "ユーザートークンがないため、画像のアップロードに失敗しました。もう一度お試しください", + "errorCode": "エラーコード" + }, + "photoGallery": { + "name": "フォトギャラリー", + "imageKeyword": "画像", + "imageGalleryKeyword": "画像ギャラリー", + "photoKeyword": "写真", + "photoBrowserKeyword": "フォトブラウザ", + "galleryKeyword": "ギャラリー", + "addImageTooltip": "画像を追加", + "changeLayoutTooltip": "レイアウトを変更", + "browserLayout": "ブラウザ", + "gridLayout": "グリッド", + "deleteBlockTooltip": "ギャラリー全体を削除" + }, + "math": { + "copiedToPasteBoard": "数式がクリップボードにコピーされました" + }, + "urlPreview": { + "copiedToPasteBoard": "リンクがクリップボードにコピーされました", + "convertToLink": "埋め込みリンクに変換" }, "outline": { - "addHeadingToCreateOutline": "見出しを追加して目次を作成します。" - } + "addHeadingToCreateOutline": "目次を作成するには見出しを追加してください。", + "noMatchHeadings": "一致する見出しが見つかりませんでした。" + }, + "table": { + "addAfter": "後に追加", + "addBefore": "前に追加", + "delete": "削除", + "clear": "内容をクリア", + "duplicate": "複製", + "bgColor": "背景色" + }, + "contextMenu": { + "copy": "コピー", + "cut": "切り取り", + "paste": "貼り付け", + "pasteAsPlainText": "プレーンテキストとして貼り付け" + }, + "action": "アクション", + "database": { + "selectDataSource": "データソースを選択", + "noDataSource": "データソースなし", + "selectADataSource": "データソースを選択", + "toContinue": "続行する", + "newDatabase": "新しいデータベース", + "linkToDatabase": "データベースにリンク" + }, + "date": "日付", + "video": { + "label": "ビデオ", + "emptyLabel": "ビデオを追加", + "placeholder": "ビデオリンクを貼り付け", + "copiedToPasteBoard": "ビデオリンクがクリップボードにコピーされました", + "insertVideo": "ビデオを追加", + "invalidVideoUrl": "ソースURLはサポートされていません。", + "invalidVideoUrlYouTube": "YouTubeはまだサポートされていません。", + "supportedFormats": "サポートされている形式: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" + }, + "file": { + "name": "ファイル", + "uploadTab": "アップロード", + "uploadMobile": "ファイルを選択", + "uploadMobileGallery": "フォトギャラリーより", + "networkTab": "リンクを埋め込む", + "placeholderText": "ファイルをアップロードまたは埋め込む", + "placeholderDragging": "アップロードするファイルをドロップ", + "dropFileToUpload": "アップロードするファイルをドロップ", + "fileUploadHint": "ファイルをここにドロップ\nまたはクリックして参照", + "fileUploadHintSuffix": "ブラウズ", + "networkHint": "ファイルリンクを貼り付け", + "networkUrlInvalid": "無効なURLです。URLを修正してもう一度お試しください", + "networkAction": "ファイルリンクを埋め込む", + "fileTooBigError": "ファイルサイズが大きすぎます。10MB未満のファイルをアップロードしてください", + "renameFile": { + "title": "ファイルの名前を変更", + "description": "このファイルの新しい名前を入力してください", + "nameEmptyError": "ファイル名を空にすることはできません。" + }, + "uploadedAt": "アップロード日: {}", + "linkedAt": "リンク追加日: {}", + "failedToOpenMsg": "開けませんでした。ファイルが見つかりません" + }, + "subPage": { + "handlingPasteHint": " - (ペーストの取り扱い)", + "errors": { + "failedDeletePage": "ページの削除に失敗しました", + "failedCreatePage": "ページの作成に失敗しました", + "failedMovePage": "このドキュメントにページを移動できませんでした", + "failedDuplicatePage": "ページの複製に失敗しました", + "failedDuplicateFindView": "ページの複製に失敗しました - 元のビューが見つかりません" + } + }, + "cannotMoveToItsChildren": "子に移動できません" + }, + "outlineBlock": { + "placeholder": "目次" }, "textBlock": { - "placeholder": "コマンドには「/」を入力します" + "placeholder": "'/' を入力してコマンドを使用" }, "title": { "placeholder": "無題" @@ -603,87 +1937,1114 @@ }, "url": { "label": "画像URL", - "placeholder": "画像のURLを入力してください" + "placeholder": "画像URLを入力" }, - "support": "画像サイズ制限は5MBです。サポートされている形式: JPEG、PNG、GIF、SVG", + "ai": { + "label": "AIから画像を生成", + "placeholder": "AIに画像を生成させるプロンプトを入力してください" + }, + "stability_ai": { + "label": "Stability AIから画像を生成", + "placeholder": "Stability AIに画像を生成させるプロンプトを入力してください" + }, + "support": "画像サイズの上限は5MBです。サポートされている形式: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "無効な画像です", "invalidImageSize": "画像サイズは5MB未満である必要があります", - "invalidImageFormat": "画像形式はサポートされていません。サポートされている形式: JPEG、PNG、GIF、SVG", - "invalidImageUrl": "無効な画像 URL" + "invalidImageFormat": "サポートされていない画像形式です。サポートされている形式: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "無効な画像URLです", + "noImage": "ファイルまたはディレクトリが見つかりません", + "multipleImagesFailed": "1つ以上の画像のアップロードに失敗しました。再試行してください" + }, + "embedLink": { + "label": "リンクを埋め込む", + "placeholder": "画像リンクを貼り付けるか入力" + }, + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "画像を検索", + "pleaseInputYourOpenAIKey": "設定ページでAIキーを入力してください", + "saveImageToGallery": "画像をギャラリーに保存", + "failedToAddImageToGallery": "ギャラリーに画像を追加できませんでした", + "successToAddImageToGallery": "画像がギャラリーに正常に追加されました", + "unableToLoadImage": "画像を読み込めませんでした", + "maximumImageSize": "サポートされている画像の最大サイズは10MBです", + "uploadImageErrorImageSizeTooBig": "画像サイズは10MB未満である必要があります", + "imageIsUploading": "画像をアップロード中", + "openFullScreen": "全画面で開く", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "前の画像", + "nextImageTooltip": "次の画像", + "zoomOutTooltip": "ズームアウト", + "zoomInTooltip": "ズームイン", + "changeZoomLevelTooltip": "ズームレベルを変更", + "openLocalImage": "画像を開く", + "downloadImage": "画像をダウンロード", + "closeViewer": "インタラクティブビューアを閉じる", + "scalePercentage": "{}%", + "deleteImageTooltip": "画像を削除" + } } }, "codeBlock": { "language": { "label": "言語", - "placeholder": "言語を選択する" - } + "placeholder": "言語を選択", + "auto": "自動" + }, + "copyTooltip": "コピー", + "searchLanguageHint": "言語を検索", + "codeCopiedSnackbar": "コードがクリップボードにコピーされました!" }, "inlineLink": { - "placeholder": "リンクを貼り付けるか入力します", + "placeholder": "リンクを貼り付けるか入力", + "openInNewTab": "新しいタブで開く", + "copyLink": "リンクをコピー", + "removeLink": "リンクを削除", "url": { "label": "リンクURL", - "placeholder": "リンクURLを入力してください" + "placeholder": "リンクURLを入力" }, "title": { "label": "リンクタイトル", - "placeholder": "リンクタイトルを入力してください" + "placeholder": "リンクタイトルを入力" } + }, + "mention": { + "placeholder": "人物、ページ、日付をメンション...", + "page": { + "label": "ページへのリンク", + "tooltip": "クリックしてページを開く" + }, + "deleted": "削除済み", + "deletedContent": "このコンテンツは存在しないか、削除されました", + "noAccess": "アクセス権がありません", + "deletedPage": "削除されたページ", + "trashHint": " - ゴミ箱に入れる", + "morePages": "その他のページ" + }, + "toolbar": { + "resetToDefaultFont": "デフォルトに戻す" + }, + "errorBlock": { + "theBlockIsNotSupported": "ブロックコンテンツを解析できません", + "clickToCopyTheBlockContent": "クリックしてブロックコンテンツをコピー", + "blockContentHasBeenCopied": "ブロックコンテンツがコピーされました。", + "parseError": "{}ブロックの解析中にエラーが発生しました。", + "copyBlockContent": "ブロックコンテンツをコピー" + }, + "mobilePageSelector": { + "title": "ページを選択", + "failedToLoad": "ページリストの読み込みに失敗しました", + "noPagesFound": "ページが見つかりません" + }, + "attachmentMenu": { + "choosePhoto": "写真を選択", + "takePicture": "写真を撮る", + "chooseFile": "ファイルを選択" } }, "board": { "column": { - "createNewCard": "新しい" + "label": "カラム", + "createNewCard": "新規", + "renameGroupTooltip": "押してグループ名を変更", + "createNewColumn": "新しいグループを追加", + "addToColumnTopTooltip": "上に新しいカードを追加", + "addToColumnBottomTooltip": "下に新しいカードを追加", + "renameColumn": "名前を変更", + "hideColumn": "非表示", + "newGroup": "新しいグループ", + "deleteColumn": "削除", + "deleteColumnConfirmation": "このグループとその中のすべてのカードが削除されます。\n続行してもよろしいですか?" }, + "hiddenGroupSection": { + "sectionTitle": "非表示のグループ", + "collapseTooltip": "非表示グループを隠す", + "expandTooltip": "非表示グループを表示" + }, + "cardDetail": "カードの詳細", + "cardActions": "カードアクション", + "cardDuplicated": "カードが複製されました", + "cardDeleted": "カードが削除されました", + "showOnCard": "カードの詳細に表示", + "setting": "設定", + "propertyName": "プロパティ名", "menuName": "ボード", - "referencedBoardPrefix": "のビュー", + "showUngrouped": "グループ化されていない項目を表示", + "ungroupedButtonText": "グループ化されていない", + "ungroupedButtonTooltip": "どのグループにも属していないカードが含まれています", + "ungroupedItemsTitle": "クリックしてボードに追加", + "groupBy": "グループ化", + "groupCondition": "グループ条件", + "referencedBoardPrefix": "表示元", + "notesTooltip": "内部のメモ", "mobile": { + "editURL": "URLを編集", "showGroup": "グループを表示", - "showGroupContent": "このグループをボードに表示してもよろしいですか?", + "showGroupContent": "このグループをボード上に表示してもよろしいですか?", "failedToLoad": "ボードビューの読み込みに失敗しました" + }, + "dateCondition": { + "weekOf": "{}週 - {}", + "today": "今日", + "yesterday": "昨日", + "tomorrow": "明日", + "lastSevenDays": "過去7日間", + "nextSevenDays": "次の7日間", + "lastThirtyDays": "過去30日間", + "nextThirtyDays": "次の30日間" + }, + "noGroup": "プロパティによるグループ化なし", + "noGroupDesc": "ボードビューを表示するには、グループ化するプロパティが必要です", + "media": { + "cardText": "{} {}", + "fallbackName": "ファイル" } }, "calendar": { "menuName": "カレンダー", "defaultNewCalendarTitle": "無題", + "newEventButtonTooltip": "新しいイベントを追加", "navigation": { "today": "今日", "jumpToday": "今日にジャンプ", - "previousMonth": "前月", - "nextMonth": "来月" + "previousMonth": "前の月", + "nextMonth": "次の月", + "views": { + "day": "日", + "week": "週", + "month": "月", + "year": "年" + } + }, + "mobileEventScreen": { + "emptyTitle": "まだイベントがありません", + "emptyBody": "プラスボタンを押してこの日にイベントを作成してください。" }, "settings": { - "showWeekNumbers": "週番号を表示する", - "showWeekends": "週末を表示する", + "showWeekNumbers": "週番号を表示", + "showWeekends": "週末を表示", "firstDayOfWeek": "週の開始日", - "layoutDateField": "レイアウトカレンダー", + "layoutDateField": "カレンダーのレイアウト", + "changeLayoutDateField": "レイアウトフィールドを変更", "noDateTitle": "日付なし", - "clickToAdd": "クリックしてカレンダーに追加します", - "name": "カレンダーのレイアウト", - "noDateHint": "予定外のイベントがここに表示されます" + "noDateHint": { + "zero": "予定されていないイベントがここに表示されます", + "one": "{count} 件の予定されていないイベント", + "other": "{count} 件の予定されていないイベント" + }, + "unscheduledEventsTitle": "予定されていないイベント", + "clickToAdd": "クリックしてカレンダーに追加", + "name": "カレンダー設定", + "clickToOpen": "クリックしてレコードを開く" }, - "referencedCalendarPrefix": "のビュー" + "referencedCalendarPrefix": "表示元", + "quickJumpYear": "ジャンプ", + "duplicateEvent": "イベントを複製" }, "errorDialog": { - "title": "AppFlowyエラー", - "howToFixFallback": "ご不便をおかけして申し訳ございません。エラーを説明した issue を GitHub ページに送信してください。", - "github": "GitHub で見る" + "title": "@:appName エラー", + "howToFixFallback": "ご不便をおかけして申し訳ありません!エラー内容をGitHubページに報告してください。", + "howToFixFallbackHint1": "ご不便をおかけして申し訳ありません!エラー内容を報告するには、", + "howToFixFallbackHint2": "ページにアクセスしてください。", + "github": "GitHubで表示" }, "search": { "label": "検索", + "sidebarSearchIcon": "検索してページに素早く移動", "placeholder": { - "actions": "検索アクション..." + "actions": "アクションを検索..." } }, "message": { "copy": { - "success": "コピーしました!", - "fail": "コピーできません" + "success": "コピー完了!", + "fail": "コピーに失敗しました" } }, - "unSupportBlock": "現在のバージョンではこのブロックはサポートされていません。", + "unSupportBlock": "このバージョンではこのブロックはサポートされていません。", "views": { - "deleteContentTitle": "{pageType} を削除してもよろしいですか?", - "deleteContentCaption": "この {pageType} を削除しても、ゴミ箱から復元できます。" + "deleteContentTitle": "{pageType}を削除してもよろしいですか?", + "deleteContentCaption": "この{pageType}を削除すると、ゴミ箱から復元できます。" + }, + "colors": { + "custom": "カスタム", + "default": "デフォルト", + "red": "赤", + "orange": "オレンジ", + "yellow": "黄色", + "green": "緑", + "blue": "青", + "purple": "紫", + "pink": "ピンク", + "brown": "茶色", + "gray": "灰色" + }, + "emoji": { + "emojiTab": "絵文字", + "search": "絵文字を検索", + "noRecent": "最近使用された絵文字なし", + "noEmojiFound": "絵文字が見つかりません", + "filter": "フィルター", + "random": "ランダム", + "selectSkinTone": "スキントーンを選択", + "remove": "絵文字を削除", + "categories": { + "smileys": "スマイリー&感情", + "people": "人々", + "animals": "自然", + "food": "食べ物", + "activities": "アクティビティ", + "places": "場所", + "objects": "オブジェクト", + "symbols": "記号", + "flags": "旗", + "nature": "自然", + "frequentlyUsed": "よく使われる" + }, + "skinTone": { + "default": "デフォルト", + "light": "明るい", + "mediumLight": "やや明るい", + "medium": "普通", + "mediumDark": "やや暗い", + "dark": "暗い" + }, + "openSourceIconsFrom": "オープンソースのアイコン提供元" + }, + "inlineActions": { + "noResults": "結果なし", + "recentPages": "最近のページ", + "pageReference": "ページ参照", + "docReference": "ドキュメント参照", + "boardReference": "ボード参照", + "calReference": "カレンダー参照", + "gridReference": "グリッド参照", + "date": "日付", + "reminder": { + "groupTitle": "リマインダー", + "shortKeyword": "リマインド" + }, + "createPage": "\"{}\" サブページを作成" + }, + "datePicker": { + "dateTimeFormatTooltip": "設定で日付と時刻の形式を変更", + "dateFormat": "日付形式", + "includeTime": "時刻を含む", + "isRange": "終了日", + "timeFormat": "時刻形式", + "clearDate": "日付をクリア", + "reminderLabel": "リマインダー", + "selectReminder": "リマインダーを選択", + "reminderOptions": { + "none": "なし", + "atTimeOfEvent": "イベント時", + "fiveMinsBefore": "5分前", + "tenMinsBefore": "10分前", + "fifteenMinsBefore": "15分前", + "thirtyMinsBefore": "30分前", + "oneHourBefore": "1時間前", + "twoHoursBefore": "2時間前", + "onDayOfEvent": "イベント当日", + "oneDayBefore": "1日前", + "twoDaysBefore": "2日前", + "oneWeekBefore": "1週間前", + "custom": "カスタム" + } + }, + "relativeDates": { + "yesterday": "昨日", + "today": "今日", + "tomorrow": "明日", + "oneWeek": "1週間" + }, + "notificationHub": { + "title": "通知", + "mobile": { + "title": "更新" + }, + "emptyTitle": "すべて完了!", + "emptyBody": "保留中の通知やアクションはありません。", + "tabs": { + "inbox": "受信箱", + "upcoming": "今後" + }, + "actions": { + "markAllRead": "すべて既読にする", + "showAll": "すべて表示", + "showUnreads": "未読のみ表示" + }, + "filters": { + "ascending": "昇順", + "descending": "降順", + "groupByDate": "日付でグループ化", + "showUnreadsOnly": "未読のみ表示", + "resetToDefault": "デフォルトにリセット" + } + }, + "reminderNotification": { + "title": "リマインダー", + "message": "忘れないうちに確認してください!", + "tooltipDelete": "削除", + "tooltipMarkRead": "既読にする", + "tooltipMarkUnread": "未読にする" + }, + "findAndReplace": { + "find": "検索", + "previousMatch": "前の一致", + "nextMatch": "次の一致", + "close": "閉じる", + "replace": "置換", + "replaceAll": "すべて置換", + "noResult": "結果なし", + "caseSensitive": "大文字と小文字を区別", + "searchMore": "さらに検索して結果を探す" + }, + "error": { + "weAreSorry": "申し訳ありません", + "loadingViewError": "このビューの読み込みに問題があります。インターネット接続を確認し、アプリを更新してください。問題が続く場合は、チームにお問い合わせください。", + "syncError": "別のデバイスからデータが同期されていません", + "syncErrorHint": "最後に編集したデバイスでこのページを再度開き、次に現在のデバイスで再度開いてください。", + "clickToCopy": "クリックしてエラーコードをコピー" + }, + "editor": { + "bold": "太字", + "bulletedList": "箇条書き", + "bulletedListShortForm": "箇条書き", + "checkbox": "チェックボックス", + "embedCode": "コードを埋め込む", + "heading1": "見出し1", + "heading2": "見出し2", + "heading3": "見出し3", + "highlight": "ハイライト", + "color": "色", + "image": "画像", + "date": "日付", + "page": "ページ", + "italic": "斜体", + "link": "リンク", + "numberedList": "番号付きリスト", + "numberedListShortForm": "番号付き", + "toggleHeading1ShortForm": "h1 トグル", + "toggleHeading2ShortForm": "h2 トグル", + "toggleHeading3ShortForm": "h3 トグル", + "quote": "引用", + "strikethrough": "取り消し線", + "text": "テキスト", + "underline": "下線", + "fontColorDefault": "デフォルト", + "fontColorGray": "灰色", + "fontColorBrown": "茶色", + "fontColorOrange": "オレンジ", + "fontColorYellow": "黄色", + "fontColorGreen": "緑", + "fontColorBlue": "青", + "fontColorPurple": "紫", + "fontColorPink": "ピンク", + "fontColorRed": "赤", + "backgroundColorDefault": "デフォルト背景", + "backgroundColorGray": "灰色背景", + "backgroundColorBrown": "茶色背景", + "backgroundColorOrange": "オレンジ背景", + "backgroundColorYellow": "黄色背景", + "backgroundColorGreen": "緑背景", + "backgroundColorBlue": "青背景", + "backgroundColorPurple": "紫背景", + "backgroundColorPink": "ピンク背景", + "backgroundColorRed": "赤背景", + "backgroundColorLime": "ライム背景", + "backgroundColorAqua": "アクア背景", + "done": "完了", + "cancel": "キャンセル", + "tint1": "ティント1", + "tint2": "ティント2", + "tint3": "ティント3", + "tint4": "ティント4", + "tint5": "ティント5", + "tint6": "ティント6", + "tint7": "ティント7", + "tint8": "ティント8", + "tint9": "ティント9", + "lightLightTint1": "紫", + "lightLightTint2": "ピンク", + "lightLightTint3": "ライトピンク", + "lightLightTint4": "オレンジ", + "lightLightTint5": "黄色", + "lightLightTint6": "ライム", + "lightLightTint7": "緑", + "lightLightTint8": "アクア", + "lightLightTint9": "青", + "urlHint": "URL", + "mobileHeading1": "見出し1", + "mobileHeading2": "見出し2", + "mobileHeading3": "見出し3", + "mobileHeading4": "見出し4", + "mobileHeading5": "見出し5", + "mobileHeading6": "見出し6", + "textColor": "テキスト色", + "backgroundColor": "背景色", + "addYourLink": "リンクを追加", + "openLink": "リンクを開く", + "copyLink": "リンクをコピー", + "removeLink": "リンクを削除", + "editLink": "リンクを編集", + "linkText": "テキスト", + "linkTextHint": "テキストを入力してください", + "linkAddressHint": "URLを入力してください", + "highlightColor": "ハイライト色", + "clearHighlightColor": "ハイライト色をクリア", + "customColor": "カスタムカラー", + "hexValue": "16進数値", + "opacity": "不透明度", + "resetToDefaultColor": "デフォルト色にリセット", + "ltr": "左から右", + "rtl": "右から左", + "auto": "自動", + "cut": "切り取り", + "copy": "コピー", + "paste": "貼り付け", + "find": "検索", + "select": "選択", + "selectAll": "すべて選択", + "previousMatch": "前の一致", + "nextMatch": "次の一致", + "closeFind": "閉じる", + "replace": "置換", + "replaceAll": "すべて置換", + "regex": "正規表現", + "caseSensitive": "大文字小文字を区別", + "uploadImage": "画像をアップロード", + "urlImage": "URL画像", + "incorrectLink": "不正なリンク", + "upload": "アップロード", + "chooseImage": "画像を選択", + "loading": "読み込み中", + "imageLoadFailed": "画像の読み込みに失敗しました", + "divider": "区切り線", + "table": "テーブル", + "colAddBefore": "前に追加", + "rowAddBefore": "前に追加", + "colAddAfter": "後に追加", + "rowAddAfter": "後に追加", + "colRemove": "削除", + "rowRemove": "削除", + "colDuplicate": "複製", + "rowDuplicate": "複製", + "colClear": "内容をクリア", + "rowClear": "内容をクリア", + "slashPlaceHolder": "'/'を入力してブロックを挿入、または入力を開始", + "typeSomething": "何かを入力...", + "toggleListShortForm": "トグル", + "quoteListShortForm": "引用", + "mathEquationShortForm": "数式", + "codeBlockShortForm": "コード" + }, + "favorite": { + "noFavorite": "お気に入りページがありません", + "noFavoriteHintText": "ページを左にスワイプして、お気に入りに追加します", + "removeFromSidebar": "サイドバーから削除", + "addToSidebar": "サイドバーにピン留め" + }, + "cardDetails": { + "notesPlaceholder": "ブロックを挿入するには「/」を入力、または入力を開始" + }, + "blockPlaceholders": { + "todoList": "To-do", + "bulletList": "リスト", + "numberList": "リスト", + "quote": "引用", + "heading": "見出し {}" + }, + "titleBar": { + "pageIcon": "ページアイコン", + "language": "言語", + "font": "フォント", + "actions": "アクション", + "date": "日付", + "addField": "フィールドを追加", + "userIcon": "ユーザーアイコン" + }, + "noLogFiles": "ログファイルがありません", + "newSettings": { + "myAccount": { + "title": "アカウント", + "subtitle": "プロフィールのカスタマイズ、アカウントセキュリティ管理、AIキーの設定、アカウントへのログイン管理。", + "profileLabel": "アカウント名とプロフィール画像", + "profileNamePlaceholder": "名前を入力", + "accountSecurity": "アカウントセキュリティ", + "2FA": "2段階認証", + "aiKeys": "AIキー", + "accountLogin": "アカウントログイン", + "updateNameError": "名前の更新に失敗しました", + "updateIconError": "アイコンの更新に失敗しました", + "deleteAccount": { + "title": "アカウント削除", + "subtitle": "アカウントとすべてのデータを完全に削除します。", + "description": "アカウントを削除し、すべてのワークスペースへのアクセスを削除します。", + "deleteMyAccount": "アカウントを削除", + "dialogTitle": "アカウント削除", + "dialogContent1": "アカウントを完全に削除してよろしいですか?", + "dialogContent2": "この操作は元に戻せません。すべてのワークスペースへのアクセスが削除され、アカウント全体とプライベートワークスペースを含むすべてが削除されます。", + "confirmHint1": "確認のために「DELETE MY ACCOUNT」と入力してください。", + "confirmHint2": "この操作が元に戻せないことを理解しました。", + "confirmHint3": "DELETE MY ACCOUNT", + "checkToConfirmError": "削除の確認チェックボックスを選択してください", + "failedToGetCurrentUser": "現在のユーザーの取得に失敗しました", + "confirmTextValidationFailed": "確認テキストが「DELETE MY ACCOUNT」と一致しません", + "deleteAccountSuccess": "アカウントが正常に削除されました" + } + }, + "workplace": { + "name": "ワークプレース", + "title": "ワークプレース設定", + "subtitle": "ワークスペースの外観、テーマ、フォント、テキストレイアウト、日付、時刻、言語をカスタマイズします。", + "workplaceName": "ワークプレース名", + "workplaceNamePlaceholder": "ワークプレース名を入力", + "workplaceIcon": "ワークプレースアイコン", + "workplaceIconSubtitle": "画像をアップロードするか、絵文字を使用してワークスペースを表現します。アイコンはサイドバーや通知で表示されます。", + "renameError": "ワークプレースの名前変更に失敗しました", + "updateIconError": "アイコンの更新に失敗しました", + "chooseAnIcon": "アイコンを選択", + "appearance": { + "name": "外観", + "themeMode": { + "auto": "自動", + "light": "ライト", + "dark": "ダーク" + }, + "language": "言語" + } + }, + "syncState": { + "syncing": "同期中", + "synced": "同期済み", + "noNetworkConnected": "ネットワークに接続されていません" + } + }, + "pageStyle": { + "title": "ページスタイル", + "layout": "レイアウト", + "coverImage": "カバー画像", + "pageIcon": "ページアイコン", + "colors": "色", + "gradient": "グラデーション", + "backgroundImage": "背景画像", + "presets": "プリセット", + "photo": "写真", + "unsplash": "Unsplash", + "pageCover": "ページカバー", + "none": "なし", + "openSettings": "設定を開く", + "photoPermissionTitle": "@:appName はフォトライブラリへのアクセスを希望します", + "photoPermissionDescription": "@:appName に写真へのアクセス権が必要です。写真をドキュメントに追加するには許可が必要です。", + "cameraPermissionTitle": "@:appNameカメラにアクセスしようとしています", + "cameraPermissionDescription": "カメラからドキュメントに画像を追加するには、 @:appNameにカメラへのアクセスを許可する必要があります", + "doNotAllow": "許可しない", + "image": "画像" + }, + "commandPalette": { + "placeholder": "検索ワードを入力...", + "bestMatches": "最適な候補", + "recentHistory": "最近の履歴", + "navigateHint": "移動するには", + "loadingTooltip": "結果を検索中...", + "betaLabel": "ベータ", + "betaTooltip": "現在はページやドキュメントの内容のみの検索をサポートしています", + "fromTrashHint": "ゴミ箱から", + "noResultsHint": "目的の結果が見つかりませんでした。別のキーワードで検索してください。", + "clearSearchTooltip": "検索フィールドをクリア" + }, + "space": { + "delete": "削除", + "deleteConfirmation": "削除: ", + "deleteConfirmationDescription": "このスペース内のすべてのページが削除され、ゴミ箱に移動されます。公開されたページは公開が取り消されます。", + "rename": "スペースの名前変更", + "changeIcon": "アイコンを変更", + "manage": "スペースの管理", + "addNewSpace": "スペースを作成", + "collapseAllSubPages": "すべてのサブページを折りたたむ", + "createNewSpace": "新しいスペースを作成", + "createSpaceDescription": "複数のパブリックおよびプライベートスペースを作成し、作業を整理しましょう。", + "spaceName": "スペース名", + "spaceNamePlaceholder": "例: マーケティング、エンジニアリング、HR", + "permission": "権限", + "publicPermission": "パブリック", + "publicPermissionDescription": "フルアクセスを持つすべてのワークスペースメンバー", + "privatePermission": "プライベート", + "privatePermissionDescription": "あなたのみがこのスペースにアクセス可能", + "spaceIconBackground": "アイコンの背景色", + "spaceIcon": "アイコン", + "dangerZone": "危険ゾーン", + "unableToDeleteLastSpace": "最後のスペースは削除できません", + "unableToDeleteSpaceNotCreatedByYou": "他のユーザーが作成したスペースは削除できません", + "enableSpacesForYourWorkspace": "ワークスペースでスペースを有効にする", + "title": "スペース", + "defaultSpaceName": "全般", + "upgradeSpaceTitle": "スペースを有効にする", + "upgradeSpaceDescription": "複数のパブリックおよびプライベートスペースを作成し、ワークスペースを整理しましょう。", + "upgrade": "アップグレード", + "upgradeYourSpace": "複数のスペースを作成", + "quicklySwitch": "次のスペースに素早く切り替える", + "duplicate": "スペースを複製", + "movePageToSpace": "ページをスペースに移動", + "cannotMovePageToDatabase": "ページをデータベースに移動できません", + "switchSpace": "スペースを切り替え", + "spaceNameCannotBeEmpty": "スペース名は空にできません", + "success": { + "deleteSpace": "スペースが正常に削除されました", + "renameSpace": "スペース名の変更に成功しました", + "duplicateSpace": "スペースが正常に複製されました", + "updateSpace": "スペースが正常に更新されました" + }, + "error": { + "deleteSpace": "スペースを削除できませんでした", + "renameSpace": "スペースの名前を変更できませんでした", + "duplicateSpace": "スペースの複製に失敗しました", + "updateSpace": "スペースの更新に失敗しました" + }, + "createSpace": "スペースを作る", + "manageSpace": "スペースを管理する", + "renameSpace": "スペースの名前を変更する", + "mSpaceIconColor": "スペースアイコンの色", + "mSpaceIcon": "スペースアイコン" + }, + "publish": { + "hasNotBeenPublished": "このページはまだ公開されていません", + "spaceHasNotBeenPublished": "スペースの公開はまだサポートされていません", + "reportPage": "ページを報告", + "databaseHasNotBeenPublished": "データベースの公開はまだサポートされていません。", + "createdWith": "作成元", + "downloadApp": "AppFlowy をダウンロード", + "copy": { + "codeBlock": "コードブロックの内容がクリップボードにコピーされました", + "imageBlock": "画像リンクがクリップボードにコピーされました", + "mathBlock": "数式がクリップボードにコピーされました", + "fileBlock": "ファイルリンクがクリップボードにコピーされました" + }, + "containsPublishedPage": "このページには公開済みのページが含まれています。続行すると公開が解除されます。削除してもよろしいですか?", + "publishSuccessfully": "正常に公開されました", + "unpublishSuccessfully": "正常に非公開にされました", + "publishFailed": "公開に失敗しました", + "unpublishFailed": "非公開に失敗しました", + "noAccessToVisit": "このページへのアクセス権がありません...", + "createWithAppFlowy": "AppFlowy でウェブサイトを作成", + "fastWithAI": "AIを使って迅速かつ簡単に。", + "tryItNow": "今すぐ試してみる", + "onlyGridViewCanBePublished": "グリッドビューのみが公開可能です", + "database": { + "zero": "{} 選択したビューを公開", + "one": "{} 選択したビューを公開", + "many": "{} 選択したビューを公開", + "other": "{} 選択したビューを公開" + }, + "mustSelectPrimaryDatabase": "プライマリビューを選択する必要があります", + "noDatabaseSelected": "少なくとも1つのデータベースを選択してください。", + "unableToDeselectPrimaryDatabase": "プライマリデータベースの選択を解除できません", + "saveThisPage": "このテンプレートを使用", + "duplicateTitle": "どこに追加しますか?", + "selectWorkspace": "ワークスペースを選択", + "addTo": "追加", + "duplicateSuccessfully": "ワークスペースに追加されました", + "duplicateSuccessfullyDescription": "AppFlowy がインストールされていませんか? 「ダウンロード」をクリックすると、自動的にダウンロードが開始されます。", + "downloadIt": "ダウンロード", + "openApp": "アプリで開く", + "duplicateFailed": "複製に失敗しました", + "membersCount": { + "zero": "メンバーなし", + "one": "1人のメンバー", + "many": "{count}人のメンバー", + "other": "{count}人のメンバー" + }, + "useThisTemplate": "このテンプレートを使用" + }, + "web": { + "continue": "続ける", + "or": "または", + "continueWithGoogle": "Googleで続ける", + "continueWithGithub": "GitHubで続ける", + "continueWithDiscord": "Discordで続ける", + "continueWithApple": "Appleで続ける", + "moreOptions": "他のオプション", + "collapse": "折りたたむ", + "signInAgreement": "「続ける」をクリックすると、AppFlowyの", + "and": "および", + "termOfUse": "利用規約", + "privacyPolicy": "プライバシーポリシー", + "signInError": "サインインエラー", + "login": "サインアップまたはログイン", + "fileBlock": { + "uploadedAt": "{time}にアップロード", + "linkedAt": "{time}にリンク追加", + "empty": "ファイルをアップロードまたは埋め込み", + "uploadFailed": "アップロードに失敗しました。もう一度お試しください。", + "retry": "リトライ" + }, + "importNotion": "Notionからインポート", + "import": "輸入", + "importSuccess": "アップロードに成功しました", + "importSuccessMessage": "インポートが完了すると通知されます。その後、インポートしたページをサイドバーで表示できます。", + "importFailed": "インポートに失敗しました。ファイル形式を確認してください", + "dropNotionFile": "Notionのzipファイルをここにドロップしてアップロードするか、クリックして参照してください", + "error": { + "pageNameIsEmpty": "ページ名が空です。別の名前を試してください" + } + }, + "globalComment": { + "comments": "コメント", + "addComment": "コメントを追加", + "reactedBy": "リアクションした人", + "addReaction": "リアクションを追加", + "reactedByMore": "他 {count} 人", + "showSeconds": { + "one": "1秒前", + "other": "{count}秒前", + "zero": "たった今", + "many": "{count}秒前" + }, + "showMinutes": { + "one": "1分前", + "other": "{count}分前", + "many": "{count}分前" + }, + "showHours": { + "one": "1時間前", + "other": "{count}時間前", + "many": "{count}時間前" + }, + "showDays": { + "one": "1日前", + "other": "{count}日前", + "many": "{count}日前" + }, + "showMonths": { + "one": "1ヶ月前", + "other": "{count}ヶ月前", + "many": "{count}ヶ月前" + }, + "showYears": { + "one": "1年前", + "other": "{count}年前", + "many": "{count}年前" + }, + "reply": "返信", + "deleteComment": "コメントを削除", + "youAreNotOwner": "このコメントの所有者ではありません", + "confirmDeleteDescription": "このコメントを削除してもよろしいですか?", + "hasBeenDeleted": "削除済み", + "replyingTo": "返信先", + "noAccessDeleteComment": "このコメントを削除する権限がありません", + "collapse": "折りたたむ", + "readMore": "続きを読む", + "failedToAddComment": "コメントの追加に失敗しました...", + "commentAddedSuccessfully": "コメントが正常に追加されました。", + "commentAddedSuccessTip": "コメントまたは返信を追加しました。最新のコメントを確認するためにトップに移動しますか?" + }, + "template": { + "asTemplate": "テンプレートとして保存", + "name": "テンプレート名", + "description": "テンプレートの説明", + "about": "テンプレートについて", + "preview": "テンプレートのプレビュー", + "categories": "テンプレートのカテゴリー", + "isNewTemplate": "新しいテンプレートとしてピン留め", + "featured": "おすすめとしてピン留め", + "relatedTemplates": "関連テンプレート", + "requiredField": "{field}は必須です", + "addCategory": "「{category}」を追加", + "addNewCategory": "新しいカテゴリーを追加", + "addNewCreator": "新しい作成者を追加", + "deleteCategory": "カテゴリーを削除", + "editCategory": "カテゴリーを編集", + "editCreator": "作成者を編集", + "category": { + "name": "カテゴリー名", + "icon": "カテゴリーアイコン", + "bgColor": "カテゴリー背景色", + "priority": "カテゴリーの優先度", + "desc": "カテゴリーの説明", + "type": "カテゴリーの種類", + "icons": "カテゴリーアイコン", + "colors": "カテゴリーの色", + "byUseCase": "ユースケース別", + "byFeature": "機能別", + "deleteCategory": "カテゴリーを削除", + "deleteCategoryDescription": "このカテゴリーを削除してもよろしいですか?", + "typeToSearch": "カテゴリーを検索..." + }, + "creator": { + "label": "テンプレート作成者", + "name": "作成者名", + "avatar": "作成者のアバター", + "accountLinks": "作成者のアカウントリンク", + "uploadAvatar": "クリックしてアバターをアップロード", + "deleteCreator": "作成者を削除", + "deleteCreatorDescription": "この作成者を削除してもよろしいですか?", + "typeToSearch": "作成者を検索..." + }, + "uploadSuccess": "テンプレートが正常にアップロードされました。", + "uploadSuccessDescription": "テンプレートが正常にアップロードされました。テンプレートギャラリーで確認できます。", + "viewTemplate": "テンプレートを表示", + "deleteTemplate": "テンプレートを削除", + "deleteSuccess": "テンプレートが正常に削除されました", + "deleteTemplateDescription": "このテンプレートを削除してもよろしいですか?", + "addRelatedTemplate": "関連テンプレートを追加", + "removeRelatedTemplate": "関連テンプレートを削除", + "uploadAvatar": "アバターをアップロード", + "searchInCategory": "{category}で検索", + "label": "テンプレート" + }, + "fileDropzone": { + "dropFile": "クリックまたはドラッグしてファイルをアップロード", + "uploading": "アップロード中...", + "uploadFailed": "アップロードに失敗しました", + "uploadSuccess": "アップロード成功", + "uploadSuccessDescription": "ファイルが正常にアップロードされました", + "uploadFailedDescription": "ファイルのアップロードに失敗しました", + "uploadingDescription": "ファイルをアップロード中" + }, + "gallery": { + "preview": "全画面表示で開く", + "copy": "コピー", + "download": "ダウンロード", + "prev": "前へ", + "next": "次へ", + "resetZoom": "ズームをリセット", + "zoomIn": "ズームイン", + "zoomOut": "ズームアウト" + }, + "invitation": { + "join": "参加する", + "on": "の上", + "invitedBy": "招待者", + "membersCount": { + "zero": "{count} 人のメンバー", + "one": "{count} 人のメンバー", + "many": "{count} 人のメンバー", + "other": "{count} 人のメンバー" + }, + "tip": "以下の連絡先情報を使用して、このワークスペースに参加するよう招待されました。これが間違っている場合は、管理者に連絡して招待を再送信してください。", + "joinWorkspace": "ワークスペースに参加", + "success": "ワークスペースへの参加が完了しました", + "successMessage": "これで、その中のすべてのページとワークスペースにアクセスできるようになります。", + "openWorkspace": "AppFlowyを開く", + "alreadyAccepted": "すでに招待を承諾しています", + "errorModal": { + "title": "問題が発生しました", + "description": "現在のアカウント {email} ではこのワークスペースにアクセスできない可能性があります。正しいアカウントでログインするか、ワークスペースの所有者に問い合わせてください。", + "contactOwner": "所有者に連絡する", + "close": "家に戻る", + "changeAccount": "アカウントを変更する" + } + }, + "requestAccess": { + "title": "このページにアクセスできません", + "subtitle": "このページの所有者にアクセスをリクエストできます。承認されると、ページを表示できるようになります。", + "requestAccess": "アクセスをリクエスト", + "backToHome": "homeに戻る", + "tip": "現在ログインしているのは。", + "mightBe": "別のアカウントでのログインが必要になるかもしれません。", + "successful": "リクエストは正常に送信されました", + "successfulMessage": "所有者がリクエストを承認すると通知されます。", + "requestError": "アクセスをリクエストできませんでした", + "repeatRequestError": "このページへのアクセスはすでにリクエストされています" + }, + "approveAccess": { + "title": "ワークスペース参加リクエストを承認", + "requestSummary": "参加リクエストアクセス", + "upgrade": "アップグレード", + "downloadApp": "AppFlowyをダウンロード", + "approveButton": "承認する", + "approveSuccess": "承認されました", + "approveError": "承認に失敗しました。ワークスペース プランの制限を超えていないことを確認してください。", + "getRequestInfoError": "リクエスト情報を取得できませんでした", + "memberCount": { + "zero": "メンバーなし", + "one": "メンバー 1 人", + "many": "{count} 人のメンバー", + "other": "{count} 人のメンバー" + }, + "alreadyProTitle": "ワークスペースプランの制限に達しました", + "alreadyProMessage": "連絡を取るよう依頼するより多くのメンバーのロックを解除する", + "repeatApproveError": "このリクエストはすでに承認されています", + "ensurePlanLimit": "ワークスペースプランの制限を超えていないことを確認してください。制限を超えた場合は、ワークスペースプランまたは。", + "requestToJoin": "参加をリクエストされた", + "asMember": "メンバーとして" + }, + "upgradePlanModal": { + "title": "プロにアップグレード", + "message": "{name} は無料メンバーの上限に達しました。より多くのメンバーを招待するには、Pro プランにアップグレードしてください。", + "upgradeSteps": "AppFlowyでプランをアップグレードする方法:", + "step1": "1.設定へ移動", + "step2": "2. 「プラン」をクリック", + "step3": "3. 「プランの変更」を選択します", + "appNote": "注記: ", + "actionButton": "アップグレード", + "downloadLink": "アプリをダウンロード", + "laterButton": "後で", + "refreshNote": "アップグレードが成功したら、新しい機能を有効にします。", + "refresh": "ここ" + }, + "breadcrumbs": { + "label": "パンくず" + }, + "time": { + "justNow": "たった今", + "seconds": { + "one": "1秒", + "other": "{count} 秒" + }, + "minutes": { + "one": "1分", + "other": "{count} 分" + }, + "hours": { + "one": "1時間", + "other": "{count} 時間" + }, + "days": { + "one": "1日", + "other": "{count} 日" + }, + "weeks": { + "one": "1週間", + "other": "{count} 週間" + }, + "months": { + "one": "1ヶ月", + "other": "{count} ヶ月" + }, + "years": { + "one": "1年", + "other": "{count} 年" + }, + "ago": "以前", + "yesterday": "昨日", + "today": "今日" + }, + "members": { + "zero": "メンバーなし", + "one": "メンバー 1 人", + "many": "{count} 人のメンバー", + "other": "{count} 人のメンバー" + }, + "tabMenu": { + "close": "閉じる", + "closeDisabledHint": "固定されたタブを閉じることはできません。まず固定を解除してください", + "closeOthers": "他のタブを閉じる", + "closeOthersHint": "これにより、このタブを除くすべての固定されていないタブが閉じられます", + "closeOthersDisabledHint": "すべてのタブが固定されており、閉じるタブが見つかりません", + "favorite": "お気に入り", + "unfavorite": "お気に入りを解除", + "favoriteDisabledHint": "このビューをお気に入りに登録できません", + "pinTab": "ピン", + "unpinTab": "ピンを外す" + }, + "openFileMessage": { + "success": "ファイルは正常に開かれました", + "fileNotFound": "ファイルが見つかりません", + "noAppToOpenFile": "このファイルを開くアプリはありません", + "permissionDenied": "このファイルを開く権限がありません", + "unknownError": "ファイルのオープンに失敗しました" + }, + "inviteMember": { + "requestInviteMembers": "ワークスペースに招待する", + "inviteFailedMemberLimit": "メンバーの上限に達しました。 ", + "upgrade": "アップグレード", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "招待状を送る", + "inviteAlready": "このメールアドレスはすでに招待されています: {email}", + "inviteSuccess": "招待状は正常に送信されました", + "description": "以下にカンマで区切ってメールアドレスを入力してください。料金はメンバー数に基づいて決まります。", + "emails": "メール" + }, + "quickNote": { + "label": "クイックノート", + "quickNotes": "クイックノート", + "search": "クイックノートを検索", + "collapseFullView": "全画面表示を折りたたむ", + "expandFullView": "全画面表示を拡大", + "createFailed": "クイックノートの作成に失敗しました", + "quickNotesEmpty": "クイックノートなし", + "emptyNote": "空のメモ", + "deleteNotePrompt": "選択したメモは完全に削除されます。削除してもよろしいですか?", + "addNote": "新しいメモ", + "noAdditionalText": "追加テキストなし" + }, + "subscribe": { + "upgradePlanTitle": "プランを比較して選択", + "yearly": "年間", + "save": "{discount}% 節約", + "monthly": "月次", + "priceIn": "価格", + "free": "無料", + "pro": "プロ", + "freeDescription": "個人で最大2人までがすべてを管理", + "proDescription": "小規模なチームがプロジェクトとチームの知識を管理する", + "proDuration": { + "monthly": "会員あたり月額\n毎月請求", + "yearly": "メンバーあたり月額\n毎年請求" + }, + "cancel": "ダウングレード", + "changePlan": "プロプランにアップグレード", + "everythingInFree": "すべて無料+", + "currentPlan": "現在", + "freeDuration": "永遠", + "freePoints": { + "first": "最大 2 人のメンバーが参加できる 1 つの共同ワークスペース", + "second": "ページとブロック無制限", + "three": "5 GBのストレージ", + "four": "インテリジェント検索", + "five": "20件のAI回答", + "six": "モバイルアプリ", + "seven": "リアルタイムコラボレーション" + }, + "proPoints": { + "first": "無制限のストレージ", + "second": "ワークスペースメンバー最大10人", + "three": "無制限のAI応答", + "four": "無制限のファイルアップロード", + "five": "カスタム名前空間" + }, + "cancelPlan": { + "title": "去ってしまうのは残念です", + "success": "サブスクリプションは正常にキャンセルされました", + "description": "ご利用いただけなくなるのは残念です。AppFlowy の改善に役立てていただけるよう、皆様のフィードバックをお待ちしています。お時間をいただき、いくつかの質問にお答えください。", + "commonOther": "他", + "otherHint": "ここに答えを書いてください", + "questionOne": { + "question": "AppFlowy Pro サブスクリプションをキャンセルした理由は何ですか?", + "answerOne": "コストが高すぎる", + "answerTwo": "機能が期待に応えられなかった", + "answerThree": "より良い代替案を見つけた", + "answerFour": "費用に見合うほど使用しなかった", + "answerFive": "サービスの問題または技術的な問題" + }, + "questionTwo": { + "question": "将来的にAppFlowy Proへの再加入を検討する可能性はどのくらいありますか?", + "answerOne": "非常に可能性が高い", + "answerTwo": "可能性が高い", + "answerThree": "わからない", + "answerFour": "可能性が低い", + "answerFive": "非常に可能性が低い" + }, + "questionThree": { + "question": "サブスクリプション期間中に最も価値を感じた Pro 機能はどれですか?", + "answerOne": "複数ユーザーコラボレーション", + "answerTwo": "長期間のバージョン履歴", + "answerThree": "無制限のAI応答", + "answerFour": "ローカルAIモデルへのアクセス" + }, + "questionFour": { + "question": "AppFlowy の全体的な使用感と満足感をどう思いますか?", + "answerOne": "秀", + "answerTwo": "優", + "answerThree": "良", + "answerFour": "可", + "answerFive": "不可" + } + } } } diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index 81f7be0185..1246b65f30 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -1,356 +1,1525 @@ { "appName": "AppFlowy", - "defaultUsername": "Me", - "welcomeText": "@:appName 에 오신것을 환영합니다", - "githubStarText": "Star on GitHub", + "defaultUsername": "나", + "welcomeText": "@:appName에 오신 것을 환영합니다", + "welcomeTo": "환영합니다", + "githubStarText": "GitHub에서 별표", "subscribeNewsletterText": "뉴스레터 구독", - "letsGoButtonText": "Let's Go", + "letsGoButtonText": "빠른 시작", "title": "제목", - "youCanAlso": "당신은 또한 수", + "youCanAlso": "또한 할 수 있습니다", "and": "그리고", + "failedToOpenUrl": "URL을 열지 못했습니다: {}", "blockActions": { "addBelowTooltip": "아래에 추가하려면 클릭", "addAboveCmd": "Alt+클릭", "addAboveMacCmd": "Option+클릭", - "addAboveTooltip": "위에 추가" + "addAboveTooltip": "위에 추가하려면", + "dragTooltip": "이동하려면 드래그", + "openMenuTooltip": "메뉴를 열려면 클릭" }, "signUp": { - "buttonText": "회원가입", - "title": "@:appName 에 회원가입", + "buttonText": "가입하기", + "title": "@:appName에 가입하기", "getStartedText": "시작하기", - "emptyPasswordError": "비밀번호는 공백일 수 없습니다", - "repeatPasswordEmptyError": "비밀번호 재입력란은 공백일 수 없습니다", - "unmatchedPasswordError": "재입력 하신 비밀번호가 같지 않습니다", + "emptyPasswordError": "비밀번호는 비워둘 수 없습니다", + "repeatPasswordEmptyError": "비밀번호 확인은 비워둘 수 없습니다", + "unmatchedPasswordError": "비밀번호 확인이 비밀번호와 일치하지 않습니다", "alreadyHaveAnAccount": "이미 계정이 있으신가요?", "emailHint": "이메일", "passwordHint": "비밀번호", - "repeatPasswordHint": "비밀번호 재입력" + "repeatPasswordHint": "비밀번호 확인", + "signUpWith": "다음으로 가입:" }, "signIn": { - "loginTitle": "@:appName 에 로그인", + "loginTitle": "@:appName에 로그인", "loginButtonText": "로그인", + "loginStartWithAnonymous": "익명 세션으로 계속", + "continueAnonymousUser": "익명 세션으로 계속", + "anonymous": "익명", "buttonText": "로그인", + "signingInText": "로그인 중...", "forgotPassword": "비밀번호를 잊으셨나요?", "emailHint": "이메일", "passwordHint": "비밀번호", "dontHaveAnAccount": "계정이 없으신가요?", - "repeatPasswordEmptyError": "비밀번호 재입력란은 공백일 수 없습니다", - "unmatchedPasswordError": "재입력 하신 비밀번호가 같지 않습니다", - "loginAsGuestButtonText": "시작하다" + "createAccount": "계정 만들기", + "repeatPasswordEmptyError": "비밀번호 확인은 비워둘 수 없습니다", + "unmatchedPasswordError": "비밀번호 확인이 비밀번호와 일치하지 않습니다", + "syncPromptMessage": "데이터 동기화에는 시간이 걸릴 수 있습니다. 이 페이지를 닫지 마세요", + "or": "또는", + "signInWithGoogle": "Google로 계속", + "signInWithGithub": "GitHub로 계속", + "signInWithDiscord": "Discord로 계속", + "signInWithApple": "Apple로 계속", + "continueAnotherWay": "다른 방법으로 계속", + "signUpWithGoogle": "Google로 가입", + "signUpWithGithub": "GitHub로 가입", + "signUpWithDiscord": "Discord로 가입", + "signInWith": "다음으로 계속:", + "signInWithEmail": "이메일로 계속", + "signInWithMagicLink": "계속", + "signUpWithMagicLink": "Magic Link로 가입", + "pleaseInputYourEmail": "이메일 주소를 입력하세요", + "settings": "설정", + "magicLinkSent": "Magic Link가 전송되었습니다!", + "invalidEmail": "유효한 이메일 주소를 입력하세요", + "alreadyHaveAnAccount": "이미 계정이 있으신가요?", + "logIn": "로그인", + "generalError": "문제가 발생했습니다. 나중에 다시 시도하세요", + "limitRateError": "보안상의 이유로, 매 60초마다 한 번씩만 Magic Link를 요청할 수 있습니다", + "magicLinkSentDescription": "Magic Link가 이메일로 전송되었습니다. 링크를 클릭하여 로그인을 완료하세요. 링크는 5분 후에 만료됩니다." }, "workspace": { - "create": "워크스페이스 생성", - "hint": "워크스페이스", - "notFoundError": "워크스페이스를 찾을 수 없습니다" + "chooseWorkspace": "작업 공간 선택", + "defaultName": "내 작업 공간", + "create": "작업 공간 생성", + "new": "새 작업 공간", + "importFromNotion": "Notion에서 가져오기", + "learnMore": "자세히 알아보기", + "reset": "작업 공간 재설정", + "renameWorkspace": "작업 공간 이름 변경", + "workspaceNameCannotBeEmpty": "작업 공간 이름은 비워둘 수 없습니다", + "resetWorkspacePrompt": "작업 공간을 재설정하면 모든 페이지와 데이터가 삭제됩니다. 작업 공간을 재설정하시겠습니까? 또는 지원 팀에 문의하여 작업 공간을 복원할 수 있습니다", + "hint": "작업 공간", + "notFoundError": "작업 공간을 찾을 수 없습니다", + "failedToLoad": "문제가 발생했습니다! 작업 공간을 로드하지 못했습니다. @:appName의 모든 열린 인스턴스를 닫고 다시 시도하세요.", + "errorActions": { + "reportIssue": "문제 보고", + "reportIssueOnGithub": "GitHub에서 문제 보고", + "exportLogFiles": "로그 파일 내보내기", + "reachOut": "Discord에서 문의" + }, + "menuTitle": "작업 공간", + "deleteWorkspaceHintText": "작업 공간을 삭제하시겠습니까? 이 작업은 되돌릴 수 없으며, 게시한 모든 페이지가 게시 취소됩니다.", + "createSuccess": "작업 공간이 성공적으로 생성되었습니다", + "createFailed": "작업 공간 생성 실패", + "createLimitExceeded": "계정에 허용된 최대 작업 공간 수에 도달했습니다. 추가 작업 공간이 필요하면 GitHub에 요청하세요", + "deleteSuccess": "작업 공간이 성공적으로 삭제되었습니다", + "deleteFailed": "작업 공간 삭제 실패", + "openSuccess": "작업 공간이 성공적으로 열렸습니다", + "openFailed": "작업 공간 열기 실패", + "renameSuccess": "작업 공간 이름이 성공적으로 변경되었습니다", + "renameFailed": "작업 공간 이름 변경 실패", + "updateIconSuccess": "작업 공간 아이콘이 성공적으로 업데이트되었습니다", + "updateIconFailed": "작업 공간 아이콘 업데이트 실패", + "cannotDeleteTheOnlyWorkspace": "유일한 작업 공간을 삭제할 수 없습니다", + "fetchWorkspacesFailed": "작업 공간을 가져오지 못했습니다", + "leaveCurrentWorkspace": "작업 공간 나가기", + "leaveCurrentWorkspacePrompt": "현재 작업 공간을 나가시겠습니까?" }, "shareAction": { "buttonText": "공유", - "workInProgress": "Coming soon", - "markdown": "마크다운", - "copyLink": "링크 복사" + "workInProgress": "곧 출시 예정", + "markdown": "Markdown", + "html": "HTML", + "clipboard": "클립보드에 복사", + "csv": "CSV", + "copyLink": "링크 복사", + "publishToTheWeb": "웹에 게시", + "publishToTheWebHint": "AppFlowy로 웹사이트 만들기", + "publish": "게시", + "unPublish": "게시 취소", + "visitSite": "사이트 방문", + "exportAsTab": "다음으로 내보내기", + "publishTab": "게시", + "shareTab": "공유", + "publishOnAppFlowy": "AppFlowy에 게시", + "shareTabTitle": "협업 초대", + "shareTabDescription": "누구와도 쉽게 협업할 수 있습니다", + "copyLinkSuccess": "링크가 클립보드에 복사되었습니다", + "copyShareLink": "공유 링크 복사", + "copyLinkFailed": "링크를 클립보드에 복사하지 못했습니다", + "copyLinkToBlockSuccess": "블록 링크가 클립보드에 복사되었습니다", + "copyLinkToBlockFailed": "블록 링크를 클립보드에 복사하지 못했습니다", + "manageAllSites": "모든 사이트 관리", + "updatePathName": "경로 이름 업데이트" }, "moreAction": { - "small": "작은", + "small": "작게", "medium": "중간", - "large": "크기가 큰", + "large": "크게", "fontSize": "글꼴 크기", - "import": "수입", - "moreOptions": "추가 옵션" + "import": "가져오기", + "moreOptions": "더 많은 옵션", + "wordCount": "단어 수: {}", + "charCount": "문자 수: {}", + "createdAt": "생성일: {}", + "deleteView": "삭제", + "duplicateView": "복제", + "wordCountLabel": "단어 수: ", + "charCountLabel": "문자 수: ", + "createdAtLabel": "생성일: ", + "syncedAtLabel": "동기화됨: ", + "saveAsNewPage": "페이지에 메시지 추가", + "saveAsNewPageDisabled": "사용 가능한 메시지가 없습니다" }, "importPanel": { - "textAndMarkdown": "텍스트 및 마크다운", - "documentFromV010": "v0.1.0의 문서", - "databaseFromV010": "v0.1.0의 데이터베이스", + "textAndMarkdown": "텍스트 & Markdown", + "documentFromV010": "v0.1.0에서 문서 가져오기", + "databaseFromV010": "v0.1.0에서 데이터베이스 가져오기", + "notionZip": "Notion 내보낸 Zip 파일", "csv": "CSV", - "database": "데이터 베이스" + "database": "데이터베이스" + }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "파일을 드래그 앤 드롭하거나 클릭하여 ", + "placeholderUpload": "업로드", + "placeholderRight": "하거나 이미지 링크를 붙여넣으세요.", + "dropToUpload": "업로드할 파일을 드롭하세요", + "change": "변경" + } }, "disclosureAction": { - "rename": "이름변경", + "rename": "이름 변경", "delete": "삭제", "duplicate": "복제", - "openNewTab": "새 탭에서 열기" + "unfavorite": "즐겨찾기에서 제거", + "favorite": "즐겨찾기에 추가", + "openNewTab": "새 탭에서 열기", + "moveTo": "이동", + "addToFavorites": "즐겨찾기에 추가", + "copyLink": "링크 복사", + "changeIcon": "아이콘 변경", + "collapseAllPages": "모든 하위 페이지 접기", + "movePageTo": "페이지 이동", + "move": "이동", + "lockPage": "페이지 잠금" }, "blankPageTitle": "빈 페이지", - "newPageText": "새로운 페이지", + "newPageText": "새 페이지", + "newDocumentText": "새 문서", + "newGridText": "새 그리드", + "newCalendarText": "새 캘린더", + "newBoardText": "새 보드", + "chat": { + "newChat": "AI 채팅", + "inputMessageHint": "@:appName AI에게 물어보세요", + "inputLocalAIMessageHint": "@:appName 로컬 AI에게 물어보세요", + "unsupportedCloudPrompt": "이 기능은 @:appName Cloud를 사용할 때만 사용할 수 있습니다", + "relatedQuestion": "추천 질문", + "serverUnavailable": "연결이 끊어졌습니다. 인터넷을 확인하고", + "aiServerUnavailable": "AI 서비스가 일시적으로 사용할 수 없습니다. 나중에 다시 시도하세요.", + "retry": "다시 시도", + "clickToRetry": "다시 시도하려면 클릭", + "regenerateAnswer": "다시 생성", + "question1": "Kanban을 사용하여 작업 관리하는 방법", + "question2": "GTD 방법 설명", + "question3": "Rust를 사용하는 이유", + "question4": "내 주방에 있는 재료로 요리법 만들기", + "question5": "내 페이지에 대한 일러스트레이션 만들기", + "question6": "다가오는 주의 할 일 목록 작성", + "aiMistakePrompt": "AI는 실수를 할 수 있습니다. 중요한 정보를 확인하세요.", + "chatWithFilePrompt": "파일과 채팅하시겠습니까?", + "indexFileSuccess": "파일 색인화 성공", + "inputActionNoPages": "페이지 결과 없음", + "referenceSource": { + "zero": "0개의 출처 발견", + "one": "{count}개의 출처 발견", + "other": "{count}개의 출처 발견" + }, + "clickToMention": "페이지 언급", + "uploadFile": "PDF, 텍스트 또는 마크다운 파일 첨부", + "questionDetail": "안녕하세요 {}! 오늘 어떻게 도와드릴까요?", + "indexingFile": "{} 색인화 중", + "generatingResponse": "응답 생성 중", + "selectSources": "출처 선택", + "currentPage": "현재 페이지", + "sourcesLimitReached": "최대 3개의 최상위 문서와 그 하위 문서만 선택할 수 있습니다", + "sourceUnsupported": "현재 데이터베이스와의 채팅을 지원하지 않습니다", + "regenerate": "다시 시도", + "addToPageButton": "페이지에 메시지 추가", + "addToPageTitle": "메시지 추가...", + "addToNewPage": "새 페이지 만들기", + "addToNewPageName": "\"{}\"에서 추출한 메시지", + "addToNewPageSuccessToast": "메시지가 추가되었습니다", + "openPagePreviewFailedToast": "페이지를 열지 못했습니다", + "changeFormat": { + "actionButton": "형식 변경", + "confirmButton": "이 형식으로 다시 생성", + "textOnly": "텍스트", + "imageOnly": "이미지 전용", + "textAndImage": "텍스트와 이미지", + "text": "단락", + "bullet": "글머리 기호 목록", + "number": "번호 매기기 목록", + "table": "표", + "blankDescription": "응답 형식", + "defaultDescription": "자동 모드", + "textWithImageDescription": "@:chat.changeFormat.text와 이미지", + "numberWithImageDescription": "@:chat.changeFormat.number와 이미지", + "bulletWithImageDescription": "@:chat.changeFormat.bullet와 이미지", + "tableWithImageDescription": "@:chat.changeFormat.table와 이미지" + }, + "selectBanner": { + "saveButton": "추가 ...", + "selectMessages": "메시지 선택", + "nSelected": "{}개 선택됨", + "allSelected": "모두 선택됨" + }, + "stopTooltip": "생성 중지" + }, "trash": { "text": "휴지통", - "restoreAll": "모두 복구", + "restoreAll": "모두 복원", + "restore": "복원", "deleteAll": "모두 삭제", "pageHeader": { "fileName": "파일 이름", - "lastModified": "수정날짜", - "created": "생성날짜" + "lastModified": "마지막 수정", + "created": "생성됨" }, "confirmDeleteAll": { - "title": "휴지통의 모든 페이지를 삭제하시겠습니까?", - "caption": "이 작업은 취소할 수 없습니다." + "title": "휴지통의 모든 페이지", + "caption": "휴지통의 모든 항목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." }, "confirmRestoreAll": { - "title": "휴지통의 모든 페이지를 복원하시겠습니까?", - "caption": "이 작업은 취소할 수 없습니다." - } + "title": "휴지통의 모든 페이지 복원", + "caption": "이 작업은 되돌릴 수 없습니다." + }, + "restorePage": { + "title": "복원: {}", + "caption": "이 페이지를 복원하시겠습니까?" + }, + "mobile": { + "actions": "휴지통 작업", + "empty": "휴지통에 페이지나 공간이 없습니다", + "emptyDescription": "필요 없는 항목을 휴지통으로 이동하세요.", + "isDeleted": "삭제됨", + "isRestored": "복원됨" + }, + "confirmDeleteTitle": "이 페이지를 영구적으로 삭제하시겠습니까?" }, "deletePagePrompt": { - "text": "현재 페이지는 휴지통에 있습니다", - "restore": "페이지 복구", - "deletePermanent": "영구 삭제" + "text": "이 페이지는 휴지통에 있습니다", + "restore": "페이지 복원", + "deletePermanent": "영구적으로 삭제", + "deletePermanentDescription": "이 페이지를 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." }, "dialogCreatePageNameHint": "페이지 이름", "questionBubble": { - "shortcuts": "바로 가기", - "whatsNew": "새로운 소식", - "help": "도움 및 지원", - "markdown": "가격 인하", + "shortcuts": "단축키", + "whatsNew": "새로운 기능", + "markdown": "Markdown", "debug": { "name": "디버그 정보", - "success": "디버그 정보를 클립보드로 복사했습니다.", - "fail": "디버그 정보를 클립보드로 복사할 수 없습니다." + "success": "디버그 정보를 클립보드에 복사했습니다!", + "fail": "디버그 정보를 클립보드에 복사할 수 없습니다" }, - "feedback": "피드백" + "feedback": "피드백", + "help": "도움말 및 지원" }, "menuAppHeader": { - "addPageTooltip": "하위에 페이지 추가", - "defaultNewPageName": "제목없음", - "renameDialog": "이름변경" + "moreButtonToolTip": "제거, 이름 변경 등...", + "addPageTooltip": "빠르게 페이지 추가", + "defaultNewPageName": "제목 없음", + "renameDialog": "이름 변경", + "pageNameSuffix": "복사본" }, + "noPagesInside": "내부에 페이지가 없습니다", "toolbar": { - "undo": "실행취소", - "redo": "재실행", + "undo": "실행 취소", + "redo": "다시 실행", "bold": "굵게", "italic": "기울임꼴", "underline": "밑줄", "strike": "취소선", "numList": "번호 매기기 목록", "bulletList": "글머리 기호 목록", - "checkList": "작업 목록", + "checkList": "체크리스트", "inlineCode": "인라인 코드", - "quote": "인용구 블록", + "quote": "인용 블록", "header": "헤더", - "highlight": "하이라이트", + "highlight": "강조", "color": "색상", - "addLink": "링크 추가", - "link": "링크" + "addLink": "링크 추가" }, "tooltip": { - "lightMode": "라이트 모드로 변경", - "darkMode": "다크 모드로 변경", + "lightMode": "라이트 모드로 전환", + "darkMode": "다크 모드로 전환", "openAsPage": "페이지로 열기", - "addNewRow": "열 추가", - "openMenu": "메뉴를 여시려면 클릭하세요", - "dragRow": "행을 재정렬하려면 길게 누르세요.", + "addNewRow": "새 행 추가", + "openMenu": "메뉴 열기", + "dragRow": "행 순서 변경", "viewDataBase": "데이터베이스 보기", - "referencePage": "이 {name}은(는) 참조됩니다", - "addBlockBelow": "아래에 블록 추가" + "referencePage": "이 {name}이 참조됨", + "addBlockBelow": "아래에 블록 추가", + "aiGenerate": "생성" }, "sideBar": { "closeSidebar": "사이드바 닫기", - "openSidebar": "사이드바 열기" + "openSidebar": "사이드바 열기", + "expandSidebar": "전체 페이지로 확장", + "personal": "개인", + "private": "비공개", + "workspace": "작업 공간", + "favorites": "즐겨찾기", + "clickToHidePrivate": "비공개 공간 숨기기\n여기에서 만든 페이지는 본인만 볼 수 있습니다", + "clickToHideWorkspace": "작업 공간 숨기기\n여기에서 만든 페이지는 모든 멤버가 볼 수 있습니다", + "clickToHidePersonal": "개인 공간 숨기기", + "clickToHideFavorites": "즐겨찾기 공간 숨기기", + "addAPage": "새 페이지 추가", + "addAPageToPrivate": "비공개 공간에 페이지 추가", + "addAPageToWorkspace": "작업 공간에 페이지 추가", + "recent": "최근", + "today": "오늘", + "thisWeek": "이번 주", + "others": "이전 즐겨찾기", + "earlier": "이전", + "justNow": "방금", + "minutesAgo": "{count}분 전", + "lastViewed": "마지막으로 본", + "favoriteAt": "즐겨찾기한", + "emptyRecent": "최근 페이지 없음", + "emptyRecentDescription": "페이지를 보면 여기에서 쉽게 찾을 수 있습니다.", + "emptyFavorite": "즐겨찾기 페이지 없음", + "emptyFavoriteDescription": "페이지를 즐겨찾기에 추가하면 여기에서 빠르게 접근할 수 있습니다!", + "removePageFromRecent": "최근 항목에서 이 페이지를 제거하시겠습니까?", + "removeSuccess": "성공적으로 제거되었습니다", + "favoriteSpace": "즐겨찾기", + "RecentSpace": "최근", + "Spaces": "공간", + "upgradeToPro": "Pro로 업그레이드", + "upgradeToAIMax": "무제한 AI 잠금 해제", + "storageLimitDialogTitle": "무료 저장 공간이 부족합니다. 무제한 저장 공간을 잠금 해제하려면 업그레이드하세요", + "storageLimitDialogTitleIOS": "무료 저장 공간이 부족합니다.", + "aiResponseLimitTitle": "무료 AI 응답이 부족합니다. Pro 플랜으로 업그레이드하거나 AI 애드온을 구매하여 무제한 응답을 잠금 해제하세요", + "aiResponseLimitDialogTitle": "AI 응답 한도에 도달했습니다", + "aiResponseLimit": "무료 AI 응답이 부족합니다.\n\n설정 -> 플랜 -> AI Max 또는 Pro 플랜을 클릭하여 더 많은 AI 응답을 받으세요", + "askOwnerToUpgradeToPro": "작업 공간의 무료 저장 공간이 부족합니다. 작업 공간 소유자에게 Pro 플랜으로 업그레이드하도록 요청하세요", + "askOwnerToUpgradeToProIOS": "작업 공간의 무료 저장 공간이 부족합니다.", + "askOwnerToUpgradeToAIMax": "작업 공간의 무료 AI 응답이 부족합니다. 작업 공간 소유자에게 플랜을 업그레이드하거나 AI 애드온을 구매하도록 요청하세요", + "askOwnerToUpgradeToAIMaxIOS": "작업 공간의 무료 AI 응답이 부족합니다.", + "purchaseAIMax": "작업 공간의 AI 이미지 응답이 부족합니다. 작업 공간 소유자에게 AI Max를 구매하도록 요청하세요", + "aiImageResponseLimit": "AI 이미지 응답이 부족합니다.\n\n설정 -> 플랜 -> AI Max를 클릭하여 더 많은 AI 이미지 응답을 받으세요", + "purchaseStorageSpace": "저장 공간 구매", + "singleFileProPlanLimitationDescription": "무료 플랜에서 허용되는 최대 파일 업로드 크기를 초과했습니다. 더 큰 파일을 업로드하려면 Pro 플랜으로 업그레이드하세요", + "purchaseAIResponse": "구매 ", + "askOwnerToUpgradeToLocalAI": "작업 공간 소유자에게 AI On-device를 활성화하도록 요청하세요", + "upgradeToAILocal": "최고의 프라이버시를 위해 로컬 모델을 장치에서 실행", + "upgradeToAILocalDesc": "PDF와 채팅하고, 글쓰기를 개선하고, 로컬 AI를 사용하여 테이블을 자동으로 채우세요" }, "notifications": { "export": { - "markdown": "마크다운으로 노트를 내보냄", + "markdown": "노트를 Markdown으로 내보냈습니다", "path": "Documents/flowy" } }, "contactsPage": { "title": "연락처", - "whatsHappening": "이번주에는 무슨 일이 있나요?", + "whatsHappening": "이번 주에 무슨 일이 있나요?", "addContact": "연락처 추가", - "editContact": "연락처 편집" + "editContact": "연락처 수정" }, "button": { "ok": "확인", + "confirm": "확인", "done": "완료", "cancel": "취소", "signIn": "로그인", "signOut": "로그아웃", "complete": "완료", "save": "저장", - "generate": "생성하다", + "generate": "생성", "esc": "ESC", - "keep": "유지하다", - "tryAgain": "다시 시도하십시오", - "discard": "버리다", - "replace": "바꾸다", + "keep": "유지", + "tryAgain": "다시 시도", + "discard": "버리기", + "replace": "교체", "insertBelow": "아래에 삽입", + "insertAbove": "위에 삽입", "upload": "업로드", - "edit": "편집하다", + "edit": "편집", "delete": "삭제", - "duplicate": "복제하다", - "putback": "다시 집어 넣어" + "copy": "복사", + "duplicate": "복제", + "putback": "되돌리기", + "update": "업데이트", + "share": "공유", + "removeFromFavorites": "즐겨찾기에서 제거", + "removeFromRecent": "최근 항목에서 제거", + "addToFavorites": "즐겨찾기에 추가", + "favoriteSuccessfully": "즐겨찾기에 성공적으로 추가되었습니다", + "unfavoriteSuccessfully": "즐겨찾기에서 성공적으로 제거되었습니다", + "duplicateSuccessfully": "성공적으로 복제되었습니다", + "rename": "이름 변경", + "helpCenter": "도움말 센터", + "add": "추가", + "yes": "예", + "no": "아니요", + "clear": "지우기", + "remove": "제거", + "dontRemove": "제거하지 않음", + "copyLink": "링크 복사", + "align": "정렬", + "login": "로그인", + "logout": "로그아웃", + "deleteAccount": "계정 삭제", + "back": "뒤로", + "signInGoogle": "Google로 계속", + "signInGithub": "GitHub로 계속", + "signInDiscord": "Discord로 계속", + "more": "더 보기", + "create": "생성", + "close": "닫기", + "next": "다음", + "previous": "이전", + "submit": "제출", + "download": "다운로드", + "backToHome": "홈으로 돌아가기", + "viewing": "보기", + "editing": "편집 중", + "gotIt": "알겠습니다", + "retry": "다시 시도", + "uploadFailed": "업로드 실패.", + "copyLinkOriginal": "원본 링크 복사" }, "label": { "welcome": "환영합니다!", "firstName": "이름", "middleName": "중간 이름", "lastName": "성", - "stepX": "{X} 단계" + "stepX": "단계 {X}" }, "oAuth": { "err": { - "failedTitle": "계정에 연결을 할 수 없습니다.", - "failedMsg": "브라우저에서 회원가입이 완료되었는지 확인해주세요." + "failedTitle": "계정에 연결할 수 없습니다.", + "failedMsg": "브라우저에서 로그인 프로세스를 완료했는지 확인하세요." }, "google": { - "title": "GOOGLE SIGN-IN", - "instruction1": "구글 연락처를 가져오기 위해서 웹브라우저로 앱을 승인 해야 합니다.", - "instruction2": "아이콘을 클릭 또는 텍스트를 선택해서 이 코드를 클립보드로 복사하세요:", - "instruction3": "웹브라우저로 다음 링크로 가셔서 위 코드를 입력해주세요:", - "instruction4": "가입 완료 후 아래 버튼을 눌러주세요:" + "title": "GOOGLE 로그인", + "instruction1": "Google 연락처를 가져오려면 웹 브라우저를 사용하여 이 애플리케이션을 인증해야 합니다.", + "instruction2": "아이콘을 클릭하거나 텍스트를 선택하여 이 코드를 클립보드에 복사하세요:", + "instruction3": "웹 브라우저에서 다음 링크로 이동하고 위의 코드를 입력하세요:", + "instruction4": "가입을 완료했으면 아래 버튼을 누르세요:" } }, "settings": { "title": "설정", + "popupMenuItem": { + "settings": "설정", + "members": "멤버", + "trash": "휴지통", + "helpAndSupport": "도움말 및 지원" + }, + "sites": { + "title": "사이트", + "namespaceTitle": "네임스페이스", + "namespaceDescription": "네임스페이스 및 홈페이지 관리", + "namespaceHeader": "네임스페이스", + "homepageHeader": "홈페이지", + "updateNamespace": "네임스페이스 업데이트", + "removeHomepage": "홈페이지 제거", + "selectHomePage": "페이지 선택", + "clearHomePage": "이 네임스페이스의 홈페이지를 지웁니다", + "customUrl": "맞춤 URL", + "namespace": { + "description": "이 변경 사항은 이 네임스페이스에 라이브로 게시된 모든 페이지에 적용됩니다", + "tooltip": "부적절한 네임스페이스를 제거할 권리를 보유합니다", + "updateExistingNamespace": "기존 네임스페이스 업데이트", + "upgradeToPro": "Pro 플랜으로 업그레이드하여 홈페이지 설정", + "redirectToPayment": "결제 페이지로 리디렉션 중...", + "onlyWorkspaceOwnerCanSetHomePage": "작업 공간 소유자만 홈페이지를 설정할 수 있습니다", + "pleaseAskOwnerToSetHomePage": "작업 공간 소유자에게 Pro 플랜으로 업그레이드하도록 요청하세요" + }, + "publishedPage": { + "title": "모든 게시된 페이지", + "description": "게시된 페이지 관리", + "page": "페이지", + "pathName": "경로 이름", + "date": "게시 날짜", + "emptyHinText": "이 작업 공간에 게시된 페이지가 없습니다", + "noPublishedPages": "게시된 페이지 없음", + "settings": "게시 설정", + "clickToOpenPageInApp": "앱에서 페이지 열기", + "clickToOpenPageInBrowser": "브라우저에서 페이지 열기" + }, + "error": { + "failedToGeneratePaymentLink": "Pro 플랜 결제 링크 생성 실패", + "failedToUpdateNamespace": "네임스페이스 업데이트 실패", + "proPlanLimitation": "네임스페이스를 업데이트하려면 Pro 플랜으로 업그레이드해야 합니다", + "namespaceAlreadyInUse": "네임스페이스가 이미 사용 중입니다. 다른 네임스페이스를 시도하세요", + "invalidNamespace": "잘못된 네임스페이스입니다. 다른 네임스페이스를 시도하세요", + "namespaceLengthAtLeast2Characters": "네임스페이스는 최소 2자 이상이어야 합니다", + "onlyWorkspaceOwnerCanUpdateNamespace": "작업 공간 소유자만 네임스페이스를 업데이트할 수 있습니다", + "onlyWorkspaceOwnerCanRemoveHomepage": "작업 공간 소유자만 홈페이지를 제거할 수 있습니다", + "setHomepageFailed": "홈페이지 설정 실패", + "namespaceTooLong": "네임스페이스가 너무 깁니다. 다른 네임스페이스를 시도하세요", + "namespaceTooShort": "네임스페이스가 너무 짧습니다. 다른 네임스페이스를 시도하세요", + "namespaceIsReserved": "네임스페이스가 예약되어 있습니다. 다른 네임스페이스를 시도하세요", + "updatePathNameFailed": "경로 이름 업데이트 실패", + "removeHomePageFailed": "홈페이지 제거 실패", + "publishNameContainsInvalidCharacters": "경로 이름에 잘못된 문자가 포함되어 있습니다. 다른 이름을 시도하세요", + "publishNameTooShort": "경로 이름이 너무 짧습니다. 다른 이름을 시도하세요", + "publishNameTooLong": "경로 이름이 너무 깁니다. 다른 이름을 시도하세요", + "publishNameAlreadyInUse": "경로 이름이 이미 사용 중입니다. 다른 이름을 시도하세요", + "namespaceContainsInvalidCharacters": "네임스페이스에 잘못된 문자가 포함되어 있습니다. 다른 네임스페이스를 시도하세요", + "publishPermissionDenied": "작업 공간 소유자 또는 페이지 게시자만 게시 설정을 관리할 수 있습니다", + "publishNameCannotBeEmpty": "경로 이름은 비워둘 수 없습니다. 다른 이름을 시도하세요" + }, + "success": { + "namespaceUpdated": "네임스페이스가 성공적으로 업데이트되었습니다", + "setHomepageSuccess": "홈페이지가 성공적으로 설정되었습니다", + "updatePathNameSuccess": "경로 이름이 성공적으로 업데이트되었습니다", + "removeHomePageSuccess": "홈페이지가 성공적으로 제거되었습니다" + } + }, + "accountPage": { + "menuLabel": "계정 및 앱", + "title": "내 계정", + "general": { + "title": "계정 이름 및 프로필 이미지", + "changeProfilePicture": "프로필 사진 변경" + }, + "email": { + "title": "이메일", + "actions": { + "change": "이메일 변경" + } + }, + "login": { + "title": "계정 로그인", + "loginLabel": "로그인", + "logoutLabel": "로그아웃" + }, + "isUpToDate": "@:appName이 최신 상태입니다!", + "officialVersion": "버전 {version} (공식 빌드)" + }, + "workspacePage": { + "menuLabel": "작업 공간", + "title": "작업 공간", + "description": "작업 공간의 외관, 테마, 글꼴, 텍스트 레이아웃, 날짜/시간 형식 및 언어를 사용자 정의합니다.", + "workspaceName": { + "title": "작업 공간 이름" + }, + "workspaceIcon": { + "title": "작업 공간 아이콘", + "description": "작업 공간에 대한 이미지를 업로드하거나 이모지를 사용하세요. 아이콘은 사이드바와 알림에 표시됩니다." + }, + "appearance": { + "title": "외관", + "description": "작업 공간의 외관, 테마, 글꼴, 텍스트 레이아웃, 날짜, 시간 및 언어를 사용자 정의합니다.", + "options": { + "system": "자동", + "light": "라이트", + "dark": "다크" + } + }, + "resetCursorColor": { + "title": "문서 커서 색상 재설정", + "description": "커서 색상을 재설정하시겠습니까?" + }, + "resetSelectionColor": { + "title": "문서 선택 색상 재설정", + "description": "선택 색상을 재설정하시겠습니까?" + }, + "resetWidth": { + "resetSuccess": "문서 너비가 성공적으로 재설정되었습니다" + }, + "theme": { + "title": "테마", + "description": "미리 설정된 테마를 선택하거나 사용자 정의 테마를 업로드하세요.", + "uploadCustomThemeTooltip": "사용자 정의 테마 업로드" + }, + "workspaceFont": { + "title": "작업 공간 글꼴", + "noFontHint": "글꼴을 찾을 수 없습니다. 다른 용어를 시도하세요." + }, + "textDirection": { + "title": "텍스트 방향", + "leftToRight": "왼쪽에서 오른쪽으로", + "rightToLeft": "오른쪽에서 왼쪽으로", + "auto": "자동", + "enableRTLItems": "RTL 도구 모음 항목 활성화" + }, + "layoutDirection": { + "title": "레이아웃 방향", + "leftToRight": "왼쪽에서 오른쪽으로", + "rightToLeft": "오른쪽에서 왼쪽으로" + }, + "dateTime": { + "title": "날짜 및 시간", + "example": "{}에 {} ({})", + "24HourTime": "24시간 형식", + "dateFormat": { + "label": "날짜 형식", + "local": "로컬", + "us": "미국", + "iso": "ISO", + "friendly": "친숙한", + "dmy": "일/월/년" + } + }, + "language": { + "title": "언어" + }, + "deleteWorkspacePrompt": { + "title": "작업 공간 삭제", + "content": "이 작업 공간을 삭제하시겠습니까? 이 작업은 되돌릴 수 없으며, 게시한 모든 페이지가 게시 취소됩니다." + }, + "leaveWorkspacePrompt": { + "title": "작업 공간 나가기", + "content": "이 작업 공간을 나가시겠습니까? 모든 페이지와 데이터에 대한 액세스를 잃게 됩니다.", + "success": "작업 공간을 성공적으로 나갔습니다.", + "fail": "작업 공간 나가기 실패." + }, + "manageWorkspace": { + "title": "작업 공간 관리", + "leaveWorkspace": "작업 공간 나가기", + "deleteWorkspace": "작업 공간 삭제" + } + }, + "manageDataPage": { + "menuLabel": "데이터 관리", + "title": "데이터 관리", + "description": "로컬 저장소 데이터를 관리하거나 기존 데이터를 @:appName에 가져옵니다.", + "dataStorage": { + "title": "파일 저장 위치", + "tooltip": "파일이 저장되는 위치", + "actions": { + "change": "경로 변경", + "open": "폴더 열기", + "openTooltip": "현재 데이터 폴더 위치 열기", + "copy": "경로 복사", + "copiedHint": "경로가 복사되었습니다!", + "resetTooltip": "기본 위치로 재설정" + }, + "resetDialog": { + "title": "확실합니까?", + "description": "경로를 기본 데이터 위치로 재설정해도 데이터가 삭제되지 않습니다. 현재 데이터를 다시 가져오려면 현재 위치의 경로를 먼저 복사해야 합니다." + } + }, + "importData": { + "title": "데이터 가져오기", + "tooltip": "@:appName 백업/데이터 폴더에서 데이터 가져오기", + "description": "외부 @:appName 데이터 폴더에서 데이터 복사", + "action": "파일 찾아보기" + }, + "encryption": { + "title": "암호화", + "tooltip": "데이터 저장 및 암호화 방법 관리", + "descriptionNoEncryption": "암호화를 켜면 모든 데이터가 암호화됩니다. 이 작업은 되돌릴 수 없습니다.", + "descriptionEncrypted": "데이터가 암호화되었습니다.", + "action": "데이터 암호화", + "dialog": { + "title": "모든 데이터를 암호화하시겠습니까?", + "description": "모든 데이터를 암호화하면 데이터가 안전하고 보호됩니다. 이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?" + } + }, + "cache": { + "title": "캐시 지우기", + "description": "이미지가 로드되지 않거나 공간에 페이지가 누락되거나 글꼴이 로드되지 않는 등의 문제를 해결하는 데 도움이 됩니다. 데이터에는 영향을 미치지 않습니다.", + "dialog": { + "title": "캐시 지우기", + "description": "이미지가 로드되지 않거나 공간에 페이지가 누락되거나 글꼴이 로드되지 않는 등의 문제를 해결하는 데 도움이 됩니다. 데이터에는 영향을 미치지 않습니다.", + "successHint": "캐시가 지워졌습니다!" + } + }, + "data": { + "fixYourData": "데이터 수정", + "fixButton": "수정", + "fixYourDataDescription": "데이터에 문제가 있는 경우 여기에서 수정할 수 있습니다." + } + }, + "shortcutsPage": { + "menuLabel": "단축키", + "title": "단축키", + "editBindingHint": "새 바인딩 입력", + "searchHint": "검색", + "actions": { + "resetDefault": "기본값으로 재설정" + }, + "errorPage": { + "message": "단축키를 로드하지 못했습니다: {}", + "howToFix": "다시 시도해 보세요. 문제가 계속되면 GitHub에 문의하세요." + }, + "resetDialog": { + "title": "단축키 재설정", + "description": "모든 키 바인딩을 기본값으로 재설정합니다. 나중에 되돌릴 수 없습니다. 계속하시겠습니까?", + "buttonLabel": "재설정" + }, + "conflictDialog": { + "title": "{}가 이미 사용 중입니다", + "descriptionPrefix": "이 키 바인딩은 현재 ", + "descriptionSuffix": "에서 사용 중입니다. 이 키 바인딩을 교체하면 {}에서 제거됩니다.", + "confirmLabel": "계속" + }, + "editTooltip": "키 바인딩 편집을 시작하려면 누르세요", + "keybindings": { + "toggleToDoList": "할 일 목록 전환", + "insertNewParagraphInCodeblock": "새 단락 삽입", + "pasteInCodeblock": "코드 블록에 붙여넣기", + "selectAllCodeblock": "모두 선택", + "indentLineCodeblock": "줄 시작에 두 칸 삽입", + "outdentLineCodeblock": "줄 시작에서 두 칸 삭제", + "twoSpacesCursorCodeblock": "커서 위치에 두 칸 삽입", + "copy": "선택 항목 복사", + "paste": "내용 붙여넣기", + "cut": "선택 항목 잘라내기", + "alignLeft": "텍스트 왼쪽 정렬", + "alignCenter": "텍스트 가운데 정렬", + "alignRight": "텍스트 오른쪽 정렬", + "insertInlineMathEquation": "인라인 수학 방정식 삽입", + "undo": "실행 취소", + "redo": "다시 실행", + "convertToParagraph": "블록을 단락으로 변환", + "backspace": "삭제", + "deleteLeftWord": "왼쪽 단어 삭제", + "deleteLeftSentence": "왼쪽 문장 삭제", + "delete": "오른쪽 문자 삭제", + "deleteMacOS": "왼쪽 문자 삭제", + "deleteRightWord": "오른쪽 단어 삭제", + "moveCursorLeft": "커서를 왼쪽으로 이동", + "moveCursorBeginning": "커서를 시작으로 이동", + "moveCursorLeftWord": "커서를 왼쪽 단어로 이동", + "moveCursorLeftSelect": "선택하고 커서를 왼쪽으로 이동", + "moveCursorBeginSelect": "선택하고 커서를 시작으로 이동", + "moveCursorLeftWordSelect": "선택하고 커서를 왼쪽 단어로 이동", + "moveCursorRight": "커서를 오른쪽으로 이동", + "moveCursorEnd": "커서를 끝으로 이동", + "moveCursorRightWord": "커서를 오른쪽 단어로 이동", + "moveCursorRightSelect": "선택하고 커서를 오른쪽으로 이동", + "moveCursorEndSelect": "선택하고 커서를 끝으로 이동", + "moveCursorRightWordSelect": "선택하고 커서를 오른쪽 단어로 이동", + "moveCursorUp": "커서를 위로 이동", + "moveCursorTopSelect": "선택하고 커서를 위로 이동", + "moveCursorTop": "커서를 위로 이동", + "moveCursorUpSelect": "선택하고 커서를 위로 이동", + "moveCursorBottomSelect": "선택하고 커서를 아래로 이동", + "moveCursorBottom": "커서를 아래로 이동", + "moveCursorDown": "커서를 아래로 이동", + "moveCursorDownSelect": "선택하고 커서를 아래로 이동", + "home": "맨 위로 스크롤", + "end": "맨 아래로 스크롤", + "toggleBold": "굵게 전환", + "toggleItalic": "기울임꼴 전환", + "toggleUnderline": "밑줄 전환", + "toggleStrikethrough": "취소선 전환", + "toggleCode": "인라인 코드 전환", + "toggleHighlight": "강조 전환", + "showLinkMenu": "링크 메뉴 표시", + "openInlineLink": "인라인 링크 열기", + "openLinks": "선택한 모든 링크 열기", + "indent": "들여쓰기", + "outdent": "내어쓰기", + "exit": "편집 종료", + "pageUp": "한 페이지 위로 스크롤", + "pageDown": "한 페이지 아래로 스크롤", + "selectAll": "모두 선택", + "pasteWithoutFormatting": "서식 없이 붙여넣기", + "showEmojiPicker": "이모지 선택기 표시", + "enterInTableCell": "테이블에 줄 바꿈 추가", + "leftInTableCell": "테이블에서 왼쪽 셀로 이동", + "rightInTableCell": "테이블에서 오른쪽 셀로 이동", + "upInTableCell": "테이블에서 위쪽 셀로 이동", + "downInTableCell": "테이블에서 아래쪽 셀로 이동", + "tabInTableCell": "테이블에서 다음 사용 가능한 셀로 이동", + "shiftTabInTableCell": "테이블에서 이전 사용 가능한 셀로 이동", + "backSpaceInTableCell": "셀의 시작 부분에서 멈춤" + }, + "commands": { + "codeBlockNewParagraph": "코드 블록 옆에 새 단락 삽입", + "codeBlockIndentLines": "코드 블록에서 줄 시작에 두 칸 삽입", + "codeBlockOutdentLines": "코드 블록에서 줄 시작에서 두 칸 삭제", + "codeBlockAddTwoSpaces": "코드 블록에서 커서 위치에 두 칸 삽입", + "codeBlockSelectAll": "코드 블록 내의 모든 내용 선택", + "codeBlockPasteText": "코드 블록에 텍스트 붙여넣기", + "textAlignLeft": "텍스트를 왼쪽으로 정렬", + "textAlignCenter": "텍스트를 가운데로 정렬", + "textAlignRight": "텍스트를 오른쪽으로 정렬" + }, + "couldNotLoadErrorMsg": "단축키를 로드할 수 없습니다. 다시 시도하세요", + "couldNotSaveErrorMsg": "단축키를 저장할 수 없습니다. 다시 시도하세요" + }, + "aiPage": { + "title": "AI 설정", + "menuLabel": "AI 설정", + "keys": { + "enableAISearchTitle": "AI 검색", + "aiSettingsDescription": "선호하는 모델을 선택하여 AppFlowy AI를 구동하세요. 이제 GPT 4-o, Claude 3,5, Llama 3.1 및 Mistral 7B를 포함합니다", + "loginToEnableAIFeature": "AI 기능은 @:appName Cloud에 로그인한 후에만 활성화됩니다. @:appName 계정이 없는 경우 '내 계정'에서 가입하세요", + "llmModel": "언어 모델", + "llmModelType": "언어 모델 유형", + "downloadLLMPrompt": "{} 다운로드", + "downloadAppFlowyOfflineAI": "AI 오프라인 패키지를 다운로드하면 AI가 장치에서 실행됩니다. 계속하시겠습니까?", + "downloadLLMPromptDetail": "{} 로컬 모델을 다운로드하면 최대 {}의 저장 공간이 필요합니다. 계속하시겠습니까?", + "downloadBigFilePrompt": "다운로드 완료까지 약 10분이 소요될 수 있습니다", + "downloadAIModelButton": "다운로드", + "downloadingModel": "다운로드 중", + "localAILoaded": "로컬 AI 모델이 성공적으로 추가되어 사용할 준비가 되었습니다", + "localAIStart": "로컬 AI가 시작 중입니다. 느리다면 껐다가 다시 켜보세요", + "localAILoading": "로컬 AI 채팅 모델이 로드 중입니다...", + "localAIStopped": "로컬 AI가 중지되었습니다", + "localAIRunning": "로컬 AI가 실행 중입니다", + "localAIInitializing": "로컬 AI가 로드 중이며 장치에 따라 몇 분이 소요될 수 있습니다", + "localAINotReadyTextFieldPrompt": "로컬 AI가 로드되는 동안 편집할 수 없습니다", + "failToLoadLocalAI": "로컬 AI를 시작하지 못했습니다", + "restartLocalAI": "로컬 AI 다시 시작", + "disableLocalAITitle": "로컬 AI 비활성화", + "disableLocalAIDescription": "로컬 AI를 비활성화하시겠습니까?", + "localAIToggleTitle": "로컬 AI를 활성화 또는 비활성화하려면 전환", + "offlineAIInstruction1": "다음을 따르세요", + "offlineAIInstruction2": "지침", + "offlineAIInstruction3": "오프라인 AI를 활성화하려면", + "offlineAIDownload1": "AppFlowy AI를 다운로드하지 않은 경우 먼저", + "offlineAIDownload2": "다운로드", + "offlineAIDownload3": "하세요", + "activeOfflineAI": "활성화됨", + "downloadOfflineAI": "다운로드", + "openModelDirectory": "폴더 열기", + "pleaseFollowThese": "지침", + "instructions": "이 지침을 따르세요", + "installOllamaLai": "Ollama 및 AppFlowy 로컬 AI를 설정합니다. 이미 설정한 경우 건너뛰세요", + "startLocalAI": "로컬 AI를 시작하는 데 몇 초가 소요될 수 있습니다" + } + }, + "planPage": { + "menuLabel": "플랜", + "title": "가격 플랜", + "planUsage": { + "title": "플랜 사용 요약", + "storageLabel": "저장 공간", + "storageUsage": "{} / {} GB", + "unlimitedStorageLabel": "무제한 저장 공간", + "collaboratorsLabel": "멤버", + "collaboratorsUsage": "{} / {}", + "aiResponseLabel": "AI 응답", + "aiResponseUsage": "{} / {}", + "unlimitedAILabel": "무제한 응답", + "proBadge": "Pro", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "Mac용 AI On-device", + "memberProToggle": "더 많은 멤버 및 무제한 AI", + "aiMaxToggle": "무제한 AI 및 고급 모델 액세스", + "aiOnDeviceToggle": "최고의 프라이버시를 위한 로컬 AI", + "aiCredit": { + "title": "@:appName AI 크레딧 추가", + "price": "{}", + "priceDescription": "1,000 크레딧당", + "purchase": "AI 구매", + "info": "작업 공간당 1,000개의 AI 크레딧을 추가하여 더 스마트하고 빠른 결과를 위해 AI를 워크플로에 원활하게 통합하세요:", + "infoItemOne": "데이터베이스당 최대 10,000개의 응답", + "infoItemTwo": "작업 공간당 최대 1,000개의 응답" + }, + "currentPlan": { + "bannerLabel": "현재 플랜", + "freeTitle": "무료", + "proTitle": "Pro", + "teamTitle": "팀", + "freeInfo": "모든 것을 정리하기 위한 최대 2명의 개인용", + "proInfo": "최대 10명의 소규모 팀을 위한 완벽한 솔루션.", + "teamInfo": "모든 생산적이고 잘 조직된 팀을 위한 완벽한 솔루션.", + "upgrade": "플랜 변경", + "canceledInfo": "플랜이 취소되었습니다. {}에 무료 플랜으로 다운그레이드됩니다." + }, + "addons": { + "title": "애드온", + "addLabel": "추가", + "activeLabel": "추가됨", + "aiMax": { + "title": "AI Max", + "description": "무제한 AI 응답 및 고급 AI 모델로 구동되는 50개의 AI 이미지를 매월 제공합니다", + "price": "{}", + "priceInfo": "연간 청구되는 사용자당 월별" + }, + "aiOnDevice": { + "title": "Mac용 AI On-device", + "description": "장치에서 Mistral 7B, LLAMA 3 및 기타 로컬 모델 실행", + "price": "{}", + "priceInfo": "연간 청구되는 사용자당 월별", + "recommend": "M1 이상 권장" + } + }, + "deal": { + "bannerLabel": "새해 할인!", + "title": "팀을 성장시키세요!", + "info": "Pro 및 팀 플랜을 업그레이드하고 10% 할인 혜택을 받으세요! @:appName AI를 포함한 강력한 새로운 기능으로 작업 공간 생산성을 높이세요.", + "viewPlans": "플랜 보기" + } + } + }, + "billingPage": { + "menuLabel": "청구", + "title": "청구", + "plan": { + "title": "플랜", + "freeLabel": "무료", + "proLabel": "Pro", + "planButtonLabel": "플랜 변경", + "billingPeriod": "청구 기간", + "periodButtonLabel": "기간 수정" + }, + "paymentDetails": { + "title": "결제 세부 정보", + "methodLabel": "결제 방법", + "methodButtonLabel": "방법 수정" + }, + "addons": { + "title": "애드온", + "addLabel": "추가", + "removeLabel": "제거", + "renewLabel": "갱신", + "aiMax": { + "label": "AI Max", + "description": "무제한 AI 및 고급 모델 잠금 해제", + "activeDescription": "다음 청구서가 {}에 만료됩니다", + "canceledDescription": "AI Max는 {}까지 사용할 수 있습니다" + }, + "aiOnDevice": { + "label": "Mac용 AI On-device", + "description": "장치에서 무제한 AI 잠금 해제", + "activeDescription": "다음 청구서가 {}에 만료됩니다", + "canceledDescription": "Mac용 AI On-device는 {}까지 사용할 수 있습니다" + }, + "removeDialog": { + "title": "{} 제거", + "description": "{plan}을 제거하시겠습니까? {plan}의 기능과 혜택에 대한 액세스를 즉시 잃게 됩니다." + } + }, + "currentPeriodBadge": "현재", + "changePeriod": "기간 변경", + "planPeriod": "{} 기간", + "monthlyInterval": "월별", + "monthlyPriceInfo": "월별 청구되는 좌석당", + "annualInterval": "연간", + "annualPriceInfo": "연간 청구되는 좌석당" + }, + "comparePlanDialog": { + "title": "플랜 비교 및 선택", + "planFeatures": "플랜\n기능", + "current": "현재", + "actions": { + "upgrade": "업그레이드", + "downgrade": "다운그레이드", + "current": "현재" + }, + "freePlan": { + "title": "무료", + "description": "모든 것을 정리하기 위한 최대 2명의 개인용", + "price": "{}", + "priceInfo": "영원히 무료" + }, + "proPlan": { + "title": "Pro", + "description": "프로젝트 및 팀 지식을 관리하기 위한 소규모 팀용", + "price": "{}", + "priceInfo": "연간 청구되는 사용자당 월별\n\n{} 월별 청구" + }, + "planLabels": { + "itemOne": "작업 공간", + "itemTwo": "멤버", + "itemThree": "저장 공간", + "itemFour": "실시간 협업", + "itemFive": "모바일 앱", + "itemSix": "AI 응답", + "itemSeven": "AI 이미지", + "itemFileUpload": "파일 업로드", + "customNamespace": "맞춤 네임스페이스", + "tooltipSix": "평생 동안 응답 수는 재설정되지 않습니다", + "intelligentSearch": "지능형 검색", + "tooltipSeven": "작업 공간의 URL 일부를 사용자 정의할 수 있습니다", + "customNamespaceTooltip": "맞춤 게시 사이트 URL" + }, + "freeLabels": { + "itemOne": "작업 공간당 청구", + "itemTwo": "최대 2명", + "itemThree": "5 GB", + "itemFour": "예", + "itemFive": "예", + "itemSix": "평생 10회", + "itemSeven": "평생 2회", + "itemFileUpload": "최대 7 MB", + "intelligentSearch": "지능형 검색" + }, + "proLabels": { + "itemOne": "작업 공간당 청구", + "itemTwo": "최대 10명", + "itemThree": "무제한", + "itemFour": "예", + "itemFive": "예", + "itemSix": "무제한", + "itemSeven": "월별 10개 이미지", + "itemFileUpload": "무제한", + "intelligentSearch": "지능형 검색" + }, + "paymentSuccess": { + "title": "이제 {} 플랜을 사용 중입니다!", + "description": "결제가 성공적으로 처리되었으며 플랜이 @:appName {}로 업그레이드되었습니다. 플랜 세부 정보를 플랜 페이지에서 확인할 수 있습니다" + }, + "downgradeDialog": { + "title": "플랜을 다운그레이드하시겠습니까?", + "description": "플랜을 다운그레이드하면 무료 플랜으로 돌아갑니다. 멤버는 이 작업 공간에 대한 액세스를 잃을 수 있으며 저장 공간 제한을 충족하기 위해 공간을 확보해야 할 수 있습니다.", + "downgradeLabel": "플랜 다운그레이드" + } + }, + "cancelSurveyDialog": { + "title": "떠나셔서 아쉽습니다", + "description": "@:appName을 개선하는 데 도움이 되도록 피드백을 듣고 싶습니다. 몇 가지 질문에 답변해 주세요.", + "commonOther": "기타", + "otherHint": "여기에 답변을 작성하세요", + "questionOne": { + "question": "@:appName Pro 구독을 취소한 이유는 무엇입니까?", + "answerOne": "비용이 너무 높음", + "answerTwo": "기능이 기대에 미치지 못함", + "answerThree": "더 나은 대안을 찾음", + "answerFour": "비용을 정당화할 만큼 충분히 사용하지 않음", + "answerFive": "서비스 문제 또는 기술적 어려움" + }, + "questionTwo": { + "question": "미래에 @:appName Pro를 다시 구독할 가능성은 얼마나 됩니까?", + "answerOne": "매우 가능성이 높음", + "answerTwo": "어느 정도 가능성이 있음", + "answerThree": "잘 모르겠음", + "answerFour": "가능성이 낮음", + "answerFive": "매우 가능성이 낮음" + }, + "questionThree": { + "question": "구독 기간 동안 가장 가치 있게 여긴 Pro 기능은 무엇입니까?", + "answerOne": "다중 사용자 협업", + "answerTwo": "더 긴 시간 버전 기록", + "answerThree": "무제한 AI 응답", + "answerFour": "로컬 AI 모델 액세스" + }, + "questionFour": { + "question": "@:appName에 대한 전반적인 경험을 어떻게 설명하시겠습니까?", + "answerOne": "훌륭함", + "answerTwo": "좋음", + "answerThree": "보통", + "answerFour": "평균 이하", + "answerFive": "불만족" + } + }, + "common": { + "uploadingFile": "파일 업로드 중입니다. 앱을 종료하지 마세요", + "uploadNotionSuccess": "Notion zip 파일이 성공적으로 업로드되었습니다. 가져오기가 완료되면 확인 이메일을 받게 됩니다", + "reset": "재설정" + }, "menu": { - "appearance": "화면", + "appearance": "외관", "language": "언어", "user": "사용자", "files": "파일", + "notifications": "알림", "open": "설정 열기", - "supabaseSetting": "수파베이스 설정" + "logout": "로그아웃", + "logoutPrompt": "로그아웃하시겠습니까?", + "selfEncryptionLogoutPrompt": "로그아웃하시겠습니까? 암호화 비밀을 복사했는지 확인하세요", + "syncSetting": "동기화 설정", + "cloudSettings": "클라우드 설정", + "enableSync": "동기화 활성화", + "enableSyncLog": "동기화 로그 활성화", + "enableSyncLogWarning": "동기화 문제를 진단하는 데 도움을 주셔서 감사합니다. 이 작업은 문서 편집 내용을 로컬 파일에 기록합니다. 활성화 후 앱을 종료하고 다시 열어야 합니다", + "enableEncrypt": "데이터 암호화", + "cloudURL": "기본 URL", + "webURL": "웹 URL", + "invalidCloudURLScheme": "잘못된 스키마", + "cloudServerType": "클라우드 서버", + "cloudServerTypeTip": "클라우드 서버를 변경한 후 현재 계정에서 로그아웃될 수 있습니다", + "cloudLocal": "로컬", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName Cloud 셀프 호스팅", + "appFlowyCloudUrlCanNotBeEmpty": "클라우드 URL은 비워둘 수 없습니다", + "clickToCopy": "클립보드에 복사", + "selfHostStart": "서버가 없는 경우", + "selfHostContent": "문서", + "selfHostEnd": "를 참조하여 셀프 호스팅 서버를 설정하는 방법을 확인하세요", + "pleaseInputValidURL": "유효한 URL을 입력하세요", + "changeUrl": "셀프 호스팅 URL을 {}로 변경", + "cloudURLHint": "서버의 기본 URL을 입력하세요", + "webURLHint": "웹 서버의 기본 URL을 입력하세요", + "cloudWSURL": "웹소켓 URL", + "cloudWSURLHint": "서버의 웹소켓 주소를 입력하세요", + "restartApp": "재시작", + "restartAppTip": "변경 사항을 적용하려면 애플리케이션을 재시작하세요. 현재 계정에서 로그아웃될 수 있습니다.", + "changeServerTip": "서버를 변경한 후 변경 사항을 적용하려면 재시작 버튼을 클릭해야 합니다", + "enableEncryptPrompt": "이 비밀로 데이터를 보호하려면 암호화를 활성화하세요. 안전하게 보관하세요. 활성화 후에는 비활성화할 수 없습니다. 비밀을 잃어버리면 데이터를 복구할 수 없습니다. 복사하려면 클릭하세요", + "inputEncryptPrompt": "암호화 비밀을 입력하세요", + "clickToCopySecret": "비밀을 복사하려면 클릭", + "configServerSetting": "서버 설정 구성", + "configServerGuide": "`빠른 시작`을 선택한 후 `설정`으로 이동하여 \"클라우드 설정\"을 구성하세요.", + "inputTextFieldHint": "비밀", + "historicalUserList": "사용자 로그인 기록", + "historicalUserListTooltip": "이 목록에는 익명 계정이 표시됩니다. 계정을 클릭하여 세부 정보를 확인할 수 있습니다. 익명 계정은 '시작하기' 버튼을 클릭하여 생성됩니다", + "openHistoricalUser": "익명 계정을 열려면 클릭", + "customPathPrompt": "Google Drive와 같은 클라우드 동기화 폴더에 @:appName 데이터 폴더를 저장하면 위험이 발생할 수 있습니다. 이 폴더 내의 데이터베이스에 여러 위치에서 동시에 액세스하거나 수정하면 동기화 충돌 및 데이터 손상이 발생할 수 있습니다", + "importAppFlowyData": "외부 @:appName 폴더에서 데이터 가져오기", + "importingAppFlowyDataTip": "데이터 가져오는 중입니다. 앱을 종료하지 마세요", + "importAppFlowyDataDescription": "외부 @:appName 데이터 폴더에서 데이터를 복사하여 현재 AppFlowy 데이터 폴더에 가져옵니다", + "importSuccess": "성공적으로 @:appName 데이터 폴더를 가져왔습니다", + "importFailed": "@:appName 데이터 폴더 가져오기 실패", + "importGuide": "자세한 내용은 참조 문서를 확인하세요" + }, + "notifications": { + "enableNotifications": { + "label": "알림 활성화", + "hint": "로컬 알림이 나타나지 않도록 하려면 끄세요." + }, + "showNotificationsIcon": { + "label": "알림 아이콘 표시", + "hint": "사이드바에서 알림 아이콘을 숨기려면 끄세요." + }, + "archiveNotifications": { + "allSuccess": "모든 알림이 성공적으로 보관되었습니다", + "success": "알림이 성공적으로 보관되었습니다" + }, + "markAsReadNotifications": { + "allSuccess": "모두 읽음으로 표시되었습니다", + "success": "읽음으로 표시되었습니다" + }, + "action": { + "markAsRead": "읽음으로 표시", + "multipleChoice": "더 선택", + "archive": "보관" + }, + "settings": { + "settings": "설정", + "markAllAsRead": "모두 읽음으로 표시", + "archiveAll": "모두 보관" + }, + "emptyInbox": { + "title": "받은 편지함 비어 있음!", + "description": "알림을 받으려면 알림을 설정하세요." + }, + "emptyUnread": { + "title": "읽지 않은 알림 없음", + "description": "모든 알림을 확인했습니다!" + }, + "emptyArchived": { + "title": "보관된 항목 없음", + "description": "보관된 알림이 여기에 표시됩니다." + }, + "tabs": { + "inbox": "받은 편지함", + "unread": "읽지 않음", + "archived": "보관됨" + }, + "refreshSuccess": "알림이 성공적으로 새로고침되었습니다", + "titles": { + "notifications": "알림", + "reminder": "알림" + } }, "appearance": { + "resetSetting": "재설정", "fontFamily": { - "label": "글꼴 패밀리", - "search": "찾다" + "label": "글꼴", + "search": "검색", + "defaultFont": "시스템" }, "themeMode": { "label": "테마 모드", "light": "라이트 모드", "dark": "다크 모드", - "system": "시스템에 적응" + "system": "시스템에 맞춤" + }, + "fontScaleFactor": "글꼴 크기 비율", + "displaySize": "디스플레이 크기", + "documentSettings": { + "cursorColor": "문서 커서 색상", + "selectionColor": "문서 선택 색상", + "width": "문서 너비", + "changeWidth": "변경", + "pickColor": "색상 선택", + "colorShade": "색상 음영", + "opacity": "불투명도", + "hexEmptyError": "16진수 색상은 비워둘 수 없습니다", + "hexLengthError": "16진수 값은 6자리여야 합니다", + "hexInvalidError": "잘못된 16진수 값", + "opacityEmptyError": "불투명도는 비워둘 수 없습니다", + "opacityRangeError": "불투명도는 1에서 100 사이여야 합니다", + "app": "앱", + "flowy": "Flowy", + "apply": "적용" + }, + "layoutDirection": { + "label": "레이아웃 방향", + "hint": "화면의 콘텐츠 흐름을 왼쪽에서 오른쪽 또는 오른쪽에서 왼쪽으로 제어합니다.", + "ltr": "LTR", + "rtl": "RTL" + }, + "textDirection": { + "label": "기본 텍스트 방향", + "hint": "텍스트가 기본적으로 왼쪽에서 시작할지 오른쪽에서 시작할지 지정합니다.", + "ltr": "LTR", + "rtl": "RTL", + "auto": "자동", + "fallback": "레이아웃 방향과 동일" }, "themeUpload": { "button": "업로드", - "description": "아래 버튼을 사용하여 나만의 AppFlowy 테마를 업로드하세요.", - "loading": "테마를 확인하고 업로드하는 동안 잠시 기다려 주십시오...", - "uploadSuccess": "테마가 성공적으로 업로드되었습니다.", - "deletionFailure": "테마를 삭제하지 못했습니다. 수동으로 삭제해 보십시오.", + "uploadTheme": "테마 업로드", + "description": "아래 버튼을 사용하여 사용자 정의 @:appName 테마를 업로드하세요.", + "loading": "테마를 검증하고 업로드하는 동안 기다려주세요...", + "uploadSuccess": "테마가 성공적으로 업로드되었습니다", + "deletionFailure": "테마를 삭제하지 못했습니다. 수동으로 삭제해 보세요.", "filePickerDialogTitle": ".flowy_plugin 파일 선택", - "urlUploadFailure": "URL을 열지 못했습니다: {}", - "failure": "업로드된 테마의 형식이 잘못되었습니다." + "urlUploadFailure": "URL을 열지 못했습니다: {}" }, - "theme": "주제", + "theme": "테마", "builtInsLabel": "내장 테마", "pluginsLabel": "플러그인", - "lightLabel": "라이트 모드", - "darkLabel": "다크 모드" + "dateFormat": { + "label": "날짜 형식", + "local": "로컬", + "us": "미국", + "iso": "ISO", + "friendly": "친숙한", + "dmy": "일/월/년" + }, + "timeFormat": { + "label": "시간 형식", + "twelveHour": "12시간 형식", + "twentyFourHour": "24시간 형식" + }, + "showNamingDialogWhenCreatingPage": "페이지 생성 시 이름 지정 대화 상자 표시", + "enableRTLToolbarItems": "RTL 도구 모음 항목 활성화", + "members": { + "title": "멤버 설정", + "inviteMembers": "멤버 초대", + "inviteHint": "이메일로 초대", + "sendInvite": "초대 보내기", + "copyInviteLink": "초대 링크 복사", + "label": "멤버", + "user": "사용자", + "role": "역할", + "removeFromWorkspace": "작업 공간에서 제거", + "removeFromWorkspaceSuccess": "작업 공간에서 성공적으로 제거되었습니다", + "removeFromWorkspaceFailed": "작업 공간에서 제거 실패", + "owner": "소유자", + "guest": "게스트", + "member": "멤버", + "memberHintText": "멤버는 페이지를 읽고 편집할 수 있습니다", + "guestHintText": "게스트는 페이지를 읽고, 반응하고, 댓글을 달 수 있으며, 권한이 있는 특정 페이지를 편집할 수 있습니다.", + "emailInvalidError": "잘못된 이메일입니다. 확인하고 다시 시도하세요", + "emailSent": "이메일이 전송되었습니다. 받은 편지함을 확인하세요", + "members": "멤버", + "membersCount": { + "zero": "{}명의 멤버", + "one": "{}명의 멤버", + "other": "{}명의 멤버" + }, + "inviteFailedDialogTitle": "초대 전송 실패", + "inviteFailedMemberLimit": "멤버 한도에 도달했습니다. 더 많은 멤버를 초대하려면 업그레이드하세요.", + "inviteFailedMemberLimitMobile": "작업 공간의 멤버 한도에 도달했습니다.", + "memberLimitExceeded": "멤버 한도에 도달했습니다. 더 많은 멤버를 초대하려면 ", + "memberLimitExceededUpgrade": "업그레이드", + "memberLimitExceededPro": "멤버 한도에 도달했습니다. 더 많은 멤버가 필요하면 ", + "memberLimitExceededProContact": "support@appflowy.io에 문의하세요", + "failedToAddMember": "멤버 추가 실패", + "addMemberSuccess": "멤버가 성공적으로 추가되었습니다", + "removeMember": "멤버 제거", + "areYouSureToRemoveMember": "이 멤버를 제거하시겠습니까?", + "inviteMemberSuccess": "초대가 성공적으로 전송되었습니다", + "failedToInviteMember": "멤버 초대 실패", + "workspaceMembersError": "문제가 발생했습니다", + "workspaceMembersErrorDescription": "현재 멤버 목록을 로드할 수 없습니다. 나중에 다시 시도하세요" + } }, "files": { "copy": "복사", "defaultLocation": "파일 및 데이터 저장 위치 읽기", "exportData": "데이터 내보내기", - "doubleTapToCopy": "경로를 복사하려면 두 번 탭하세요.", - "restoreLocation": "AppFlowy 기본 경로로 복원", + "doubleTapToCopy": "경로를 복사하려면 두 번 탭하세요", + "restoreLocation": "@:appName 기본 경로로 복원", "customizeLocation": "다른 폴더 열기", - "restartApp": "변경 사항을 적용하려면 앱을 다시 시작하십시오.", + "restartApp": "변경 사항을 적용하려면 앱을 재시작하세요.", "exportDatabase": "데이터베이스 내보내기", - "selectFiles": "내보낼 파일을 선택하십시오", + "selectFiles": "내보낼 파일 선택", "selectAll": "모두 선택", - "deselectAll": "모두 선택 취소", + "deselectAll": "모두 선택 해제", "createNewFolder": "새 폴더 만들기", - "createNewFolderDesc": "데이터를 저장할 위치를 알려주십시오.", + "createNewFolderDesc": "데이터를 저장할 위치를 알려주세요", "defineWhereYourDataIsStored": "데이터가 저장되는 위치 정의", - "open": "열려 있는", + "open": "열기", "openFolder": "기존 폴더 열기", - "openFolderDesc": "기존 AppFlowy 폴더에서 읽고 쓰기", + "openFolderDesc": "기존 @:appName 폴더를 읽고 쓰기", "folderHintText": "폴더 이름", "location": "새 폴더 만들기", - "locationDesc": "AppFlowy 데이터 폴더의 이름을 선택하세요", - "browser": "검색", - "create": "만들다", - "set": "세트", + "locationDesc": "@:appName 데이터 폴더의 이름을 지정하세요", + "browser": "찾아보기", + "create": "생성", + "set": "설정", "folderPath": "폴더를 저장할 경로", - "locationCannotBeEmpty": "경로는 비워둘 수 없습니다.", + "locationCannotBeEmpty": "경로는 비워둘 수 없습니다", "pathCopiedSnackbar": "파일 저장 경로가 클립보드에 복사되었습니다!", "changeLocationTooltips": "데이터 디렉토리 변경", - "change": "변화", + "change": "변경", "openLocationTooltips": "다른 데이터 디렉토리 열기", "openCurrentDataFolder": "현재 데이터 디렉토리 열기", - "recoverLocationTooltips": "AppFlowy의 기본 데이터 디렉터리로 재설정", - "exportFileSuccess": "파일을 성공적으로 내보냈습니다!", + "recoverLocationTooltips": "@:appName의 기본 데이터 디렉토리로 재설정", + "exportFileSuccess": "파일이 성공적으로 내보내졌습니다!", "exportFileFail": "파일 내보내기 실패!", - "export": "내보내다" + "export": "내보내기", + "clearCache": "캐시 지우기", + "clearCacheDesc": "이미지가 로드되지 않거나 글꼴이 제대로 표시되지 않는 등의 문제가 발생하면 캐시를 지워보세요. 이 작업은 사용자 데이터에는 영향을 미치지 않습니다.", + "areYouSureToClearCache": "캐시를 지우시겠습니까?", + "clearCacheSuccess": "캐시가 성공적으로 지워졌습니다!" }, "user": { "name": "이름", - "selectAnIcon": "아이콘을 선택하세요", - "pleaseInputYourOpenAIKey": "OpenAI 키를 입력하십시오" + "email": "이메일", + "tooltipSelectIcon": "아이콘 선택", + "selectAnIcon": "아이콘 선택", + "pleaseInputYourOpenAIKey": "AI 키를 입력하세요", + "clickToLogout": "현재 사용자 로그아웃" + }, + "mobile": { + "personalInfo": "개인 정보", + "username": "사용자 이름", + "usernameEmptyError": "사용자 이름은 비워둘 수 없습니다", + "about": "정보", + "pushNotifications": "푸시 알림", + "support": "지원", + "joinDiscord": "Discord에 참여", + "privacyPolicy": "개인정보 보호정책", + "userAgreement": "사용자 계약", + "termsAndConditions": "이용 약관", + "userprofileError": "사용자 프로필을 로드하지 못했습니다", + "userprofileErrorDescription": "로그아웃 후 다시 로그인하여 문제가 계속되는지 확인하세요.", + "selectLayout": "레이아웃 선택", + "selectStartingDay": "시작일 선택", + "version": "버전" } }, "grid": { "deleteView": "이 보기를 삭제하시겠습니까?", - "createView": "새로운", + "createView": "새로 만들기", + "title": { + "placeholder": "제목 없음" + }, "settings": { "filter": "필터", - "sort": "종류", + "sort": "정렬", "sortBy": "정렬 기준", "properties": "속성", - "reorderPropertiesTooltip": "드래그하여 속성 재정렬", + "reorderPropertiesTooltip": "속성 순서 변경", "group": "그룹", "addFilter": "필터 추가", "deleteFilter": "필터 삭제", - "filterBy": "필터링 기준...", - "typeAValue": "값을 입력하세요...", - "layout": "공들여 나열한 것", - "databaseLayout": "공들여 나열한 것", - "Properties": "속성" + "filterBy": "필터 기준", + "typeAValue": "값 입력...", + "layout": "레이아웃", + "compactMode": "압축 모드", + "databaseLayout": "레이아웃", + "viewList": { + "zero": "0개의 보기", + "one": "{count}개의 보기", + "other": "{count}개의 보기" + }, + "editView": "보기 편집", + "boardSettings": "보드 설정", + "calendarSettings": "캘린더 설정", + "createView": "새 보기", + "duplicateView": "보기 복제", + "deleteView": "보기 삭제", + "numberOfVisibleFields": "{}개 표시됨" + }, + "filter": { + "empty": "활성 필터 없음", + "addFilter": "필터 추가", + "cannotFindCreatableField": "필터링할 적절한 필드를 찾을 수 없습니다", + "conditon": "조건", + "where": "조건" }, "textFilter": { "contains": "포함", - "doesNotContain": "포함되어 있지 않다", - "endsWith": "로 끝나다", + "doesNotContain": "포함하지 않음", + "endsWith": "끝남", "startWith": "시작", - "is": "~이다", - "isNot": "아니다", - "isEmpty": "비었다", + "is": "일치", + "isNot": "일치하지 않음", + "isEmpty": "비어 있음", "isNotEmpty": "비어 있지 않음", "choicechipPrefix": { - "isNot": "아니다", + "isNot": "일치하지 않음", "startWith": "시작", - "endWith": "로 끝나다", - "isEmpty": "비었다", - "isNotEmpty": "비어있지 않다" + "endWith": "끝남", + "isEmpty": "비어 있음", + "isNotEmpty": "비어 있지 않음" } }, "checkboxFilter": { - "isChecked": "체크", - "isUnchecked": "체크 해제", + "isChecked": "체크됨", + "isUnchecked": "체크되지 않음", "choicechipPrefix": { - "is": "~이다" + "is": "체크됨" } }, "checklistFilter": { - "isComplete": "완료되었습니다", - "isIncomplted": "불완전하다" + "isComplete": "완료됨", + "isIncomplted": "미완료" }, "selectOptionFilter": { - "is": "~이다", - "isNot": "아니다", + "is": "일치", + "isNot": "일치하지 않음", "contains": "포함", - "doesNotContain": "포함되어 있지 않다", - "isEmpty": "비었다", + "doesNotContain": "포함하지 않음", + "isEmpty": "비어 있음", + "isNotEmpty": "비어 있지 않음" + }, + "dateFilter": { + "is": "일치", + "before": "이전", + "after": "이후", + "onOrBefore": "이전 또는 일치", + "onOrAfter": "이후 또는 일치", + "between": "사이", + "empty": "비어 있음", + "notEmpty": "비어 있지 않음", + "startDate": "시작 날짜", + "endDate": "종료 날짜", + "choicechipPrefix": { + "before": "이전", + "after": "이후", + "between": "사이", + "onOrBefore": "이전 또는 일치", + "onOrAfter": "이후 또는 일치", + "isEmpty": "비어 있음", + "isNotEmpty": "비어 있지 않음" + } + }, + "numberFilter": { + "equal": "일치", + "notEqual": "일치하지 않음", + "lessThan": "미만", + "greaterThan": "초과", + "lessThanOrEqualTo": "이하", + "greaterThanOrEqualTo": "이상", + "isEmpty": "비어 있음", "isNotEmpty": "비어 있지 않음" }, "field": { - "hide": "숨기기", - "insertLeft": "왼쪽 삽입", - "insertRight": "오른쪽 삽입", + "label": "속성", + "hide": "속성 숨기기", + "show": "속성 표시", + "insertLeft": "왼쪽에 삽입", + "insertRight": "오른쪽에 삽입", "duplicate": "복제", "delete": "삭제", + "wrapCellContent": "텍스트 줄 바꿈", + "clear": "셀 지우기", + "switchPrimaryFieldTooltip": "기본 필드의 필드 유형을 변경할 수 없습니다", "textFieldName": "텍스트", "checkboxFieldName": "체크박스", "dateFieldName": "날짜", - "updatedAtFieldName": "마지막 수정 시간", - "createdAtFieldName": "만든 시간", + "updatedAtFieldName": "마지막 수정", + "createdAtFieldName": "생성일", "numberFieldName": "숫자", "singleSelectFieldName": "선택", - "multiSelectFieldName": "다중선택", - "urlFieldName": "링크", + "multiSelectFieldName": "다중 선택", + "urlFieldName": "URL", "checklistFieldName": "체크리스트", + "relationFieldName": "관계", + "summaryFieldName": "AI 요약", + "timeFieldName": "시간", + "mediaFieldName": "파일 및 미디어", + "translateFieldName": "AI 번역", + "translateTo": "번역 대상", "numberFormat": "숫자 형식", "dateFormat": "날짜 형식", - "includeTime": "시간 표시", + "includeTime": "시간 포함", + "isRange": "종료 날짜", "dateFormatFriendly": "월 일, 년", "dateFormatISO": "년-월-일", "dateFormatLocal": "월/일/년", @@ -358,181 +1527,558 @@ "dateFormatDayMonthYear": "일/월/년", "timeFormat": "시간 형식", "invalidTimeFormat": "잘못된 형식", - "timeFormatTwelveHour": "12 시간", - "timeFormatTwentyFourHour": "24 시간", + "timeFormatTwelveHour": "12시간", + "timeFormatTwentyFourHour": "24시간", + "clearDate": "날짜 지우기", + "dateTime": "날짜 시간", + "startDateTime": "시작 날짜 시간", + "endDateTime": "종료 날짜 시간", + "failedToLoadDate": "날짜 값을 로드하지 못했습니다", + "selectTime": "시간 선택", + "selectDate": "날짜 선택", + "visibility": "가시성", + "propertyType": "속성 유형", "addSelectOption": "옵션 추가", + "typeANewOption": "새 옵션 입력", "optionTitle": "옵션", "addOption": "옵션 추가", "editProperty": "속성 편집", - "newProperty": "열 추가", - "deleteFieldPromptMessage": "해당 속성을 삭제 하시겠습니까?" + "newProperty": "새 속성", + "openRowDocument": "페이지로 열기", + "deleteFieldPromptMessage": "확실합니까? 이 속성과 모든 데이터가 삭제됩니다", + "clearFieldPromptMessage": "확실합니까? 이 열의 모든 셀이 비워집니다", + "newColumn": "새 열", + "format": "형식", + "reminderOnDateTooltip": "이 셀에는 예약된 알림이 있습니다", + "optionAlreadyExist": "옵션이 이미 존재합니다" + }, + "rowPage": { + "newField": "새 필드 추가", + "fieldDragElementTooltip": "메뉴 열기", + "showHiddenFields": { + "one": "숨겨진 {count}개의 필드 표시", + "many": "숨겨진 {count}개의 필드 표시", + "other": "숨겨진 {count}개의 필드 표시" + }, + "hideHiddenFields": { + "one": "숨겨진 {count}개의 필드 숨기기", + "many": "숨겨진 {count}개의 필드 숨기기", + "other": "숨겨진 {count}개의 필드 숨기기" + }, + "openAsFullPage": "전체 페이지로 열기", + "moreRowActions": "더 많은 행 작업" }, "sort": { "ascending": "오름차순", "descending": "내림차순", + "by": "기준", + "empty": "활성 정렬 없음", + "cannotFindCreatableField": "정렬할 적절한 필드를 찾을 수 없습니다", + "deleteAllSorts": "모든 정렬 삭제", "addSort": "정렬 추가", - "deleteSort": "정렬 삭제" + "sortsActive": "정렬 중에는 {intention}할 수 없습니다", + "removeSorting": "이 보기의 모든 정렬을 제거하고 계속하시겠습니까?", + "fieldInUse": "이미 이 필드로 정렬 중입니다" }, "row": { + "label": "행", "duplicate": "복제", "delete": "삭제", - "textPlaceholder": "비어있음", - "copyProperty": "속성이 클립보드로 복사됨", + "titlePlaceholder": "제목 없음", + "textPlaceholder": "비어 있음", + "copyProperty": "속성이 클립보드에 복사되었습니다", "count": "개수", - "newRow": "행 추가", - "action": "행동" + "newRow": "새 행", + "loadMore": "더 로드", + "action": "작업", + "add": "아래에 추가하려면 클릭", + "drag": "이동하려면 드래그", + "deleteRowPrompt": "이 행을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "deleteCardPrompt": "이 카드를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "dragAndClick": "이동하려면 드래그, 메뉴를 열려면 클릭", + "insertRecordAbove": "위에 레코드 삽입", + "insertRecordBelow": "아래에 레코드 삽입", + "noContent": "내용 없음", + "reorderRowDescription": "행 순서 변경", + "createRowAboveDescription": "위에 행 생성", + "createRowBelowDescription": "아래에 행 삽입" }, "selectOption": { "create": "생성", "purpleColor": "보라색", - "pinkColor": "핑크색", - "lightPinkColor": "연한 핑크색", - "orangeColor": "오렌지색", - "yellowColor": "노랑색", + "pinkColor": "분홍색", + "lightPinkColor": "연분홍색", + "orangeColor": "주황색", + "yellowColor": "노란색", "limeColor": "라임색", - "greenColor": "초록색", - "aquaColor": "아쿠아색", - "blueColor": "파랑색", + "greenColor": "녹색", + "aquaColor": "청록색", + "blueColor": "파란색", "deleteTag": "태그 삭제", "colorPanelTitle": "색상", "panelTitle": "옵션 선택 또는 생성", - "searchOption": "옵션 검색" + "searchOption": "옵션 검색", + "searchOrCreateOption": "옵션 검색 또는 생성", + "createNew": "새로 생성", + "orSelectOne": "또는 옵션 선택", + "typeANewOption": "새 옵션 입력", + "tagName": "태그 이름" }, "checklist": { - "addNew": "항목 추가" + "taskHint": "작업 설명", + "addNew": "새 작업 추가", + "submitNewTask": "생성", + "hideComplete": "완료된 작업 숨기기", + "showComplete": "모든 작업 표시" + }, + "url": { + "launch": "브라우저에서 링크 열기", + "copy": "링크를 클립보드에 복사", + "textFieldHint": "URL 입력" + }, + "relation": { + "relatedDatabasePlaceLabel": "관련 데이터베이스", + "relatedDatabasePlaceholder": "없음", + "inRelatedDatabase": "에", + "rowSearchTextFieldPlaceholder": "검색", + "noDatabaseSelected": "선택된 데이터베이스가 없습니다. 아래 목록에서 하나를 먼저 선택하세요:", + "emptySearchResult": "레코드를 찾을 수 없습니다", + "linkedRowListLabel": "{count}개의 연결된 행", + "unlinkedRowListLabel": "다른 행 연결" }, "menuName": "그리드", - "referencedGridPrefix": "관점" + "referencedGridPrefix": "보기", + "calculate": "계산", + "calculationTypeLabel": { + "none": "없음", + "average": "평균", + "max": "최대", + "median": "중앙값", + "min": "최소", + "sum": "합계", + "count": "개수", + "countEmpty": "비어 있는 개수", + "countEmptyShort": "비어 있음", + "countNonEmpty": "비어 있지 않은 개수", + "countNonEmptyShort": "채워짐" + }, + "media": { + "rename": "이름 변경", + "download": "다운로드", + "expand": "확장", + "delete": "삭제", + "moreFilesHint": "+{}", + "addFileOrImage": "파일 또는 링크 추가", + "attachmentsHint": "{}", + "addFileMobile": "파일 추가", + "extraCount": "+{}", + "deleteFileDescription": "이 파일을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "showFileNames": "파일 이름 표시", + "downloadSuccess": "파일이 다운로드되었습니다", + "downloadFailedToken": "파일을 다운로드하지 못했습니다. 사용자 토큰이 없습니다", + "setAsCover": "표지로 설정", + "openInBrowser": "브라우저에서 열기", + "embedLink": "파일 링크 삽입" + } }, "document": { - "menuName": "도큐먼트", + "menuName": "문서", "date": { - "timeHintTextInTwelveHour": "01:00 PM", + "timeHintTextInTwelveHour": "오후 01:00", "timeHintTextInTwentyFourHour": "13:00" }, + "creating": "생성 중...", "slashMenu": { "board": { "selectABoardToLinkTo": "연결할 보드 선택", - "createANewBoard": "새 보드 만들기" + "createANewBoard": "새 보드 생성" }, "grid": { "selectAGridToLinkTo": "연결할 그리드 선택", - "createANewGrid": "새 그리드 만들기" + "createANewGrid": "새 그리드 생성" }, "calendar": { "selectACalendarToLinkTo": "연결할 캘린더 선택", - "createANewCalendar": "새 캘린더 만들기" + "createANewCalendar": "새 캘린더 생성" + }, + "document": { + "selectADocumentToLinkTo": "연결할 문서 선택" + }, + "name": { + "textStyle": "텍스트 스타일", + "list": "목록", + "toggle": "토글", + "fileAndMedia": "파일 및 미디어", + "simpleTable": "간단한 테이블", + "visuals": "시각 자료", + "document": "문서", + "advanced": "고급", + "text": "텍스트", + "heading1": "헤딩 1", + "heading2": "헤딩 2", + "heading3": "헤딩 3", + "image": "이미지", + "bulletedList": "글머리 기호 목록", + "numberedList": "번호 매기기 목록", + "todoList": "할 일 목록", + "doc": "문서", + "linkedDoc": "페이지로 연결", + "grid": "그리드", + "linkedGrid": "연결된 그리드", + "kanban": "칸반", + "linkedKanban": "연결된 칸반", + "calendar": "캘린더", + "linkedCalendar": "연결된 캘린더", + "quote": "인용", + "divider": "구분선", + "table": "테이블", + "callout": "콜아웃", + "outline": "개요", + "mathEquation": "수학 방정식", + "code": "코드", + "toggleList": "토글 목록", + "toggleHeading1": "토글 헤딩 1", + "toggleHeading2": "토글 헤딩 2", + "toggleHeading3": "토글 헤딩 3", + "emoji": "이모지", + "aiWriter": "AI에게 물어보기", + "dateOrReminder": "날짜 또는 알림", + "photoGallery": "사진 갤러리", + "file": "파일", + "twoColumns": "2열", + "threeColumns": "3열", + "fourColumns": "4열" + }, + "subPage": { + "name": "문서", + "keyword1": "하위 페이지", + "keyword2": "페이지", + "keyword3": "자식 페이지", + "keyword4": "페이지 삽입", + "keyword5": "페이지 포함", + "keyword6": "새 페이지", + "keyword7": "페이지 생성", + "keyword8": "문서" } }, "selectionMenu": { - "outline": "개요" + "outline": "개요", + "codeBlock": "코드 블록" }, "plugins": { - "referencedBoard": "참조 보드", + "referencedBoard": "참조된 보드", "referencedGrid": "참조된 그리드", - "referencedCalendar": "참조된 달력", - "autoGeneratorMenuItemName": "OpenAI 작성자", - "autoGeneratorTitleName": "OpenAI: AI에게 무엇이든 쓰라고 요청하세요...", - "autoGeneratorLearnMore": "더 알아보기", - "autoGeneratorGenerate": "생성하다", - "autoGeneratorHintText": "OpenAI에게 물어보세요 ...", - "autoGeneratorCantGetOpenAIKey": "OpenAI 키를 가져올 수 없습니다.", - "autoGeneratorRewrite": "고쳐 쓰기", - "smartEdit": "AI 어시스턴트", - "openAI": "OpenAI", - "smartEditFixSpelling": "맞춤법 수정", + "referencedCalendar": "참조된 캘린더", + "referencedDocument": "참조된 문서", + "aiWriter": { + "userQuestion": "AI에게 물어보기", + "continueWriting": "계속 작성", + "fixSpelling": "맞춤법 및 문법 수정", + "improveWriting": "글쓰기 개선", + "summarize": "요약", + "explain": "설명", + "makeShorter": "짧게 만들기", + "makeLonger": "길게 만들기" + }, + "autoGeneratorMenuItemName": "AI 작성기", + "autoGeneratorTitleName": "AI: AI에게 무엇이든 물어보세요...", + "autoGeneratorLearnMore": "자세히 알아보기", + "autoGeneratorGenerate": "생성", + "autoGeneratorHintText": "AI에게 물어보기 ...", + "autoGeneratorCantGetOpenAIKey": "AI 키를 가져올 수 없습니다", + "autoGeneratorRewrite": "다시 작성", + "smartEdit": "AI에게 물어보기", + "aI": "AI", + "smartEditFixSpelling": "맞춤법 및 문법 수정", "warning": "⚠️ AI 응답은 부정확하거나 오해의 소지가 있을 수 있습니다.", - "smartEditSummarize": "요약하다", - "smartEditImproveWriting": "쓰기 향상", - "smartEditMakeLonger": "더 길게", - "smartEditCouldNotFetchResult": "OpenAI에서 결과를 가져올 수 없습니다.", - "smartEditCouldNotFetchKey": "OpenAI 키를 가져올 수 없습니다.", - "smartEditDisabled": "설정에서 OpenAI 연결", - "discardResponse": "AI 응답을 삭제하시겠습니까?", - "createInlineMathEquation": "방정식 만들기", + "smartEditSummarize": "요약", + "smartEditImproveWriting": "글쓰기 개선", + "smartEditMakeLonger": "길게 만들기", + "smartEditCouldNotFetchResult": "AI에서 결과를 가져올 수 없습니다", + "smartEditCouldNotFetchKey": "AI 키를 가져올 수 없습니다", + "smartEditDisabled": "설정에서 AI 연결", + "appflowyAIEditDisabled": "AI 기능을 활성화하려면 로그인하세요", + "discardResponse": "AI 응답을 버리시겠습니까?", + "createInlineMathEquation": "방정식 생성", + "fonts": "글꼴", + "insertDate": "날짜 삽입", + "emoji": "이모지", "toggleList": "토글 목록", + "emptyToggleHeading": "빈 토글 h{}. 내용을 추가하려면 클릭하세요.", + "emptyToggleList": "빈 토글 목록. 내용을 추가하려면 클릭하세요.", + "emptyToggleHeadingWeb": "빈 토글 h{level}. 내용을 추가하려면 클릭하세요", + "quoteList": "인용 목록", + "numberedList": "번호 매기기 목록", + "bulletedList": "글머리 기호 목록", + "todoList": "할 일 목록", + "callout": "콜아웃", + "simpleTable": { + "moreActions": { + "color": "색상", + "align": "정렬", + "delete": "삭제", + "duplicate": "복제", + "insertLeft": "왼쪽에 삽입", + "insertRight": "오른쪽에 삽입", + "insertAbove": "위에 삽입", + "insertBelow": "아래에 삽입", + "headerColumn": "헤더 열", + "headerRow": "헤더 행", + "clearContents": "내용 지우기", + "setToPageWidth": "페이지 너비로 설정", + "distributeColumnsWidth": "열 너비 균등 분배", + "duplicateRow": "행 복제", + "duplicateColumn": "열 복제", + "textColor": "텍스트 색상", + "cellBackgroundColor": "셀 배경 색상", + "duplicateTable": "테이블 복제" + }, + "clickToAddNewRow": "새 행을 추가하려면 클릭", + "clickToAddNewColumn": "새 열을 추가하려면 클릭", + "clickToAddNewRowAndColumn": "새 행과 열을 추가하려면 클릭", + "headerName": { + "table": "테이블", + "alignText": "텍스트 정렬" + } + }, "cover": { - "changeCover": "커버를 바꾸다", - "colors": "그림 물감", + "changeCover": "표지 변경", + "colors": "색상", "images": "이미지", "clearAll": "모두 지우기", - "abstract": "추상적인", + "abstract": "추상", "addCover": "표지 추가", "addLocalImage": "로컬 이미지 추가", "invalidImageUrl": "잘못된 이미지 URL", - "failedToAddImageToGallery": "갤러리에 이미지를 추가하지 못했습니다.", + "failedToAddImageToGallery": "갤러리에 이미지를 추가하지 못했습니다", "enterImageUrl": "이미지 URL 입력", - "add": "추가하다", - "back": "뒤쪽에", + "add": "추가", + "back": "뒤로", "saveToGallery": "갤러리에 저장", "removeIcon": "아이콘 제거", + "removeCover": "표지 제거", "pasteImageUrl": "이미지 URL 붙여넣기", "or": "또는", "pickFromFiles": "파일에서 선택", - "couldNotFetchImage": "이미지를 가져올 수 없습니다.", + "couldNotFetchImage": "이미지를 가져올 수 없습니다", "imageSavingFailed": "이미지 저장 실패", "addIcon": "아이콘 추가", + "changeIcon": "아이콘 변경", "coverRemoveAlert": "삭제 후 표지에서 제거됩니다.", - "alertDialogConfirmation": "너 정말 계속하고 싶니?" + "alertDialogConfirmation": "계속하시겠습니까?" }, "mathEquation": { - "addMathEquation": "수학 방정식 추가", + "name": "수학 방정식", + "addMathEquation": "TeX 방정식 추가", "editMathEquation": "수학 방정식 편집" }, "optionAction": { - "click": "딸깍 하는 소리", + "click": "클릭", "toOpenMenu": " 메뉴 열기", + "drag": "드래그", + "toMove": " 이동", "delete": "삭제", - "duplicate": "복제하다", - "turnInto": "로 변하다", - "moveUp": "이동", + "duplicate": "복제", + "turnInto": "변환", + "moveUp": "위로 이동", "moveDown": "아래로 이동", "color": "색상", - "align": "맞추다", + "align": "정렬", "left": "왼쪽", - "center": "센터", + "center": "가운데", "right": "오른쪽", - "defaultColor": "기본" + "defaultColor": "기본", + "depth": "깊이", + "copyLinkToBlock": "블록 링크 복사" }, "image": { - "copiedToPasteBoard": "이미지 링크가 클립보드에 복사되었습니다." + "addAnImage": "이미지 추가", + "copiedToPasteBoard": "이미지 링크가 클립보드에 복사되었습니다", + "addAnImageDesktop": "이미지 추가", + "addAnImageMobile": "하나 이상의 이미지를 추가하려면 클릭", + "dropImageToInsert": "이미지를 드롭하여 삽입", + "imageUploadFailed": "이미지 업로드 실패", + "imageDownloadFailed": "이미지 다운로드 실패, 다시 시도하세요", + "imageDownloadFailedToken": "사용자 토큰이 없어 이미지 다운로드 실패, 다시 시도하세요", + "errorCode": "오류 코드" + }, + "photoGallery": { + "name": "사진 갤러리", + "imageKeyword": "이미지", + "imageGalleryKeyword": "이미지 갤러리", + "photoKeyword": "사진", + "photoBrowserKeyword": "사진 브라우저", + "galleryKeyword": "갤러리", + "addImageTooltip": "이미지 추가", + "changeLayoutTooltip": "레이아웃 변경", + "browserLayout": "브라우저", + "gridLayout": "그리드", + "deleteBlockTooltip": "전체 갤러리 삭제" + }, + "math": { + "copiedToPasteBoard": "수학 방정식이 클립보드에 복사되었습니다" + }, + "urlPreview": { + "copiedToPasteBoard": "링크가 클립보드에 복사되었습니다", + "convertToLink": "링크로 변환" }, "outline": { - "addHeadingToCreateOutline": "제목을 추가하여 목차를 만듭니다." - } + "addHeadingToCreateOutline": "목차를 만들려면 헤딩을 추가하세요.", + "noMatchHeadings": "일치하는 헤딩이 없습니다." + }, + "table": { + "addAfter": "뒤에 추가", + "addBefore": "앞에 추가", + "delete": "삭제", + "clear": "내용 지우기", + "duplicate": "복제", + "bgColor": "배경 색상" + }, + "contextMenu": { + "copy": "복사", + "cut": "잘라내기", + "paste": "붙여넣기", + "pasteAsPlainText": "서식 없이 붙여넣기" + }, + "action": "작업", + "database": { + "selectDataSource": "데이터 소스 선택", + "noDataSource": "데이터 소스 없음", + "selectADataSource": "데이터 소스 선택", + "toContinue": "계속하려면", + "newDatabase": "새 데이터베이스", + "linkToDatabase": "데이터베이스로 연결" + }, + "date": "날짜", + "video": { + "label": "비디오", + "emptyLabel": "비디오 추가", + "placeholder": "비디오 링크 붙여넣기", + "copiedToPasteBoard": "비디오 링크가 클립보드에 복사되었습니다", + "insertVideo": "비디오 추가", + "invalidVideoUrl": "지원되지 않는 소스 URL입니다.", + "invalidVideoUrlYouTube": "YouTube는 아직 지원되지 않습니다.", + "supportedFormats": "지원되는 형식: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" + }, + "file": { + "name": "파일", + "uploadTab": "업로드", + "uploadMobile": "파일 선택", + "uploadMobileGallery": "사진 갤러리에서", + "networkTab": "링크 삽입", + "placeholderText": "파일 업로드 또는 삽입", + "placeholderDragging": "업로드할 파일을 드롭하세요", + "dropFileToUpload": "업로드할 파일을 드롭하세요", + "fileUploadHint": "파일을 드래그 앤 드롭하거나 클릭하여 ", + "fileUploadHintSuffix": "찾아보기", + "networkHint": "파일 링크 붙여넣기", + "networkUrlInvalid": "잘못된 URL입니다. URL을 확인하고 다시 시도하세요.", + "networkAction": "삽입", + "fileTooBigError": "파일 크기가 너무 큽니다. 10MB 미만의 파일을 업로드하세요", + "renameFile": { + "title": "파일 이름 변경", + "description": "이 파일의 새 이름을 입력하세요", + "nameEmptyError": "파일 이름은 비워둘 수 없습니다." + }, + "uploadedAt": "{}에 업로드됨", + "linkedAt": "{}에 링크 추가됨", + "failedToOpenMsg": "열지 못했습니다. 파일을 찾을 수 없습니다" + }, + "subPage": { + "handlingPasteHint": " - (붙여넣기 처리 중)", + "errors": { + "failedDeletePage": "페이지 삭제 실패", + "failedCreatePage": "페이지 생성 실패", + "failedMovePage": "이 문서로 페이지 이동 실패", + "failedDuplicatePage": "페이지 복제 실패", + "failedDuplicateFindView": "페이지 복제 실패 - 원본 보기를 찾을 수 없습니다" + } + }, + "cannotMoveToItsChildren": "자식으로 이동할 수 없습니다" + }, + "outlineBlock": { + "placeholder": "목차" }, "textBlock": { - "placeholder": "명령에 '/' 입력" + "placeholder": "명령어를 입력하려면 '/'를 입력하세요" }, "title": { - "placeholder": "무제" + "placeholder": "제목 없음" }, "imageBlock": { - "placeholder": "이미지를 추가하려면 클릭하세요.", + "placeholder": "이미지 추가하려면 클릭", "upload": { "label": "업로드", - "placeholder": "이미지를 업로드하려면 클릭하세요." + "placeholder": "이미지 업로드하려면 클릭" }, "url": { "label": "이미지 URL", "placeholder": "이미지 URL 입력" }, + "ai": { + "label": "AI로 이미지 생성", + "placeholder": "AI가 이미지를 생성할 프롬프트를 입력하세요" + }, + "stability_ai": { + "label": "Stability AI로 이미지 생성", + "placeholder": "Stability AI가 이미지를 생성할 프롬프트를 입력하세요" + }, "support": "이미지 크기 제한은 5MB입니다. 지원되는 형식: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "잘못된 이미지", - "invalidImageSize": "이미지 크기는 5MB 미만이어야 합니다.", - "invalidImageFormat": "이미지 형식은 지원되지 않습니다. 지원되는 형식: JPEG, PNG, GIF, SVG", - "invalidImageUrl": "잘못된 이미지 URL" + "invalidImageSize": "이미지 크기는 5MB 미만이어야 합니다", + "invalidImageFormat": "지원되지 않는 이미지 형식입니다. 지원되는 형식: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "잘못된 이미지 URL", + "noImage": "파일 또는 디렉토리가 없습니다", + "multipleImagesFailed": "하나 이상의 이미지 업로드 실패, 다시 시도하세요" + }, + "embedLink": { + "label": "링크 삽입", + "placeholder": "이미지 링크를 붙여넣거나 입력하세요" + }, + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "이미지 검색", + "pleaseInputYourOpenAIKey": "설정 페이지에서 AI 키를 입력하세요", + "saveImageToGallery": "이미지 저장", + "failedToAddImageToGallery": "이미지 저장 실패", + "successToAddImageToGallery": "이미지가 사진에 저장되었습니다", + "unableToLoadImage": "이미지를 로드할 수 없습니다", + "maximumImageSize": "최대 지원 업로드 이미지 크기는 10MB입니다", + "uploadImageErrorImageSizeTooBig": "이미지 크기는 10MB 미만이어야 합니다", + "imageIsUploading": "이미지 업로드 중", + "openFullScreen": "전체 화면으로 열기", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "이전 이미지", + "nextImageTooltip": "다음 이미지", + "zoomOutTooltip": "축소", + "zoomInTooltip": "확대", + "changeZoomLevelTooltip": "확대/축소 수준 변경", + "openLocalImage": "이미지 열기", + "downloadImage": "이미지 다운로드", + "closeViewer": "인터랙티브 뷰어 닫기", + "scalePercentage": "{}%", + "deleteImageTooltip": "이미지 삭제" + } } }, "codeBlock": { "language": { "label": "언어", - "placeholder": "언어 선택" - } + "placeholder": "언어 선택", + "auto": "자동" + }, + "copyTooltip": "복사", + "searchLanguageHint": "언어 검색", + "codeCopiedSnackbar": "코드가 클립보드에 복사되었습니다!" }, "inlineLink": { - "placeholder": "링크 붙여넣기 또는 입력", + "placeholder": "링크를 붙여넣거나 입력하세요", + "openInNewTab": "새 탭에서 열기", + "copyLink": "링크 복사", + "removeLink": "링크 제거", "url": { "label": "링크 URL", "placeholder": "링크 URL 입력" @@ -541,61 +2087,1101 @@ "label": "링크 제목", "placeholder": "링크 제목 입력" } + }, + "mention": { + "placeholder": "사람, 페이지 또는 날짜 언급...", + "page": { + "label": "페이지로 연결", + "tooltip": "페이지 열기" + }, + "deleted": "삭제됨", + "deletedContent": "이 콘텐츠는 존재하지 않거나 삭제되었습니다", + "noAccess": "액세스 불가", + "deletedPage": "삭제된 페이지", + "trashHint": " - 휴지통에 있음", + "morePages": "더 많은 페이지" + }, + "toolbar": { + "resetToDefaultFont": "기본값으로 재설정", + "textSize": "텍스트 크기", + "h1": "헤딩 1", + "h2": "헤딩 2", + "h3": "헤딩 3", + "alignLeft": "왼쪽 정렬", + "alignRight": "오른쪽 정렬", + "alignCenter": "가운데 정렬", + "link": "링크", + "textAlign": "텍스트 정렬", + "moreOptions": "더 많은 옵션", + "font": "글꼴", + "suggestions": "제안", + "turnInto": "변환" + }, + "errorBlock": { + "theBlockIsNotSupported": "블록 콘텐츠를 구문 분석할 수 없습니다", + "clickToCopyTheBlockContent": "블록 콘텐츠를 복사하려면 클릭", + "blockContentHasBeenCopied": "블록 콘텐츠가 복사되었습니다.", + "parseError": "{} 블록을 구문 분석하는 동안 오류가 발생했습니다.", + "copyBlockContent": "블록 콘텐츠 복사" + }, + "mobilePageSelector": { + "title": "페이지 선택", + "failedToLoad": "페이지 목록을 로드하지 못했습니다", + "noPagesFound": "페이지를 찾을 수 없습니다" + }, + "attachmentMenu": { + "choosePhoto": "사진 선택", + "takePicture": "사진 찍기", + "chooseFile": "파일 선택" } }, "board": { "column": { - "createNewCard": "추가" + "label": "열", + "createNewCard": "새로 만들기", + "renameGroupTooltip": "그룹 이름 변경", + "createNewColumn": "새 그룹 추가", + "addToColumnTopTooltip": "맨 위에 새 카드 추가", + "addToColumnBottomTooltip": "맨 아래에 새 카드 추가", + "renameColumn": "이름 변경", + "hideColumn": "숨기기", + "newGroup": "새 그룹", + "deleteColumn": "삭제", + "deleteColumnConfirmation": "이 그룹과 그룹 내 모든 카드를 삭제합니다. 계속하시겠습니까?" }, - "menuName": "판자", - "referencedBoardPrefix": "관점", + "hiddenGroupSection": { + "sectionTitle": "숨겨진 그룹", + "collapseTooltip": "숨겨진 그룹 숨기기", + "expandTooltip": "숨겨진 그룹 보기" + }, + "cardDetail": "카드 세부 정보", + "cardActions": "카드 작업", + "cardDuplicated": "카드가 복제되었습니다", + "cardDeleted": "카드가 삭제되었습니다", + "showOnCard": "카드 세부 정보에 표시", + "setting": "설정", + "propertyName": "속성 이름", + "menuName": "보드", + "showUngrouped": "그룹화되지 않은 항목 표시", + "ungroupedButtonText": "그룹화되지 않음", + "ungroupedButtonTooltip": "어떤 그룹에도 속하지 않는 카드가 포함되어 있습니다", + "ungroupedItemsTitle": "보드에 추가하려면 클릭", + "groupBy": "그룹 기준", + "groupCondition": "그룹 조건", + "referencedBoardPrefix": "보기", + "notesTooltip": "내부에 노트 있음", "mobile": { + "editURL": "URL 편집", "showGroup": "그룹 표시", "showGroupContent": "이 그룹을 보드에 표시하시겠습니까?", - "failedToLoad": "보드 보기를 로드하지 못했습니다." + "failedToLoad": "보드 보기를 로드하지 못했습니다" + }, + "dateCondition": { + "weekOf": "{} - {} 주", + "today": "오늘", + "yesterday": "어제", + "tomorrow": "내일", + "lastSevenDays": "지난 7일", + "nextSevenDays": "다음 7일", + "lastThirtyDays": "지난 30일", + "nextThirtyDays": "다음 30일" + }, + "noGroup": "그룹화할 속성 없음", + "noGroupDesc": "보드 보기를 표시하려면 그룹화할 속성이 필요합니다", + "media": { + "cardText": "{} {}", + "fallbackName": "파일" } }, "calendar": { - "menuName": "달력", - "defaultNewCalendarTitle": "무제", + "menuName": "캘린더", + "defaultNewCalendarTitle": "제목 없음", + "newEventButtonTooltip": "새 이벤트 추가", "navigation": { "today": "오늘", "jumpToday": "오늘로 이동", - "previousMonth": "지난달", - "nextMonth": "다음 달" + "previousMonth": "이전 달", + "nextMonth": "다음 달", + "views": { + "day": "일", + "week": "주", + "month": "월", + "year": "년" + } + }, + "mobileEventScreen": { + "emptyTitle": "이벤트 없음", + "emptyBody": "이 날에 이벤트를 생성하려면 더하기 버튼을 누르세요." }, "settings": { "showWeekNumbers": "주 번호 표시", - "showWeekends": "주말 보기", - "firstDayOfWeek": "주 시작", - "layoutDateField": "레이아웃 캘린더", + "showWeekends": "주말 표시", + "firstDayOfWeek": "주 시작일", + "layoutDateField": "캘린더 레이아웃 기준", + "changeLayoutDateField": "레이아웃 필드 변경", "noDateTitle": "날짜 없음", - "clickToAdd": "캘린더에 추가하려면 클릭하세요.", - "name": "달력 레이아웃", - "noDateHint": "예약되지 않은 일정이 여기에 표시됩니다." + "noDateHint": { + "zero": "일정이 없는 이벤트가 여기에 표시됩니다", + "one": "{count}개의 일정이 없는 이벤트", + "other": "{count}개의 일정이 없는 이벤트" + }, + "unscheduledEventsTitle": "일정이 없는 이벤트", + "clickToAdd": "캘린더에 추가하려면 클릭", + "name": "캘린더 설정", + "clickToOpen": "레코드를 열려면 클릭" }, - "referencedCalendarPrefix": "관점" + "referencedCalendarPrefix": "보기", + "quickJumpYear": "이동", + "duplicateEvent": "이벤트 복제" }, "errorDialog": { - "title": "AppFlowy 오류", - "howToFixFallback": "불편을 끼쳐드려 죄송합니다! 오류를 설명하는 문제를 GitHub 페이지에 제출하세요.", + "title": "@:appName 오류", + "howToFixFallback": "불편을 드려 죄송합니다! GitHub 페이지에 오류를 설명하는 문제를 제출하세요.", + "howToFixFallbackHint1": "불편을 드려 죄송합니다! ", + "howToFixFallbackHint2": " 페이지에 오류를 설명하는 문제를 제출하세요.", "github": "GitHub에서 보기" }, "search": { - "label": "찾다", + "label": "검색", + "sidebarSearchIcon": "검색하고 페이지로 빠르게 이동", "placeholder": { - "actions": "검색 작업..." + "actions": "작업 검색..." } }, "message": { "copy": { - "success": "복사했습니다!", + "success": "복사됨!", "fail": "복사할 수 없음" } }, - "unSupportBlock": "현재 버전은 이 블록을 지원하지 않습니다.", + "unSupportBlock": "현재 버전에서는 이 블록을 지원하지 않습니다.", "views": { "deleteContentTitle": "{pageType}을(를) 삭제하시겠습니까?", "deleteContentCaption": "이 {pageType}을(를) 삭제하면 휴지통에서 복원할 수 있습니다." + }, + "colors": { + "custom": "사용자 정의", + "default": "기본", + "red": "빨간색", + "orange": "주황색", + "yellow": "노란색", + "green": "녹색", + "blue": "파란색", + "purple": "보라색", + "pink": "분홍색", + "brown": "갈색", + "gray": "회색" + }, + "emoji": { + "emojiTab": "이모지", + "search": "이모지 검색", + "noRecent": "최근 사용한 이모지 없음", + "noEmojiFound": "이모지를 찾을 수 없음", + "filter": "필터", + "random": "무작위", + "selectSkinTone": "피부 톤 선택", + "remove": "이모지 제거", + "categories": { + "smileys": "스마일리 및 감정", + "people": "사람", + "animals": "자연", + "food": "음식", + "activities": "활동", + "places": "장소", + "objects": "사물", + "symbols": "기호", + "flags": "깃발", + "nature": "자연", + "frequentlyUsed": "자주 사용됨" + }, + "skinTone": { + "default": "기본", + "light": "밝은", + "mediumLight": "중간 밝은", + "medium": "중간", + "mediumDark": "중간 어두운", + "dark": "어두운" + }, + "openSourceIconsFrom": "오픈 소스 아이콘 제공" + }, + "inlineActions": { + "noResults": "결과 없음", + "recentPages": "최근 페이지", + "pageReference": "페이지 참조", + "docReference": "문서 참조", + "boardReference": "보드 참조", + "calReference": "캘린더 참조", + "gridReference": "그리드 참조", + "date": "날짜", + "reminder": { + "groupTitle": "알림", + "shortKeyword": "알림" + }, + "createPage": "\"{}\" 하위 페이지 생성" + }, + "datePicker": { + "dateTimeFormatTooltip": "설정에서 날짜 및 시간 형식 변경", + "dateFormat": "날짜 형식", + "includeTime": "시간 포함", + "isRange": "종료 날짜", + "timeFormat": "시간 형식", + "clearDate": "날짜 지우기", + "reminderLabel": "알림", + "selectReminder": "알림 선택", + "reminderOptions": { + "none": "없음", + "atTimeOfEvent": "이벤트 시간", + "fiveMinsBefore": "5분 전", + "tenMinsBefore": "10분 전", + "fifteenMinsBefore": "15분 전", + "thirtyMinsBefore": "30분 전", + "oneHourBefore": "1시간 전", + "twoHoursBefore": "2시간 전", + "onDayOfEvent": "이벤트 당일", + "oneDayBefore": "1일 전", + "twoDaysBefore": "2일 전", + "oneWeekBefore": "1주일 전", + "custom": "사용자 정의" + } + }, + "relativeDates": { + "yesterday": "어제", + "today": "오늘", + "tomorrow": "내일", + "oneWeek": "1주일" + }, + "notificationHub": { + "title": "알림", + "mobile": { + "title": "업데이트" + }, + "emptyTitle": "모두 확인했습니다!", + "emptyBody": "대기 중인 알림이나 작업이 없습니다. 평온을 즐기세요.", + "tabs": { + "inbox": "받은 편지함", + "upcoming": "다가오는" + }, + "actions": { + "markAllRead": "모두 읽음으로 표시", + "showAll": "모두", + "showUnreads": "읽지 않음" + }, + "filters": { + "ascending": "오름차순", + "descending": "내림차순", + "groupByDate": "날짜별 그룹", + "showUnreadsOnly": "읽지 않은 항목만 표시", + "resetToDefault": "기본값으로 재설정" + } + }, + "reminderNotification": { + "title": "알림", + "message": "잊기 전에 확인하세요!", + "tooltipDelete": "삭제", + "tooltipMarkRead": "읽음으로 표시", + "tooltipMarkUnread": "읽지 않음으로 표시" + }, + "findAndReplace": { + "find": "찾기", + "previousMatch": "이전 일치 항목", + "nextMatch": "다음 일치 항목", + "close": "닫기", + "replace": "교체", + "replaceAll": "모두 교체", + "noResult": "결과 없음", + "caseSensitive": "대소문자 구분", + "searchMore": "더 많은 결과를 찾으려면 검색" + }, + "error": { + "weAreSorry": "죄송합니다", + "loadingViewError": "이 보기를 로드하는 데 문제가 있습니다. 인터넷 연결을 확인하고 앱을 새로 고침하세요. 문제가 계속되면 팀에 문의하세요.", + "syncError": "다른 장치에서 데이터가 동기화되지 않음", + "syncErrorHint": "마지막으로 편집한 장치에서 이 페이지를 다시 열고 현재 장치에서 다시 열어보세요.", + "clickToCopy": "오류 코드를 복사하려면 클릭" + }, + "editor": { + "bold": "굵게", + "bulletedList": "글머리 기호 목록", + "bulletedListShortForm": "글머리 기호", + "checkbox": "체크박스", + "embedCode": "코드 삽입", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "강조", + "color": "색상", + "image": "이미지", + "date": "날짜", + "page": "페이지", + "italic": "기울임꼴", + "link": "링크", + "numberedList": "번호 매기기 목록", + "numberedListShortForm": "번호 매기기", + "toggleHeading1ShortForm": "토글 h1", + "toggleHeading2ShortForm": "토글 h2", + "toggleHeading3ShortForm": "토글 h3", + "quote": "인용", + "strikethrough": "취소선", + "text": "텍스트", + "underline": "밑줄", + "fontColorDefault": "기본", + "fontColorGray": "회색", + "fontColorBrown": "갈색", + "fontColorOrange": "주황색", + "fontColorYellow": "노란색", + "fontColorGreen": "녹색", + "fontColorBlue": "파란색", + "fontColorPurple": "보라색", + "fontColorPink": "분홍색", + "fontColorRed": "빨간색", + "backgroundColorDefault": "기본 배경", + "backgroundColorGray": "회색 배경", + "backgroundColorBrown": "갈색 배경", + "backgroundColorOrange": "주황색 배경", + "backgroundColorYellow": "노란색 배경", + "backgroundColorGreen": "녹색 배경", + "backgroundColorBlue": "파란색 배경", + "backgroundColorPurple": "보라색 배경", + "backgroundColorPink": "분홍색 배경", + "backgroundColorRed": "빨간색 배경", + "backgroundColorLime": "라임색 배경", + "backgroundColorAqua": "청록색 배경", + "done": "완료", + "cancel": "취소", + "tint1": "색조 1", + "tint2": "색조 2", + "tint3": "색조 3", + "tint4": "색조 4", + "tint5": "색조 5", + "tint6": "색조 6", + "tint7": "색조 7", + "tint8": "색조 8", + "tint9": "색조 9", + "lightLightTint1": "보라색", + "lightLightTint2": "분홍색", + "lightLightTint3": "연분홍색", + "lightLightTint4": "주황색", + "lightLightTint5": "노란색", + "lightLightTint6": "라임색", + "lightLightTint7": "녹색", + "lightLightTint8": "청록색", + "lightLightTint9": "파란색", + "urlHint": "URL", + "mobileHeading1": "헤딩 1", + "mobileHeading2": "헤딩 2", + "mobileHeading3": "헤딩 3", + "mobileHeading4": "헤딩 4", + "mobileHeading5": "헤딩 5", + "mobileHeading6": "헤딩 6", + "textColor": "텍스트 색상", + "backgroundColor": "배경 색상", + "addYourLink": "링크 추가", + "openLink": "링크 열기", + "copyLink": "링크 복사", + "removeLink": "링크 제거", + "editLink": "링크 편집", + "linkText": "텍스트", + "linkTextHint": "텍스트를 입력하세요", + "linkAddressHint": "URL을 입력하세요", + "highlightColor": "강조 색상", + "clearHighlightColor": "강조 색상 지우기", + "customColor": "사용자 정의 색상", + "hexValue": "16진수 값", + "opacity": "불투명도", + "resetToDefaultColor": "기본 색상으로 재설정", + "ltr": "LTR", + "rtl": "RTL", + "auto": "자동", + "cut": "잘라내기", + "copy": "복사", + "paste": "붙여넣기", + "find": "찾기", + "select": "선택", + "selectAll": "모두 선택", + "previousMatch": "이전 일치 항목", + "nextMatch": "다음 일치 항목", + "closeFind": "닫기", + "replace": "교체", + "replaceAll": "모두 교체", + "regex": "정규식", + "caseSensitive": "대소문자 구분", + "uploadImage": "이미지 업로드", + "urlImage": "URL 이미지", + "incorrectLink": "잘못된 링크", + "upload": "업로드", + "chooseImage": "이미지 선택", + "loading": "로드 중", + "imageLoadFailed": "이미지 로드 실패", + "divider": "구분선", + "table": "테이블", + "colAddBefore": "앞에 추가", + "rowAddBefore": "앞에 추가", + "colAddAfter": "뒤에 추가", + "rowAddAfter": "뒤에 추가", + "colRemove": "제거", + "rowRemove": "제거", + "colDuplicate": "복제", + "rowDuplicate": "복제", + "colClear": "내용 지우기", + "rowClear": "내용 지우기", + "slashPlaceHolder": "'/'를 입력하여 블록을 삽입하거나 입력 시작", + "typeSomething": "무언가 입력...", + "toggleListShortForm": "토글", + "quoteListShortForm": "인용", + "mathEquationShortForm": "수식", + "codeBlockShortForm": "코드" + }, + "favorite": { + "noFavorite": "즐겨찾기 페이지 없음", + "noFavoriteHintText": "페이지를 왼쪽으로 스와이프하여 즐겨찾기에 추가하세요", + "removeFromSidebar": "사이드바에서 제거", + "addToSidebar": "사이드바에 고정" + }, + "cardDetails": { + "notesPlaceholder": "/를 입력하여 블록을 삽입하거나 입력 시작" + }, + "blockPlaceholders": { + "todoList": "할 일", + "bulletList": "목록", + "numberList": "목록", + "quote": "인용", + "heading": "헤딩 {}" + }, + "titleBar": { + "pageIcon": "페이지 아이콘", + "language": "언어", + "font": "글꼴", + "actions": "작업", + "date": "날짜", + "addField": "필드 추가", + "userIcon": "사용자 아이콘" + }, + "noLogFiles": "로그 파일이 없습니다", + "newSettings": { + "myAccount": { + "title": "내 계정", + "subtitle": "프로필을 사용자 정의하고, 계정 보안을 관리하고, AI 키를 열거나 계정에 로그인하세요.", + "profileLabel": "계정 이름 및 프로필 이미지", + "profileNamePlaceholder": "이름 입력", + "accountSecurity": "계정 보안", + "2FA": "2단계 인증", + "aiKeys": "AI 키", + "accountLogin": "계정 로그인", + "updateNameError": "이름 업데이트 실패", + "updateIconError": "아이콘 업데이트 실패", + "aboutAppFlowy": "@:appName 정보", + "deleteAccount": { + "title": "계정 삭제", + "subtitle": "계정과 모든 데이터를 영구적으로 삭제합니다.", + "description": "계정을 영구적으로 삭제하고 모든 작업 공간에서 액세스를 제거합니다.", + "deleteMyAccount": "내 계정 삭제", + "dialogTitle": "계정 삭제", + "dialogContent1": "계정을 영구적으로 삭제하시겠습니까?", + "dialogContent2": "이 작업은 되돌릴 수 없으며, 모든 작업 공간에서 액세스를 제거하고, 개인 작업 공간을 포함한 전체 계정을 삭제하며, 모든 공유 작업 공간에서 제거됩니다.", + "confirmHint1": "\"@:newSettings.myAccount.deleteAccount.confirmHint3\"를 입력하여 확인하세요.", + "confirmHint2": "이 작업은 되돌릴 수 없으며, 계정과 모든 관련 데이터를 영구적으로 삭제합니다.", + "confirmHint3": "내 계정 삭제", + "checkToConfirmError": "삭제를 확인하려면 확인란을 선택해야 합니다", + "failedToGetCurrentUser": "현재 사용자 이메일을 가져오지 못했습니다", + "confirmTextValidationFailed": "확인 텍스트가 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"와 일치하지 않습니다", + "deleteAccountSuccess": "계정이 성공적으로 삭제되었습니다" + } + }, + "workplace": { + "name": "작업 공간", + "title": "작업 공간 설정", + "subtitle": "작업 공간의 외관, 테마, 글꼴, 텍스트 레이아웃, 날짜, 시간 및 언어를 사용자 정의합니다.", + "workplaceName": "작업 공간 이름", + "workplaceNamePlaceholder": "작업 공간 이름 입력", + "workplaceIcon": "작업 공간 아이콘", + "workplaceIconSubtitle": "작업 공간에 대한 이미지를 업로드하거나 이모지를 사용하세요. 아이콘은 사이드바와 알림에 표시됩니다.", + "renameError": "작업 공간 이름 변경 실패", + "updateIconError": "아이콘 업데이트 실패", + "chooseAnIcon": "아이콘 선택", + "appearance": { + "name": "외관", + "themeMode": { + "auto": "자동", + "light": "라이트", + "dark": "다크" + }, + "language": "언어" + } + }, + "syncState": { + "syncing": "동기화 중", + "synced": "동기화됨", + "noNetworkConnected": "네트워크에 연결되지 않음" + } + }, + "pageStyle": { + "title": "페이지 스타일", + "layout": "레이아웃", + "coverImage": "표지 이미지", + "pageIcon": "페이지 아이콘", + "colors": "색상", + "gradient": "그라데이션", + "backgroundImage": "배경 이미지", + "presets": "프리셋", + "photo": "사진", + "unsplash": "Unsplash", + "pageCover": "페이지 표지", + "none": "없음", + "openSettings": "설정 열기", + "photoPermissionTitle": "@:appName가 사진 라이브러리에 접근하려고 합니다", + "photoPermissionDescription": "@:appName가 문서에 이미지를 추가할 수 있도록 사진에 접근해야 합니다", + "cameraPermissionTitle": "@:appName가 카메라에 접근하려고 합니다", + "cameraPermissionDescription": "카메라에서 문서에 이미지를 추가하려면 @:appName가 카메라에 액세스할 수 있어야 합니다.", + "doNotAllow": "허용하지 않음", + "image": "이미지" + }, + "commandPalette": { + "placeholder": "검색하거나 질문하세요...", + "bestMatches": "최고의 일치 항목", + "recentHistory": "최근 기록", + "navigateHint": "탐색하려면", + "loadingTooltip": "결과를 찾고 있습니다...", + "betaLabel": "베타", + "betaTooltip": "현재 문서의 페이지 및 콘텐츠 검색만 지원합니다", + "fromTrashHint": "휴지통에서", + "noResultsHint": "찾고 있는 항목을 찾지 못했습니다. 다른 용어로 검색해 보세요.", + "clearSearchTooltip": "검색 필드 지우기" + }, + "space": { + "delete": "삭제", + "deleteConfirmation": "삭제: ", + "deleteConfirmationDescription": "이 공간 내의 모든 페이지가 삭제되어 휴지통으로 이동되며, 게시한 모든 페이지가 게시 취소됩니다.", + "rename": "공간 이름 변경", + "changeIcon": "아이콘 변경", + "manage": "공간 관리", + "addNewSpace": "새 공간 생성", + "collapseAllSubPages": "모든 하위 페이지 접기", + "createNewSpace": "새 공간 생성", + "createSpaceDescription": "작업을 더 잘 조직하기 위해 여러 공용 및 비공개 공간을 생성하세요.", + "spaceName": "공간 이름", + "spaceNamePlaceholder": "예: 마케팅, 엔지니어링, 인사", + "permission": "공간 권한", + "publicPermission": "공용", + "publicPermissionDescription": "전체 액세스 권한이 있는 모든 작업 공간 멤버", + "privatePermission": "비공개", + "privatePermissionDescription": "이 공간에 접근할 수 있는 사람은 본인뿐입니다", + "spaceIconBackground": "배경 색상", + "spaceIcon": "아이콘", + "dangerZone": "위험 구역", + "unableToDeleteLastSpace": "마지막 공간을 삭제할 수 없습니다", + "unableToDeleteSpaceNotCreatedByYou": "다른 사람이 생성한 공간을 삭제할 수 없습니다", + "enableSpacesForYourWorkspace": "작업 공간에 공간을 활성화하세요", + "title": "공간", + "defaultSpaceName": "일반", + "upgradeSpaceTitle": "공간 활성화", + "upgradeSpaceDescription": "작업 공간을 더 잘 조직하기 위해 여러 공용 및 비공개 공간을 생성하세요.", + "upgrade": "업데이트", + "upgradeYourSpace": "여러 공간 생성", + "quicklySwitch": "다음 공간으로 빠르게 전환", + "duplicate": "공간 복제", + "movePageToSpace": "페이지를 공간으로 이동", + "cannotMovePageToDatabase": "페이지를 데이터베이스로 이동할 수 없습니다", + "switchSpace": "공간 전환", + "spaceNameCannotBeEmpty": "공간 이름은 비워둘 수 없습니다", + "success": { + "deleteSpace": "공간이 성공적으로 삭제되었습니다", + "renameSpace": "공간 이름이 성공적으로 변경되었습니다", + "duplicateSpace": "공간이 성공적으로 복제되었습니다", + "updateSpace": "공간이 성공적으로 업데이트되었습니다" + }, + "error": { + "deleteSpace": "공간 삭제 실패", + "renameSpace": "공간 이름 변경 실패", + "duplicateSpace": "공간 복제 실패", + "updateSpace": "공간 업데이트 실패" + }, + "createSpace": "공간 생성", + "manageSpace": "공간 관리", + "renameSpace": "공간 이름 변경", + "mSpaceIconColor": "공간 아이콘 색상", + "mSpaceIcon": "공간 아이콘" + }, + "publish": { + "hasNotBeenPublished": "이 페이지는 아직 게시되지 않았습니다", + "spaceHasNotBeenPublished": "아직 공간 게시를 지원하지 않습니다", + "reportPage": "페이지 신고", + "databaseHasNotBeenPublished": "데이터베이스 게시를 아직 지원하지 않습니다.", + "createdWith": "제작", + "downloadApp": "AppFlowy 다운로드", + "copy": { + "codeBlock": "코드 블록의 내용이 클립보드에 복사되었습니다", + "imageBlock": "이미지 링크가 클립보드에 복사되었습니다", + "mathBlock": "수학 방정식이 클립보드에 복사되었습니다", + "fileBlock": "파일 링크가 클립보드에 복사되었습니다" + }, + "containsPublishedPage": "이 페이지에는 하나 이상의 게시된 페이지가 포함되어 있습니다. 계속하면 게시가 취소됩니다. 삭제를 진행하시겠습니까?", + "publishSuccessfully": "성공적으로 게시되었습니다", + "unpublishSuccessfully": "성공적으로 게시 취소되었습니다", + "publishFailed": "게시 실패", + "unpublishFailed": "게시 취소 실패", + "noAccessToVisit": "이 페이지에 접근할 수 없습니다...", + "createWithAppFlowy": "AppFlowy로 웹사이트 만들기", + "fastWithAI": "AI로 빠르고 쉽게.", + "tryItNow": "지금 시도해보세요", + "onlyGridViewCanBePublished": "그리드 보기만 게시할 수 있습니다", + "database": { + "zero": "선택한 {} 보기 게시", + "one": "선택한 {} 보기 게시", + "many": "선택한 {} 보기 게시", + "other": "선택한 {} 보기 게시" + }, + "mustSelectPrimaryDatabase": "기본 보기를 선택해야 합니다", + "noDatabaseSelected": "선택된 데이터베이스가 없습니다. 최소 하나의 데이터베이스를 선택하세요.", + "unableToDeselectPrimaryDatabase": "기본 보기를 선택 해제할 수 없습니다", + "saveThisPage": "이 템플릿으로 시작", + "duplicateTitle": "추가할 위치 선택", + "selectWorkspace": "작업 공간 선택", + "addTo": "추가", + "duplicateSuccessfully": "작업 공간에 추가되었습니다", + "duplicateSuccessfullyDescription": "AppFlowy가 설치되어 있지 않습니까? '다운로드'를 클릭하면 다운로드가 자동으로 시작됩니다.", + "downloadIt": "다운로드", + "openApp": "앱에서 열기", + "duplicateFailed": "복제 실패", + "membersCount": { + "zero": "멤버 없음", + "one": "1명의 멤버", + "many": "{count}명의 멤버", + "other": "{count}명의 멤버" + }, + "useThisTemplate": "템플릿 사용" + }, + "web": { + "continue": "계속", + "or": "또는", + "continueWithGoogle": "Google로 계속", + "continueWithGithub": "GitHub로 계속", + "continueWithDiscord": "Discord로 계속", + "continueWithApple": "Apple로 계속", + "moreOptions": "더 많은 옵션", + "collapse": "접기", + "signInAgreement": "\"계속\"을 클릭하면 AppFlowy의", + "and": "및", + "termOfUse": "이용 약관", + "privacyPolicy": "개인정보 보호정책", + "signInError": "로그인 오류", + "login": "가입 또는 로그인", + "fileBlock": { + "uploadedAt": "{time}에 업로드됨", + "linkedAt": "{time}에 링크 추가됨", + "empty": "파일 업로드 또는 삽입", + "uploadFailed": "업로드 실패, 다시 시도하세요", + "retry": "다시 시도" + }, + "importNotion": "Notion에서 가져오기", + "import": "가져오기", + "importSuccess": "성공적으로 업로드되었습니다", + "importSuccessMessage": "가져오기가 완료되면 알림을 받게 됩니다. 이후 사이드바에서 가져온 페이지를 확인할 수 있습니다.", + "importFailed": "가져오기 실패, 파일 형식을 확인하세요", + "dropNotionFile": "Notion zip 파일을 여기에 드롭하여 업로드하거나 클릭하여 찾아보기", + "error": { + "pageNameIsEmpty": "페이지 이름이 비어 있습니다. 다른 이름을 시도하세요" + } + }, + "globalComment": { + "comments": "댓글", + "addComment": "댓글 추가", + "reactedBy": "반응한 사람", + "addReaction": "반응 추가", + "reactedByMore": "및 {count}명", + "showSeconds": { + "one": "1초 전", + "other": "{count}초 전", + "zero": "방금", + "many": "{count}초 전" + }, + "showMinutes": { + "one": "1분 전", + "other": "{count}분 전", + "many": "{count}분 전" + }, + "showHours": { + "one": "1시간 전", + "other": "{count}시간 전", + "many": "{count}시간 전" + }, + "showDays": { + "one": "1일 전", + "other": "{count}일 전", + "many": "{count}일 전" + }, + "showMonths": { + "one": "1개월 전", + "other": "{count}개월 전", + "many": "{count}개월 전" + }, + "showYears": { + "one": "1년 전", + "other": "{count}년 전", + "many": "{count}년 전" + }, + "reply": "답글", + "deleteComment": "댓글 삭제", + "youAreNotOwner": "이 댓글의 소유자가 아닙니다", + "confirmDeleteDescription": "이 댓글을 삭제하시겠습니까?", + "hasBeenDeleted": "삭제됨", + "replyingTo": "답글 대상", + "noAccessDeleteComment": "이 댓글을 삭제할 수 없습니다", + "collapse": "접기", + "readMore": "더 읽기", + "failedToAddComment": "댓글 추가 실패", + "commentAddedSuccessfully": "댓글이 성공적으로 추가되었습니다.", + "commentAddedSuccessTip": "댓글을 추가하거나 답글을 달았습니다. 최신 댓글을 보려면 상단으로 이동하시겠습니까?" + }, + "template": { + "asTemplate": "템플릿으로 저장", + "name": "템플릿 이름", + "description": "템플릿 설명", + "about": "템플릿 정보", + "deleteFromTemplate": "템플릿에서 삭제", + "preview": "템플릿 미리보기", + "categories": "템플릿 카테고리", + "isNewTemplate": "새 템플릿에 고정", + "featured": "추천에 고정", + "relatedTemplates": "관련 템플릿", + "requiredField": "{field}은(는) 필수 항목입니다", + "addCategory": "\"{category}\" 추가", + "addNewCategory": "새 카테고리 추가", + "addNewCreator": "새 제작자 추가", + "deleteCategory": "카테고리 삭제", + "editCategory": "카테고리 편집", + "editCreator": "제작자 편집", + "category": { + "name": "카테고리 이름", + "icon": "카테고리 아이콘", + "bgColor": "카테고리 배경 색상", + "priority": "카테고리 우선순위", + "desc": "카테고리 설명", + "type": "카테고리 유형", + "icons": "카테고리 아이콘", + "colors": "카테고리 색상", + "byUseCase": "사용 사례별", + "byFeature": "기능별", + "deleteCategory": "카테고리 삭제", + "deleteCategoryDescription": "이 카테고리를 삭제하시겠습니까?", + "typeToSearch": "카테고리 검색..." + }, + "creator": { + "label": "템플릿 제작자", + "name": "제작자 이름", + "avatar": "제작자 아바타", + "accountLinks": "제작자 계정 링크", + "uploadAvatar": "아바타 업로드", + "deleteCreator": "제작자 삭제", + "deleteCreatorDescription": "이 제작자를 삭제하시겠습니까?", + "typeToSearch": "제작자 검색..." + }, + "uploadSuccess": "템플릿이 성공적으로 업로드되었습니다", + "uploadSuccessDescription": "템플릿이 성공적으로 업로드되었습니다. 이제 템플릿 갤러리에서 확인할 수 있습니다.", + "viewTemplate": "템플릿 보기", + "deleteTemplate": "템플릿 삭제", + "deleteSuccess": "템플릿이 성공적으로 삭제되었습니다", + "deleteTemplateDescription": "현재 페이지나 게시 상태에는 영향을 미치지 않습니다. 이 템플릿을 삭제하시겠습니까?", + "addRelatedTemplate": "관련 템플릿 추가", + "removeRelatedTemplate": "관련 템플릿 제거", + "uploadAvatar": "아바타 업로드", + "searchInCategory": "{category}에서 검색", + "label": "템플릿" + }, + "fileDropzone": { + "dropFile": "업로드하려면 파일을 클릭하거나 드래그하세요", + "uploading": "업로드 중...", + "uploadFailed": "업로드 실패", + "uploadSuccess": "업로드 성공", + "uploadSuccessDescription": "파일이 성공적으로 업로드되었습니다", + "uploadFailedDescription": "파일 업로드 실패", + "uploadingDescription": "파일이 업로드 중입니다" + }, + "gallery": { + "preview": "전체 화면으로 열기", + "copy": "복사", + "download": "다운로드", + "prev": "이전", + "next": "다음", + "resetZoom": "확대/축소 재설정", + "zoomIn": "확대", + "zoomOut": "축소" + }, + "invitation": { + "join": "참여", + "on": "에", + "invitedBy": "초대자", + "membersCount": { + "zero": "{count}명의 멤버", + "one": "{count}명의 멤버", + "many": "{count}명의 멤버", + "other": "{count}명의 멤버" + }, + "tip": "아래 연락처 정보로 이 작업 공간에 참여하도록 초대되었습니다. 정보가 잘못된 경우 관리자에게 연락하여 초대를 다시 보내달라고 요청하세요.", + "joinWorkspace": "작업 공간 참여", + "success": "작업 공간에 성공적으로 참여했습니다", + "successMessage": "이제 작업 공간 내의 모든 페이지와 작업 공간에 접근할 수 있습니다.", + "openWorkspace": "AppFlowy 열기", + "alreadyAccepted": "이미 초대를 수락했습니다", + "errorModal": { + "title": "문제가 발생했습니다", + "description": "현재 계정 {email}이(가) 이 작업 공간에 접근할 수 없을 수 있습니다. 올바른 계정으로 로그인하거나 작업 공간 소유자에게 도움을 요청하세요.", + "contactOwner": "소유자에게 연락", + "close": "홈으로 돌아가기", + "changeAccount": "계정 변경" + } + }, + "requestAccess": { + "title": "이 페이지에 접근할 수 없습니다", + "subtitle": "이 페이지의 소유자에게 접근을 요청할 수 있습니다. 승인되면 페이지를 볼 수 있습니다.", + "requestAccess": "접근 요청", + "backToHome": "홈으로 돌아가기", + "tip": "현재 로 로그인 중입니다.", + "mightBe": "다른 계정으로 해야 할 수 있습니다.", + "successful": "요청이 성공적으로 전송되었습니다", + "successfulMessage": "소유자가 요청을 승인하면 알림을 받게 됩니다.", + "requestError": "접근 요청 실패", + "repeatRequestError": "이미 이 페이지에 접근을 요청했습니다" + }, + "approveAccess": { + "title": "작업 공간 참여 요청 승인", + "requestSummary": "이(가) 에 참여하고 에 접근하려고 요청합니다", + "upgrade": "업그레이드", + "downloadApp": "AppFlowy 다운로드", + "approveButton": "승인", + "approveSuccess": "성공적으로 승인되었습니다", + "approveError": "승인 실패, 작업 공간 플랜 한도를 초과하지 않았는지 확인하세요", + "getRequestInfoError": "요청 정보를 가져오지 못했습니다", + "memberCount": { + "zero": "멤버 없음", + "one": "1명의 멤버", + "many": "{count}명의 멤버", + "other": "{count}명의 멤버" + }, + "alreadyProTitle": "작업 공간 플랜 한도에 도달했습니다", + "alreadyProMessage": "에 연락하여 더 많은 멤버를 잠금 해제하도록 요청하세요", + "repeatApproveError": "이미 이 요청을 승인했습니다", + "ensurePlanLimit": "작업 공간 플랜 한도를 초과하지 않았는지 확인하세요. 한도를 초과한 경우 작업 공간 플랜을 하거나 를 고려하세요.", + "requestToJoin": "참여 요청", + "asMember": "멤버로" + }, + "upgradePlanModal": { + "title": "Pro로 업그레이드", + "message": "{name}이(가) 무료 멤버 한도에 도달했습니다. 더 많은 멤버를 초대하려면 Pro 플랜으로 업그레이드하세요.", + "upgradeSteps": "AppFlowy에서 플랜을 업그레이드하는 방법:", + "step1": "1. 설정으로 이동", + "step2": "2. '플랜' 클릭", + "step3": "3. '플랜 변경' 선택", + "appNote": "참고: ", + "actionButton": "업그레이드", + "downloadLink": "앱 다운로드", + "laterButton": "나중에", + "refreshNote": "성공적으로 업그레이드한 후 새 기능을 활성화하려면 를 클릭하세요.", + "refresh": "여기" + }, + "breadcrumbs": { + "label": "탐색 경로" + }, + "time": { + "justNow": "방금", + "seconds": { + "one": "1초", + "other": "{count}초" + }, + "minutes": { + "one": "1분", + "other": "{count}분" + }, + "hours": { + "one": "1시간", + "other": "{count}시간" + }, + "days": { + "one": "1일", + "other": "{count}일" + }, + "weeks": { + "one": "1주일", + "other": "{count}주일" + }, + "months": { + "one": "1개월", + "other": "{count}개월" + }, + "years": { + "one": "1년", + "other": "{count}년" + }, + "ago": "전", + "yesterday": "어제", + "today": "오늘" + }, + "members": { + "zero": "멤버 없음", + "one": "1명의 멤버", + "many": "{count}명의 멤버", + "other": "{count}명의 멤버" + }, + "tabMenu": { + "close": "닫기", + "closeDisabledHint": "고정된 탭은 닫을 수 없습니다. 먼저 고정을 해제하세요", + "closeOthers": "다른 탭 닫기", + "closeOthersHint": "이 탭을 제외한 모든 고정되지 않은 탭을 닫습니다", + "closeOthersDisabledHint": "모든 탭이 고정되어 있어 닫을 탭을 찾을 수 없습니다", + "favorite": "즐겨찾기", + "unfavorite": "즐겨찾기 해제", + "favoriteDisabledHint": "이 보기를 즐겨찾기에 추가할 수 없습니다", + "pinTab": "고정", + "unpinTab": "고정 해제" + }, + "openFileMessage": { + "success": "파일이 성공적으로 열렸습니다", + "fileNotFound": "파일을 찾을 수 없습니다", + "noAppToOpenFile": "이 파일을 열 수 있는 앱이 없습니다", + "permissionDenied": "이 파일을 열 수 있는 권한이 없습니다", + "unknownError": "파일 열기 실패" + }, + "inviteMember": { + "requestInviteMembers": "작업 공간에 초대", + "inviteFailedMemberLimit": "멤버 한도에 도달했습니다. ", + "upgrade": "업그레이드", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "초대 보내기", + "inviteAlready": "이미 이 이메일을 초대했습니다: {email}", + "inviteSuccess": "초대가 성공적으로 전송되었습니다", + "description": "아래에 이메일을 쉼표로 구분하여 입력하세요. 멤버 수에 따라 요금이 부과됩니다.", + "emails": "이메일" + }, + "quickNote": { + "label": "빠른 노트", + "quickNotes": "빠른 노트", + "search": "빠른 노트 검색", + "collapseFullView": "전체 보기 축소", + "expandFullView": "전체 보기 확장", + "createFailed": "빠른 노트 생성 실패", + "quickNotesEmpty": "빠른 노트 없음", + "emptyNote": "빈 노트", + "deleteNotePrompt": "선택한 노트가 영구적으로 삭제됩니다. 삭제하시겠습니까?", + "addNote": "새 노트", + "noAdditionalText": "추가 텍스트 없음" + }, + "subscribe": { + "upgradePlanTitle": "플랜 비교 및 선택", + "yearly": "연간", + "save": "{discount}% 절약", + "monthly": "월별", + "priceIn": "가격 ", + "free": "무료", + "pro": "Pro", + "freeDescription": "모든 것을 정리하기 위한 최대 2명의 개인용", + "proDescription": "프로젝트 및 팀 지식을 관리하기 위한 소규모 팀용", + "proDuration": { + "monthly": "월별 청구되는 멤버당 월별", + "yearly": "연간 청구되는 멤버당 월별" + }, + "cancel": "다운그레이드", + "changePlan": "Pro 플랜으로 업그레이드", + "everythingInFree": "무료 플랜의 모든 기능 +", + "currentPlan": "현재", + "freeDuration": "영원히", + "freePoints": { + "first": "최대 2명의 협업 작업 공간", + "second": "무제한 페이지 및 블록", + "three": "5 GB 저장 공간", + "four": "지능형 검색", + "five": "20 AI 응답", + "six": "모바일 앱", + "seven": "실시간 협업" + }, + "proPoints": { + "first": "무제한 저장 공간", + "second": "최대 10명의 작업 공간 멤버", + "three": "무제한 AI 응답", + "four": "무제한 파일 업로드", + "five": "맞춤 네임스페이스" + }, + "cancelPlan": { + "title": "떠나셔서 아쉽습니다", + "success": "구독이 성공적으로 취소되었습니다", + "description": "@:appName을 개선하는 데 도움이 되도록 피드백을 듣고 싶습니다. 몇 가지 질문에 답변해 주세요.", + "commonOther": "기타", + "otherHint": "여기에 답변을 작성하세요", + "questionOne": { + "question": "@:appName Pro 구독을 취소한 이유는 무엇입니까?", + "answerOne": "비용이 너무 높음", + "answerTwo": "기능이 기대에 미치지 못함", + "answerThree": "더 나은 대안을 찾음", + "answerFour": "비용을 정당화할 만큼 충분히 사용하지 않음", + "answerFive": "서비스 문제 또는 기술적 어려움" + }, + "questionTwo": { + "question": "미래에 @:appName Pro를 다시 구독할 가능성은 얼마나 됩니까?", + "answerOne": "매우 가능성이 높음", + "answerTwo": "어느 정도 가능성이 있음", + "answerThree": "잘 모르겠음", + "answerFour": "가능성이 낮음", + "answerFive": "매우 가능성이 낮음" + }, + "questionThree": { + "question": "구독 기간 동안 가장 가치 있게 여긴 Pro 기능은 무엇입니까?", + "answerOne": "다중 사용자 협업", + "answerTwo": "더 긴 시간 버전 기록", + "answerThree": "무제한 AI 응답", + "answerFour": "로컬 AI 모델 액세스" + }, + "questionFour": { + "question": "@:appName에 대한 전반적인 경험을 어떻게 설명하시겠습니까?", + "answerOne": "훌륭함", + "answerTwo": "좋음", + "answerThree": "보통", + "answerFour": "평균 이하", + "answerFive": "불만족" + } + } + }, + "ai": { + "contentPolicyViolation": "민감한 콘텐츠로 인해 이미지 생성에 실패했습니다. 입력을 다시 작성하고 다시 시도하세요", + "textLimitReachedDescription": "작업 공간의 무료 AI 응답이 부족합니다. Pro 플랜으로 업그레이드하거나 AI 애드온을 구매하여 무제한 응답을 잠금 해제하세요", + "imageLimitReachedDescription": "무료 AI 이미지 할당량을 모두 사용했습니다. Pro 플랜으로 업그레이드하거나 AI 애드온을 구매하여 무제한 응답을 잠금 해제하세요", + "limitReachedAction": { + "textDescription": "작업 공간의 무료 AI 응답이 부족합니다. 더 많은 응답을 받으려면 ", + "imageDescription": "무료 AI 이미지 할당량을 모두 사용했습니다. ", + "upgrade": "업그레이드", + "toThe": " ", + "proPlan": "Pro 플랜", + "orPurchaseAn": " 또는 ", + "aiAddon": "AI 애드온을 구매하세요" + }, + "editing": "편집 중", + "analyzing": "분석 중", + "continueWritingEmptyDocumentTitle": "계속 작성 오류", + "continueWritingEmptyDocumentDescription": "문서의 내용을 확장하는 데 문제가 있습니다. 간단한 소개를 작성하면 나머지는 우리가 처리할 수 있습니다!" + }, + "autoUpdate": { + "criticalUpdateTitle": "계속하려면 업데이트가 필요합니다", + "criticalUpdateDescription": "경험을 향상시키기 위해 개선 사항을 추가했습니다! 앱을 계속 사용하려면 {currentVersion}에서 {newVersion}으로 업데이트하세요.", + "criticalUpdateButton": "업데이트", + "bannerUpdateTitle": "새 버전 사용 가능!", + "bannerUpdateDescription": "최신 기능 및 수정 사항을 받으세요. 지금 설치하려면 \"업데이트\"를 클릭하세요", + "bannerUpdateButton": "업데이트", + "settingsUpdateTitle": "새 버전 ({newVersion}) 사용 가능!", + "settingsUpdateDescription": "현재 버전: {currentVersion} (공식 빌드) → {newVersion}", + "settingsUpdateButton": "업데이트", + "settingsUpdateWhatsNew": "새로운 기능" + }, + "lockPage": { + "lockPage": "잠금", + "reLockPage": "다시 잠금", + "lockTooltip": "실수로 편집하지 않도록 페이지가 잠겨 있습니다. 잠금 해제하려면 클릭하세요.", + "pageLockedToast": "페이지가 잠겼습니다. 누군가 잠금을 해제할 때까지 편집이 비활성화됩니다.", + "lockedOperationTooltip": "실수로 편집하지 않도록 페이지가 잠겨 있습니다." + }, + "suggestion": { + "accept": "수락", + "keep": "유지", + "discard": "버리기", + "close": "닫기", + "tryAgain": "다시 시도", + "rewrite": "다시 작성", + "insertBelow": "아래에 삽입" } } diff --git a/frontend/resources/translations/mr-IN.json b/frontend/resources/translations/mr-IN.json new file mode 100644 index 0000000000..f86a1e0081 --- /dev/null +++ b/frontend/resources/translations/mr-IN.json @@ -0,0 +1,3210 @@ +{ + "appName": "AppFlowy", + "defaultUsername": "मी", + "welcomeText": "@:appName मध्ये आ पले स्वागत आहे.", + "welcomeTo": "मध्ये आ पले स्वागत आ हे", + "githubStarText": "GitHub वर स्टार करा", + "subscribeNewsletterText": "वृत्तपत्राची सदस्यता घ्या", + "letsGoButtonText": "क्विक स्टार्ट", + "title": "Title", + "youCanAlso": "तुम्ही देखील", + "and": "आ णि", + "failedToOpenUrl": "URL उघडण्यात अयशस्वी: {}", + "blockActions": { + "addBelowTooltip": "खाली जोडण्यासाठी क्लिक करा", + "addAboveCmd": "Alt+click", + "addAboveMacCmd": "Option+click", + "addAboveTooltip": "वर जोडण्यासाठी", + "dragTooltip": "Drag to move", + "openMenuTooltip": "मेनू उघडण्यासाठी क्लिक करा" + }, + "signUp": { + "buttonText": "साइन अप", + "title": "साइन अप to @:appName", + "getStartedText": "सुरुवात करा", + "emptyPasswordError": "पासवर्ड रिकामा असू शकत नाही", + "repeatPasswordEmptyError": "Repeat पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "emailHint": "Email", + "passwordHint": "Password", + "repeatPasswordHint": "पासवर्ड पुन्हा लिहा", + "signUpWith": "यामध्ये साइन अप करा:" + }, + "signIn": { + "loginTitle": "@:appName मध्ये लॉगिन करा", + "loginButtonText": "लॉगिन", + "loginStartWithAnonymous": "अनामिक सत्रासह पुढे जा", + "continueAnonymousUser": "अनामिक सत्रासह पुढे जा", + "anonymous": "अनामिक", + "buttonText": "साइन इन", + "signingInText": "साइन इन होत आहे...", + "forgotPassword": "पासवर्ड विसरलात?", + "emailHint": "ईमेल", + "passwordHint": "पासवर्ड", + "dontHaveAnAccount": "तुमचं खाते नाही?", + "createAccount": "खाते तयार करा", + "repeatPasswordEmptyError": "पुन्हा पासवर्ड रिकामा असू शकत नाही", + "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", + "syncPromptMessage": "डेटा सिंक होण्यास थोडा वेळ लागू शकतो. कृपया हे पृष्ठ बंद करू नका", + "or": "किंवा", + "signInWithGoogle": "Google सह पुढे जा", + "signInWithGithub": "GitHub सह पुढे जा", + "signInWithDiscord": "Discord सह पुढे जा", + "signInWithApple": "Apple सह पुढे जा", + "continueAnotherWay": "इतर पर्यायांनी पुढे जा", + "signUpWithGoogle": "Google सह साइन अप करा", + "signUpWithGithub": "GitHub सह साइन अप करा", + "signUpWithDiscord": "Discord सह साइन अप करा", + "signInWith": "यासह पुढे जा:", + "signInWithEmail": "ईमेलसह पुढे जा", + "signInWithMagicLink": "पुढे जा", + "signUpWithMagicLink": "Magic Link सह साइन अप करा", + "pleaseInputYourEmail": "कृपया तुमचा ईमेल पत्ता टाका", + "settings": "सेटिंग्ज", + "magicLinkSent": "Magic Link पाठवण्यात आली आहे!", + "invalidEmail": "कृपया वैध ईमेल पत्ता टाका", + "alreadyHaveAnAccount": "आधीच खाते आहे?", + "logIn": "लॉगिन", + "generalError": "काहीतरी चुकलं. कृपया नंतर प्रयत्न करा", + "limitRateError": "सुरक्षेच्या कारणास्तव, तुम्ही दर ६० सेकंदांतून एकदाच Magic Link मागवू शकता", + "magicLinkSentDescription": "तुमच्या ईमेलवर Magic Link पाठवण्यात आली आहे. लॉगिन पूर्ण करण्यासाठी लिंकवर क्लिक करा. ही लिंक ५ मिनिटांत कालबाह्य होईल." + }, + "workspace": { + "chooseWorkspace": "तुमचे workspace निवडा", + "defaultName": "माझे Workspace", + "create": "नवीन workspace तयार करा", + "new": "नवीन workspace", + "importFromNotion": "Notion मधून आयात करा", + "learnMore": "अधिक जाणून घ्या", + "reset": "workspace रीसेट करा", + "renameWorkspace": "workspace चे नाव बदला", + "workspaceNameCannotBeEmpty": "workspace चे नाव रिकामे असू शकत नाही", + "resetWorkspacePrompt": "workspace रीसेट केल्यास त्यातील सर्व पृष्ठे आणि डेटा हटवले जातील. तुम्हाला workspace रीसेट करायचे आहे का? पर्यायी म्हणून तुम्ही सपोर्ट टीमशी संपर्क करू शकता.", + "hint": "workspace", + "notFoundError": "workspace सापडले नाही", + "failedToLoad": "काहीतरी चूक झाली! workspace लोड होण्यात अयशस्वी. कृपया @:appName चे कोणतेही उघडे instance बंद करा आणि पुन्हा प्रयत्न करा.", + "errorActions": { + "reportIssue": "समस्या नोंदवा", + "reportIssueOnGithub": "Github वर समस्या नोंदवा", + "exportLogFiles": "लॉग फाइल्स निर्यात करा", + "reachOut": "Discord वर संपर्क करा" + }, + "menuTitle": "कार्यक्षेत्रे", + "deleteWorkspaceHintText": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील.", + "createSuccess": "कार्यक्षेत्र यशस्वीरित्या तयार झाले", + "createFailed": "कार्यक्षेत्र तयार करण्यात अयशस्वी", + "createLimitExceeded": "तुम्ही तुमच्या खात्यासाठी परवानगी दिलेल्या कार्यक्षेत्र मर्यादेपर्यंत पोहोचलात. अधिक कार्यक्षेत्रासाठी GitHub वर विनंती करा.", + "deleteSuccess": "कार्यक्षेत्र यशस्वीरित्या हटवले गेले", + "deleteFailed": "कार्यक्षेत्र हटवण्यात अयशस्वी", + "openSuccess": "कार्यक्षेत्र यशस्वीरित्या उघडले", + "openFailed": "कार्यक्षेत्र उघडण्यात अयशस्वी", + "renameSuccess": "कार्यक्षेत्राचे नाव यशस्वीरित्या बदलले", + "renameFailed": "कार्यक्षेत्राचे नाव बदलण्यात अयशस्वी", + "updateIconSuccess": "कार्यक्षेत्राचे चिन्ह यशस्वीरित्या अद्यतनित केले", + "updateIconFailed": "कार्यक्षेत्राचे चिन्ह अद्यतनित करण्यात अयशस्वी", + "cannotDeleteTheOnlyWorkspace": "फक्त एकच कार्यक्षेत्र असल्यास ते हटवता येत नाही", + "fetchWorkspacesFailed": "कार्यक्षेत्रे मिळवण्यात अयशस्वी", + "leaveCurrentWorkspace": "कार्यक्षेत्र सोडा", + "leaveCurrentWorkspacePrompt": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का?" + }, + "shareAction": { + "buttonText": "शेअर करा", + "workInProgress": "लवकरच येत आहे", + "markdown": "Markdown", + "html": "HTML", + "clipboard": "क्लिपबोर्डवर कॉपी करा", + "csv": "CSV", + "copyLink": "लिंक कॉपी करा", + "publishToTheWeb": "वेबवर प्रकाशित करा", + "publishToTheWebHint": "AppFlowy सह वेबसाइट तयार करा", + "publish": "प्रकाशित करा", + "unPublish": "अप्रकाशित करा", + "visitSite": "साइटला भेट द्या", + "exportAsTab": "या स्वरूपात निर्यात करा", + "publishTab": "प्रकाशित करा", + "shareTab": "शेअर करा", + "publishOnAppFlowy": "AppFlowy वर प्रकाशित करा", + "shareTabTitle": "सहकार्य करण्यासाठी आमंत्रित करा", + "shareTabDescription": "कोणासही सहज सहकार्य करण्यासाठी", + "copyLinkSuccess": "लिंक क्लिपबोर्डवर कॉपी केली", + "copyShareLink": "शेअर लिंक कॉपी करा", + "copyLinkFailed": "लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "copyLinkToBlockSuccess": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी केली", + "copyLinkToBlockFailed": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", + "manageAllSites": "सर्व साइट्स व्यवस्थापित करा", + "updatePathName": "पथाचे नाव अपडेट करा" + }, + "moreAction": { + "small": "लहान", + "medium": "मध्यम", + "large": "मोठा", + "fontSize": "फॉन्ट आकार", + "import": "Import", + "moreOptions": "अधिक पर्याय", + "wordCount": "शब्द संख्या: {}", + "charCount": "अक्षर संख्या: {}", + "createdAt": "निर्मिती: {}", + "deleteView": "हटवा", + "duplicateView": "प्रत बनवा", + "wordCountLabel": "शब्द संख्या: ", + "charCountLabel": "अक्षर संख्या: ", + "createdAtLabel": "निर्मिती: ", + "syncedAtLabel": "सिंक केले: ", + "saveAsNewPage": "संदेश पृष्ठात जोडा", + "saveAsNewPageDisabled": "उपलब्ध संदेश नाहीत" + }, + "importPanel": { + "textAndMarkdown": "मजकूर आणि Markdown", + "documentFromV010": "v0.1.0 पासून दस्तऐवज", + "databaseFromV010": "v0.1.0 पासून डेटाबेस", + "notionZip": "Notion निर्यात केलेली Zip फाईल", + "csv": "CSV", + "database": "डेटाबेस" + }, + "emojiIconPicker": { + "iconUploader": { + "placeholderLeft": "फाईल ड्रॅग आणि ड्रॉप करा, क्लिक करा ", + "placeholderUpload": "अपलोड", + "placeholderRight": ", किंवा इमेज लिंक पेस्ट करा.", + "dropToUpload": "अपलोडसाठी फाईल ड्रॉप करा", + "change": "बदला" + } + }, + "disclosureAction": { + "rename": "नाव बदला", + "delete": "हटवा", + "duplicate": "प्रत बनवा", + "unfavorite": "आवडतीतून काढा", + "favorite": "आवडतीत जोडा", + "openNewTab": "नवीन टॅबमध्ये उघडा", + "moveTo": "या ठिकाणी हलवा", + "addToFavorites": "आवडतीत जोडा", + "copyLink": "लिंक कॉपी करा", + "changeIcon": "आयकॉन बदला", + "collapseAllPages": "सर्व उपपृष्ठे संकुचित करा", + "movePageTo": "पृष्ठ हलवा", + "move": "हलवा", + "lockPage": "पृष्ठ लॉक करा" + }, + "blankPageTitle": "रिक्त पृष्ठ", + "newPageText": "नवीन पृष्ठ", + "newDocumentText": "नवीन दस्तऐवज", + "newGridText": "नवीन ग्रिड", + "newCalendarText": "नवीन कॅलेंडर", + "newBoardText": "नवीन बोर्ड", + "chat": { + "newChat": "AI गप्पा", + "inputMessageHint": "@:appName AI ला विचार करा", + "inputLocalAIMessageHint": "@:appName लोकल AI ला विचार करा", + "unsupportedCloudPrompt": "ही सुविधा फक्त @:appName Cloud वापरताना उपलब्ध आहे", + "relatedQuestion": "सूचवलेले", + "serverUnavailable": "कनेक्शन गमावले. कृपया तुमचे इंटरनेट तपासा आणि पुन्हा प्रयत्न करा", + "aiServerUnavailable": "AI सेवा सध्या अनुपलब्ध आहे. कृपया नंतर पुन्हा प्रयत्न करा.", + "retry": "पुन्हा प्रयत्न करा", + "clickToRetry": "पुन्हा प्रयत्न करण्यासाठी क्लिक करा", + "regenerateAnswer": "उत्तर पुन्हा तयार करा", + "question1": "Kanban वापरून कामे कशी व्यवस्थापित करायची", + "question2": "GTD पद्धत समजावून सांगा", + "question3": "Rust का वापरावा", + "question4": "माझ्या स्वयंपाकघरात असलेल्या वस्तूंनी रेसिपी", + "question5": "माझ्या पृष्ठासाठी एक चित्र तयार करा", + "question6": "या आठवड्याची माझी कामांची यादी तयार करा", + "aiMistakePrompt": "AI चुकू शकतो. महत्त्वाची माहिती तपासा.", + "chatWithFilePrompt": "तुम्हाला फाइलसोबत गप्पा मारायच्या आहेत का?", + "indexFileSuccess": "फाईल यशस्वीरित्या अनुक्रमित केली गेली", + "inputActionNoPages": "काहीही पृष्ठे सापडली नाहीत", + "referenceSource": { + "zero": "0 स्रोत सापडले", + "one": "{count} स्रोत सापडला", + "other": "{count} स्रोत सापडले" + } + }, + "clickToMention": "पृष्ठाचा उल्लेख करा", + "uploadFile": "PDFs, मजकूर किंवा markdown फाइल्स जोडा", + "questionDetail": "नमस्कार {}! मी तुम्हाला कशी मदत करू शकतो?", + "indexingFile": "{} अनुक्रमित करत आहे", + "generatingResponse": "उत्तर तयार होत आहे", + "selectSources": "स्रोत निवडा", + "currentPage": "सध्याचे पृष्ठ", + "sourcesLimitReached": "तुम्ही फक्त ३ मुख्य दस्तऐवज आणि त्यांची उपदस्तऐवज निवडू शकता", + "sourceUnsupported": "सध्या डेटाबेससह चॅटिंगसाठी आम्ही समर्थन करत नाही", + "regenerate": "पुन्हा प्रयत्न करा", + "addToPageButton": "संदेश पृष्ठावर जोडा", + "addToPageTitle": "या पृष्ठात संदेश जोडा...", + "addToNewPage": "नवीन पृष्ठ तयार करा", + "addToNewPageName": "\"{}\" मधून काढलेले संदेश", + "addToNewPageSuccessToast": "संदेश जोडण्यात आला", + "openPagePreviewFailedToast": "पृष्ठ उघडण्यात अयशस्वी", + "changeFormat": { + "actionButton": "फॉरमॅट बदला", + "confirmButton": "या फॉरमॅटसह पुन्हा तयार करा", + "textOnly": "मजकूर", + "imageOnly": "फक्त प्रतिमा", + "textAndImage": "मजकूर आणि प्रतिमा", + "text": "परिच्छेद", + "bullet": "बुलेट यादी", + "number": "क्रमांकित यादी", + "table": "सारणी", + "blankDescription": "उत्तराचे फॉरमॅट ठरवा", + "defaultDescription": "स्वयंचलित उत्तर फॉरमॅट", + "textWithImageDescription": "@:chat.changeFormat.text प्रतिमेसह", + "numberWithImageDescription": "@:chat.changeFormat.number प्रतिमेसह", + "bulletWithImageDescription": "@:chat.changeFormat.bullet प्रतिमेसह", + " tableWithImageDescription": "@:chat.changeFormat.table प्रतिमेसह" + }, + "switchModel": { + "label": "मॉडेल बदला", + "localModel": "स्थानिक मॉडेल", + "cloudModel": "क्लाऊड मॉडेल", + "autoModel": "स्वयंचलित" + }, + "selectBanner": { + "saveButton": "… मध्ये जोडा", + "selectMessages": "संदेश निवडा", + "nSelected": "{} निवडले गेले", + "allSelected": "सर्व निवडले गेले" + }, + "stopTooltip": "उत्पन्न करणे थांबवा", + "trash": { + "text": "कचरा", + "restoreAll": "सर्व पुनर्संचयित करा", + "restore": "पुनर्संचयित करा", + "deleteAll": "सर्व हटवा", + "pageHeader": { + "fileName": "फाईलचे नाव", + "lastModified": "शेवटचा बदल", + "created": "निर्मिती" + } + }, + "confirmDeleteAll": { + "title": "कचरापेटीतील सर्व पृष्ठे", + "caption": "तुम्हाला कचरापेटीतील सर्व काही हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "confirmRestoreAll": { + "title": "कचरापेटीतील सर्व पृष्ठे पुनर्संचयित करा", + "caption": "ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "restorePage": { + "title": "पुनर्संचयित करा: {}", + "caption": "तुम्हाला हे पृष्ठ पुनर्संचयित करायचे आहे का?" + }, + "mobile": { + "actions": "कचरा क्रिया", + "empty": "कचरापेटीत कोणतीही पृष्ठे किंवा जागा नाहीत", + "emptyDescription": "जे आवश्यक नाही ते कचरापेटीत हलवा.", + "isDeleted": "हटवले गेले आहे", + "isRestored": "पुनर्संचयित केले गेले आहे" + }, + "confirmDeleteTitle": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का?", + "deletePagePrompt": { + "text": "हे पृष्ठ कचरापेटीत आहे", + "restore": "पृष्ठ पुनर्संचयित करा", + "deletePermanent": "कायमचे हटवा", + "deletePermanentDescription": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." + }, + "dialogCreatePageNameHint": "पृष्ठाचे नाव", + "questionBubble": { + "shortcuts": "शॉर्टकट्स", + "whatsNew": "नवीन काय आहे?", + "help": "मदत आणि समर्थन", + "markdown": "Markdown", + "debug": { + "name": "डीबग माहिती", + "success": "डीबग माहिती क्लिपबोर्डवर कॉपी केली!", + "fail": "डीबग माहिती कॉपी करता आली नाही" + }, + "feedback": "अभिप्राय" + }, + "menuAppHeader": { + "moreButtonToolTip": "हटवा, नाव बदला आणि अधिक...", + "addPageTooltip": "तत्काळ एक पृष्ठ जोडा", + "defaultNewPageName": "शीर्षक नसलेले", + "renameDialog": "नाव बदला", + "pageNameSuffix": "प्रत" + }, + "noPagesInside": "अंदर कोणतीही पृष्ठे नाहीत", + "toolbar": { + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "bold": "ठळक", + "italic": "तिरकस", + "underline": "अधोरेखित", + "strike": "मागे ओढलेले", + "numList": "क्रमांकित यादी", + "bulletList": "बुलेट यादी", + "checkList": "चेक यादी", + "inlineCode": "इनलाइन कोड", + "quote": "उद्धरण ब्लॉक", + "header": "शीर्षक", + "highlight": "हायलाइट", + "color": "रंग", + "addLink": "लिंक जोडा" + }, + "tooltip": { + "lightMode": "लाइट मोडमध्ये स्विच करा", + "darkMode": "डार्क मोडमध्ये स्विच करा", + "openAsPage": "पृष्ठ म्हणून उघडा", + "addNewRow": "नवीन पंक्ती जोडा", + "openMenu": "मेनू उघडण्यासाठी क्लिक करा", + "dragRow": "पंक्तीचे स्थान बदलण्यासाठी ड्रॅग करा", + "viewDataBase": "डेटाबेस पहा", + "referencePage": "हे {name} संदर्भित आहे", + "addBlockBelow": "खाली एक ब्लॉक जोडा", + "aiGenerate": "निर्मिती करा" + }, + "sideBar": { + "closeSidebar": "साइडबार बंद करा", + "openSidebar": "साइडबार उघडा", + "expandSidebar": "पूर्ण पृष्ठावर विस्तारित करा", + "personal": "वैयक्तिक", + "private": "खाजगी", + "workspace": "कार्यक्षेत्र", + "favorites": "आवडती", + "clickToHidePrivate": "खाजगी जागा लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने फक्त तुम्हाला दिसतील", + "clickToHideWorkspace": "कार्यक्षेत्र लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने सर्व सदस्यांना दिसतील", + "clickToHidePersonal": "वैयक्तिक जागा लपवण्यासाठी क्लिक करा", + "clickToHideFavorites": "आवडती जागा लपवण्यासाठी क्लिक करा", + "addAPage": "नवीन पृष्ठ जोडा", + "addAPageToPrivate": "खाजगी जागेत पृष्ठ जोडा", + "addAPageToWorkspace": "कार्यक्षेत्रात पृष्ठ जोडा", + "recent": "अलीकडील", + "today": "आज", + "thisWeek": "या आठवड्यात", + "others": "पूर्वीच्या आवडती", + "earlier": "पूर्वीचे", + "justNow": "आत्ताच", + "minutesAgo": "{count} मिनिटांपूर्वी", + "lastViewed": "शेवटी पाहिलेले", + "favoriteAt": "आवडते म्हणून चिन्हांकित", + "emptyRecent": "अलीकडील पृष्ठे नाहीत", + "emptyRecentDescription": "तुम्ही पाहिलेली पृष्ठे येथे सहज पुन्हा मिळवण्यासाठी दिसतील.", + "emptyFavorite": "आवडती पृष्ठे नाहीत", + "emptyFavoriteDescription": "पृष्ठांना आवडते म्हणून चिन्हांकित करा—ते येथे झपाट्याने प्रवेशासाठी दिसतील!", + "removePageFromRecent": "हे पृष्ठ अलीकडील यादीतून काढायचे?", + "removeSuccess": "यशस्वीरित्या काढले गेले", + "favoriteSpace": "आवडती", + "RecentSpace": "अलीकडील", + "Spaces": "जागा", + "upgradeToPro": "Pro मध्ये अपग्रेड करा", + "upgradeToAIMax": "अमर्यादित AI अनलॉक करा", + "storageLimitDialogTitle": "तुमचा मोफत स्टोरेज संपला आहे. अमर्यादित स्टोरेजसाठी अपग्रेड करा", + "storageLimitDialogTitleIOS": "तुमचा मोफत स्टोरेज संपला आहे.", + "aiResponseLimitTitle": "तुमचे मोफत AI प्रतिसाद संपले आहेत. कृपया Pro प्लॅनला अपग्रेड करा किंवा AI add-on खरेदी करा", + "aiResponseLimitDialogTitle": "AI प्रतिसाद मर्यादा गाठली आहे", + "aiResponseLimit": "तुमचे मोफत AI प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max किंवा Pro प्लॅन क्लिक करा", + "askOwnerToUpgradeToPro": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे. कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा", + "askOwnerToUpgradeToProIOS": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे.", + "askOwnerToUpgradeToAIMax": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला प्लॅन अपग्रेड किंवा AI add-ons खरेदी करण्यास सांगा", + "askOwnerToUpgradeToAIMaxIOS": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत.", + "purchaseAIMax": "तुमच्या कार्यक्षेत्राचे AI प्रतिमा प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला AI Max खरेदी करण्यास सांगा", + "aiImageResponseLimit": "तुमचे AI प्रतिमा प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max क्लिक करा", + "purchaseStorageSpace": "स्टोरेज स्पेस खरेदी करा", + "singleFileProPlanLimitationDescription": "तुम्ही मोफत प्लॅनमध्ये परवानगी दिलेल्या फाइल अपलोड आकाराची मर्यादा ओलांडली आहे. कृपया Pro प्लॅनमध्ये अपग्रेड करा", + "purchaseAIResponse": "AI प्रतिसाद खरेदी करा", + "askOwnerToUpgradeToLocalAI": "कार्यक्षेत्र मालकाला ऑन-डिव्हाइस AI सक्षम करण्यास सांगा", + "upgradeToAILocal": "अत्याधिक गोपनीयतेसाठी तुमच्या डिव्हाइसवर लोकल मॉडेल चालवा", + "upgradeToAILocalDesc": "PDFs सोबत गप्पा मारा, तुमचे लेखन सुधारा आणि लोकल AI वापरून टेबल्स आपोआप भरा" +}, + "notifications": { + "export": { + "markdown": "टीप Markdown मध्ये निर्यात केली", + "path": "Documents/flowy" + } + }, + "contactsPage": { + "title": "संपर्क", + "whatsHappening": "या आठवड्यात काय घडत आहे?", + "addContact": "संपर्क जोडा", + "editContact": "संपर्क संपादित करा" + }, + "button": { + "ok": "ठीक आहे", + "confirm": "खात्री करा", + "done": "पूर्ण", + "cancel": "रद्द करा", + "signIn": "साइन इन", + "signOut": "साइन आउट", + "complete": "पूर्ण करा", + "save": "जतन करा", + "generate": "निर्माण करा", + "esc": "ESC", + "keep": "ठेवा", + "tryAgain": "पुन्हा प्रयत्न करा", + "discard": "टाका", + "replace": "बदला", + "insertBelow": "खाली घाला", + "insertAbove": "वर घाला", + "upload": "अपलोड करा", + "edit": "संपादित करा", + "delete": "हटवा", + "copy": "कॉपी करा", + "duplicate": "प्रत बनवा", + "putback": "परत ठेवा", + "update": "अद्यतनित करा", + "share": "शेअर करा", + "removeFromFavorites": "आवडतीतून काढा", + "removeFromRecent": "अलीकडील यादीतून काढा", + "addToFavorites": "आवडतीत जोडा", + "favoriteSuccessfully": "आवडतीत यशस्वीरित्या जोडले", + "unfavoriteSuccessfully": "आवडतीतून यशस्वीरित्या काढले", + "duplicateSuccessfully": "प्रत यशस्वीरित्या तयार झाली", + "rename": "नाव बदला", + "helpCenter": "मदत केंद्र", + "add": "जोड़ा", + "yes": "होय", + "no": "नाही", + "clear": "साफ करा", + "remove": "काढा", + "dontRemove": "काढू नका", + "copyLink": "लिंक कॉपी करा", + "align": "जुळवा", + "login": "लॉगिन", + "logout": "लॉगआउट", + "deleteAccount": "खाते हटवा", + "back": "मागे", + "signInGoogle": "Google सह पुढे जा", + "signInGithub": "GitHub सह पुढे जा", + "signInDiscord": "Discord सह पुढे जा", + "more": "अधिक", + "create": "तयार करा", + "close": "बंद करा", + "next": "पुढे", + "previous": "मागील", + "submit": "सबमिट करा", + "download": "डाउनलोड करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "viewing": "पाहत आहात", + "editing": "संपादन करत आहात", + "gotIt": "समजले", + "retry": "पुन्हा प्रयत्न करा", + "uploadFailed": "अपलोड अयशस्वी.", + "copyLinkOriginal": "मूळ दुव्याची कॉपी करा" + }, + "label": { + "welcome": "स्वागत आहे!", + "firstName": "पहिले नाव", + "middleName": "मधले नाव", + "lastName": "आडनाव", + "stepX": "पायरी {X}" + }, + "oAuth": { + "err": { + "failedTitle": "तुमच्या खात्याशी कनेक्ट होता आले नाही.", + "failedMsg": "कृपया खात्री करा की तुम्ही ब्राउझरमध्ये साइन-इन प्रक्रिया पूर्ण केली आहे." + }, + "google": { + "title": "GOOGLE साइन-इन", + "instruction1": "तुमचे Google Contacts आयात करण्यासाठी, तुम्हाला तुमच्या वेब ब्राउझरचा वापर करून या अ‍ॅप्लिकेशनला अधिकृत करणे आवश्यक आहे.", + "instruction2": "ही कोड आयकॉनवर क्लिक करून किंवा मजकूर निवडून क्लिपबोर्डवर कॉपी करा:", + "instruction3": "तुमच्या वेब ब्राउझरमध्ये खालील दुव्यावर जा आणि वरील कोड टाका:", + "instruction4": "साइनअप पूर्ण झाल्यावर खालील बटण क्लिक करा:" + } + }, + "settings": { + "title": "सेटिंग्ज", + "popupMenuItem": { + "settings": "सेटिंग्ज", + "members": "सदस्य", + "trash": "कचरा", + "helpAndSupport": "मदत आणि समर्थन" + }, + "sites": { + "title": "साइट्स", + "namespaceTitle": "नेमस्पेस", + "namespaceDescription": "तुमचा नेमस्पेस आणि मुख्यपृष्ठ व्यवस्थापित करा", + "namespaceHeader": "नेमस्पेस", + "homepageHeader": "मुख्यपृष्ठ", + "updateNamespace": "नेमस्पेस अद्यतनित करा", + "removeHomepage": "मुख्यपृष्ठ हटवा", + "selectHomePage": "एक पृष्ठ निवडा", + "clearHomePage": "या नेमस्पेससाठी मुख्यपृष्ठ साफ करा", + "customUrl": "स्वतःची URL", + "namespace": { + "description": "हे बदल सर्व प्रकाशित पृष्ठांवर लागू होतील जे या नेमस्पेसवर चालू आहेत", + "tooltip": "कोणताही अनुचित नेमस्पेस आम्ही काढून टाकण्याचा अधिकार राखून ठेवतो", + "updateExistingNamespace": "विद्यमान नेमस्पेस अद्यतनित करा", + "upgradeToPro": "मुख्यपृष्ठ सेट करण्यासाठी Pro प्लॅनमध्ये अपग्रेड करा", + "redirectToPayment": "पेमेंट पृष्ठावर वळवत आहे...", + "onlyWorkspaceOwnerCanSetHomePage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ सेट करू शकतो", + "pleaseAskOwnerToSetHomePage": "कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा" + }, + "publishedPage": { + "title": "सर्व प्रकाशित पृष्ठे", + "description": "तुमची प्रकाशित पृष्ठे व्यवस्थापित करा", + "page": "पृष्ठ", + "pathName": "पथाचे नाव", + "date": "प्रकाशन तारीख", + "emptyHinText": "या कार्यक्षेत्रात तुमच्याकडे कोणतीही प्रकाशित पृष्ठे नाहीत", + "noPublishedPages": "प्रकाशित पृष्ठे नाहीत", + "settings": "प्रकाशन सेटिंग्ज", + "clickToOpenPageInApp": "पृष्ठ अ‍ॅपमध्ये उघडा", + "clickToOpenPageInBrowser": "पृष्ठ ब्राउझरमध्ये उघडा" + } + } + }, + "error": { + "failedToGeneratePaymentLink": "Pro प्लॅनसाठी पेमेंट लिंक तयार करण्यात अयशस्वी", + "failedToUpdateNamespace": "नेमस्पेस अद्यतनित करण्यात अयशस्वी", + "proPlanLimitation": "नेमस्पेस अद्यतनित करण्यासाठी तुम्हाला Pro प्लॅनमध्ये अपग्रेड करणे आवश्यक आहे", + "namespaceAlreadyInUse": "नेमस्पेस आधीच वापरात आहे, कृपया दुसरे प्रयत्न करा", + "invalidNamespace": "अवैध नेमस्पेस, कृपया दुसरे प्रयत्न करा", + "namespaceLengthAtLeast2Characters": "नेमस्पेस किमान २ अक्षरे लांब असावे", + "onlyWorkspaceOwnerCanUpdateNamespace": "फक्त कार्यक्षेत्र मालकच नेमस्पेस अद्यतनित करू शकतो", + "onlyWorkspaceOwnerCanRemoveHomepage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ हटवू शकतो", + "setHomepageFailed": "मुख्यपृष्ठ सेट करण्यात अयशस्वी", + "namespaceTooLong": "नेमस्पेस खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "namespaceTooShort": "नेमस्पेस खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "namespaceIsReserved": "हा नेमस्पेस राखीव आहे, कृपया दुसरे प्रयत्न करा", + "updatePathNameFailed": "पथाचे नाव अद्यतनित करण्यात अयशस्वी", + "removeHomePageFailed": "मुख्यपृष्ठ हटवण्यात अयशस्वी", + "publishNameContainsInvalidCharacters": "पथाच्या नावामध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishNameTooShort": "पथाचे नाव खूप लहान आहे, कृपया दुसरे प्रयत्न करा", + "publishNameTooLong": "पथाचे नाव खूप लांब आहे, कृपया दुसरे प्रयत्न करा", + "publishNameAlreadyInUse": "हे पथाचे नाव आधीच वापरले गेले आहे, कृपया दुसरे प्रयत्न करा", + "namespaceContainsInvalidCharacters": "नेमस्पेसमध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", + "publishPermissionDenied": "फक्त कार्यक्षेत्र मालक किंवा पृष्ठ प्रकाशकच प्रकाशन सेटिंग्ज व्यवस्थापित करू शकतो", + "publishNameCannotBeEmpty": "पथाचे नाव रिकामे असू शकत नाही, कृपया दुसरे प्रयत्न करा" + }, + "success": { + "namespaceUpdated": "नेमस्पेस यशस्वीरित्या अद्यतनित केला", + "setHomepageSuccess": "मुख्यपृष्ठ यशस्वीरित्या सेट केले", + "updatePathNameSuccess": "पथाचे नाव यशस्वीरित्या अद्यतनित केले", + "removeHomePageSuccess": "मुख्यपृष्ठ यशस्वीरित्या हटवले" + }, + "accountPage": { + "menuLabel": "खाते आणि अ‍ॅप", + "title": "माझे खाते", + "general": { + "title": "खात्याचे नाव आणि प्रोफाइल प्रतिमा", + "changeProfilePicture": "प्रोफाइल प्रतिमा बदला" + }, + "email": { + "title": "ईमेल", + "actions": { + "change": "ईमेल बदला" + } + }, + "login": { + "title": "खाते लॉगिन", + "loginLabel": "लॉगिन", + "logoutLabel": "लॉगआउट" + }, + "isUpToDate": "@:appName अद्ययावत आहे!", + "officialVersion": "आवृत्ती {version} (अधिकृत बिल्ड)" +}, + "workspacePage": { + "menuLabel": "कार्यक्षेत्र", + "title": "कार्यक्षेत्र", + "description": "तुमचे कार्यक्षेत्र स्वरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक/वेळ फॉरमॅट आणि भाषा सानुकूलित करा.", + "workspaceName": { + "title": "कार्यक्षेत्राचे नाव" + }, + "workspaceIcon": { + "title": "कार्यक्षेत्राचे चिन्ह", + "description": "तुमच्या कार्यक्षेत्रासाठी प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे चिन्ह साइडबार आणि सूचना मध्ये दिसेल." + }, + "appearance": { + "title": "दृश्यरूप", + "description": "कार्यक्षेत्राचे दृश्यरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक, वेळ आणि भाषा सानुकूलित करा.", + "options": { + "system": "स्वयंचलित", + "light": "लाइट", + "dark": "डार्क" + } + } + }, + "resetCursorColor": { + "title": "दस्तऐवज कर्सरचा रंग रीसेट करा", + "description": "तुम्हाला कर्सरचा रंग रीसेट करायचा आहे का?" + }, + "resetSelectionColor": { + "title": "दस्तऐवज निवडीचा रंग रीसेट करा", + "description": "तुम्हाला निवडीचा रंग रीसेट करायचा आहे का?" + }, + "resetWidth": { + "resetSuccess": "दस्तऐवजाची रुंदी यशस्वीरित्या रीसेट केली" + }, + "theme": { + "title": "थीम", + "description": "पूर्व-निर्धारित थीम निवडा किंवा तुमची स्वतःची थीम अपलोड करा.", + "uploadCustomThemeTooltip": "स्वतःची थीम अपलोड करा" + }, + "workspaceFont": { + "title": "कार्यक्षेत्र फॉन्ट", + "noFontHint": "कोणताही फॉन्ट सापडला नाही, कृपया दुसरा शब्द वापरून पहा." + }, + "textDirection": { + "title": "मजकूर दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे", + "auto": "स्वयंचलित", + "enableRTLItems": "RTL टूलबार घटक सक्षम करा" + }, + "layoutDirection": { + "title": "लेआउट दिशा", + "leftToRight": "डावीकडून उजवीकडे", + "rightToLeft": "उजवीकडून डावीकडे" + }, + "dateTime": { + "title": "दिनांक आणि वेळ", + "example": "{} वाजता {} ({})", + "24HourTime": "२४-तास वेळ", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "सुलभ", + "dmy": "D/M/Y" + } + }, + "language": { + "title": "भाषा" + }, + "deleteWorkspacePrompt": { + "title": "कार्यक्षेत्र हटवा", + "content": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही, आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील." + }, + "leaveWorkspacePrompt": { + "title": "कार्यक्षेत्र सोडा", + "content": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का? यानंतर तुम्हाला यामधील सर्व पृष्ठे आणि डेटावर प्रवेश मिळणार नाही.", + "success": "तुम्ही कार्यक्षेत्र यशस्वीरित्या सोडले.", + "fail": "कार्यक्षेत्र सोडण्यात अयशस्वी." + }, + "manageWorkspace": { + "title": "कार्यक्षेत्र व्यवस्थापित करा", + "leaveWorkspace": "कार्यक्षेत्र सोडा", + "deleteWorkspace": "कार्यक्षेत्र हटवा" + }, + "manageDataPage": { + "menuLabel": "डेटा व्यवस्थापित करा", + "title": "डेटा व्यवस्थापन", + "description": "@:appName मध्ये स्थानिक डेटा संचयन व्यवस्थापित करा किंवा तुमचा विद्यमान डेटा आयात करा.", + "dataStorage": { + "title": "फाइल संचयन स्थान", + "tooltip": "जिथे तुमच्या फाइल्स संग्रहित आहेत ते स्थान", + "actions": { + "change": "मार्ग बदला", + "open": "फोल्डर उघडा", + "openTooltip": "सध्याच्या डेटा फोल्डरचे स्थान उघडा", + "copy": "मार्ग कॉपी करा", + "copiedHint": "मार्ग कॉपी केला!", + "resetTooltip": "मूलभूत स्थानावर रीसेट करा" + }, + "resetDialog": { + "title": "तुम्हाला खात्री आहे का?", + "description": "पथ मूलभूत डेटा स्थानावर रीसेट केल्याने तुमचा डेटा हटवला जाणार नाही. तुम्हाला सध्याचा डेटा पुन्हा आयात करायचा असल्यास, कृपया आधी त्याचा पथ कॉपी करा." + } + }, + "importData": { + "title": "डेटा आयात करा", + "tooltip": "@:appName बॅकअप/डेटा फोल्डरमधून डेटा आयात करा", + "description": "बाह्य @:appName डेटा फोल्डरमधून डेटा कॉपी करा", + "action": "फाइल निवडा" + }, + "encryption": { + "title": "एनक्रिप्शन", + "tooltip": "तुमचा डेटा कसा संग्रहित आणि एनक्रिप्ट केला जातो ते व्यवस्थापित करा", + "descriptionNoEncryption": "एनक्रिप्शन चालू केल्याने सर्व डेटा एनक्रिप्ट केला जाईल. ही क्रिया पूर्ववत केली जाऊ शकत नाही.", + "descriptionEncrypted": "तुमचा डेटा एनक्रिप्टेड आहे.", + "action": "डेटा एनक्रिप्ट करा", + "dialog": { + "title": "संपूर्ण डेटा एनक्रिप्ट करायचा?", + "description": "तुमचा संपूर्ण डेटा एनक्रिप्ट केल्याने तो सुरक्षित राहील. ही क्रिया पूर्ववत केली जाऊ शकत नाही. तुम्हाला पुढे जायचे आहे का?" + } + }, + "cache": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "dialog": { + "title": "कॅशे साफ करा", + "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", + "successHint": "कॅशे साफ झाली!" + } + }, + "data": { + "fixYourData": "तुमचा डेटा सुधारा", + "fixButton": "सुधारा", + "fixYourDataDescription": "तुमच्या डेटामध्ये काही अडचण येत असल्यास, तुम्ही येथे ती सुधारू शकता." + } + }, + "shortcutsPage": { + "menuLabel": "शॉर्टकट्स", + "title": "शॉर्टकट्स", + "editBindingHint": "नवीन बाइंडिंग टाका", + "searchHint": "शोधा", + "actions": { + "resetDefault": "मूलभूत रीसेट करा" + }, + "errorPage": { + "message": "शॉर्टकट्स लोड करण्यात अयशस्वी: {}", + "howToFix": "कृपया पुन्हा प्रयत्न करा. समस्या कायम राहिल्यास GitHub वर संपर्क साधा." + }, + "resetDialog": { + "title": "शॉर्टकट्स रीसेट करा", + "description": "हे सर्व कीबाइंडिंग्जना मूळ स्थितीत रीसेट करेल. ही क्रिया पूर्ववत करता येणार नाही. तुम्हाला खात्री आहे का?", + "buttonLabel": "रीसेट करा" + }, + "conflictDialog": { + "title": "{} आधीच वापरले जात आहे", + "descriptionPrefix": "हे कीबाइंडिंग सध्या ", + "descriptionSuffix": " यामध्ये वापरले जात आहे. तुम्ही हे कीबाइंडिंग बदलल्यास, ते {} मधून काढले जाईल.", + "confirmLabel": "पुढे जा" + }, + "editTooltip": "कीबाइंडिंग संपादित करण्यासाठी क्लिक करा", + "keybindings": { + "toggleToDoList": "टू-डू सूची चालू/बंद करा", + "insertNewParagraphInCodeblock": "नवीन परिच्छेद टाका", + "pasteInCodeblock": "कोडब्लॉकमध्ये पेस्ट करा", + "selectAllCodeblock": "सर्व निवडा", + "indentLineCodeblock": "ओळीच्या सुरुवातीला दोन स्पेस टाका", + "outdentLineCodeblock": "ओळीच्या सुरुवातीची दोन स्पेस काढा", + "twoSpacesCursorCodeblock": "कर्सरवर दोन स्पेस टाका", + "copy": "निवड कॉपी करा", + "paste": "मजकुरात पेस्ट करा", + "cut": "निवड कट करा", + "alignLeft": "मजकूर डावीकडे संरेखित करा", + "alignCenter": "मजकूर मधोमध संरेखित करा", + "alignRight": "मजकूर उजवीकडे संरेखित करा", + "insertInlineMathEquation": "इनलाइन गणितीय सूत्र टाका", + "undo": "पूर्ववत करा", + "redo": "पुन्हा करा", + "convertToParagraph": "ब्लॉक परिच्छेदात रूपांतरित करा", + "backspace": "हटवा", + "deleteLeftWord": "डावीकडील शब्द हटवा", + "deleteLeftSentence": "डावीकडील वाक्य हटवा", + "delete": "उजवीकडील अक्षर हटवा", + "deleteMacOS": "डावीकडील अक्षर हटवा", + "deleteRightWord": "उजवीकडील शब्द हटवा", + "moveCursorLeft": "कर्सर डावीकडे हलवा", + "moveCursorBeginning": "कर्सर सुरुवातीला हलवा", + "moveCursorLeftWord": "कर्सर एक शब्द डावीकडे हलवा", + "moveCursorLeftSelect": "निवडा आणि कर्सर डावीकडे हलवा", + "moveCursorBeginSelect": "निवडा आणि कर्सर सुरुवातीला हलवा", + "moveCursorLeftWordSelect": "निवडा आणि कर्सर एक शब्द डावीकडे हलवा", + "moveCursorRight": "कर्सर उजवीकडे हलवा", + "moveCursorEnd": "कर्सर शेवटी हलवा", + "moveCursorRightWord": "कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorRightSelect": "निवडा आणि कर्सर उजवीकडे हलवा", + "moveCursorEndSelect": "निवडा आणि कर्सर शेवटी हलवा", + "moveCursorRightWordSelect": "निवडा आणि कर्सर एक शब्द उजवीकडे हलवा", + "moveCursorUp": "कर्सर वर हलवा", + "moveCursorTopSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorTop": "कर्सर वर हलवा", + "moveCursorUpSelect": "निवडा आणि कर्सर वर हलवा", + "moveCursorBottomSelect": "निवडा आणि कर्सर खाली हलवा", + "moveCursorBottom": "कर्सर खाली हलवा", + "moveCursorDown": "कर्सर खाली हलवा", + "moveCursorDownSelect": "निवडा आणि कर्सर खाली हलवा", + "home": "वर स्क्रोल करा", + "end": "खाली स्क्रोल करा", + "toggleBold": "बोल्ड चालू/बंद करा", + "toggleItalic": "इटालिक चालू/बंद करा", + "toggleUnderline": "अधोरेखित चालू/बंद करा", + "toggleStrikethrough": "स्ट्राईकथ्रू चालू/बंद करा", + "toggleCode": "इनलाइन कोड चालू/बंद करा", + "toggleHighlight": "हायलाईट चालू/बंद करा", + "showLinkMenu": "लिंक मेनू दाखवा", + "openInlineLink": "इनलाइन लिंक उघडा", + "openLinks": "सर्व निवडलेले लिंक उघडा", + "indent": "इंडेंट", + "outdent": "आउटडेंट", + "exit": "संपादनातून बाहेर पडा", + "pageUp": "एक पृष्ठ वर स्क्रोल करा", + "pageDown": "एक पृष्ठ खाली स्क्रोल करा", + "selectAll": "सर्व निवडा", + "pasteWithoutFormatting": "फॉरमॅटिंगशिवाय पेस्ट करा", + "showEmojiPicker": "इमोजी निवडकर्ता दाखवा", + "enterInTableCell": "टेबलमध्ये नवीन ओळ जोडा", + "leftInTableCell": "टेबलमध्ये डावीकडील सेलमध्ये जा", + "rightInTableCell": "टेबलमध्ये उजवीकडील सेलमध्ये जा", + "upInTableCell": "टेबलमध्ये वरील सेलमध्ये जा", + "downInTableCell": "टेबलमध्ये खालील सेलमध्ये जा", + "tabInTableCell": "टेबलमध्ये पुढील सेलमध्ये जा", + "shiftTabInTableCell": "टेबलमध्ये मागील सेलमध्ये जा", + "backSpaceInTableCell": "सेलच्या सुरुवातीला थांबा" + }, + "commands": { + "codeBlockNewParagraph": "कोडब्लॉकच्या शेजारी नवीन परिच्छेद टाका", + "codeBlockIndentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीला दोन स्पेस टाका", + "codeBlockOutdentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीची दोन स्पेस काढा", + "codeBlockAddTwoSpaces": "कोडब्लॉकमध्ये कर्सरच्या ठिकाणी दोन स्पेस टाका", + "codeBlockSelectAll": "कोडब्लॉकमधील सर्व मजकूर निवडा", + "codeBlockPasteText": "कोडब्लॉकमध्ये मजकूर पेस्ट करा", + "textAlignLeft": "मजकूर डावीकडे संरेखित करा", + "textAlignCenter": "मजकूर मधोमध संरेखित करा", + "textAlignRight": "मजकूर उजवीकडे संरेखित करा" + }, + "couldNotLoadErrorMsg": "शॉर्टकट लोड करता आले नाहीत. पुन्हा प्रयत्न करा", + "couldNotSaveErrorMsg": "शॉर्टकट सेव्ह करता आले नाहीत. पुन्हा प्रयत्न करा" +}, + "aiPage": { + "title": "AI सेटिंग्ज", + "menuLabel": "AI सेटिंग्ज", + "keys": { + "enableAISearchTitle": "AI शोध", + "aiSettingsDescription": "AppFlowy AI चालवण्यासाठी तुमचा पसंतीचा मॉडेल निवडा. आता GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet आणि Ollama मधील मॉडेल्सचा समावेश आहे.", + "loginToEnableAIFeature": "AI वैशिष्ट्ये फक्त @:appName Cloud मध्ये लॉगिन केल्यानंतरच सक्षम होतात. तुमच्याकडे @:appName खाते नसेल तर 'माय अकाउंट' मध्ये जाऊन साइन अप करा.", + "llmModel": "भाषा मॉडेल", + "llmModelType": "भाषा मॉडेल प्रकार", + "downloadLLMPrompt": "{} डाउनलोड करा", + "downloadAppFlowyOfflineAI": "AI ऑफलाइन पॅकेज डाउनलोड केल्याने तुमच्या डिव्हाइसवर AI चालवता येईल. तुम्हाला पुढे जायचे आहे का?", + "downloadLLMPromptDetail": "{} स्थानिक मॉडेल डाउनलोड करण्यासाठी सुमारे {} संचयन लागेल. तुम्हाला पुढे जायचे आहे का?", + "downloadBigFilePrompt": "डाउनलोड पूर्ण होण्यासाठी सुमारे १० मिनिटे लागू शकतात", + "downloadAIModelButton": "डाउनलोड करा", + "downloadingModel": "डाउनलोड करत आहे", + "localAILoaded": "स्थानिक AI मॉडेल यशस्वीरित्या जोडले गेले आणि वापरण्यास सज्ज आहे", + "localAIStart": "स्थानिक AI सुरू होत आहे. जर हळू वाटत असेल, तर ते बंद करून पुन्हा सुरू करून पहा", + "localAILoading": "स्थानिक AI Chat मॉडेल लोड होत आहे...", + "localAIStopped": "स्थानिक AI थांबले आहे", + "localAIRunning": "स्थानिक AI चालू आहे", + "localAINotReadyRetryLater": "स्थानिक AI सुरू होत आहे, कृपया नंतर पुन्हा प्रयत्न करा", + "localAIDisabled": "तुम्ही स्थानिक AI वापरत आहात, पण ते अकार्यक्षम आहे. कृपया सेटिंग्जमध्ये जाऊन ते सक्षम करा किंवा दुसरे मॉडेल वापरून पहा", + "localAIInitializing": "स्थानिक AI लोड होत आहे. तुमच्या डिव्हाइसवर अवलंबून याला काही मिनिटे लागू शकतात", + "localAINotReadyTextFieldPrompt": "स्थानिक AI लोड होत असताना संपादन करता येणार नाही", + "failToLoadLocalAI": "स्थानिक AI सुरू करण्यात अयशस्वी.", + "restartLocalAI": "पुन्हा सुरू करा", + "disableLocalAITitle": "स्थानिक AI अकार्यक्षम करा", + "disableLocalAIDescription": "तुम्हाला स्थानिक AI अकार्यक्षम करायचा आहे का?", + "localAIToggleTitle": "AppFlowy स्थानिक AI (LAI)", + "localAIToggleSubTitle": "AppFlowy मध्ये अत्याधुनिक स्थानिक AI मॉडेल्स वापरून गोपनीयता आणि सुरक्षा सुनिश्चित करा", + "offlineAIInstruction1": "हे अनुसरा", + "offlineAIInstruction2": "सूचना", + "offlineAIInstruction3": "ऑफलाइन AI सक्षम करण्यासाठी.", + "offlineAIDownload1": "जर तुम्ही AppFlowy AI डाउनलोड केले नसेल, तर कृपया", + "offlineAIDownload2": "डाउनलोड", + "offlineAIDownload3": "करा", + "activeOfflineAI": "सक्रिय", + "downloadOfflineAI": "डाउनलोड करा", + "openModelDirectory": "फोल्डर उघडा", + "laiNotReady": "स्थानिक AI अ‍ॅप योग्य प्रकारे इन्स्टॉल झालेले नाही.", + "ollamaNotReady": "Ollama सर्व्हर तयार नाही.", + "pleaseFollowThese": "कृपया हे अनुसरा", + "instructions": "सूचना", + "installOllamaLai": "Ollama आणि AppFlowy स्थानिक AI सेटअप करण्यासाठी.", + "modelsMissing": "आवश्यक मॉडेल्स सापडत नाहीत.", + "downloadModel": "त्यांना डाउनलोड करण्यासाठी." + } +}, + "planPage": { + "menuLabel": "योजना", + "title": "दर योजना", + "planUsage": { + "title": "योजनेचा वापर सारांश", + "storageLabel": "स्टोरेज", + "storageUsage": "{} पैकी {} GB", + "unlimitedStorageLabel": "अमर्यादित स्टोरेज", + "collaboratorsLabel": "सदस्य", + "collaboratorsUsage": "{} पैकी {}", + "aiResponseLabel": "AI प्रतिसाद", + "aiResponseUsage": "{} पैकी {}", + "unlimitedAILabel": "अमर्यादित AI प्रतिसाद", + "proBadge": "प्रो", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "मॅकसाठी ऑन-डिव्हाइस AI", + "memberProToggle": "अधिक सदस्य आणि अमर्यादित AI", + "aiMaxToggle": "अमर्यादित AI आणि प्रगत मॉडेल्सचा प्रवेश", + "aiOnDeviceToggle": "जास्त गोपनीयतेसाठी स्थानिक AI", + "aiCredit": { + "title": "@:appName AI क्रेडिट जोडा", + "price": "{}", + "priceDescription": "1,000 क्रेडिट्ससाठी", + "purchase": "AI खरेदी करा", + "info": "प्रत्येक कार्यक्षेत्रासाठी 1,000 AI क्रेडिट्स जोडा आणि आपल्या कामाच्या प्रवाहात सानुकूलनीय AI सहजपणे एकत्र करा — अधिक स्मार्ट आणि जलद निकालांसाठी:", + "infoItemOne": "प्रत्येक डेटाबेससाठी 10,000 प्रतिसाद", + "infoItemTwo": "प्रत्येक कार्यक्षेत्रासाठी 1,000 प्रतिसाद" + }, + "currentPlan": { + "bannerLabel": "सद्य योजना", + "freeTitle": "फ्री", + "proTitle": "प्रो", + "teamTitle": "टीम", + "freeInfo": "2 सदस्यांपर्यंत वैयक्तिक वापरासाठी उत्तम", + "proInfo": "10 सदस्यांपर्यंत लहान आणि मध्यम टीमसाठी योग्य", + "teamInfo": "सर्व उत्पादनक्षम आणि सुव्यवस्थित टीमसाठी योग्य", + "upgrade": "योजना बदला", + "canceledInfo": "तुमची योजना रद्द करण्यात आली आहे, {} रोजी तुम्हाला फ्री योजनेवर डाऊनग्रेड केले जाईल." + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "activeLabel": "जोडले गेले", + "aiMax": { + "title": "AI Max", + "description": "प्रगत AI मॉडेल्ससह अमर्यादित AI प्रतिसाद आणि दरमहा 50 AI प्रतिमा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)" + }, + "aiOnDevice": { + "title": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर Mistral 7B, LLAMA 3 आणि अधिक स्थानिक मॉडेल्स चालवा", + "price": "{}", + "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)", + "recommend": "M1 किंवा नवीनतम शिफारस केली जाते" + } + }, + "deal": { + "bannerLabel": "नववर्षाचे विशेष ऑफर!", + "title": "तुमची टीम वाढवा!", + "info": "Pro आणि Team योजनांवर 10% सूट मिळवा! @:appName AI सह शक्तिशाली नवीन वैशिष्ट्यांसह तुमचे कार्यक्षेत्र अधिक कार्यक्षम बनवा.", + "viewPlans": "योजना पहा" + } + } +}, + "billingPage": { + "menuLabel": "बिलिंग", + "title": "बिलिंग", + "plan": { + "title": "योजना", + "freeLabel": "फ्री", + "proLabel": "प्रो", + "planButtonLabel": "योजना बदला", + "billingPeriod": "बिलिंग कालावधी", + "periodButtonLabel": "कालावधी संपादित करा" + }, + "paymentDetails": { + "title": "पेमेंट तपशील", + "methodLabel": "पेमेंट पद्धत", + "methodButtonLabel": "पद्धत संपादित करा" + }, + "addons": { + "title": "ऍड-ऑन्स", + "addLabel": "जोडा", + "removeLabel": "काढा", + "renewLabel": "नवीन करा", + "aiMax": { + "label": "AI Max", + "description": "अमर्यादित AI आणि प्रगत मॉडेल्स अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "AI Max {} पर्यंत उपलब्ध असेल" + }, + "aiOnDevice": { + "label": "मॅकसाठी ऑन-डिव्हाइस AI", + "description": "तुमच्या डिव्हाइसवर अमर्यादित ऑन-डिव्हाइस AI अनलॉक करा", + "activeDescription": "पुढील बिलिंग तारीख {} आहे", + "canceledDescription": "मॅकसाठी ऑन-डिव्हाइस AI {} पर्यंत उपलब्ध असेल" + }, + "removeDialog": { + "title": "{} काढा", + "description": "तुम्हाला {plan} काढायचे आहे का? तुम्हाला तत्काळ {plan} चे सर्व फिचर्स आणि फायदे वापरण्याचा अधिकार गमावावा लागेल." + } + }, + "currentPeriodBadge": "सद्य कालावधी", + "changePeriod": "कालावधी बदला", + "planPeriod": "{} कालावधी", + "monthlyInterval": "मासिक", + "monthlyPriceInfo": "प्रति सदस्य, मासिक बिलिंग", + "annualInterval": "वार्षिक", + "annualPriceInfo": "प्रति सदस्य, वार्षिक बिलिंग" +}, + "comparePlanDialog": { + "title": "योजना तुलना आणि निवड", + "planFeatures": "योजनेची\nवैशिष्ट्ये", + "current": "सध्याची", + "actions": { + "upgrade": "अपग्रेड करा", + "downgrade": "डाऊनग्रेड करा", + "current": "सध्याची" + }, + "freePlan": { + "title": "फ्री", + "description": "२ सदस्यांपर्यंत व्यक्तींसाठी सर्व काही व्यवस्थित करण्यासाठी", + "price": "{}", + "priceInfo": "सदैव फ्री" + }, + "proPlan": { + "title": "प्रो", + "description": "लहान टीम्ससाठी प्रोजेक्ट्स व ज्ञान व्यवस्थापनासाठी", + "price": "{}", + "priceInfo": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग\n\n{} मासिक बिलिंगसाठी" + }, + "planLabels": { + "itemOne": "वर्कस्पेसेस", + "itemTwo": "सदस्य", + "itemThree": "स्टोरेज", + "itemFour": "रिअल-टाइम सहकार्य", + "itemFive": "मोबाईल अ‍ॅप", + "itemSix": "AI प्रतिसाद", + "itemSeven": "AI प्रतिमा", + "itemFileUpload": "फाइल अपलोड", + "customNamespace": "सानुकूल नेमस्पेस", + "tooltipSix": "‘Lifetime’ म्हणजे या प्रतिसादांची मर्यादा कधीही रीसेट केली जात नाही", + "intelligentSearch": "स्मार्ट शोध", + "tooltipSeven": "तुमच्या कार्यक्षेत्रासाठी URL चा भाग सानुकूलित करू देते", + "customNamespaceTooltip": "सानुकूल प्रकाशित साइट URL" + }, + "freeLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "२ पर्यंत", + "itemThree": "५ GB", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "१० कायमस्वरूपी", + "itemSeven": "२ कायमस्वरूपी", + "itemFileUpload": "७ MB पर्यंत", + "intelligentSearch": "स्मार्ट शोध" + }, + "proLabels": { + "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", + "itemTwo": "१० पर्यंत", + "itemThree": "अमर्यादित", + "itemFour": "होय", + "itemFive": "होय", + "itemSix": "अमर्यादित", + "itemSeven": "दर महिन्याला १० प्रतिमा", + "itemFileUpload": "अमर्यादित", + "intelligentSearch": "स्मार्ट शोध" + }, + "paymentSuccess": { + "title": "तुम्ही आता {} योजनेवर आहात!", + "description": "तुमचे पेमेंट यशस्वीरित्या पूर्ण झाले असून तुमची योजना @:appName {} मध्ये अपग्रेड झाली आहे. तुम्ही 'योजना' पृष्ठावर तपशील पाहू शकता." + }, + "downgradeDialog": { + "title": "तुम्हाला योजना डाऊनग्रेड करायची आहे का?", + "description": "तुमची योजना डाऊनग्रेड केल्यास तुम्ही फ्री योजनेवर परत जाल. काही सदस्यांना या कार्यक्षेत्राचा प्रवेश मिळणार नाही आणि तुम्हाला स्टोरेज मर्यादेप्रमाणे जागा रिकामी करावी लागू शकते.", + "downgradeLabel": "योजना डाऊनग्रेड करा" + } +}, + "cancelSurveyDialog": { + "title": "तुम्ही जात आहात याचे दुःख आहे", + "description": "तुम्ही जात आहात याचे आम्हाला खरोखरच दुःख वाटते. @:appName सुधारण्यासाठी तुमचा अभिप्राय आम्हाला महत्त्वाचा वाटतो. कृपया काही क्षण घेऊन काही प्रश्नांची उत्तरे द्या.", + "commonOther": "इतर", + "otherHint": "तुमचे उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही तुमची @:appName Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षांनुसार नव्हती", + "answerThree": "यापेक्षा चांगला पर्याय सापडला", + "answerFour": "वापर फारसा केला नाही, त्यामुळे खर्च योग्य वाटला नाही", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "भविष्यात @:appName Pro सदस्यता पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता", + "answerFive": "एकदम कमी शक्यता" + }, + "questionThree": { + "question": "तुमच्या सदस्यत्वादरम्यान कोणते Pro वैशिष्ट्य सर्वात उपयुक्त वाटले?", + "answerOne": "अनेक वापरकर्त्यांशी सहकार्य", + "answerTwo": "लांब कालावधीची आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "@:appName वापरण्याचा तुमचा एकूण अनुभव कसा होता?", + "answerOne": "खूप छान", + "answerTwo": "चांगला", + "answerThree": "सरासरी", + "answerFour": "सरासरीपेक्षा कमी", + "answerFive": "असंतोषजनक" + } +}, + "common": { + "uploadingFile": "फाईल अपलोड होत आहे. कृपया अ‍ॅप बंद करू नका", + "uploadNotionSuccess": "तुमची Notion zip फाईल यशस्वीरित्या अपलोड झाली आहे. आयात पूर्ण झाल्यानंतर तुम्हाला पुष्टीकरण ईमेल मिळेल", + "reset": "रीसेट करा" +}, + "menu": { + "appearance": "दृश्यरूप", + "language": "भाषा", + "user": "वापरकर्ता", + "files": "फाईल्स", + "notifications": "सूचना", + "open": "सेटिंग्ज उघडा", + "logout": "लॉगआउट", + "logoutPrompt": "तुम्हाला नक्की लॉगआउट करायचे आहे का?", + "selfEncryptionLogoutPrompt": "तुम्हाला खात्रीने लॉगआउट करायचे आहे का? कृपया खात्री करा की तुम्ही एनक्रिप्शन गुप्तकी कॉपी केली आहे", + "syncSetting": "सिंक्रोनायझेशन सेटिंग", + "cloudSettings": "क्लाऊड सेटिंग्ज", + "enableSync": "सिंक्रोनायझेशन सक्षम करा", + "enableSyncLog": "सिंक लॉगिंग सक्षम करा", + "enableSyncLogWarning": "सिंक समस्यांचे निदान करण्यात मदतीसाठी धन्यवाद. हे तुमचे डॉक्युमेंट संपादन स्थानिक फाईलमध्ये लॉग करेल. कृपया सक्षम केल्यानंतर अ‍ॅप बंद करून पुन्हा उघडा", + "enableEncrypt": "डेटा एन्क्रिप्ट करा", + "cloudURL": "बेस URL", + "webURL": "वेब URL", + "invalidCloudURLScheme": "अवैध स्कीम", + "cloudServerType": "क्लाऊड सर्व्हर", + "cloudServerTypeTip": "कृपया लक्षात घ्या की क्लाऊड सर्व्हर बदलल्यास तुम्ही सध्या लॉगिन केलेले खाते लॉगआउट होऊ शकते", + "cloudLocal": "स्थानिक", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName क्लाऊड सेल्फ-होस्टेड", + "appFlowyCloudUrlCanNotBeEmpty": "क्लाऊड URL रिकामा असू शकत नाही", + "clickToCopy": "क्लिपबोर्डवर कॉपी करा", + "selfHostStart": "जर तुमच्याकडे सर्व्हर नसेल, तर कृपया हे पाहा", + "selfHostContent": "दस्तऐवज", + "selfHostEnd": "तुमचा स्वतःचा सर्व्हर कसा होस्ट करावा यासाठी मार्गदर्शनासाठी", + "pleaseInputValidURL": "कृपया वैध URL टाका", + "changeUrl": "सेल्फ-होस्टेड URL {} मध्ये बदला", + "cloudURLHint": "तुमच्या सर्व्हरचा बेस URL टाका", + "webURLHint": "तुमच्या वेब सर्व्हरचा बेस URL टाका", + "cloudWSURL": "वेबसॉकेट URL", + "cloudWSURLHint": "तुमच्या सर्व्हरचा वेबसॉकेट पत्ता टाका", + "restartApp": "अ‍ॅप रीस्टार्ट करा", + "restartAppTip": "बदल प्रभावी होण्यासाठी अ‍ॅप रीस्टार्ट करा. कृपया लक्षात घ्या की यामुळे सध्याचे खाते लॉगआउट होऊ शकते.", + "changeServerTip": "सर्व्हर बदलल्यानंतर, बदल लागू करण्यासाठी तुम्हाला 'रीस्टार्ट' बटणावर क्लिक करणे आवश्यक आहे", + "enableEncryptPrompt": "तुमचा डेटा सुरक्षित करण्यासाठी एन्क्रिप्शन सक्रिय करा. ही गुप्तकी सुरक्षित ठेवा; एकदा सक्षम केल्यावर ती बंद करता येणार नाही. जर हरवली तर तुमचा डेटा पुन्हा मिळवता येणार नाही. कॉपी करण्यासाठी क्लिक करा", + "inputEncryptPrompt": "कृपया खालीलसाठी तुमची एनक्रिप्शन गुप्तकी टाका:", + "clickToCopySecret": "गुप्तकी कॉपी करण्यासाठी क्लिक करा", + "configServerSetting": "तुमच्या सर्व्हर सेटिंग्ज कॉन्फिगर करा", + "configServerGuide": "`Quick Start` निवडल्यानंतर, `Settings` → \"Cloud Settings\" मध्ये जा आणि तुमचा सेल्फ-होस्टेड सर्व्हर कॉन्फिगर करा.", + "inputTextFieldHint": "तुमची गुप्तकी", + "historicalUserList": "वापरकर्ता लॉगिन इतिहास", + "historicalUserListTooltip": "ही यादी तुमची अनामिक खाती दर्शवते. तपशील पाहण्यासाठी खात्यावर क्लिक करा. अनामिक खाती 'सुरुवात करा' बटणावर क्लिक करून तयार केली जातात", + "openHistoricalUser": "अनामिक खाते उघडण्यासाठी क्लिक करा", + "customPathPrompt": "@:appName डेटा फोल्डर Google Drive सारख्या क्लाऊड-सिंक फोल्डरमध्ये साठवणे धोकादायक ठरू शकते. फोल्डरमधील डेटाबेस अनेक ठिकाणांवरून एकाच वेळी ऍक्सेस/संपादित केल्यास डेटा सिंक समस्या किंवा भ्रष्ट होण्याची शक्यता असते.", + "importAppFlowyData": "बाह्य @:appName फोल्डरमधून डेटा आयात करा", + "importingAppFlowyDataTip": "डेटा आयात सुरू आहे. कृपया अ‍ॅप बंद करू नका", + "importAppFlowyDataDescription": "बाह्य @:appName फोल्डरमधून डेटा कॉपी करून सध्याच्या AppFlowy डेटा फोल्डरमध्ये आयात करा", + "importSuccess": "@:appName डेटा फोल्डर यशस्वीरित्या आयात झाला", + "importFailed": "@:appName डेटा फोल्डर आयात करण्यात अयशस्वी", + "importGuide": "अधिक माहितीसाठी, कृपया संदर्भित दस्तऐवज पहा" +}, + "notifications": { + "enableNotifications": { + "label": "सूचना सक्षम करा", + "hint": "स्थानिक सूचना दिसू नयेत यासाठी बंद करा." + }, + "showNotificationsIcon": { + "label": "सूचना चिन्ह दाखवा", + "hint": "साइडबारमध्ये सूचना चिन्ह लपवण्यासाठी बंद करा." + }, + "archiveNotifications": { + "allSuccess": "सर्व सूचना यशस्वीरित्या संग्रहित केल्या", + "success": "सूचना यशस्वीरित्या संग्रहित केली" + }, + "markAsReadNotifications": { + "allSuccess": "सर्व वाचलेल्या म्हणून चिन्हांकित केल्या", + "success": "वाचलेले म्हणून चिन्हांकित केले" + }, + "action": { + "markAsRead": "वाचलेले म्हणून चिन्हांकित करा", + "multipleChoice": "अधिक निवडा", + "archive": "संग्रहित करा" + }, + "settings": { + "settings": "सेटिंग्ज", + "markAllAsRead": "सर्व वाचलेले म्हणून चिन्हांकित करा", + "archiveAll": "सर्व संग्रहित करा" + }, + "emptyInbox": { + "title": "इनबॉक्स झिरो!", + "description": "इथे सूचना मिळवण्यासाठी रिमाइंडर सेट करा." + }, + "emptyUnread": { + "title": "कोणतीही न वाचलेली सूचना नाही", + "description": "तुम्ही सर्व वाचले आहे!" + }, + "emptyArchived": { + "title": "कोणतीही संग्रहित सूचना नाही", + "description": "संग्रहित सूचना इथे दिसतील." + }, + "tabs": { + "inbox": "इनबॉक्स", + "unread": "न वाचलेले", + "archived": "संग्रहित" + }, + "refreshSuccess": "सूचना यशस्वीरित्या रीफ्रेश केल्या", + "titles": { + "notifications": "सूचना", + "reminder": "रिमाइंडर" + } +}, + "appearance": { + "resetSetting": "रीसेट", + "fontFamily": { + "label": "फॉन्ट फॅमिली", + "search": "शोध", + "defaultFont": "सिस्टम" + }, + "themeMode": { + "label": "थीम मोड", + "light": "लाइट मोड", + "dark": "डार्क मोड", + "system": "सिस्टमशी जुळवा" + }, + "fontScaleFactor": "फॉन्ट स्केल घटक", + "displaySize": "डिस्प्ले आकार", + "documentSettings": { + "cursorColor": "डॉक्युमेंट कर्सरचा रंग", + "selectionColor": "डॉक्युमेंट निवडीचा रंग", + "width": "डॉक्युमेंटची रुंदी", + "changeWidth": "बदला", + "pickColor": "रंग निवडा", + "colorShade": "रंगाची छटा", + "opacity": "अपारदर्शकता", + "hexEmptyError": "Hex रंग रिकामा असू शकत नाही", + "hexLengthError": "Hex व्हॅल्यू 6 अंकांची असावी", + "hexInvalidError": "अवैध Hex व्हॅल्यू", + "opacityEmptyError": "अपारदर्शकता रिकामी असू शकत नाही", + "opacityRangeError": "अपारदर्शकता 1 ते 100 दरम्यान असावी", + "app": "अ‍ॅप", + "flowy": "Flowy", + "apply": "लागू करा" + }, + "layoutDirection": { + "label": "लेआउट दिशा", + "hint": "तुमच्या स्क्रीनवरील कंटेंटचा प्रवाह नियंत्रित करा — डावीकडून उजवीकडे किंवा उजवीकडून डावीकडे.", + "ltr": "LTR", + "rtl": "RTL" + }, + "textDirection": { + "label": "मूलभूत मजकूर दिशा", + "hint": "मजकूर डावीकडून सुरू व्हावा की उजवीकडून याचे पूर्वनिर्धारित निर्धारण करा.", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयं", + "fallback": "लेआउट दिशेशी जुळवा" + }, + "themeUpload": { + "button": "अपलोड", + "uploadTheme": "थीम अपलोड करा", + "description": "खालील बटण वापरून तुमची स्वतःची @:appName थीम अपलोड करा.", + "loading": "कृपया प्रतीक्षा करा. आम्ही तुमची थीम पडताळत आहोत आणि अपलोड करत आहोत...", + "uploadSuccess": "तुमची थीम यशस्वीरित्या अपलोड झाली आहे", + "deletionFailure": "थीम हटवण्यात अयशस्वी. कृपया ती मॅन्युअली हटवून पाहा.", + "filePickerDialogTitle": ".flowy_plugin फाईल निवडा", + "urlUploadFailure": "URL उघडण्यात अयशस्वी: {}" + }, + "theme": "थीम", + "builtInsLabel": "अंतर्गत थीम्स", + "pluginsLabel": "प्लगइन्स", + "dateFormat": { + "label": "दिनांक फॉरमॅट", + "local": "स्थानिक", + "us": "US", + "iso": "ISO", + "friendly": "अनौपचारिक", + "dmy": "D/M/Y" + }, + "timeFormat": { + "label": "वेळ फॉरमॅट", + "twelveHour": "१२ तास", + "twentyFourHour": "२४ तास" + }, + "showNamingDialogWhenCreatingPage": "पृष्ठ तयार करताना नाव विचारणारा डायलॉग दाखवा", + "enableRTLToolbarItems": "RTL टूलबार आयटम्स सक्षम करा", + "members": { + "title": "सदस्य सेटिंग्ज", + "inviteMembers": "सदस्यांना आमंत्रण द्या", + "inviteHint": "ईमेलद्वारे आमंत्रण द्या", + "sendInvite": "आमंत्रण पाठवा", + "copyInviteLink": "आमंत्रण दुवा कॉपी करा", + "label": "सदस्य", + "user": "वापरकर्ता", + "role": "भूमिका", + "removeFromWorkspace": "वर्कस्पेसमधून काढा", + "removeFromWorkspaceSuccess": "वर्कस्पेसमधून यशस्वीरित्या काढले", + "removeFromWorkspaceFailed": "वर्कस्पेसमधून काढण्यात अयशस्वी", + "owner": "मालक", + "guest": "अतिथी", + "member": "सदस्य", + "memberHintText": "सदस्य पृष्ठे वाचू व संपादित करू शकतो", + "guestHintText": "अतिथी पृष्ठे वाचू शकतो, प्रतिक्रिया देऊ शकतो, टिप्पणी करू शकतो, आणि परवानगी असल्यास काही पृष्ठे संपादित करू शकतो.", + "emailInvalidError": "अवैध ईमेल, कृपया तपासा व पुन्हा प्रयत्न करा", + "emailSent": "ईमेल पाठवला गेला आहे, कृपया इनबॉक्स तपासा", + "members": "सदस्य", + "membersCount": { + "zero": "{} सदस्य", + "one": "{} सदस्य", + "other": "{} सदस्य" + }, + "inviteFailedDialogTitle": "आमंत्रण पाठवण्यात अयशस्वी", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, अधिक सदस्यांसाठी कृपया अपग्रेड करा.", + "inviteFailedMemberLimitMobile": "तुमच्या वर्कस्पेसने सदस्य मर्यादा गाठली आहे.", + "memberLimitExceeded": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आमंत्रित करण्यासाठी कृपया ", + "memberLimitExceededUpgrade": "अपग्रेड करा", + "memberLimitExceededPro": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आवश्यक असल्यास संपर्क करा", + "memberLimitExceededProContact": "support@appflowy.io", + "failedToAddMember": "सदस्य जोडण्यात अयशस्वी", + "addMemberSuccess": "सदस्य यशस्वीरित्या जोडला गेला", + "removeMember": "सदस्य काढा", + "areYouSureToRemoveMember": "तुम्हाला हा सदस्य काढायचा आहे का?", + "inviteMemberSuccess": "आमंत्रण यशस्वीरित्या पाठवले गेले", + "failedToInviteMember": "सदस्य आमंत्रित करण्यात अयशस्वी", + "workspaceMembersError": "अरे! काहीतरी चूक झाली आहे", + "workspaceMembersErrorDescription": "आम्ही सध्या सदस्यांची यादी लोड करू शकत नाही. कृपया नंतर पुन्हा प्रयत्न करा" + } +}, + "files": { + "copy": "कॉपी करा", + "defaultLocation": "फाईल्स आणि डेटाचा संचय स्थान", + "exportData": "तुमचा डेटा निर्यात करा", + "doubleTapToCopy": "पथ कॉपी करण्यासाठी दोनदा टॅप करा", + "restoreLocation": "@:appName चे मूळ स्थान पुनर्संचयित करा", + "customizeLocation": "इतर फोल्डर उघडा", + "restartApp": "बदल लागू करण्यासाठी कृपया अ‍ॅप रीस्टार्ट करा.", + "exportDatabase": "डेटाबेस निर्यात करा", + "selectFiles": "निर्यात करण्यासाठी फाईल्स निवडा", + "selectAll": "सर्व निवडा", + "deselectAll": "सर्व निवड रद्द करा", + "createNewFolder": "नवीन फोल्डर तयार करा", + "createNewFolderDesc": "तुमचा डेटा कुठे साठवायचा हे सांगा", + "defineWhereYourDataIsStored": "तुमचा डेटा कुठे साठवला जातो हे ठरवा", + "open": "उघडा", + "openFolder": "आधीक फोल्डर उघडा", + "openFolderDesc": "तुमच्या विद्यमान @:appName फोल्डरमध्ये वाचन व लेखन करा", + "folderHintText": "फोल्डरचे नाव", + "location": "नवीन फोल्डर तयार करत आहे", + "locationDesc": "तुमच्या @:appName डेटासाठी नाव निवडा", + "browser": "ब्राउझ करा", + "create": "तयार करा", + "set": "सेट करा", + "folderPath": "फोल्डर साठवण्याचा मार्ग", + "locationCannotBeEmpty": "मार्ग रिकामा असू शकत नाही", + "pathCopiedSnackbar": "फाईल संचय मार्ग क्लिपबोर्डवर कॉपी केला!", + "changeLocationTooltips": "डेटा डिरेक्टरी बदला", + "change": "बदला", + "openLocationTooltips": "इतर डेटा डिरेक्टरी उघडा", + "openCurrentDataFolder": "सध्याची डेटा डिरेक्टरी उघडा", + "recoverLocationTooltips": "@:appName च्या मूळ डेटा डिरेक्टरीवर रीसेट करा", + "exportFileSuccess": "फाईल यशस्वीरित्या निर्यात झाली!", + "exportFileFail": "फाईल निर्यात करण्यात अयशस्वी!", + "export": "निर्यात करा", + "clearCache": "कॅशे साफ करा", + "clearCacheDesc": "प्रतिमा लोड होत नाहीत किंवा फॉन्ट अयोग्यरित्या दिसत असल्यास, कॅशे साफ करून पाहा. ही क्रिया तुमचा वापरकर्ता डेटा हटवणार नाही.", + "areYouSureToClearCache": "तुम्हाला नक्की कॅशे साफ करायची आहे का?", + "clearCacheSuccess": "कॅशे यशस्वीरित्या साफ झाली!" +}, + "user": { + "name": "नाव", + "email": "ईमेल", + "tooltipSelectIcon": "चिन्ह निवडा", + "selectAnIcon": "चिन्ह निवडा", + "pleaseInputYourOpenAIKey": "कृपया तुमची AI की टाका", + "clickToLogout": "सध्याचा वापरकर्ता लॉगआउट करण्यासाठी क्लिक करा" +}, + "mobile": { + "personalInfo": "वैयक्तिक माहिती", + "username": "वापरकर्तानाव", + "usernameEmptyError": "वापरकर्तानाव रिकामे असू शकत नाही", + "about": "विषयी", + "pushNotifications": "पुश सूचना", + "support": "सपोर्ट", + "joinDiscord": "Discord मध्ये सहभागी व्हा", + "privacyPolicy": "गोपनीयता धोरण", + "userAgreement": "वापरकर्ता करार", + "termsAndConditions": "अटी व शर्ती", + "userprofileError": "वापरकर्ता प्रोफाइल लोड करण्यात अयशस्वी", + "userprofileErrorDescription": "कृपया लॉगआउट करून पुन्हा लॉगिन करा आणि त्रुटी अजूनही येते का ते पहा.", + "selectLayout": "लेआउट निवडा", + "selectStartingDay": "सप्ताहाचा प्रारंभ दिवस निवडा", + "version": "आवृत्ती" +}, + "grid": { + "deleteView": "तुम्हाला हे दृश्य हटवायचे आहे का?", + "createView": "नवीन", + "title": { + "placeholder": "नाव नाही" + }, + "settings": { + "filter": "फिल्टर", + "sort": "क्रमवारी", + "sortBy": "यावरून क्रमवारी लावा", + "properties": "गुणधर्म", + "reorderPropertiesTooltip": "गुणधर्मांचे स्थान बदला", + "group": "समूह", + "addFilter": "फिल्टर जोडा", + "deleteFilter": "फिल्टर हटवा", + "filterBy": "यावरून फिल्टर करा", + "typeAValue": "मूल्य लिहा...", + "layout": "लेआउट", + "compactMode": "कॉम्पॅक्ट मोड", + "databaseLayout": "लेआउट", + "viewList": { + "zero": "० दृश्ये", + "one": "{count} दृश्य", + "other": "{count} दृश्ये" + }, + "editView": "दृश्य संपादित करा", + "boardSettings": "बोर्ड सेटिंग", + "calendarSettings": "कॅलेंडर सेटिंग", + "createView": "नवीन दृश्य", + "duplicateView": "दृश्याची प्रत बनवा", + "deleteView": "दृश्य हटवा", + "numberOfVisibleFields": "{} दर्शविले" + }, + "filter": { + "empty": "कोणतेही सक्रिय फिल्टर नाहीत", + "addFilter": "फिल्टर जोडा", + "cannotFindCreatableField": "फिल्टर करण्यासाठी योग्य फील्ड सापडले नाही", + "conditon": "अट", + "where": "जिथे" + }, + "textFilter": { + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "endsWith": "याने समाप्त होते", + "startWith": "याने सुरू होते", + "is": "आहे", + "isNot": "नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही", + "choicechipPrefix": { + "isNot": "नाही", + "startWith": "याने सुरू होते", + "endWith": "याने समाप्त होते", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } + }, + "checkboxFilter": { + "isChecked": "निवडलेले आहे", + "isUnchecked": "निवडलेले नाही", + "choicechipPrefix": { + "is": "आहे" + } + }, + "checklistFilter": { + "isComplete": "पूर्ण झाले आहे", + "isIncomplted": "अपूर्ण आहे" + }, + "selectOptionFilter": { + "is": "आहे", + "isNot": "नाही", + "contains": "अंतर्भूत आहे", + "doesNotContain": "अंतर्भूत नाही", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"dateFilter": { + "is": "या दिवशी आहे", + "before": "पूर्वी आहे", + "after": "नंतर आहे", + "onOrBefore": "या दिवशी किंवा त्याआधी आहे", + "onOrAfter": "या दिवशी किंवा त्यानंतर आहे", + "between": "दरम्यान आहे", + "empty": "रिकामे आहे", + "notEmpty": "रिकामे नाही", + "startDate": "सुरुवातीची तारीख", + "endDate": "शेवटची तारीख", + "choicechipPrefix": { + "before": "पूर्वी", + "after": "नंतर", + "between": "दरम्यान", + "onOrBefore": "या दिवशी किंवा त्याआधी", + "onOrAfter": "या दिवशी किंवा त्यानंतर", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" + } +}, +"numberFilter": { + "equal": "बरोबर आहे", + "notEqual": "बरोबर नाही", + "lessThan": "पेक्षा कमी आहे", + "greaterThan": "पेक्षा जास्त आहे", + "lessThanOrEqualTo": "किंवा कमी आहे", + "greaterThanOrEqualTo": "किंवा जास्त आहे", + "isEmpty": "रिकामे आहे", + "isNotEmpty": "रिकामे नाही" +}, +"field": { + "label": "गुणधर्म", + "hide": "गुणधर्म लपवा", + "show": "गुणधर्म दर्शवा", + "insertLeft": "डावीकडे जोडा", + "insertRight": "उजवीकडे जोडा", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "wrapCellContent": "पाठ लपेटा", + "clear": "सेल्स रिकामे करा", + "switchPrimaryFieldTooltip": "प्राथमिक फील्डचा प्रकार बदलू शकत नाही", + "textFieldName": "मजकूर", + "checkboxFieldName": "चेकबॉक्स", + "dateFieldName": "तारीख", + "updatedAtFieldName": "शेवटचे अपडेट", + "createdAtFieldName": "तयार झाले", + "numberFieldName": "संख्या", + "singleSelectFieldName": "सिंगल सिलेक्ट", + "multiSelectFieldName": "मल्टीसिलेक्ट", + "urlFieldName": "URL", + "checklistFieldName": "चेकलिस्ट", + "relationFieldName": "संबंध", + "summaryFieldName": "AI सारांश", + "timeFieldName": "वेळ", + "mediaFieldName": "फाईल्स आणि मीडिया", + "translateFieldName": "AI भाषांतर", + "translateTo": "मध्ये भाषांतर करा", + "numberFormat": "संख्या स्वरूप", + "dateFormat": "तारीख स्वरूप", + "includeTime": "वेळ जोडा", + "isRange": "शेवटची तारीख", + "dateFormatFriendly": "महिना दिवस, वर्ष", + "dateFormatISO": "वर्ष-महिना-दिनांक", + "dateFormatLocal": "महिना/दिवस/वर्ष", + "dateFormatUS": "वर्ष/महिना/दिवस", + "dateFormatDayMonthYear": "दिवस/महिना/वर्ष", + "timeFormat": "वेळ स्वरूप", + "invalidTimeFormat": "अवैध स्वरूप", + "timeFormatTwelveHour": "१२ तास", + "timeFormatTwentyFourHour": "२४ तास", + "clearDate": "तारीख हटवा", + "dateTime": "तारीख व वेळ", + "startDateTime": "सुरुवातीची तारीख व वेळ", + "endDateTime": "शेवटची तारीख व वेळ", + "failedToLoadDate": "तारीख मूल्य लोड करण्यात अयशस्वी", + "selectTime": "वेळ निवडा", + "selectDate": "तारीख निवडा", + "visibility": "दृश्यता", + "propertyType": "गुणधर्माचा प्रकार", + "addSelectOption": "पर्याय जोडा", + "typeANewOption": "नवीन पर्याय लिहा", + "optionTitle": "पर्याय", + "addOption": "पर्याय जोडा", + "editProperty": "गुणधर्म संपादित करा", + "newProperty": "नवीन गुणधर्म", + "openRowDocument": "पृष्ठ म्हणून उघडा", + "deleteFieldPromptMessage": "तुम्हाला खात्री आहे का? हा गुणधर्म आणि त्याचा डेटा हटवला जाईल", + "clearFieldPromptMessage": "तुम्हाला खात्री आहे का? या कॉलममधील सर्व सेल्स रिकामे होतील", + "newColumn": "नवीन कॉलम", + "format": "स्वरूप", + "reminderOnDateTooltip": "या सेलमध्ये अनुस्मारक शेड्यूल केले आहे", + "optionAlreadyExist": "पर्याय आधीच अस्तित्वात आहे" +}, + "rowPage": { + "newField": "नवीन फील्ड जोडा", + "fieldDragElementTooltip": "मेनू उघडण्यासाठी क्लिक करा", + "showHiddenFields": { + "one": "{count} लपलेले फील्ड दाखवा", + "many": "{count} लपलेली फील्ड दाखवा", + "other": "{count} लपलेली फील्ड दाखवा" + }, + "hideHiddenFields": { + "one": "{count} लपलेले फील्ड लपवा", + "many": "{count} लपलेली फील्ड लपवा", + "other": "{count} लपलेली फील्ड लपवा" + }, + "openAsFullPage": "पूर्ण पृष्ठ म्हणून उघडा", + "moreRowActions": "अधिक पंक्ती क्रिया" +}, +"sort": { + "ascending": "चढत्या क्रमाने", + "descending": "उतरत्या क्रमाने", + "by": "द्वारे", + "empty": "सक्रिय सॉर्ट्स नाहीत", + "cannotFindCreatableField": "सॉर्टसाठी योग्य फील्ड सापडले नाही", + "deleteAllSorts": "सर्व सॉर्ट्स हटवा", + "addSort": "सॉर्ट जोडा", + "sortsActive": "सॉर्टिंग करत असताना {intention} करू शकत नाही", + "removeSorting": "या दृश्यातील सर्व सॉर्ट्स हटवायचे आहेत का?", + "fieldInUse": "या फील्डवर आधीच सॉर्टिंग चालू आहे" +}, +"row": { + "label": "पंक्ती", + "duplicate": "प्रत बनवा", + "delete": "हटवा", + "titlePlaceholder": "शीर्षक नाही", + "textPlaceholder": "रिक्त", + "copyProperty": "गुणधर्म क्लिपबोर्डवर कॉपी केला", + "count": "संख्या", + "newRow": "नवीन पंक्ती", + "loadMore": "अधिक लोड करा", + "action": "क्रिया", + "add": "खाली जोडा वर क्लिक करा", + "drag": "हलवण्यासाठी ड्रॅग करा", + "deleteRowPrompt": "तुम्हाला खात्री आहे का की ही पंक्ती हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "deleteCardPrompt": "तुम्हाला खात्री आहे का की हे कार्ड हटवायचे आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "dragAndClick": "हलवण्यासाठी ड्रॅग करा, मेनू उघडण्यासाठी क्लिक करा", + "insertRecordAbove": "वर रेकॉर्ड जोडा", + "insertRecordBelow": "खाली रेकॉर्ड जोडा", + "noContent": "माहिती नाही", + "reorderRowDescription": "पंक्तीचे पुन्हा क्रमांकन", + "createRowAboveDescription": "वर पंक्ती तयार करा", + "createRowBelowDescription": "खाली पंक्ती जोडा" +}, +"selectOption": { + "create": "तयार करा", + "purpleColor": "जांभळा", + "pinkColor": "गुलाबी", + "lightPinkColor": "फिकट गुलाबी", + "orangeColor": "नारंगी", + "yellowColor": "पिवळा", + "limeColor": "लिंबू", + "greenColor": "हिरवा", + "aquaColor": "आक्वा", + "blueColor": "निळा", + "deleteTag": "टॅग हटवा", + "colorPanelTitle": "रंग", + "panelTitle": "पर्याय निवडा किंवा नवीन तयार करा", + "searchOption": "पर्याय शोधा", + "searchOrCreateOption": "पर्याय शोधा किंवा तयार करा", + "createNew": "नवीन तयार करा", + "orSelectOne": "किंवा पर्याय निवडा", + "typeANewOption": "नवीन पर्याय टाइप करा", + "tagName": "टॅग नाव" +}, +"checklist": { + "taskHint": "कार्याचे वर्णन", + "addNew": "नवीन कार्य जोडा", + "submitNewTask": "तयार करा", + "hideComplete": "पूर्ण कार्ये लपवा", + "showComplete": "सर्व कार्ये दाखवा" +}, +"url": { + "launch": "बाह्य ब्राउझरमध्ये लिंक उघडा", + "copy": "लिंक क्लिपबोर्डवर कॉपी करा", + "textFieldHint": "URL टाका", + "copiedNotification": "क्लिपबोर्डवर कॉपी केले!" +}, +"relation": { + "relatedDatabasePlaceLabel": "संबंधित डेटाबेस", + "relatedDatabasePlaceholder": "काही नाही", + "inRelatedDatabase": "या मध्ये", + "rowSearchTextFieldPlaceholder": "शोध", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया खालील यादीतून एक निवडा:", + "emptySearchResult": "कोणतीही नोंद सापडली नाही", + "linkedRowListLabel": "{count} लिंक केलेल्या पंक्ती", + "unlinkedRowListLabel": "आणखी एक पंक्ती लिंक करा" +}, +"menuName": "ग्रिड", +"referencedGridPrefix": "दृश्य", +"calculate": "गणना करा", +"calculationTypeLabel": { + "none": "काही नाही", + "average": "सरासरी", + "max": "कमाल", + "median": "मध्यम", + "min": "किमान", + "sum": "बेरीज", + "count": "मोजणी", + "countEmpty": "रिकाम्यांची मोजणी", + "countEmptyShort": "रिक्त", + "countNonEmpty": "रिक्त नसलेल्यांची मोजणी", + "countNonEmptyShort": "भरलेले" +}, +"media": { + "rename": "पुन्हा नाव द्या", + "download": "डाउनलोड करा", + "expand": "मोठे करा", + "delete": "हटवा", + "moreFilesHint": "+{}", + "addFileOrImage": "फाईल किंवा लिंक जोडा", + "attachmentsHint": "{}", + "addFileMobile": "फाईल जोडा", + "extraCount": "+{}", + "deleteFileDescription": "तुम्हाला खात्री आहे का की ही फाईल हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", + "showFileNames": "फाईलचे नाव दाखवा", + "downloadSuccess": "फाईल डाउनलोड झाली", + "downloadFailedToken": "फाईल डाउनलोड अयशस्वी, वापरकर्ता टोकन अनुपलब्ध", + "setAsCover": "कव्हर म्हणून सेट करा", + "openInBrowser": "ब्राउझरमध्ये उघडा", + "embedLink": "फाईल लिंक एम्बेड करा" + } +}, + "document": { + "menuName": "दस्तऐवज", + "date": { + "timeHintTextInTwelveHour": "01:00 PM", + "timeHintTextInTwentyFourHour": "13:00" + }, + "creating": "तयार करत आहे...", + "slashMenu": { + "board": { + "selectABoardToLinkTo": "लिंक करण्यासाठी बोर्ड निवडा", + "createANewBoard": "नवीन बोर्ड तयार करा" + }, + "grid": { + "selectAGridToLinkTo": "लिंक करण्यासाठी ग्रिड निवडा", + "createANewGrid": "नवीन ग्रिड तयार करा" + }, + "calendar": { + "selectACalendarToLinkTo": "लिंक करण्यासाठी दिनदर्शिका निवडा", + "createANewCalendar": "नवीन दिनदर्शिका तयार करा" + }, + "document": { + "selectADocumentToLinkTo": "लिंक करण्यासाठी दस्तऐवज निवडा" + }, + "name": { + "textStyle": "मजकुराची शैली", + "list": "यादी", + "toggle": "टॉगल", + "fileAndMedia": "फाईल व मीडिया", + "simpleTable": "सोपे टेबल", + "visuals": "दृश्य घटक", + "document": "दस्तऐवज", + "advanced": "प्रगत", + "text": "मजकूर", + "heading1": "शीर्षक 1", + "heading2": "शीर्षक 2", + "heading3": "शीर्षक 3", + "image": "प्रतिमा", + "bulletedList": "बुलेट यादी", + "numberedList": "क्रमांकित यादी", + "todoList": "करण्याची यादी", + "doc": "दस्तऐवज", + "linkedDoc": "पृष्ठाशी लिंक करा", + "grid": "ग्रिड", + "linkedGrid": "लिंक केलेला ग्रिड", + "kanban": "कानबन", + "linkedKanban": "लिंक केलेला कानबन", + "calendar": "दिनदर्शिका", + "linkedCalendar": "लिंक केलेली दिनदर्शिका", + "quote": "उद्धरण", + "divider": "विभाजक", + "table": "टेबल", + "callout": "महत्त्वाचा मजकूर", + "outline": "रूपरेषा", + "mathEquation": "गणिती समीकरण", + "code": "कोड", + "toggleList": "टॉगल यादी", + "toggleHeading1": "टॉगल शीर्षक 1", + "toggleHeading2": "टॉगल शीर्षक 2", + "toggleHeading3": "टॉगल शीर्षक 3", + "emoji": "इमोजी", + "aiWriter": "AI ला काहीही विचारा", + "dateOrReminder": "दिनांक किंवा स्मरणपत्र", + "photoGallery": "फोटो गॅलरी", + "file": "फाईल", + "twoColumns": "२ स्तंभ", + "threeColumns": "३ स्तंभ", + "fourColumns": "४ स्तंभ" + }, + "subPage": { + "name": "दस्तऐवज", + "keyword1": "उपपृष्ठ", + "keyword2": "पृष्ठ", + "keyword3": "चाइल्ड पृष्ठ", + "keyword4": "पृष्ठ जोडा", + "keyword5": "एम्बेड पृष्ठ", + "keyword6": "नवीन पृष्ठ", + "keyword7": "पृष्ठ तयार करा", + "keyword8": "दस्तऐवज" + } + }, + "selectionMenu": { + "outline": "रूपरेषा", + "codeBlock": "कोड ब्लॉक" + }, + "plugins": { + "referencedBoard": "संदर्भित बोर्ड", + "referencedGrid": "संदर्भित ग्रिड", + "referencedCalendar": "संदर्भित दिनदर्शिका", + "referencedDocument": "संदर्भित दस्तऐवज", + "aiWriter": { + "userQuestion": "AI ला काहीही विचारा", + "continueWriting": "लेखन सुरू ठेवा", + "fixSpelling": "स्पेलिंग व व्याकरण सुधारणा", + "improveWriting": "लेखन सुधारित करा", + "summarize": "सारांश द्या", + "explain": "स्पष्टीकरण द्या", + "makeShorter": "लहान करा", + "makeLonger": "मोठे करा" + }, + "autoGeneratorMenuItemName": "AI लेखक", +"autoGeneratorTitleName": "AI: काहीही लिहिण्यासाठी AI ला विचारा...", +"autoGeneratorLearnMore": "अधिक जाणून घ्या", +"autoGeneratorGenerate": "उत्पन्न करा", +"autoGeneratorHintText": "AI ला विचारा...", +"autoGeneratorCantGetOpenAIKey": "AI की मिळवू शकलो नाही", +"autoGeneratorRewrite": "पुन्हा लिहा", +"smartEdit": "AI ला विचारा", +"aI": "AI", +"smartEditFixSpelling": "स्पेलिंग आणि व्याकरण सुधारा", +"warning": "⚠️ AI उत्तरं चुकीची किंवा दिशाभूल करणारी असू शकतात.", +"smartEditSummarize": "सारांश द्या", +"smartEditImproveWriting": "लेखन सुधारित करा", +"smartEditMakeLonger": "लांब करा", +"smartEditCouldNotFetchResult": "AI कडून उत्तर मिळवता आले नाही", +"smartEditCouldNotFetchKey": "AI की मिळवता आली नाही", +"smartEditDisabled": "सेटिंग्जमध्ये AI कनेक्ट करा", +"appflowyAIEditDisabled": "AI वैशिष्ट्ये सक्षम करण्यासाठी साइन इन करा", +"discardResponse": "AI उत्तर फेकून द्यायचं आहे का?", +"createInlineMathEquation": "समीकरण तयार करा", +"fonts": "फॉन्ट्स", +"insertDate": "तारीख जोडा", +"emoji": "इमोजी", +"toggleList": "टॉगल यादी", +"emptyToggleHeading": "रिकामे टॉगल h{}. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleList": "रिकामी टॉगल यादी. मजकूर जोडण्यासाठी क्लिक करा.", +"emptyToggleHeadingWeb": "रिकामे टॉगल h{level}. मजकूर जोडण्यासाठी क्लिक करा", +"quoteList": "उद्धरण यादी", +"numberedList": "क्रमांकित यादी", +"bulletedList": "बुलेट यादी", +"todoList": "करण्याची यादी", +"callout": "ठळक मजकूर", +"simpleTable": { + "moreActions": { + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "insertLeft": "डावीकडे घाला", + "insertRight": "उजवीकडे घाला", + "insertAbove": "वर घाला", + "insertBelow": "खाली घाला", + "headerColumn": "हेडर स्तंभ", + "headerRow": "हेडर ओळ", + "clearContents": "सामग्री साफ करा", + "setToPageWidth": "पृष्ठाच्या रुंदीप्रमाणे सेट करा", + "distributeColumnsWidth": "स्तंभ समान करा", + "duplicateRow": "ओळ डुप्लिकेट करा", + "duplicateColumn": "स्तंभ डुप्लिकेट करा", + "textColor": "मजकूराचा रंग", + "cellBackgroundColor": "सेलचा पार्श्वभूमी रंग", + "duplicateTable": "टेबल डुप्लिकेट करा" + }, + "clickToAddNewRow": "नवीन ओळ जोडण्यासाठी क्लिक करा", + "clickToAddNewColumn": "नवीन स्तंभ जोडण्यासाठी क्लिक करा", + "clickToAddNewRowAndColumn": "नवीन ओळ आणि स्तंभ जोडण्यासाठी क्लिक करा", + "headerName": { + "table": "टेबल", + "alignText": "मजकूर पंक्तिबद्ध करा" + } +}, +"cover": { + "changeCover": "कव्हर बदला", + "colors": "रंग", + "images": "प्रतिमा", + "clearAll": "सर्व साफ करा", + "abstract": "ऍबस्ट्रॅक्ट", + "addCover": "कव्हर जोडा", + "addLocalImage": "स्थानिक प्रतिमा जोडा", + "invalidImageUrl": "अवैध प्रतिमा URL", + "failedToAddImageToGallery": "प्रतिमा गॅलरीत जोडता आली नाही", + "enterImageUrl": "प्रतिमा URL लिहा", + "add": "जोडा", + "back": "मागे", + "saveToGallery": "गॅलरीत जतन करा", + "removeIcon": "आयकॉन काढा", + "removeCover": "कव्हर काढा", + "pasteImageUrl": "प्रतिमा URL पेस्ट करा", + "or": "किंवा", + "pickFromFiles": "फाईल्समधून निवडा", + "couldNotFetchImage": "प्रतिमा मिळवता आली नाही", + "imageSavingFailed": "प्रतिमा जतन करणे अयशस्वी", + "addIcon": "आयकॉन जोडा", + "changeIcon": "आयकॉन बदला", + "coverRemoveAlert": "हे हटवल्यानंतर कव्हरमधून काढले जाईल.", + "alertDialogConfirmation": "तुम्हाला खात्री आहे का? तुम्हाला पुढे जायचे आहे?" +}, +"mathEquation": { + "name": "गणिती समीकरण", + "addMathEquation": "TeX समीकरण जोडा", + "editMathEquation": "गणिती समीकरण संपादित करा" +}, +"optionAction": { + "click": "क्लिक", + "toOpenMenu": "मेनू उघडण्यासाठी", + "drag": "ओढा", + "toMove": "हलवण्यासाठी", + "delete": "हटा", + "duplicate": "डुप्लिकेट करा", + "turnInto": "मध्ये बदला", + "moveUp": "वर हलवा", + "moveDown": "खाली हलवा", + "color": "रंग", + "align": "पंक्तिबद्ध करा", + "left": "डावीकडे", + "center": "मध्यभागी", + "right": "उजवीकडे", + "defaultColor": "डिफॉल्ट", + "depth": "खोली", + "copyLinkToBlock": "ब्लॉकसाठी लिंक कॉपी करा" +}, + "image": { + "addAnImage": "प्रतिमा जोडा", + "copiedToPasteBoard": "प्रतिमेची लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "addAnImageDesktop": "प्रतिमा जोडा", + "addAnImageMobile": "एक किंवा अधिक प्रतिमा जोडण्यासाठी क्लिक करा", + "dropImageToInsert": "प्रतिमा ड्रॉप करून जोडा", + "imageUploadFailed": "प्रतिमा अपलोड करण्यात अयशस्वी", + "imageDownloadFailed": "प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "imageDownloadFailedToken": "युजर टोकन नसल्यामुळे प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "errorCode": "त्रुटी कोड" +}, +"photoGallery": { + "name": "फोटो गॅलरी", + "imageKeyword": "प्रतिमा", + "imageGalleryKeyword": "प्रतिमा गॅलरी", + "photoKeyword": "फोटो", + "photoBrowserKeyword": "फोटो ब्राउझर", + "galleryKeyword": "गॅलरी", + "addImageTooltip": "प्रतिमा जोडा", + "changeLayoutTooltip": "लेआउट बदला", + "browserLayout": "ब्राउझर", + "gridLayout": "ग्रिड", + "deleteBlockTooltip": "संपूर्ण गॅलरी हटवा" +}, +"math": { + "copiedToPasteBoard": "समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे" +}, +"urlPreview": { + "copiedToPasteBoard": "लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "convertToLink": "एंबेड लिंकमध्ये रूपांतर करा" +}, +"outline": { + "addHeadingToCreateOutline": "सामग्री यादी तयार करण्यासाठी शीर्षके जोडा.", + "noMatchHeadings": "जुळणारी शीर्षके आढळली नाहीत." +}, +"table": { + "addAfter": "नंतर जोडा", + "addBefore": "आधी जोडा", + "delete": "हटा", + "clear": "सामग्री साफ करा", + "duplicate": "डुप्लिकेट करा", + "bgColor": "पार्श्वभूमीचा रंग" +}, +"contextMenu": { + "copy": "कॉपी करा", + "cut": "कापा", + "paste": "पेस्ट करा", + "pasteAsPlainText": "साध्या मजकूराच्या स्वरूपात पेस्ट करा" +}, +"action": "कृती", +"database": { + "selectDataSource": "डेटा स्रोत निवडा", + "noDataSource": "डेटा स्रोत नाही", + "selectADataSource": "डेटा स्रोत निवडा", + "toContinue": "पुढे जाण्यासाठी", + "newDatabase": "नवीन डेटाबेस", + "linkToDatabase": "डेटाबेसशी लिंक करा" +}, +"date": "तारीख", +"video": { + "label": "व्हिडिओ", + "emptyLabel": "व्हिडिओ जोडा", + "placeholder": "व्हिडिओ लिंक पेस्ट करा", + "copiedToPasteBoard": "व्हिडिओ लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "insertVideo": "व्हिडिओ जोडा", + "invalidVideoUrl": "ही URL सध्या समर्थित नाही.", + "invalidVideoUrlYouTube": "YouTube सध्या समर्थित नाही.", + "supportedFormats": "समर्थित स्वरूप: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" +}, +"file": { + "name": "फाईल", + "uploadTab": "अपलोड", + "uploadMobile": "फाईल निवडा", + "uploadMobileGallery": "फोटो गॅलरीमधून", + "networkTab": "लिंक एम्बेड करा", + "placeholderText": "फाईल अपलोड किंवा एम्बेड करा", + "placeholderDragging": "फाईल ड्रॉप करून अपलोड करा", + "dropFileToUpload": "फाईल ड्रॉप करून अपलोड करा", + "fileUploadHint": "फाईल ड्रॅग आणि ड्रॉप करा किंवा क्लिक करा ", + "fileUploadHintSuffix": "ब्राउझ करा", + "networkHint": "फाईल लिंक पेस्ट करा", + "networkUrlInvalid": "अवैध URL. कृपया तपासा आणि पुन्हा प्रयत्न करा.", + "networkAction": "एम्बेड", + "fileTooBigError": "फाईल खूप मोठी आहे, कृपया 10MB पेक्षा कमी फाईल अपलोड करा", + "renameFile": { + "title": "फाईलचे नाव बदला", + "description": "या फाईलसाठी नवीन नाव लिहा", + "nameEmptyError": "फाईलचे नाव रिकामे असू शकत नाही." + }, + "uploadedAt": "{} रोजी अपलोड केले", + "linkedAt": "{} रोजी लिंक जोडली", + "failedToOpenMsg": "उघडण्यात अयशस्वी, फाईल सापडली नाही" +}, +"subPage": { + "handlingPasteHint": " - (पेस्ट प्रक्रिया करत आहे)", + "errors": { + "failedDeletePage": "पृष्ठ हटवण्यात अयशस्वी", + "failedCreatePage": "पृष्ठ तयार करण्यात अयशस्वी", + "failedMovePage": "हे पृष्ठ हलवण्यात अयशस्वी", + "failedDuplicatePage": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी", + "failedDuplicateFindView": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी - मूळ दृश्य सापडले नाही" + } +}, + "cannotMoveToItsChildren": "मुलांमध्ये हलवू शकत नाही" +}, +"outlineBlock": { + "placeholder": "सामग्री सूची" +}, +"textBlock": { + "placeholder": "कमांडसाठी '/' टाइप करा" +}, +"title": { + "placeholder": "शीर्षक नाही" +}, +"imageBlock": { + "placeholder": "प्रतिमा जोडण्यासाठी क्लिक करा", + "upload": { + "label": "अपलोड", + "placeholder": "प्रतिमा अपलोड करण्यासाठी क्लिक करा" + }, + "url": { + "label": "प्रतिमेची URL", + "placeholder": "प्रतिमेची URL टाका" + }, + "ai": { + "label": "AI द्वारे प्रतिमा तयार करा", + "placeholder": "AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "stability_ai": { + "label": "Stability AI द्वारे प्रतिमा तयार करा", + "placeholder": "Stability AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" + }, + "support": "प्रतिमेचा कमाल आकार 5MB आहे. समर्थित स्वरूप: JPEG, PNG, GIF, SVG", + "error": { + "invalidImage": "अवैध प्रतिमा", + "invalidImageSize": "प्रतिमेचा आकार 5MB पेक्षा कमी असावा", + "invalidImageFormat": "प्रतिमेचे स्वरूप समर्थित नाही. समर्थित स्वरूप: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "अवैध प्रतिमेची URL", + "noImage": "अशी फाईल किंवा निर्देशिका नाही", + "multipleImagesFailed": "एक किंवा अधिक प्रतिमा अपलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा" + }, + "embedLink": { + "label": "लिंक एम्बेड करा", + "placeholder": "प्रतिमेची लिंक पेस्ट करा किंवा टाका" + }, + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "प्रतिमा शोधा", + "pleaseInputYourOpenAIKey": "कृपया सेटिंग्ज पृष्ठात आपला AI की प्रविष्ट करा", + "saveImageToGallery": "प्रतिमा जतन करा", + "failedToAddImageToGallery": "प्रतिमा जतन करण्यात अयशस्वी", + "successToAddImageToGallery": "प्रतिमा 'Photos' मध्ये जतन केली", + "unableToLoadImage": "प्रतिमा लोड करण्यात अयशस्वी", + "maximumImageSize": "कमाल प्रतिमा अपलोड आकार 10MB आहे", + "uploadImageErrorImageSizeTooBig": "प्रतिमेचा आकार 10MB पेक्षा कमी असावा", + "imageIsUploading": "प्रतिमा अपलोड होत आहे", + "openFullScreen": "पूर्ण स्क्रीनमध्ये उघडा", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "मागील प्रतिमा", + "nextImageTooltip": "पुढील प्रतिमा", + "zoomOutTooltip": "लहान करा", + "zoomInTooltip": "मोठी करा", + "changeZoomLevelTooltip": "झूम पातळी बदला", + "openLocalImage": "प्रतिमा उघडा", + "downloadImage": "प्रतिमा डाउनलोड करा", + "closeViewer": "इंटरअॅक्टिव्ह व्ह्युअर बंद करा", + "scalePercentage": "{}%", + "deleteImageTooltip": "प्रतिमा हटवा" + } + } +}, + "codeBlock": { + "language": { + "label": "भाषा", + "placeholder": "भाषा निवडा", + "auto": "स्वयंचलित" + }, + "copyTooltip": "कॉपी करा", + "searchLanguageHint": "भाषा शोधा", + "codeCopiedSnackbar": "कोड क्लिपबोर्डवर कॉपी झाला!" +}, +"inlineLink": { + "placeholder": "लिंक पेस्ट करा किंवा टाका", + "openInNewTab": "नवीन टॅबमध्ये उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "url": { + "label": "लिंक URL", + "placeholder": "लिंक URL टाका" + }, + "title": { + "label": "लिंक शीर्षक", + "placeholder": "लिंक शीर्षक टाका" + } +}, +"mention": { + "placeholder": "कोणीतरी, पृष्ठ किंवा तारीख नमूद करा...", + "page": { + "label": "पृष्ठाला लिंक करा", + "tooltip": "पृष्ठ उघडण्यासाठी क्लिक करा" + }, + "deleted": "हटवले गेले", + "deletedContent": "ही सामग्री अस्तित्वात नाही किंवा हटवण्यात आली आहे", + "noAccess": "प्रवेश नाही", + "deletedPage": "हटवलेले पृष्ठ", + "trashHint": " - ट्रॅशमध्ये", + "morePages": "अजून पृष्ठे" +}, +"toolbar": { + "resetToDefaultFont": "डीफॉल्ट फॉन्टवर परत जा", + "textSize": "मजकूराचा आकार", + "textColor": "मजकूराचा रंग", + "h1": "मथळा 1", + "h2": "मथळा 2", + "h3": "मथळा 3", + "alignLeft": "डावीकडे संरेखित करा", + "alignRight": "उजवीकडे संरेखित करा", + "alignCenter": "मध्यभागी संरेखित करा", + "link": "लिंक", + "textAlign": "मजकूर संरेखन", + "moreOptions": "अधिक पर्याय", + "font": "फॉन्ट", + "inlineCode": "इनलाइन कोड", + "suggestions": "सूचना", + "turnInto": "मध्ये रूपांतरित करा", + "equation": "समीकरण", + "insert": "घाला", + "linkInputHint": "लिंक पेस्ट करा किंवा पृष्ठे शोधा", + "pageOrURL": "पृष्ठ किंवा URL", + "linkName": "लिंकचे नाव", + "linkNameHint": "लिंकचे नाव प्रविष्ट करा" +}, +"errorBlock": { + "theBlockIsNotSupported": "ब्लॉक सामग्री पार्स करण्यात अक्षम", + "clickToCopyTheBlockContent": "ब्लॉक सामग्री कॉपी करण्यासाठी क्लिक करा", + "blockContentHasBeenCopied": "ब्लॉक सामग्री कॉपी केली आहे.", + "parseError": "{} ब्लॉक पार्स करताना त्रुटी आली.", + "copyBlockContent": "ब्लॉक सामग्री कॉपी करा" +}, +"mobilePageSelector": { + "title": "पृष्ठ निवडा", + "failedToLoad": "पृष्ठ यादी लोड करण्यात अयशस्वी", + "noPagesFound": "कोणतीही पृष्ठे सापडली नाहीत" +}, +"attachmentMenu": { + "choosePhoto": "फोटो निवडा", + "takePicture": "फोटो काढा", + "chooseFile": "फाईल निवडा" + } + }, + "board": { + "column": { + "label": "स्तंभ", + "createNewCard": "नवीन", + "renameGroupTooltip": "गटाचे नाव बदलण्यासाठी क्लिक करा", + "createNewColumn": "नवीन गट जोडा", + "addToColumnTopTooltip": "वर नवीन कार्ड जोडा", + "addToColumnBottomTooltip": "खाली नवीन कार्ड जोडा", + "renameColumn": "स्तंभाचे नाव बदला", + "hideColumn": "लपवा", + "newGroup": "नवीन गट", + "deleteColumn": "हटवा", + "deleteColumnConfirmation": "हा गट आणि त्यामधील सर्व कार्ड्स हटवले जातील. तुम्हाला खात्री आहे का?" + }, + "hiddenGroupSection": { + "sectionTitle": "लपवलेले गट", + "collapseTooltip": "लपवलेले गट लपवा", + "expandTooltip": "लपवलेले गट पाहा" + }, + "cardDetail": "कार्ड तपशील", + "cardActions": "कार्ड क्रिया", + "cardDuplicated": "कार्डची प्रत तयार झाली", + "cardDeleted": "कार्ड हटवले गेले", + "showOnCard": "कार्ड तपशिलावर दाखवा", + "setting": "सेटिंग", + "propertyName": "गुणधर्माचे नाव", + "menuName": "बोर्ड", + "showUngrouped": "गटात नसलेली कार्ड्स दाखवा", + "ungroupedButtonText": "गट नसलेली", + "ungroupedButtonTooltip": "ज्या कार्ड्स कोणत्याही गटात नाहीत", + "ungroupedItemsTitle": "बोर्डमध्ये जोडण्यासाठी क्लिक करा", + "groupBy": "या आधारावर गट करा", + "groupCondition": "गट स्थिती", + "referencedBoardPrefix": "याचे दृश्य", + "notesTooltip": "नोट्स आहेत", + "mobile": { + "editURL": "URL संपादित करा", + "showGroup": "गट दाखवा", + "showGroupContent": "हा गट बोर्डवर दाखवायचा आहे का?", + "failedToLoad": "बोर्ड दृश्य लोड होण्यात अयशस्वी" + }, + "dateCondition": { + "weekOf": "{} - {} ची आठवडा", + "today": "आज", + "yesterday": "काल", + "tomorrow": "उद्या", + "lastSevenDays": "शेवटचे ७ दिवस", + "nextSevenDays": "पुढील ७ दिवस", + "lastThirtyDays": "शेवटचे ३० दिवस", + "nextThirtyDays": "पुढील ३० दिवस" + }, + "noGroup": "गटासाठी कोणताही गुणधर्म निवडलेला नाही", + "noGroupDesc": "बोर्ड दृश्य दाखवण्यासाठी गट करण्याचा एक गुणधर्म आवश्यक आहे", + "media": { + "cardText": "{} {}", + "fallbackName": "फायली" + } +}, + "calendar": { + "menuName": "कॅलेंडर", + "defaultNewCalendarTitle": "नाव नाही", + "newEventButtonTooltip": "नवीन इव्हेंट जोडा", + "navigation": { + "today": "आज", + "jumpToday": "आजवर जा", + "previousMonth": "मागील महिना", + "nextMonth": "पुढील महिना", + "views": { + "day": "दिवस", + "week": "आठवडा", + "month": "महिना", + "year": "वर्ष" + } + }, + "mobileEventScreen": { + "emptyTitle": "सध्या कोणतेही इव्हेंट नाहीत", + "emptyBody": "या दिवशी इव्हेंट तयार करण्यासाठी प्लस बटणावर क्लिक करा." + }, + "settings": { + "showWeekNumbers": "आठवड्याचे क्रमांक दाखवा", + "showWeekends": "सप्ताहांत दाखवा", + "firstDayOfWeek": "आठवड्याची सुरुवात", + "layoutDateField": "कॅलेंडर मांडणी दिनांकानुसार", + "changeLayoutDateField": "मांडणी फील्ड बदला", + "noDateTitle": "तारीख नाही", + "noDateHint": { + "zero": "नियोजित नसलेली इव्हेंट्स येथे दिसतील", + "one": "{count} नियोजित नसलेली इव्हेंट", + "other": "{count} नियोजित नसलेल्या इव्हेंट्स" + }, + "unscheduledEventsTitle": "नियोजित नसलेल्या इव्हेंट्स", + "clickToAdd": "कॅलेंडरमध्ये जोडण्यासाठी क्लिक करा", + "name": "कॅलेंडर सेटिंग्ज", + "clickToOpen": "रेकॉर्ड उघडण्यासाठी क्लिक करा" + }, + "referencedCalendarPrefix": "याचे दृश्य", + "quickJumpYear": "या वर्षावर जा", + "duplicateEvent": "इव्हेंट डुप्लिकेट करा" +}, + "errorDialog": { + "title": "@:appName त्रुटी", + "howToFixFallback": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया GitHub पेजवर त्रुटीबद्दल माहिती देणारे एक issue सबमिट करा.", + "howToFixFallbackHint1": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया ", + "howToFixFallbackHint2": " पेजवर त्रुटीचे वर्णन करणारे issue सबमिट करा.", + "github": "GitHub वर पहा" +}, +"search": { + "label": "शोध", + "sidebarSearchIcon": "पृष्ठ शोधा आणि पटकन जा", + "placeholder": { + "actions": "कृती शोधा..." + } +}, +"message": { + "copy": { + "success": "कॉपी झाले!", + "fail": "कॉपी करू शकत नाही" + } +}, +"unSupportBlock": "सध्याचा व्हर्जन या ब्लॉकला समर्थन देत नाही.", +"views": { + "deleteContentTitle": "तुम्हाला हे {pageType} हटवायचे आहे का?", + "deleteContentCaption": "हे {pageType} हटवल्यास, तुम्ही ते trash मधून पुनर्संचयित करू शकता." +}, + "colors": { + "custom": "सानुकूल", + "default": "डीफॉल्ट", + "red": "लाल", + "orange": "संत्रा", + "yellow": "पिवळा", + "green": "हिरवा", + "blue": "निळा", + "purple": "जांभळा", + "pink": "गुलाबी", + "brown": "तपकिरी", + "gray": "करड्या रंगाचा" +}, + "emoji": { + "emojiTab": "इमोजी", + "search": "इमोजी शोधा", + "noRecent": "अलीकडील कोणतेही इमोजी नाहीत", + "noEmojiFound": "कोणतेही इमोजी सापडले नाहीत", + "filter": "फिल्टर", + "random": "योगायोगाने", + "selectSkinTone": "त्वचेचा टोन निवडा", + "remove": "इमोजी काढा", + "categories": { + "smileys": "स्मायली आणि भावना", + "people": "लोक", + "animals": "प्राणी आणि निसर्ग", + "food": "अन्न", + "activities": "क्रिया", + "places": "स्थळे", + "objects": "वस्तू", + "symbols": "चिन्हे", + "flags": "ध्वज", + "nature": "निसर्ग", + "frequentlyUsed": "नेहमी वापरलेले" + }, + "skinTone": { + "default": "डीफॉल्ट", + "light": "हलका", + "mediumLight": "मध्यम-हलका", + "medium": "मध्यम", + "mediumDark": "मध्यम-गडद", + "dark": "गडद" + }, + "openSourceIconsFrom": "खुल्या स्रोताचे आयकॉन्स" +}, + "inlineActions": { + "noResults": "निकाल नाही", + "recentPages": "अलीकडील पृष्ठे", + "pageReference": "पृष्ठ संदर्भ", + "docReference": "दस्तऐवज संदर्भ", + "boardReference": "बोर्ड संदर्भ", + "calReference": "कॅलेंडर संदर्भ", + "gridReference": "ग्रिड संदर्भ", + "date": "तारीख", + "reminder": { + "groupTitle": "स्मरणपत्र", + "shortKeyword": "remind" + }, + "createPage": "\"{}\" उप-पृष्ठ तयार करा" +}, + "datePicker": { + "dateTimeFormatTooltip": "सेटिंग्जमध्ये तारीख आणि वेळ फॉरमॅट बदला", + "dateFormat": "तारीख फॉरमॅट", + "includeTime": "वेळ समाविष्ट करा", + "isRange": "शेवटची तारीख", + "timeFormat": "वेळ फॉरमॅट", + "clearDate": "तारीख साफ करा", + "reminderLabel": "स्मरणपत्र", + "selectReminder": "स्मरणपत्र निवडा", + "reminderOptions": { + "none": "काहीही नाही", + "atTimeOfEvent": "इव्हेंटच्या वेळी", + "fiveMinsBefore": "५ मिनिटे आधी", + "tenMinsBefore": "१० मिनिटे आधी", + "fifteenMinsBefore": "१५ मिनिटे आधी", + "thirtyMinsBefore": "३० मिनिटे आधी", + "oneHourBefore": "१ तास आधी", + "twoHoursBefore": "२ तास आधी", + "onDayOfEvent": "इव्हेंटच्या दिवशी", + "oneDayBefore": "१ दिवस आधी", + "twoDaysBefore": "२ दिवस आधी", + "oneWeekBefore": "१ आठवडा आधी", + "custom": "सानुकूल" + } +}, + "relativeDates": { + "yesterday": "काल", + "today": "आज", + "tomorrow": "उद्या", + "oneWeek": "१ आठवडा" +}, + "notificationHub": { + "title": "सूचना", + "mobile": { + "title": "अपडेट्स" + }, + "emptyTitle": "सर्व पूर्ण झाले!", + "emptyBody": "कोणतीही प्रलंबित सूचना किंवा कृती नाहीत. शांततेचा आनंद घ्या.", + "tabs": { + "inbox": "इनबॉक्स", + "upcoming": "आगामी" + }, + "actions": { + "markAllRead": "सर्व वाचलेल्या म्हणून चिन्हित करा", + "showAll": "सर्व", + "showUnreads": "न वाचलेल्या" + }, + "filters": { + "ascending": "आरोही", + "descending": "अवरोही", + "groupByDate": "तारीखेनुसार गटबद्ध करा", + "showUnreadsOnly": "फक्त न वाचलेल्या दाखवा", + "resetToDefault": "डीफॉल्टवर रीसेट करा" + } +}, + "reminderNotification": { + "title": "स्मरणपत्र", + "message": "तुम्ही विसरण्याआधी हे तपासण्याचे लक्षात ठेवा!", + "tooltipDelete": "हटवा", + "tooltipMarkRead": "वाचले म्हणून चिन्हित करा", + "tooltipMarkUnread": "न वाचले म्हणून चिन्हित करा" +}, + "findAndReplace": { + "find": "शोधा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "close": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "noResult": "कोणतेही निकाल नाहीत", + "caseSensitive": "केस सेंसिटिव्ह", + "searchMore": "अधिक निकालांसाठी शोधा" +}, + "error": { + "weAreSorry": "आम्ही क्षमस्व आहोत", + "loadingViewError": "हे दृश्य लोड करण्यात अडचण येत आहे. कृपया तुमचे इंटरनेट कनेक्शन तपासा, अ‍ॅप रीफ्रेश करा, आणि समस्या कायम असल्यास आमच्याशी संपर्क साधा.", + "syncError": "इतर डिव्हाइसमधून डेटा सिंक झाला नाही", + "syncErrorHint": "कृपया हे पृष्ठ शेवटचे जिथे संपादित केले होते त्या डिव्हाइसवर उघडा, आणि मग सध्याच्या डिव्हाइसवर पुन्हा उघडा.", + "clickToCopy": "एरर कोड कॉपी करण्यासाठी क्लिक करा" +}, + "editor": { + "bold": "जाड", + "bulletedList": "बुलेट यादी", + "bulletedListShortForm": "बुलेट", + "checkbox": "चेकबॉक्स", + "embedCode": "कोड एम्बेड करा", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "हायलाइट", + "color": "रंग", + "image": "प्रतिमा", + "date": "तारीख", + "page": "पृष्ठ", + "italic": "तिरका", + "link": "लिंक", + "numberedList": "क्रमांकित यादी", + "numberedListShortForm": "क्रमांकित", + "toggleHeading1ShortForm": "Toggle H1", + "toggleHeading2ShortForm": "Toggle H2", + "toggleHeading3ShortForm": "Toggle H3", + "quote": "कोट", + "strikethrough": "ओढून टाका", + "text": "मजकूर", + "underline": "अधोरेखित", + "fontColorDefault": "डीफॉल्ट", + "fontColorGray": "धूसर", + "fontColorBrown": "तपकिरी", + "fontColorOrange": "केशरी", + "fontColorYellow": "पिवळा", + "fontColorGreen": "हिरवा", + "fontColorBlue": "निळा", + "fontColorPurple": "जांभळा", + "fontColorPink": "पिंग", + "fontColorRed": "लाल", + "backgroundColorDefault": "डीफॉल्ट पार्श्वभूमी", + "backgroundColorGray": "धूसर पार्श्वभूमी", + "backgroundColorBrown": "तपकिरी पार्श्वभूमी", + "backgroundColorOrange": "केशरी पार्श्वभूमी", + "backgroundColorYellow": "पिवळी पार्श्वभूमी", + "backgroundColorGreen": "हिरवी पार्श्वभूमी", + "backgroundColorBlue": "निळी पार्श्वभूमी", + "backgroundColorPurple": "जांभळी पार्श्वभूमी", + "backgroundColorPink": "पिंग पार्श्वभूमी", + "backgroundColorRed": "लाल पार्श्वभूमी", + "backgroundColorLime": "लिंबू पार्श्वभूमी", + "backgroundColorAqua": "पाण्याचा पार्श्वभूमी", + "done": "पूर्ण", + "cancel": "रद्द करा", + "tint1": "टिंट 1", + "tint2": "टिंट 2", + "tint3": "टिंट 3", + "tint4": "टिंट 4", + "tint5": "टिंट 5", + "tint6": "टिंट 6", + "tint7": "टिंट 7", + "tint8": "टिंट 8", + "tint9": "टिंट 9", + "lightLightTint1": "जांभळा", + "lightLightTint2": "पिंग", + "lightLightTint3": "फिकट पिंग", + "lightLightTint4": "केशरी", + "lightLightTint5": "पिवळा", + "lightLightTint6": "लिंबू", + "lightLightTint7": "हिरवा", + "lightLightTint8": "पाणी", + "lightLightTint9": "निळा", + "urlHint": "URL", + "mobileHeading1": "Heading 1", + "mobileHeading2": "Heading 2", + "mobileHeading3": "Heading 3", + "mobileHeading4": "Heading 4", + "mobileHeading5": "Heading 5", + "mobileHeading6": "Heading 6", + "textColor": "मजकूराचा रंग", + "backgroundColor": "पार्श्वभूमीचा रंग", + "addYourLink": "तुमची लिंक जोडा", + "openLink": "लिंक उघडा", + "copyLink": "लिंक कॉपी करा", + "removeLink": "लिंक काढा", + "editLink": "लिंक संपादित करा", + "linkText": "मजकूर", + "linkTextHint": "कृपया मजकूर प्रविष्ट करा", + "linkAddressHint": "कृपया URL प्रविष्ट करा", + "highlightColor": "हायलाइट रंग", + "clearHighlightColor": "हायलाइट काढा", + "customColor": "स्वतःचा रंग", + "hexValue": "Hex मूल्य", + "opacity": "अपारदर्शकता", + "resetToDefaultColor": "डीफॉल्ट रंगावर रीसेट करा", + "ltr": "LTR", + "rtl": "RTL", + "auto": "स्वयंचलित", + "cut": "कट", + "copy": "कॉपी", + "paste": "पेस्ट", + "find": "शोधा", + "select": "निवडा", + "selectAll": "सर्व निवडा", + "previousMatch": "मागील जुळणारे", + "nextMatch": "पुढील जुळणारे", + "closeFind": "बंद करा", + "replace": "बदला", + "replaceAll": "सर्व बदला", + "regex": "Regex", + "caseSensitive": "केस सेंसिटिव्ह", + "uploadImage": "प्रतिमा अपलोड करा", + "urlImage": "URL प्रतिमा", + "incorrectLink": "चुकीची लिंक", + "upload": "अपलोड", + "chooseImage": "प्रतिमा निवडा", + "loading": "लोड करत आहे", + "imageLoadFailed": "प्रतिमा लोड करण्यात अयशस्वी", + "divider": "विभाजक", + "table": "तक्त्याचे स्वरूप", + "colAddBefore": "यापूर्वी स्तंभ जोडा", + "rowAddBefore": "यापूर्वी पंक्ती जोडा", + "colAddAfter": "यानंतर स्तंभ जोडा", + "rowAddAfter": "यानंतर पंक्ती जोडा", + "colRemove": "स्तंभ काढा", + "rowRemove": "पंक्ती काढा", + "colDuplicate": "स्तंभ डुप्लिकेट", + "rowDuplicate": "पंक्ती डुप्लिकेट", + "colClear": "सामग्री साफ करा", + "rowClear": "सामग्री साफ करा", + "slashPlaceHolder": "'/' टाइप करा आणि घटक जोडा किंवा टाइप सुरू करा", + "typeSomething": "काहीतरी लिहा...", + "toggleListShortForm": "टॉगल", + "quoteListShortForm": "कोट", + "mathEquationShortForm": "सूत्र", + "codeBlockShortForm": "कोड" +}, + "favorite": { + "noFavorite": "कोणतेही आवडते पृष्ठ नाही", + "noFavoriteHintText": "पृष्ठाला डावीकडे स्वाइप करा आणि ते आवडत्या यादीत जोडा", + "removeFromSidebar": "साइडबारमधून काढा", + "addToSidebar": "साइडबारमध्ये पिन करा" +}, +"cardDetails": { + "notesPlaceholder": "/ टाइप करा ब्लॉक घालण्यासाठी, किंवा टाइप करायला सुरुवात करा" +}, +"blockPlaceholders": { + "todoList": "करण्याची यादी", + "bulletList": "यादी", + "numberList": "क्रमांकित यादी", + "quote": "कोट", + "heading": "मथळा {}" +}, +"titleBar": { + "pageIcon": "पृष्ठ चिन्ह", + "language": "भाषा", + "font": "फॉन्ट", + "actions": "क्रिया", + "date": "तारीख", + "addField": "फील्ड जोडा", + "userIcon": "वापरकर्त्याचे चिन्ह" +}, +"noLogFiles": "कोणतीही लॉग फाइल्स नाहीत", +"newSettings": { + "myAccount": { + "title": "माझे खाते", + "subtitle": "तुमचा प्रोफाइल सानुकूल करा, खाते सुरक्षा व्यवस्थापित करा, AI कीज पहा किंवा लॉगिन करा.", + "profileLabel": "खाते नाव आणि प्रोफाइल चित्र", + "profileNamePlaceholder": "तुमचे नाव प्रविष्ट करा", + "accountSecurity": "खाते सुरक्षा", + "2FA": "2-स्टेप प्रमाणीकरण", + "aiKeys": "AI कीज", + "accountLogin": "खाते लॉगिन", + "updateNameError": "नाव अपडेट करण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "aboutAppFlowy": "@:appName विषयी", + "deleteAccount": { + "title": "खाते हटवा", + "subtitle": "तुमचे खाते आणि सर्व डेटा कायमचे हटवा.", + "description": "तुमचे खाते कायमचे हटवले जाईल आणि सर्व वर्कस्पेसमधून प्रवेश काढून टाकला जाईल.", + "deleteMyAccount": "माझे खाते हटवा", + "dialogTitle": "खाते हटवा", + "dialogContent1": "तुम्हाला खात्री आहे की तुम्ही तुमचे खाते कायमचे हटवू इच्छिता?", + "dialogContent2": "ही क्रिया पूर्ववत केली जाऊ शकत नाही. हे सर्व वर्कस्पेसमधून प्रवेश हटवेल, खाजगी वर्कस्पेस मिटवेल, आणि सर्व शेअर्ड वर्कस्पेसमधून काढून टाकेल.", + "confirmHint1": "कृपया पुष्टीसाठी \"@:newSettings.myAccount.deleteAccount.confirmHint3\" टाइप करा.", + "confirmHint2": "मला समजले आहे की ही क्रिया अपरिवर्तनीय आहे आणि माझे खाते व सर्व संबंधित डेटा कायमचा हटवला जाईल.", + "confirmHint3": "DELETE MY ACCOUNT", + "checkToConfirmError": "हटवण्यासाठी पुष्टी बॉक्स निवडणे आवश्यक आहे", + "failedToGetCurrentUser": "वर्तमान वापरकर्त्याचा ईमेल मिळवण्यात अयशस्वी", + "confirmTextValidationFailed": "तुमचा पुष्टी मजकूर \"@:newSettings.myAccount.deleteAccount.confirmHint3\" शी जुळत नाही", + "deleteAccountSuccess": "खाते यशस्वीरित्या हटवले गेले" + } + }, + "workplace": { + "name": "वर्कस्पेस", + "title": "वर्कस्पेस सेटिंग्स", + "subtitle": "तुमचा वर्कस्पेस लुक, थीम, फॉन्ट, मजकूर लेआउट, तारीख, वेळ आणि भाषा सानुकूल करा.", + "workplaceName": "वर्कस्पेसचे नाव", + "workplaceNamePlaceholder": "वर्कस्पेसचे नाव टाका", + "workplaceIcon": "वर्कस्पेस चिन्ह", + "workplaceIconSubtitle": "एक प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे साइडबार आणि सूचना मध्ये दर्शवले जाईल.", + "renameError": "वर्कस्पेसचे नाव बदलण्यात अयशस्वी", + "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", + "chooseAnIcon": "चिन्ह निवडा", + "appearance": { + "name": "दृश्यरूप", + "themeMode": { + "auto": "स्वयंचलित", + "light": "प्रकाश मोड", + "dark": "गडद मोड" + }, + "language": "भाषा" + } + }, + "syncState": { + "syncing": "सिंक्रोनायझ करत आहे", + "synced": "सिंक्रोनायझ झाले", + "noNetworkConnected": "नेटवर्क कनेक्ट केलेले नाही" + } +}, + "pageStyle": { + "title": "पृष्ठ शैली", + "layout": "लेआउट", + "coverImage": "मुखपृष्ठ प्रतिमा", + "pageIcon": "पृष्ठ चिन्ह", + "colors": "रंग", + "gradient": "ग्रेडियंट", + "backgroundImage": "पार्श्वभूमी प्रतिमा", + "presets": "पूर्वनियोजित", + "photo": "फोटो", + "unsplash": "Unsplash", + "pageCover": "पृष्ठ कव्हर", + "none": "काही नाही", + "openSettings": "सेटिंग्स उघडा", + "photoPermissionTitle": "@:appName तुमच्या फोटो लायब्ररीमध्ये प्रवेश करू इच्छित आहे", + "photoPermissionDescription": "तुमच्या कागदपत्रांमध्ये प्रतिमा जोडण्यासाठी @:appName ला तुमच्या फोटोंमध्ये प्रवेश आवश्यक आहे", + "cameraPermissionTitle": "@:appName तुमच्या कॅमेऱ्याला प्रवेश करू इच्छित आहे", + "cameraPermissionDescription": "कॅमेऱ्यातून प्रतिमा जोडण्यासाठी @:appName ला तुमच्या कॅमेऱ्याचा प्रवेश आवश्यक आहे", + "doNotAllow": "परवानगी देऊ नका", + "image": "प्रतिमा" +}, +"commandPalette": { + "placeholder": "शोधा किंवा प्रश्न विचारा...", + "bestMatches": "सर्वोत्तम जुळवणी", + "recentHistory": "अलीकडील इतिहास", + "navigateHint": "नेव्हिगेट करण्यासाठी", + "loadingTooltip": "आम्ही निकाल शोधत आहोत...", + "betaLabel": "बेटा", + "betaTooltip": "सध्या आम्ही फक्त दस्तऐवज आणि पृष्ठ शोध समर्थन करतो", + "fromTrashHint": "कचरापेटीतून", + "noResultsHint": "आपण जे शोधत आहात ते सापडले नाही, कृपया दुसरा शब्द वापरून शोधा.", + "clearSearchTooltip": "शोध फील्ड साफ करा" +}, +"space": { + "delete": "हटवा", + "deleteConfirmation": "हटवा: ", + "deleteConfirmationDescription": "या स्पेसमधील सर्व पृष्ठे हटवली जातील आणि कचरापेटीत टाकली जातील, आणि प्रकाशित पृष्ठे अनपब्लिश केली जातील.", + "rename": "स्पेसचे नाव बदला", + "changeIcon": "चिन्ह बदला", + "manage": "स्पेस व्यवस्थापित करा", + "addNewSpace": "स्पेस तयार करा", + "collapseAllSubPages": "सर्व उपपृष्ठे संकुचित करा", + "createNewSpace": "नवीन स्पेस तयार करा", + "createSpaceDescription": "तुमचे कार्य अधिक चांगल्या प्रकारे आयोजित करण्यासाठी अनेक सार्वजनिक व खाजगी स्पेस तयार करा.", + "spaceName": "स्पेसचे नाव", + "spaceNamePlaceholder": "उदा. मार्केटिंग, अभियांत्रिकी, HR", + "permission": "स्पेस परवानगी", + "publicPermission": "सार्वजनिक", + "publicPermissionDescription": "पूर्ण प्रवेशासह सर्व वर्कस्पेस सदस्य", + "privatePermission": "खाजगी", + "privatePermissionDescription": "फक्त तुम्हाला या स्पेसमध्ये प्रवेश आहे", + "spaceIconBackground": "पार्श्वभूमीचा रंग", + "spaceIcon": "चिन्ह", + "dangerZone": "धोकादायक क्षेत्र", + "unableToDeleteLastSpace": "शेवटची स्पेस हटवता येणार नाही", + "unableToDeleteSpaceNotCreatedByYou": "तुमच्याद्वारे तयार न केलेली स्पेस हटवता येणार नाही", + "enableSpacesForYourWorkspace": "तुमच्या वर्कस्पेससाठी स्पेस सक्षम करा", + "title": "स्पेसेस", + "defaultSpaceName": "सामान्य", + "upgradeSpaceTitle": "स्पेस सक्षम करा", + "upgradeSpaceDescription": "तुमच्या वर्कस्पेसचे अधिक चांगल्या प्रकारे व्यवस्थापन करण्यासाठी अनेक सार्वजनिक आणि खाजगी स्पेस तयार करा.", + "upgrade": "अपग्रेड", + "upgradeYourSpace": "अनेक स्पेस तयार करा", + "quicklySwitch": "पुढील स्पेसवर पटकन स्विच करा", + "duplicate": "स्पेस डुप्लिकेट करा", + "movePageToSpace": "पृष्ठ स्पेसमध्ये हलवा", + "cannotMovePageToDatabase": "पृष्ठ डेटाबेसमध्ये हलवता येणार नाही", + "switchSpace": "स्पेस स्विच करा", + "spaceNameCannotBeEmpty": "स्पेसचे नाव रिकामे असू शकत नाही", + "success": { + "deleteSpace": "स्पेस यशस्वीरित्या हटवली", + "renameSpace": "स्पेसचे नाव यशस्वीरित्या बदलले", + "duplicateSpace": "स्पेस यशस्वीरित्या डुप्लिकेट केली", + "updateSpace": "स्पेस यशस्वीरित्या अपडेट केली" + }, + "error": { + "deleteSpace": "स्पेस हटवण्यात अयशस्वी", + "renameSpace": "स्पेसचे नाव बदलण्यात अयशस्वी", + "duplicateSpace": "स्पेस डुप्लिकेट करण्यात अयशस्वी", + "updateSpace": "स्पेस अपडेट करण्यात अयशस्वी" + }, + "createSpace": "स्पेस तयार करा", + "manageSpace": "स्पेस व्यवस्थापित करा", + "renameSpace": "स्पेसचे नाव बदला", + "mSpaceIconColor": "स्पेस चिन्हाचा रंग", + "mSpaceIcon": "स्पेस चिन्ह" +}, + "publish": { + "hasNotBeenPublished": "हे पृष्ठ अजून प्रकाशित केलेले नाही", + "spaceHasNotBeenPublished": "स्पेस प्रकाशित करण्यासाठी समर्थन नाही", + "reportPage": "पृष्ठाची तक्रार करा", + "databaseHasNotBeenPublished": "डेटाबेस प्रकाशित करण्यास समर्थन नाही.", + "createdWith": "यांनी तयार केले", + "downloadApp": "AppFlowy डाउनलोड करा", + "copy": { + "codeBlock": "कोड ब्लॉकची सामग्री क्लिपबोर्डवर कॉपी केली गेली आहे", + "imageBlock": "प्रतिमा लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", + "mathBlock": "गणितीय समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे", + "fileBlock": "फाइल लिंक क्लिपबोर्डवर कॉपी केली गेली आहे" + }, + "containsPublishedPage": "या पृष्ठात एक किंवा अधिक प्रकाशित पृष्ठे आहेत. तुम्ही पुढे गेल्यास ती अनपब्लिश होतील. तुम्हाला हटवणे चालू ठेवायचे आहे का?", + "publishSuccessfully": "यशस्वीरित्या प्रकाशित झाले", + "unpublishSuccessfully": "यशस्वीरित्या अनपब्लिश झाले", + "publishFailed": "प्रकाशित करण्यात अयशस्वी", + "unpublishFailed": "अनपब्लिश करण्यात अयशस्वी", + "noAccessToVisit": "या पृष्ठावर प्रवेश नाही...", + "createWithAppFlowy": "AppFlowy ने वेबसाइट तयार करा", + "fastWithAI": "AI सह जलद आणि सोपे.", + "tryItNow": "आत्ताच वापरून पहा", + "onlyGridViewCanBePublished": "फक्त Grid view प्रकाशित केला जाऊ शकतो", + "database": { + "zero": "{} निवडलेले दृश्य प्रकाशित करा", + "one": "{} निवडलेली दृश्ये प्रकाशित करा", + "many": "{} निवडलेली दृश्ये प्रकाशित करा", + "other": "{} निवडलेली दृश्ये प्रकाशित करा" + }, + "mustSelectPrimaryDatabase": "प्राथमिक दृश्य निवडणे आवश्यक आहे", + "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया किमान एक डेटाबेस निवडा.", + "unableToDeselectPrimaryDatabase": "प्राथमिक डेटाबेस अननिवड करता येणार नाही", + "saveThisPage": "या टेम्पलेटपासून सुरू करा", + "duplicateTitle": "तुम्हाला हे कुठे जोडायचे आहे", + "selectWorkspace": "वर्कस्पेस निवडा", + "addTo": "मध्ये जोडा", + "duplicateSuccessfully": "तुमच्या वर्कस्पेसमध्ये जोडले गेले", + "duplicateSuccessfullyDescription": "AppFlowy स्थापित केले नाही? तुम्ही 'डाउनलोड' वर क्लिक केल्यावर डाउनलोड आपोआप सुरू होईल.", + "downloadIt": "डाउनलोड करा", + "openApp": "अ‍ॅपमध्ये उघडा", + "duplicateFailed": "डुप्लिकेट करण्यात अयशस्वी", + "membersCount": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "useThisTemplate": "हा टेम्पलेट वापरा" +}, +"web": { + "continue": "पुढे जा", + "or": "किंवा", + "continueWithGoogle": "Google सह पुढे जा", + "continueWithGithub": "GitHub सह पुढे जा", + "continueWithDiscord": "Discord सह पुढे जा", + "continueWithApple": "Apple सह पुढे जा", + "moreOptions": "अधिक पर्याय", + "collapse": "आकुंचन", + "signInAgreement": "\"पुढे जा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "signInLocalAgreement": "\"सुरुवात करा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", + "and": "आणि", + "termOfUse": "वापर अटी", + "privacyPolicy": "गोपनीयता धोरण", + "signInError": "साइन इन त्रुटी", + "login": "साइन अप किंवा लॉग इन करा", + "fileBlock": { + "uploadedAt": "{time} रोजी अपलोड केले", + "linkedAt": "{time} रोजी लिंक जोडली", + "empty": "फाईल अपलोड करा किंवा एम्बेड करा", + "uploadFailed": "अपलोड अयशस्वी, कृपया पुन्हा प्रयत्न करा", + "retry": "पुन्हा प्रयत्न करा" + }, + "importNotion": "Notion वरून आयात करा", + "import": "आयात करा", + "importSuccess": "यशस्वीरित्या अपलोड केले", + "importSuccessMessage": "आम्ही तुम्हाला आयात पूर्ण झाल्यावर सूचित करू. त्यानंतर, तुम्ही साइडबारमध्ये तुमची आयात केलेली पृष्ठे पाहू शकता.", + "importFailed": "आयात अयशस्वी, कृपया फाईल फॉरमॅट तपासा", + "dropNotionFile": "तुमची Notion zip फाईल येथे ड्रॉप करा किंवा ब्राउझ करा", + "error": { + "pageNameIsEmpty": "पृष्ठाचे नाव रिकामे आहे, कृपया दुसरे नाव वापरून पहा" + } +}, + "globalComment": { + "comments": "टिप्पण्या", + "addComment": "टिप्पणी जोडा", + "reactedBy": "यांनी प्रतिक्रिया दिली", + "addReaction": "प्रतिक्रिया जोडा", + "reactedByMore": "आणि {count} इतर", + "showSeconds": { + "one": "1 सेकंदापूर्वी", + "other": "{count} सेकंदांपूर्वी", + "zero": "आत्ताच", + "many": "{count} सेकंदांपूर्वी" + }, + "showMinutes": { + "one": "1 मिनिटापूर्वी", + "other": "{count} मिनिटांपूर्वी", + "many": "{count} मिनिटांपूर्वी" + }, + "showHours": { + "one": "1 तासापूर्वी", + "other": "{count} तासांपूर्वी", + "many": "{count} तासांपूर्वी" + }, + "showDays": { + "one": "1 दिवसापूर्वी", + "other": "{count} दिवसांपूर्वी", + "many": "{count} दिवसांपूर्वी" + }, + "showMonths": { + "one": "1 महिन्यापूर्वी", + "other": "{count} महिन्यांपूर्वी", + "many": "{count} महिन्यांपूर्वी" + }, + "showYears": { + "one": "1 वर्षापूर्वी", + "other": "{count} वर्षांपूर्वी", + "many": "{count} वर्षांपूर्वी" + }, + "reply": "उत्तर द्या", + "deleteComment": "टिप्पणी हटवा", + "youAreNotOwner": "तुम्ही या टिप्पणीचे मालक नाही", + "confirmDeleteDescription": "तुम्हाला ही टिप्पणी हटवायची आहे याची खात्री आहे का?", + "hasBeenDeleted": "हटवले गेले", + "replyingTo": "याला उत्तर देत आहे", + "noAccessDeleteComment": "तुम्हाला ही टिप्पणी हटवण्याची परवानगी नाही", + "collapse": "संकुचित करा", + "readMore": "अधिक वाचा", + "failedToAddComment": "टिप्पणी जोडण्यात अयशस्वी", + "commentAddedSuccessfully": "टिप्पणी यशस्वीरित्या जोडली गेली.", + "commentAddedSuccessTip": "तुम्ही नुकतीच एक टिप्पणी जोडली किंवा उत्तर दिले आहे. वर जाऊन ताजी टिप्पण्या पाहायच्या का?" +}, + "template": { + "asTemplate": "टेम्पलेट म्हणून जतन करा", + "name": "टेम्पलेट नाव", + "description": "टेम्पलेट वर्णन", + "about": "टेम्पलेट माहिती", + "deleteFromTemplate": "टेम्पलेटमधून हटवा", + "preview": "टेम्पलेट पूर्वदृश्य", + "categories": "टेम्पलेट श्रेणी", + "isNewTemplate": "नवीन टेम्पलेटमध्ये पिन करा", + "featured": "वैशिष्ट्यीकृतमध्ये पिन करा", + "relatedTemplates": "संबंधित टेम्पलेट्स", + "requiredField": "{field} आवश्यक आहे", + "addCategory": "\"{category}\" जोडा", + "addNewCategory": "नवीन श्रेणी जोडा", + "addNewCreator": "नवीन निर्माता जोडा", + "deleteCategory": "श्रेणी हटवा", + "editCategory": "श्रेणी संपादित करा", + "editCreator": "निर्माता संपादित करा", + "category": { + "name": "श्रेणीचे नाव", + "icon": "श्रेणी चिन्ह", + "bgColor": "श्रेणी पार्श्वभूमीचा रंग", + "priority": "श्रेणी प्राधान्य", + "desc": "श्रेणीचे वर्णन", + "type": "श्रेणी प्रकार", + "icons": "श्रेणी चिन्हे", + "colors": "श्रेणी रंग", + "byUseCase": "वापराच्या आधारे", + "byFeature": "वैशिष्ट्यांनुसार", + "deleteCategory": "श्रेणी हटवा", + "deleteCategoryDescription": "तुम्हाला ही श्रेणी हटवायची आहे का?", + "typeToSearch": "श्रेणी शोधण्यासाठी टाइप करा..." + }, + "creator": { + "label": "टेम्पलेट निर्माता", + "name": "निर्मात्याचे नाव", + "avatar": "निर्मात्याचा अवतार", + "accountLinks": "निर्मात्याचे खाते दुवे", + "uploadAvatar": "अवतार अपलोड करण्यासाठी क्लिक करा", + "deleteCreator": "निर्माता हटवा", + "deleteCreatorDescription": "तुम्हाला हा निर्माता हटवायचा आहे का?", + "typeToSearch": "निर्माते शोधण्यासाठी टाइप करा..." + }, + "uploadSuccess": "टेम्पलेट यशस्वीरित्या अपलोड झाले", + "uploadSuccessDescription": "तुमचे टेम्पलेट यशस्वीरित्या अपलोड झाले आहे. आता तुम्ही ते टेम्पलेट गॅलरीमध्ये पाहू शकता.", + "viewTemplate": "टेम्पलेट पहा", + "deleteTemplate": "टेम्पलेट हटवा", + "deleteSuccess": "टेम्पलेट यशस्वीरित्या हटवले गेले", + "deleteTemplateDescription": "याचा वर्तमान पृष्ठ किंवा प्रकाशित स्थितीवर परिणाम होणार नाही. तुम्हाला हे टेम्पलेट हटवायचे आहे का?", + "addRelatedTemplate": "संबंधित टेम्पलेट जोडा", + "removeRelatedTemplate": "संबंधित टेम्पलेट हटवा", + "uploadAvatar": "अवतार अपलोड करा", + "searchInCategory": "{category} मध्ये शोधा", + "label": "टेम्पलेट्स" +}, + "fileDropzone": { + "dropFile": "फाइल अपलोड करण्यासाठी येथे क्लिक करा किंवा ड्रॅग करा", + "uploading": "अपलोड करत आहे...", + "uploadFailed": "अपलोड अयशस्वी", + "uploadSuccess": "अपलोड यशस्वी", + "uploadSuccessDescription": "फाइल यशस्वीरित्या अपलोड झाली आहे", + "uploadFailedDescription": "फाइल अपलोड अयशस्वी झाली आहे", + "uploadingDescription": "फाइल अपलोड होत आहे" +}, + "gallery": { + "preview": "पूर्ण स्क्रीनमध्ये उघडा", + "copy": "कॉपी करा", + "download": "डाउनलोड", + "prev": "मागील", + "next": "पुढील", + "resetZoom": "झूम रिसेट करा", + "zoomIn": "झूम इन", + "zoomOut": "झूम आउट" +}, + "invitation": { + "join": "सामील व्हा", + "on": "वर", + "invitedBy": "यांनी आमंत्रित केले", + "membersCount": { + "zero": "{count} सदस्य", + "one": "{count} सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "tip": "तुम्हाला खालील माहितीच्या आधारे या कार्यक्षेत्रात सामील होण्यासाठी आमंत्रित करण्यात आले आहे. ही माहिती चुकीची असल्यास, कृपया प्रशासकाशी संपर्क साधा.", + "joinWorkspace": "वर्कस्पेसमध्ये सामील व्हा", + "success": "तुम्ही यशस्वीरित्या वर्कस्पेसमध्ये सामील झाला आहात", + "successMessage": "आता तुम्ही सर्व पृष्ठे आणि कार्यक्षेत्रे वापरू शकता.", + "openWorkspace": "AppFlowy उघडा", + "alreadyAccepted": "तुम्ही आधीच आमंत्रण स्वीकारले आहे", + "errorModal": { + "title": "काहीतरी चुकले आहे", + "description": "तुमचे सध्याचे खाते {email} कदाचित या वर्कस्पेसमध्ये प्रवेशासाठी पात्र नाही. कृपया योग्य खात्याने लॉग इन करा किंवा वर्कस्पेस मालकाशी संपर्क साधा.", + "contactOwner": "मालकाशी संपर्क करा", + "close": "मुख्यपृष्ठावर परत जा", + "changeAccount": "खाते बदला" + } +}, + "requestAccess": { + "title": "या पृष्ठासाठी प्रवेश नाही", + "subtitle": "तुम्ही या पृष्ठाच्या मालकाकडून प्रवेशासाठी विनंती करू शकता. मंजुरीनंतर तुम्हाला हे पृष्ठ पाहता येईल.", + "requestAccess": "प्रवेशाची विनंती करा", + "backToHome": "मुख्यपृष्ठावर परत जा", + "tip": "तुम्ही सध्या म्हणून लॉग इन आहात.", + "mightBe": "कदाचित तुम्हाला दुसऱ्या खात्याने लॉग इन करणे आवश्यक आहे.", + "successful": "विनंती यशस्वीपणे पाठवली गेली", + "successfulMessage": "मालकाने मंजुरी दिल्यावर तुम्हाला सूचित केले जाईल.", + "requestError": "प्रवेशाची विनंती अयशस्वी", + "repeatRequestError": "तुम्ही यासाठी आधीच विनंती केली आहे" +}, + "approveAccess": { + "title": "वर्कस्पेसमध्ये सामील होण्यासाठी विनंती मंजूर करा", + "requestSummary": " यांनी मध्ये सामील होण्यासाठी आणि पाहण्यासाठी विनंती केली आहे", + "upgrade": "अपग्रेड", + "downloadApp": "AppFlowy डाउनलोड करा", + "approveButton": "मंजूर करा", + "approveSuccess": "मंजूर यशस्वी", + "approveError": "मंजुरी अयशस्वी. कृपया वर्कस्पेस मर्यादा ओलांडलेली नाही याची खात्री करा", + "getRequestInfoError": "विनंतीची माहिती मिळवण्यात अयशस्वी", + "memberCount": { + "zero": "कोणतेही सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" + }, + "alreadyProTitle": "वर्कस्पेस योजना मर्यादा गाठली आहे", + "alreadyProMessage": "अधिक सदस्यांसाठी शी संपर्क साधा", + "repeatApproveError": "तुम्ही ही विनंती आधीच मंजूर केली आहे", + "ensurePlanLimit": "कृपया खात्री करा की योजना मर्यादा ओलांडलेली नाही. जर ओलांडली असेल तर वर्कस्पेस योजना करा किंवा करा.", + "requestToJoin": "मध्ये सामील होण्यासाठी विनंती केली", + "asMember": "सदस्य म्हणून" +}, + "upgradePlanModal": { + "title": "Pro प्लॅनवर अपग्रेड करा", + "message": "{name} ने फ्री सदस्य मर्यादा गाठली आहे. अधिक सदस्य आमंत्रित करण्यासाठी Pro प्लॅनवर अपग्रेड करा.", + "upgradeSteps": "AppFlowy वर तुमची योजना कशी अपग्रेड करावी:", + "step1": "1. सेटिंग्जमध्ये जा", + "step2": "2. 'योजना' वर क्लिक करा", + "step3": "3. 'योजना बदला' निवडा", + "appNote": "नोंद:", + "actionButton": "अपग्रेड करा", + "downloadLink": "अ‍ॅप डाउनलोड करा", + "laterButton": "नंतर", + "refreshNote": "यशस्वी अपग्रेडनंतर, तुमची नवीन वैशिष्ट्ये सक्रिय करण्यासाठी वर क्लिक करा.", + "refresh": "येथे" +}, + "breadcrumbs": { + "label": "ब्रेडक्रम्स" +}, + "time": { + "justNow": "आत्ताच", + "seconds": { + "one": "1 सेकंद", + "other": "{count} सेकंद" + }, + "minutes": { + "one": "1 मिनिट", + "other": "{count} मिनिटे" + }, + "hours": { + "one": "1 तास", + "other": "{count} तास" + }, + "days": { + "one": "1 दिवस", + "other": "{count} दिवस" + }, + "weeks": { + "one": "1 आठवडा", + "other": "{count} आठवडे" + }, + "months": { + "one": "1 महिना", + "other": "{count} महिने" + }, + "years": { + "one": "1 वर्ष", + "other": "{count} वर्षे" + }, + "ago": "पूर्वी", + "yesterday": "काल", + "today": "आज" +}, + "members": { + "zero": "सदस्य नाहीत", + "one": "1 सदस्य", + "many": "{count} सदस्य", + "other": "{count} सदस्य" +}, + "tabMenu": { + "close": "बंद करा", + "closeDisabledHint": "पिन केलेले टॅब बंद करता येत नाही, कृपया आधी अनपिन करा", + "closeOthers": "इतर टॅब बंद करा", + "closeOthersHint": "हे सर्व अनपिन केलेले टॅब्स बंद करेल या टॅब वगळता", + "closeOthersDisabledHint": "सर्व टॅब पिन केलेले आहेत, बंद करण्यासाठी कोणतेही टॅब सापडले नाहीत", + "favorite": "आवडते", + "unfavorite": "आवडते काढा", + "favoriteDisabledHint": "हे दृश्य आवडते म्हणून जतन करता येत नाही", + "pinTab": "पिन करा", + "unpinTab": "अनपिन करा" +}, + "openFileMessage": { + "success": "फाइल यशस्वीरित्या उघडली", + "fileNotFound": "फाइल सापडली नाही", + "noAppToOpenFile": "ही फाइल उघडण्यासाठी कोणतेही अ‍ॅप उपलब्ध नाही", + "permissionDenied": "ही फाइल उघडण्यासाठी परवानगी नाही", + "unknownError": "फाइल उघडण्यात अयशस्वी" +}, + "inviteMember": { + "requestInviteMembers": "तुमच्या वर्कस्पेसमध्ये आमंत्रित करा", + "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, कृपया ", + "upgrade": "अपग्रेड करा", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "आमंत्रण पाठवा", + "inviteAlready": "तुम्ही या ईमेलला आधीच आमंत्रित केले आहे: {email}", + "inviteSuccess": "आमंत्रण यशस्वीपणे पाठवले", + "description": "खाली ईमेल कॉमा वापरून टाका. शुल्क सदस्य संख्येवर आधारित असते.", + "emails": "ईमेल" +}, + "quickNote": { + "label": "झटपट नोंद", + "quickNotes": "झटपट नोंदी", + "search": "झटपट नोंदी शोधा", + "collapseFullView": "पूर्ण दृश्य लपवा", + "expandFullView": "पूर्ण दृश्य उघडा", + "createFailed": "झटपट नोंद तयार करण्यात अयशस्वी", + "quickNotesEmpty": "कोणत्याही झटपट नोंदी नाहीत", + "emptyNote": "रिकामी नोंद", + "deleteNotePrompt": "निवडलेली नोंद कायमची हटवली जाईल. तुम्हाला नक्की ती हटवायची आहे का?", + "addNote": "नवीन नोंद", + "noAdditionalText": "अधिक माहिती नाही" +}, + "subscribe": { + "upgradePlanTitle": "योजना तुलना करा आणि निवडा", + "yearly": "वार्षिक", + "save": "{discount}% बचत", + "monthly": "मासिक", + "priceIn": "किंमत येथे: ", + "free": "फ्री", + "pro": "प्रो", + "freeDescription": "2 सदस्यांपर्यंत वैयक्तिक वापरकर्त्यांसाठी सर्व काही आयोजित करण्यासाठी", + "proDescription": "प्रकल्प आणि टीम नॉलेज व्यवस्थापित करण्यासाठी लहान टीमसाठी", + "proDuration": { + "monthly": "प्रति सदस्य प्रति महिना\nमासिक बिलिंग", + "yearly": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग" + }, + "cancel": "खालच्या योजनेवर जा", + "changePlan": "प्रो योजनेवर अपग्रेड करा", + "everythingInFree": "फ्री योजनेतील सर्व काही +", + "currentPlan": "सध्याची योजना", + "freeDuration": "कायम", + "freePoints": { + "first": "1 सहकार्यात्मक वर्कस्पेस (2 सदस्यांपर्यंत)", + "second": "अमर्यादित पृष्ठे आणि ब्लॉक्स", + "three": "5 GB संचयन", + "four": "बुद्धिमान शोध", + "five": "20 AI प्रतिसाद", + "six": "मोबाईल अ‍ॅप", + "seven": "रिअल-टाइम सहकार्य" + }, + "proPoints": { + "first": "अमर्यादित संचयन", + "second": "10 वर्कस्पेस सदस्यांपर्यंत", + "three": "अमर्यादित AI प्रतिसाद", + "four": "अमर्यादित फाइल अपलोड्स", + "five": "कस्टम नेमस्पेस" + }, + "cancelPlan": { + "title": "आपल्याला जाताना पाहून वाईट वाटते", + "success": "आपली सदस्यता यशस्वीरित्या रद्द झाली आहे", + "description": "आपल्याला जाताना वाईट वाटते. कृपया आम्हाला सुधारण्यासाठी तुमचे अभिप्राय कळवा. खालील काही प्रश्नांना उत्तर द्या.", + "commonOther": "इतर", + "otherHint": "आपले उत्तर येथे लिहा", + "questionOne": { + "question": "तुम्ही AppFlowy Pro सदस्यता का रद्द केली?", + "answerOne": "खर्च खूप जास्त आहे", + "answerTwo": "वैशिष्ट्ये अपेक्षेनुसार नव्हती", + "answerThree": "चांगला पर्याय सापडला", + "answerFour": "खर्च योग्य ठरण्यासाठी वापर पुरेसा नव्हता", + "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" + }, + "questionTwo": { + "question": "तुम्ही भविष्यात AppFlowy Pro पुन्हा घेण्याची शक्यता किती आहे?", + "answerOne": "खूप शक्यता आहे", + "answerTwo": "काहीशी शक्यता आहे", + "answerThree": "निश्चित नाही", + "answerFour": "अल्प शक्यता आहे", + "answerFive": "शक्यता नाही" + }, + "questionThree": { + "question": "सदस्यतेदरम्यान कोणते Pro फिचर तुम्हाला सर्वात जास्त उपयोगी वाटले?", + "answerOne": "मल्टी-यूजर सहकार्य", + "answerTwo": "लांब कालावधीसाठी आवृत्ती इतिहास", + "answerThree": "अमर्यादित AI प्रतिसाद", + "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" + }, + "questionFour": { + "question": "AppFlowy बाबत तुमचा एकूण अनुभव कसा होता?", + "answerOne": "छान", + "answerTwo": "चांगला", + "answerThree": "सामान्य", + "answerFour": "थोडासा वाईट", + "answerFive": "असंतोषजनक" + } + } +}, + "ai": { + "contentPolicyViolation": "संवेदनशील सामग्रीमुळे प्रतिमा निर्मिती अयशस्वी झाली. कृपया तुमचे इनपुट पुन्हा लिहा आणि पुन्हा प्रयत्न करा.", + "textLimitReachedDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अनलिमिटेड प्रतिसादांसाठी प्रो योजना घेण्याचा किंवा AI अ‍ॅड-ऑन खरेदी करण्याचा विचार करा.", + "imageLimitReachedDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया प्रो योजना घ्या किंवा AI अ‍ॅड-ऑन खरेदी करा.", + "limitReachedAction": { + "textDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अधिक प्रतिसाद मिळवण्यासाठी कृपया", + "imageDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया", + "upgrade": "अपग्रेड करा", + "toThe": "या योजनेवर", + "proPlan": "प्रो योजना", + "orPurchaseAn": "किंवा खरेदी करा", + "aiAddon": "AI अ‍ॅड-ऑन" + }, + "editing": "संपादन करत आहे", + "analyzing": "विश्लेषण करत आहे", + "continueWritingEmptyDocumentTitle": "लेखन सुरू ठेवता आले नाही", + "continueWritingEmptyDocumentDescription": "तुमच्या दस्तऐवजातील मजकूर वाढवण्यात अडचण येत आहे. कृपया एक छोटं परिचय लिहा, मग आम्ही पुढे नेऊ!", + "more": "अधिक" +}, + "autoUpdate": { + "criticalUpdateTitle": "अद्यतन आवश्यक आहे", + "criticalUpdateDescription": "तुमचा अनुभव सुधारण्यासाठी आम्ही सुधारणा केल्या आहेत! कृपया {currentVersion} वरून {newVersion} वर अद्यतन करा.", + "criticalUpdateButton": "अद्यतन करा", + "bannerUpdateTitle": "नवीन आवृत्ती उपलब्ध!", + "bannerUpdateDescription": "नवीन वैशिष्ट्ये आणि सुधारणांसाठी. अद्यतनासाठी 'Update' क्लिक करा.", + "bannerUpdateButton": "अद्यतन करा", + "settingsUpdateTitle": "नवीन आवृत्ती ({newVersion}) उपलब्ध!", + "settingsUpdateDescription": "सध्याची आवृत्ती: {currentVersion} (अधिकृत बिल्ड) → {newVersion}", + "settingsUpdateButton": "अद्यतन करा", + "settingsUpdateWhatsNew": "काय नवीन आहे" +}, + "lockPage": { + "lockPage": "लॉक केलेले", + "reLockPage": "पुन्हा लॉक करा", + "lockTooltip": "अनवधानाने संपादन टाळण्यासाठी पृष्ठ लॉक केले आहे. अनलॉक करण्यासाठी क्लिक करा.", + "pageLockedToast": "पृष्ठ लॉक केले आहे. कोणी अनलॉक करेपर्यंत संपादन अक्षम आहे.", + "lockedOperationTooltip": "पृष्ठ अनवधानाने संपादनापासून वाचवण्यासाठी लॉक केले आहे." +}, + "suggestion": { + "accept": "स्वीकारा", + "keep": "जसे आहे तसे ठेवा", + "discard": "रद्द करा", + "close": "बंद करा", + "tryAgain": "पुन्हा प्रयत्न करा", + "rewrite": "पुन्हा लिहा", + "insertBelow": "खाली टाका" +} +} diff --git a/frontend/resources/translations/pl-PL.json b/frontend/resources/translations/pl-PL.json index 5b11fec3c9..9473d7e2f0 100644 --- a/frontend/resources/translations/pl-PL.json +++ b/frontend/resources/translations/pl-PL.json @@ -2,12 +2,14 @@ "appName": "AppFlowy", "defaultUsername": "Ja", "welcomeText": "Witaj w @:appName", + "welcomeTo": "Witamy w", "githubStarText": "Gwiazdka na GitHub-ie", "subscribeNewsletterText": "Zapisz się do naszego Newslettera", "letsGoButtonText": "Start!", "title": "Tytuł", "youCanAlso": "Możesz również", "and": "i", + "failedToOpenUrl": "Nie udało się otworzyć adresu URL: {}", "blockActions": { "addBelowTooltip": "Kliknij, aby dodać poniżej", "addAboveCmd": "Alt+kliknięcie", @@ -35,15 +37,27 @@ "loginStartWithAnonymous": "Rozpocznij sesję anonimową", "continueAnonymousUser": "Kontynuuj sesję anonimową", "buttonText": "Zaloguj", + "signingInText": "Logowanie się...", "forgotPassword": "Zapomniałeś hasła?", "emailHint": "Email", "passwordHint": "Hasło", "dontHaveAnAccount": "Nie masz konta?", + "createAccount": "Utwórz konto", "repeatPasswordEmptyError": "Powtórzone hasło nie moze być puste", "unmatchedPasswordError": "Hasła nie są takie same", "syncPromptMessage": "Synchronizacja danych może chwilę potrwać. Proszę nie zamykać tej strony", "or": "ALBO", + "signInWithGoogle": "Zaloguj się za pomocą Google", + "signInWithGithub": "Zaloguj się za pomocą Githuba", + "signInWithDiscord": "Zaloguj się za pomocą Discorda", + "signUpWithGoogle": "Zarejestruj się za pomocą Google", + "signUpWithGithub": "Zarejestruj się za pomocą Githuba", + "signUpWithDiscord": "Zarejestruj się za pomocą Discorda", "signInWith": "Zaloguj się korzystając z:", + "pleaseInputYourEmail": "Podaj swój adres e-mail", + "settings": "Ustawienia", + "logIn": "Zaloguj się", + "generalError": "Coś poszło nie tak. Spróbuj ponownie później", "LogInWithGoogle": "Zaloguj się za pomocą Google", "LogInWithGithub": "Zaloguj się za pomocą Githuba", "LogInWithDiscord": "Zaloguj się za pomocą Discorda", @@ -53,19 +67,29 @@ "chooseWorkspace": "Wybierz swoją przestrzeń do pracy", "create": "Utwórz przestrzeń", "reset": "Zresetuj przestrzeń roboczą", + "renameWorkspace": "Zmień nazwę obszaru roboczego", "resetWorkspacePrompt": "Zresetowanie przestrzeni roboczej spowoduje usunięcie wszystkich znajdujących się w niej stron i danych. Czy na pewno chcesz zresetować przestrzeń roboczą? Alternatywnie możesz skontaktować się z zespołem pomocy technicznej, aby przywrócić przestrzeń roboczą", "hint": "przestrzeń robocza", "notFoundError": "Przestrzeni nie znaleziono", - "failedToLoad": "Coś poszło nie tak! Wczytywanie przestrzeni roboczej nie powiodło się. Spróbuj wyłączyć wszystkie otwarte instancje AppFlowy i spróbuj ponownie.", + "failedToLoad": "Coś poszło nie tak! Wczytywanie przestrzeni roboczej nie powiodło się. Spróbuj wyłączyć wszystkie otwarte instancje @:appName i spróbuj ponownie.", "errorActions": { "reportIssue": "Zgłoś problem", + "reportIssueOnGithub": "Zgłoś problem na Githubie", "reachOut": "Skontaktuj się na Discord" - } + }, + "menuTitle": "Obszary robocze", + "deleteWorkspaceHintText": "Czy na pewno chcesz usunąć obszar roboczy? Tej akcji nie można cofnąć.", + "createSuccess": "Obszar roboczy został utworzony pomyślnie", + "createFailed": "Nie udało się utworzyć obszaru roboczego", + "deleteSuccess": "Obszar roboczy został pomyślnie usunięty", + "deleteFailed": "Nie udało się usunąć obszaru roboczego" }, "shareAction": { "buttonText": "Udostępnij", "workInProgress": "Dostępne wkrótce", "markdown": "Markdown", + "html": "HTML", + "clipboard": "Skopiuj do schowka", "csv": "CSV", "copyLink": "Skopiuj link" }, @@ -75,7 +99,11 @@ "large": "duży", "fontSize": "Rozmiar czcionki", "import": "Import", - "moreOptions": "Więcej opcji" + "moreOptions": "Więcej opcji", + "wordCount": "Liczba słów: {}", + "charCount": "Liczba znaków: {}", + "createdAt": "Utworzony: {}", + "deleteView": "Usuń" }, "importPanel": { "textAndMarkdown": "Tekst i Markdown", @@ -93,7 +121,8 @@ "openNewTab": "Otwórz w nowej karcie", "moveTo": "Przenieś do", "addToFavorites": "Dodaj do ulubionych", - "copyLink": "Skopiuj link" + "copyLink": "Skopiuj link", + "changeIcon": "Zmień ikonę" }, "blankPageTitle": "Pusta strona", "newPageText": "Nowa strona", @@ -135,14 +164,14 @@ "questionBubble": { "shortcuts": "Skróty", "whatsNew": "Co nowego?", - "help": "Pomoc & Wsparcie", "markdown": "Markdown", "debug": { "name": "Informacje Debugowania", "success": "Skopiowano informacje debugowania do schowka!", "fail": "Nie mozna skopiować informacji debugowania do schowka" }, - "feedback": "Feedback" + "feedback": "Feedback", + "help": "Pomoc & Wsparcie" }, "menuAppHeader": { "moreButtonToolTip": "Usuń, zmień nazwę i więcej...", @@ -188,7 +217,10 @@ "clickToHidePersonal": "Kliknij, aby ukryć sekcję osobistą", "clickToHideFavorites": "Kliknij, aby ukryć ulubioną sekcję", "addAPage": "Dodaj stronę", - "recent": "Najnowsze" + "recent": "Najnowsze", + "today": "Dziś", + "thisWeek": "Ten tydzień", + "favoriteSpace": "Ulubione" }, "notifications": { "export": { @@ -204,6 +236,7 @@ }, "button": { "ok": "OK", + "confirm": "Potwierdź", "done": "Zrobione", "cancel": "Anuluj", "signIn": "Zaloguj", @@ -228,7 +261,21 @@ "removeFromFavorites": "Usuń z ulubionych", "addToFavorites": "Dodaj do ulubionych", "rename": "Zmień nazwę", - "helpCenter": "Centrum Pomocy" + "helpCenter": "Centrum Pomocy", + "add": "Dodaj", + "yes": "Tak", + "clear": "Wyczyść", + "remove": "Usuń", + "login": "Zaloguj się", + "logout": "Wyloguj", + "deleteAccount": "Usuń konto", + "back": "Wstecz", + "signInGoogle": "Zaloguj się za pomocą Google", + "signInGithub": "Zaloguj się za pomocą Githuba", + "signInDiscord": "Zaloguj się za pomocą Discorda", + "more": "Więcej", + "create": "Utwórz", + "close": "Zamknij" }, "label": { "welcome": "Witaj!", @@ -252,6 +299,28 @@ }, "settings": { "title": "Ustawienia", + "accountPage": { + "menuLabel": "Moje konto", + "title": "Moje konto", + "general": { + "title": "Nazwa konta i zdjęcie profilowe", + "changeProfilePicture": "Zmień zdjęcie profilowe" + }, + "email": { + "title": "E-mail", + "actions": { + "change": "Zmień adres e-mail" + } + }, + "login": { + "loginLabel": "Zaloguj się", + "logoutLabel": "Wyloguj" + } + }, + "workspacePage": { + "menuLabel": "Obszar roboczy", + "title": "Obszar roboczy" + }, "menu": { "appearance": "Wygląd", "language": "Język", @@ -273,7 +342,6 @@ "historicalUserList": "Historia logowania użytkownika", "historicalUserListTooltip": "Na tej liście wyświetlane są Twoje anonimowe konta. Możesz kliknąć konto, aby wyświetlić jego szczegóły. Konta anonimowe tworzy się poprzez kliknięcie przycisku „Rozpocznij”.", "openHistoricalUser": "Kliknij, aby otworzyć anonimowe konto", - "supabaseSetting": "Ustawienie Supabase", "cloudSetting": "Ustawienia Chmury" }, "notifications": { @@ -310,7 +378,7 @@ }, "themeUpload": { "button": "Prześlij", - "description": "Prześlij własny motyw AppFlowy za pomocą przycisku poniżej.", + "description": "Prześlij własny motyw @:appName za pomocą przycisku poniżej.", "loading": "Poczekaj, aż zweryfikujemy i prześlemy Twój motyw...", "uploadSuccess": "Twój motyw został przesłany pomyślnie", "deletionFailure": "Nie udało się usunąć motywu. Spróbuj usunąć go ręcznie.", @@ -341,7 +409,7 @@ "defaultLocation": "Ścieżka katalogu z plikami", "exportData": "Eksportuj swoje dane", "doubleTapToCopy": "Kliknij dwukrotnie, aby skopiować ścieżkę", - "restoreLocation": "Przywróć domyślną ścieżkę AppFlowy", + "restoreLocation": "Przywróć domyślną ścieżkę @:appName", "customizeLocation": "Otwórz inny folder", "restartApp": "Uruchom ponownie aplikację, aby zmiany zaczęły obowiązywać.", "exportDatabase": "Eksportuj bazę danych", @@ -353,10 +421,10 @@ "defineWhereYourDataIsStored": "Zdefiniuj miejsce przechowywania Twoich danych", "open": "Otwórz", "openFolder": "Otwórz istniejący folder", - "openFolderDesc": "Przeczytaj i zapisz go w istniejącym folderze AppFlowy", + "openFolderDesc": "Przeczytaj i zapisz go w istniejącym folderze @:appName", "folderHintText": "Nazwa folderu", "location": "Tworzenie nowego folderu", - "locationDesc": "Wybierz nazwę folderu danych AppFlowy", + "locationDesc": "Wybierz nazwę folderu danych @:appName", "browser": "Przeglądaj", "create": "Stwórz", "set": "Ustaw", @@ -367,7 +435,7 @@ "change": "Zmień", "openLocationTooltips": "Otwórz inny katalog danych", "openCurrentDataFolder": "Otwórz bieżący katalog danych", - "recoverLocationTooltips": "Zresetuj do domyślnego katalogu danych AppFlowy", + "recoverLocationTooltips": "Zresetuj do domyślnego katalogu danych @:appName", "exportFileSuccess": "Eksportowanie pliku zakończono pomyślnie!", "exportFileFail": "Eksport pliku nie powiódł się!", "export": "Eksport" @@ -377,20 +445,9 @@ "email": "E-mail", "tooltipSelectIcon": "Wybierz ikonę", "selectAnIcon": "Wybierz ikonę", - "pleaseInputYourOpenAIKey": "wprowadź swój klucz OpenAI", - "pleaseInputYourStabilityAIKey": "wprowadź swój klucz Stability AI", - "clickToLogout": "Kliknij, aby wylogować bieżącego użytkownika" - }, - "shortcuts": { - "shortcutsLabel": "Skróty", - "command": "Polecenie", - "keyBinding": "Skróty klawiszowe", - "addNewCommand": "Dodaj nowe polecenie", - "updateShortcutStep": "Naciśnij żądaną kombinację klawiszy i naciśnij ENTER", - "shortcutIsAlreadyUsed": "Ten skrót jest już używany w przypadku: {conflict}", - "resetToDefault": "Przywróć domyślne skróty klawiszowe", - "couldNotLoadErrorMsg": "Nie udało się wczytać skrótów. Spróbuj ponownie", - "couldNotSaveErrorMsg": "Nie udało się zapisać skrótów. Spróbuj ponownie" + "pleaseInputYourOpenAIKey": "wprowadź swój klucz AI", + "clickToLogout": "Kliknij, aby wylogować bieżącego użytkownika", + "pleaseInputYourStabilityAIKey": "wprowadź swój klucz Stability AI" }, "mobile": { "personalInfo": "Informacje Osobiste", @@ -404,6 +461,17 @@ "userAgreement": "Regulamin Użytkowania", "userprofileError": "Nie udało się wczytać profilu użytkownika", "userprofileErrorDescription": "Spróbuj wylogować się i zalogować ponownie, aby zobaczyć czy problem nadal występuje." + }, + "shortcuts": { + "shortcutsLabel": "Skróty", + "command": "Polecenie", + "keyBinding": "Skróty klawiszowe", + "addNewCommand": "Dodaj nowe polecenie", + "updateShortcutStep": "Naciśnij żądaną kombinację klawiszy i naciśnij ENTER", + "shortcutIsAlreadyUsed": "Ten skrót jest już używany w przypadku: {conflict}", + "resetToDefault": "Przywróć domyślne skróty klawiszowe", + "couldNotLoadErrorMsg": "Nie udało się wczytać skrótów. Spróbuj ponownie", + "couldNotSaveErrorMsg": "Nie udało się zapisać skrótów. Spróbuj ponownie" } }, "grid": { @@ -598,23 +666,23 @@ "referencedGrid": "Siatka referencyjna", "referencedCalendar": "Kalendarz referencyjny", "referencedDocument": "Dokument referencyjny", - "autoGeneratorMenuItemName": "Pisarz OpenAI", - "autoGeneratorTitleName": "OpenAI: Poproś AI o napisanie czegokolwiek...", + "autoGeneratorMenuItemName": "Pisarz AI", + "autoGeneratorTitleName": "AI: Poproś AI o napisanie czegokolwiek...", "autoGeneratorLearnMore": "Dowiedz się więcej", "autoGeneratorGenerate": "Generuj", - "autoGeneratorHintText": "Zapytaj OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Nie można uzyskać klucza OpenAI", + "autoGeneratorHintText": "Zapytaj AI...", + "autoGeneratorCantGetOpenAIKey": "Nie można uzyskać klucza AI", "autoGeneratorRewrite": "Przepisz", "smartEdit": "Asystenci AI", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Popraw pisownię", "warning": "⚠️ Odpowiedzi AI mogą być niedokładne lub mylące.", "smartEditSummarize": "Podsumuj", "smartEditImproveWriting": "Popraw pisanie", "smartEditMakeLonger": "Dłużej", - "smartEditCouldNotFetchResult": "Nie można pobrać wyniku z OpenAI", - "smartEditCouldNotFetchKey": "Nie można pobrać klucza OpenAI", - "smartEditDisabled": "Połącz OpenAI w Ustawieniach", + "smartEditCouldNotFetchResult": "Nie można pobrać wyniku z AI", + "smartEditCouldNotFetchKey": "Nie można pobrać klucza AI", + "smartEditDisabled": "Połącz AI w Ustawieniach", "discardResponse": "Czy chcesz odrzucić odpowiedzi AI?", "createInlineMathEquation": "Utwórz równanie", "toggleList": "Przełącz listę", @@ -669,8 +737,8 @@ "defaultColor": "Domyślny" }, "image": { - "copiedToPasteBoard": "Link do obrazu został skopiowany do schowka", - "addAnImage": "Dodaj obraz" + "addAnImage": "Dodaj obraz", + "copiedToPasteBoard": "Link do obrazu został skopiowany do schowka" }, "outline": { "addHeadingToCreateOutline": "Dodaj nagłówki, aby utworzyć spis treści." @@ -707,8 +775,8 @@ "placeholder": "Wprowadź adres URL obrazu" }, "ai": { - "label": "Wygeneruj obraz z OpenAI", - "placeholder": "Wpisz treść podpowiedzi dla OpenAI, aby wygenerować obraz" + "label": "Wygeneruj obraz z AI", + "placeholder": "Wpisz treść podpowiedzi dla AI, aby wygenerować obraz" }, "stability_ai": { "label": "Wygeneruj obraz z Stability AI", @@ -726,12 +794,12 @@ "placeholder": "Wklej lub wpisz link obrazu" }, "searchForAnImage": "Szukaj obrazu", - "pleaseInputYourOpenAIKey": "wpisz swój klucz OpenAI w ustawieniach", - "pleaseInputYourStabilityAIKey": "wpisz swój klucz Stability AI w ustawieniach", + "pleaseInputYourOpenAIKey": "wpisz swój klucz AI w ustawieniach", "saveImageToGallery": "Zapisz obraz", "failedToAddImageToGallery": "Nie udało się dodać obrazu do galerii", "successToAddImageToGallery": "Pomyślnie dodano obraz do galerii", - "unableToLoadImage": "Nie udało się wczytać obrazu" + "unableToLoadImage": "Nie udało się wczytać obrazu", + "pleaseInputYourStabilityAIKey": "wpisz swój klucz Stability AI w ustawieniach" }, "codeBlock": { "language": { @@ -821,7 +889,7 @@ "referencedCalendarPrefix": "Widok" }, "errorDialog": { - "title": "Błąd AppFlowy", + "title": "Błąd @:appName", "howToFixFallback": "Przepraszamy za niedogodności! Zgłoś problem na naszej stronie GitHub, który opisuje Twój błąd.", "github": "Zobacz na GitHubie" }, diff --git a/frontend/resources/translations/pt-BR.json b/frontend/resources/translations/pt-BR.json index 1341f735b7..864d225095 100644 --- a/frontend/resources/translations/pt-BR.json +++ b/frontend/resources/translations/pt-BR.json @@ -9,6 +9,7 @@ "title": "Título", "youCanAlso": "Você também pode", "and": "e", + "failedToOpenUrl": "Falha ao abrir url: {}", "blockActions": { "addBelowTooltip": "Clique para adicionar abaixo", "addAboveCmd": "Alt+clique", @@ -35,17 +36,39 @@ "loginButtonText": "Conectar-se", "loginStartWithAnonymous": "Iniciar com uma sessão anônima", "continueAnonymousUser": "Continuar em uma sessão anônima", + "anonymous": "Anônimo", "buttonText": "Entre", "signingInText": "Entrando...", "forgotPassword": "Esqueceu sua senha?", "emailHint": "E-mail", "passwordHint": "Senha", "dontHaveAnAccount": "Não possui uma conta?", + "createAccount": "Criar uma conta", "repeatPasswordEmptyError": "Senha não pode estar em branco.", "unmatchedPasswordError": "As senhas não conferem.", "syncPromptMessage": "A sincronização dos dados pode demorar um pouco. Por favor não feche esta página", "or": "OU", + "signInWithGoogle": "Continuar com o Google", + "signInWithGithub": "Continuar com o Github", + "signInWithDiscord": "Continuar com o Discord", + "signInWithApple": "Continuar com a Apple", + "continueAnotherWay": "Continuar de outra forma", + "signUpWithGoogle": "Cadastro com o Google", + "signUpWithGithub": "Cadastro com o Github", + "signUpWithDiscord": "Cadastro com o Discord", "signInWith": "Entrar com:", + "signInWithEmail": "Continuar com e-mail", + "signInWithMagicLink": "Continuar", + "signUpWithMagicLink": "Cadastro com um Link Mágico", + "pleaseInputYourEmail": "Por favor, insira seu endereço de e-mail", + "settings": "Configurações", + "magicLinkSent": "Link Mágico enviado!", + "invalidEmail": "Por favor, insira um endereço de e-mail válido", + "alreadyHaveAnAccount": "Já tem uma conta?", + "logIn": "Entrar", + "generalError": "Algo deu errado. Tente novamente mais tarde.", + "limitRateError": "Por razões de segurança, você só pode solicitar um link mágico a cada 60 segundos", + "magicLinkSentDescription": "Um Link Mágico foi enviado para seu e-mail. Clique no link para concluir seu login. O link expirará após 5 minutos.", "LogInWithGoogle": "Entrar com o Google", "LogInWithGithub": "Entrar com o Github", "LogInWithDiscord": "Entrar com o Discord", @@ -55,21 +78,50 @@ "chooseWorkspace": "Escolha seu espaço de trabalho", "create": "Crie um espaço de trabalho", "reset": "Redefinir espaço de trabalho", + "renameWorkspace": "Renomear espaço de trabalho", "resetWorkspacePrompt": "A redefinição do espaço de trabalho excluirá todas as páginas e dados contidos nele. Tem certeza de que deseja redefinir o espaço de trabalho? Alternativamente, você pode entrar em contato com a equipe de suporte para restaurar o espaço de trabalho", "hint": "Espaço de trabalho", "notFoundError": "Espaço de trabalho não encontrado", - "failedToLoad": "Algo deu errado! Falha ao carregar o espaço de trabalho. Tente fechar qualquer instância aberta do AppFlowy e tente novamente.", + "failedToLoad": "Algo deu errado! Falha ao carregar o espaço de trabalho. Tente fechar qualquer instância aberta do @:appName e tente novamente.", "errorActions": { "reportIssue": "Reporte um problema", + "reportIssueOnGithub": "Reportar um problema no Github", + "exportLogFiles": "Exportar arquivos de log", "reachOut": "Entre em contato no Discord" - } + }, + "menuTitle": "Espaços de trabalho", + "deleteWorkspaceHintText": "Tem certeza de que deseja excluir o espaço de trabalho? Esta ação não pode ser desfeita, e quaisquer páginas que você tenha publicado deixarão de estar publicadas.", + "createSuccess": "Espaço de trabalho criado com sucesso", + "createFailed": "Falha ao criar espaço de trabalho", + "createLimitExceeded": "Você atingiu o limite máximo de espaços de trabalho permitido para sua conta. Se precisar de espaços de trabalho adicionais para continuar seu trabalho, solicite no Github", + "deleteSuccess": "Espaço de trabalho excluído com sucesso", + "deleteFailed": "Falha ao excluir o espaço de trabalho", + "openSuccess": "Espaço de trabalho aberto com sucesso", + "openFailed": "Falha ao abrir o espaço de trabalho", + "renameSuccess": "Espaço de trabalho renomeado com sucesso", + "renameFailed": "Falha ao renomear o espaço de trabalho", + "updateIconSuccess": "Ícone do espaço de trabalho atualizado com sucesso", + "updateIconFailed": "Falha ao atualizar ícone do espaço de trabalho", + "cannotDeleteTheOnlyWorkspace": "Não é possível excluir o único espaço de trabalho", + "fetchWorkspacesFailed": "Falha ao buscar espaços de trabalho", + "leaveCurrentWorkspace": "Sair do espaço de trabalho", + "leaveCurrentWorkspacePrompt": "Tem certeza de que deseja sair do espaço de trabalho atual?" }, "shareAction": { "buttonText": "Compartilhar", "workInProgress": "Em breve", "markdown": "Marcador", + "clipboard": "Copiar para área de transferência", "csv": "CSV", - "copyLink": "Copiar link" + "copyLink": "Copiar link", + "publishToTheWeb": "Publicar na Web", + "publishToTheWebHint": "Crie um site com AppFlowy", + "publish": "Publicar", + "unPublish": "Remover publicação", + "visitSite": "Visitar site", + "exportAsTab": "Exportar como", + "publishTab": "Publicar", + "shareTab": "Compartilhar" }, "moreAction": { "small": "pequeno", @@ -77,7 +129,12 @@ "large": "grande", "fontSize": "Tamanho da fonte", "import": "Importar", - "moreOptions": "Mais opções" + "moreOptions": "Mais opções", + "wordCount": "Contagem de palavras: {}", + "charCount": "Contagem de caracteres: {}", + "createdAt": "Criado: {}", + "deleteView": "Excluir", + "duplicateView": "Duplicar" }, "importPanel": { "textAndMarkdown": "Texto e Remarcação", @@ -95,7 +152,9 @@ "openNewTab": "Abrir em uma nova guia", "moveTo": "Mover para", "addToFavorites": "Adicionar aos favoritos", - "copyLink": "Copiar link" + "copyLink": "Copiar link", + "changeIcon": "Alterar ícone", + "collapseAllPages": "Recolher todas as subpáginas" }, "blankPageTitle": "Página em branco", "newPageText": "Nova página", @@ -103,6 +162,34 @@ "newGridText": "Nova grelha", "newCalendarText": "Novo calendário", "newBoardText": "Novo quadro", + "chat": { + "newChat": "Bate-papo com IA", + "inputMessageHint": "Pergunte a IA @:appName", + "inputLocalAIMessageHint": "Pergunte a IA local @:appName", + "unsupportedCloudPrompt": "Este recurso só está disponível ao usar a nuvem @:appName", + "relatedQuestion": "Relacionado", + "serverUnavailable": "Serviço Temporariamente Indisponível. Tente novamente mais tarde.", + "aiServerUnavailable": "🌈 Uh-oh! 🌈. Um unicórnio comeu nossa resposta. Por favor, tente novamente!", + "clickToRetry": "Clique para tentar novamente", + "regenerateAnswer": "Gerar novamente", + "question1": "Como usar Kanban para gerenciar tarefas", + "question2": "Explique o método GTD", + "question3": "Por que usar Rust", + "question4": "Receita com o que tenho na cozinha", + "aiMistakePrompt": "A IA pode cometer erros. Verifique informações importantes.", + "chatWithFilePrompt": "Você quer conversar com o arquivo?", + "indexFileSuccess": "Arquivo indexado com sucesso", + "inputActionNoPages": "Nenhum resultado", + "referenceSource": { + "zero": "0 fontes encontradas", + "one": "{count} fonte encontrada", + "other": "{count} fontes encontradas" + }, + "clickToMention": "Clique para mencionar uma página", + "uploadFile": "Carregue arquivos PDFs, md ou txt para conversar", + "questionDetail": "Olá {}! Como posso te ajudar hoje?", + "indexingFile": "Indexando {}" + }, "trash": { "text": "Lixeira", "restoreAll": "Restaurar tudo", @@ -126,7 +213,8 @@ "emptyDescription": "Você não tem nenhum arquivo excluído", "isDeleted": "foi deletado", "isRestored": "foi restaurado" - } + }, + "confirmDeleteTitle": "Tem certeza de que deseja excluir esta página permanentemente?" }, "deletePagePrompt": { "text": "Está página está na lixeira", @@ -137,14 +225,14 @@ "questionBubble": { "shortcuts": "Atalhos", "whatsNew": "O que há de novo?", - "help": "Ajuda e Suporte", "markdown": "Remarcação", "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" }, - "feedback": "Opinião" + "feedback": "Opinião", + "help": "Ajuda e Suporte" }, "menuAppHeader": { "moreButtonToolTip": "Remover, renomear e muito mais...", @@ -180,17 +268,53 @@ "dragRow": "Pressione e segure para reordenar a linha", "viewDataBase": "Visualizar banco de dados", "referencePage": "Esta {name} é uma referência", - "addBlockBelow": "Adicione um bloco abaixo" + "addBlockBelow": "Adicione um bloco abaixo", + "aiGenerate": "Gerar" }, "sideBar": { "closeSidebar": "Fechar barra lateral", "openSidebar": "Abrir barra lateral", "personal": "Pessoal", + "private": "Privado", + "workspace": "Espaço de trabalho", "favorites": "Favoritos", + "clickToHidePrivate": "Clique para ocultar o espaço privado\nAs páginas que você criou aqui são visíveis apenas para você", + "clickToHideWorkspace": "Clique para ocultar o espaço de trabalho\nAs páginas que você criou aqui são visíveis para todos os membros", "clickToHidePersonal": "Clique para ocultar a seção pessoal", "clickToHideFavorites": "Clique para ocultar a seção favorita", "addAPage": "Adicionar uma página", - "recent": "Recentes" + "addAPageToPrivate": "Adicionar uma página ao espaço privado", + "addAPageToWorkspace": "Adicionar uma página ao espaço de trabalho", + "recent": "Recentes", + "today": "Hoje", + "thisWeek": "Essa semana", + "others": "Favoritos anteriores", + "justNow": "agora mesmo", + "minutesAgo": "{count} minutos atrás", + "lastViewed": "Última visualização", + "favoriteAt": "Favorito", + "emptyRecent": "Nenhum documento recente", + "emptyRecentDescription": "Conforme você visualiza os documentos, eles aparecerão aqui para fácil recuperação", + "emptyFavorite": "Nenhum documento favorito", + "emptyFavoriteDescription": "Comece a explorar e marque os documentos como favoritos. Eles serão listados aqui para acesso rápido!", + "removePageFromRecent": "Remover esta página dos Recentes?", + "removeSuccess": "Removido com sucesso", + "favoriteSpace": "Favoritos", + "RecentSpace": "Recente", + "Spaces": "Espaços", + "upgradeToPro": "Atualizar para Pro", + "upgradeToAIMax": "Desbloqueie IA ilimitada", + "storageLimitDialogTitle": "Você ficou sem armazenamento gratuito. Atualize para desbloquear armazenamento ilimitado", + "aiResponseLimitTitle": "Você ficou sem respostas de IA gratuitas. Atualize para o Plano Pro ou adquira um complemento de IA para desbloquear respostas ilimitadas", + "aiResponseLimitDialogTitle": "Limite de respostas de IA atingido", + "aiResponseLimit": "Você ficou sem respostas de IA gratuitas.\n\nVá para Configurações -> Plano -> Clique em AI Max ou Plano Pro para obter mais respostas de IA", + "askOwnerToUpgradeToPro": "Seu espaço de trabalho está ficando sem armazenamento gratuito. Peça ao proprietário do seu espaço de trabalho para atualizar para o Plano Pro", + "askOwnerToUpgradeToAIMax": "Seu espaço de trabalho está ficando sem respostas de IA gratuitas. Peça ao proprietário do seu espaço de trabalho para atualizar o plano ou adquirir complementos de IA", + "purchaseStorageSpace": "Adquirir espaço de armazenamento", + "purchaseAIResponse": "Adquirir ", + "askOwnerToUpgradeToLocalAI": "Peça ao proprietário do espaço de trabalho para habilitar a IA no dispositivo", + "upgradeToAILocal": "Execute modelos locais no seu dispositivo para máxima privacidade", + "upgradeToAILocalDesc": "Converse com PDFs, melhore sua escrita e preencha tabelas automaticamente usando IA local" }, "notifications": { "export": { @@ -206,6 +330,7 @@ }, "button": { "ok": "OK", + "confirm": "Confirmar", "done": "Feito", "cancel": "Cancelar", "signIn": "Conectar", @@ -228,11 +353,34 @@ "update": "Atualizar", "share": "Compartilhar", "removeFromFavorites": "Remover dos favoritos", + "removeFromRecent": "Remover dos recentes", "addToFavorites": "Adicionar aos favoritos", + "favoriteSuccessfully": "Adicionado aos favoritos", + "unfavoriteSuccessfully": "Removido dos favoritos", + "duplicateSuccessfully": "Duplicado com sucesso", "rename": "Renomear", "helpCenter": "Central de Ajuda", "add": "Adicionar", "yes": "Sim", + "no": "Não", + "clear": "Limpar", + "remove": "Remover", + "dontRemove": "Não remova", + "copyLink": "Copiar Link", + "align": "Alinhar", + "login": "Entrar", + "logout": "Sair", + "deleteAccount": "Deletar conta", + "back": "Voltar", + "signInGoogle": "Continuar com o Google", + "signInGithub": "Continuar com o Github", + "signInDiscord": "Continuar com o Discord", + "more": "Mais", + "create": "Criar", + "close": "Fechar", + "next": "Próximo", + "previous": "Anterior", + "submit": "Enviar", "tryAGain": "Tentar novamente" }, "label": { @@ -257,6 +405,190 @@ }, "settings": { "title": "Configurações", + "popupMenuItem": { + "settings": "Configurações", + "members": "Membros", + "trash": "Lixo", + "helpAndSupport": "Ajuda e Suporte" + }, + "accountPage": { + "menuLabel": "Minha conta", + "title": "Minha conta", + "general": { + "title": "Nome da conta e foto de perfil", + "changeProfilePicture": "Alterar foto do perfil" + }, + "email": { + "title": "E-mail", + "actions": { + "change": "Alterar e-mail" + } + }, + "login": { + "title": "Entrar com uma conta", + "loginLabel": "Entrar", + "logoutLabel": "Sair" + } + }, + "workspacePage": { + "menuLabel": "Espaço de trabalho", + "title": "Espaço de trabalho", + "description": "Personalize a aparência do seu espaço de trabalho, tema, fonte, layout de texto, formato de data/hora e idioma.", + "workspaceName": { + "title": "Nome do espaço de trabalho" + }, + "workspaceIcon": { + "title": "Ícone do espaço de trabalho", + "description": "Carregue uma imagem ou use um emoji para seu espaço de trabalho. O ícone será exibido na sua barra lateral e notificações." + }, + "appearance": { + "title": "Aparência", + "description": "Personalize a aparência do seu espaço de trabalho, tema, fonte, layout de texto, data, hora e idioma.", + "options": { + "system": "Automático", + "light": "Claro", + "dark": "Escuro" + } + }, + "resetCursorColor": { + "title": "Redefinir a cor do cursor do documento", + "description": "Tem certeza de que deseja redefinir a cor do cursor?" + }, + "resetSelectionColor": { + "title": "Redefinir cor de seleção de documento", + "description": "Tem certeza de que deseja redefinir a cor de seleção?" + }, + "theme": { + "title": "Tema", + "description": "Selecione um tema predefinido ou carregue seu próprio tema personalizado.", + "uploadCustomThemeTooltip": "Carregar um tema personalizado" + }, + "workspaceFont": { + "title": "Fonte do espaço de trabalho", + "noFontHint": "Nenhuma fonte encontrada, tente outro termo." + }, + "textDirection": { + "title": "Direção do texto", + "leftToRight": "Da esquerda para a direita", + "rightToLeft": "Da direita para a esquerda", + "auto": "Automático", + "enableRTLItems": "Habilitar items da barra de ferramenta da direita para a esquerda" + }, + "layoutDirection": { + "title": "Direção do layout", + "leftToRight": "Da esquerda para a direita", + "rightToLeft": "Da direita para a esquerda" + }, + "dateTime": { + "title": "Data e hora", + "example": "{} as {} ({})", + "24HourTime": "Tempo de 24 horas", + "dateFormat": { + "label": "Formato de data", + "us": "EUA", + "friendly": "Amigável" + } + }, + "language": { + "title": "Língua" + }, + "deleteWorkspacePrompt": { + "title": "Excluir espaço de trabalho", + "content": "Tem certeza de que deseja excluir este espaço de trabalho? Esta ação não pode ser desfeita, e quaisquer páginas que você tenha publicado deixarão de estar publicadas." + }, + "leaveWorkspacePrompt": { + "title": "Sair do espaço de trabalho", + "content": "Tem certeza de que deseja sair deste espaço de trabalho? Você perderá o acesso a todas as páginas e dados dentro dele." + }, + "manageWorkspace": { + "title": "Gerenciar espaço de trabalho", + "leaveWorkspace": "Sair do espaço de trabalho", + "deleteWorkspace": "Excluir espaço de trabalho" + } + }, + "manageDataPage": { + "menuLabel": "Gerenciar dados", + "title": "Gerenciar dados", + "description": "Gerencie o armazenamento local de dados ou importe seus dados existentes para @:appName .", + "dataStorage": { + "title": "Local de armazenamento de arquivos", + "tooltip": "O local onde seus arquivos são armazenados", + "actions": { + "change": "Mudar caminho", + "open": "Abrir pasta", + "openTooltip": "Abrir local da pasta de dados atual", + "copy": "Copiar caminho", + "copiedHint": "Caminho copiado!", + "resetTooltip": "Redefinir para o local padrão" + }, + "resetDialog": { + "title": "Tem certeza?", + "description": "Redefinir o caminho para o local de dados padrão não excluirá seus dados. Se você quiser reimportar seus dados atuais, você deve copiar o caminho do seu local atual primeiro." + } + }, + "importData": { + "title": "Importar dados", + "tooltip": "Importar dados das pastas de backups/dados de @:appName", + "description": "Copiar dados de uma pasta de dados externa ao @:appName", + "action": "Selecionar arquivo" + }, + "encryption": { + "title": "Criptografia", + "tooltip": "Gerencie como seus dados são armazenados e criptografados", + "descriptionNoEncryption": "Ativar a criptografia criptografará todos os dados. Isso não pode ser desfeito.", + "descriptionEncrypted": "Seus dados estão criptografados.", + "action": "Criptografar dados", + "dialog": { + "title": "Criptografar todos os seus dados?", + "description": "Criptografar todos os seus dados manterá seus dados seguros e protegidos. Esta ação NÃO pode ser desfeita. Tem certeza de que deseja continuar?" + } + }, + "cache": { + "title": "Limpar cache", + "description": "Ajude a resolver problemas como imagem não carregando, páginas faltando em um espaço e fontes não carregando. Isso não afetará seus dados.", + "dialog": { + "title": "Limpar cache", + "description": "Ajude a resolver problemas como imagem não carregando, páginas faltando em um espaço e fontes não carregando. Isso não afetará seus dados.", + "successHint": "Cache limpo!" + } + }, + "data": { + "fixYourData": "Corrija seus dados", + "fixButton": "Corrigir", + "fixYourDataDescription": "Se estiver com problemas com seus dados, você pode tentar corrigi-los aqui." + } + }, + "shortcutsPage": { + "menuLabel": "Atalhos", + "title": "Atalhos", + "editBindingHint": "Insira uma nova combinação", + "searchHint": "Pesquisar", + "actions": { + "resetDefault": "Redefinir padrão" + }, + "errorPage": { + "message": "Falha ao carregar atalhos: {}", + "howToFix": "Por favor, tente novamente. Se o problema persistir, entre em contato pelo GitHub." + }, + "resetDialog": { + "title": "Redefinir atalhos", + "description": "Isso redefinirá todas as suas combinações de teclas para o padrão. Você não poderá desfazer isso depois. Tem certeza de que deseja continuar?", + "buttonLabel": "Redefinir" + }, + "conflictDialog": { + "title": "{} já está em uso", + "descriptionPrefix": "Esta combinação de teclas está sendo usada atualmente por ", + "descriptionSuffix": ". Se você substituir esta combinação de teclas, ela será removida de {}.", + "confirmLabel": "Continuar" + }, + "editTooltip": "Pressione para começar a editar a combinação de teclas", + "keybindings": { + "toggleToDoList": "Alternar para a lista de tarefas", + "insertNewParagraphInCodeblock": "Inserir novo parágrafo", + "pasteInCodeblock": "Colar bloco de código", + "selectAllCodeblock": "Selecionar tudo" + } + }, "menu": { "appearance": "Aparência", "language": "Idioma", @@ -276,11 +608,7 @@ "cloudServerType": "Servidor em nuvem", "cloudServerTypeTip": "Observe que ele pode desconectar sua conta atual após mudar o servidor em nuvem", "cloudLocal": "Local", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "URL da Supabase", - "cloudSupabaseAnonKey": "Chave anônima Supabase", - "cloudSupabaseAnonKeyCanNotBeEmpty": "A chave anon não pode estar vazia se o URL da supabase não estiver vazio", - "cloudAppFlowy": "AppFlowy Cloud", + "cloudAppFlowy": "@:appName Cloud Beta", "clickToCopy": "Clique para copiar", "selfHostStart": "Se você não possui um servidor, consulte o", "selfHostContent": "documento", @@ -299,13 +627,12 @@ "historicalUserList": "Histórico de login do usuário", "historicalUserListTooltip": "Esta lista exibe suas contas anônimas. Você pode clicar em uma conta para ver seus detalhes. Contas anônimas são criadas clicando no botão ‘Começar’", "openHistoricalUser": "Clique para abrir a conta anônima", - "customPathPrompt": "Armazenar a pasta de dados do AppFlowy em uma pasta sincronizada na nuvem, como o Google Drive, pode representar riscos. Se o banco de dados nesta pasta for acessado ou modificado de vários locais ao mesmo tempo, isso poderá resultar em conflitos de sincronização e possível corrupção de dados", - "importAppFlowyData": "Importar dados da pasta AppFlowy externa", - "importAppFlowyDataDescription": "Copie dados de uma pasta de dados externa do AppFlowy e importe-os para a pasta de dados atual do AppFlowy", - "importSuccess": "Importou com sucesso a pasta de dados do AppFlowy", - "importFailed": "Falha ao importar a pasta de dados do AppFlowy", - "importGuide": "Para mais detalhes, consulte o documento referenciado", - "supabaseSetting": "Configuração de Supabase" + "customPathPrompt": "Armazenar a pasta de dados do @:appName em uma pasta sincronizada na nuvem, como o Google Drive, pode representar riscos. Se o banco de dados nesta pasta for acessado ou modificado de vários locais ao mesmo tempo, isso poderá resultar em conflitos de sincronização e possível corrupção de dados", + "importAppFlowyData": "Importar dados da pasta @:appName externa", + "importAppFlowyDataDescription": "Copie dados de uma pasta de dados externa do @:appName e importe-os para a pasta de dados atual do @:appName", + "importSuccess": "Importou com sucesso a pasta de dados do @:appName", + "importFailed": "Falha ao importar a pasta de dados do @:appName", + "importGuide": "Para mais detalhes, consulte o documento referenciado" }, "notifications": { "enableNotifications": { @@ -354,7 +681,7 @@ "themeUpload": { "button": "Carregar", "uploadTheme": "Carregar tema", - "description": "Carregue seu próprio tema AppFlowy usando o botão abaixo.", + "description": "Carregue seu próprio tema @:appName usando o botão abaixo.", "loading": "Aguarde enquanto validamos e carregamos seu tema...", "uploadSuccess": "Seu tema foi carregado com sucesso", "deletionFailure": "Falha ao excluir o tema. Tente excluí-lo manualmente.", @@ -385,7 +712,7 @@ "defaultLocation": "Onde os seus dados ficam armazenados", "exportData": "Exporte seus dados", "doubleTapToCopy": "Clique duas vezes para copiar o caminho", - "restoreLocation": "Restaurar para o caminho padrão do AppFlowy", + "restoreLocation": "Restaurar para o caminho padrão do @:appName", "customizeLocation": "Abrir outra pasta", "restartApp": "Reinicie o aplicativo para que as alterações entrem em vigor.", "exportDatabase": "Exportar banco de dados", @@ -397,10 +724,10 @@ "defineWhereYourDataIsStored": "Defina onde seus dados são armazenados", "open": "Abrir", "openFolder": "Abra uma pasta existente", - "openFolderDesc": "Gravar na pasta AppFlowy existente ...", + "openFolderDesc": "Gravar na pasta @:appName existente ...", "folderHintText": "nome da pasta", "location": "Criando nova pasta", - "locationDesc": "Escolha um nome para sua pasta de dados do AppFlowy", + "locationDesc": "Escolha um nome para sua pasta de dados do @:appName", "browser": "Navegar", "create": "Criar", "set": "Definir", @@ -411,7 +738,7 @@ "change": "Mudar", "openLocationTooltips": "Abra outro diretório de dados", "openCurrentDataFolder": "Abra o diretório de dados atual", - "recoverLocationTooltips": "Redefinir para o diretório de dados padrão do AppFlowy", + "recoverLocationTooltips": "Redefinir para o diretório de dados padrão do @:appName", "exportFileSuccess": "Exportar arquivo com sucesso!", "exportFileFail": "Falha na exportação do arquivo!", "export": "Exportar" @@ -421,9 +748,26 @@ "email": "E-mail", "tooltipSelectIcon": "Selecionar ícone", "selectAnIcon": "Escolha um ícone", - "pleaseInputYourOpenAIKey": "por favor insira sua chave OpenAI", - "pleaseInputYourStabilityAIKey": "insira sua chave Stability AI", - "clickToLogout": "Clique para sair do usuário atual" + "pleaseInputYourOpenAIKey": "por favor insira sua chave AI", + "clickToLogout": "Clique para sair do usuário atual", + "pleaseInputYourStabilityAIKey": "insira sua chave Stability AI" + }, + "mobile": { + "personalInfo": "Informações pessoais", + "username": "Nome de usuário", + "usernameEmptyError": "O nome de usuário não pode ficar vazio", + "about": "Sobre", + "pushNotifications": "Notificações via push", + "support": "Apoiar", + "joinDiscord": "Junte-se a nós no Discord", + "privacyPolicy": "Política de Privacidade", + "userAgreement": "Termo de Acordo do Usuário", + "termsAndConditions": "Termos e Condições", + "userprofileError": "Falha ao carregar o perfil do usuário", + "userprofileErrorDescription": "Tente sair e fazer login novamente para verificar se o problema ainda persiste.", + "selectLayout": "Selecione o layout", + "selectStartingDay": "Selecione o dia de início", + "version": "Versão" }, "shortcuts": { "shortcutsLabel": "Atalhos", @@ -445,23 +789,6 @@ "textAlignRight": "Alinhar texto à direita", "codeBlockDeleteTwoSpaces": "Bloco de código: excluir dois espaços no início da linha" } - }, - "mobile": { - "personalInfo": "Informações pessoais", - "username": "Nome de usuário", - "usernameEmptyError": "O nome de usuário não pode ficar vazio", - "about": "Sobre", - "pushNotifications": "Notificações via push", - "support": "Apoiar", - "joinDiscord": "Junte-se a nós no Discord", - "privacyPolicy": "Política de Privacidade", - "userAgreement": "Termo de Acordo do Usuário", - "termsAndConditions": "Termos e Condições", - "userprofileError": "Falha ao carregar o perfil do usuário", - "userprofileErrorDescription": "Tente sair e fazer login novamente para verificar se o problema ainda persiste.", - "selectLayout": "Selecione o layout", - "selectStartingDay": "Selecione o dia de início", - "version": "Versão" } }, "grid": { @@ -641,9 +968,7 @@ "createNew": "Crie um novo", "orSelectOne": "Ou selecione uma opção", "typeANewOption": "Digite uma nova opção", - "tagName": "Nome da etiqueta", - "colorPannelTitle": "Cores", - "pannelTitle": "Escolha uma opção ou crie uma" + "tagName": "Nome da etiqueta" }, "checklist": { "taskHint": "Descrição da tarefa", @@ -696,18 +1021,18 @@ "autoGeneratorLearnMore": "Saiba mais", "autoGeneratorGenerate": "Gerar", "autoGeneratorHintText": "Diga-nos o que você deseja gerar por IA ...", - "autoGeneratorCantGetOpenAIKey": "Não foi possível obter a chave da OpenAI", + "autoGeneratorCantGetOpenAIKey": "Não foi possível obter a chave da AI", "autoGeneratorRewrite": "Reescrever", "smartEdit": "Assistentes de IA", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Corrigir ortografia", "warning": "⚠️ As respostas da IA podem ser imprecisas ou enganosas.", "smartEditSummarize": "Resumir", "smartEditImproveWriting": "melhorar a escrita", "smartEditMakeLonger": "Faça mais", - "smartEditCouldNotFetchResult": "Não foi possível obter o resultado do OpenAI", - "smartEditCouldNotFetchKey": "Não foi possível obter a chave OpenAI", - "smartEditDisabled": "Conecte OpenAI em Configurações", + "smartEditCouldNotFetchResult": "Não foi possível obter o resultado do AI", + "smartEditCouldNotFetchKey": "Não foi possível obter a chave AI", + "smartEditDisabled": "Conecte AI em Configurações", "discardResponse": "Deseja descartar as respostas de IA?", "createInlineMathEquation": "Criar equação", "fonts": "Fontes", @@ -765,8 +1090,8 @@ "defaultColor": "Padrão" }, "image": { - "copiedToPasteBoard": "O link da imagem foi copiado para a área de transferência", - "addAnImage": "Adicione uma imagem" + "addAnImage": "Adicione uma imagem", + "copiedToPasteBoard": "O link da imagem foi copiado para a área de transferência" }, "urlPreview": { "copiedToPasteBoard": "O link foi copiado para a área de transferência" @@ -815,8 +1140,8 @@ "placeholder": "Insira o URL da imagem" }, "ai": { - "label": "Gerar imagem da OpenAI", - "placeholder": "Insira o prompt para OpenAI gerar imagem" + "label": "Gerar imagem da AI", + "placeholder": "Insira o prompt para AI gerar imagem" }, "stability_ai": { "label": "Gerar imagem da Stability AI", @@ -837,12 +1162,12 @@ "label": "Remover respingo" }, "searchForAnImage": "Procurar uma imagem", - "pleaseInputYourOpenAIKey": "insira sua chave OpenAI na página configurações", - "pleaseInputYourStabilityAIKey": "insira sua chave Stability AI na página Configurações", + "pleaseInputYourOpenAIKey": "insira sua chave AI na página configurações", "saveImageToGallery": "Salvar imagem", "failedToAddImageToGallery": "Falha ao adicionar imagem à galeria", "successToAddImageToGallery": "Imagem adicionada à galeria com sucesso", - "unableToLoadImage": "Não foi possível carregar a imagem" + "unableToLoadImage": "Não foi possível carregar a imagem", + "pleaseInputYourStabilityAIKey": "insira sua chave Stability AI na página Configurações" }, "codeBlock": { "language": { @@ -945,7 +1270,7 @@ "quickJumpYear": "Ir para" }, "errorDialog": { - "title": "Erro do AppFlowy", + "title": "Erro do @:appName", "howToFixFallback": "Lamentamos o inconveniente! Envie um problema em nossa página do GitHub que descreva seu erro.", "github": "Ver no GitHub" }, diff --git a/frontend/resources/translations/pt-PT.json b/frontend/resources/translations/pt-PT.json index 8a9745650a..c9892bf9df 100644 --- a/frontend/resources/translations/pt-PT.json +++ b/frontend/resources/translations/pt-PT.json @@ -56,7 +56,7 @@ "resetWorkspacePrompt": "Reinciar do espaço de trabalho excluirá todas as páginas e dados contidos. Tem certeza de que deseja reiniciar o espaço de trabalho? Alternativamente, podes entrar em contato com a equipe de suporte para restaurar o espaço de trabalho", "hint": "ambiente de trabalho", "notFoundError": "Ambiente de trabalho não encontrada", - "failedToLoad": "Algo correu mal! Falha ao carregar o espaço de trabalho. Tente fechar qualquer instância aberta do AppFlowy e tente novamente.", + "failedToLoad": "Algo correu mal! Falha ao carregar o espaço de trabalho. Tente fechar qualquer instância aberta do @:appName e tente novamente.", "errorActions": { "reportIssue": "Relatar problema", "reachOut": "Entre em contacto no Discord" @@ -128,14 +128,14 @@ "questionBubble": { "shortcuts": "Atalhos", "whatsNew": "O que há de novo?", - "help": "Ajuda & Suporte", "markdown": "Remarcação", "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" }, - "feedback": "Opinião" + "feedback": "Opinião", + "help": "Ajuda & Suporte" }, "menuAppHeader": { "moreButtonToolTip": "Remover, renomear e muito mais...", @@ -255,8 +255,7 @@ "inputTextFieldHint": "A sua palavra-chave", "historicalUserList": "Histórico de login do utilizador", "historicalUserListTooltip": "Esta lista mostrs suas contas anónimas. Pode clicar numa conta para ver os detalhes. Contas anónimas são criadas clicando no botão ‘Começar’", - "openHistoricalUser": "Clique para abrir a conta anónima", - "supabaseSetting": "Configuração de Supabase" + "openHistoricalUser": "Clique para abrir a conta anónima" }, "notifications": { "enableNotifications": { @@ -293,7 +292,7 @@ "themeUpload": { "button": "Carregar", "uploadTheme": "Carregar tema", - "description": "Carregue seu próprio tema AppFlowy usando o botão abaixo.", + "description": "Carregue seu próprio tema @:appName usando o botão abaixo.", "loading": "Aguarde enquanto validamos e carregamos seu tema...", "uploadSuccess": "Seu tema foi carregado com sucesso", "deletionFailure": "Falha ao excluir o tema. Tente excluí-lo manualmente.", @@ -326,7 +325,7 @@ "defaultLocation": "Leia arquivos e local de armazenamento de dados", "exportData": "Exporte seus dados", "doubleTapToCopy": "Toque duas vezes para copiar o caminho", - "restoreLocation": "Restaurar para o caminho padrão do AppFlowy", + "restoreLocation": "Restaurar para o caminho padrão do @:appName", "customizeLocation": "Abra outra pasta", "restartApp": "Reinicie o aplicativo para que as alterações entrem em vigor.", "exportDatabase": "Exportar banco de dados", @@ -338,10 +337,10 @@ "defineWhereYourDataIsStored": "Defina onde seus dados são armazenados", "open": "Abrir", "openFolder": "Abra uma pasta existente", - "openFolderDesc": "Leia e grave-o em sua pasta AppFlowy existente", + "openFolderDesc": "Leia e grave-o em sua pasta @:appName existente", "folderHintText": "nome da pasta", "location": "Criando uma nova pasta", - "locationDesc": "Escolha um nome para sua pasta de dados do AppFlowy", + "locationDesc": "Escolha um nome para sua pasta de dados do @:appName", "browser": "Navegar", "create": "Criar", "set": "Definir", @@ -352,7 +351,7 @@ "change": "Mudar", "openLocationTooltips": "Abra outro diretório de dados", "openCurrentDataFolder": "Abra o diretório de dados atual", - "recoverLocationTooltips": "Redefinir para o diretório de dados padrão do AppFlowy", + "recoverLocationTooltips": "Redefinir para o diretório de dados padrão do @:appName", "exportFileSuccess": "Exportar arquivo com sucesso!", "exportFileFail": "Falha na exportação do arquivo!", "export": "Exportar" @@ -362,9 +361,9 @@ "email": "E-mail", "tooltipSelectIcon": "Selecione o ícone", "selectAnIcon": "Selecione um ícone", - "pleaseInputYourOpenAIKey": "por favor insira sua chave OpenAI", - "pleaseInputYourStabilityAIKey": "por favor, insira a sua chave Stability AI", - "clickToLogout": "Clique para fazer logout" + "pleaseInputYourOpenAIKey": "por favor insira sua chave AI", + "clickToLogout": "Clique para fazer logout", + "pleaseInputYourStabilityAIKey": "por favor, insira a sua chave Stability AI" }, "shortcuts": { "shortcutsLabel": "Atalhos", @@ -561,23 +560,23 @@ "referencedBoard": "Conselho Referenciado", "referencedGrid": "grade referenciada", "referencedCalendar": "calendário referenciado", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Peça à IA para escrever qualquer coisa...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Peça à IA para escrever qualquer coisa...", "autoGeneratorLearnMore": "Saber mais", "autoGeneratorGenerate": "Gerar", - "autoGeneratorHintText": "Pergunte ao OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Não é possível obter a chave OpenAI", + "autoGeneratorHintText": "Pergunte ao AI...", + "autoGeneratorCantGetOpenAIKey": "Não é possível obter a chave AI", "autoGeneratorRewrite": "Reescrever", "smartEdit": "Assistentes de IA", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "corrigir ortografia", "warning": "⚠️ As respostas da IA podem ser imprecisas ou enganosas.", "smartEditSummarize": "Resumir", "smartEditImproveWriting": "melhorar a escrita", "smartEditMakeLonger": "Faça mais", - "smartEditCouldNotFetchResult": "Não foi possível obter o resultado do OpenAI", - "smartEditCouldNotFetchKey": "Não foi possível obter a chave OpenAI", - "smartEditDisabled": "Conecte OpenAI em Configurações", + "smartEditCouldNotFetchResult": "Não foi possível obter o resultado do AI", + "smartEditCouldNotFetchKey": "Não foi possível obter a chave AI", + "smartEditDisabled": "Conecte AI em Configurações", "discardResponse": "Deseja descartar as respostas de IA?", "createInlineMathEquation": "Criar equação", "toggleList": "Alternar lista", @@ -625,8 +624,8 @@ "defaultColor": "Padrão" }, "image": { - "copiedToPasteBoard": "O link da imagem foi copiado para a área de transferência", - "addAnImage": "Adicione uma imagem" + "addAnImage": "Adicione uma imagem", + "copiedToPasteBoard": "O link da imagem foi copiado para a área de transferência" }, "outline": { "addHeadingToCreateOutline": "Adicione títulos para criar um sumário." @@ -662,8 +661,8 @@ "placeholder": "Insira o URL da imagem" }, "ai": { - "label": "Gerar imagem da OpenAI", - "placeholder": "Por favor, insira o comando para a OpenAI gerar a imagem" + "label": "Gerar imagem da AI", + "placeholder": "Por favor, insira o comando para a AI gerar a imagem" }, "stability_ai": { "label": "Gerar imagem da Stability AI", @@ -681,7 +680,7 @@ "placeholder": "Cole ou digite uma hiperligação de imagem" }, "searchForAnImage": "Procure uma imagem", - "pleaseInputYourOpenAIKey": "por favor, insira a sua chave OpenAI na página Configurações", + "pleaseInputYourOpenAIKey": "por favor, insira a sua chave AI na página Configurações", "pleaseInputYourStabilityAIKey": "por favor, insira a sua chave Stability AI na página Configurações" }, "codeBlock": { @@ -754,7 +753,7 @@ "referencedCalendarPrefix": "Vista de" }, "errorDialog": { - "title": "Erro do AppFlowy", + "title": "Erro do @:appName", "howToFixFallback": "Lamentamos o inconveniente! Envie um problema em nossa página do GitHub que descreva seu erro.", "github": "Ver no GitHub" }, diff --git a/frontend/resources/translations/ru-RU.json b/frontend/resources/translations/ru-RU.json index c138c87bd1..c45010b8fc 100644 --- a/frontend/resources/translations/ru-RU.json +++ b/frontend/resources/translations/ru-RU.json @@ -9,6 +9,7 @@ "title": "Заголовок", "youCanAlso": "Вы также можете", "and": "и", + "failedToOpenUrl": "Не удалось открыть URL: {}", "blockActions": { "addBelowTooltip": "Нажмите, чтобы добавить ниже", "addAboveCmd": "Alt+клик", @@ -24,7 +25,7 @@ "emptyPasswordError": "Пароль не может быть пустым", "repeatPasswordEmptyError": "Повтор пароля не может быть пустым", "unmatchedPasswordError": "Пароли не совпадают", - "alreadyHaveAnAccount": "Уже есть аккаунт?", + "alreadyHaveAnAccount": "У Вас уже есть аккаунт?", "emailHint": "Электронная почта", "passwordHint": "Пароль", "repeatPasswordHint": "Повторите пароль", @@ -35,17 +36,39 @@ "loginButtonText": "Войти", "loginStartWithAnonymous": "Начать анонимную сессию", "continueAnonymousUser": "Продолжить анонимную сессию", + "anonymous": "Анонимно", "buttonText": "Авторизация", "signingInText": "Вход…", "forgotPassword": "Забыли пароль?", "emailHint": "Электронная почта", "passwordHint": "Пароль", - "dontHaveAnAccount": "Нет аккаунта?", + "dontHaveAnAccount": "У Вас ещё нет аккаунта?", + "createAccount": "Зарегистрироваться", "repeatPasswordEmptyError": "Повтор пароля не может быть пустым", "unmatchedPasswordError": "Пароли не совпадают", "syncPromptMessage": "Синхронизация данных может занять некоторое время. Пожалуйста, не закрывайте эту страницу", "or": "или", + "signInWithGoogle": "Продолжить с Google", + "signInWithGithub": "Продолжить с Github", + "signInWithDiscord": "Продолжить с Discord", + "signInWithApple": "Вход с Apple", + "continueAnotherWay": "Другие способы входа", + "signUpWithGoogle": "Зарегистрироваться с Google", + "signUpWithGithub": "Зарегистрироваться с Github", + "signUpWithDiscord": "Зарегистрироваться с Discord", "signInWith": "Войти с помощью:", + "signInWithEmail": "Продолжить с эл. почтой", + "signInWithMagicLink": "Продолжить", + "signUpWithMagicLink": "Зарегистрироваться с волшебной ссылкой", + "pleaseInputYourEmail": "Пожалуйста, введите ваш адрес эл. почты", + "settings": "Настройки", + "magicLinkSent": "Волшебная ссылка отправлена!", + "invalidEmail": "Пожалуйста, введите действующий адрес эл. почты", + "alreadyHaveAnAccount": "У Вас уже есть аккаунт?", + "logIn": "Войти", + "generalError": "Что-то пошло не так. Пожалуйста, повторите попытку позже.", + "limitRateError": "Из соображений безопасности вы можете запрашивать волшебную ссылку только каждые 60 секунд.", + "magicLinkSentDescription": "Волшебная ссылка была отправлена на ваш адрес эл. почты. Нажмите на ссылку, чтобы завершить вход. Срок действия ссылки истекает через 5 минут.", "LogInWithGoogle": "Войти через Google", "LogInWithGithub": "Войти через GitHub", "LogInWithDiscord": "Войти через Discord", @@ -53,25 +76,60 @@ }, "workspace": { "chooseWorkspace": "Выберите рабочее пространство", + "defaultName": "Моё рабочее пространство", "create": "Создать рабочее пространство", + "new": "Новое рабочее пространство", + "importFromNotion": "Импортировать с Notion", + "learnMore": "Узнать больше", "reset": "Сбросить рабочее пространство", + "renameWorkspace": "Переименовать рабочее пространство", + "workspaceNameCannotBeEmpty": "Название рабочего пространства не может быть пустым", "resetWorkspacePrompt": "Сброс рабочего пространства приведет к удалению всех страниц и данных внутри него. Вы уверены, что хотите сбросить рабочее пространство? В качестве альтернативы вы можете обратиться в службу поддержки для восстановления рабочего пространства", "hint": "рабочее пространство", - "notFoundError": "Рабочее пространство не найдено", - "failedToLoad": "Что-то пошло не так! Не удалось загрузить рабочее пространство. Попробуйте закрыть все открытые экземпляры AppFlowy и повторите попытку.", + "notFoundError": "Рабочее пространство не найдено.", + "failedToLoad": "Что-то пошло не так! Не удалось загрузить рабочее пространство. Попробуйте закрыть все открытые экземпляры @:appName и повторите попытку.", "errorActions": { "reportIssue": "Сообщить о проблеме", "reportIssueOnGithub": "Сообщить о проблеме на Github", "exportLogFiles": "Экспорт логов", "reachOut": "Написать в Discord" - } + }, + "menuTitle": "Рабочие пространства", + "deleteWorkspaceHintText": "Вы уверены, что хотите удалить рабочее пространство? Это действие нельзя отменить, и все опубликованные страницы будут отменены.", + "createSuccess": "Рабочее пространство успешно создано.", + "createFailed": "Не удалось создать рабочее пространство.", + "createLimitExceeded": "Вы достигли максимального количества рабочих пространств для вашего аккаунта. Если вам нужны дополнительные рабочие пространства для продолжения работы, отправьте запрос на Github.", + "deleteSuccess": "Рабочее пространство успешно удалено.", + "deleteFailed": "Не удалось удалить рабочее пространство.", + "openSuccess": "Рабочее пространство успешно открыто.", + "openFailed": "Не удалось открыть рабочее пространство.", + "renameSuccess": "Рабочее пространство успешно переименовано.", + "renameFailed": "Не удалось переименовать рабочее пространство.", + "updateIconSuccess": "Иконка рабочего пространства успешно обновлена.", + "updateIconFailed": "Не удалось обновить иконку рабочего пространства.", + "cannotDeleteTheOnlyWorkspace": "Нельзя удалить единственное рабочее пространство.", + "fetchWorkspacesFailed": "Не удалось получить рабочие пространства.", + "leaveCurrentWorkspace": "Покинуть рабочее пространство", + "leaveCurrentWorkspacePrompt": "Вы уверены, что хотите покинуть текущее рабочее пространство?" }, "shareAction": { "buttonText": "Поделиться", "workInProgress": "В разработке", "markdown": "Markdown", + "html": "HTML", + "clipboard": "Скопировать в буфер обмена", "csv": "CSV", - "copyLink": "Скопировать ссылку" + "copyLink": "Скопировать ссылку", + "publishToTheWeb": "Опубликовать в интернете", + "publishToTheWebHint": "Создать веб-сайт с AppFlowy", + "publish": "Опубликовать", + "unPublish": "Отменить", + "visitSite": "Посетить сайт", + "exportAsTab": "Экспортировать как", + "publishTab": "Опубликовать", + "shareTab": "Поделиться", + "publishOnAppFlowy": "Выложить на AppFlowy", + "shareTabTitle": "Пригласить к сотрудничеству" }, "moreAction": { "small": "маленький", @@ -93,6 +151,11 @@ "csv": "CSV", "database": "База данных" }, + "emojiIconPicker": { + "iconUploader": { + "change": "Изменить" + } + }, "disclosureAction": { "rename": "Переименовать", "delete": "Удалить", @@ -102,7 +165,10 @@ "openNewTab": "Открыть в новой вкладке", "moveTo": "Переместить в", "addToFavorites": "Добавить в избранное", - "copyLink": "Скопировать ссылку" + "copyLink": "Скопировать ссылку", + "changeIcon": "Изменить иконку", + "collapseAllPages": "Свернуть все подстраницы", + "lockPage": "Заблокировать страницу" }, "blankPageTitle": "Пустая страница", "newPageText": "Новая страница", @@ -110,9 +176,46 @@ "newGridText": "Новая таблица", "newCalendarText": "Новый календарь", "newBoardText": "Новая доска", + "chat": { + "newChat": "Чат с ИИ", + "inputMessageHint": "Спросите ИИ @:appName", + "unsupportedCloudPrompt": "Эта функция доступна только при использовании облака @:appName", + "relatedQuestion": "Связано", + "serverUnavailable": "Сервис временно недоступен. Пожалуйста, повторите попытку позже.", + "aiServerUnavailable": "🌈 Ой-ой! 🌈. Единорог съел наш ответ. Пожалуйста, повторите попытку!", + "retry": "Повторить", + "clickToRetry": "Нажмите, чтобы повторить попытку", + "regenerateAnswer": "Повторно сгенерировать", + "question1": "Как использовать канбан для управления задачами.", + "question2": "Объясните метод GTD.", + "question3": "Зачем использовать Rust.", + "question4": "Рецепт из того, что есть у меня на кухне.", + "aiMistakePrompt": "ИИ может ошибаться. Проверяйте важную информацию.", + "inputActionNoPages": "Нет результатов на странице", + "currentPage": "Текущая страница", + "regenerate": "Попробуйте ещё раз", + "addToNewPage": "Создать новую страницу", + "openPagePreviewFailedToast": "Не удалось открыть страницу", + "changeFormat": { + "actionButton": "Изменить формат", + "textOnly": "Текст", + "imageOnly": "Только изображение", + "textAndImage": "Текст и изображение", + "text": "Параграф", + "bullet": "Список маркеров", + "number": "Нумерованный список", + "defaultDescription": "Автоматический режим" + }, + "selectBanner": { + "selectMessages": "Выбрать сообщения", + "allSelected": "Все выбрано" + }, + "stopTooltip": "Остановить генерацию" + }, "trash": { "text": "Корзина", "restoreAll": "Восстановить всё", + "restore": "Восстановить", "deleteAll": "Удалить всё", "pageHeader": { "fileName": "Имя файла", @@ -127,13 +230,17 @@ "title": "Вы уверены, что хотите восстановить все страницы в корзине?", "caption": "Это действие не может быть отменено." }, + "restorePage": { + "caption": "Вы уверены, что хотите восстановить эту страницу?" + }, "mobile": { "actions": "Действия с корзиной", "empty": "Корзина пуста", - "emptyDescription": "У вас нет удалённых файлов", + "emptyDescription": "У Вас нет удалённых файлов", "isDeleted": "удалён", "isRestored": "восстановлен" - } + }, + "confirmDeleteTitle": "Вы уверены, что хотите удалить эту страницу навсегда?" }, "deletePagePrompt": { "text": "Эта страница находится в корзине", @@ -144,14 +251,14 @@ "questionBubble": { "shortcuts": "Горячие клавиши", "whatsNew": "Что нового?", - "help": "Помощь и поддержка", "markdown": "Markdown", "debug": { "name": "Отладочная информация", "success": "Отладочная информация скопирована в буфер обмена!", "fail": "Не удалось скопировать отладочную информацию в буфер обмена" }, - "feedback": "Обратная связь" + "feedback": "Обратная связь", + "help": "Помощь и поддержка" }, "menuAppHeader": { "moreButtonToolTip": "Удалить, переименовать и другие действия...", @@ -187,17 +294,43 @@ "dragRow": "Перетащите для изменения порядка строк", "viewDataBase": "Просмотр базы данных", "referencePage": "Ссылки на {name}", - "addBlockBelow": "Добавьте блок ниже" + "addBlockBelow": "Добавьте блок ниже", + "aiGenerate": "Сгенерировать" }, "sideBar": { "closeSidebar": "Закрыть боковое меню", "openSidebar": "Открыть боковое меню", "personal": "Личное", + "private": "Личное", + "workspace": "Рабочее пространство", "favorites": "Избранное", + "clickToHidePrivate": "Нажмите, чтобы скрыть личное пространство\nСтраницы, созданные вами здесь, видны только вам.", + "clickToHideWorkspace": "Нажмите, чтобы скрыть рабочее пространство\nСтраницы, созданные вами здесь, видны каждому участнику.", "clickToHidePersonal": "Нажмите, чтобы скрыть личный раздел", "clickToHideFavorites": "Нажмите, чтобы скрыть раздел избранного", "addAPage": "Добавить страницу", - "recent": "Недавние" + "addAPageToPrivate": "Добавить страницу в личное пространство", + "addAPageToWorkspace": "Добавить страницу в рабочее пространство", + "recent": "Недавние", + "today": "Сегодня", + "thisWeek": "На этой неделе", + "others": "Другие избранные", + "justNow": "только что", + "minutesAgo": "{count} минут назад", + "lastViewed": "Последний раз просмотрено", + "favoriteAt": "Добавлено в избранное", + "emptyRecent": "Нет последних документов", + "emptyRecentDescription": "Когда вы просматриваете документы, они будут появляться здесь для облегчения поиска.", + "emptyFavorite": "Нет избранных документов", + "emptyFavoriteDescription": "Начните изучать и отмечайте документы как избранные. Они будут перечислены здесь для быстрого доступа!", + "removePageFromRecent": "Удалить эту страницу из списка недавних?", + "removeSuccess": "Удалено успешно", + "favoriteSpace": "Избранное", + "RecentSpace": "Недавнее", + "Spaces": "Пространства", + "upgradeToPro": "Обновление до Pro", + "upgradeToAIMax": "Разблокируйте неограниченный ИИ", + "purchaseAIResponse": "Покупка " }, "notifications": { "export": { @@ -213,6 +346,7 @@ }, "button": { "ok": "Ок", + "confirm": "Подтвердить", "done": "Готово", "cancel": "Отмена", "signIn": "Войти", @@ -230,21 +364,48 @@ "upload": "Загрузить", "edit": "Редактировать", "delete": "Удалить", + "copy": "Копировать", "duplicate": "Дублировать", "putback": "Вернуть", "update": "Обновить", "share": "Поделиться", "removeFromFavorites": "Удалить из избранного", + "removeFromRecent": "Удалить из недавних", "addToFavorites": "Добавить в избранное", "rename": "Переименовать", "helpCenter": "Центр помощи", "add": "Добавить", "yes": "Да", + "no": "Нет", "clear": "Очистить", "remove": "Удалить", "dontRemove": "Не удалять", "copyLink": "Скопировать ссылку", - "align": "Выровнять" + "align": "Выровнять", + "login": "Войти", + "logout": "Выйти", + "deleteAccount": "Удалить аккаунт", + "back": "Назад", + "signInGoogle": "Продолжить с Google", + "signInGithub": "Продолжить с Github", + "signInDiscord": "Продолжить с Discord", + "more": "Больше", + "create": "Создать", + "close": "Закрыть", + "next": "Следующий", + "previous": "Предыдущий", + "submit": "Представить", + "download": "Скачать", + "backToHome": "Вернуться на главную", + "viewing": "Просмотр", + "editing": "Редактирование", + "gotIt": "Понятно", + "retry": "Повторить попытку", + "uploadFailed": "Загрузка не удалась.", + "tryAGain": "Попробовать ещё раз", + "Done": "Готово", + "Cancel": "Отмена", + "OK": "Хорошо" }, "label": { "welcome": "Добро пожаловать!", @@ -268,6 +429,483 @@ }, "settings": { "title": "Настройки", + "popupMenuItem": { + "settings": "Настройки", + "members": "Участники", + "helpAndSupport": "Помощь и поддержка" + }, + "sites": { + "namespaceTitle": "Пространство имен", + "namespaceDescription": "Управляйте своим пространством имен и домашней страницей", + "namespaceHeader": "Пространство имен", + "homepageHeader": "Домашняя страница", + "updateNamespace": "Обновить пространство имен", + "removeHomepage": "Удалить домашнюю страницу", + "selectHomePage": "Выберите страницу", + "clearHomePage": "Очистить домашнюю страницу для этого пространства имен", + "customUrl": "Пользовательский URL-адрес", + "namespace": { + "description": "Это изменение будет применено ко всем опубликованным страницам, размещенным в этом пространстве имен." + }, + "publishedPage": { + "page": "Страница" + } + }, + "accountPage": { + "menuLabel": "Мой аккаунт", + "title": "Мой аккаунт", + "general": { + "title": "Имя аккаунта и изображение профиля", + "changeProfilePicture": "Изменить изображение профиля" + }, + "email": { + "title": "Адрес эл. почты", + "actions": { + "change": "Изменить адрес эл. почты" + } + }, + "login": { + "title": "Войти в аккаунт", + "loginLabel": "Войти", + "logoutLabel": "Выйти" + } + }, + "workspacePage": { + "menuLabel": "Рабочее пространство", + "title": "Рабочее пространство", + "description": "Настройте внешний вид рабочего пространства, тему, шрифт, оформление текста, формат даты и времени и язык.", + "workspaceName": { + "title": "Имя рабочего пространства" + }, + "workspaceIcon": { + "title": "Иконка рабочего пространства", + "description": "Загрузите изображение или используйте эмодзи для своего рабочего пространства. Иконка будет отображаться на боковой панели и в уведомлениях." + }, + "appearance": { + "title": "Внешний вид", + "description": "Настройте внешний вид рабочего пространства, тему, шрифт, оформление текста, дату, время и язык.", + "options": { + "system": "Авто", + "light": "Светлая", + "dark": "Темная" + } + }, + "theme": { + "title": "Тема", + "description": "Выберите предустановленную тему или загрузите собственную тему.", + "uploadCustomThemeTooltip": "Загрузить собственную тему" + }, + "workspaceFont": { + "title": "Шрифт рабочего пространства", + "noFontHint": "Шрифт не найден, попробуйте другой термин." + }, + "textDirection": { + "title": "Направление текста", + "leftToRight": "Слева направо", + "rightToLeft": "Справа налево", + "auto": "Авто", + "enableRTLItems": "Вкл. элементы пан. инстр. с письмом спр. нал." + }, + "layoutDirection": { + "title": "Направление оформления", + "leftToRight": "Слева направо", + "rightToLeft": "Справа налево" + }, + "dateTime": { + "title": "Дата и время", + "example": "{} в {} ({})", + "24HourTime": "24-часовое время", + "dateFormat": { + "label": "Формат даты", + "local": "Локальный", + "us": "США", + "iso": "ИСО", + "friendly": "Дружественный", + "dmy": "Д/М/Г" + } + }, + "language": { + "title": "Язык" + }, + "deleteWorkspacePrompt": { + "title": "Удалить рабочее пространство", + "content": "Вы уверены, что хотите удалить это рабочее пространство? Это действие нельзя отменить, и все опубликованные вами страницы будут отменены." + }, + "leaveWorkspacePrompt": { + "title": "Покинуть рабочее пространство", + "content": "Вы уверены, что хотите покинуть это рабочее пространство? Вы потеряете доступ ко всем страницам и данным на нем." + }, + "manageWorkspace": { + "title": "Управление рабочим пространством", + "leaveWorkspace": "Покинуть рабочее пространство", + "deleteWorkspace": "Удалить рабочее пространство" + } + }, + "manageDataPage": { + "menuLabel": "Управление данными", + "title": "Управление данными", + "description": "Управляйте локальным хранилищем данных или импортируйте существующие данные в @:appName.", + "dataStorage": { + "title": "Место хранения файлов", + "tooltip": "Место, где хранятся ваши файлы", + "actions": { + "change": "Изменить путь", + "open": "Открыть папку", + "openTooltip": "Открыть текущее местоположение папки с данными", + "copy": "Скопировать путь", + "copiedHint": "Путь скопирован!", + "resetTooltip": "Сброс к стандартному местоположению" + }, + "resetDialog": { + "title": "Вы уверены?", + "description": "Сброс пути к стандартному местоположению данных не приведёт к удалению ваших данных. Если вы хотите повторно импортировать текущие данные, вам следует сначала скопировать путь к вашему текущему местоположению." + } + }, + "importData": { + "title": "Импортировать данные", + "tooltip": "Импортировать данные из папок резервных копий или данных @:appName", + "description": "Копировать данные из внешней папки данных @:appName", + "action": "Смотреть файл" + }, + "encryption": { + "title": "Шифрование", + "tooltip": "Управлять тем, как ваши данные хранятся и шифруются", + "descriptionNoEncryption": "Включение шифрования зашифрует все данные. Это не может быть отменено.", + "descriptionEncrypted": "Ваши данные зашифрованы.", + "action": "Зашифровать данные", + "dialog": { + "title": "Зашифровать все данные?", + "description": "Шифрование всех ваших данных обеспечит их безопасность и надежность. Это действие НЕЛЬЗЯ отменить. Вы уверены, что хотите продолжить?" + } + }, + "cache": { + "title": "Очистить кеш", + "description": "Очистите кеш приложения. Это может помочь решить такие проблемы, как незагружаемые изображения или шрифты. Это не повлияет на ваши данные.", + "dialog": { + "title": "Вы уверены?", + "description": "Очистка кеша приведёт к повторной загрузке изображений и шрифтов при загрузке. Это действие не приведет к удалению или изменению ваших данных.", + "successHint": "Кэш очищен!" + } + }, + "data": { + "fixYourData": "Исправить данные", + "fixButton": "Исправить", + "fixYourDataDescription": "Если у вас возникли проблемы с данными, вы можете попробовать исправить их здесь." + } + }, + "shortcutsPage": { + "menuLabel": "Горячие клавиши", + "title": "Горячие клавиши", + "editBindingHint": "Введите новое сочетание клавиш", + "searchHint": "Поиск", + "actions": { + "resetDefault": "Сбросить все" + }, + "errorPage": { + "message": "Не удалось загрузить горячие клавиши: {}", + "howToFix": "Пожалуйста, повторите попытку. Если проблема не исчезнет, обратитесь на GitHub." + }, + "resetDialog": { + "title": "Сбросить горячие клавиши", + "description": "Это сбросит все горячие клавиши к значениям по умолчанию. Вы не сможете отменить это позже. Вы уверены, что хотите продолжить?", + "buttonLabel": "Сбросить" + }, + "conflictDialog": { + "title": "{} сейчас используется", + "descriptionPrefix": "Эта комбинация клавиш сейчас используется ", + "descriptionSuffix": ". Если вы замените это сочетание, оно будет удалено из {}.", + "confirmLabel": "Продолжить" + }, + "editTooltip": "Нажмите, чтобы редактировать сочетание", + "keybindings": { + "toggleToDoList": "Переключить список дел", + "insertNewParagraphInCodeblock": "Вставить новый абзац", + "pasteInCodeblock": "Вставить в кодовый блок", + "selectAllCodeblock": "Выбрать всё", + "indentLineCodeblock": "Вставить два пробела в начале строки", + "outdentLineCodeblock": "Удалить два пробела в начале строки", + "twoSpacesCursorCodeblock": "Вставить два пробела возле курсора", + "copy": "Скопировать выделение", + "paste": "Вставить в контент", + "cut": "Вырезать выделение", + "alignLeft": "Выровнять текст по левому краю", + "alignCenter": "Выровнять текст по центру", + "alignRight": "Выровнять текст по правому краю", + "undo": "Отменить", + "redo": "Вернуться", + "convertToParagraph": "Преобразовать блок в абзац", + "backspace": "Удалить", + "deleteLeftWord": "Удалить левое слово", + "deleteLeftSentence": "Удалить левое предложение", + "delete": "Удалить правый символ", + "deleteMacOS": "Удалить левый символ", + "deleteRightWord": "Удалить правое слово", + "moveCursorLeft": "Переместить курсор влево", + "moveCursorBeginning": "Переместить курсор в начало", + "moveCursorLeftWord": "Переместить курсор на одно слово влево", + "moveCursorLeftSelect": "Выделить и переместить курсор влево", + "moveCursorBeginSelect": "Выделить и переместить курсор в начало", + "moveCursorLeftWordSelect": "Выделить и переместить курсор на одно слово влево", + "moveCursorRight": "Переместить курсор вправо", + "moveCursorEnd": "Переместить курсор в конец", + "moveCursorRightWord": "Переместить курсор на одно слово вправо", + "moveCursorRightSelect": "Выделить и переместить курсор вправо", + "moveCursorEndSelect": "Выделить и переместить курсор в конец", + "moveCursorRightWordSelect": "Выделитб и переместить курсор на одно слово вправо", + "moveCursorUp": "Переместить курсор вверх", + "moveCursorTopSelect": "Выделить и переместить курсор на самый верх", + "moveCursorTop": "Переместить курсор на самый верх", + "moveCursorUpSelect": "Выделить и переместить курсор вверх", + "moveCursorBottomSelect": "Выделить и переместить курсор в самый низ", + "moveCursorBottom": "Переместить курсор в самый низ", + "moveCursorDown": "Переместить курсор вниз", + "moveCursorDownSelect": "Выбрать и переместить курсор вниз", + "home": "Прокрутить на самый верх", + "end": "Прокрутить в самый низ", + "toggleBold": "Переключить жирный шрифт", + "toggleItalic": "Переключить курсив", + "toggleUnderline": "Переключить подчёркивание", + "toggleStrikethrough": "Переключить зачёркивание", + "toggleCode": "Переключить встроенный код", + "toggleHighlight": "Переключить выделение", + "showLinkMenu": "Показать меню ссылок", + "openInlineLink": "Открыть встроенную ссылку", + "openLinks": "Открыть все выбранные ссылки", + "indent": "Отступ", + "outdent": "Обратный отступ", + "exit": "Покинуть редактирование", + "pageUp": "Прокрутить одну страницу вверх", + "pageDown": "Прокрутить одну страницу вниз", + "selectAll": "Выбрать всё", + "pasteWithoutFormatting": "Вставить контент без форматирования", + "showEmojiPicker": "Показать средство выбора эмодзи", + "enterInTableCell": "Добавить перенос строки в таблицу", + "leftInTableCell": "Переместить одну ячейку таблицы влево", + "rightInTableCell": "Переместить одну ячейку таблицы вправо", + "upInTableCell": "Переместить одну ячейку таблицы вверх", + "downInTableCell": "Переместить одну ячейку таблицы вниз", + "tabInTableCell": "Перейти к следующей доступной ячейке таблицы", + "shiftTabInTableCell": "Перейти к ранее доступной ячейке в таблице", + "backSpaceInTableCell": "Остановиться в начале ячейки" + }, + "commands": { + "codeBlockNewParagraph": "Вставить новый абзац рядом с блоком кода", + "codeBlockIndentLines": "Вставить два пробела в начале строки в блоке кода", + "codeBlockOutdentLines": "Удалить два пробела в начале строки в блоке кода", + "codeBlockAddTwoSpaces": "Вставить два пробела в позицию курсора в блоке кода", + "codeBlockSelectAll": "Выбрать всё содержимое внутри блока кода", + "codeBlockPasteText": "Вставить текст в кодовый блок", + "textAlignLeft": "Выровнять текст по левому краю", + "textAlignCenter": "Выровнять текст по центру", + "textAlignRight": "Выровнять текст по правому краю" + }, + "couldNotLoadErrorMsg": "Не удалось загрузить горячие клавиши. Попробуйте ещё раз.", + "couldNotSaveErrorMsg": "Не удалось сохранить горячие клавиши. Попробуйте ещё раз." + }, + "aiPage": { + "title": "Настройки ИИ", + "menuLabel": "Настройки ИИ", + "keys": { + "enableAISearchTitle": "Поиск с ИИ", + "aiSettingsDescription": "Выберите или настройте модели ИИ, используемые в @:appName. Для достижения наилучшей производительности мы рекомендуем использовать параметры модели по умолчанию.", + "loginToEnableAIFeature": "Функции ИИ активируются только после входа в систему с помощью @:appName Cloud. Если у Вас нет аккаунта @:appName, перейдите в раздел «Мой аккаунт», чтобы зарегистрироваться.", + "llmModel": "Языковая модель", + "title": "Ключи API ИИ", + "openAILabel": "Ключ API OpenAI", + "openAITooltip": "Вы можете найти свой секретный ключ API на странице ключа API.", + "openAIHint": "Введите свой ключ API OpenAI", + "stabilityAILabel": "Ключ API Stability", + "stabilityAITooltip": "Ваш ключ API Stability, используемый для аутентификации ваших запросов", + "stabilityAIHint": "Введите свой ключ API Stability" + } + }, + "planPage": { + "menuLabel": "План", + "title": "Тарифный план", + "planUsage": { + "title": "Сводка использования плана", + "storageLabel": "Хранилище", + "storageUsage": "{} из {} ГБ", + "collaboratorsLabel": "Сотрудники", + "collaboratorsUsage": "{} из {}", + "aiResponseLabel": "Ответы ИИ", + "aiResponseUsage": "{} из {}", + "proBadge": "Pro", + "memberProToggle": "Неогр. кол-во участников", + "aiCredit": { + "title": "Добавить кредитов @:appName AI", + "price": "{}", + "priceDescription": "за 1000 кредитов", + "purchase": "Купить ИИ", + "info": "Добавьте 1000 кредитов ИИ на каждое рабочее пространство и легко интегрируйте настраиваемый ИИ в свой рабочий процесс, чтобы получить более разумные и быстрые результаты:", + "infoItemOne": "10 000 ответов на базу данных", + "infoItemTwo": "1000 ответов на рабочее пространство" + }, + "currentPlan": { + "bannerLabel": "Текущий план", + "freeTitle": "Бесплатно", + "proTitle": "Профессиональный", + "teamTitle": "Командный", + "freeInfo": "Идеально подходит для отдельных лиц или небольших команд до 3 человек.", + "proInfo": "Идеально подходит для небольших и средних команд до 10 человек.", + "teamInfo": "Идеально подходит для всех продуктивных и хорошо организованных команд..", + "upgrade": "Сравнить и\n Обновить", + "canceledInfo": "Ваш план отменён, ваш уровень будет понижен до бесплатного плана {}.", + "freeProOne": "Совместное рабочее пространство", + "freeProTwo": "До 3 участников (вкл. владельца)", + "freeProThree": "Неогр. кол-во гостей (только просмотр)", + "freeProFour": "5 ГБ хранилища", + "freeProFive": "История изменений за 30 дней", + "freeConOne": "Гостевые сотрудники (доступ редакт.)", + "freeConTwo": "Неогр. хранилище", + "freeConThree": "История изменений за 6 месяцев", + "professionalProOne": "Совместное рабочее пространство", + "professionalProTwo": "Неогр. кол-во участников", + "professionalProThree": "Неогр. кол-во гостей (только просмотр)", + "professionalProFour": "Неогр. хранилище", + "professionalProFive": "История изменений за 6 месяцев", + "professionalConOne": "Неогр. кол-во приглашенных соавторов (доступ для редактирования)", + "professionalConTwo": "Неогр. кол-во ответов ИИ", + "professionalConThree": "История изменений за 1 год" + }, + "addons": { + "aiMax": { + "price": "{}" + }, + "aiOnDevice": { + "price": "{}" + } + }, + "deal": { + "bannerLabel": "Новогодняя акция!", + "title": "Развивайте свою команду!", + "info": "Обновитесь и сэкономьте 10% на профессиональном и командном планах! Повысьте производительность своего рабочего пространства с помощью новых мощных функций, включая ИИ @:appName.", + "viewPlans": "Посмотреть планы" + }, + "guestCollabToggle": "10 гостевых сотрудников", + "storageUnlimited": "Неогр. хранилище с профессиональным планом" + } + }, + "billingPage": { + "menuLabel": "Выставление счетов", + "title": "Выставление счетов", + "plan": { + "title": "План", + "freeLabel": "Бесплатный", + "proLabel": "Профессиональный", + "planButtonLabel": "Изменить план", + "billingPeriod": "Расчётный период", + "periodButtonLabel": "Изменить период" + }, + "paymentDetails": { + "title": "Детали оплаты", + "methodLabel": "Способ оплаты", + "methodButtonLabel": "Изменить способ" + } + }, + "comparePlanDialog": { + "title": "Сравнить и выбрать план", + "planFeatures": "План\nФункции", + "current": "Текущий", + "actions": { + "upgrade": "Обновиться", + "downgrade": "Понизиться", + "current": "Текущий", + "downgradeDisabledTooltip": "Вы автоматически понизите план в конце платежного цикла." + }, + "freePlan": { + "title": "Бесплатный", + "description": "Для организации каждого уголка вашей работы и жизни.", + "price": "{}", + "priceInfo": "бесплатно навсегда" + }, + "proPlan": { + "title": "Профессиональный", + "description": "Место, где небольшие группы могут планировать и организовываться.", + "price": "{}/месяц", + "priceInfo": "снимается ежегодно" + }, + "planLabels": { + "itemOne": "Рабочие пространства", + "itemTwo": "Участники", + "itemThree": "Гости", + "itemFour": "Гостевые сотрудники", + "itemFive": "Хранилище", + "itemSix": "Сотрудничество в реальном времени", + "itemSeven": "Мобильное приложение", + "tooltipThree": "Гости имеют разрешение только на чтение опубликованного контента.", + "tooltipFour": "Гостевые сотрудники оплачиваются как одно рабочее место.", + "itemEight": "Ответы ИИ", + "tooltipEight": "Навсегда означает, что количество ответов никогда не сбрасывается." + }, + "freeLabels": { + "itemOne": "снимается за рабочее пространство", + "itemTwo": "3", + "itemFour": "0", + "itemFive": "5 ГБ", + "itemSix": "10 пожизненных", + "itemSeven": "да", + "itemEight": "1000 навсегда" + }, + "proLabels": { + "itemOne": "оплата за рабочее пространство", + "itemTwo": "до 10", + "itemFour": "10 гостей оплачиваются как одно место", + "itemFive": "неограничено", + "itemSix": "да", + "itemSeven": "да", + "itemEight": "10 000 ежемесячно" + }, + "paymentSuccess": { + "title": "Теперь Вы пользуетесь планом {}!", + "description": "Ваш платёж успешно обработан, и ваш план повышен до @:appName {}. Вы можете посмотреть информацию о своём плане на странице «План»." + }, + "downgradeDialog": { + "title": "Вы уверены, что хотите понизить свой план?", + "description": "Понижение Вашего плана вернёт Вас обратно к бесплатному плану. Участники могут потерять доступ к рабочим пространствам, и Вам может потребоваться освободить место, чтобы соответствовать ограничениям на хранилище бесплатного плана.", + "downgradeLabel": "Понизить план" + } + }, + "cancelSurveyDialog": { + "title": "Жаль, что вы уходите", + "description": "Нам жаль, что вы уходите. Нам будет приятно, если вы оставите отзыв и поможете улучшить @:appName. Ответьте, пожалуйста, на несколько вопросов.", + "commonOther": "Другое", + "otherHint": "Дайте ответ здесь", + "questionOne": { + "question": "Почему вы решили отменить подписку на @:appName Pro?", + "answerOne": "Высокая стоимость", + "answerTwo": "Функционал не оправдал ожиданий", + "answerThree": "Нашлась алтернатива лучше", + "answerFour": "Не использовал(а) его достаточно, чтобы оправдать стоимость", + "answerFive": "Технические проблемы" + }, + "questionTwo": { + "question": "Насколько вероятно, что вы купите подписку на @:appName Pro в будущем?", + "answerOne": "Очень вероятно", + "answerTwo": "Довольно вероятно", + "answerThree": "Не уверен(а)", + "answerFour": "Маловероятно", + "answerFive": "Очень маловероятно" + }, + "questionThree": { + "question": "Какой функционал Pro версии понравился вам больше всего?", + "answerOne": "Работа с другими пользователями", + "answerTwo": "Более длинная история версий", + "answerThree": "Неограниченный ИИ", + "answerFour": "Доступ к локальным ИИ-моделям" + }, + "questionFour": { + "question": "Как бы вы оценили ваши впечатления от @:appName в целом?", + "answerOne": "Отлично", + "answerTwo": "Хорошо", + "answerThree": "Средне", + "answerFour": "Ниже среднего", + "answerFive": "Неудовлетворительно" + } + }, + "common": { + "reset": "Сбросить" + }, "menu": { "appearance": "Внешний вид", "language": "Язык", @@ -285,27 +923,22 @@ "cloudURL": "Базовый URL", "invalidCloudURLScheme": "Неверный формат URL", "cloudServerType": "Тип облачного сервера", - "cloudServerTypeTip": "Обратите внимание, что после смены облачного сервера может произойти выход из текущего аккаунта", + "cloudServerTypeTip": "Обратите внимание, что после смены облачного сервера может произойти выход из текущего аккаунта.", "cloudLocal": "Локально", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "Supabase URL", - "cloudSupabaseUrlCanNotBeEmpty": "URL-адрес Supabase не может быть пустым.", - "cloudSupabaseAnonKey": "Анонимный ключ Supabase", - "cloudSupabaseAnonKeyCanNotBeEmpty": "Анонимный ключ не может быть пустым, если URL Supabase не пуст", - "cloudAppFlowy": "AppFlowy Cloud", - "cloudAppFlowySelfHost": "AppFlowy Cloud на своём сервере", + "cloudAppFlowy": "@:appName Cloud Бета", + "cloudAppFlowySelfHost": "@:appName Cloud на своём сервере", "appFlowyCloudUrlCanNotBeEmpty": "URL облака не может быть пустым.", "clickToCopy": "Нажмите, чтобы скопировать", - "selfHostStart": "Если у вас нет сервера, пожалуйста, обратитесь к", + "selfHostStart": "Если у Вас нет сервера, пожалуйста, обратитесь к", "selfHostContent": "документации", "selfHostEnd": "для получения инструкций по самостоятельному размещению собственного сервера", "cloudURLHint": "Введите базовый URL вашего сервера", "cloudWSURL": "URL вебсокета", "cloudWSURLHint": "Введите адрес вебсокета вашего сервера", "restartApp": "Перезапуск", - "restartAppTip": "Перезапустите приложение, чтобы изменения вступили в силу. Обратите внимание, что это может привести к выходу из текущего аккаунта", + "restartAppTip": "Перезапустите приложение, чтобы изменения вступили в силу. Обратите внимание, что это может привести к выходу из текущего аккаунта.", "changeServerTip": "После смены сервера необходимо нажать кнопку перезагрузки, чтобы изменения вступили в силу.", - "enableEncryptPrompt": "Активируйте шифрование для защиты ваших данных с этим секретом. Храните его безопасно; после включения, он не может быть отключён. В случае потери секрета ваши данные будут также потеряны. Нажмите, чтобы скопировать", + "enableEncryptPrompt": "Активировать шифрование для защиты ваших данных с этим секретом. Храните его безопасно; после включения, он не может быть отключён. В случае потери секрета ваши данные будут также потеряны. Нажмите, чтобы скопировать.", "inputEncryptPrompt": "Пожалуйста, введите ваш секрет шифрования для", "clickToCopySecret": "Нажмите, чтобы скопировать секрет", "configServerSetting": "Настройте параметры вашего сервера", @@ -313,27 +946,70 @@ "inputTextFieldHint": "Ваш секрет", "historicalUserList": "История входа пользователя", "historicalUserListTooltip": "В этом списке отображаются ваши анонимные аккаунты. Вы можете нажать на аккаунт, чтобы посмотреть данные. Анонимный аккаунт создаётся нажатием кнопки «Начать».", - "openHistoricalUser": "Нажмите, чтобы открыть анонимный аккаунт", - "customPathPrompt": "Хранение папки данных AppFlowy в папке с облачной синхронизацией, например на Google Диске, может представлять риск. Если база данных в этой папке будет доступна или изменена с нескольких мест одновременно, это может привести к конфликтам синхронизации и потенциальному повреждению данных", - "importAppFlowyData": "Импортировать данные из внешней папки AppFlowy", - "importingAppFlowyDataTip": "Выполняется импорт данных. Пожалуйста, не закрывайте приложение", - "importAppFlowyDataDescription": "Скопируйте данные из внешней папки данных AppFlowy и импортируйте их в текущую папку данных AppFlowy.", - "importSuccess": "Папка данных AppFlowy успешно импортирована", - "importFailed": "Не удалось импортировать папку данных AppFlowy", - "importGuide": "Для получения более подробной информации, пожалуйста, проверьте указанный документ.", - "supabaseSetting": "Настройка Supabase" + "openHistoricalUser": "Нажмите, чтобы открыть анонимный аккаунт.", + "customPathPrompt": "Хранение папки данных @:appName в папке с облачной синхронизацией, например на Google Диске, может представлять риск. Если база данных в этой папке будет доступна или изменена с нескольких мест одновременно, это может привести к конфликтам синхронизации и потенциальному повреждению данных", + "importAppFlowyData": "Импортировать данные из внешней папки @:appName", + "importingAppFlowyDataTip": "Выполняется импорт данных. Пожалуйста, не закрывайте приложение.", + "importAppFlowyDataDescription": "Скопируйте данные из внешней папки данных @:appName и импортируйте их в текущую папку данных @:appName.", + "importSuccess": "Папка данных @:appName успешно импортирована", + "importFailed": "Не удалось импортировать папку данных @:appName", + "importGuide": "Для получения более подробной информации, пожалуйста, проверьте указанный документ." }, "notifications": { "enableNotifications": { "label": "Включить уведомления", "hint": "Отключите, чтобы прекратить появление локальных уведомлений." + }, + "showNotificationsIcon": { + "label": "Показать иконку уведомлений", + "hint": "Выключите, чтобы скрыть иконку уведомления на боковой панели." + }, + "archiveNotifications": { + "allSuccess": "Все уведомления были добавлены в архив", + "success": "Уведомление было добавлено в архив" + }, + "markAsReadNotifications": { + "allSuccess": "Отмечено как прочитанное: все", + "success": "Отмечено как прочитанное" + }, + "action": { + "markAsRead": "Отметить как прочитанное", + "archive": "Архивировать" + }, + "settings": { + "settings": "Настройки", + "markAllAsRead": "Отметить все как прочитанные", + "archiveAll": "Архивировать все" + }, + "emptyInbox": { + "title": "Пусто!", + "description": "Ставьте напоминания и получайте уведомления." + }, + "emptyUnread": { + "title": "Нет непрочитанных напоминаний", + "description": "Все напоминания прочитаны!" + }, + "emptyArchived": { + "title": "Архив напоминаний пуст", + "description": "Архивированные напоминания будут здесь. " + }, + "tabs": { + "inbox": "Входящие", + "unread": "Непрочитано", + "archived": "Архивировано" + }, + "refreshSuccess": "Уведомления были обновлены", + "titles": { + "notifications": "Уведомления", + "reminder": "Напоминание" } }, "appearance": { "resetSetting": "Сбросить", "fontFamily": { "label": "Семейство шрифтов", - "search": "Поиск" + "search": "Поиск", + "defaultFont": "Системный" }, "themeMode": { "label": "Тема приложения", @@ -345,6 +1021,9 @@ "documentSettings": { "cursorColor": "Цвет курсора в документе", "selectionColor": "Цвет выделения в документе", + "pickColor": "Выбрать цвет", + "colorShade": "Цветовой оттенок", + "opacity": "Непрозрачность", "hexEmptyError": "HEX-код цвета не может быть пустым", "hexLengthError": "HEX-код цвета должен быть шестизначным", "hexInvalidError": "Неверный HEX-код", @@ -371,7 +1050,7 @@ "themeUpload": { "button": "Загрузить", "uploadTheme": "Загрузить тему", - "description": "Загрузите собственную тему AppFlowy, используя кнопку ниже.", + "description": "Загрузите собственную тему @:appName, используя кнопку ниже.", "loading": "Подождите, пока мы проверим и загрузим вашу тему...", "uploadSuccess": "Ваша тема была успешно загружена", "deletionFailure": "Не удалось удалить тему. Попробуйте удалить её вручную.", @@ -396,14 +1075,47 @@ "twentyFourHour": "24Ч" }, "showNamingDialogWhenCreatingPage": "Показывать диалоговое окно именования при создании страницы", - "enableRTLToolbarItems": "Включить режим панели слева-направо" + "enableRTLToolbarItems": "Включить режим панели слева-направо", + "members": { + "title": "Настройки участников", + "inviteMembers": "Пригласить участников", + "inviteHint": "Пригласить по эл. почте", + "sendInvite": "Отправить приглашение", + "copyInviteLink": "Скопировать ссылку-приглашение", + "label": "Участники", + "user": "Пользователь", + "role": "Роль", + "removeFromWorkspace": "Удалить из рабочего пространства", + "owner": "Владелец", + "guest": "Гость", + "member": "Участник", + "memberHintText": "Участник может читать и редактировать страницы.", + "guestHintText": "Гость может читать, реагировать, комментировать и редактировать определенные страницы с разрешением.", + "emailInvalidError": "Неверный адрес эл. почты, пожалуйста, проверьте и повторите попытку.", + "emailSent": "Письмо отправлено, пожалуйста, проверьте входящие.", + "members": "участники", + "membersCount": { + "zero": "{} участников", + "one": "{} участник", + "other": "{} участников" + }, + "memberLimitExceeded": "Вы достигли максимального количества участников, разрешенного для вашей учетной записи. Если вы хотите добавить дополнительных участников для продолжения вашей работы, отправьте запрос на Github.", + "failedToAddMember": "Не удалось добавить участника.", + "addMemberSuccess": "Участник успешно добавлен.", + "removeMember": "Удалить участника", + "areYouSureToRemoveMember": "Вы уверены, что хотите удалить этого участника?", + "inviteMemberSuccess": "Приглашение успешно отправлено.", + "failedToInviteMember": "Не удалось пригласить участника." + }, + "lightLabel": "Светлая", + "darkLabel": "Темная" }, "files": { "copy": "Копировать", "defaultLocation": "Путь до хранилища", "exportData": "Экспорт данных", "doubleTapToCopy": "Нажмите дважды, чтобы скопировать путь", - "restoreLocation": "Восстановить путь AppFlowy по умолчанию", + "restoreLocation": "Восстановить путь @:appName по умолчанию", "customizeLocation": "Выбрать другую папку", "restartApp": "Пожалуйста, перезапустите приложение, чтобы изменения вступили в силу.", "exportDatabase": "Экспорт базы данных", @@ -415,44 +1127,37 @@ "defineWhereYourDataIsStored": "Указать хранилище данных", "open": "Открыть", "openFolder": "Открыть существующую папку", - "openFolderDesc": "Чтение и запись в существующую папку AppFlowy ...", + "openFolderDesc": "Чтение и запись в существующую папку @:appName ...", "folderHintText": "имя папки", "location": "Создание новой папки", - "locationDesc": "Выбрать имя папки данных AppFlowy", + "locationDesc": "Выбрать имя папки данных @:appName", "browser": "Обзор", "create": "Создать", "set": "Установить", "folderPath": "Путь к вашей папке", "locationCannotBeEmpty": "Путь не может быть пустым", "pathCopiedSnackbar": "Путь скопирован в буфер обмена!", - "changeLocationTooltips": "Сменить местоположение", + "changeLocationTooltips": "Сменить каталог", "change": "Изменить", "openLocationTooltips": "Открыть другой каталог", "openCurrentDataFolder": "Открыть текущий каталог данных", - "recoverLocationTooltips": "Сбросить к местоположению по умолчанию", + "recoverLocationTooltips": "Сбросить к каталогу по умолчанию", "exportFileSuccess": "Экспорт завершён!", "exportFileFail": "Не удалось экспортировать!", - "export": "Экспорт" + "export": "Экспорт", + "clearCache": "Очистить кэш", + "clearCacheDesc": "Если у вас возникли проблемы с загрузкой изображений или некорректным отображением шрифтов, попробуйте очистить кеш. Это действие не приведет к удалению ваших пользовательских данных.", + "areYouSureToClearCache": "Вы уверены, что хотите очистить кеш?", + "clearCacheSuccess": "Кэш успешно очищен!" }, "user": { "name": "Имя", "email": "Электронная почта", "tooltipSelectIcon": "Выберите иконку", "selectAnIcon": "Выбрать иконку", - "pleaseInputYourOpenAIKey": "Пожалуйста, введите токен OpenAI", - "pleaseInputYourStabilityAIKey": "Пожалуйста, введите свой токен Stability AI", - "clickToLogout": "Нажмите, чтобы выйти из текущего аккаунта" - }, - "shortcuts": { - "shortcutsLabel": "Горячие клавиши", - "command": "Команда", - "keyBinding": "Привязка клавиш", - "addNewCommand": "Добавить новую команду", - "updateShortcutStep": "Нажмите нужную комбинацию клавиш и нажмите Enter.", - "shortcutIsAlreadyUsed": "Это сочетание клавиш уже используется для: {conflict}", - "resetToDefault": "Сбросить к стандартным сочетаниям клавиш", - "couldNotLoadErrorMsg": "Не удалось загрузить горячие клавиши, попробуйте снова", - "couldNotSaveErrorMsg": "Не удалось сохранить горячие клавиши, попробуйте снова" + "pleaseInputYourOpenAIKey": "Пожалуйста, введите токен AI", + "clickToLogout": "Нажмите, чтобы выйти из текущего аккаунта", + "pleaseInputYourStabilityAIKey": "Пожалуйста, введите свой токен Stability AI" }, "mobile": { "personalInfo": "Личная информация", @@ -470,11 +1175,22 @@ "selectLayout": "Выбрать раскладку", "selectStartingDay": "Выбрать день начала", "version": "Версия" + }, + "shortcuts": { + "shortcutsLabel": "Горячие клавиши", + "command": "Команда", + "keyBinding": "Привязка клавиш", + "addNewCommand": "Добавить новую команду", + "updateShortcutStep": "Нажмите нужную комбинацию клавиш и нажмите Enter.", + "shortcutIsAlreadyUsed": "Это сочетание клавиш уже используется для: {conflict}", + "resetToDefault": "Сбросить к стандартным сочетаниям клавиш", + "couldNotLoadErrorMsg": "Не удалось загрузить горячие клавиши, попробуйте снова", + "couldNotSaveErrorMsg": "Не удалось сохранить горячие клавиши, попробуйте снова" } }, "grid": { - "deleteView": "Вы уверены, что хотите удалить это представление?", - "createView": "Новое представление", + "deleteView": "Вы уверены, что хотите удалить этот вид?", + "createView": "Новый", "title": { "placeholder": "Без названия" }, @@ -491,14 +1207,19 @@ "typeAValue": "Введите значение...", "layout": "Вид", "databaseLayout": "Вид базы данных", - "editView": "Редактировать представление", + "viewList": { + "zero": "0 просмотров", + "one": "{count} просмотр", + "other": "{count} просмотров" + }, + "editView": "Редактировать вид", "boardSettings": "Настройки доски", "calendarSettings": "Настройки календаря", - "createView": "Новое представление", - "duplicateView": "Дублировать представление", - "deleteView": "Удалить представление", + "createView": "Новый вид", + "duplicateView": "Дублировать вид", + "deleteView": "Удалить вид", "numberOfVisibleFields": "{} показано", - "viewList": "Представление базы данных" + "Properties": "Свойства" }, "textFilter": { "contains": "Содержит", @@ -571,8 +1292,10 @@ "insertRight": "Вставить справа", "duplicate": "Дублировать", "delete": "Удалить", + "wrapCellContent": "Обернуть текст", + "clear": "Очистить ячейки", "textFieldName": "Текст", - "checkboxFieldName": "Чекбокс", + "checkboxFieldName": "Флажок", "dateFieldName": "Дата", "updatedAtFieldName": "Последнее изменение", "createdAtFieldName": "Дата создания", @@ -582,6 +1305,10 @@ "urlFieldName": "URL", "checklistFieldName": "To-Do лист", "relationFieldName": "Связь", + "summaryFieldName": "Обзор ИИ", + "timeFieldName": "Время", + "translateFieldName": "ИИ-переводчик", + "translateTo": "Перевести на", "numberFormat": "Формат числа", "dateFormat": "Формат даты", "includeTime": "Время", @@ -611,6 +1338,7 @@ "editProperty": "Редактировать свойство", "newProperty": "Новое свойство", "deleteFieldPromptMessage": "Вы уверены, что хотите удалить?", + "clearFieldPromptMessage": "Вы уверены? Все ячейки в этом столбце будут очищены.", "newColumn": "Новый столбец", "format": "Формат", "reminderOnDateTooltip": "В этой ячейке есть запланированное напоминание", @@ -628,17 +1356,20 @@ "one": "Скрыть {} скрытое поле", "many": "Скрыто {} скрытых полей", "other": "Скрыто {} скрытых поля" - } + }, + "openAsFullPage": "Открыть на всю страницу", + "moreRowActions": "Доп. действия со строками" }, "sort": { "ascending": "По возрастанию", "descending": "По убыванию", "by": "По", "empty": "Нет активных сортировок", - "cannotFindCreatableField": "Не могу найти подходящее поле для сортировки", + "cannotFindCreatableField": "Не могу найти подходящее поле для сортировки.", "deleteAllSorts": "Удалить все сортировки", "addSort": "Добавить сортировку", - "removeSorting": "Убрать сортировку?" + "removeSorting": "Убрать сортировку?", + "fieldInUse": "Вы уже сортируете по этому полю" }, "row": { "duplicate": "Дублировать", @@ -651,9 +1382,12 @@ "action": "Действия", "add": "Нажмите, чтобы добавить ниже", "drag": "Перетащите для перемещения", + "deleteRowPrompt": "Вы уверены, что хотите удалить эту строку? Это действие нельзя отменить.", + "deleteCardPrompt": "Вы уверены, что хотите удалить эту карту? Это действие нельзя отменить.", "dragAndClick": "Перетащите, чтобы переместить; нажмите, чтобы открыть меню", "insertRecordAbove": "Вставить запись выше", - "insertRecordBelow": "Вставить запись ниже" + "insertRecordBelow": "Вставить запись ниже", + "noContent": "Без содержания" }, "selectOption": { "create": "Создать", @@ -685,16 +1419,21 @@ }, "url": { "launch": "Открыть в браузере", - "copy": "Скопировать URL" + "copy": "Скопировать URL", + "textFieldHint": "Введите URL-адрес" }, "relation": { "relatedDatabasePlaceLabel": "Связанная база данных", "relatedDatabasePlaceholder": "Пусто", "inRelatedDatabase": "В", - "emptySearchResult": "записей не найдено" + "rowSearchTextFieldPlaceholder": "Поиск", + "noDatabaseSelected": "База данных не выбрана. Сначала выберите одну из списка ниже:", + "emptySearchResult": "записей не найдено", + "linkedRowListLabel": "{count} связанных строк", + "unlinkedRowListLabel": "Связать ещё одну строку" }, "menuName": "Сетка", - "referencedGridPrefix": "Просмотр", + "referencedGridPrefix": "Вид", "calculate": "Рассчитать", "calculationTypeLabel": { "none": "Пусто", @@ -702,13 +1441,18 @@ "max": "Максимум", "median": "Медиана", "min": "Минимум", - "sum": "Сумма" + "sum": "Сумма", + "count": "Кол-во", + "countEmpty": "Кол-во пустое", + "countEmptyShort": "ПУСТО", + "countNonEmpty": "Кол-во не пустое", + "countNonEmptyShort": "ЗАПОЛНЕНО" } }, "document": { "menuName": "Документ", "date": { - "timeHintTextInTwelveHour": "01:00 PM", + "timeHintTextInTwelveHour": "01:00 ппд.", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { @@ -717,7 +1461,7 @@ "createANewBoard": "Создать доску" }, "grid": { - "selectAGridToLinkTo": "Выбрать сетку", + "selectAGridToLinkTo": "Выбрать таблицу", "createANewGrid": "Создать сетку" }, "calendar": { @@ -737,15 +1481,15 @@ "referencedGrid": "Связанные сетки", "referencedCalendar": "Связанные календари", "referencedDocument": "Связанные документы", - "autoGeneratorMenuItemName": "OpenAI Генератор", - "autoGeneratorTitleName": "OpenAI: попросить ИИ написать что угодно...", + "autoGeneratorMenuItemName": "AI Генератор", + "autoGeneratorTitleName": "AI: попросить ИИ написать что угодно...", "autoGeneratorLearnMore": "Узнать больше", "autoGeneratorGenerate": "Генерировать", - "autoGeneratorHintText": "Спросить OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "Не могу получить токен OpenAI", + "autoGeneratorHintText": "Спросить AI ...", + "autoGeneratorCantGetOpenAIKey": "Не могу получить токен AI", "autoGeneratorRewrite": "Переписать", "smartEdit": "ИИ-ассистенты", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Исправить правописание", "warning": "⚠️ Ответы ИИ могут быть неправильными или неточными.", "smartEditSummarize": "Обобщить", @@ -754,9 +1498,11 @@ "smartEditCouldNotFetchResult": "Не могу получить ответ от OpenAI", "smartEditCouldNotFetchKey": "Не могу получить токен OpenAI", "smartEditDisabled": "OpenAI", + "appflowyAIEditDisabled": "Войдите, чтобы включить функции ИИ.", "discardResponse": "Хотите убрать ответы ИИ?", "createInlineMathEquation": "Создать уравнение", "fonts": "Шрифты", + "insertDate": "Вставить дату", "emoji": "Эмодзи", "toggleList": "Выпадающий список", "quoteList": "Список цитат", @@ -785,7 +1531,7 @@ "couldNotFetchImage": "Не удалось получить изображение", "imageSavingFailed": "Не удалось сохранить изображение", "addIcon": "Добавить иконку", - "changeIcon": "Изменить значок", + "changeIcon": "Изменить иконку", "coverRemoveAlert": "Изображение будет удалено с обложки", "alertDialogConfirmation": "Вы хотите продолжить?" }, @@ -811,9 +1557,13 @@ "depth": "Глубина" }, "image": { - "copiedToPasteBoard": "Ссылка на изображение скопирована в буфер обмена", "addAnImage": "Добавить изображение", - "imageUploadFailed": "Не удалось загрузить изображение." + "copiedToPasteBoard": "Ссылка на изображение скопирована в буфер обмена", + "imageUploadFailed": "Не удалось загрузить изображение.", + "errorCode": "Код ошибки" + }, + "math": { + "copiedToPasteBoard": "Математическое выражение скопировано в буфер обмена." }, "urlPreview": { "copiedToPasteBoard": "Ссылка скопирована в буфер обмена", @@ -845,7 +1595,17 @@ "newDatabase": "Новая база данных", "linkToDatabase": "Связать базу данных" }, - "date": "Дата" + "date": "Дата", + "video": { + "label": "Видео", + "emptyLabel": "Добавить видео", + "placeholder": "Вставьте ссылку на видео", + "copiedToPasteBoard": "Ссылка на видео скопирована в буфер обмена.", + "insertVideo": "Добавить видео", + "invalidVideoUrl": "Исходный URL-адрес пока не поддерживается.", + "invalidVideoUrlYouTube": "YouTube пока не поддерживается.", + "supportedFormats": "Поддерживаемые форматы: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264." + } }, "outlineBlock": { "placeholder": "Оглавление" @@ -867,8 +1627,8 @@ "placeholder": "Введите URL-адрес изображения" }, "ai": { - "label": "Сгенерировать изображение через OpenAI", - "placeholder": "Пожалуйста, введите запрос для OpenAI чтобы сгенерировать изображение" + "label": "Сгенерировать изображение через AI", + "placeholder": "Пожалуйста, введите запрос для AI чтобы сгенерировать изображение" }, "stability_ai": { "label": "Сгенерировать изображение через Stability AI", @@ -878,8 +1638,9 @@ "error": { "invalidImage": "Недопустимое изображение", "invalidImageSize": "Размер изображения должен быть менее 5 МБ.", - "invalidImageFormat": "Формат изображения не поддерживается. Поддерживаемые форматы: JPEG, PNG, GIF, SVG", - "invalidImageUrl": "Недопустимый URL-адрес изображения" + "invalidImageFormat": "Формат изображения не поддерживается. Поддерживаемые форматы: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "Недопустимый URL-адрес изображения", + "noImage": "Данный файл или каталог отсутствует" }, "embedLink": { "label": "Вставить ссылку", @@ -889,21 +1650,25 @@ "label": "Unsplash" }, "searchForAnImage": "Поиск изображения", - "pleaseInputYourOpenAIKey": "пожалуйста, введите свой токен OpenAI на странице настроек", - "pleaseInputYourStabilityAIKey": "пожалуйста, введите свой токен Stability AI на странице настроек", + "pleaseInputYourOpenAIKey": "пожалуйста, введите свой токен AI на странице настроек", "saveImageToGallery": "Сохранить изображение", "failedToAddImageToGallery": "Ошибка добавления изображения в галерею", "successToAddImageToGallery": "Изображение успешно добавлено", "unableToLoadImage": "Ошибка загрузки изображения", "maximumImageSize": "Максимальный поддерживаемый размер загружаемого изображения — 10 МБ.", "uploadImageErrorImageSizeTooBig": "Размер изображения должен быть меньше 10 МБ.", - "imageIsUploading": "Изображение загружается" + "imageIsUploading": "Изображение загружается", + "pleaseInputYourStabilityAIKey": "пожалуйста, введите свой токен Stability AI на странице настроек" }, "codeBlock": { "language": { "label": "Язык", - "placeholder": "Выберите язык" - } + "placeholder": "Выберите язык", + "auto": "Авто" + }, + "copyTooltip": "Скопировать содержимое блока кода", + "searchLanguageHint": "Поиск языка", + "codeCopiedSnackbar": "Код скопирован в буфер обмена!" }, "inlineLink": { "placeholder": "Вставьте или введите ссылку", @@ -926,14 +1691,20 @@ "tooltip": "Нажмите, чтобы открыть страницу" }, "deleted": "Удалено", - "deletedContent": "Этот контент не существует или был удален" + "deletedContent": "Этот контент не существует или был удалён." }, "toolbar": { "resetToDefaultFont": "Восстановить по умолчанию" }, "errorBlock": { "theBlockIsNotSupported": "Текущая версия не поддерживает этот блок.", + "clickToCopyTheBlockContent": "Нажмите, чтобы скопировать содержимое блока.", "blockContentHasBeenCopied": "Содержимое блока скопировано." + }, + "mobilePageSelector": { + "title": "Выбрать страницу", + "failedToLoad": "Не удалось загрузить список страниц.", + "noPagesFound": "Страницы не найдены." } }, "board": { @@ -942,7 +1713,7 @@ "renameGroupTooltip": "Нажмите, чтобы переименовать группу", "createNewColumn": "Создать новую группу", "addToColumnTopTooltip": "Добавить новую карту сверху", - "addToColumnBottomTooltip": "Add a new card at the bottom", + "addToColumnBottomTooltip": "Добавить новую карту в самом низу", "renameColumn": "Переименовать", "hideColumn": "Скрыть", "newGroup": "Новая группа", @@ -968,14 +1739,27 @@ "ungroupedButtonTooltip": "Содержит карточки, которые не принадлежат ни к одной группе.", "ungroupedItemsTitle": "Нажмите, чтобы добавить на доску", "groupBy": "Сгруппировать по", - "referencedBoardPrefix": "Просмотр", + "groupCondition": "Групповое состояние", + "referencedBoardPrefix": "Вид", "notesTooltip": "Заметки", "mobile": { "editURL": "Редактировать URL", "showGroup": "Показать группу", "showGroupContent": "Точно показать эту группу на доске?", "failedToLoad": "Ошибка загрузки доски" - } + }, + "dateCondition": { + "weekOf": "Неделя {} - {}", + "today": "Сегодня", + "yesterday": "Вчера", + "tomorrow": "Завтра", + "lastSevenDays": "Последние 7 дней", + "nextSevenDays": "Следующие 7 дней", + "lastThirtyDays": "Последние 30 дней", + "nextThirtyDays": "Следующие 30 дней" + }, + "noGroup": "Нет группировки по свойствам", + "noGroupDesc": "Для просмотра видов досок требуется свойство для группировки." }, "calendar": { "menuName": "Календарь", @@ -985,10 +1769,16 @@ "today": "Сегодня", "jumpToday": "Перейти к сегодняшнему дню", "previousMonth": "Предыдущий месяц", - "nextMonth": "Следующий месяц" + "nextMonth": "Следующий месяц", + "views": { + "day": "День", + "week": "Неделя", + "month": "Месяц", + "year": "Год" + } }, "mobileEventScreen": { - "emptyTitle": "Мероприятий пока нет", + "emptyTitle": "Событий пока нет", "emptyBody": "Нажмите кнопку «плюс», чтобы создать событие в этот день." }, "settings": { @@ -999,16 +1789,18 @@ "changeLayoutDateField": "Изменить отображение поля", "noDateTitle": "Без даты", "noDateHint": { - "zero": "Здесь будут отображаться незапланированные мероприятия.", + "zero": "Здесь будут отображаться незапланированные события.", "one": "{count} незапланированное событие", - "other": "{count} незапланированных события" + "other": "{count} незапланированных событий" }, - "unscheduledEventsTitle": "Unscheduled events", + "unscheduledEventsTitle": "Незапланированные мероприятия", "clickToAdd": "Нажмите, чтобы добавить в календарь", - "name": "Макет календаря" + "name": "Макет календаря", + "clickToOpen": "Нажмите, чтобы открыть запись." }, "referencedCalendarPrefix": "Вид", - "quickJumpYear": "Перейти к" + "quickJumpYear": "Перейти к", + "duplicateEvent": "Дублировать событие" }, "errorDialog": { "title": "Ошибка приложения", @@ -1078,6 +1870,7 @@ }, "inlineActions": { "noResults": "Нет результатов", + "recentPages": "Последние страницы", "pageReference": "Ссылка на страницу", "docReference": "Ссылка на документ", "boardReference": "Ссылка на доску", @@ -1107,7 +1900,7 @@ "thirtyMinsBefore": "за 30 минут до", "oneHourBefore": "за 1 час до", "twoHoursBefore": "за 2 часа до", - "onDayOfEvent": "В день мероприятия", + "onDayOfEvent": "В день события", "oneDayBefore": "за 1 день до", "twoDaysBefore": "за 2 дня до", "oneWeekBefore": "за 1 неделю до", @@ -1164,21 +1957,22 @@ }, "error": { "weAreSorry": "Мы сожалеем", - "loadingViewError": "У нас возникли проблемы при загрузке этого представления. Пожалуйста, проверьте ваше интернет-соединение, обновите приложение, и не стесняйтесь обратиться к команде, если проблема не исчезнет." + "loadingViewError": "У нас возникли проблемы при загрузке этого вида. Пожалуйста, проверьте ваше интернет-соединение, обновите приложение, и не стесняйтесь обратиться к команде, если проблема не исчезнет." }, "editor": { "bold": "Жирный", "bulletedList": "Маркированный список", "bulletedListShortForm": "Маркированный", - "checkbox": "Чекбокс", + "checkbox": "Флажок", "embedCode": "Встроенный код", - "heading1": "H1", - "heading2": "H2", - "heading3": "H3", + "heading1": "Заголовок 1 уровня", + "heading2": "Заголовок 2 уровня", + "heading3": "Заголовок 3 уровня", "highlight": "Выделить", "color": "Цвет", "image": "Изображение", "date": "Дата", + "page": "Страница", "italic": "Курсив", "link": "Ссылка", "numberedList": "Нумерованный список", @@ -1207,6 +2001,8 @@ "backgroundColorPurple": "Фиолетовый фон", "backgroundColorPink": "Розовый фон", "backgroundColorRed": "Красный фон", + "backgroundColorLime": "Лаймовый фон", + "backgroundColorAqua": "Аква фон", "done": "Готово", "cancel": "Отмена", "tint1": "Оттенок 1", @@ -1227,7 +2023,7 @@ "lightLightTint7": "Зелёный", "lightLightTint8": "Бирюзовый", "lightLightTint9": "Синий", - "urlHint": "URL", + "urlHint": "Ссылка", "mobileHeading1": "Заголовок 1", "mobileHeading2": "Заголовок 2", "mobileHeading3": "Заголовок 3", @@ -1254,6 +2050,8 @@ "copy": "Копировать", "paste": "Вставить", "find": "Найти", + "select": "Выбрать", + "selectAll": "Выбрать всё", "previousMatch": "Предыдущее совпадение", "nextMatch": "Следующее совпадение", "closeFind": "Закрыть", @@ -1289,7 +2087,9 @@ }, "favorite": { "noFavorite": "Нет избранных страниц", - "noFavoriteHintText": "Проведите по странице влево, чтобы добавить ее в избранное." + "noFavoriteHintText": "Проведите по странице влево, чтобы добавить ее в избранное.", + "removeFromSidebar": "Удалить из боковой панели", + "addToSidebar": "Закрепить на боковой панели" }, "cardDetails": { "notesPlaceholder": "Введите '/', чтобы вставить блок, или начните писать" @@ -1310,5 +2110,162 @@ "addField": "Добавить поле", "userIcon": "Пользовательская иконка" }, - "noLogFiles": "Нет файлов журналов" + "noLogFiles": "Нет файлов журналов", + "newSettings": { + "myAccount": { + "title": "Мой аккаунт", + "subtitle": "Настройте свой профиль, управляйте безопасностью аккаунта, открывайте ключи ИИ или войдите в свой аккаунт.", + "profileLabel": "Имя аккаунта и изображение профиля", + "profileNamePlaceholder": "Введите ваше имя", + "accountSecurity": "Безопасность аккаунта", + "2FA": "Двухэтапная аутентификация", + "aiKeys": "Ключи ИИ", + "accountLogin": "Войти в аккаунт", + "updateNameError": "Не удалось обновить имя", + "updateIconError": "Не удалось обновить иконку", + "deleteAccount": { + "title": "Удалить аккаунт", + "subtitle": "Удалить навсегда ваш аккаунт и все ваши данные.", + "deleteMyAccount": "Удалить мой аккаунт", + "dialogTitle": "Удалить аккаунт", + "dialogContent1": "Вы уверены, что хотите навсегда удалить свой аккаунт?", + "dialogContent2": "Это действие невозможно отменить. Оно приведет к закрытию доступа ко всем рабочим пространствам, удалению вашего аккаунта, включая личные рабочие пространства, а также к удалению вас из всех общих рабочих пространств." + } + }, + "workplace": { + "name": "Рабочее место", + "title": "Настройки рабочего места", + "subtitle": "Настройте внешний вид рабочего пространства, тему, шрифт, макет текста, дату, время и язык.", + "workplaceName": "Название рабочего места", + "workplaceNamePlaceholder": "Введите название рабочего места", + "workplaceIcon": "Иконка рабочего места", + "workplaceIconSubtitle": "Загрузите изображение или используйте эмодзи для своего рабочего пространства. Иконка будет отображаться на боковой панели и в уведомлениях.", + "renameError": "Не удалось переименовать рабочее место.", + "updateIconError": "Не удалось обновить иконку", + "chooseAnIcon": "Выберите иконку", + "appearance": { + "name": "Внешний вид", + "themeMode": { + "auto": "Авто", + "light": "Светлый", + "dark": "Тёмный" + }, + "language": "Язык" + } + }, + "syncState": { + "syncing": "Синхронизация", + "synced": "Синхронизировано", + "noNetworkConnected": "Нет подключения к сети" + } + }, + "pageStyle": { + "title": "Стиль страницы", + "layout": "Оформление", + "coverImage": "Обложка", + "pageIcon": "Иконка страницы", + "colors": "Цвета", + "gradient": "Градиент", + "backgroundImage": "Изображение на заднем плане", + "presets": "Пресеты", + "photo": "Фото", + "unsplash": "Unsplash", + "pageCover": "Обложка страницы", + "none": "Ничего", + "openSettings": "Открыть настройки", + "photoPermissionTitle": "@:appName хочет получить доступ к вашей библиотеке фотографий", + "photoPermissionDescription": "Разрешите доступ к библиотеке фотографий для загрузки изображений.", + "doNotAllow": "Не разрешать", + "image": "Изображение" + }, + "commandPalette": { + "placeholder": "Нажмите для поиска...", + "bestMatches": "Лучшие соответствия", + "recentHistory": "Недавняя история", + "navigateHint": "для навигации", + "loadingTooltip": "Мы ищем результаты...", + "betaLabel": "БЕТА", + "betaTooltip": "В настоящее время мы поддерживаем только поиск страниц и содержимого в документах.", + "fromTrashHint": "Из корзины", + "noResultsHint": "Мы не нашли то, что Вы ищете, попробуйте поискать по другому термину.", + "clearSearchTooltip": "Очистить поле поиска" + }, + "space": { + "delete": "Удалить", + "deleteConfirmation": "Удалить: ", + "deleteConfirmationDescription": "Все страницы в этом пространстве будут удалены и перемещены в корзину, а все опубликованные страницы будут отменены.", + "rename": "Переименовать пространство", + "changeIcon": "Изменить иконку", + "manage": "Управление пространством", + "addNewSpace": "Создать пространство", + "collapseAllSubPages": "Свернуть все подстраницы", + "createNewSpace": "Создать новое пространство", + "createSpaceDescription": "Создайте несколько общедоступных и личных пространств, чтобы лучше организовать свою работу.", + "spaceName": "Название пространства", + "permission": "Разрешение", + "publicPermission": "Общедоступное", + "publicPermissionDescription": "Все участники рабочего пространства с полным доступом", + "privatePermission": "Личное", + "privatePermissionDescription": "Только Вы можете получить доступ к этому пространству", + "spaceIconBackground": "Фоновый цвет", + "spaceIcon": "Иконка", + "dangerZone": "Опасная зона", + "unableToDeleteLastSpace": "Невозможно удалить последнее пространство.", + "unableToDeleteSpaceNotCreatedByYou": "Невозможно удалить пространства, созданные другими.", + "enableSpacesForYourWorkspace": "Включить пространства для вашего рабочего пространства", + "title": "Пространства", + "defaultSpaceName": "Общее", + "upgradeSpaceTitle": "Включить пространства", + "upgradeSpaceDescription": "Создайте несколько общедоступных и личных пространств, чтобы лучше организовать свое рабочее пространство.", + "upgrade": "Обновление", + "upgradeYourSpace": "Создать несколько пространств", + "quicklySwitch": "Быстро переключиться на следующее пространство", + "duplicate": "Дублировать пространство", + "movePageToSpace": "Переместить страницу в пространство", + "switchSpace": "Переключить пространство" + }, + "publish": { + "hasNotBeenPublished": "Эта страница ещё не опубликована.", + "reportPage": "Донести на страницу", + "databaseHasNotBeenPublished": "Публикация базы данных пока не поддерживается.", + "createdWith": "Создано с", + "downloadApp": "Скачать AppFlowy", + "copy": { + "codeBlock": "Содержимое блока кода скопировано в буфер обмена.", + "imageBlock": "Ссылка на изображение скопирована в буфер обмена.", + "mathBlock": "Математическое выражение скопировано в буфер обмена." + }, + "containsPublishedPage": "Эта страница содержит одну или несколько опубликованных страниц. Если вы продолжите, они будут отменены из публикации. Вы хотите продолжить удаление?", + "publishSuccessfully": "Опубликовано успешно.", + "unpublishSuccessfully": "Публикация успешно отменена.", + "publishFailed": "Не удалось опубликовать.", + "unpublishFailed": "Не удалось отменить публикацию.", + "noAccessToVisit": "Нет доступа к этой странице...", + "createWithAppFlowy": "Создать веб-сайт с AppFlowy", + "fastWithAI": "Быстро и легко с ИИ.", + "tryItNow": "Попробуй сейчас" + }, + "web": { + "continue": "Продолжить", + "or": "или", + "continueWithGoogle": "Продолжить с Google", + "continueWithGithub": "Продолжить с GitHub", + "continueWithDiscord": "Продолжить с Discord", + "signInAgreement": "Нажимая «Продолжить» выше, вы подтверждаете, что\nвы прочитали, поняли и согласились с\nAppFlowy", + "and": "и", + "termOfUse": "Условия", + "privacyPolicy": "Политика конфиденциальности", + "signInError": "Ошибка входа", + "login": "Зарегистрироваться или войти" + }, + "ai": { + "limitReachedAction": { + "upgrade": "улучшить", + "proPlan": "план Pro", + "aiAddon": "Дополнение ИИ" + }, + "editing": "Редактирование", + "analyzing": "Анализ", + "more": "Более" + } } diff --git a/frontend/resources/translations/sv-SE.json b/frontend/resources/translations/sv-SE.json index 1d125b1402..3210aa1f15 100644 --- a/frontend/resources/translations/sv-SE.json +++ b/frontend/resources/translations/sv-SE.json @@ -107,14 +107,14 @@ "questionBubble": { "shortcuts": "Genvägar", "whatsNew": "Vad nytt?", - "help": "Hjälp & Support", "markdown": "Prissänkning", "debug": { "name": "Felsökningsinfo", "success": "Kopierade felsökningsinfo till urklipp!", "fail": "Kunde inte kopiera felsökningsinfo till urklipp" }, - "feedback": "Återkoppling" + "feedback": "Återkoppling", + "help": "Hjälp & Support" }, "menuAppHeader": { "addPageTooltip": "Lägg till en underliggande sida", @@ -247,11 +247,6 @@ "cloudServerType": "Molnserver", "cloudServerTypeTip": "Observera att det kan logga ut ditt nuvarande konto efter att ha bytt molnserver", "cloudLocal": "Lokal", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "Supabase URL", - "cloudSupabaseUrlCanNotBeEmpty": "Webbadressen för supabase kan inte vara tom", - "cloudSupabaseAnonKey": "Supabase anonym nyckel", - "cloudSupabaseAnonKeyCanNotBeEmpty": "Anon-nyckeln kan inte vara tom", "appFlowyCloudUrlCanNotBeEmpty": "Molnets webbadress får inte vara tom", "clickToCopy": "Klicka för att kopiera", "selfHostStart": "Om du inte har en server, vänligen se", @@ -272,14 +267,13 @@ "historicalUserList": "Användarinloggningshistorik", "historicalUserListTooltip": "Den här listan visar dina anonyma konton. Du kan klicka på ett konto för att se dess detaljer. Anonyma konton skapas genom att klicka på knappen \"Kom igång\".", "openHistoricalUser": "Klicka för att öppna det anonyma kontot", - "customPathPrompt": "Att lagra AppFlowy-datamappen i en molnsynkroniserad mapp som Google Drive kan innebära risker. Om databasen i den här mappen nås eller ändras från flera platser samtidigt, kan det resultera i synkroniseringskonflikter och potentiell datakorruption", - "importAppFlowyData": "Importera data från extern AppFlowy-mapp", + "customPathPrompt": "Att lagra @:appName-datamappen i en molnsynkroniserad mapp som Google Drive kan innebära risker. Om databasen i den här mappen nås eller ändras från flera platser samtidigt, kan det resultera i synkroniseringskonflikter och potentiell datakorruption", + "importAppFlowyData": "Importera data från extern @:appName-mapp", "importingAppFlowyDataTip": "Dataimport pågår. Stäng inte appen", - "importAppFlowyDataDescription": "Kopiera data från en extern AppFlowy-datamapp och importera den till den aktuella AppFlowy-datamappen", - "importSuccess": "AppFlowy-datamappen har importerats", - "importFailed": "Det gick inte att importera AppFlowy-datamappen", - "importGuide": "För ytterligare information, snälla se det refererade dokumentet", - "supabaseSetting": "Supabase-inställning" + "importAppFlowyDataDescription": "Kopiera data från en extern @:appName-datamapp och importera den till den aktuella @:appName-datamappen", + "importSuccess": "@:appName-datamappen har importerats", + "importFailed": "Det gick inte att importera @:appName-datamappen", + "importGuide": "För ytterligare information, snälla se det refererade dokumentet" }, "appearance": { "fontFamily": { @@ -294,7 +288,7 @@ }, "themeUpload": { "button": "Ladda upp", - "description": "Ladda upp ditt eget AppFlowy-tema med knappen nedan.", + "description": "Ladda upp ditt eget @:appName-tema med knappen nedan.", "loading": "Vänta medan vi validerar och laddar upp ditt tema...", "uploadSuccess": "Ditt tema laddades upp", "deletionFailure": "Det gick inte att ta bort temat. Försök att radera det manuellt.", @@ -311,7 +305,7 @@ "defaultLocation": "Läs filer och datalagringsplats", "exportData": "Exportera dina data", "doubleTapToCopy": "Dubbeltryck för att kopiera sökvägen", - "restoreLocation": "Återställ till AppFlowy standardsökväg", + "restoreLocation": "Återställ till @:appName standardsökväg", "customizeLocation": "Öppna en annan mapp", "restartApp": "Starta om appen för att ändringarna ska träda i kraft.", "exportDatabase": "Exportera databas", @@ -323,10 +317,10 @@ "defineWhereYourDataIsStored": "Definiera var din data lagras", "open": "Öppen", "openFolder": "Öppna en befintlig mapp", - "openFolderDesc": "Läs och skriv det till din befintliga AppFlowy-mapp", + "openFolderDesc": "Läs och skriv det till din befintliga @:appName-mapp", "folderHintText": "mappnamn", "location": "Skapar en ny mapp", - "locationDesc": "Välj ett namn för din AppFlowy-datamapp", + "locationDesc": "Välj ett namn för din @:appName-datamapp", "browser": "Bläddra", "create": "Skapa", "set": "Uppsättning", @@ -337,7 +331,7 @@ "change": "Förändra", "openLocationTooltips": "Öppna en annan datakatalog", "openCurrentDataFolder": "Öppna aktuell datakatalog", - "recoverLocationTooltips": "Återställ till AppFlowys standarddatakatalog", + "recoverLocationTooltips": "Återställ till @:appNames standarddatakatalog", "exportFileSuccess": "Exporterade filen framgångsrikt!", "exportFileFail": "Export av fil misslyckades!", "export": "Exportera" @@ -345,7 +339,7 @@ "user": { "name": "namn", "selectAnIcon": "Välj en ikon", - "pleaseInputYourOpenAIKey": "vänligen ange din OpenAI-nyckel" + "pleaseInputYourOpenAIKey": "vänligen ange din AI-nyckel" } }, "grid": { @@ -501,23 +495,23 @@ "referencedBoard": "Refererad tavla", "referencedGrid": "Refererade tabell", "referencedCalendar": "Refererad kalender", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Be AI skriva vad som helst...", + "autoGeneratorMenuItemName": "AI Writer", + "autoGeneratorTitleName": "AI: Be AI skriva vad som helst...", "autoGeneratorLearnMore": "Läs mer", "autoGeneratorGenerate": "Generera", - "autoGeneratorHintText": "Fråga OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Kan inte hämta OpenAI-nyckeln", + "autoGeneratorHintText": "Fråga AI...", + "autoGeneratorCantGetOpenAIKey": "Kan inte hämta AI-nyckeln", "autoGeneratorRewrite": "Skriva om", "smartEdit": "AI-assistenter", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "Fixa stavningen", "warning": "⚠️ AI-svar kan vara felaktiga eller vilseledande.", "smartEditSummarize": "Sammanfatta", "smartEditImproveWriting": "Förbättra skrivandet", "smartEditMakeLonger": "Gör längre", - "smartEditCouldNotFetchResult": "Det gick inte att hämta resultatet från OpenAI", - "smartEditCouldNotFetchKey": "Det gick inte att hämta OpenAI-nyckeln", - "smartEditDisabled": "Anslut OpenAI i Inställningar", + "smartEditCouldNotFetchResult": "Det gick inte att hämta resultatet från AI", + "smartEditCouldNotFetchKey": "Det gick inte att hämta AI-nyckeln", + "smartEditDisabled": "Anslut AI i Inställningar", "discardResponse": "Vill du kassera AI-svaren?", "createInlineMathEquation": "Skapa ekvation", "toggleList": "Växla lista", @@ -647,7 +641,7 @@ "referencedCalendarPrefix": "Utsikt över" }, "errorDialog": { - "title": "AppFlowy-fel", + "title": "@:appName-fel", "howToFixFallback": "Vi ber om ursäkt för besväret! Skapa en felrapport på vår GitHub-sida som beskriver ditt fel.", "github": "Visa på GitHub" }, diff --git a/frontend/resources/translations/th-TH.json b/frontend/resources/translations/th-TH.json index 2986fa6264..78e5462d7f 100644 --- a/frontend/resources/translations/th-TH.json +++ b/frontend/resources/translations/th-TH.json @@ -2,12 +2,14 @@ "appName": "AppFlowy", "defaultUsername": "ฉัน", "welcomeText": "ยินดีต้อนรับเข้าสู่ @:appName", + "welcomeTo": "ยินดีต้อนรับเข้าสู่", "githubStarText": "กดดาวบน GitHub", "subscribeNewsletterText": "สมัครรับจดหมายข่าว", "letsGoButtonText": "เริ่มต้นอย่างรวดเร็ว", "title": "ชื่อ", "youCanAlso": "นอกจากนี้คุณยังสามารถ", "and": "และ", + "failedToOpenUrl": "ไม่สามารถเปิด url ได้: {}", "blockActions": { "addBelowTooltip": "คลิกเพื่อเพิ่มด้านล่าง", "addAboveCmd": "Alt+click", @@ -21,12 +23,12 @@ "title": "ลงทะเบียนเพื่อใช้ @:appName", "getStartedText": "เริ่มต้น", "emptyPasswordError": "รหัสผ่านต้องไม่เว้นว่าง", - "repeatPasswordEmptyError": "รหัสผ่านซ้ำต้องไม่เว้นว่าง", - "unmatchedPasswordError": "รหัสผ่านซ้ำไม่เท่ากับรหัสผ่าน", + "repeatPasswordEmptyError": "ช่องยืนยันรหัสผ่านต้องไม่เว้นว่าง", + "unmatchedPasswordError": "รหัสผ่านที่ยืนยันไม่ตรงกับรหัสผ่าน", "alreadyHaveAnAccount": "มีบัญชีอยู่แล้วหรือไม่?", "emailHint": "อีเมล", "passwordHint": "รหัสผ่าน", - "repeatPasswordHint": "รหัสผ่านซ้ำ", + "repeatPasswordHint": "ยืนยันรหัสผ่าน", "signUpWith": "ลงทะเบียนกับ:" }, "signIn": { @@ -34,39 +36,106 @@ "loginButtonText": "เข้าสู่ระบบ", "loginStartWithAnonymous": "เริ่มต้นด้วยเซสชั่นแบบไม่ระบุตัวตน", "continueAnonymousUser": "ดำเนินการต่อด้วยเซสชันแบบไม่ระบุตัวตน", + "anonymous": "ไม่ระบุตัวตน", "buttonText": "เข้าสู่ระบบ", + "signingInText": "กำลังลงชื่อเข้าใช้...", "forgotPassword": "ลืมรหัสผ่านหรือไม่?", "emailHint": "อีเมล", "passwordHint": "รหัสผ่าน", "dontHaveAnAccount": "ยังไม่มีบัญชีใช่หรือไม่?", - "repeatPasswordEmptyError": "รหัสผ่านซ้ำต้องไม่เว้นว่าง", - "unmatchedPasswordError": "รหัสผ่านซ้ำไม่เท่ากับรหัสผ่าน", - "syncPromptMessage": "การซิงค์ข้อมูลอาจใช้เวลาสักครู่ กรุณาอย่าปิดหน้านี้", + "createAccount": "สร้างบัญชี", + "repeatPasswordEmptyError": "ช่องยืนยันรหัสผ่านต้องไม่เว้นว่าง", + "unmatchedPasswordError": "รหัสผ่านที่ยืนยันไม่ตรงกับรหัสผ่าน", + "syncPromptMessage": "กำลังซิงค์ข้อมูล ซึ่งอาจใช้เวลาสักครู่ กรุณาอย่าปิดหน้านี้", "or": "หรือ", + "signInWithGoogle": "ดำเนินการต่อด้วย Google", + "signInWithGithub": "ดำเนินการต่อด้วย GitHub", + "signInWithDiscord": "ดำเนินการต่อด้วย Discord", + "signInWithApple": "ดำเนินการต่อด้วย Apple", + "continueAnotherWay": "ลองวิธีอื่นเพื่อดำเนินการต่อ", + "signUpWithGoogle": "ลงทะเบียนด้วย Google", + "signUpWithGithub": "ลงทะเบียนด้วย Github", + "signUpWithDiscord": "ลงทะเบียนด้วย Discord", + "signInWith": "ลงชื่อเข้าใช้ด้วย:", + "signInWithEmail": "ดำเนินการต่อด้วยอีเมล์", + "signInWithMagicLink": "ดำเนินการต่อ", + "signUpWithMagicLink": "ลงทะเบียนด้วย Magic Link", + "pleaseInputYourEmail": "กรุณากรอกที่อยู่อีเมล์ของคุณ", + "settings": "การตั้งค่า", + "magicLinkSent": "ส่ง Magic Link แล้ว!", + "invalidEmail": "กรุณากรอกอีเมลที่ถูกต้อง", + "alreadyHaveAnAccount": "มีบัญชีอยู่แล้วใช่ไหม?", + "logIn": "เข้าสู่ระบบ", + "generalError": "มีบางอย่างผิดพลาด โปรดลองอีกครั้งในภายหลัง", + "limitRateError": "ด้วยเหตุผลด้านความปลอดภัย คุณสามารถขอ Magic Link ได้ทุกๆ 60 วินาทีเท่านั้น", + "magicLinkSentDescription": "Magic Link ถูกส่งไปยังอีเมลของคุณแล้ว กรุณาคลิกลิงก์เพื่อเข้าสู่ระบบ ลิงก์จะหมดอายุภายใน 5 นาที", "LogInWithGoogle": "เข้าสู่ระบบด้วย Google", "LogInWithGithub": "เข้าสู่ระบบด้วย Github", - "LogInWithDiscord": "เข้าสู่ระบบด้วย Discord", - "signInWith": "ลงชื่อเข้าใช้ด้วย:" + "LogInWithDiscord": "เข้าสู่ระบบด้วย Discord" }, "workspace": { "chooseWorkspace": "เลือกพื้นที่ทำงานของคุณ", + "defaultName": "พื้นที่ทำงานของฉัน", "create": "สร้างพื้นที่ทำงาน", + "importFromNotion": "นำเข้าจาก Notion", + "learnMore": "เรียนรู้เพิ่มเติม", "reset": "รีเซ็ตพื้นที่ทำงาน", - "resetWorkspacePrompt": "การรีเซ็ตพื้นที่ทำงานจะลบหน้าและข้อมูลทั้งหมดภายในนั้น คุณแน่ใจหรือไม่ว่าต้องการรีเซ็ตพื้นที่ทำงาน หรือคุณสามารถติดต่อทีมสนับสนุนเพื่อกู้คืนพื้นที่ทำงาน", + "renameWorkspace": "เปลี่ยนชื่อพื้นที่ทำงาน", + "workspaceNameCannotBeEmpty": "ชื่อพื้นที่ทำงานไม่สามารถเว้นว่างได้", + "resetWorkspacePrompt": "การรีเซ็ตพื้นที่ทำงานจะลบทุกหน้าและข้อมูลภายในนั้น คุณแน่ใจหรือไม่ว่าต้องการรีเซ็ตพื้นที่ทำงาน? หรือคุณสามารถติดต่อทีมสนับสนุนเพื่อกู้คืนพื้นที่ทำงานได้", "hint": "พื้นที่ทำงาน", "notFoundError": "ไม่พบพื้นที่ทำงาน", - "failedToLoad": "เกิดข้อผิดพลาด! ไม่สามารถโหลดพื้นที่ทำงานได้ โปรดปิดแอปพลิเคชัน AppFlowy ใดๆ ที่เปิดอยู่แล้วลองอีกครั้ง", + "failedToLoad": "เกิดข้อผิดพลาด! ไม่สามารถโหลดพื้นที่ทำงานได้ โปรดปิดแอปพลิเคชัน @:appName ที่เปิดอยู่ทั้งหมดแล้วลองอีกครั้ง", "errorActions": { "reportIssue": "รายงานปัญหา", + "reportIssueOnGithub": "รายงานปัญหาบน Github", + "exportLogFiles": "ส่งออกไฟล์บันทึก", "reachOut": "ติดต่อได้ที่ Discord" - } + }, + "menuTitle": "พื้นที่ทำงาน", + "deleteWorkspaceHintText": "คุณแน่ใจหรือไม่ว่าต้องการลบพื้นที่ทำงาน? การกระทำนี้ไม่สามารถย้อนกลับได้ และทุกหน้าที่คุณเผยแพร่จะถูกยกเลิกการเผยแพร่", + "createSuccess": "สร้างพื้นที่ทำงานสำเร็จแล้ว", + "createFailed": "ไม่สามารถสร้างพื้นที่ทำงานได้", + "createLimitExceeded": "คุณถึงขีดจำกัดสูงสุดของพื้นที่ทำงานที่บัญชีของคุณสามารถสร้างได้แล้ว หากคุณต้องการพื้นที่ทำงานเพิ่มเติมเพื่อดำเนินการต่อ โปรดส่งคำขอบน Github", + "deleteSuccess": "ลบพื้นที่ทำงานสำเร็จแล้ว", + "deleteFailed": "ไม่สามารถลบพื้นที่ทำงานได้", + "openSuccess": "เปิดพื้นที่ทำงานได้สำเร็จ", + "openFailed": "ไม่สามารถเปิดพื้นที่ทำงานได้", + "renameSuccess": "เปลี่ยนชื่อพื้นที่ทำงานสำเร็จแล้ว", + "renameFailed": "ไม่สามารถเปลี่ยนชื่อพื้นที่ทำงานได้", + "updateIconSuccess": "อัปเดตไอคอนพื้นที่ทำงานสำเร็จแล้ว", + "updateIconFailed": "ไม่สามารถอัปเดตไอคอนพื้นที่ทำงานได้", + "cannotDeleteTheOnlyWorkspace": "ไม่สามารถลบพื้นที่ทำงานเพียงอย่างเดียวได้", + "fetchWorkspacesFailed": "ไม่สามารถดึงข้อมูลพื้นที่ทำงานได้", + "leaveCurrentWorkspace": "ออกจากพื้นที่ทำงาน", + "leaveCurrentWorkspacePrompt": "คุณแน่ใจหรือไม่ว่าต้องการออกจากพื้นที่ทำงานปัจจุบัน?" }, "shareAction": { "buttonText": "แชร์", "workInProgress": "เร็วๆ นี้", "markdown": "Markdown", + "html": "HTML", + "clipboard": "คัดลอกไปยังคลิปบอร์ด", "csv": "CSV", - "copyLink": "คัดลอกลิงค์" + "copyLink": "คัดลอกลิงค์", + "publishToTheWeb": "เผยแพร่สู่เว็บ", + "publishToTheWebHint": "สร้างเว็บไซต์ด้วย AppFlowy", + "publish": "เผยแพร่", + "unPublish": "ยกเลิกการเผยแพร่", + "visitSite": "เยี่ยมชมเว็บไซต์", + "exportAsTab": "ส่งออกเป็น", + "publishTab": "เผยแพร่", + "shareTab": "แชร์", + "publishOnAppFlowy": "เผยแพร่บน AppFlowy", + "shareTabTitle": "เชิญเพื่อการทำงานร่วมกัน", + "shareTabDescription": "เพื่อการทำงานร่วมกันอย่างง่ายดายกับใครก็ได้", + "copyLinkSuccess": "คัดลอกลิงก์ไปยังคลิปบอร์ดแล้ว", + "copyShareLink": "คัดลอกลิงก์แชร์", + "copyLinkFailed": "ไม่สามารถคัดลอกลิงก์ไปยังคลิปบอร์ดได้", + "copyLinkToBlockSuccess": "คัดลอกลิงก์บล็อกไปยังคลิปบอร์ดแล้ว", + "copyLinkToBlockFailed": "ไม่สามารถคัดลอกลิงก์บล็อกไปยังคลิปบอร์ด", + "manageAllSites": "จัดการไซต์ทั้งหมด", + "updatePathName": "อัปเดตชื่อเส้นทาง" }, "moreAction": { "small": "เล็ก", @@ -74,12 +143,18 @@ "large": "ใหญ่", "fontSize": "ขนาดตัวอักษร", "import": "นำเข้า", - "moreOptions": "ตัวเลือกเพิ่มเติม" + "moreOptions": "ตัวเลือกเพิ่มเติม", + "wordCount": "จำนวนคำ: {}", + "charCount": "จำนวนตัวอักษร: {}", + "createdAt": "สร้างแล้ว: {}", + "deleteView": "ลบ", + "duplicateView": "ทำสำเนา" }, "importPanel": { "textAndMarkdown": "ข้อความ & Markdown", "documentFromV010": "เอกสารจาก v0.1.0", "databaseFromV010": "ฐานข้อมูลจาก v0.1.0", + "notionZip": "ไฟล์ Zip ที่ส่งออกจาก Notion", "csv": "CSV", "database": "ฐานข้อมูล" }, @@ -92,7 +167,9 @@ "openNewTab": "เปิดในแท็บใหม่", "moveTo": "ย้ายไปยัง", "addToFavorites": "เพิ่มในรายการโปรด", - "copyLink": "คัดลอกลิงค์" + "copyLink": "คัดลอกลิงค์", + "changeIcon": "เปลี่ยนไอคอน", + "collapseAllPages": "ยุบหน้าย่อยทั้งหมด" }, "blankPageTitle": "หน้าเปล่า", "newPageText": "หน้าใหม่", @@ -100,9 +177,42 @@ "newGridText": "ตารางใหม่", "newCalendarText": "ปฏิทินใหม่", "newBoardText": "กระดานใหม่", + "chat": { + "newChat": "แชท AI", + "inputMessageHint": "ถาม @:appName AI", + "inputLocalAIMessageHint": "ถาม @:appName Local AI", + "unsupportedCloudPrompt": "คุณสมบัตินี้ใช้ได้เมื่อใช้ @:appName Cloud เท่านั้น", + "relatedQuestion": "ข้อเสนอแนะ", + "serverUnavailable": "บริการไม่สามารถใช้งานได้ชั่วคราว กรุณาลองใหม่อีกครั้งในภายหลัง", + "aiServerUnavailable": "การเชื่อมต่อขาดหาย กรุณาตรวจสอบอินเทอร์เน็ตของคุณ", + "retry": "ลองใหม่อีกครั้ง", + "clickToRetry": "คลิกเพื่อลองใหม่อีกครั้ง", + "regenerateAnswer": "สร้างใหม่", + "question1": "วิธีการใช้ Kanban เพื่อจัดการงาน", + "question2": "อธิบายวิธี GTD", + "question3": "เหตุใดจึงใช้ Rust", + "question4": "สูตรอาหารจากสิ่งที่มีในครัวของฉัน", + "question5": "สร้างภาพประกอบให้กับหน้าของฉัน", + "question6": "จัดทำรายการสิ่งที่ต้องทำสำหรับสัปดาห์หน้าของฉัน", + "aiMistakePrompt": "AI อาจทำผิดพลาดได้ กรุณาตรวจสอบข้อมูลที่สำคัญ", + "chatWithFilePrompt": "คุณต้องการแชทกับไฟล์หรือไม่?", + "indexFileSuccess": "สร้างดัชนีไฟล์สำเร็จแล้ว", + "inputActionNoPages": "ไม่พบผลลัพธ์จากหน้า", + "referenceSource": { + "zero": "ไม่พบแหล่งข้อมูล 0 รายการ", + "one": "พบแหล่งข้อมูล {count} รายการ", + "other": "พบแหล่งข้อมูล {count} รายการ" + }, + "clickToMention": "อ้างอิงถึงหน้า", + "uploadFile": "แนบไฟล์ PDF, ข้อความ หรือไฟล์ Markdown", + "questionDetail": "สวัสดี {}! วันนี้ฉันสามารถช่วยอะไรคุณได้บ้าง?", + "indexingFile": "การจัดทำดัชนี {}", + "generatingResponse": "กำลังสร้างการตอบกลับ" + }, "trash": { "text": "ขยะ", "restoreAll": "กู้คืนทั้งหมด", + "restore": "กู้คืน", "deleteAll": "ลบทั้งหมด", "pageHeader": { "fileName": "ชื่อไฟล์", @@ -117,31 +227,37 @@ "title": "คุณแน่ใจหรือว่าจะกู้คืนทุกหน้าในถังขยะ", "caption": "การดำเนินการนี้ไม่สามารถยกเลิกได้" }, + "restorePage": { + "title": "กู้คืน: {}", + "caption": "คุณแน่ใจหรือไม่ว่าต้องการกู้คืนหน้านี้?" + }, "mobile": { "actions": "การดำเนินการถังขยะ", "empty": "ถังขยะว่างเปล่า", "emptyDescription": "คุณไม่มีไฟล์ที่ถูกลบ", "isDeleted": "ถูกลบแล้ว", "isRestored": "ถูกกู้คืนแล้ว" - } + }, + "confirmDeleteTitle": "คุณแน่ใจหรือไม่ว่าต้องการลบหน้านี้อย่างถาวร?" }, "deletePagePrompt": { "text": "หน้านี้อยู่ในถังขยะ", "restore": "กู้คืนหน้า", - "deletePermanent": "ลบถาวร" + "deletePermanent": "ลบถาวร", + "deletePermanentDescription": "คุณแน่ใจหรือไม่ว่าต้องการลบหน้านี้อย่างถาวร? การกระทำนี้ไม่สามารถย้อนกลับได้" }, "dialogCreatePageNameHint": "ชื่อหน้า", "questionBubble": { "shortcuts": "ทางลัด", "whatsNew": "มีอะไรใหม่?", - "help": "ช่วยเหลือและสนับสนุน", "markdown": "Markdown", "debug": { - "name": "ข้อมูล debug", - "success": "คัดลอกข้อมูล debug ไปยังคลิปบอร์ดแล้ว!", - "fail": "คัดลอกข้อมูล debug ไปยังคลิปบอร์ดไม่ได้" + "name": "ข้อมูลดีบัก", + "success": "คัดลอกข้อมูลดีบักไปยังคลิปบอร์ดแล้ว!", + "fail": "ไม่สามารถคัดลอกข้อมูลดีบักไปยังคลิปบอร์ด" }, - "feedback": "ข้อเสนอแนะ" + "feedback": "ข้อเสนอแนะ", + "help": "ช่วยเหลือและสนับสนุน" }, "menuAppHeader": { "moreButtonToolTip": "ลบ เปลี่ยนชื่อ และอื่นๆ...", @@ -161,7 +277,7 @@ "bulletList": "รายการลำดับหัวข้อย่อย", "checkList": "รายการลำดับ Check", "inlineCode": "อินไลน์โค้ด", - "quote": "บล็อกอ้างอิง", + "quote": "บล็อกคำกล่าว", "header": "หัวข้อ", "highlight": "ไฮไลท์", "color": "สี", @@ -177,17 +293,58 @@ "dragRow": "กดค้างเพื่อเรียงลำดับแถวใหม่", "viewDataBase": "ดูฐานข้อมูล", "referencePage": "{name} ถูกอ้างอิงถึง", - "addBlockBelow": "เพิ่มบล็อกด้านล่าง" + "addBlockBelow": "เพิ่มบล็อกด้านล่าง", + "aiGenerate": "สร้าง" }, "sideBar": { "closeSidebar": "ปิดแถบด้านข้าง", "openSidebar": "เปิดแถบด้านข้าง", "personal": "ส่วนบุคคล", + "private": "ส่วนตัว", + "workspace": "พื้นที่ทำงาน", "favorites": "รายการโปรด", + "clickToHidePrivate": "คลิกเพื่อซ่อนพื้นที่ส่วนตัว\nหน้าที่คุณสร้างที่นี่จะแสดงให้เห็นเฉพาะคุณเท่านั้น", + "clickToHideWorkspace": "คลิกเพื่อซ่อนพื้นที่ทำงาน\nหน้าที่คุณสร้างที่นี่จะแสดงให้สมาชิกทุกคนเห็น", "clickToHidePersonal": "คลิกเพื่อซ่อนส่วนส่วนบุคคล", "clickToHideFavorites": "คลิกเพื่อซ่อนส่วนรายการโปรด", "addAPage": "เพิ่มหน้า", - "recent": "ล่าสุด" + "addAPageToPrivate": "เพิ่มหน้าไปยังพื้นที่ส่วนตัว", + "addAPageToWorkspace": "เพิ่มหน้าไปยังพื้นที่ทำงาน", + "recent": "ล่าสุด", + "today": "วันนี้", + "thisWeek": "สัปดาห์นี้", + "others": "รายการโปรดก่อนหน้านี้", + "earlier": "ก่อนหน้านี้", + "justNow": "เมื่อสักครู่", + "minutesAgo": "{count} นาทีที่ผ่านมา", + "lastViewed": "ดูล่าสุด", + "favoriteAt": "รายการโปรด", + "emptyRecent": "ไม่มีหน้าล่าสุด", + "emptyRecentDescription": "เมื่อคุณดูหน้าเพจ หน้าเหล่านั้นจะปรากฏที่นี่เพื่อให้คุณสามารถเรียกดูได้ง่าย", + "emptyFavorite": "ไม่มีหน้าโปรด", + "emptyFavoriteDescription": "ทำเครื่องหมายหน้าเป็นรายการโปรด—หน้าเหล่านี้จะแสดงไว้ที่นี่เพื่อการเข้าถึงอย่างรวดเร็ว!", + "removePageFromRecent": "ลบหน้านี้ออกจากรายการล่าสุดหรือไม่?", + "removeSuccess": "ลบสำเร็จ", + "favoriteSpace": "รายการโปรด", + "RecentSpace": "ล่าสุด", + "Spaces": "พื้นที่", + "upgradeToPro": "อัพเกรดเป็น Pro", + "upgradeToAIMax": "ปลดล็อค AI แบบไม่จำกัด", + "storageLimitDialogTitle": "คุณใช้พื้นที่จัดเก็บฟรีหมดแล้ว กรุณาอัปเกรดเพื่อปลดล็อกพื้นที่จัดเก็บแบบไม่จำกัด", + "storageLimitDialogTitleIOS": "คุณใช้พื้นที่จัดเก็บฟรีหมดแล้ว", + "aiResponseLimitTitle": "คุณใช้จำนวนการตอบกลับ AI ฟรีหมดแล้ว กรุณาอัปเกรดเป็นแผน Pro หรือซื้อส่วนเสริม AI เพื่อปลดล็อกการตอบกลับไม่จำกัด", + "aiResponseLimitDialogTitle": "ถึงขีดจำกัดการตอบกลับ AI แล้ว", + "aiResponseLimit": "คุณใช้จำนวนการตอบกลับ AI ฟรีหมดแล้ว\nไปที่ การตั้งค่า -> แผน -> คลิก AI Max หรือแผน Pro เพื่อรับการตอบกลับ AI เพิ่มเติม", + "askOwnerToUpgradeToPro": "พื้นที่จัดเก็บฟรีในพื้นที่ทำงานของคุณกำลังจะหมด กรุณาขอให้เจ้าของพื้นที่ทำงานของคุณอัปเกรดเป็นแผน Pro", + "askOwnerToUpgradeToProIOS": "พื้นที่จัดเก็บฟรีในพื้นที่ทำงานของคุณกำลังจะหมด", + "askOwnerToUpgradeToAIMax": "พื้นที่ทำงานของคุณใช้จำนวนการตอบกลับ AI ฟรีหมดแล้ว กรุณาขอให้เจ้าของพื้นที่ทำงานของคุณอัปเกรดแผนหรือซื้อส่วนเสริม AI", + "askOwnerToUpgradeToAIMaxIOS": "จำนวนการตอบกลับ AI ฟรี ในพื้นที่ทำงานของคุณใกล้หมดแล้ว", + "purchaseStorageSpace": "ซื้อพื้นที่จัดเก็บข้อมูล", + "singleFileProPlanLimitationDescription": "คุณอัปโหลดไฟล์เกินขนาดสูงสุดที่อนุญาตในแผนฟรี โปรดอัปเกรดเป็นแผน Pro เพื่ออัปโหลดไฟล์ขนาดใหญ่ขึ้น", + "purchaseAIResponse": "ซื้อ ", + "askOwnerToUpgradeToLocalAI": "กรุณาขอให้เจ้าของพื้นที่ทำงานเปิดใช้งาน AI บนอุปกรณ์", + "upgradeToAILocal": "เรียกใช้โมเดลบนอุปกรณ์ของคุณเพื่อความเป็นส่วนตัวสูงสุด", + "upgradeToAILocalDesc": "สนทนากับ PDFs ปรับปรุงการเขียนของคุณ และเติมข้อมูลในตารางอัตโนมัติด้วย AI ภายในเครื่อง" }, "notifications": { "export": { @@ -203,6 +360,7 @@ }, "button": { "ok": "ตกลง", + "confirm": "ยืนยัน", "done": "เสร็จแล้ว", "cancel": "ยกเลิก", "signIn": "ลงชื่อเข้าใช้", @@ -220,16 +378,45 @@ "upload": "อัปโหลด", "edit": "แก้ไข", "delete": "ลบ", + "copy": "คัดลอก", "duplicate": "ทำสำเนา", "putback": "นำกลับมา", "update": "อัปโหลด", "share": "แชร์", "removeFromFavorites": "ลบออกจากรายการโปรด", + "removeFromRecent": "ลบออกจากรายการล่าสุด", "addToFavorites": "เพิ่มในรายการโปรด", + "favoriteSuccessfully": "เพิ่มในรายการที่ชื่นชอบสำเร็จ", + "unfavoriteSuccessfully": "นำออกจากรายการที่ชื่นชอบสำเร็จ", + "duplicateSuccessfully": "ทำสำเนาสำเร็จแล้ว", "rename": "เปลี่ยนชื่อ", "helpCenter": "ศูนย์ช่วยเหลือ", "add": "เพิ่ม", - "yes": "ใช่" + "yes": "ใช่", + "no": "ไม่", + "clear": "ล้าง", + "remove": "ลบ", + "dontRemove": "ไม่ลบ", + "copyLink": "คัดลอกลิงค์", + "align": "จัดตำแหน่ง", + "login": "เข้าสู่ระบบ", + "logout": "ออกจากระบบ", + "deleteAccount": "ลบบัญชี", + "back": "กลับ", + "signInGoogle": "ดำเนินการต่อด้วย Google", + "signInGithub": "ดำเนินการต่อด้วย GitHub", + "signInDiscord": "ดำเนินการต่อด้วย Discord", + "more": "เพิ่มเติม", + "create": "สร้าง", + "close": "ปิด", + "next": "ถัดไป", + "previous": "ก่อนหน้า", + "submit": "ส่ง", + "download": "ดาวน์โหลด", + "backToHome": "กลับสู่หน้าแรก", + "viewing": "การดู", + "editing": "การแก้ไข", + "gotIt": "เข้าใจแล้ว" }, "label": { "welcome": "ยินดีต้อนรับ!", @@ -253,6 +440,591 @@ }, "settings": { "title": "การตั้งค่า", + "popupMenuItem": { + "settings": "การตั้งค่า", + "members": "สมาชิก", + "trash": "ถังขยะ", + "helpAndSupport": "ความช่วยเหลือ & การสนับสนุน" + }, + "sites": { + "title": "ไซต์", + "namespaceTitle": "เนมสเปซ", + "namespaceDescription": "จัดการเนมสเปซและโฮมเพจของคุณ", + "namespaceHeader": "เนมสเปซ", + "homepageHeader": "หน้าแรก", + "updateNamespace": "อัปเดตเนมสเปซ", + "removeHomepage": "ลบหน้าแรก", + "selectHomePage": "เลือกหน้า", + "clearHomePage": "ล้างโฮมเพจสำหรับเนมสเปซนี้", + "customUrl": "URL แบบกำหนดเอง", + "namespace": { + "description": "การเปลี่ยนแปลงนี้จะมีผลกับหน้าที่เผยแพร่ทั้งหมดที่เปิดใช้งานในพื้นที่นี้", + "tooltip": "เราขอสงวนสิทธิ์ในการลบเนมสเปซที่ไม่เหมาะสม", + "updateExistingNamespace": "อัปเดตเนมสเปซที่มีอยู่", + "upgradeToPro": "อัปเกรดเป็นแผน Pro เพื่อกำหนดหน้าโฮมเพจ", + "redirectToPayment": "กำลังเปลี่ยนเส้นทางไปยังหน้าชำระเงิน...", + "onlyWorkspaceOwnerCanSetHomePage": "เฉพาะเจ้าของพื้นที่ทำงานเท่านั้นที่สามารถกำหนดหน้าโฮมเพจได้", + "pleaseAskOwnerToSetHomePage": "กรุณาขอให้เจ้าของพื้นที่ทำงานอัปเกรดเป็นแผน Pro" + }, + "publishedPage": { + "title": "หน้าที่เผยแพร่ทั้งหมด", + "description": "จัดการหน้าที่เผยแพร่ของคุณ", + "page": "หน้า", + "pathName": "ชื่อเส้นทาง", + "date": "วันที่เผยแพร่", + "emptyHinText": "คุณไม่มีหน้าที่เผยแพร่ในพื้นที่ทำงานนี้", + "noPublishedPages": "ยังไม่มีหน้าที่เผยแพร่", + "settings": "การตั้งค่าการเผยแพร่", + "clickToOpenPageInApp": "เปิดหน้าในแอป", + "clickToOpenPageInBrowser": "เปิดหน้าในเบราว์เซอร์" + }, + "error": { + "failedToGeneratePaymentLink": "ไม่สามารถสร้างลิงก์การชำระเงินสำหรับแผน Pro ได้", + "failedToUpdateNamespace": "ไม่สามารถอัปเดตเนมสเปซได้", + "proPlanLimitation": "คุณต้องอัปเกรดเป็นแผน Pro เพื่ออัปเดตเนมสเปซ", + "namespaceAlreadyInUse": "เนมสเปซนี้ถูกใช้ไปแล้ว กรุณาลองชื่ออื่น", + "invalidNamespace": "เนมสเปซไม่ถูกต้อง กรุณาลองชื่ออื่น", + "namespaceLengthAtLeast2Characters": "เนมสเปซต้องมีความยาวอย่างน้อย 2 ตัวอักษร", + "onlyWorkspaceOwnerCanUpdateNamespace": "เฉพาะเจ้าของพื้นที่ทำงานเท่านั้นที่สามารถอัปเดตเนมสเปซได้", + "onlyWorkspaceOwnerCanRemoveHomepage": "เฉพาะเจ้าของพื้นที่ทำงานเท่านั้นที่สามารถลบหน้าโฮมเพจได้", + "setHomepageFailed": "ไม่สามารถตั้งค่าหน้าแรกได้", + "namespaceTooLong": "เนมสเปซยาวเกินไป กรุณาลองชื่ออื่น", + "namespaceTooShort": "เนมสเปซสั้นเกินไป กรุณาลองชื่ออื่น", + "namespaceIsReserved": "เนมสเปซนี้ถูกจองไว้แล้ว กรุณาลองชื่ออื่น", + "updatePathNameFailed": "ไม่สามารถอัปเดตชื่อเส้นทางได้", + "removeHomePageFailed": "ไม่สามารถลบหน้าแรกได้", + "publishNameContainsInvalidCharacters": "ชื่อเส้นทางมีอักขระที่ไม่ถูกต้อง โปรดลองอักขระอื่น", + "publishNameTooShort": "ชื่อเส้นทางสั้นเกินไป กรุณาลองชื่ออื่น", + "publishNameTooLong": "ชื่อเส้นทางยาวเกินไป กรุณาลองชื่ออื่น", + "publishNameAlreadyInUse": "ชื่อเส้นทางนี้ถูกใช้ไปแล้ว กรุณาลองชื่ออื่น", + "namespaceContainsInvalidCharacters": "เนมสเปซมีอักขระที่ไม่ถูกต้อง โปรดลองอักขระอื่น", + "publishPermissionDenied": "เฉพาะเจ้าของพื้นที่ทำงานหรือผู้เผยแพร่เพจเท่านั้นที่สามารถจัดการการตั้งค่าการเผยแพร่ได้", + "publishNameCannotBeEmpty": "ชื่อเส้นทางไม่สามารถเว้นว่างได้ กรุณาลองชื่ออื่น" + }, + "success": { + "namespaceUpdated": "อัปเดตเนมสเปซสำเร็จ", + "setHomepageSuccess": "ตั้งค่าหน้าแรกสำเร็จ", + "updatePathNameSuccess": "อัปเดตชื่อเส้นทางสำเร็จ", + "removeHomePageSuccess": "ลบหน้าแรกสำเร็จ" + } + }, + "accountPage": { + "menuLabel": "บัญชีของฉัน", + "title": "บัญชีของฉัน", + "general": { + "title": "ชื่อบัญชีและรูปโปรไฟล์", + "changeProfilePicture": "เปลี่ยนรูปโปรไฟล์" + }, + "email": { + "title": "อีเมล", + "actions": { + "change": "เปลี่ยนอีเมล" + } + }, + "login": { + "title": "การเข้าสู่ระบบบัญชี", + "loginLabel": "เข้าสู่ระบบ", + "logoutLabel": "ออกจากระบบ" + } + }, + "workspacePage": { + "menuLabel": "พื้นที่ทำงาน", + "title": "พื้นที่ทำงาน", + "description": "ปรับแต่งรูปลักษณ์ของพื้นที่ทำงาน ธีม แบบอักษร เค้าโครงข้อความ รูปแบบวันที่/เวลา และภาษา", + "workspaceName": { + "title": "ชื่อพื้นที่ทำงาน" + }, + "workspaceIcon": { + "title": "ไอคอนพื้นที่ทำงาน", + "description": "อัปโหลดรูปภาพ หรือใช้อีโมจิสำหรับพื้นที่ทำงานของคุณ ไอคอนจะแสดงในแถบด้านข้าง และการแจ้งเตือนของคุณ" + }, + "appearance": { + "title": "ลักษณะการแสดงผล", + "description": "ปรับแต่งรูปลักษณ์พื้นที่ทำงาน ธีม แบบอักษร เค้าโครงข้อความ วันที่ เวลา และภาษาของคุณ", + "options": { + "system": "อัตโนมัติ", + "light": "สว่าง", + "dark": "มืด" + } + }, + "resetCursorColor": { + "title": "รีเซ็ตสีของเคอร์เซอร์ในเอกสาร", + "description": "คุณแน่ใจหรือไม่ว่าต้องการรีเซ็ตสีของเคอร์เซอร์?" + }, + "resetSelectionColor": { + "title": "รีเซ็ตสีการเลือกในเอกสาร", + "description": "คุณแน่ใจหรือไม่ว่าต้องการรีเซ็ตสีการเลือก?" + }, + "resetWidth": { + "resetSuccess": "รีเซ็ตความกว้างของเอกสารสำเร็จ" + }, + "theme": { + "title": "ธีม", + "description": "เลือกธีมที่ตั้งไว้ล่วงหน้าหรืออัปโหลดธีมที่คุณกำหนดเอง", + "uploadCustomThemeTooltip": "อัพโหลดธีมที่กำหนดเอง" + }, + "workspaceFont": { + "title": "ฟอนต์ของพื้นที่ทำงาน", + "noFontHint": "ไม่พบฟอนต์ กรุณาลองคำค้นหาอื่น" + }, + "textDirection": { + "title": "ทิศทางข้อความ", + "leftToRight": "จากซ้ายไปขวา", + "rightToLeft": "จากขวาไปซ้าย", + "auto": "อัตโนมัติ", + "enableRTLItems": "เปิดใช้งานแถบเครื่องมือ RTL (จากขวาไปซ้าย)" + }, + "layoutDirection": { + "title": "ทิศทางการจัดวาง", + "leftToRight": "จากซ้ายไปขวา", + "rightToLeft": "จากขวาไปซ้าย" + }, + "dateTime": { + "title": "วันที่และเวลา", + "example": "{} ที่ {} ({})", + "24HourTime": "เวลาแบบ 24 ชั่วโมง", + "dateFormat": { + "label": "รูปแบบวันที่", + "local": "ท้องถิ่น", + "us": "US", + "iso": "ISO", + "friendly": "ที่เข้าใจง่าย", + "dmy": "D/M/Y" + } + }, + "language": { + "title": "ภาษา" + }, + "deleteWorkspacePrompt": { + "title": "ลบพื้นที่ทำงาน", + "content": "คุณแน่ใจหรือไม่ว่าต้องการลบพื้นที่ทำงานนี้? การดำเนินการนี้ไม่สามารถย้อนกลับได้ และหน้าใดๆ ที่คุณเผยแพร่ไปแล้วจะถูกยกเลิกการเผยแพร่" + }, + "leaveWorkspacePrompt": { + "title": "ออกจากพื้นที่ทำงาน", + "content": "คุณแน่ใจหรือไม่ว่าต้องการออกจากพื้นที่ทำงานนี้? คุณจะสูญเสียการเข้าถึงหน้าและข้อมูลทั้งหมดภายในพื้นที่นี้", + "success": "คุณได้ออกจากพื้นที่ทำงานเรียบร้อยแล้ว", + "fail": "ไม่สามารถออกจากพื้นที่ทำงานได้" + }, + "manageWorkspace": { + "title": "จัดการพื้นที่ทำงาน", + "leaveWorkspace": "ออกจากพื้นที่ทำงาน", + "deleteWorkspace": "ลบพื้นที่ทำงาน" + } + }, + "manageDataPage": { + "menuLabel": "การจัดการข้อมูล", + "title": "การจัดการข้อมูล", + "description": "จัดการพื้นที่จัดเก็บข้อมูลในเครื่องหรือนำเข้าข้อมูลที่มีอยู่ของคุณลงใน @:appName", + "dataStorage": { + "title": "ตำแหน่งจัดเก็บไฟล์", + "tooltip": "ตำแหน่งที่จัดเก็บไฟล์ของคุณ", + "actions": { + "change": "เปลี่ยนเส้นทาง", + "open": "เปิดโฟลเดอร์", + "openTooltip": "เปิดตำแหน่งโฟลเดอร์ข้อมูลปัจจุบัน", + "copy": "คัดลอกเส้นทาง", + "copiedHint": "คัดลอกเส้นทางแล้ว!", + "resetTooltip": "รีเซ็ตเป็นตำแหน่งเริ่มต้น" + }, + "resetDialog": { + "title": "คุณแน่ใจหรือไม่?", + "description": "การรีเซ็ตเส้นทางไปยังตำแหน่งข้อมูลเริ่มต้นจะไม่ลบข้อมูลของคุณ หากคุณต้องการนำเข้าข้อมูลปัจจุบันอีกครั้ง ควรคัดลอกเส้นทางของตำแหน่งปัจจุบันก่อน" + } + }, + "importData": { + "title": "นำเข้าข้อมูล", + "tooltip": "นำเข้าข้อมูลจากการสำรองข้อมูล/โฟลเดอร์ข้อมูล @:appName", + "description": "คัดลอกข้อมูลจากโฟลเดอร์ข้อมูลภายนอกของ @:appName", + "action": "เรียกดูไฟล์" + }, + "encryption": { + "title": "การเข้ารหัส", + "tooltip": "จัดการวิธีการจัดเก็บและเข้ารหัสข้อมูลของคุณ", + "descriptionNoEncryption": "การเปิดใช้งานการเข้ารหัสจะทำให้ข้อมูลทั้งหมดถูกเข้ารหัส การกระทำนี้ไม่สามารถย้อนกลับได้", + "descriptionEncrypted": "ข้อมูลของคุณได้รับการเข้ารหัสแล้ว", + "action": "เข้ารหัสข้อมูล", + "dialog": { + "title": "ต้องการเข้ารหัสข้อมูลทั้งหมดของคุณหรือไม่?", + "description": "การเข้ารหัสข้อมูลทั้งหมดของคุณจะช่วยให้ข้อมูลของคุณปลอดภัยและมั่นคง การดำเนินการนี้ไม่สามารถย้อนกลับได้ คุณแน่ใจหรือไม่ว่าต้องการดำเนินการต่อ?" + } + }, + "cache": { + "title": "ล้างแคช", + "description": "ช่วยแก้ไขปัญหาต่างๆ เช่น รูปภาพไม่โหลด หน้าหายไปในช่องว่าง และฟอนต์ไม่โหลด ซึ่งจะไม่กระทบกับข้อมูลของคุณ", + "dialog": { + "title": "ล้างแคช", + "description": "ช่วยแก้ไขปัญหาต่างๆ เช่น รูปภาพไม่โหลด หน้าหายไปในช่องว่าง และฟอนต์ไม่โหลด ซึ่งจะไม่กระทบกับข้อมูลของคุณ", + "successHint": "ล้างแคชแล้ว!" + } + }, + "data": { + "fixYourData": "ซ่อมแซมข้อมูลของคุณ", + "fixButton": "ซ่อมแซม", + "fixYourDataDescription": "หากคุณพบปัญหากับข้อมูลของคุณ คุณสามารถลองแก้ไขได้ที่นี่" + } + }, + "shortcutsPage": { + "menuLabel": "ทางลัด", + "title": "ทางลัด", + "editBindingHint": "ป้อนการผูกปุ่มลัดใหม่", + "searchHint": "ค้นหา", + "actions": { + "resetDefault": "รีเซ็ตค่าเริ่มต้น" + }, + "errorPage": { + "message": "ไม่สามารถโหลดทางลัดได้: {}", + "howToFix": "โปรดลองอีกครั้ง หากปัญหายังคงอยู่ โปรดติดต่อบน GitHub" + }, + "resetDialog": { + "title": "รีเซ็ตทางลัด", + "description": "การดำเนินการนี้จะรีเซ็ตการตั้งค่าปุ่มทั้งหมดของคุณเป็นค่าเริ่มต้น คุณไม่สามารถย้อนกลับได้ในภายหลัง คุณแน่ใจหรือไม่ว่าต้องการดำเนินการต่อ?", + "buttonLabel": "รีเซ็ต" + }, + "conflictDialog": { + "title": "{} กำลังใช้งานอยู่ในขณะนี้", + "descriptionPrefix": "การกำหนดปุ่มลัดนี้กำลังถูกใช้งานโดย", + "descriptionSuffix": ". หากคุณแทนที่ปุ่มลัดนี้ ปุ่มลัดนี้จะถูกลบออกจาก {}", + "confirmLabel": "ดำเนินการต่อ" + }, + "editTooltip": "กดเพื่อเริ่มแก้ไขปุ่มลัด", + "keybindings": { + "toggleToDoList": "เปิดปิดรายการสิ่งที่ต้องทำ", + "insertNewParagraphInCodeblock": "แทรกย่อหน้าใหม่", + "pasteInCodeblock": "วางในโค้ดบล็อค", + "selectAllCodeblock": "เลือกทั้งหมด", + "indentLineCodeblock": "แทรกช่องว่างสองช่องที่จุดเริ่มต้นของบรรทัด", + "outdentLineCodeblock": "ลบช่องว่างสองช่องที่จุดเริ่มต้นของบรรทัด", + "twoSpacesCursorCodeblock": "แทรกช่องว่างสองช่องที่เคอร์เซอร์", + "copy": "คัดลอกการเลือก", + "paste": "วางในเนื้อหา", + "cut": "ตัดการเลือก", + "alignLeft": "จัดข้อความให้ชิดซ้าย", + "alignCenter": "จัดตำแหน่งข้อความให้อยู่กึ่งกลาง", + "alignRight": "จัดข้อความให้ชิดขวา", + "undo": "เลิกทำ", + "redo": "ทำซ้ำ", + "convertToParagraph": "แปลงบล็อกเป็นย่อหน้า", + "backspace": "ลบ", + "deleteLeftWord": "ลบคำซ้าย", + "deleteLeftSentence": "ลบประโยคซ้าย", + "delete": "ลบตัวอักษรขวา", + "deleteMacOS": "ลบตัวอักษรซ้าย", + "deleteRightWord": "ลบคำขวา", + "moveCursorLeft": "เลื่อนเคอร์เซอร์ไปทางซ้าย", + "moveCursorBeginning": "เลื่อนเคอร์เซอร์ไปที่จุดเริ่มต้น", + "moveCursorLeftWord": "เลื่อนเคอร์เซอร์ไปทางซ้ายหนึ่งคำ", + "moveCursorLeftSelect": "เลือกและเลื่อนเคอร์เซอร์ไปทางซ้าย", + "moveCursorBeginSelect": "เลือกและเลื่อนเคอร์เซอร์ไปที่จุดเริ่มต้น", + "moveCursorLeftWordSelect": "เลือกและเลื่อนเคอร์เซอร์ไปทางซ้ายหนึ่งคำ", + "moveCursorRight": "เลื่อนเคอร์เซอร์ไปทางขวา", + "moveCursorEnd": "เลื่อนเคอร์เซอร์ไปที่ท้ายสุด", + "moveCursorRightWord": "เลื่อนเคอร์เซอร์ไปทางขวาหนึ่งคำ", + "moveCursorRightSelect": "เลือกและเลื่อนเคอร์เซอร์ไปทางขวาหนึ่งช่อง", + "moveCursorEndSelect": "เลือกและเลื่อนเคอร์เซอร์ไปที่จุดสิ้นสุด", + "moveCursorRightWordSelect": "เลือกและเลื่อนเคอร์เซอร์ไปทางขวาหนึ่งคำ", + "moveCursorUp": "เลื่อนเคอร์เซอร์ขึ้น", + "moveCursorTopSelect": "เลือกและเลื่อนเคอร์เซอร์ไปด้านบน", + "moveCursorTop": "เลื่อนเคอร์เซอร์ไปด้านบน", + "moveCursorUpSelect": "เลือกและเลื่อนเคอร์เซอร์ขึ้น", + "moveCursorBottomSelect": "เลือกและเลื่อนเคอร์เซอร์ไปที่ด้านล่าง", + "moveCursorBottom": "เลื่อนเคอร์เซอร์ไปที่ด้านล่าง", + "moveCursorDown": "เลื่อนเคอร์เซอร์ลง", + "moveCursorDownSelect": "เลือกและเลื่อนเคอร์เซอร์ลง", + "home": "เลื่อนไปด้านบน", + "end": "เลื่อนไปด้านล่าง", + "toggleBold": "เปิดปิดตัวหนา", + "toggleItalic": "เปิดปิดตัวเอียง", + "toggleUnderline": "เปิดปิดขีดเส้นใต้", + "toggleStrikethrough": "เปิดปิดการขีดฆ่า", + "toggleCode": "เปิดปิดโค้ดในบรรทัด", + "toggleHighlight": "เปิดปิดไฮไลท์", + "showLinkMenu": "แสดงลิงค์เมนู", + "openInlineLink": "เปิดลิงก์ในบรรทัด", + "openLinks": "เปิดลิงก์ที่เลือกทั้งหมด", + "indent": "เยื้อง", + "outdent": "เยื้องออก", + "exit": "ออกจากการแก้ไข", + "pageUp": "เลื่อนขึ้นไปหนึ่งหน้า", + "pageDown": "เลื่อนลงมาหนึ่งหน้า", + "selectAll": "เลือกทั้งหมด", + "pasteWithoutFormatting": "วางเนื้อหาที่ไม่มีการจัดรูปแบบ", + "showEmojiPicker": "แสดงตัวเลือกอีโมจิ", + "enterInTableCell": "เพิ่มการแบ่งบรรทัดในตาราง", + "leftInTableCell": "เลื่อนไปทางซ้ายหนึ่งเซลล์ในตาราง", + "rightInTableCell": "เลื่อนไปทางขวาหนึ่งเซลล์ในตาราง", + "upInTableCell": "เลื่อนขึ้นหนึ่งเซลล์ในตาราง", + "downInTableCell": "เลื่อนลงหนึ่งเซลล์ในตาราง", + "tabInTableCell": "ไปยังเซลล์ถัดไปที่ว่างในตาราง", + "shiftTabInTableCell": "ไปยังเซลล์ก่อนหน้าที่ว่างในตาราง", + "backSpaceInTableCell": "หยุดที่จุดเริ่มต้นของเซลล์" + }, + "commands": { + "codeBlockNewParagraph": "แทรกย่อหน้าใหม่ถัดจากโค้ดบล็อก", + "codeBlockIndentLines": "แทรกช่องว่างสองช่องที่จุดเริ่มต้นบรรทัดในโค้ดบล็อก", + "codeBlockOutdentLines": "ลบช่องว่างสองช่องที่จุดเริ่มต้นบรรทัดในโค้ดบล็อค", + "codeBlockAddTwoSpaces": "แทรกช่องว่างสองช่องที่ตำแหน่งเคอร์เซอร์ในโค้ดบล็อก", + "codeBlockSelectAll": "เลือกเนื้อหาทั้งหมดภายในโค้ดบล็อก", + "codeBlockPasteText": "วางข้อความลงในโค้ดบล็อค", + "textAlignLeft": "จัดตำแหน่งข้อความให้ชิดซ้าย", + "textAlignCenter": "จัดตำแหน่งข้อความให้อยู่กึ่งกลาง", + "textAlignRight": "จัดตำแหน่งข้อความให้ชิดขวา" + }, + "couldNotLoadErrorMsg": "ไม่สามารถโหลดทางลัดได้ ลองอีกครั้ง", + "couldNotSaveErrorMsg": "ไม่สามารถบันทึกทางลัดได้ โปรดลองอีกครั้ง" + }, + "aiPage": { + "title": "การตั้งค่า AI", + "menuLabel": "การตั้งค่า AI", + "keys": { + "enableAISearchTitle": "การค้นหาด้วย AI", + "aiSettingsDescription": "เลือกโมเดลที่คุณต้องการใช้เพื่อขับเคลื่อน AppFlowy AI ขณะนี้มี GPT-4, Claude 3.5, Llama 3.1, และ Mistral 7B", + "loginToEnableAIFeature": "ฟีเจอร์ AI จะเปิดใช้งานได้หลังจากเข้าสู่ระบบด้วย @:appName Cloud เท่านั้น หากคุณไม่มีบัญชี @:appName ให้ไปที่ 'บัญชีของฉัน' เพื่อลงทะเบียน", + "llmModel": "โมเดลภาษา", + "llmModelType": "ประเภทของโมเดลภาษา", + "downloadLLMPrompt": "ดาวน์โหลด {}", + "downloadAppFlowyOfflineAI": "การดาวน์โหลดแพ็กเกจ AI แบบออฟไลน์จะทำให้ AI สามารถทำงานบนอุปกรณ์ของคุณได้ คุณต้องการดำเนินการต่อหรือไม่?", + "downloadLLMPromptDetail": "การดาวน์โหลดโมเดล {} ในเครื่องของคุณ จะใช้พื้นที่เก็บข้อมูลสูงสุด {} คุณต้องการดำเนินการต่อหรือไม่", + "downloadBigFilePrompt": "การดาวน์โหลดอาจใช้เวลาประมาณ 10 นาทีให้เสร็จสิ้น", + "downloadAIModelButton": "ดาวน์โหลด", + "downloadingModel": "กำลังดาวน์โหลด", + "localAILoaded": "เพิ่มโมเดล AI ในเครื่องสำเร็จ และพร้อมใช้งานแล้ว", + "localAIStart": "การแชท Local AI กำลังเริ่มต้น...", + "localAILoading": "กำลังโหลดโมเดลแชท Local AI...", + "localAIStopped": "Local AI หยุดทำงานแล้ว", + "failToLoadLocalAI": "ไม่สามารถเริ่มต้น Local AI ได้", + "restartLocalAI": "เริ่มต้น Local AI ใหม่", + "disableLocalAITitle": "ปิดการใช้งาน Local AI", + "disableLocalAIDescription": "คุณต้องการปิดการใช้งาน Local AI หรือไม่?", + "localAIToggleTitle": "สลับเพื่อเปิดหรือปิดใช้งาน Local AI", + "offlineAIInstruction1": "ติดตาม", + "offlineAIInstruction2": "คำแนะนำ", + "offlineAIInstruction3": "เพื่อเปิดใช้งาน AI แบบออฟไลน์", + "offlineAIDownload1": "หากคุณยังไม่ได้ดาวน์โหลด AppFlowy AI, กรุณา", + "offlineAIDownload2": "ดาวน์โหลด", + "offlineAIDownload3": "เป็นอันดับแรก", + "activeOfflineAI": "ใช้งานอยู่", + "downloadOfflineAI": "ดาวน์โหลด", + "openModelDirectory": "เปิดโฟลเดอร์" + } + }, + "planPage": { + "menuLabel": "แผน", + "title": "แผนราคา", + "planUsage": { + "title": "สรุปการใช้งานแผน", + "storageLabel": "พื้นที่จัดเก็บ", + "storageUsage": "{} จาก {} GB", + "unlimitedStorageLabel": "พื้นที่เก็บข้อมูลไม่จำกัด", + "collaboratorsLabel": "สมาชิก", + "collaboratorsUsage": "{} จาก {}", + "aiResponseLabel": "การตอบกลับของ AI", + "aiResponseUsage": "{} จาก {}", + "unlimitedAILabel": "การตอบกลับแบบไม่จำกัด", + "proBadge": "Pro", + "aiMaxBadge": "AI Max", + "aiOnDeviceBadge": "AI บนอุปกรณ์สำหรับ Mac", + "memberProToggle": "สมาชิกมากขึ้น และ AI ไม่จำกัด", + "aiMaxToggle": "AI ไม่จำกัด และการเข้าถึงโมเดลขั้นสูง", + "aiOnDeviceToggle": "Local AI เพื่อความเป็นส่วนตัวสูงสุด", + "aiCredit": { + "title": "เพิ่ม @:appName AI เครดิต", + "price": "{}", + "priceDescription": "สำหรับ 1,000 เครดิต", + "purchase": "ซื้อ AI", + "info": "เพิ่มเครดิต AI 1,000 เครดิตต่อพื้นที่ทำงาน และผสานรวม AI ที่สามารถปรับแต่งได้ เข้าไปในกระบวนการทำงานของคุณได้อย่างราบรื่น เพื่อผลลัพธ์ที่ชาญฉลาด และรวดเร็วยิ่งขึ้นด้วย สูงสุดถึง:", + "infoItemOne": "10,000 การตอบกลับต่อฐานข้อมูล", + "infoItemTwo": "1,000 การตอบกลับต่อพื้นที่ทำงาน" + }, + "currentPlan": { + "bannerLabel": "แผนปัจจุบัน", + "freeTitle": "Free", + "proTitle": "Pro", + "teamTitle": "Team", + "freeInfo": "เหมาะสำหรับบุคคลที่มีสมาชิกสูงสุด 2 คน เพื่อจัดระเบียบทุกอย่าง", + "proInfo": "เหมาะสำหรับทีมขนาดเล็ก และขนาดกลางที่มีสมาชิกไม่เกิน 10 คน", + "teamInfo": "เหมาะสำหรับทีมที่มีประสิทธิภาพ และการจัดระเบียบที่ดี", + "upgrade": "เปลี่ยนแผน", + "canceledInfo": "แผนของคุณถูกยกเลิก คุณจะถูกปรับลดเป็นแผน Free ในวันที่ {}" + }, + "addons": { + "title": "ส่วนเสริม", + "addLabel": "เพิ่ม", + "activeLabel": "เพิ่มแล้ว", + "aiMax": { + "title": "AI Max", + "description": "การตอบกลับ AI แบบไม่จำกัด ที่ขับเคลื่อนโดย GPT-4o, Claude 3.5 Sonnet และอื่นๆ", + "price": "{}", + "priceInfo": "ต่อผู้ใช้ ต่อเดือน เก็บค่าบริการเป็นรายปี" + }, + "aiOnDevice": { + "title": "AI บนอุปกรณ์สำหรับ Mac", + "description": "เรียกใช้ Mistral 7B, LLAMA 3 และโมเดล local อื่น ๆ บนเครื่องของคุณ", + "price": "{}", + "priceInfo": "ต่อผู้ใช้ ต่อเดือน เก็บค่าบริการเป็นรายปี", + "recommend": "แนะนำ M1 หรือใหม่กว่า" + } + }, + "deal": { + "bannerLabel": "ดีลปีใหม่!", + "title": "ขยายทีมของคุณ!", + "info": "อัปเกรด และรับส่วนลด 10% สำหรับแผน Pro และ Team! เพิ่มประสิทธิภาพการทำงานในพื้นที่ทำงานของคุณด้วยฟีเจอร์ใหม่อันทรงพลัง รวมถึง @:appName AI", + "viewPlans": "ดูแผน" + } + } + }, + "billingPage": { + "menuLabel": "การเรียกเก็บเงิน", + "title": "การเรียกเก็บเงิน", + "plan": { + "title": "แผน", + "freeLabel": "Free", + "proLabel": "Pro", + "planButtonLabel": "เปลี่ยนแผน", + "billingPeriod": "รอบบิล", + "periodButtonLabel": "แก้ไขช่วงเวลา" + }, + "paymentDetails": { + "title": "รายละเอียดการชำระเงิน", + "methodLabel": "วิธีการชำระเงิน", + "methodButtonLabel": "แก้ไขวิธีการ" + }, + "addons": { + "title": "ส่วนเสริม", + "addLabel": "เพิ่ม", + "removeLabel": "ลบ", + "renewLabel": "ต่ออายุ", + "aiMax": { + "label": "AI Max", + "description": "ปลดล็อค AI และโมเดลขั้นสูงแบบไม่จำกัด", + "activeDescription": "ใบแจ้งหนี้ถัดไปจะครบกำหนดในวันที่ {}", + "canceledDescription": "AI Max จะพร้อมใช้งานจนถึงวันที่ {}" + }, + "aiOnDevice": { + "label": "AI บนอุปกรณ์สำหรับ Mac", + "description": "ปลดล็อค AI ไม่จำกัดบนอุปกรณ์ของคุณ", + "activeDescription": "ใบแจ้งหนี้ถัดไปจะครบกำหนดในวันที่ {}", + "canceledDescription": "AI บนอุปกรณ์สำหรับ Mac จะพร้อมใช้งานจนถึงวันที่ {}" + }, + "removeDialog": { + "title": "ลบ {}", + "description": "คุณแน่ใจหรือไม่ว่าต้องการลบ {plan}? คุณจะสูญเสียการเข้าถึงฟีเจอร์ และสิทธิประโยชน์ของ {plan} ทันที" + } + }, + "currentPeriodBadge": "ปัจจุบัน", + "changePeriod": "การเปลี่ยนแปลงช่วงเวลา", + "planPeriod": "{} รอบ", + "monthlyInterval": "รายเดือน", + "monthlyPriceInfo": "คิดค่าบริการต่อที่นั่งแบบรายเดือน", + "annualInterval": "รายปี", + "annualPriceInfo": "คิดค่าบริการต่อที่นั่งแบบรายปี" + }, + "comparePlanDialog": { + "title": "เปรียบเทียบและเลือกแผน", + "planFeatures": "แผน\nฟีเจอร์", + "current": "ปัจจุบัน", + "actions": { + "upgrade": "อัพเกรด", + "downgrade": "ลดระดับ", + "current": "ปัจจุบัน" + }, + "freePlan": { + "title": "Free", + "description": "สำหรับบุคคลตั้งแต่ 2 คนขึ้นไป เพื่อจัดระเบียบทุกอย่าง", + "price": "{}", + "priceInfo": "ฟรีตลอดไป" + }, + "proPlan": { + "title": "Pro", + "description": "สำหรับทีมขนาดเล็กเพื่อจัดการโครงการและความรู้ของทีม", + "price": "{}", + "priceInfo": "ต่อผู้ใช้ ต่อเดือน\nเรียกเก็บค่าบริการรายปี\n{} เรียกเก็บค่าบริการรายเดือน" + }, + "planLabels": { + "itemOne": "พื้นที่ทำงาน", + "itemTwo": "สมาชิก", + "itemThree": "พื้นที่จัดเก็บ", + "itemFour": "การทำงานร่วมกันแบบเรียลไทม์", + "itemFive": "แอพมือถือ", + "itemSix": "การตอบกลับของ AI", + "itemFileUpload": "การอัพโหลดไฟล์", + "customNamespace": "เนมสเปซที่กำหนดเอง", + "tooltipSix": "ตลอดอายุการใช้งาน หมายถึง จำนวนการตอบกลับที่ไม่รีเซ็ต", + "intelligentSearch": "การค้นหาอัจฉริยะ", + "tooltipSeven": "อนุญาตให้คุณปรับแต่งส่วนหนึ่งของ URL สำหรับพื้นที่ทำงานของคุณ", + "customNamespaceTooltip": "URL ของไซต์ที่เผยแพร่แบบกำหนดเอง" + }, + "freeLabels": { + "itemOne": "คิดค่าบริการตามพื้นที่ทำงาน", + "itemTwo": "สูงสุด 2", + "itemThree": "5 GB", + "itemFour": "ใช่", + "itemFive": "ใช่", + "itemSix": "10 ตลอดอายุการใช้งาน", + "itemFileUpload": "ไม่เกิน 7 MB", + "intelligentSearch": "การค้นหาอัจฉริยะ" + }, + "proLabels": { + "itemOne": "คิดค่าบริการตามพื้นที่ทำงาน", + "itemTwo": "สูงถึง 10", + "itemThree": "ไม่จำกัด", + "itemFour": "ใช่", + "itemFive": "ใช่", + "itemSix": "ไม่จำกัด", + "itemFileUpload": "ไม่จำกัด", + "intelligentSearch": "การค้นหาอัจฉริยะ" + }, + "paymentSuccess": { + "title": "ตอนนี้คุณอยู่ในแผน {} แล้ว!", + "description": "การชำระเงินของคุณได้รับการดำเนินการเรียบร้อย แล้วและแผนของคุณได้รับการอัปเกรดเป็น @:appName {} คุณสามารถดูรายละเอียดแผนของคุณได้ในหน้าแผน" + }, + "downgradeDialog": { + "title": "คุณแน่ใจหรือไม่ว่าต้องการลดระดับแผนของคุณ?", + "description": "การลดระดับแผนของคุณจะทำให้คุณกลับไปใช้แผนฟรี สมาชิกอาจสูญเสียสิทธิ์การเข้าถึงพื้นที่ทำงานนี้ และคุณอาจต้องเพิ่มพื้นที่ว่างเพื่อให้ตรงตามขีดจำกัดพื้นที่เก็บข้อมูลของแผนฟรี", + "downgradeLabel": "การลดระดับแผน" + } + }, + "cancelSurveyDialog": { + "title": "เสียใจที่เห็นคุณไป", + "description": "เราเสียใจที่เห็นคุณไป เรายินดีรับฟังคำติชมของคุณเพื่อช่วยเราปรับปรุง @:appName โปรดใช้เวลาสักครู่เพื่อตอบคำถามสองสามข้อ", + "commonOther": "อื่นๆ", + "otherHint": "เขียนคำตอบของคุณที่นี่", + "questionOne": { + "question": "อะไรเป็นเหตุผลที่ทำให้คุณยกเลิกการสมัครสมาชิก @:appName Pro?", + "answerOne": "ราคาสูงเกินไป", + "answerTwo": "ฟีเจอร์ไม่เป็นไปตามที่คาดไว้", + "answerThree": "พบทางเลือกที่ดีกว่า", + "answerFour": "ใช้ไม่มากพอที่จะคุ้มกับค่าใช้จ่าย", + "answerFive": "ปัญหาด้านการบริการหรือความยุ่งยากทางเทคนิค" + }, + "questionTwo": { + "question": "คุณมีความเป็นไปได้มากแค่ไหนที่จะพิจารณากลับไปสมัครสมาชิก @:appName Pro ในอนาคต?", + "answerOne": "เป็นไปได้มาก", + "answerTwo": "ค่อนข้างจะเป็นไปได้", + "answerThree": "ไม่แน่ใจ", + "answerFour": "ไม่น่าจะเป็นไปได้", + "answerFive": "ไม่น่าจะเป็นไปได้มาก" + }, + "questionThree": { + "question": "ฟีเจอร์ Pro อะไรที่คุณคิดว่าคุ้มค่าที่สุดในระหว่างที่ใช้บริการ?", + "answerOne": "การทำงานร่วมกันระหว่างผู้ใช้หลายราย", + "answerTwo": "ประวัติเวอร์ชันที่ยาวนานกว่า", + "answerThree": "การตอบกลับของ AI ไม่จำกัด", + "answerFour": "การเข้าถึงโมเดล AI ในเครื่อง" + }, + "questionFour": { + "question": "คุณคิดว่าประสบการณ์โดยรวมกับ @:appName เป็นอย่างไร?", + "answerOne": "ยอดเยี่ยม", + "answerTwo": "ดี", + "answerThree": "ปานกลาง", + "answerFour": "ต่ำกว่าค่าเฉลี่ย", + "answerFive": "ไม่พึงพอใจ" + } + }, + "common": { + "uploadingFile": "กำลังอัพโหลดไฟล์ กรุณาอย่าออกจากแอป", + "uploadNotionSuccess": "ไฟล์ zip ของ Notion ของคุณได้รับการอัปโหลดเรียบร้อยแล้ว เมื่อการนำเข้าเสร็จสมบูรณ์ คุณจะได้รับอีเมลยืนยัน", + "reset": "รีเซ็ต" + }, "menu": { "appearance": "รูปลักษณ์", "language": "ภาษา", @@ -272,20 +1044,21 @@ "cloudServerType": "เซิร์ฟเวอร์คลาวด์", "cloudServerTypeTip": "โปรดทราบว่าอาจมีการออกจากระบบบัญชีปัจจุบันของคุณหลังจากเปลี่ยนเซิร์ฟเวอร์คลาวด์", "cloudLocal": "ในเครื่อง", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "URL ของ Supabase", - "cloudSupabaseAnonKey": "คีย์ไม่ระบุชื่อของ Supabase", - "cloudSupabaseAnonKeyCanNotBeEmpty": "หากมีการระบุ URL ของ Supabase คีย์ไม่ระบุชื่อจะต้องไม่ว่างเปล่า", "cloudAppFlowy": "คลาวด์ของ AppFlowy", + "cloudAppFlowySelfHost": "@:appName คลาวด์ที่โฮสต์เอง", + "appFlowyCloudUrlCanNotBeEmpty": "URL ของคลาวด์ไม่สามารถเว้นว่างได้", "clickToCopy": "คลิกเพื่อคัดลอก", "selfHostStart": "หากคุณไม่มีเซิร์ฟเวอร์ โปรดดูที่", "selfHostContent": "เอกสาร", "selfHostEnd": "สำหรับคำแนะนำเกี่ยวกับวิธีการโฮสต์เซิร์ฟเวอร์ของคุณเอง", + "pleaseInputValidURL": "กรุณาใส่ URL ที่ถูกต้อง", + "changeUrl": "เปลี่ยน URL ที่โฮสต์เองเป็น {}", "cloudURLHint": "ป้อน URL หลักของเซิร์ฟเวอร์ของคุณ", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "ป้อนที่อยู่ websocket ของเซิร์ฟเวอร์ของคุณ", "restartApp": "รีสตาร์ท", "restartAppTip": "รีสตาร์ทแอปพลิเคชันเพื่อให้มีการเปลี่ยนแปลง โปรดทราบไว้ว่าการดำเนินการนี้อาจจะออกจากระบบบัญชีปัจจุบันของคุณ", + "changeServerTip": "หลังจากเปลี่ยนเซิร์ฟเวอร์แล้ว คุณต้องคลิกปุ่มรีสตาร์ทเพื่อให้การเปลี่ยนแปลงมีผล", "enableEncryptPrompt": "เปิดใช้งานการเข้ารหัสเพื่อรักษาความปลอดภัยข้อมูลของคุณแบบเป็นความลับ จะเก็บไว้อย่างปลอดภัย เมื่อเปิดใช้งานแล้วจะไม่สามารถปิดได้ หากสูญหาย ข้อมูลของคุณจะไม่สามารถเรียกคืนได้ คลิกเพื่อคัดลอก", "inputEncryptPrompt": "กรุณาระบุการเข้ารหัสลับของคุณสำหรับ", "clickToCopySecret": "คลิกเพื่อคัดลอกรหัสลับ", @@ -295,19 +1068,70 @@ "historicalUserList": "ประวัติการเข้าสู่ระบบของผู้ใช้", "historicalUserListTooltip": "รายการนี้จะแสดงบัญชีที่ไม่ระบุตัวตนของคุณ คุณสามารถคลิกที่บัญชีเพื่อดูรายละเอียดได้ บัญชีที่ไม่เปิดเผยตัวตนถูกสร้างขึ้นโดยการคลิกปุ่ม 'เริ่มต้น'", "openHistoricalUser": "คลิกเพื่อเปิดบัญชีที่ไม่ระบุตัวตน", - "customPathPrompt": "การจัดเก็บโฟลเดอร์ข้อมูลของ AppFlowy ไว้ในโฟลเดอร์ที่ซิงค์บนคลาวด์ เช่น Google Drive อาจทำให้เกิดความเสี่ยงได้ หากมีการเข้าถึงหรือแก้ไขฐานข้อมูลภายในโฟลเดอร์นี้จากหลายตำแหน่งพร้อมกัน อาจส่งผลให้เกิดความขัดแย้งในการซิงโครไนซ์และข้อมูลอาจเสียหายได้" + "customPathPrompt": "การจัดเก็บโฟลเดอร์ข้อมูลของ AppFlowy ไว้ในโฟลเดอร์ที่ซิงค์บนคลาวด์ เช่น Google Drive อาจทำให้เกิดความเสี่ยงได้ หากมีการเข้าถึงหรือแก้ไขฐานข้อมูลภายในโฟลเดอร์นี้จากหลายตำแหน่งพร้อมกัน อาจส่งผลให้เกิดความขัดแย้งในการซิงโครไนซ์และข้อมูลอาจเสียหายได้", + "importAppFlowyData": "นำเข้าข้อมูลจากโฟลเดอร์ @:appName ภายนอก", + "importingAppFlowyDataTip": "กำลังดำเนินการนำเข้าข้อมูล โปรดอย่าปิดแอป", + "importAppFlowyDataDescription": "คัดลอกข้อมูลจากโฟลเดอร์ข้อมูลภายนอกของ @:appName และนำเข้าไปยังโฟลเดอร์ข้อมูล AppFlowy ปัจจุบัน", + "importSuccess": "นำเข้าโฟลเดอร์ข้อมูล @:appName สำเร็จแล้ว", + "importFailed": "การนำเข้าโฟลเดอร์ข้อมูล @:appName ล้มเหลว", + "importGuide": "สำหรับรายละเอียดเพิ่มเติม โปรดตรวจสอบเอกสารอ้างอิง" }, "notifications": { "enableNotifications": { "label": "เปิดใช้งานการแจ้งเตือน", "hint": "ปิดเพื่อหยุดไม่ให้แสดงการแจ้งเตือนในเครื่องของคุณ" + }, + "showNotificationsIcon": { + "label": "แสดงไอคอนการแจ้งเตือน", + "hint": "ปิดเพื่อซ่อนไอคอนการแจ้งเตือนในแถบด้านข้าง" + }, + "archiveNotifications": { + "allSuccess": "จัดเก็บการแจ้งเตือนทั้งหมดเรียบร้อยแล้ว", + "success": "จัดเก็บการแจ้งเตือนเรียบร้อยแล้ว" + }, + "markAsReadNotifications": { + "allSuccess": "ทำเครื่องหมายทั้งหมดว่าอ่านแล้วสำเร็จแล้ว", + "success": "ทำเครื่องหมายว่าอ่านแล้วสำเร็จ" + }, + "action": { + "markAsRead": "ทำเครื่องหมายว่าอ่านแล้ว", + "multipleChoice": "เลือกเพิ่มเติม", + "archive": "คลังเก็บเอกสารสำคัญ" + }, + "settings": { + "settings": "การตั้งค่า", + "markAllAsRead": "ทำเครื่องหมายทั้งหมดว่าอ่านแล้ว", + "archiveAll": "เก็บถาวรทั้งหมด" + }, + "emptyInbox": { + "title": "กล่องข้อความว่างเปล่า", + "description": "ตั้งค่าการเตือนเพื่อรับการแจ้งเตือนที่นี่" + }, + "emptyUnread": { + "title": "ไม่มีการแจ้งเตือนที่ยังไม่ได้อ่าน", + "description": "คุณตามทันทั้งหมดแล้ว!" + }, + "emptyArchived": { + "title": "ไม่มีการเก็บถาวร", + "description": "การแจ้งเตือนที่เก็บถาวรจะปรากฏที่นี่" + }, + "tabs": { + "inbox": "กล่องข้อความ", + "unread": "ยังไม่ได้อ่าน", + "archived": "เก็บถาวร" + }, + "refreshSuccess": "รีเฟรชการแจ้งเตือนสำเร็จ", + "titles": { + "notifications": "การแจ้งเตือน", + "reminder": "การเตือน" } }, "appearance": { "resetSetting": "รีเซ็ตการตั้งค่านี้", "fontFamily": { "label": "แบบตัวอักษร", - "search": "ค้นหา" + "search": "ค้นหา", + "defaultFont": "ระบบ" }, "themeMode": { "label": "โหมดธีม", @@ -315,6 +1139,24 @@ "dark": "โหมดมืด", "system": "ใช้ตามระบบ" }, + "fontScaleFactor": "ตัวปรับขนาดฟอนต์", + "documentSettings": { + "cursorColor": "สีเคอร์เซอร์เอกสาร", + "selectionColor": "สีการเลือกเอกสาร", + "width": "ความกว้างของเอกสาร", + "changeWidth": "เปลี่ยน", + "pickColor": "เลือกสี", + "colorShade": "เฉดสี", + "opacity": "ความทึบแสง", + "hexEmptyError": "รหัสสีเลขฐานสิบหกไม่สามารถเว้นว่างได้", + "hexLengthError": "ค่าเลขฐานสิบหกต้องมีความยาว 6 หลัก", + "hexInvalidError": "ค่าเลขฐานสิบหกไม่ถูกต้อง", + "opacityEmptyError": "ความทึบแสงไม่สามารถเว้นว่างได้", + "opacityRangeError": "ความทึบแสงต้องอยู่ระหว่าง 1 และ 100", + "app": "App", + "flowy": "Flowy", + "apply": "นำไปใช้" + }, "layoutDirection": { "label": "ทิศทางของเค้าโครง", "hint": "ควบคุมการไหลของเนื้อหาบนหน้าจอของคุณ จากซ้ายไปขวาหรือจากขวาไปซ้าย", @@ -333,12 +1175,12 @@ "button": "อัปโหลด", "uploadTheme": "อัปโหลดธีม", "description": "อัปโหลดธีม AppFlowy ของคุณเองโดยใช้ปุ่มด้านล่าง", - "failure": "ธีมที่อัปโหลดมีรูปแบบที่ไม่ถูกต้อง", "loading": "โปรดรอสักครู่ในขณะที่เราตรวจสอบและอัปโหลดธีมของคุณ...", "uploadSuccess": "อัปโหลดธีมของคุณสำเร็จแล้ว", "deletionFailure": "ไม่สามารถลบธีมได้ ลองลบมันด้วยตนเอง", "filePickerDialogTitle": "เลือกไฟล์ .flowy_plugin", - "urlUploadFailure": "ไม่สามารถเปิด URL: {} ได้" + "urlUploadFailure": "ไม่สามารถเปิด URL: {} ได้", + "failure": "ธีมที่อัปโหลดมีรูปแบบที่ไม่ถูกต้อง" }, "theme": "ธีม", "builtInsLabel": "ธีมในตัวแอป", @@ -356,7 +1198,49 @@ "twelveHour": "สิบสองชั่วโมง", "twentyFourHour": "ยี่สิบสี่ชั่วโมง" }, - "showNamingDialogWhenCreatingPage": "แสดงกล่องโต้ตอบการตั้งชื่อเมื่อสร้างหน้า" + "showNamingDialogWhenCreatingPage": "แสดงกล่องโต้ตอบการตั้งชื่อเมื่อสร้างหน้า", + "enableRTLToolbarItems": "เปิดใช้งานแถบเครื่องมือ RTL (จากขวาไปซ้าย)", + "members": { + "title": "การตั้งค่าสมาชิก", + "inviteMembers": "ส่งคำเชิญสมาชิก", + "inviteHint": "ส่งคำเชิญทางอีเมล", + "sendInvite": "ส่งคำเชิญ", + "copyInviteLink": "คัดลอกลิงค์คำเชิญ", + "label": "สมาชิก", + "user": "ผู้ใช้", + "role": "บทบาท", + "removeFromWorkspace": "ลบออกจากพื้นที่ทำงาน", + "removeFromWorkspaceSuccess": "ลบออกจากพื้นที่ทำงานสำเร็จ", + "removeFromWorkspaceFailed": "ลบออกจากพื้นที่ทำงานไม่สำเร็จ", + "owner": "เจ้าของ", + "guest": "ผู้เยี่ยมชม", + "member": "สมาชิก", + "memberHintText": "สมาชิกสามารถอ่านและแก้ไขหน้าได้", + "guestHintText": "ผู้เยี่ยมชมสามารถอ่าน ตอบสนอง แสดงความคิดเห็น และแก้ไขหน้าบางหน้าได้ด้วยการอนุญาต", + "emailInvalidError": "อีเมล์ไม่ถูกต้อง กรุณาตรวจสอบและลองอีกครั้ง", + "emailSent": "ส่งอีเมลแล้ว กรุณาตรวจสอบกล่องจดหมาย", + "members": "สมาชิก", + "membersCount": { + "zero": "{} สมาชิก", + "one": "{} สมาชิก", + "other": "{} สมาชิก" + }, + "inviteFailedDialogTitle": "ไม่สามารถส่งคำเชิญได้", + "inviteFailedMemberLimit": "จำนวนสมาชิกถึงขีดจำกัดแล้ว กรุณาอัปเกรดเพื่อเชิญสมาชิกเพิ่มเติม", + "inviteFailedMemberLimitMobile": "พื้นที่ทำงานของคุณถึงขีดจำกัดจำนวนสมาชิกแล้ว", + "memberLimitExceeded": "จำนวนสมาชิกถึงขีดจำกัดแล้ว เพื่อเชิญสมาชิกเพิ่มเติม กรุณา...", + "memberLimitExceededUpgrade": "อัพเกรด", + "memberLimitExceededPro": "จำนวนสมาชิกถึงขีดจำกัดแล้ว หากต้องการสมาชิกเพิ่มเติม กรุณาติดต่อ ", + "memberLimitExceededProContact": "support@appflowy.io", + "failedToAddMember": "เพิ่มสมาชิกไม่สำเร็จ", + "addMemberSuccess": "เพิ่มสมาชิกสำเร็จ", + "removeMember": "ลบสมาชิก", + "areYouSureToRemoveMember": "คุณแน่ใจหรือไม่ว่าต้องการลบสมาชิกคนนี้ออก?", + "inviteMemberSuccess": "ส่งคำเชิญเรียบร้อยแล้ว", + "failedToInviteMember": "เชิญสมาชิกไม่สำเร็จ", + "workspaceMembersError": "อุ๊ปส์, มีบางอย่างผิดปกติ", + "workspaceMembersErrorDescription": "เราไม่สามารถโหลดรายชื่อสมาชิกได้ในขณะนี้ กรุณาลองอีกครั้งในภายหลัง" + } }, "files": { "copy": "คัดลอก", @@ -392,27 +1276,20 @@ "recoverLocationTooltips": "รีเซ็ตเป็นไดเร็กทอรีข้อมูลเริ่มต้นของ AppFlowy", "exportFileSuccess": "ส่งออกไฟล์สำเร็จ!", "exportFileFail": "ส่งออกไฟล์ล้มเหลว!", - "export": "ส่งออก" + "export": "ส่งออก", + "clearCache": "ล้างแคช", + "clearCacheDesc": "หากคุณพบปัญหาเกี่ยวกับรูปภาพไม่โหลด หรือฟอนต์ไม่แสดงอย่างถูกต้อง ให้ลองล้างแคช การดำเนินการนี้จะไม่ลบข้อมูลผู้ใช้ของคุณ", + "areYouSureToClearCache": "คุณแน่ใจหรือไม่ที่จะล้างแคช?", + "clearCacheSuccess": "ล้างแคชสำเร็จ!" }, "user": { "name": "ชื่อ", "email": "อีเมล", "tooltipSelectIcon": "เลือกไอคอน", "selectAnIcon": "เลือกไอคอน", - "pleaseInputYourOpenAIKey": "โปรดระบุคีย์ OpenAI ของคุณ", - "pleaseInputYourStabilityAIKey": "โปรดระบุคีย์ Stability AI ของคุณ", - "clickToLogout": "คลิกเพื่อออกจากระบบผู้ใช้ปัจจุบัน" - }, - "shortcuts": { - "shortcutsLabel": "ทางลัด", - "command": "คำสั่ง", - "keyBinding": "การผูกแป้นพิมพ์", - "addNewCommand": "เพิ่มคำสั่งใหม่", - "updateShortcutStep": "กดปุ่มแป้นพิมพ์ที่ต้องการแล้วกด ENTER", - "shortcutIsAlreadyUsed": "ทางลัดนี้ถูกใช้แล้วสำหรับ: {conflict}", - "resetToDefault": "รีเซ็ตการกำหนดแป้นพิมพ์เป็นค่าเริ่มต้น", - "couldNotLoadErrorMsg": "ไม่สามารถโหลดทางลัดได้ ลองอีกครั้ง", - "couldNotSaveErrorMsg": "ไม่สามารถบันทึกทางลัดได้ ลองอีกครั้ง" + "pleaseInputYourOpenAIKey": "โปรดระบุคีย์ AI ของคุณ", + "clickToLogout": "คลิกเพื่อออกจากระบบผู้ใช้ปัจจุบัน", + "pleaseInputYourStabilityAIKey": "โปรดระบุคีย์ Stability AI ของคุณ" }, "mobile": { "personalInfo": "ข้อมูลส่วนตัว", @@ -424,10 +1301,23 @@ "joinDiscord": "เข้าร่วมกับเราบน Discord", "privacyPolicy": "นโยบายความเป็นส่วนตัว", "userAgreement": "ข้อตกลงผู้ใช้", + "termsAndConditions": "ข้อกำหนดและเงื่อนไข", "userprofileError": "ไม่สามารถโหลดโปรไฟล์ผู้ใช้ได้", "userprofileErrorDescription": "โปรดลองออกจากระบบแล้วเข้าสู่ระบบอีกครั้งเพื่อตรวจสอบว่าปัญหายังคงอยู่หรือไม่", "selectLayout": "เลือกเค้าโครง", - "selectStartingDay": "เลือกวันเริ่มต้น" + "selectStartingDay": "เลือกวันเริ่มต้น", + "version": "เวอร์ชัน" + }, + "shortcuts": { + "shortcutsLabel": "ทางลัด", + "command": "คำสั่ง", + "keyBinding": "การผูกแป้นพิมพ์", + "addNewCommand": "เพิ่มคำสั่งใหม่", + "updateShortcutStep": "กดปุ่มแป้นพิมพ์ที่ต้องการแล้วกด ENTER", + "shortcutIsAlreadyUsed": "ทางลัดนี้ถูกใช้แล้วสำหรับ: {conflict}", + "resetToDefault": "รีเซ็ตการกำหนดแป้นพิมพ์เป็นค่าเริ่มต้น", + "couldNotLoadErrorMsg": "ไม่สามารถโหลดทางลัดได้ ลองอีกครั้ง", + "couldNotSaveErrorMsg": "ไม่สามารถบันทึกทางลัดได้ ลองอีกครั้ง" } }, "grid": { @@ -448,7 +1338,26 @@ "filterBy": "กรองตาม...", "typeAValue": "พิมพ์ค่า...", "layout": "เค้าโครง", - "databaseLayout": "เค้าโครงฐานข้อมูล" + "databaseLayout": "เค้าโครงฐานข้อมูล", + "viewList": { + "zero": "0 มุมมอง", + "one": "{count} มุมมอง", + "other": "{count} มุมมอง" + }, + "editView": "แก้ไขมุมมอง", + "boardSettings": "การตั้งค่าบอร์ด", + "calendarSettings": "การตั้งค่าปฏิทิน", + "createView": "มุมมองใหม่", + "duplicateView": "ทำสำเนามุมมอง", + "deleteView": "ลบมุมมอง", + "numberOfVisibleFields": "{} ที่แสดงอยู่" + }, + "filter": { + "empty": "ไม่มีตัวกรองที่ใช้งานอยู่", + "addFilter": "เพิ่มตัวกรอง", + "cannotFindCreatableField": "ไม่พบฟิลด์ที่เหมาะสมในการกรอง", + "conditon": "เงื่อนไข", + "where": "โดยที่" }, "textFilter": { "contains": "ประกอบด้วย", @@ -494,15 +1403,40 @@ "onOrAfter": "อยู่ในหรือหลังจาก", "between": "อยู่ระหว่าง", "empty": "มันว่างเปล่า", - "notEmpty": "มันไม่ว่างเปล่า" + "notEmpty": "มันไม่ว่างเปล่า", + "startDate": "วันที่เริ่มต้น", + "endDate": "วันที่สิ้นสุด", + "choicechipPrefix": { + "before": "ก่อน", + "after": "หลังจาก", + "between": "ระหว่าง", + "onOrBefore": "ภายในหรือก่อน", + "onOrAfter": "ภายในหรือหลัง", + "isEmpty": "ว่างเปล่า", + "isNotEmpty": "ไม่ว่างเปล่า" + } + }, + "numberFilter": { + "equal": "เท่ากับ", + "notEqual": "ไม่เท่ากับ", + "lessThan": "น้อยกว่า", + "greaterThan": "มากกว่า", + "lessThanOrEqualTo": "น้อยกว่าหรือเท่ากับ", + "greaterThanOrEqualTo": "มากกว่าหรือเท่ากับ", + "isEmpty": "ว่างเปล่า", + "isNotEmpty": "ไม่ว่างเปล่า" }, "field": { + "label": "คุณสมบัติ", "hide": "ซ่อน", "show": "แสดง", "insertLeft": "แทรกทางซ้าย", "insertRight": "แทรกทางขวา", "duplicate": "ทำสำเนา", "delete": "ลบ", + "wrapCellContent": "ตัดข้อความ", + "clear": "ล้างเซลล์", + "switchPrimaryFieldTooltip": "ไม่สามารถเปลี่ยนประเภทฟิลด์ของฟิลด์หลักได้", "textFieldName": "ข้อความ", "checkboxFieldName": "กล่องกาเครื่องหมาย", "dateFieldName": "วันที่", @@ -513,6 +1447,12 @@ "multiSelectFieldName": "การเลือกหลายรายการ", "urlFieldName": "URL", "checklistFieldName": "รายการตรวจสอบ", + "relationFieldName": "ความสัมพันธ์", + "summaryFieldName": "AI สรุป", + "timeFieldName": "เวลา", + "mediaFieldName": "ไฟล์และสื่อ", + "translateFieldName": "AI แปล", + "translateTo": "แปลเป็น", "numberFormat": "รูปแบบตัวเลข", "dateFormat": "รูปแบบวันที่", "includeTime": "รวมเวลา", @@ -541,9 +1481,13 @@ "addOption": "เพิ่มตัวเลือก", "editProperty": "แก้ไขคุณสมบัติ", "newProperty": "คุณสมบัติใหม่", + "openRowDocument": "เปิดเป็นหน้า", "deleteFieldPromptMessage": "แน่ใจหรือไม่? คุณสมบัติเหล่านี้จะถูกลบ", + "clearFieldPromptMessage": "คุณแน่ใจหรือไม่ฦ เซลล์ทั้งหมดในคอลัมน์นี้จะถูกล้างข้อมูล", "newColumn": "คอลัมน์ใหม่", - "format": "รูปแบบ" + "format": "รูปแบบ", + "reminderOnDateTooltip": "เซลล์นี้มีการตั้งค่าการเตือนในวันที่กำหนด", + "optionAlreadyExist": "ตัวเลือกมีอยู่แล้ว" }, "rowPage": { "newField": "เพิ่มฟิลด์ใหม่", @@ -557,15 +1501,24 @@ "one": "ซ่อนฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์", "many": "ซ่อนฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์", "other": "ซ่อนฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์" - } + }, + "openAsFullPage": "เปิดแบบเต็มหน้า", + "moreRowActions": "การดำเนินการแถวเพิ่มเติม" }, "sort": { "ascending": "เรียงลำดับจากน้อยไปมาก", "descending": "เรียงลำดับจากมากไปน้อย", + "by": "โดย", + "empty": "ไม่มีการเรียงลำดับที่ใช้งานอยู่", + "cannotFindCreatableField": "ไม่พบฟิลด์ที่เหมาะสมในการเรียงลำดับ", "deleteAllSorts": "ลบการเรียงลำดับทั้งหมด", - "addSort": "เพิ่มการเรียงลำดับ" + "addSort": "เพิ่มการเรียงลำดับ", + "sortsActive": "ไม่สามารถ {intention} ขณะทำการจัดเรียง", + "removeSorting": "คุณต้องการลบการจัดเรียงทั้งหมดในมุมมองนี้ และดำเนินการต่อหรือไม่?", + "fieldInUse": "คุณกำลังเรียงลำดับตามฟิลด์นี้อยู่แล้ว" }, "row": { + "label": "แถว", "duplicate": "ทำสำเนา", "delete": "ลบ", "titlePlaceholder": "ไม่มีชื่อ", @@ -576,9 +1529,15 @@ "action": "การดำเนินการ", "add": "คลิกเพิ่มด้านล่าง", "drag": "ลากเพื่อย้าย", + "deleteRowPrompt": "คุณแน่ใจหรือไม่ว่าต้องการลบแถวนี้? การกระทำนี้ไม่สามารถย้อนกลับได้", + "deleteCardPrompt": "คุณแน่ใจหรือไม่ว่าต้องการลบการ์ดนี้? การกระทำนี้ไม่สามารถย้อนกลับได้", "dragAndClick": "ลากเพื่อย้ายคลิกเพื่อเปิดเมนู", "insertRecordAbove": "แทรกเวิ่นระเบียนด้านบน", - "insertRecordBelow": "แทรกเวิ่นระเบียนด้านล่าง" + "insertRecordBelow": "แทรกเวิ่นระเบียนด้านล่าง", + "noContent": "ไม่มีเนื้อหา", + "reorderRowDescription": "เรียงลำดับแถวใหม่", + "createRowAboveDescription": "สร้างแถวด้านบน", + "createRowBelowDescription": "สร้างแถวด้านล่าง" }, "selectOption": { "create": "สร้าง", @@ -610,10 +1569,53 @@ }, "url": { "launch": "เปิดในเบราว์เซอร์", - "copy": "คัดลอก URL" + "copy": "คัดลอก URL", + "textFieldHint": "ป้อน URL" + }, + "relation": { + "relatedDatabasePlaceLabel": "ฐานข้อมูลที่เกี่ยวข้อง", + "relatedDatabasePlaceholder": "ไม่มี", + "inRelatedDatabase": "ใน", + "rowSearchTextFieldPlaceholder": "ค้นหา", + "noDatabaseSelected": "ยังไม่ได้เลือกฐานข้อมูล กรุณาเลือกฐานข้อมูลหนึ่งรายการจากรายการด้านล่างก่อน", + "emptySearchResult": "ไม่พบข้อมูล", + "linkedRowListLabel": "{count} แถวที่เชื่อมโยง", + "unlinkedRowListLabel": "เชื่อมโยงแถวอื่น" }, "menuName": "ตาราง", - "referencedGridPrefix": "มุมมองของ" + "referencedGridPrefix": "มุมมองของ", + "calculate": "คำนวณ", + "calculationTypeLabel": { + "none": "ไม่มี", + "average": "เฉลี่ย", + "max": "สูงสุด", + "median": "มัธยฐาน", + "min": "ต่ำสุด", + "sum": "ผลรวม", + "count": "นับ", + "countEmpty": "นับที่ว่าง", + "countEmptyShort": "ว่างเปล่า", + "countNonEmpty": "นับที่ไม่ว่าง", + "countNonEmptyShort": "เติมแล้ว" + }, + "media": { + "rename": "เปลี่ยนชื่อ", + "download": "ดาวน์โหลด", + "expand": "ขยาย", + "delete": "ลบ", + "moreFilesHint": "+{}", + "addFileOrImage": "เพิ่มไฟล์หรือลิงค์", + "attachmentsHint": "{}", + "addFileMobile": "เพิ่มไฟล์", + "extraCount": "+{}", + "deleteFileDescription": "คุณแน่ใจหรือไม่ว่าต้องการลบไฟล์นี้? การกระทำนี้ไม่สามารถย้อนกลับได้", + "showFileNames": "แสดงชื่อไฟล์", + "downloadSuccess": "ดาวน์โหลดไฟล์แล้ว", + "downloadFailedToken": "ไม่สามารถดาวน์โหลดไฟล์ได้ โทเค็นผู้ใช้ไม่พร้อมใช้งาน", + "setAsCover": "ตั้งเป็นปก", + "openInBrowser": "เปิดในเบราว์เซอร์", + "embedLink": "ฝังลิงค์ไฟล์" + } }, "document": { "menuName": "เอกสาร", @@ -636,6 +1638,51 @@ }, "document": { "selectADocumentToLinkTo": "เลือกเอกสารเพื่อเชื่อมโยง" + }, + "name": { + "text": "ข้อความ", + "heading1": "หัวข้อที่ 1", + "heading2": "หัวข้อที่ 2", + "heading3": "หัวข้อที่ 3", + "image": "รูปภาพ", + "bulletedList": "รายการลำดับหัวข้อย่อย", + "numberedList": "รายการลำดับตัวเลข", + "todoList": "รายการสิ่งที่ต้องทำ", + "doc": "เอกสาร", + "linkedDoc": "ลิงค์ไปยังหน้า", + "grid": "กริด", + "linkedGrid": "กริดที่เชื่อมโยง", + "kanban": "คันบัน", + "linkedKanban": "คันบันที่เชื่อมโยง", + "calendar": "ปฏิทิน", + "linkedCalendar": "ปฏิทินที่เชื่อมโยง", + "quote": "คำกล่าว", + "divider": "ตัวคั่น", + "table": "ตาราง", + "callout": "การเน้นข้อความ", + "outline": "โครงร่าง", + "mathEquation": "สมการคณิตศาสตร์", + "code": "โค้ด", + "toggleList": "ตัวเปิดปิดรายการ", + "toggleHeading1": "ตัวเปิดปิดหัวข้อ 1", + "toggleHeading2": "ตัวเปิดปิดหัวข้อ 2", + "toggleHeading3": "ตัวเปิดปิดหัวข้อ 3", + "emoji": "อิโมจิ", + "aiWriter": "นักเขียน AI", + "dateOrReminder": "วันที่หรือการเตือน", + "photoGallery": "แกลอรี่รูปภาพ", + "file": "ไฟล์" + }, + "subPage": { + "name": "เอกสาร", + "keyword1": "หน้าย่อย", + "keyword2": "หน้า", + "keyword3": "หน้าย่อย", + "keyword4": "แทรกหน้า", + "keyword5": "ฝังหน้า", + "keyword6": "หน้าใหม่", + "keyword7": "สร้างหน้า", + "keyword8": "เอกสาร" } }, "selectionMenu": { @@ -647,32 +1694,55 @@ "referencedGrid": "ตารางอ้างอิง", "referencedCalendar": "ปฏิทินที่อ้างอิง", "referencedDocument": "เอกสารอ้างอิง", - "autoGeneratorMenuItemName": "นักเขียน OpenAI", - "autoGeneratorTitleName": "OpenAI: สอบถาม AI เพื่อให้เขียนอะไรก็ได้...", + "autoGeneratorMenuItemName": "นักเขียน AI", + "autoGeneratorTitleName": "AI: สอบถาม AI เพื่อให้เขียนอะไรก็ได้...", "autoGeneratorLearnMore": "เรียนรู้เพิ่มเติม", "autoGeneratorGenerate": "สร้าง", - "autoGeneratorHintText": "ถาม OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "ไม่สามารถรับคีย์ OpenAI ได้", + "autoGeneratorHintText": "ถาม AI ...", + "autoGeneratorCantGetOpenAIKey": "ไม่สามารถรับคีย์ AI ได้", "autoGeneratorRewrite": "เขียนใหม่", "smartEdit": "ผู้ช่วย AI", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "แก้ไขการสะกด", "warning": "⚠️ คำตอบของ AI อาจจะไม่ถูกต้องหรืออาจจะเข้าใจผิดได้", "smartEditSummarize": "สรุป", "smartEditImproveWriting": "ปรับปรุงการเขียน", "smartEditMakeLonger": "ทำให้ยาวขึ้น", - "smartEditCouldNotFetchResult": "ไม่สามารถดึงผลลัพธ์จาก OpenAI ได้", - "smartEditCouldNotFetchKey": "ไม่สามารถดึงคีย์ OpenAI ได้", - "smartEditDisabled": "เชื่อมต่อ OpenAI ในการตั้งค่า", + "smartEditCouldNotFetchResult": "ไม่สามารถดึงผลลัพธ์จาก AI ได้", + "smartEditCouldNotFetchKey": "ไม่สามารถดึงคีย์ AI ได้", + "smartEditDisabled": "เชื่อมต่อ AI ในการตั้งค่า", + "appflowyAIEditDisabled": "ลงชื่อเข้าใช้เพื่อเปิดใช้งานฟีเจอร์ AI", "discardResponse": "คุณต้องการทิ้งการตอบกลับของ AI หรือไม่", "createInlineMathEquation": "สร้างสมการ", "fonts": "แบบอักษร", - "toggleList": "สลับรายการ", - "quoteList": "รายการอ้างอิง", - "numberedList": "รายการแบบมีหมายเลข", - "bulletedList": "รายการแบบมีจุด", + "insertDate": "ใส่วันที่", + "emoji": "อิโมจิ", + "toggleList": "ตัวเปิดปิดรายการ", + "emptyToggleHeading": "ตัวเปิดปิด h{} ว่างเปล่า คลิกเพื่อเพิ่มเนื้อหา", + "emptyToggleList": "ตัวเปิดปิดรายการว่างเปล่า คลิกเพื่อเพิ่มเนื้อหา", + "quoteList": "รายการคำกล่าว", + "numberedList": "รายการลำดับตัวเลข", + "bulletedList": "รายการลำดับหัวข้อย่อย", "todoList": "รายการสิ่งที่ต้องทำ", "callout": "คำอธิบายประกอบ", + "simpleTable": { + "moreActions": { + "color": "สี", + "align": "จัดตำแหน่ง", + "delete": "ลบ", + "duplicate": "ทำสำเนา", + "insertLeft": "แทรกซ้าย", + "insertRight": "แทรกขวา", + "insertAbove": "แทรกด้านบน", + "insertBelow": "แทรกด้านล่าง", + "headerColumn": "ส่วนหัวของคอลัมน์", + "headerRow": "ส่วนหัวของแถว", + "clearContents": "ล้างเนื้อหา" + }, + "clickToAddNewRow": "คลิกเพื่อเพิ่มแถวใหม่", + "clickToAddNewColumn": "คลิกเพื่อเพิ่มคอลัมน์ใหม่", + "clickToAddNewRowAndColumn": "คลิกเพื่อเพิ่มแถว และคอลัมน์ใหม่" + }, "cover": { "changeCover": "เปลี่ยนปก", "colors": "สี", @@ -688,6 +1758,7 @@ "back": "ย้อนกลับ", "saveToGallery": "บันทึกลงในแกลเลอรี่", "removeIcon": "ลบไอคอน", + "removeCover": "ลบปก", "pasteImageUrl": "วาง URL รูปภาพ", "or": "หรือ", "pickFromFiles": "เลือกจากไฟล์", @@ -706,6 +1777,8 @@ "optionAction": { "click": "คลิก", "toOpenMenu": " เพื่อเปิดเมนู", + "drag": "ลาก", + "toMove": " เพื่อย้าย", "delete": "ลบ", "duplicate": "ทำสำเนา", "turnInto": "แปลงเป็น", @@ -716,14 +1789,44 @@ "left": "ซ้าย", "center": "กึ่งกลาง", "right": "ขวา", - "defaultColor": "สีเริ่มต้น" + "defaultColor": "สีเริ่มต้น", + "depth": "ความลึก", + "copyLinkToBlock": "คัดลอกลิงก์ไปยังบล็อก" }, "image": { + "addAnImage": "เพิ่มรูปภาพ", "copiedToPasteBoard": "ลิงก์รูปภาพถูกคัดลอกไปที่คลิปบอร์ดแล้ว", - "addAnImage": "เพิ่มรูปภาพ" + "addAnImageDesktop": "เพิ่มรูปภาพ", + "addAnImageMobile": "คลิกเพื่อเพิ่มรูปภาพหนึ่งรูป หรือมากกว่า", + "dropImageToInsert": "วางภาพเพื่อแทรก", + "imageUploadFailed": "อัพโหลดรูปภาพไม่สำเร็จ", + "imageDownloadFailed": "ดาวน์โหลดรูปภาพล้มเหลว กรุณาลองอีกครั้ง", + "imageDownloadFailedToken": "การดาวน์โหลดภาพล้มเหลวเนื่องจากขาดโทเค็นผู้ใช้ โปรดลองอีกครั้ง", + "errorCode": "รหัสข้อผิดพลาด" + }, + "photoGallery": { + "name": "แกลอรี่รูปภาพ", + "imageKeyword": "รูปภาพ", + "imageGalleryKeyword": "แกลอรี่รูปภาพ", + "photoKeyword": "ภาพถ่าย", + "photoBrowserKeyword": "เบราว์เซอร์รูปภาพ", + "galleryKeyword": "แกลเลอรี่", + "addImageTooltip": "เพิ่มรูปภาพ", + "changeLayoutTooltip": "เปลี่ยนเค้าโครง", + "browserLayout": "เบราว์เซอร์", + "gridLayout": "กริด", + "deleteBlockTooltip": "ลบทั้งแกลอรี่" + }, + "math": { + "copiedToPasteBoard": "คัดลอกสมการคณิตศาสตร์ไปยังคลิปบอร์ดแล้ว" + }, + "urlPreview": { + "copiedToPasteBoard": "คัดลอกลิงก์ไปยังคลิปบอร์ดแล้ว", + "convertToLink": "แปลงเป็นลิงค์ฝัง" }, "outline": { - "addHeadingToCreateOutline": "เพิ่มหัวข้อเพื่อสร้างสารบัญ" + "addHeadingToCreateOutline": "เพิ่มหัวข้อเพื่อสร้างสารบัญ", + "noMatchHeadings": "ไม่พบหัวข้อที่ตรงกัน" }, "table": { "addAfter": "เพิ่มหลัง", @@ -738,7 +1841,64 @@ "cut": "ตัด", "paste": "วาง" }, - "action": "การดำเนินการ" + "action": "การดำเนินการ", + "database": { + "selectDataSource": "เลือกแหล่งข้อมูล", + "noDataSource": "ไม่มีแหล่งข้อมูล", + "selectADataSource": "เลือกแหล่งข้อมูล", + "toContinue": "เพื่อดำเนินการต่อ", + "newDatabase": "ฐานข้อมูลใหม่", + "linkToDatabase": "ลิงค์ไปยังฐานข้อมูล" + }, + "date": "วันที่", + "video": { + "label": "วีดีโอ", + "emptyLabel": "เพิ่มวิดีโอ", + "placeholder": "วางลิงค์วิดีโอ", + "copiedToPasteBoard": "คัดลอกลิงก์วิดีโอไปยังคลิปบอร์ดแล้ว", + "insertVideo": "เพิ่มวีดีโอ", + "invalidVideoUrl": "URL แหล่งที่มายังไม่รองรับในขณะนี้", + "invalidVideoUrlYouTube": "YouTube ยังไม่รองรับ", + "supportedFormats": "รูปแบบที่รองรับ: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" + }, + "file": { + "name": "ไฟล์", + "uploadTab": "อัพโหลด", + "uploadMobile": "เลือกไฟล์", + "uploadMobileGallery": "จากแกลเลอรี่รูปภาพ", + "networkTab": "ฝังลิงค์", + "placeholderText": "อัพโหลดหรือฝังไฟล์", + "placeholderDragging": "วางไฟล์เพื่ออัพโหลด", + "dropFileToUpload": "วางไฟล์เพื่ออัพโหลด", + "fileUploadHint": "ลากและวางไฟล์หรือคลิกเพื่อ ", + "fileUploadHintSuffix": "เรียกดู", + "networkHint": "วางลิงก์ไฟล์", + "networkUrlInvalid": "URL ไม่ถูกต้อง ตรวจสอบ URL และลองอีกครั้ง", + "networkAction": "ฝัง", + "fileTooBigError": "ขนาดไฟล์ใหญ่เกินไป กรุณาอัพโหลดไฟล์ที่มีขนาดน้อยกว่า 10MB", + "renameFile": { + "title": "เปลี่ยนชื่อไฟล์", + "description": "ป้อนชื่อใหม่สำหรับไฟล์นี้", + "nameEmptyError": "ชื่อไฟล์ไม่สามารถเว้นว่างได้" + }, + "uploadedAt": "อัพโหลดเมื่อ {}", + "linkedAt": "ลิงก์ถูกเพิ่มเมื่อ {}", + "failedToOpenMsg": "ไม่สามารถเปิดได้ ไม่พบไฟล์" + }, + "subPage": { + "handlingPasteHint": " - (การจัดการการวาง)", + "errors": { + "failedDeletePage": "ลบหน้าไม่สำเร็จ", + "failedCreatePage": "สร้างหน้าไม่สำเร็จ", + "failedMovePage": "ย้ายหน้ามายังเอกสารนี้ไม่สำเร็จ", + "failedDuplicatePage": "ทำสำเนาหน้าไม่สำเร็จ", + "failedDuplicateFindView": "ทำสำเนาหน้าไม่สำเร็จ - ไม่พบมุมมองต้นฉบับ" + } + }, + "cannotMoveToItsChildren": "ไม่สามารถย้ายไปยังหน้าย่อยได้" + }, + "outlineBlock": { + "placeholder": "สารบัญ" }, "textBlock": { "placeholder": "พิมพ์ '/' เพื่อดูคำสั่ง" @@ -757,8 +1917,8 @@ "placeholder": "ป้อน URL รูปภาพ" }, "ai": { - "label": "สร้างรูปภาพจาก OpenAI", - "placeholder": "โปรดระบุคำขอให้ OpenAI สร้างรูปภาพ" + "label": "สร้างรูปภาพจาก AI", + "placeholder": "โปรดระบุคำขอให้ AI สร้างรูปภาพ" }, "stability_ai": { "label": "สร้างรูปภาพจาก Stability AI", @@ -769,7 +1929,9 @@ "invalidImage": "รูปภาพไม่ถูกต้อง", "invalidImageSize": "ขนาดรูปภาพต้องไม่เกิน 5MB", "invalidImageFormat": "ไม่รองรับรูปแบบรูปภาพนี้ รูปแบบที่รองรับ: JPEG, PNG, GIF, SVG", - "invalidImageUrl": "URL รูปภาพไม่ถูกต้อง" + "invalidImageUrl": "URL รูปภาพไม่ถูกต้อง", + "noImage": "ไม่มีไฟล์หรือไดเร็กทอรีดังกล่าว", + "multipleImagesFailed": "มีภาพหนึ่งหรือมากกว่าที่ไม่สามารถอัปโหลดได้ กรุณาลองใหม่อีกครั้ง" }, "embedLink": { "label": "ฝังลิงก์", @@ -779,18 +1941,40 @@ "label": "Unsplash" }, "searchForAnImage": "ค้นหารูปภาพ", - "pleaseInputYourOpenAIKey": "โปรดระบุคีย์ OpenAI ของคุณในหน้าการตั้งค่า", - "pleaseInputYourStabilityAIKey": "โปรดระบุคีย์ Stability AI ของคุณในหน้าการตั้งค่า", + "pleaseInputYourOpenAIKey": "โปรดระบุคีย์ AI ของคุณในหน้าการตั้งค่า", "saveImageToGallery": "บันทึกภาพ", "failedToAddImageToGallery": "ไม่สามารถเพิ่มรูปภาพลงในแกลเลอรี่ได้", "successToAddImageToGallery": "เพิ่มรูปภาพลงในแกลเลอรี่เรียบร้อยแล้ว", - "unableToLoadImage": "ไม่สามารถโหลดรูปภาพได้" + "unableToLoadImage": "ไม่สามารถโหลดรูปภาพได้", + "maximumImageSize": "ขนาดรูปภาพอัปโหลดที่รองรับสูงสุดคือ 10MB", + "uploadImageErrorImageSizeTooBig": "ขนาดรูปภาพต้องน้อยกว่า 10MB", + "imageIsUploading": "กำลังอัพโหลดรูปภาพ", + "openFullScreen": "เปิดแบบเต็มจอ", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "ภาพก่อนหน้า", + "nextImageTooltip": "ภาพถัดไป", + "zoomOutTooltip": "ซูมออก", + "zoomInTooltip": "ซูมเข้า", + "changeZoomLevelTooltip": "เปลี่ยนระดับการซูม", + "openLocalImage": "เปิดภาพ", + "downloadImage": "ดาวน์โหลดภาพ", + "closeViewer": "ปิดโปรแกรมดูแบบโต้ตอบ", + "scalePercentage": "{}%", + "deleteImageTooltip": "ลบรูปภาพ" + } + }, + "pleaseInputYourStabilityAIKey": "โปรดระบุคีย์ Stability AI ของคุณในหน้าการตั้งค่า" }, "codeBlock": { "language": { "label": "ภาษา", - "placeholder": "เลือกภาษา" - } + "placeholder": "เลือกภาษา", + "auto": "อัตโนมัติ" + }, + "copyTooltip": "สำเนา", + "searchLanguageHint": "ค้นหาภาษา", + "codeCopiedSnackbar": "คัดลอกโค้ดไปยังคลิปบอร์ดแล้ว!" }, "inlineLink": { "placeholder": "วางหรือพิมพ์ลิงก์", @@ -811,18 +1995,37 @@ "page": { "label": "ลิงก์ไปยังหน้า", "tooltip": "คลิกเพื่อเปิดหน้า" - } + }, + "deleted": "ลบแล้ว", + "deletedContent": "เนื้อหานี้ไม่มีอยู่หรือถูกลบไปแล้ว", + "noAccess": "ไม่มีการเข้าถึง", + "deletedPage": "หน้าที่ถูกลบ", + "trashHint": " - ในถังขยะ" }, "toolbar": { "resetToDefaultFont": "รีเซ็ตเป็นค่าเริ่มต้น" }, "errorBlock": { "theBlockIsNotSupported": "เวอร์ชันปัจจุบันไม่รองรับบล็อกนี้", - "blockContentHasBeenCopied": "เนื้อหาบล็อกได้รับการคัดลอกแล้ว" + "clickToCopyTheBlockContent": "คลิกเพื่อคัดลอกเนื้อหาบล็อค", + "blockContentHasBeenCopied": "เนื้อหาบล็อกได้รับการคัดลอกแล้ว", + "parseError": "เกิดข้อผิดพลาดขณะทำการแยกข้อมูลบล็อก {}", + "copyBlockContent": "คัดลอกเนื้อหาบล็อค" + }, + "mobilePageSelector": { + "title": "เลือกหน้า", + "failedToLoad": "โหลดรายการหน้าไม่สำเร็จ", + "noPagesFound": "ไม่พบหน้าใดๆ" + }, + "attachmentMenu": { + "choosePhoto": "เลือกภาพถ่าย", + "takePicture": "ถ่ายรูป", + "chooseFile": "เลือกไฟล์" } }, "board": { "column": { + "label": "คอลัมน์", "createNewCard": "สร้างใหม่", "renameGroupTooltip": "กดเพื่อเปลี่ยนชื่อกลุ่ม", "createNewColumn": "เพิ่มกลุ่มใหม่", @@ -830,10 +2033,10 @@ "addToColumnBottomTooltip": "เพิ่มการ์ดใหม่ที่ด้านล่างสุด", "renameColumn": "เปลี่ยนชื่อ", "hideColumn": "ซ่อน", - "groupActions": "กลุ่มการดำเนินการ", "newGroup": "กลุ่มใหม่", "deleteColumn": "ลบ", - "deleteColumnConfirmation": "การดำเนินการนี้จะลบกลุ่มนี้และการ์ดทั้งหมดในกลุ่ม\nคุณแน่ใจหรือไม่ว่าต้องการดำเนินการต่อ?" + "deleteColumnConfirmation": "การดำเนินการนี้จะลบกลุ่มนี้และการ์ดทั้งหมดในกลุ่ม\nคุณแน่ใจหรือไม่ว่าต้องการดำเนินการต่อ?", + "groupActions": "กลุ่มการดำเนินการ" }, "hiddenGroupSection": { "sectionTitle": "กลุ่มที่ซ่อนไว้", @@ -853,6 +2056,7 @@ "ungroupedButtonTooltip": "ประกอบด้วยการ์ดที่ไม่อยู่ในกลุ่มใดๆ", "ungroupedItemsTitle": "คลิกเพื่อเพิ่มไปยังกระดาน", "groupBy": "จัดกลุ่มตาม", + "groupCondition": "เงื่อนไขการจัดกลุ่ม", "referencedBoardPrefix": "มุมมองของ", "notesTooltip": "บันทึกย่อข้างใน", "mobile": { @@ -860,6 +2064,22 @@ "showGroup": "เลิกซ่อนกลุ่ม", "showGroupContent": "คุณแน่ใจหรือไม่ว่าต้องการแสดงกลุ่มนี้บนกระดาน?", "failedToLoad": "โหลดมุมมองกระดานไม่สำเร็จ" + }, + "dateCondition": { + "weekOf": "สัปดาห์ที่ {} - {}", + "today": "วันนี้", + "yesterday": "เมื่อวาน", + "tomorrow": "พรุ่งนี้", + "lastSevenDays": "7 วันที่ผ่านมา", + "nextSevenDays": "7 วันถัดไป", + "lastThirtyDays": "30 วันที่ผ่านมา", + "nextThirtyDays": "30 วันถัดไป" + }, + "noGroup": "ไม่มีการจัดกลุ่มตามคุณสมบัติ", + "noGroupDesc": "มุมมองบอร์ดต้องการคุณสมบัติสำหรับการจัดกลุ่มเพื่อแสดงผล", + "media": { + "cardText": "{} {}", + "fallbackName": "ไฟล์" } }, "calendar": { @@ -870,13 +2090,24 @@ "today": "วันนี้", "jumpToday": "ข้ามไปยังวันนี้", "previousMonth": "เดือนก่อนหน้า", - "nextMonth": "เดือนถัดไป" + "nextMonth": "เดือนถัดไป", + "views": { + "day": "วัน", + "week": "สัปดาห์", + "month": "เดือน", + "year": "ปี" + } + }, + "mobileEventScreen": { + "emptyTitle": "ยังไม่มีกิจกรรม", + "emptyBody": "กดปุ่มบวกเพื่อสร้างกิจกรรมในวันนี้" }, "settings": { "showWeekNumbers": "แสดงหมายเลขสัปดาห์", "showWeekends": "แสดงวันหยุดสุดสัปดาห์", "firstDayOfWeek": "เริ่มต้นสัปดาห์ในวัน", "layoutDateField": "จัดรูปแบบปฏิทินตาม", + "changeLayoutDateField": "เปลี่ยนเค้าโครงฟิลด์", "noDateTitle": "ไม่มีวันที่", "noDateHint": { "zero": "กิจกรรมที่ไม่ได้กำหนดวันจะแสดงที่นี่", @@ -885,18 +2116,23 @@ }, "unscheduledEventsTitle": "เหตุการณ์ที่ไม่ได้กำหนดไว้", "clickToAdd": "คลิกเพื่อเพิ่มไปยังปฏิทิน", - "name": "การตั้งค่าปฏิทิน" + "name": "การตั้งค่าปฏิทิน", + "clickToOpen": "คลิกเพื่อเปิดบันทึก" }, "referencedCalendarPrefix": "มุมมองของ", - "quickJumpYear": "ข้ามไปที่" + "quickJumpYear": "ข้ามไปที่", + "duplicateEvent": "ทำสำเนาเหตุการณ์" }, "errorDialog": { "title": "ข้อผิดพลาด AppFlowy", "howToFixFallback": "ขออภัยในความไม่สะดวก! ส่งปัญหาบนหน้า GitHub ของเราอธิบายถึงข้อผิดพลาดของคุณ", + "howToFixFallbackHint1": "ขออภัยในความไม่สะดวก! ส่งปัญหาของคุณมาที่ ", + "howToFixFallbackHint2": " หน้าที่อธิบายข้อผิดพลาดของคุณ", "github": "ดูบน GitHub" }, "search": { "label": "ค้นหา", + "sidebarSearchIcon": "ค้นหาและไปยังหน้านั้นอย่างรวดเร็ว", "placeholder": { "actions": "ค้นหาการการดำเนินการ..." } @@ -954,19 +2190,48 @@ "medium": "ปานกลาง", "mediumDark": "ปานกลางเข้ม", "dark": "เข้ม" - } + }, + "openSourceIconsFrom": "ไอคอนโอเพ่นซอร์สจาก" }, "inlineActions": { "noResults": "ไม่พบผลลัพธ์", + "recentPages": "หน้าล่าสุด", "pageReference": "อ้างอิงหน้า", + "docReference": "การอ้างอิงเอกสาร", + "boardReference": "การอ้างอิงบอร์ด", + "calReference": "การอ้างอิงปฏิทิน", + "gridReference": "การอ้างอิงตาราง", "date": "วันที่", "reminder": { "groupTitle": "ตัวเตือน", "shortKeyword": "ตัวเตือน" - } + }, + "createPage": "สร้าง \"{}\" หน้าย่อย" }, "datePicker": { - "dateTimeFormatTooltip": "เปลี่ยนรูปแบบวันที่และเวลาในการตั้งค่า" + "dateTimeFormatTooltip": "เปลี่ยนรูปแบบวันที่และเวลาในการตั้งค่า", + "dateFormat": "รูปแบบวันที่", + "includeTime": "รวมถึงเวลา", + "isRange": "วันที่สิ้นสุด", + "timeFormat": "รูปแบบเวลา", + "clearDate": "ล้างวันที่", + "reminderLabel": "การเตือน", + "selectReminder": "เลือกการเตือน", + "reminderOptions": { + "none": "ไม่มี", + "atTimeOfEvent": "เวลาของงาน", + "fiveMinsBefore": "5 นาทีก่อน", + "tenMinsBefore": "10 นาทีก่อน", + "fifteenMinsBefore": "15 นาทีก่อน", + "thirtyMinsBefore": "30 นาทีก่อน", + "oneHourBefore": "1 ชั่วโมงก่อน", + "twoHoursBefore": "2 ชั่วโมงก่อน", + "onDayOfEvent": "ในวันที่มีงาน", + "oneDayBefore": "1 วันก่อน", + "twoDaysBefore": "2 วันก่อน", + "oneWeekBefore": "1 สัปดาห์ก่อน", + "custom": "กำหนดเอง" + } }, "relativeDates": { "yesterday": "เมื่อวานนี้", @@ -1013,15 +2278,20 @@ "replace": "แทนที่", "replaceAll": "แทนที่ทั้งหมด", "noResult": "ไม่มีผลลัพธ์", - "caseSensitive": "แบบเจาะจง" + "caseSensitive": "แบบเจาะจง", + "searchMore": "ค้นหาเพื่อหาผลลัพธ์เพิ่มเติม" }, "error": { "weAreSorry": "ขออภัย", - "loadingViewError": "เรากำลังพบปัญหาในการโหลดมุมมองนี้ โปรดตรวจสอบการเชื่อมต่ออินเทอร์เน็ตของคุณ โหลดแอปซ้ำ และอย่าลังเลที่จะติดต่อทีมงานหากปัญหายังคงไม่หาย" + "loadingViewError": "เรากำลังพบปัญหาในการโหลดมุมมองนี้ โปรดตรวจสอบการเชื่อมต่ออินเทอร์เน็ตของคุณ โหลดแอปซ้ำ และอย่าลังเลที่จะติดต่อทีมงานหากปัญหายังคงไม่หาย", + "syncError": "ข้อมูลไม่ได้รับการซิงค์จากอุปกรณ์อื่น", + "syncErrorHint": "โปรดเปิดหน้านี้อีกครั้งบนอุปกรณ์ที่แก้ไขล่าสุด จากนั้นเปิดอีกครั้งบนอุปกรณ์ปัจจุบัน", + "clickToCopy": "คลิกเพื่อคัดลอกรหัสข้อผิดพลาด" }, "editor": { "bold": "ตัวหนา", "bulletedList": "รายการลำดับหัวข้อย่อย", + "bulletedListShortForm": "รายการสัญลักษณ์จุด", "checkbox": "กล่องกาเครื่องหมาย", "embedCode": "ฝังโค้ด", "heading1": "H1", @@ -1030,9 +2300,15 @@ "highlight": "ไฮไลท์", "color": "สี", "image": "รูปภาพ", + "date": "วันที่", + "page": "หน้า", "italic": "ตัวเอียง", "link": "ลิงก์", "numberedList": "รายการลำดับตัวเลข", + "numberedListShortForm": "แบบระบุหมายเลข", + "toggleHeading1ShortForm": "ตัวเปิดปิดหัวข้อ h1", + "toggleHeading2ShortForm": "ตัวเปิดปิดหัวข้อ h2", + "toggleHeading3ShortForm": "ตัวเปิดปิดหัวข้อ h3", "quote": "คำกล่าว", "strikethrough": "ขีดฆ่า", "text": "ข้อความ", @@ -1057,6 +2333,8 @@ "backgroundColorPurple": "พื้นหลังสีม่วง", "backgroundColorPink": "พื้นหลังสีชมพู", "backgroundColorRed": "พื้นหลังสีแดง", + "backgroundColorLime": "พื้นหลังสีเขียวมะนาว", + "backgroundColorAqua": "พื้นหลังสีฟ้าน้ำทะเล", "done": "เสร็จสิ้น", "cancel": "ยกเลิก", "tint1": "สีจาง 1", @@ -1104,6 +2382,8 @@ "copy": "คัดลอก", "paste": "วาง", "find": "ค้นหา", + "select": "เลือก", + "selectAll": "เลือกทั้งหมด", "previousMatch": "จับคู่ก่อนหน้า", "nextMatch": "จับคู่ถัดไป", "closeFind": "ปิด", @@ -1130,11 +2410,18 @@ "rowDuplicate": "ทำซ้ำ", "colClear": "ล้างเนื้อหา", "rowClear": "ล้างเนื้อหา", - "slashPlaceHolder": "พิมพ์ / เพื่อแทรกบล็อก หรือเริ่มพิมพ์" + "slashPlaceHolder": "พิมพ์ / เพื่อแทรกบล็อก หรือเริ่มพิมพ์", + "typeSomething": "พิมพ์อะไรบางอย่าง...", + "toggleListShortForm": "สลับ", + "quoteListShortForm": "คำกล่าว", + "mathEquationShortForm": "สูตร", + "codeBlockShortForm": "โค้ด" }, "favorite": { "noFavorite": "ไม่มีหน้ารายการโปรด", - "noFavoriteHintText": "ปัดหน้าไปทางซ้ายเพื่อเพิ่มลงในรายการโปรด" + "noFavoriteHintText": "ปัดหน้าไปทางซ้ายเพื่อเพิ่มลงในรายการโปรด", + "removeFromSidebar": "ลบออกจากแถบด้านข้าง", + "addToSidebar": "ปักหมุดไปที่แถบด้านข้าง" }, "cardDetails": { "notesPlaceholder": "ป้อน / เพื่อแทรกบล็อก หรือเริ่มพิมพ์" @@ -1154,5 +2441,473 @@ "date": "วันที่", "addField": "เพิ่มฟิลด์", "userIcon": "ไอคอนผู้ใช้งาน" + }, + "noLogFiles": "ไม่มีไฟล์บันทึก", + "newSettings": { + "myAccount": { + "title": "บัญชีของฉัน", + "subtitle": "ปรับแต่งโปรไฟล์ของคุณ จัดการความปลอดภัยบัญชี เปิดคีย์ AI หรือเข้าสู่ระบบบัญชีของคุณ", + "profileLabel": "ชื่อบัญชีและรูปโปรไฟล์", + "profileNamePlaceholder": "กรุณากรอกชื่อของคุณ", + "accountSecurity": "ความปลอดภัยของบัญชี", + "2FA": "การยืนยันตัวตน 2 ขั้นตอน", + "aiKeys": "คีย์ AI", + "accountLogin": "การเข้าสู่ระบบบัญชี", + "updateNameError": "อัปเดตชื่อไม่สำเร็จ", + "updateIconError": "อัปเดตไอคอนไม่สำเร็จ", + "deleteAccount": { + "title": "ลบบัญชี", + "subtitle": "ลบบัญชี และข้อมูลทั้งหมดของคุณอย่างถาวร", + "description": "ลบบัญชีของคุณอย่างถาวร และลบสิทธิ์การเข้าถึงจากพื้นที่ทำงานทั้งหมด", + "deleteMyAccount": "ลบบัญชีของฉัน", + "dialogTitle": "ลบบัญชี", + "dialogContent1": "คุณแน่ใจหรือไม่ว่าต้องการลบบัญชีของคุณอย่างถาวร?", + "dialogContent2": "การดำเนินการนี้ไม่สามารถย้อนกลับได้ และจะลบสิทธิ์การเข้าถึงจากพื้นที่ทำงานทั้งหมด ลบบัญชีของคุณทั้งหมด รวมถึงพื้นที่ทำงานส่วนตัว และลบคุณออกจากพื้นที่ทำงานที่แชร์ทั้งหมด", + "confirmHint1": "กรุณาพิมพ์ \"DELETE MY ACCOUNT\" เพื่อยืนยัน", + "confirmHint2": "ฉันเข้าใจดีว่าการดำเนินการนี้ไม่สามารถย้อนกลับได้ และจะลบบัญชีของฉันรวมถึงข้อมูลที่เกี่ยวข้องทั้งหมดอย่างถาวร", + "confirmHint3": "DELETE MY ACCOUNT", + "checkToConfirmError": "คุณต้องทำเครื่องหมายในช่องเพื่อยืนยันการลบ", + "failedToGetCurrentUser": "ไม่สามารถดึงอีเมลของผู้ใช้ปัจจุบันได้", + "confirmTextValidationFailed": "ข้อความยืนยันของคุณไม่ตรงกับ \"DELETE MY ACCOUNT\"", + "deleteAccountSuccess": "ลบบัญชีสำเร็จแล้ว" + } + }, + "workplace": { + "name": "พื้นไที่ทำงาน", + "title": "การตั้งค่าพื้นไที่ทำงาน", + "subtitle": "ปรับแต่งรูปลักษณ์พื้นที่ทำงาน ธีม แบบอักษร เค้าโครงข้อความ วันที่ เวลา และภาษาของคุณ", + "workplaceName": "ชื่อพื้นไที่ทำงาน", + "workplaceNamePlaceholder": "กรอกชื่อพื้นที่ทำงาน", + "workplaceIcon": "ไอคอนพื้นที่ทำงาน", + "workplaceIconSubtitle": "อัปโหลดรูปภาพ หรือใช้อีโมจิสำหรับพื้นที่ทำงานของคุณ ไอคอนจะแสดงในแถบด้านข้าง และการแจ้งเตือนของคุณ", + "renameError": "ไม่สามารถเปลี่ยนชื่อสถานที่ทำงานได้", + "updateIconError": "ไม่สามารถอัปเดตไอคอนได้", + "chooseAnIcon": "เลือกไอคอน", + "appearance": { + "name": "ลักษณะการแสดงผล", + "themeMode": { + "auto": "อัตโนมัติ", + "light": "สว่าง", + "dark": "มืด" + }, + "language": "ภาษา" + } + }, + "syncState": { + "syncing": "กำลังซิงค์", + "synced": "ซิงค์แล้ว", + "noNetworkConnected": "ไม่มีการเชื่อมต่อเครือข่าย" + } + }, + "pageStyle": { + "title": "รูปแบบหน้า", + "layout": "เค้าโครง", + "coverImage": "ภาพปก", + "pageIcon": "ไอคอนหน้าเพจ", + "colors": "สีสัน", + "gradient": "การไล่ระดับ", + "backgroundImage": "ภาพพื้นหลัง", + "presets": "พรีเซ็ต", + "photo": "รูปถ่าย", + "unsplash": "อันสแปลช", + "pageCover": "ปกหน้า", + "none": "ไม่มี", + "openSettings": "เปิดการตั้งค่า", + "photoPermissionTitle": "@:appName ต้องการเข้าถึงคลังรูปภาพของคุณ", + "photoPermissionDescription": "@:appName ต้องการเข้าถึงรูปภาพของคุณเพื่อให้คุณสามารถเพิ่มภาพลงในเอกสารของคุณได้", + "cameraPermissionTitle": "@:appName ต้องการเข้าถึงกล้องของคุณ", + "cameraPermissionDescription": "@:appName ต้องการเข้าถึงกล้องของคุณเพื่อให้คุณสามารถเพิ่มรูปภาพลงในเอกสารจากกล้องได้", + "doNotAllow": "ไม่อนุญาต", + "image": "รูปภาพ" + }, + "commandPalette": { + "placeholder": "พิมพ์เพื่อค้นหา...", + "bestMatches": "การจับคู่ที่ดีที่สุด", + "recentHistory": "ประวัติล่าสุด", + "navigateHint": "เพื่อนำทาง", + "loadingTooltip": "เรากำลังมองหาผลลัพธ์...", + "betaLabel": "BETA", + "betaTooltip": "ปัจจุบันเรารองรับเฉพาะการค้นหาหน้า หรือเนื้อหาภายในเอกสารเท่านั้น", + "fromTrashHint": "จากถังขยะ", + "noResultsHint": "เราไม่พบสิ่งที่คุณกำลังมองหา ลองค้นหาด้วยคำอื่นดู", + "clearSearchTooltip": "ล้างช่องค้นหา" + }, + "space": { + "delete": "ลบ", + "deleteConfirmation": "ลบ: ", + "deleteConfirmationDescription": "หน้าทั้งหมดในพื้นที่นี้จะถูกลบออก และย้ายไปยังถังขยะ และหน้าที่เผยแพร่ทั้งหมดจะถูกยกเลิกการเผยแพร่", + "rename": "เปลี่ยนชื่อพื้นที่", + "changeIcon": "เปลี่ยนไอคอน", + "manage": "จัดการพื้นที่", + "addNewSpace": "สร้างพื้นที่", + "collapseAllSubPages": "ยุบหน้าย่อยทั้งหมด", + "createNewSpace": "สร้างพื้นที่ใหม่", + "createSpaceDescription": "สร้างพื้นที่สาธารณะ และส่วนตัวหลายแห่ง เพื่อจัดระเบียบงานของคุณได้ดีขึ้น", + "spaceName": "ชื่อพื้นที่", + "spaceNamePlaceholder": "เช่น การตลาด วิศวกรรม ทรัพยากรบุคคล", + "permission": "การอนุญาตใช้พื้นที่", + "publicPermission": "สาธารณะ", + "publicPermissionDescription": "สมาชิกพื้นที่ทำงานทั้งหมดที่มีสิทธิ์เข้าถึงเต็มรูปแบบ", + "privatePermission": "ส่วนตัว", + "privatePermissionDescription": "เฉพาะคุณเท่านั้นที่สามารถเข้าถึงพื้นที่นี้ได้", + "spaceIconBackground": "สีพื้นหลัง", + "spaceIcon": "ไอคอน", + "dangerZone": "โซนอันตราย", + "unableToDeleteLastSpace": "ไม่สามารถลบพื้นที่สุดท้ายได้", + "unableToDeleteSpaceNotCreatedByYou": "ไม่สามารถลบพื้นที่ที่สร้างโดยผู้อื่นได้", + "enableSpacesForYourWorkspace": "เปิดใช้งานพื้นที่สำหรับพื้นที่ทำงานของคุณ", + "title": "พื้นที่", + "defaultSpaceName": "ทั่วไป", + "upgradeSpaceTitle": "เปิดใช้งานพื้นที่", + "upgradeSpaceDescription": "สร้างพื้นที่สาธารณะ และส่วนตัวหลายแห่ง เพื่อจัดระเบียบพื้นที่ทำงานของคุณได้ดีขึ้น", + "upgrade": "อัปเดต", + "upgradeYourSpace": "สร้างหลายพื้นที่", + "quicklySwitch": "สลับไปยังพื้นที่ถัดไปอย่างรวดเร็ว", + "duplicate": "ทำสำเนาพื้นที่", + "movePageToSpace": "ย้ายหน้าไปยังพื้นที่", + "cannotMovePageToDatabase": "ไม่สามารถย้ายหน้าไปยังฐานข้อมูลได้", + "switchSpace": "สลับพื้นที่", + "spaceNameCannotBeEmpty": "ชื่อพื้นที่ไม่สามารถปล่อยว่างได้", + "success": { + "deleteSpace": "ลบพื้นที่สำเร็จ", + "renameSpace": "เปลี่ยนชื่อพื้นที่สำเร็จ", + "duplicateSpace": "คัดลอกพื้นที่สำเร็จ", + "updateSpace": "อัปเดตพื้นที่สำเร็จ" + }, + "error": { + "deleteSpace": "ลบพื้นที่ไม่สำเร็จ", + "renameSpace": "เปลี่ยนชื่อพื้นที่ไม่สำเร็จ", + "duplicateSpace": "ทำสำเนาพื้นที่ไม่สำเร็จ", + "updateSpace": "อัปเดตพื้นที่ไม่สำเร็จ" + }, + "createSpace": "สร้างพื้นที่", + "manageSpace": "จัดการพื้นที่", + "renameSpace": "เปลี่ยนชื่อพื้นที่", + "mSpaceIconColor": "สีไอคอนพื้นที่", + "mSpaceIcon": "ไอคอนพื้นที่" + }, + "publish": { + "hasNotBeenPublished": "หน้านี้ยังไม่ได้เผยแพร่", + "spaceHasNotBeenPublished": "ยังไม่รองรับการเผยแพร่พื้นที่", + "reportPage": "รายงานหน้า", + "databaseHasNotBeenPublished": "ยังไม่รองรับการเผยแพร่ฐานข้อมูล", + "createdWith": "สร้างด้วย", + "downloadApp": "ดาวน์โหลด AppFlowy", + "copy": { + "codeBlock": "คัดลอกเนื้อหาของโค้ดบล็อคไปยังคลิปบอร์ดแล้ว", + "imageBlock": "คัดลอกลิงก์รูปภาพไปยังคลิปบอร์ดแล้ว", + "mathBlock": "คัดลอกสมการคณิตศาสตร์ไปยังคลิปบอร์ดแล้ว", + "fileBlock": "คัดลอกลิงก์ไฟล์ไปยังคลิปบอร์ดแล้ว" + }, + "containsPublishedPage": "หน้านี้มีหน้าที่เผยแพร่แล้วหนึ่งหน้าขึ้นไป หากคุณดำเนินการต่อ หน้าเหล่านั้นจะถูกยกเลิกการเผยแพร่ คุณต้องการดำเนินการลบหรือไม่", + "publishSuccessfully": "เผยแพร่สำเร็จ", + "unpublishSuccessfully": "ยกเลิกการเผยแพร่สำเร็จ", + "publishFailed": "เผยแพร่ไม่สำเร็จ", + "unpublishFailed": "ยกเลิกการเผยแพร่ไม่สำเร็จ", + "noAccessToVisit": "ไม่มีการเข้าถึงหน้านี้...", + "createWithAppFlowy": "สร้างเว็บไซต์ด้วย AppFlowy", + "fastWithAI": "รวดเร็วและง่ายดายด้วย AI", + "tryItNow": "ลองเลยตอนนี้", + "onlyGridViewCanBePublished": "สามารถเผยแพร่ได้เฉพาะมุมมองแบบกริดเท่านั้น", + "database": { + "zero": "เผยแพร่ {} มุมมองที่เลือก", + "one": "เผยแพร่ {} มุมมองที่เลือก", + "many": "เผยแพร่ {} มุมมองที่เลือก", + "other": "เผยแพร่ {} มุมมองที่เลือก" + }, + "mustSelectPrimaryDatabase": "ต้องเลือกมุมมองหลัก", + "noDatabaseSelected": "ยังไม่ได้เลือกฐานข้อมูล กรุณาเลือกอย่างน้อยหนึ่งฐานข้อมูล", + "unableToDeselectPrimaryDatabase": "ไม่สามารถยกเลิกการเลือกฐานข้อมูลหลักได้", + "saveThisPage": "เริ่มต้นด้วยเทมเพลตนี้", + "duplicateTitle": "คุณต้องการเพิ่มที่ไหน", + "selectWorkspace": "เลือกพื้นที่ทำงาน", + "addTo": "เพิ่มไปยัง", + "duplicateSuccessfully": "เพิ่มไปที่พื้นที่ทำงานของคุณแล้ว", + "duplicateSuccessfullyDescription": "ยังไม่ได้ติดตั้ง AppFlowy ใช่ไหม? การดาวน์โหลดจะเริ่มต้นโดยอัตโนมัติหลังจากที่คุณคลิก 'ดาวน์โหลด'", + "downloadIt": "ดาวน์โหลด", + "openApp": "เปิดในแอป", + "duplicateFailed": "ทำสำเนาไม่สำเร็จ", + "membersCount": { + "zero": "ไม่มีสมาชิก", + "one": "1 สมาชิก", + "many": "{count} สมาชิก", + "other": "{count} สมาชิก" + }, + "useThisTemplate": "ใช้เทมเพลต" + }, + "web": { + "continue": "ดำเนินการต่อ", + "or": "หรือ", + "continueWithGoogle": "ดำเนินการต่อด้วย Google", + "continueWithGithub": "ดำเนินการต่อด้วย GitHub", + "continueWithDiscord": "ดำเนินการต่อด้วย Discord", + "continueWithApple": "ดำเนินการต่อด้วย Apple ", + "moreOptions": "ตัวเลือกเพิ่มเติม", + "collapse": "ยุบ", + "signInAgreement": "โดยการคลิก \"ดำเนินการต่อ\" ด้านบน, คุณตกลงตามข้อกำหนดของ AppFlowy", + "and": "และ", + "termOfUse": "เงื่อนไข", + "privacyPolicy": "นโยบายความเป็นส่วนตัว", + "signInError": "เกิดข้อผิดพลาดในการเข้าสู่ระบบ", + "login": "ลงทะเบียนหรือเข้าสู่ระบบ", + "fileBlock": { + "uploadedAt": "อัพโหลดเมื่อ {เวลา}", + "linkedAt": "เพิ่มลิงก์เมื่อ {เวลา}", + "empty": "อัพโหลดหรือฝังไฟล์" + }, + "importNotion": "นำเข้าจาก Notion", + "import": "นำเข้า", + "importSuccess": "อัพโหลดสำเร็จ", + "importSuccessMessage": "เราจะแจ้งให้คุณทราบเมื่อการนำเข้าเสร็จสมบูรณ์ หลังจากนั้นคุณสามารถดูหน้าที่นำเข้าได้ในแถบด้านข้าง", + "importFailed": "การนำเข้าไม่สำเร็จ กรุณาตรวจสอบรูปแบบไฟล์", + "dropNotionFile": "วางไฟล์ zip ของ Notion ไว้ที่นี่เพื่ออัพโหลด หรือคลิกเพื่อเรียกดู" + }, + "globalComment": { + "comments": "ความคิดเห็น", + "addComment": "เพิ่มความคิดเห็น", + "reactedBy": "ตอบสนองโดย", + "addReaction": "เพิ่มความรู้สึก", + "reactedByMore": "และอีก {count}", + "showSeconds": { + "one": "1 วินาทีที่แล้ว", + "other": "{count} วินาทีที่ผ่านมา", + "zero": "เมื่อสักครู่", + "many": "{count} วินาทีที่ผ่านมา" + }, + "showMinutes": { + "one": "1 นาทีที่แล้ว", + "other": "{count} นาทีที่ผ่านมา", + "many": "{count} นาทีที่ผ่านมา" + }, + "showHours": { + "one": "1 ชั่วโมงที่แล้ว", + "other": "{count} ชั่วโมงที่ผ่านมา", + "many": "{count} ชั่วโมงที่ผ่านมา" + }, + "showDays": { + "one": "1 วันที่ผ่านมา", + "other": "{count} วันที่ผ่านมา", + "many": "{count} วันที่ผ่านมา" + }, + "showMonths": { + "one": "1 เดือนที่ผ่านมา", + "other": "{count} เดือนที่ผ่านมา", + "many": "{count} เดือนที่ผ่านมา" + }, + "showYears": { + "one": "1 ปีที่ผ่านมา", + "other": "{count} ปีที่ผ่านมา", + "many": "{count} ปีที่ผ่านมา" + }, + "reply": "ตอบกลับ", + "deleteComment": "ลบความคิดเห็น", + "youAreNotOwner": "คุณไม่ใช่เจ้าของความคิดเห็นนี้", + "confirmDeleteDescription": "คุณแน่ใจหรือไม่ว่าต้องการลบความคิดเห็นนี้?", + "hasBeenDeleted": "ลบแล้ว", + "replyingTo": "กำลังตอบกลับถึง", + "noAccessDeleteComment": "คุณไม่มีสิทธิ์ลบความคิดเห็นนี้", + "collapse": "ยุบ", + "readMore": "อ่านเพิ่มเติม", + "failedToAddComment": "เพิ่มความคิดเห็นไม่สำเร็จ", + "commentAddedSuccessfully": "เพิ่มความคิดเห็นสำเร็จ", + "commentAddedSuccessTip": "คุณเพิ่งเพิ่ม หรือตอบกลับความคิดเห็น คุณต้องการข้ามไปที่ด้านบนเพื่อดูความคิดเห็นล่าสุดหรือไม่" + }, + "template": { + "asTemplate": "บันทึกเป็นเทมเพลต", + "name": "ชื่อเทมเพลต", + "description": "คำอธิบายเทมเพลต", + "about": "เทมเพลตเกี่ยวกับ", + "deleteFromTemplate": "ลบออกจากเทมเพลต", + "preview": "ตัวอย่างเทมเพลต", + "categories": "หมวดหมู่เทมเพลต", + "isNewTemplate": "PIN ไปยังเทมเพลตใหม่", + "featured": "PIN ไปที่ฟีเจอร์", + "relatedTemplates": "เทมเพลตที่เกี่ยวข้อง", + "requiredField": "{field} จำเป็นต้องกรอก", + "addCategory": "เพิ่ม \"{category}\"", + "addNewCategory": "เพิ่มหมวดหมู่ใหม่", + "addNewCreator": "เพิ่มผู้สร้างใหม่", + "deleteCategory": "ลบหมวดหมู่", + "editCategory": "แก้ไขหมวดหมู่", + "editCreator": "แก้ไขผู้สร้าง", + "category": { + "name": "ชื่อหมวดหมู่", + "icon": "ไอคอนหมวดหมู่", + "bgColor": "สีพื้นหลังหมวดหมู่", + "priority": "ลำดับความสำคัญของหมวดหมู่", + "desc": "คำอธิบายหมวดหมู่", + "type": "ประเภทหมวดหมู่", + "icons": "ไอคอนหมวดหมู่", + "colors": "สีหมวดหมู่", + "byUseCase": "ตามลักษณะการใช้งาน", + "byFeature": "ตามคุณสมบัติ", + "deleteCategory": "ลบหมวดหมู่", + "deleteCategoryDescription": "คุณแน่ใจหรือไม่ว่าต้องการลบหมวดหมู่นี้?", + "typeToSearch": "พิมพ์เพื่อค้นหาหมวดหมู่..." + }, + "creator": { + "label": "ผู้สร้างเทมเพลต", + "name": "ชื่อผู้สร้าง", + "avatar": "อวตาร์ของผู้สร้าง", + "accountLinks": "ลิงค์บัญชีผู้สร้าง", + "uploadAvatar": "คลิกเพื่ออัพโหลดอวาตาร์", + "deleteCreator": "ลบผู้สร้าง", + "deleteCreatorDescription": "คุณแน่ใจหรือไม่ว่าต้องการลบผู้สร้างรายนี้?", + "typeToSearch": "พิมพ์เพื่อค้นหาผู้สร้าง..." + }, + "uploadSuccess": "อัพโหลดเทมเพลตสำเร็จ", + "uploadSuccessDescription": "เทมเพลตของคุณได้รับการอัปโหลดสำเร็จแล้ว ขณะนี้คุณสามารถดูเทมเพลตได้ในแกลเลอรีเทมเพลต", + "viewTemplate": "ดูเทมเพลต", + "deleteTemplate": "ลบเทมเพลต", + "deleteSuccess": "ลบเทมเพลตสำเร็จ", + "deleteTemplateDescription": "การกระทำนี้จะไม่ส่งผลกระทบต่อหน้าปัจจุบัน หรือสถานะการเผยแพร่ คุณแน่ใจหรือไม่ว่าต้องการลบเทมเพลตนี้?", + "addRelatedTemplate": "เพิ่มเทมเพลตที่เกี่ยวข้อง", + "removeRelatedTemplate": "ลบเทมเพลตที่เกี่ยวข้อง", + "uploadAvatar": "อัพโหลดอวาตาร์", + "searchInCategory": "ค้นหาใน {category}", + "label": "เทมเพลต" + }, + "fileDropzone": { + "dropFile": "คลิก หรือลากไฟล์ ไปยังพื้นที่นี้เพื่ออัพโหลด", + "uploading": "กำลังอัพโหลด...", + "uploadFailed": "อัพโหลดไม่สำเร็จ", + "uploadSuccess": "อัพโหลดสำเร็จ", + "uploadSuccessDescription": "ไฟล์ได้รับการอัพโหลดเรียบร้อยแล้ว", + "uploadFailedDescription": "อัพโหลดไฟล์ไม่สำเร็จ", + "uploadingDescription": "ไฟล์กำลังถูกอัพโหลด" + }, + "gallery": { + "preview": "เปิดแบบเต็มจอ", + "copy": "สำเนา", + "download": "ดาวน์โหลด", + "prev": "ก่อนหน้า", + "next": "ถัดไป", + "resetZoom": "รีเซ็ตการซูม", + "zoomIn": "ซูมเข้า", + "zoomOut": "ซูมออก" + }, + "invitation": { + "join": "เข้าร่วม", + "on": "บน", + "invitedBy": "ได้รับเชิญโดย", + "membersCount": { + "zero": "{count} สมาชิก", + "one": "{count} สมาชิก", + "many": "{count} สมาชิก", + "other": "{count} สมาชิก" + }, + "tip": "คุณได้รับคำเชิญให้เข้าร่วมพื้นที่ทำงานนี้โดยใช้ข้อมูลติดต่อด้านล่าง หากข้อมูลนี้ไม่ถูกต้อง กรุณาติดต่อผู้ดูแลเพื่อขอให้ส่งคำเชิญใหม่", + "joinWorkspace": "เข้าร่วมพื้นที่ทำงาน", + "success": "คุณได้เข้าร่วมพื้นที่ทำงานเรียบร้อยแล้ว", + "successMessage": "ตอนนี้คุณสามารถเข้าถึงหน้า และพื้นที่ทำงานทั้งหมดภายในนั้นได้แล้ว", + "openWorkspace": "เปิด AppFlowy", + "alreadyAccepted": "คุณได้ยอมรับคำเชิญแล้ว", + "errorModal": { + "title": "มีบางอย่างผิดพลาด", + "description": "บัญชีปัจจุบันของคุณ {email} อาจไม่มีสิทธิ์เข้าถึงพื้นที่ทำงานนี้ กรุณาล็อกอินด้วยบัญชีที่ถูกต้องหรือ ติดต่อเจ้าของพื้นที่ทำงานเพื่อขอความช่วยเหลือ", + "contactOwner": "ติดต่อเจ้าของ", + "close": "กลับสู่หน้าแรก", + "changeAccount": "เปลี่ยนบัญชี" + } + }, + "requestAccess": { + "title": "ไม่มีการเข้าถึงหน้านี้", + "subtitle": "คุณสามารถขอสิทธิ์การเข้าถึงจากเจ้าขอหน้านี้ได้ เมื่อได้รับการอนุมัติแล้วคุณจะสามารถดูหน้าได้", + "requestAccess": "ขอสิทธิ์การเข้าถึง", + "backToHome": "กลับสู่หน้าแรก", + "tip": "ขณะนี้คุณเข้าสู่ระบบเป็น ", + "mightBe": "คุณอาจต้อง ด้วยบัญชีอื่น", + "successful": "ส่งคำขอเรียบร้อยแล้ว", + "successfulMessage": "คุณจะได้รับการแจ้งเตือนเมื่อเจ้าของอนุมัติคำขอของคุณ", + "requestError": "ไม่สามารถขอการเข้าถึงได้", + "repeatRequestError": "คุณได้ขอการเข้าถึงหน้านี้แล้ว" + }, + "approveAccess": { + "title": "อนุมัติคำขอเข้าร่วมพื้นที่ทำงาน", + "requestSummary": " ขอเข้าร่วม และเข้าถึง ", + "upgrade": "อัพเกรด", + "downloadApp": "ดาวน์โหลด AppFlowy", + "approveButton": "อนุมัติ", + "approveSuccess": "ได้รับการอนุมัติเรียบร้อยแล้ว", + "approveError": "ไม่สามารถอนุมัติได้ โปรดตรวจสอบให้แน่ใจว่าไม่เกินขีดจำกัดของแผนพื้นที่ทำงาน", + "getRequestInfoError": "ไม่สามารถรับข้อมูลคำขอได้", + "memberCount": { + "zero": "ไม่มีสมาชิก", + "one": "1 สมาชิก", + "many": "{count} สมาชิก", + "other": "{count} สมาชิก" + }, + "alreadyProTitle": "คุณได้ถึงขีดจำกัดของแผนพื้นที่ทำงานแล้ว", + "alreadyProMessage": "ขอให้พวกเขาติดต่อ เพื่อปลดล็อกสมาชิกเพิ่มเติม", + "repeatApproveError": "คุณได้อนุมัติคำขอนี้แล้ว", + "ensurePlanLimit": "โปรดตรวจสอบให้แน่ใจว่าไม่เกินขีดจำกัดของแผนพื้นที่ทำงาน หากเกินขีดจำกัดแล้ว ให้พิจารณา แผนพื้นที่ทำงานหรือ ", + "requestToJoin": "ขอเข้าร่วม", + "asMember": "ในฐานะสมาชิก" + }, + "upgradePlanModal": { + "title": "อัพเกรดเป็น Pro", + "message": "{name} ได้ถึงขีดจำกัดของสมาชิกฟรีแล้ว อัปเกรดเป็นแผน Pro เพื่อเชิญสมาชิกเพิ่ม", + "upgradeSteps": "วิธีอัพเกรดแผนของคุณบน AppFlowy:", + "step1": "1. ไปที่การตั้งค่า", + "step2": "2. คลิกที่ 'แผน'", + "step3": "3. เลือก 'เปลี่ยนแผน'", + "appNote": "บันทึก: ", + "actionButton": "อัพเกรด", + "downloadLink": "ดาวน์โหลดแอป", + "laterButton": "ภายหลัง", + "refreshNote": "หลังจากอัปเกรดสำเร็จแล้ว คลิก เพื่อเปิดใช้งานฟีเจอร์ใหม่ของคุณ", + "refresh": "ที่นี่" + }, + "breadcrumbs": { + "label": "เส้นทางนำทาง" + }, + "time": { + "justNow": "เมื่อสักครู่", + "seconds": { + "one": "1 วินาที", + "other": "{count} วินาที" + }, + "minutes": { + "one": "1 นาที", + "other": "{count} นาที" + }, + "hours": { + "one": "1 ชั่วโมง", + "other": "{count} ชั่วโมง" + }, + "days": { + "one": "1 วัน", + "other": "{จำนวน} วัน" + }, + "weeks": { + "one": "1 สัปดาห์", + "other": "{count} สัปดาห์" + }, + "months": { + "one": "1 เดือน", + "other": "{count} เดือน" + }, + "years": { + "one": "1 ปี", + "other": "{count} ปี" + }, + "ago": "ที่ผ่านมา", + "yesterday": "เมื่อวาน", + "today": "วันนี้" + }, + "members": { + "zero": "ไม่มีสมาชิก", + "one": "1 สมาชิก", + "many": "{count} สมาชิก", + "other": "{count} สมาชิก" + }, + "tabMenu": { + "close": "ปิด", + "closeOthers": "ปิดแท็บอื่น ๆ", + "favorite": "รายการโปรด", + "unfavorite": "ยกเลิกรายการโปรด", + "favoriteDisabledHint": "ไม่สามารถเพิ่มมุมมองนี้เป็นรายการโปรดได้", + "pinTab": "ปักหมุด", + "unpinTab": "ยกเลิกการปักหมุด" } -} \ No newline at end of file +} diff --git a/frontend/resources/translations/tr-TR.json b/frontend/resources/translations/tr-TR.json index 8f82fedb62..0eeac684c6 100644 --- a/frontend/resources/translations/tr-TR.json +++ b/frontend/resources/translations/tr-TR.json @@ -1,80 +1,100 @@ { "appName": "AppFlowy", - "defaultUsername": "Ben", - "welcomeText": "@:appName'a Hoş Geldiniz", + "defaultUsername": "Kullanıcı", + "welcomeText": "@:appName'ye Hoş Geldiniz", "welcomeTo": "Hoş Geldiniz", "githubStarText": "GitHub'da Yıldız Ver", - "subscribeNewsletterText": "Bültene Abone Ol", - "letsGoButtonText": "Hızlı Başlangıç", + "subscribeNewsletterText": "Bültenimize Abone Ol", + "letsGoButtonText": "Hemen Başla", "title": "Başlık", - "youCanAlso": "Ayrıca yapabilirsiniz", + "youCanAlso": "Ayrıca", "and": "ve", "failedToOpenUrl": "URL açılamadı: {}", "blockActions": { - "addBelowTooltip": "Alta eklemek için tıklayın", - "addAboveCmd": "Alt+tıkla", - "addAboveMacCmd": "Option+tıkla", - "addAboveTooltip": "üste eklemek için", - "dragTooltip": "Taşımak için sürükleyin", - "openMenuTooltip": "Menüyü açmak için tıklayın" + "addBelowTooltip": "Altına eklemek için tıklayın", + "addAboveCmd": "Alt+tıklama", + "addAboveMacCmd": "Option+tıklama", + "addAboveTooltip": "Üstüne eklemek için", + "dragTooltip": "Sürükleyerek taşıyın", + "openMenuTooltip": "Menüyü aç" }, "signUp": { "buttonText": "Kayıt Ol", - "title": "@:appName'a Kayıt Ol", + "title": "@:appName'e Kayıt Ol", "getStartedText": "Başlayın", - "emptyPasswordError": "Parola boş bırakılamaz", - "repeatPasswordEmptyError": "Parola tekrarı boş bırakılamaz", - "unmatchedPasswordError": "Parola tekrarı girmiş olduğunuz parola ile aynı değil", - "alreadyHaveAnAccount": "Zaten bir hesabınız var mı?", - "emailHint": "E-posta", + "emptyPasswordError": "Parola boş olamaz", + "repeatPasswordEmptyError": "Parola tekrarı alanı boş olamaz", + "unmatchedPasswordError": "Parola tekrarı, parola ile aynı değil", + "alreadyHaveAnAccount": "Hesabınız zaten var mı?", + "emailHint": "E-posta adresi", "passwordHint": "Parola", - "repeatPasswordHint": "Parolayı tekrar girin", - "signUpWith": "Şununla kayıt olun:" + "repeatPasswordHint": "Parolayı tekrarla", + "signUpWith": "Kayıt ol:" }, "signIn": { - "loginTitle": "@:appName'a Giriş Yap", - "loginButtonText": "Giriş Yap", + "loginTitle": "@:appName'e Oturum Aç", + "loginButtonText": "Oturum Aç", "loginStartWithAnonymous": "Anonim oturumla başla", "continueAnonymousUser": "Anonim oturumla devam et", - "buttonText": "Giriş Yap", - "signingInText": "Giriş yapılıyor...", - "forgotPassword": "Parolanızı mı unuttunuz?", - "emailHint": "E-posta", + "anonymous": "Anonim", + "buttonText": "Oturum Aç", + "signingInText": "Oturum açılıyor...", + "forgotPassword": "Parolamı Unuttum?", + "emailHint": "E-posta adresi", "passwordHint": "Parola", "dontHaveAnAccount": "Hesabınız yok mu?", - "repeatPasswordEmptyError": "Parola tekrarı boş bırakılamaz", - "unmatchedPasswordError": "Parola tekrarı girmiş olduğunuz parola ile aynı değil", - "syncPromptMessage": "Veriler senkronize ediliyor, lütfen bu sayfayı kapatmayın", + "createAccount": "Hesap oluştur", + "repeatPasswordEmptyError": "Parola tekrarı alanı boş bırakılamaz", + "unmatchedPasswordError": "Parola tekrarı parolayla eşleşmiyor", + "syncPromptMessage": "Verilerin senkronize edilmesi biraz zaman alabilir. Lütfen bu sayfayı kapatmayın", "or": "VEYA", - "signInWith": "Şununla giriş yapın:", - "signInWithEmail": "E-posta ile giriş yap", + "signInWithGoogle": "Google ile devam et", + "signInWithGithub": "GitHub ile devam et", + "signInWithDiscord": "Discord ile devam et", + "signInWithApple": "Apple ile devam et", + "continueAnotherWay": "Başka yöntemle devam et", + "signUpWithGoogle": "Google ile kaydol", + "signUpWithGithub": "GitHub ile kaydol", + "signUpWithDiscord": "Discord ile kaydol", + "signInWith": "Devam et:", + "signInWithEmail": "E-posta ile devam et", + "signInWithMagicLink": "Devam et", + "signUpWithMagicLink": "Sihirli Bağlantı ile kaydol", "pleaseInputYourEmail": "Lütfen e-posta adresinizi girin", - "magicLinkSent": "Sihirli bağlantı e-postanıza gönderildi, lütfen gelen kutunuzu kontrol edin", - "invalidEmail": "Lütfen geçerli bir e-posta adresi girin", - "LogInWithGoogle": "Google ile Giriş Yap", - "LogInWithGithub": "Github ile Giriş Yap", - "LogInWithDiscord": "Discord ile Giriş Yap", - "logInWithMagicLink": "Sihirli Bağlantı ile Giriş Yap" + "settings": "Ayarlar", + "magicLinkSent": "Sihirli Bağlantı gönderildi!", + "invalidEmail": "Geçerli bir e-posta adresi girin", + "alreadyHaveAnAccount": "Zaten hesabınız var mı?", + "logIn": "Oturum Aç", + "generalError": "Bir hata oluştu. Lütfen daha sonra tekrar deneyin.", + "limitRateError": "Güvenlik önlemi olarak, sihirli bağlantı talepleri 60 saniyede bir ile sınırlandırılmıştır.", + "magicLinkSentDescription": "E-posta adresinize sihirli bir bağlantı gönderdik. Giriş yapmak için bu bağlantıya tıklayın. Bağlantı 5 dakika içinde geçersiz hale gelecektir." }, "workspace": { - "chooseWorkspace": "Çalışma Alanınızı Seçin", - "create": "Çalışma Alanı Oluştur", - "reset": "Çalışma Alanını Sıfırla", - "resetWorkspacePrompt": "Çalışma alanını sıfırlamak, içindeki tüm sayfaları ve verileri silecektir. Çalışma alanını sıfırlamak istediğinizden emin misiniz? Alternatif olarak, çalışma alanını geri yüklemek için destek ekibiyle iletişime geçebilirsiniz", + "chooseWorkspace": "Çalışma alanınızı seçin", + "defaultName": "Çalışma Alanım", + "create": "Çalışma alanı oluştur", + "new": "Yeni çalışma alanı", + "importFromNotion": "Notion'dan içe aktar", + "learnMore": "Daha fazla bilgi", + "reset": "Çalışma alanını sıfırla", + "renameWorkspace": "Çalışma alanını yeniden adlandır", + "workspaceNameCannotBeEmpty": "Çalışma alanı adı boş olamaz", + "resetWorkspacePrompt": "Çalışma alanını sıfırlamak tüm sayfaları ve verileri silecektir. Çalışma alanını sıfırlamak istediğinizden emin misiniz? Geri yüklemek için destek ekibine ulaşabilirsiniz.", "hint": "çalışma alanı", "notFoundError": "Çalışma alanı bulunamadı", - "failedToLoad": "Bir şeyler yanlış gitti! Çalışma alanı yüklenemedi. Açık olan tüm AppFlowy örneklerini kapatıp tekrar deneyin.", + "failedToLoad": "Bir şeyler yanlış gitti! Çalışma alanı yüklenemedi. @:appName'in açık olan tüm örneklerini kapatıp tekrar deneyin.", "errorActions": { - "reportIssue": "Sorun bildir", - "reportIssueOnGithub": "GitHub'da sorun bildir", + "reportIssue": "Hata bildir", + "reportIssueOnGithub": "GitHub'da hata bildir", "exportLogFiles": "Günlük dosyalarını dışa aktar", - "reachOut": "Discord'da ulaşın" + "reachOut": "Discord'da iletişime geç" }, "menuTitle": "Çalışma Alanları", - "deleteWorkspaceHintText": "Çalışma alanını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "deleteWorkspaceHintText": "Çalışma alanını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve yayınladığınız tüm sayfaların yayını kaldırılacaktır.", "createSuccess": "Çalışma alanı başarıyla oluşturuldu", "createFailed": "Çalışma alanı oluşturulamadı", - "createLimitExceeded": "Hesabınız için izin verilen maksimum çalışma alanı sınırına ulaştınız. Çalışmanıza devam etmek için ek çalışma alanlarına ihtiyacınız varsa, lütfen GitHub'da talepte bulunun", + "createLimitExceeded": "Hesabınız için izin verilen maksimum çalışma alanı limitine ulaştınız. Çalışmanıza devam etmek için ek çalışma alanlarına ihtiyacınız varsa, lütfen GitHub'da talepte bulunun", "deleteSuccess": "Çalışma alanı başarıyla silindi", "deleteFailed": "Çalışma alanı silinemedi", "openSuccess": "Çalışma alanı başarıyla açıldı", @@ -85,115 +105,215 @@ "updateIconFailed": "Çalışma alanı simgesi güncellenemedi", "cannotDeleteTheOnlyWorkspace": "Tek çalışma alanı silinemez", "fetchWorkspacesFailed": "Çalışma alanları getirilemedi", - "leaveCurrentWorkspace": "Çalışma alanından çık", - "leaveCurrentWorkspacePrompt": "Geçerli çalışma alanından çıkmak istediğinizden emin misiniz?" + "leaveCurrentWorkspace": "Çalışma alanından ayrıl", + "leaveCurrentWorkspacePrompt": "Mevcut çalışma alanından ayrılmak istediğinizden emin misiniz?" }, "shareAction": { "buttonText": "Paylaş", - "workInProgress": "Yakında geliyor", + "workInProgress": "Yakında", "markdown": "Markdown", "html": "HTML", "clipboard": "Panoya kopyala", "csv": "CSV", - "copyLink": "Bağlantıyı Kopyala" + "copyLink": "Bağlantıyı kopyala", + "publishToTheWeb": "Web'de Yayınla", + "publishToTheWebHint": "AppFlowy ile bir web sitesi oluşturun", + "publish": "Yayınla", + "unPublish": "Yayından kaldır", + "visitSite": "Siteyi ziyaret et", + "exportAsTab": "Farklı dışa aktar", + "publishTab": "Yayınla", + "shareTab": "Paylaş", + "publishOnAppFlowy": "AppFlowy'de Yayınla", + "shareTabTitle": "İşbirliği için davet et", + "shareTabDescription": "Herhangi biriyle kolay işbirliği için", + "copyLinkSuccess": "Bağlantı panoya kopyalandı", + "copyShareLink": "Paylaşım bağlantısını kopyala", + "copyLinkFailed": "Bağlantı panoya kopyalanamadı", + "copyLinkToBlockSuccess": "Blok bağlantısı panoya kopyalandı", + "copyLinkToBlockFailed": "Blok bağlantısı panoya kopyalanamadı", + "manageAllSites": "Tüm siteleri yönet", + "updatePathName": "Yol adını güncelle" }, "moreAction": { "small": "küçük", "medium": "orta", "large": "büyük", - "fontSize": "Yazı boyutu", - "import": "İçe Aktar", + "fontSize": "Yazı tipi boyutu", + "import": "İçe aktar", "moreOptions": "Daha fazla seçenek", "wordCount": "Kelime sayısı: {}", "charCount": "Karakter sayısı: {}", - "createdAt": "Oluşturulma tarihi: {}", + "createdAt": "Oluşturulma: {}", "deleteView": "Sil", - "duplicateView": "Kopyala" + "duplicateView": "Çoğalt", + "wordCountLabel": "Kelime sayısı: ", + "charCountLabel": "Karakter sayısı: ", + "createdAtLabel": "Oluşturulma: ", + "syncedAtLabel": "Senkronize edilme: ", + "saveAsNewPage": "Mesajları sayfaya ekle" }, "importPanel": { - "textAndMarkdown": "Metin & Markdown", - "documentFromV010": "v0.1.0 Belgesi", - "databaseFromV010": "v0.1.0 Veritabanı", + "textAndMarkdown": "Metin ve Markdown", + "documentFromV010": "v0.1.0'dan belge", + "databaseFromV010": "v0.1.0'dan veritabanı", + "notionZip": "Notion Dışa Aktarılmış Zip Dosyası", "csv": "CSV", "database": "Veritabanı" }, "disclosureAction": { - "rename": "Yeniden Adlandır", + "rename": "Yeniden adlandır", "delete": "Sil", - "duplicate": "Kopyala", - "unfavorite": "Favorilerden çıkar", + "duplicate": "Çoğalt", + "unfavorite": "Favorilerden kaldır", "favorite": "Favorilere ekle", "openNewTab": "Yeni sekmede aç", - "moveTo": "Taşı", - "addToFavorites": "Favorilere Ekle", - "copyLink": "Bağlantıyı Kopyala" + "moveTo": "Şuraya taşı", + "addToFavorites": "Favorilere ekle", + "copyLink": "Bağlantıyı kopyala", + "changeIcon": "Simgeyi değiştir", + "collapseAllPages": "Tüm alt sayfaları daralt", + "movePageTo": "Sayfayı şuraya taşı", + "move": "Taşı" }, "blankPageTitle": "Boş sayfa", "newPageText": "Yeni sayfa", "newDocumentText": "Yeni belge", - "newGridText": "Yeni tablo", + "newGridText": "Yeni ızgara", "newCalendarText": "Yeni takvim", "newBoardText": "Yeni pano", + "chat": { + "newChat": "Yapay Zeka Sohbeti", + "inputMessageHint": "@:appName Yapay Zekasına sorun", + "inputLocalAIMessageHint": "@:appName Yerel Yapay Zekasına sorun", + "unsupportedCloudPrompt": "Bu özellik yalnızca @:appName Cloud kullanırken kullanılabilir", + "relatedQuestion": "Önerilen", + "serverUnavailable": "Bağlantı kesildi. Lütfen internet bağlantınızı kontrol edin ve", + "aiServerUnavailable": "Yapay zeka hizmeti geçici olarak kullanılamıyor. Lütfen daha sonra tekrar deneyin.", + "retry": "Tekrar dene", + "clickToRetry": "Tekrar denemek için tıklayın", + "regenerateAnswer": "Yeniden oluştur", + "question1": "Görevleri yönetmek için Kanban nasıl kullanılır", + "question2": "GTD yöntemini açıkla", + "question3": "Neden Rust kullanmalı", + "question4": "Mutfağımdaki malzemelerle tarif", + "question5": "Sayfam için bir illüstrasyon oluştur", + "question6": "Önümüzdeki hafta için yapılacaklar listesi hazırla", + "aiMistakePrompt": "Yapay zeka hata yapabilir. Önemli bilgileri kontrol edin.", + "chatWithFilePrompt": "Dosya ile sohbet etmek ister misiniz?", + "indexFileSuccess": "Dosya başarıyla indekslendi", + "inputActionNoPages": "Sayfa sonucu yok", + "referenceSource": { + "zero": "0 kaynak bulundu", + "one": "{count} kaynak bulundu", + "other": "{count} kaynak bulundu" + }, + "clickToMention": "Bir sayfadan bahset", + "uploadFile": "PDF, metin veya markdown dosyaları ekle", + "questionDetail": "Merhaba {}! Size bugün nasıl yardımcı olabilirim?", + "indexingFile": "{} indeksleniyor", + "generatingResponse": "Yanıt oluşturuluyor", + "selectSources": "Kaynakları Seç", + "sourcesLimitReached": "En fazla 3 üst düzey belge ve alt öğelerini seçebilirsiniz", + "sourceUnsupported": "Şu anda veritabanlarıyla sohbet etmeyi desteklemiyoruz", + "regenerate": "Tekrar dene", + "addToPageButton": "Mesajı sayfaya ekle", + "addToPageTitle": "Mesajı şuraya ekle...", + "addToNewPage": "Yeni sayfa oluştur", + "addToNewPageName": "\"{}\" kaynağından çıkarılan mesajlar", + "addToNewPageSuccessToast": "Mesaj şuraya eklendi:", + "openPagePreviewFailedToast": "Sayfa açılamadı", + "changeFormat": { + "actionButton": "Biçimi değiştir", + "confirmButton": "Bu biçimle yeniden oluştur", + "textOnly": "Metin", + "imageOnly": "Sadece görsel", + "textAndImage": "Metin ve Görsel", + "text": "Paragraf", + "bullet": "Madde işaretli liste", + "number": "Numaralı liste", + "table": "Tablo", + "blankDescription": "Yanıt biçimi", + "defaultDescription": "Otomatik mod", + "textWithImageDescription": "@:chat.changeFormat.text ve görsel", + "numberWithImageDescription": "@:chat.changeFormat.number ve görsel", + "bulletWithImageDescription": "@:chat.changeFormat.bullet ve görsel", + "tableWithImageDescription": "@:chat.changeFormat.table ve görsel" + }, + "selectBanner": { + "saveButton": "Şuraya ekle …", + "selectMessages": "Mesajları seç", + "nSelected": "{} seçildi", + "allSelected": "Tümü seçildi" + } + }, "trash": { "text": "Çöp Kutusu", "restoreAll": "Tümünü Geri Yükle", + "restore": "Geri Yükle", "deleteAll": "Tümünü Sil", "pageHeader": { "fileName": "Dosya adı", - "lastModified": "Son Değiştirilme Tarihi", - "created": "Oluşturulma Tarihi" + "lastModified": "Son Değiştirilme", + "created": "Oluşturulma" }, "confirmDeleteAll": { - "title": "Çöp Kutusu'ndaki tüm sayfaları silmek istediğinizden emin misiniz?", - "caption": "Bu işlem geri alınamaz." + "title": "Çöp kutusundaki tüm sayfalar", + "caption": "Çöp kutusundaki her şeyi silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." }, "confirmRestoreAll": { - "title": "Çöp Kutusu'ndaki tüm sayfaları geri yüklemek istediğinizden emin misiniz?", + "title": "Çöp kutusundaki tüm sayfaları geri yükle", "caption": "Bu işlem geri alınamaz." }, + "restorePage": { + "title": "Geri Yükle: {}", + "caption": "Bu sayfayı geri yüklemek istediğinizden emin misiniz?" + }, "mobile": { "actions": "Çöp Kutusu İşlemleri", - "empty": "Çöp Kutusu Boş", - "emptyDescription": "Silinmiş dosyanız yok", + "empty": "Çöp kutusunda sayfa veya alan yok", + "emptyDescription": "İhtiyacınız olmayan şeyleri Çöp Kutusuna taşıyın.", "isDeleted": "silindi", "isRestored": "geri yüklendi" }, "confirmDeleteTitle": "Bu sayfayı kalıcı olarak silmek istediğinizden emin misiniz?" }, "deletePagePrompt": { - "text": "Bu sayfa Çöp Kutusu'nda", + "text": "Bu sayfa Çöp Kutusunda", "restore": "Sayfayı geri yükle", - "deletePermanent": "Kalıcı olarak sil" + "deletePermanent": "Kalıcı olarak sil", + "deletePermanentDescription": "Bu sayfayı kalıcı olarak silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." }, "dialogCreatePageNameHint": "Sayfa adı", "questionBubble": { "shortcuts": "Kısayollar", - "whatsNew": "Yenilikler?", - "help": "Yardım & Destek", + "whatsNew": "Yenilikler", "markdown": "Markdown", "debug": { "name": "Hata Ayıklama Bilgisi", "success": "Hata ayıklama bilgisi panoya kopyalandı!", "fail": "Hata ayıklama bilgisi panoya kopyalanamadı" }, - "feedback": "Geri Bildirim" + "feedback": "Geri Bildirim", + "help": "Yardım ve Destek" }, "menuAppHeader": { "moreButtonToolTip": "Kaldır, yeniden adlandır ve daha fazlası...", - "addPageTooltip": "İçeriye hızlıca bir sayfa ekle", - "defaultNewPageName": "İsimsiz", - "renameDialog": "Yeniden Adlandır" + "addPageTooltip": "Hızlıca içeri sayfa ekle", + "defaultNewPageName": "Başlıksız", + "renameDialog": "Yeniden adlandır", + "pageNameSuffix": "Kopya" }, - "noPagesInside": "İçinde sayfa yok", + "noPagesInside": "İçeride sayfa yok", "toolbar": { - "undo": "Geri Al", + "undo": "Geri al", "redo": "Yinele", "bold": "Kalın", "italic": "İtalik", - "underline": "Altı Çizili", - "strike": "Üstü Çizili", - "numList": "Numaralı Liste", - "bulletList": "Madde İşaretli Liste", + "underline": "Altı çizili", + "strike": "Üstü çizili", + "numList": "Numaralı liste", + "bulletList": "Madde işaretli liste", "checkList": "Kontrol Listesi", "inlineCode": "Satır İçi Kod", "quote": "Alıntı Bloğu", @@ -204,19 +324,21 @@ "link": "Bağlantı" }, "tooltip": { - "lightMode": "Açık moda geç", - "darkMode": "Koyu moda geç", + "lightMode": "Aydınlık moda geç", + "darkMode": "Karanlık moda geç", "openAsPage": "Sayfa olarak aç", - "addNewRow": "Yeni bir satır ekle", + "addNewRow": "Yeni satır ekle", "openMenu": "Menüyü açmak için tıklayın", - "dragRow": "Satırı yeniden sıralamak için uzun basın", + "dragRow": "Satırı yeniden sıralamak için sürükleyin", "viewDataBase": "Veritabanını görüntüle", - "referencePage": "Bu {name} referans gösteriliyor", - "addBlockBelow": "Alta bir blok ekle" + "referencePage": "Bu {name} referans alındı", + "addBlockBelow": "Alta blok ekle", + "aiGenerate": "Oluştur" }, "sideBar": { "closeSidebar": "Kenar çubuğunu kapat", "openSidebar": "Kenar çubuğunu aç", + "expandSidebar": "Tam sayfa olarak genişlet", "personal": "Kişisel", "private": "Özel", "workspace": "Çalışma Alanı", @@ -224,16 +346,52 @@ "clickToHidePrivate": "Özel alanı gizlemek için tıklayın\nBurada oluşturduğunuz sayfalar yalnızca size görünür", "clickToHideWorkspace": "Çalışma alanını gizlemek için tıklayın\nBurada oluşturduğunuz sayfalar tüm üyelere görünür", "clickToHidePersonal": "Kişisel alanı gizlemek için tıklayın", - "clickToHideFavorites": "Favori alanı gizlemek için tıklayın", - "addAPage": "Sayfa ekle", + "clickToHideFavorites": "Favoriler alanını gizlemek için tıklayın", + "addAPage": "Yeni sayfa ekle", "addAPageToPrivate": "Özel alana sayfa ekle", "addAPageToWorkspace": "Çalışma alanına sayfa ekle", - "recent": "Son" + "recent": "Son", + "today": "Bugün", + "thisWeek": "Bu hafta", + "others": "Önceki favoriler", + "earlier": "Daha önce", + "justNow": "az önce", + "minutesAgo": "{count} dakika önce", + "lastViewed": "Son görüntüleme", + "favoriteAt": "Favorilere eklendi", + "emptyRecent": "Son Sayfa Yok", + "emptyRecentDescription": "Sayfaları görüntüledikçe, kolay erişim için burada listelenecekler.", + "emptyFavorite": "Favori Sayfa Yok", + "emptyFavoriteDescription": "Sayfaları favori olarak işaretleyin—hızlı erişim için burada listelenecekler!", + "removePageFromRecent": "Bu sayfayı Son'dan kaldır?", + "removeSuccess": "Başarıyla kaldırıldı", + "favoriteSpace": "Favoriler", + "RecentSpace": "Son", + "Spaces": "Alanlar", + "upgradeToPro": "Pro'ya yükselt", + "upgradeToAIMax": "Sınırsız yapay zekayı aç", + "storageLimitDialogTitle": "Ücretsiz depolama alanınız bitti. Sınırsız depolama için yükseltin", + "storageLimitDialogTitleIOS": "Ücretsiz depolama alanınız bitti.", + "aiResponseLimitTitle": "Ücretsiz yapay zeka yanıtlarınız bitti. Sınırsız yanıt için Pro Plana yükseltin veya bir yapay zeka eklentisi satın alın", + "aiResponseLimitDialogTitle": "Yapay zeka yanıt limitine ulaşıldı", + "aiResponseLimit": "Ücretsiz yapay zeka yanıtlarınız bitti.\n\nDaha fazla yapay zeka yanıtı almak için Ayarlar -> Plan -> AI Max veya Pro Plan'a tıklayın", + "askOwnerToUpgradeToPro": "Çalışma alanınızın ücretsiz depolama alanı bitiyor. Lütfen çalışma alanı sahibinden Pro Plana yükseltmesini isteyin", + "askOwnerToUpgradeToProIOS": "Çalışma alanınızın ücretsiz depolama alanı bitiyor.", + "askOwnerToUpgradeToAIMax": "Çalışma alanınızın ücretsiz yapay zeka yanıtları bitti. Lütfen çalışma alanı sahibinden planı yükseltmesini veya yapay zeka eklentileri satın almasını isteyin", + "askOwnerToUpgradeToAIMaxIOS": "Çalışma alanınızın ücretsiz yapay zeka yanıtları bitiyor.", + "purchaseAIMax": "Çalışma alanınızın yapay zeka görsel yanıtları bitti. Lütfen çalışma alanı sahibinden AI Max satın almasını isteyin", + "aiImageResponseLimit": "Yapay zeka görsel yanıtlarınız bitti.\n\nDaha fazla yapay zeka görsel yanıtı almak için Ayarlar -> Plan -> AI Max'a tıklayın", + "purchaseStorageSpace": "Depolama Alanı Satın Al", + "singleFileProPlanLimitationDescription": "Ücretsiz planda izin verilen maksimum dosya yükleme boyutunu aştınız. Daha büyük dosyalar yüklemek için lütfen Pro Plana yükseltin", + "purchaseAIResponse": "Satın Al ", + "askOwnerToUpgradeToLocalAI": "Çalışma alanı sahibinden Cihaz Üzerinde Yapay Zekayı etkinleştirmesini isteyin", + "upgradeToAILocal": "En üst düzey gizlilik için yerel modelleri cihazınızda çalıştırın", + "upgradeToAILocalDesc": "Yerel yapay zeka kullanarak PDF'lerle sohbet edin, yazılarınızı geliştirin ve tabloları otomatik doldurun" }, "notifications": { "export": { "markdown": "Not Markdown Olarak Dışa Aktarıldı", - "path": "Belgeler/flowy" + "path": "Documents/flowy" } }, "contactsPage": { @@ -244,6 +402,7 @@ }, "button": { "ok": "Tamam", + "confirm": "Onayla", "done": "Bitti", "cancel": "İptal", "signIn": "Giriş Yap", @@ -252,7 +411,7 @@ "save": "Kaydet", "generate": "Oluştur", "esc": "ESC", - "keep": "Tut", + "keep": "Sakla", "tryAgain": "Tekrar dene", "discard": "Vazgeç", "replace": "Değiştir", @@ -261,16 +420,22 @@ "upload": "Yükle", "edit": "Düzenle", "delete": "Sil", - "duplicate": "Kopyala", + "copy": "Kopyala", + "duplicate": "Çoğalt", "putback": "Geri Koy", "update": "Güncelle", "share": "Paylaş", - "removeFromFavorites": "Favorilerden çıkar", + "removeFromFavorites": "Favorilerden kaldır", + "removeFromRecent": "Son'dan kaldır", "addToFavorites": "Favorilere ekle", - "rename": "Yeniden Adlandır", + "favoriteSuccessfully": "Favorilere eklendi", + "unfavoriteSuccessfully": "Favorilerden kaldırıldı", + "duplicateSuccessfully": "Başarıyla çoğaltıldı", + "rename": "Yeniden adlandır", "helpCenter": "Yardım Merkezi", "add": "Ekle", "yes": "Evet", + "no": "Hayır", "clear": "Temizle", "remove": "Kaldır", "dontRemove": "Kaldırma", @@ -280,32 +445,631 @@ "logout": "Çıkış yap", "deleteAccount": "Hesabı sil", "back": "Geri", - "signInGoogle": "Google ile giriş yap", - "signInGithub": "Github ile giriş yap", - "signInDiscord": "Discord ile giriş yap" + "signInGoogle": "Google ile devam et", + "signInGithub": "GitHub ile devam et", + "signInDiscord": "Discord ile devam et", + "more": "Daha fazla", + "create": "Oluştur", + "close": "Kapat", + "next": "İleri", + "previous": "Geri", + "submit": "Gönder", + "download": "İndir", + "backToHome": "Ana Sayfaya Dön", + "viewing": "Görüntüleme", + "editing": "Düzenleme", + "gotIt": "Anladım", + "retry": "Tekrar dene", + "uploadFailed": "Yükleme başarısız.", + "copyLinkOriginal": "Orijinal bağlantıyı kopyala" }, "label": { "welcome": "Hoş Geldiniz!", "firstName": "Ad", "middleName": "İkinci Ad", "lastName": "Soyad", - "stepX": "{X}. Adım" + "stepX": "Adım {X}" }, "oAuth": { "err": { "failedTitle": "Hesabınıza bağlanılamıyor.", - "failedMsg": "Lütfen tarayıcınızda giriş işlemini tamamladığınızdan emin olun." + "failedMsg": "Lütfen tarayıcınızda oturum açma işlemini tamamladığınızdan emin olun." }, "google": { - "title": "GOOGLE GİRİŞİ", - "instruction1": "Google Kişilerinizi içe aktarmak için, bu uygulamayı web tarayıcınızdan yetkilendirmeniz gerekecek.", + "title": "GOOGLE İLE GİRİŞ", + "instruction1": "Google Kişilerinizi içe aktarmak için, bu uygulamaya web tarayıcınızı kullanarak yetki vermeniz gerekecek.", "instruction2": "Simgeye tıklayarak veya metni seçerek bu kodu panonuza kopyalayın:", "instruction3": "Web tarayıcınızda aşağıdaki bağlantıya gidin ve yukarıdaki kodu girin:", - "instruction4": "Kayıt işlemini tamamladığınızda aşağıdaki butona basın:" + "instruction4": "Kaydı tamamladığınızda aşağıdaki düğmeye basın:" } }, "settings": { "title": "Ayarlar", + "popupMenuItem": { + "settings": "Ayarlar", + "members": "Üyeler", + "trash": "Çöp Kutusu", + "helpAndSupport": "Yardım ve Destek" + }, + "sites": { + "title": "Siteler", + "namespaceTitle": "Alan Adı", + "namespaceDescription": "Alan adınızı ve ana sayfanızı yönetin", + "namespaceHeader": "Alan Adı", + "homepageHeader": "Ana Sayfa", + "updateNamespace": "Alan adını güncelle", + "removeHomepage": "Ana sayfayı kaldır", + "selectHomePage": "Bir sayfa seç", + "clearHomePage": "Bu alan adı için ana sayfayı temizle", + "customUrl": "Özel URL", + "namespace": { + "description": "Bu değişiklik, bu alan adında yayınlanan tüm canlı sayfalara uygulanacak", + "tooltip": "Uygunsuz alan adlarını kaldırma hakkını saklı tutarız", + "updateExistingNamespace": "Mevcut alan adını güncelle", + "upgradeToPro": "Ana sayfa ayarlamak için Pro Plana yükseltin", + "redirectToPayment": "Ödeme sayfasına yönlendiriliyor...", + "onlyWorkspaceOwnerCanSetHomePage": "Yalnızca çalışma alanı sahibi ana sayfa ayarlayabilir", + "pleaseAskOwnerToSetHomePage": "Lütfen çalışma alanı sahibinden Pro Plana yükseltmesini isteyin" + }, + "publishedPage": { + "title": "Tüm yayınlanan sayfalar", + "description": "Yayınlanan sayfalarınızı yönetin", + "page": "Sayfa", + "pathName": "Yol adı", + "date": "Yayınlanma tarihi", + "emptyHinText": "Bu çalışma alanında yayınlanmış sayfanız yok", + "noPublishedPages": "Yayınlanmış sayfa yok", + "settings": "Yayın ayarları", + "clickToOpenPageInApp": "Sayfayı uygulamada aç", + "clickToOpenPageInBrowser": "Sayfayı tarayıcıda aç" + }, + "error": { + "failedToGeneratePaymentLink": "Pro Plan için ödeme bağlantısı oluşturulamadı", + "failedToUpdateNamespace": "Alan adı güncellenemedi", + "proPlanLimitation": "Alan adını güncellemek için Pro Plana yükseltmeniz gerekiyor", + "namespaceAlreadyInUse": "Bu alan adı zaten alınmış, lütfen başka bir tane deneyin", + "invalidNamespace": "Geçersiz alan adı, lütfen başka bir tane deneyin", + "namespaceLengthAtLeast2Characters": "Alan adı en az 2 karakter uzunluğunda olmalıdır", + "onlyWorkspaceOwnerCanUpdateNamespace": "Alan adını yalnızca çalışma alanı sahibi güncelleyebilir", + "onlyWorkspaceOwnerCanRemoveHomepage": "Ana sayfayı yalnızca çalışma alanı sahibi kaldırabilir", + "setHomepageFailed": "Ana sayfa ayarlanamadı", + "namespaceTooLong": "Alan adı çok uzun, lütfen başka bir tane deneyin", + "namespaceTooShort": "Alan adı çok kısa, lütfen başka bir tane deneyin", + "namespaceIsReserved": "Bu alan adı rezerve edilmiş, lütfen başka bir tane deneyin", + "updatePathNameFailed": "Yol adı güncellenemedi", + "removeHomePageFailed": "Ana sayfa kaldırılamadı", + "publishNameContainsInvalidCharacters": "Yol adı geçersiz karakter(ler) içeriyor, lütfen başka bir tane deneyin", + "publishNameTooShort": "Yol adı çok kısa, lütfen başka bir tane deneyin", + "publishNameTooLong": "Yol adı çok uzun, lütfen başka bir tane deneyin", + "publishNameAlreadyInUse": "Bu yol adı zaten kullanımda, lütfen başka bir tane deneyin", + "namespaceContainsInvalidCharacters": "Alan adı geçersiz karakter(ler) içeriyor, lütfen başka bir tane deneyin", + "publishPermissionDenied": "Yayın ayarlarını yalnızca çalışma alanı sahibi veya sayfa yayıncısı yönetebilir", + "publishNameCannotBeEmpty": "Yol adı boş olamaz, lütfen başka bir tane deneyin" + }, + "success": { + "namespaceUpdated": "Alan adı başarıyla güncellendi", + "setHomepageSuccess": "Ana sayfa başarıyla ayarlandı", + "updatePathNameSuccess": "Yol adı başarıyla güncellendi", + "removeHomePageSuccess": "Ana sayfa başarıyla kaldırıldı" + } + }, + "accountPage": { + "menuLabel": "Hesabım", + "title": "Hesabım", + "general": { + "title": "Hesap adı ve profil resmi", + "changeProfilePicture": "Profil resmini değiştir" + }, + "email": { + "title": "E-posta", + "actions": { + "change": "E-postayı değiştir" + } + }, + "login": { + "title": "Hesap girişi", + "loginLabel": "Giriş yap", + "logoutLabel": "Çıkış yap" + } + }, + "workspacePage": { + "menuLabel": "Çalışma Alanı", + "title": "Çalışma Alanı", + "description": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarih/saat biçimini ve dilini özelleştirin.", + "workspaceName": { + "title": "Çalışma alanı adı" + }, + "workspaceIcon": { + "title": "Çalışma alanı simgesi", + "description": "Çalışma alanınız için bir resim yükleyin veya emoji kullanın. Simge, kenar çubuğunuzda ve bildirimlerinizde görünecektir." + }, + "appearance": { + "title": "Görünüm", + "description": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarih, saat ve dilini özelleştirin.", + "options": { + "system": "Otomatik", + "light": "Aydınlık", + "dark": "Karanlık" + } + }, + "resetCursorColor": { + "title": "Belge imleç rengini sıfırla", + "description": "İmleç rengini sıfırlamak istediğinizden emin misiniz?" + }, + "resetSelectionColor": { + "title": "Belge seçim rengini sıfırla", + "description": "Seçim rengini sıfırlamak istediğinizden emin misiniz?" + }, + "resetWidth": { + "resetSuccess": "Belge genişliği başarıyla sıfırlandı" + }, + "theme": { + "title": "Tema", + "description": "Önceden ayarlanmış bir tema seçin veya kendi özel temanızı yükleyin.", + "uploadCustomThemeTooltip": "Özel tema yükle" + }, + "workspaceFont": { + "title": "Çalışma alanı yazı tipi", + "noFontHint": "Yazı tipi bulunamadı, başka bir terim deneyin." + }, + "textDirection": { + "title": "Metin yönü", + "leftToRight": "Soldan sağa", + "rightToLeft": "Sağdan sola", + "auto": "Otomatik", + "enableRTLItems": "RTL araç çubuğu öğelerini etkinleştir" + }, + "layoutDirection": { + "title": "Düzen yönü", + "leftToRight": "Soldan sağa", + "rightToLeft": "Sağdan sola" + }, + "dateTime": { + "title": "Tarih ve saat", + "example": "{} {} ({})", + "24HourTime": "24 saat biçimi", + "dateFormat": { + "label": "Tarih biçimi", + "local": "Yerel", + "us": "ABD", + "iso": "ISO", + "friendly": "Kullanıcı dostu", + "dmy": "G/A/Y" + } + }, + "language": { + "title": "Dil" + }, + "deleteWorkspacePrompt": { + "title": "Çalışma alanını sil", + "content": "Bu çalışma alanını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve yayınladığınız tüm sayfaların yayını kaldırılacaktır." + }, + "leaveWorkspacePrompt": { + "title": "Çalışma alanından ayrıl", + "content": "Bu çalışma alanından ayrılmak istediğinizden emin misiniz? İçindeki tüm sayfalara ve verilere erişiminizi kaybedeceksiniz.", + "success": "Çalışma alanından başarıyla ayrıldınız.", + "fail": "Çalışma alanından ayrılınamadı." + }, + "manageWorkspace": { + "title": "Çalışma alanını yönet", + "leaveWorkspace": "Çalışma alanından ayrıl", + "deleteWorkspace": "Çalışma alanını sil" + } + }, + "manageDataPage": { + "menuLabel": "Verileri yönet", + "title": "Verileri yönet", + "description": "Yerel depolama verilerini yönetin veya mevcut verilerinizi @:appName'e aktarın.", + "dataStorage": { + "title": "Dosya depolama konumu", + "tooltip": "Dosyalarınızın depolandığı konum", + "actions": { + "change": "Yolu değiştir", + "open": "Klasörü aç", + "openTooltip": "Mevcut veri klasörü konumunu aç", + "copy": "Yolu kopyala", + "copiedHint": "Yol kopyalandı!", + "resetTooltip": "Varsayılan konuma sıfırla" + }, + "resetDialog": { + "title": "Emin misiniz?", + "description": "Yolu varsayılan veri konumuna sıfırlamak verilerinizi silmeyecektir. Mevcut verilerinizi yeniden içe aktarmak istiyorsanız, önce mevcut konumunuzun yolunu kopyalamalısınız." + } + }, + "importData": { + "title": "Veri içe aktar", + "tooltip": "@:appName yedeklerinden/veri klasörlerinden veri içe aktar", + "description": "Harici bir @:appName veri klasöründen veri kopyala", + "action": "Dosyaya göz at" + }, + "encryption": { + "title": "Şifreleme", + "tooltip": "Verilerinizin nasıl depolandığını ve şifrelendiğini yönetin", + "descriptionNoEncryption": "Şifrelemeyi açmak tüm verileri şifreleyecektir. Bu işlem geri alınamaz.", + "descriptionEncrypted": "Verileriniz şifrelenmiş.", + "action": "Verileri şifrele", + "dialog": { + "title": "Tüm verileriniz şifrelensin mi?", + "description": "Tüm verilerinizi şifrelemek, verilerinizi güvenli ve emniyetli tutacaktır. Bu işlem GERİ ALINAMAZ. Devam etmek istediğinizden emin misiniz?" + } + }, + "cache": { + "title": "Önbelleği temizle", + "description": "Görsel yüklenmemesi, bir alanda eksik sayfalar ve yazı tiplerinin yüklenmemesi gibi sorunları çözmeye yardımcı olur. Bu, verilerinizi etkilemeyecektir.", + "dialog": { + "title": "Önbelleği temizle", + "description": "Görsel yüklenmemesi, bir alanda eksik sayfalar ve yazı tiplerinin yüklenmemesi gibi sorunları çözmeye yardımcı olur. Bu, verilerinizi etkilemeyecektir.", + "successHint": "Önbellek temizlendi!" + } + }, + "data": { + "fixYourData": "Verilerinizi düzeltin", + "fixButton": "Düzelt", + "fixYourDataDescription": "Verilerinizle ilgili sorunlar yaşıyorsanız, burada düzeltmeyi deneyebilirsiniz." + } + }, + "shortcutsPage": { + "menuLabel": "Kısayollar", + "title": "Kısayollar", + "editBindingHint": "Yeni bağlama girin", + "searchHint": "Ara", + "actions": { + "resetDefault": "Varsayılana sıfırla" + }, + "errorPage": { + "message": "Kısayollar yüklenemedi: {}", + "howToFix": "Lütfen tekrar deneyin, sorun devam ederse GitHub üzerinden bize ulaşın." + }, + "resetDialog": { + "title": "Kısayolları sıfırla", + "description": "Bu işlem tüm tuş bağlamalarınızı varsayılana sıfırlayacak, daha sonra geri alamazsınız, devam etmek istediğinizden emin misiniz?", + "buttonLabel": "Sıfırla" + }, + "conflictDialog": { + "title": "{} şu anda kullanımda", + "descriptionPrefix": "Bu tuş bağlaması şu anda ", + "descriptionSuffix": " tarafından kullanılıyor. Bu tuş bağlamasını değiştirirseniz, {} üzerinden kaldırılacak.", + "confirmLabel": "Devam et" + }, + "editTooltip": "Tuş bağlamasını düzenlemeye başlamak için basın", + "keybindings": { + "toggleToDoList": "Yapılacaklar listesini aç/kapat", + "insertNewParagraphInCodeblock": "Yeni paragraf ekle", + "pasteInCodeblock": "Kod bloğuna yapıştır", + "selectAllCodeblock": "Tümünü seç", + "indentLineCodeblock": "Satır başına iki boşluk ekle", + "outdentLineCodeblock": "Satır başından iki boşluk sil", + "twoSpacesCursorCodeblock": "İmleç konumuna iki boşluk ekle", + "copy": "Seçimi kopyala", + "paste": "İçeriği yapıştır", + "cut": "Seçimi kes", + "alignLeft": "Metni sola hizala", + "alignCenter": "Metni ortala", + "alignRight": "Metni sağa hizala", + "undo": "Geri al", + "redo": "Yinele", + "convertToParagraph": "Bloğu paragrafa dönüştür", + "backspace": "Sil", + "deleteLeftWord": "Sol kelimeyi sil", + "deleteLeftSentence": "Sol cümleyi sil", + "delete": "Sağdaki karakteri sil", + "deleteMacOS": "Soldaki karakteri sil", + "deleteRightWord": "Sağdaki kelimeyi sil", + "moveCursorLeft": "İmleci sola taşı", + "moveCursorBeginning": "İmleci başa taşı", + "moveCursorLeftWord": "İmleci bir kelime sola taşı", + "moveCursorLeftSelect": "Seç ve imleci sola taşı", + "moveCursorBeginSelect": "Seç ve imleci başa taşı", + "moveCursorLeftWordSelect": "Seç ve imleci bir kelime sola taşı", + "moveCursorRight": "İmleci sağa taşı", + "moveCursorEnd": "İmleci sona taşı", + "moveCursorRightWord": "İmleci bir kelime sağa taşı", + "moveCursorRightSelect": "Seç ve imleci sağa taşı", + "moveCursorEndSelect": "Seç ve imleci sona taşı", + "moveCursorRightWordSelect": "Seç ve imleci bir kelime sağa taşı", + "moveCursorUp": "İmleci yukarı taşı", + "moveCursorTopSelect": "Seç ve imleci en üste taşı", + "moveCursorTop": "İmleci en üste taşı", + "moveCursorUpSelect": "Seç ve imleci yukarı taşı", + "moveCursorBottomSelect": "Seç ve imleci en alta taşı", + "moveCursorBottom": "İmleci en alta taşı", + "moveCursorDown": "İmleci aşağı taşı", + "moveCursorDownSelect": "Seç ve imleci aşağı taşı", + "home": "En üste kaydır", + "end": "En alta kaydır", + "toggleBold": "Kalın yazıyı aç/kapat", + "toggleItalic": "İtalik yazıyı aç/kapat", + "toggleUnderline": "Altı çizili yazıyı aç/kapat", + "toggleStrikethrough": "Üstü çizili yazıyı aç/kapat", + "toggleCode": "Satır içi kodu aç/kapat", + "toggleHighlight": "Vurgulamayı aç/kapat", + "showLinkMenu": "Bağlantı menüsünü göster", + "openInlineLink": "Satır içi bağlantıyı aç", + "openLinks": "Seçili tüm bağlantıları aç", + "indent": "Girinti ekle", + "outdent": "Girintiyi azalt", + "exit": "Düzenlemeden çık", + "pageUp": "Bir sayfa yukarı kaydır", + "pageDown": "Bir sayfa aşağı kaydır", + "selectAll": "Tümünü seç", + "pasteWithoutFormatting": "İçeriği biçimlendirme olmadan yapıştır", + "showEmojiPicker": "Emoji seçiciyi göster", + "enterInTableCell": "Tabloda satır sonu ekle", + "leftInTableCell": "Tabloda bir hücre sola git", + "rightInTableCell": "Tabloda bir hücre sağa git", + "upInTableCell": "Tabloda bir hücre yukarı git", + "downInTableCell": "Tabloda bir hücre aşağı git", + "tabInTableCell": "Tabloda sonraki kullanılabilir hücreye git", + "shiftTabInTableCell": "Tabloda önceki kullanılabilir hücreye git", + "backSpaceInTableCell": "Hücrenin başında dur" + }, + "commands": { + "codeBlockNewParagraph": "Kod bloğunun yanına yeni bir paragraf ekle", + "codeBlockIndentLines": "Kod bloğunda satır başına iki boşluk ekle", + "codeBlockOutdentLines": "Kod bloğunda satır başından iki boşluk sil", + "codeBlockAddTwoSpaces": "Kod bloğunda imleç konumuna iki boşluk ekle", + "codeBlockSelectAll": "Kod bloğu içindeki tüm içeriği seç", + "codeBlockPasteText": "Kod bloğuna metin yapıştır", + "textAlignLeft": "Metni sola hizala", + "textAlignCenter": "Metni ortala", + "textAlignRight": "Metni sağa hizala" + }, + "couldNotLoadErrorMsg": "Kısayollar yüklenemedi, tekrar deneyin", + "couldNotSaveErrorMsg": "Kısayollar kaydedilemedi, tekrar deneyin" + }, + "aiPage": { + "title": "Yapay Zeka Ayarları", + "menuLabel": "Yapay Zeka Ayarları", + "keys": { + "enableAISearchTitle": "Yapay Zeka Arama", + "aiSettingsDescription": "AppFlowy Yapay Zeka'yı güçlendirmek için tercih ettiğiniz modeli seçin. Şu anda GPT 4-o, Claude 3,5, Llama 3.1 ve Mistral 7B içerir", + "loginToEnableAIFeature": "Yapay Zeka özellikleri yalnızca @:appName Cloud ile giriş yaptıktan sonra etkinleştirilir. Bir @:appName hesabınız yoksa, kaydolmak için 'Hesabım'a gidin", + "llmModel": "Dil Modeli", + "llmModelType": "Dil Modeli Türü", + "downloadLLMPrompt": "{} İndir", + "downloadAppFlowyOfflineAI": "Yapay Zeka çevrimdışı paketini indirmek, Yapay Zeka'nın cihazınızda çalışmasını sağlayacak. Devam etmek istiyor musunuz?", + "downloadLLMPromptDetail": "{} yerel modelini indirmek {} depolama alanı kullanacak. Devam etmek istiyor musunuz?", + "downloadBigFilePrompt": "İndirmenin tamamlanması yaklaşık 10 dakika sürebilir", + "downloadAIModelButton": "İndir", + "downloadingModel": "İndiriliyor", + "localAILoaded": "Yerel Yapay Zeka Modeli başarıyla eklendi ve kullanıma hazır", + "localAIStart": "Yerel Yapay Zeka Sohbeti başlatılıyor...", + "localAILoading": "Yerel Yapay Zeka Sohbet Modeli yükleniyor...", + "localAIStopped": "Yerel Yapay Zeka durduruldu", + "failToLoadLocalAI": "Yerel Yapay Zeka başlatılamadı", + "restartLocalAI": "Yerel Yapay Zeka'yı Yeniden Başlat", + "disableLocalAITitle": "Yerel Yapay Zeka'yı devre dışı bırak", + "disableLocalAIDescription": "Yerel Yapay Zeka'yı devre dışı bırakmak istiyor musunuz?", + "localAIToggleTitle": "Yerel Yapay Zeka'yı etkinleştirmek veya devre dışı bırakmak için değiştirin", + "offlineAIInstruction1": "Çevrimdışı Yapay Zeka'yı etkinleştirmek için", + "offlineAIInstruction2": "talimatları", + "offlineAIInstruction3": "takip edin.", + "offlineAIDownload1": "AppFlowy Yapay Zeka'yı henüz indirmediyseniz, lütfen", + "offlineAIDownload2": "indirin", + "offlineAIDownload3": "önce", + "activeOfflineAI": "Etkin", + "downloadOfflineAI": "İndir", + "openModelDirectory": "Klasörü aç" + } + }, + "planPage": { + "menuLabel": "Plan", + "title": "Fiyatlandırma planı", + "planUsage": { + "title": "Plan kullanım özeti", + "storageLabel": "Depolama", + "storageUsage": "{} / {} GB", + "unlimitedStorageLabel": "Sınırsız depolama", + "collaboratorsLabel": "Üyeler", + "collaboratorsUsage": "{} / {}", + "aiResponseLabel": "Yapay Zeka Yanıtları", + "aiResponseUsage": "{} / {}", + "unlimitedAILabel": "Sınırsız yanıt", + "proBadge": "Pro", + "aiMaxBadge": "Yapay Zeka Max", + "aiOnDeviceBadge": "Mac için Cihaz Üzerinde Yapay Zeka", + "memberProToggle": "Daha fazla üye ve sınırsız Yapay Zeka", + "aiMaxToggle": "Sınırsız Yapay Zeka ve gelişmiş modellere erişim", + "aiOnDeviceToggle": "Maksimum gizlilik için yerel Yapay Zeka", + "aiCredit": { + "title": "@:appName Yapay Zeka Kredisi Ekle", + "price": "{}", + "priceDescription": "1.000 kredi için", + "purchase": "Yapay Zeka Satın Al", + "info": "Çalışma alanı başına 1.000 Yapay Zeka kredisi ekleyin ve özelleştirilebilir Yapay Zeka'yı iş akışınıza sorunsuz bir şekilde entegre ederek daha akıllı, daha hızlı sonuçlar elde edin:", + "infoItemOne": "Veritabanı başına 10.000 yanıt", + "infoItemTwo": "Çalışma alanı başına 1.000 yanıt" + }, + "currentPlan": { + "bannerLabel": "Mevcut plan", + "freeTitle": "Ücretsiz", + "proTitle": "Pro", + "teamTitle": "Takım", + "freeInfo": "2 üyeye kadar bireyler için her şeyi düzenlemek için mükemmel", + "proInfo": "10 üyeye kadar küçük ve orta ölçekli takımlar için mükemmel.", + "teamInfo": "Tüm üretken ve iyi organize edilmiş takımlar için mükemmel.", + "upgrade": "Planı değiştir", + "canceledInfo": "Planınız iptal edildi, {} tarihinde Ücretsiz plana düşürüleceksiniz." + }, + "addons": { + "title": "Eklentiler", + "addLabel": "Ekle", + "activeLabel": "Eklendi", + "aiMax": { + "title": "Yapay Zeka Max", + "description": "Gelişmiş Yapay Zeka modelleri tarafından desteklenen sınırsız Yapay Zeka yanıtları ve ayda 50 Yapay Zeka görüntüsü", + "price": "{}", + "priceInfo": "Kullanıcı başına aylık, yıllık faturalandırma" + }, + "aiOnDevice": { + "title": "Mac için Cihaz Üzerinde Yapay Zeka", + "description": "Mistral 7B, LLAMA 3 ve daha fazla yerel modeli makinenizde çalıştırın", + "price": "{}", + "priceInfo": "Kullanıcı başına aylık, yıllık faturalandırma", + "recommend": "M1 veya daha yenisi önerilir" + } + }, + "deal": { + "bannerLabel": "Yeni yıl fırsatı!", + "title": "Takımınızı büyütün!", + "info": "Yükseltin ve Pro ve Takım planlarında %10 indirim kazanın! @:appName Yapay Zeka dahil güçlü yeni özelliklerle çalışma alanı verimliliğinizi artırın.", + "viewPlans": "Planları görüntüle" + } + } + }, + "billingPage": { + "menuLabel": "Faturalandırma", + "title": "Faturalandırma", + "plan": { + "title": "Plan", + "freeLabel": "Ücretsiz", + "proLabel": "Pro", + "planButtonLabel": "Planı değiştir", + "billingPeriod": "Faturalandırma dönemi", + "periodButtonLabel": "Dönemi düzenle" + }, + "paymentDetails": { + "title": "Ödeme detayları", + "methodLabel": "Ödeme yöntemi", + "methodButtonLabel": "Yöntemi düzenle" + }, + "addons": { + "title": "Eklentiler", + "addLabel": "Ekle", + "removeLabel": "Kaldır", + "renewLabel": "Yenile", + "aiMax": { + "label": "Yapay Zeka Max", + "description": "Sınırsız Yapay Zeka ve gelişmiş modellerin kilidini açın", + "activeDescription": "Sonraki fatura tarihi: {}", + "canceledDescription": "Yapay Zeka Max {} tarihine kadar kullanılabilir olacak" + }, + "aiOnDevice": { + "label": "Mac için Cihaz Üzerinde Yapay Zeka", + "description": "Cihazınızda sınırsız Cihaz Üzerinde Yapay Zeka'nın kilidini açın", + "activeDescription": "Sonraki fatura tarihi: {}", + "canceledDescription": "Mac için Cihaz Üzerinde Yapay Zeka {} tarihine kadar kullanılabilir olacak" + }, + "removeDialog": { + "title": "{} Kaldır", + "description": "{plan} planını kaldırmak istediğinizden emin misiniz? {plan} planının özelliklerine ve avantajlarına erişiminizi hemen kaybedeceksiniz." + } + }, + "currentPeriodBadge": "MEVCUT", + "changePeriod": "Dönemi değiştir", + "planPeriod": "{} dönemi", + "monthlyInterval": "Aylık", + "monthlyPriceInfo": "koltuk başına aylık faturalandırma", + "annualInterval": "Yıllık", + "annualPriceInfo": "koltuk başına yıllık faturalandırma" + }, + "comparePlanDialog": { + "title": "Plan karşılaştır ve seç", + "planFeatures": "Plan\nÖzellikleri", + "current": "Mevcut", + "actions": { + "upgrade": "Yükselt", + "downgrade": "Düşür", + "current": "Mevcut" + }, + "freePlan": { + "title": "Ücretsiz", + "description": "2 üyeye kadar bireyler için her şeyi düzenlemek için", + "price": "{}", + "priceInfo": "Sonsuza kadar ücretsiz" + }, + "proPlan": { + "title": "Pro", + "description": "Küçük takımların projeleri ve takım bilgisini yönetmesi için", + "price": "{}", + "priceInfo": "Kullanıcı başına aylık \nyıllık faturalandırma\n\n{} aylık faturalandırma" + }, + "planLabels": { + "itemOne": "Çalışma Alanları", + "itemTwo": "Üyeler", + "itemThree": "Depolama", + "itemFour": "Gerçek zamanlı işbirliği", + "itemFive": "Mobil uygulama", + "itemSix": "Yapay Zeka Yanıtları", + "itemFileUpload": "Dosya yüklemeleri", + "customNamespace": "Özel alan adı", + "tooltipSix": "Ömür boyu demek, yanıt sayısının asla sıfırlanmayacağı anlamına gelir", + "intelligentSearch": "Akıllı arama", + "tooltipSeven": "Çalışma alanınızın URL'sinin bir kısmını özelleştirmenize olanak tanır", + "customNamespaceTooltip": "Özel yayınlanmış site URL'si" + }, + "freeLabels": { + "itemOne": "Çalışma alanı başına ücretlendirilir", + "itemTwo": "2'ye kadar", + "itemThree": "5 GB", + "itemFour": "evet", + "itemFive": "evet", + "itemSix": "10 ömür boyu", + "itemFileUpload": "7 MB'a kadar", + "intelligentSearch": "Akıllı arama" + }, + "proLabels": { + "itemOne": "Çalışma alanı başına ücretlendirilir", + "itemTwo": "10'a kadar", + "itemThree": "Sınırsız", + "itemFour": "evet", + "itemFive": "evet", + "itemSix": "Sınırsız", + "itemFileUpload": "Sınırsız", + "intelligentSearch": "Akıllı arama" + }, + "paymentSuccess": { + "title": "Artık {} planındasınız!", + "description": "Ödemeniz başarıyla işleme alındı ve planınız @:appName {}'e yükseltildi. Plan detaylarınızı Plan sayfasında görüntüleyebilirsiniz" + }, + "downgradeDialog": { + "title": "Planınızı düşürmek istediğinizden emin misiniz?", + "description": "Planınızı düşürmek sizi Ücretsiz plana geri döndürecek. Üyeler bu çalışma alanına erişimlerini kaybedebilir ve Ücretsiz planın depolama sınırlarına uymak için alan açmanız gerekebilir.", + "downgradeLabel": "Planı düşür" + } + }, + "cancelSurveyDialog": { + "title": "Gitmenize üzüldük", + "description": "Gitmenize üzüldük. @:appName'i geliştirmemize yardımcı olmak için geri bildiriminizi duymak isteriz. Lütfen birkaç soruyu yanıtlamak için zaman ayırın.", + "commonOther": "Diğer", + "otherHint": "Yanıtınızı buraya yazın", + "questionOne": { + "question": "@:appName Pro aboneliğinizi iptal etmenize ne sebep oldu?", + "answerOne": "Maliyet çok yüksek", + "answerTwo": "Özellikler beklentileri karşılamadı", + "answerThree": "Daha iyi bir alternatif buldum", + "answerFour": "Maliyeti haklı çıkaracak kadar kullanmadım", + "answerFive": "Hizmet sorunu veya teknik zorluklar" + }, + "questionTwo": { + "question": "Gelecekte @:appName Pro'ya yeniden abone olma olasılığınız nedir?", + "answerOne": "Çok muhtemel", + "answerTwo": "Biraz muhtemel", + "answerThree": "Emin değilim", + "answerFour": "Muhtemel değil", + "answerFive": "Hiç muhtemel değil" + }, + "questionThree": { + "question": "Aboneliğiniz sırasında en çok hangi Pro özelliğine değer verdiniz?", + "answerOne": "Çoklu kullanıcı işbirliği", + "answerTwo": "Daha uzun süreli versiyon geçmişi", + "answerThree": "Sınırsız Yapay Zeka yanıtları", + "answerFour": "Yerel Yapay Zeka modellerine erişim" + }, + "questionFour": { + "question": "@:appName ile genel deneyiminizi nasıl tanımlarsınız?", + "answerOne": "Harika", + "answerTwo": "İyi", + "answerThree": "Ortalama", + "answerFour": "Ortalamanın altında", + "answerFive": "Memnun değilim" + } + }, + "common": { + "uploadingFile": "Dosya yükleniyor. Lütfen uygulamadan çıkmayın", + "uploadNotionSuccess": "Notion zip dosyanız başarıyla yüklendi. İçe aktarma tamamlandığında bir onay e-postası alacaksınız", + "reset": "Sıfırla" + }, "menu": { "appearance": "Görünüm", "language": "Dil", @@ -315,76 +1079,128 @@ "open": "Ayarları Aç", "logout": "Çıkış Yap", "logoutPrompt": "Çıkış yapmak istediğinizden emin misiniz?", - "selfEncryptionLogoutPrompt": "Çıkış yapmak istediğinizden emin misiniz? Lütfen şifreleme anahtarını kopyaladığınızdan emin olun", + "selfEncryptionLogoutPrompt": "Çıkış yapmak istediğinizden emin misiniz? Lütfen şifreleme anahtarınızı kopyaladığınızdan emin olun", "syncSetting": "Senkronizasyon Ayarı", "cloudSettings": "Bulut Ayarları", "enableSync": "Senkronizasyonu etkinleştir", + "enableSyncLog": "Senkronizasyon günlüğünü etkinleştir", + "enableSyncLogWarning": "Senkronizasyon sorunlarını teşhis etmeye yardımcı olduğunuz için teşekkür ederiz. Bu, belge düzenlemelerinizi yerel bir dosyaya kaydedecek. Lütfen etkinleştirdikten sonra uygulamayı kapatıp yeniden açın", "enableEncrypt": "Verileri şifrele", "cloudURL": "Temel URL", + "webURL": "Web URL'si", "invalidCloudURLScheme": "Geçersiz Şema", "cloudServerType": "Bulut sunucusu", - "cloudServerTypeTip": "Bulut sunucusunu değiştirdikten sonra geçerli hesabınızdan çıkış yapabileceğini lütfen unutmayın", + "cloudServerTypeTip": "Lütfen bulut sunucusunu değiştirdikten sonra mevcut hesabınızdan çıkış yapabileceğinizi unutmayın", "cloudLocal": "Yerel", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "Supabase URL'si", - "cloudSupabaseUrlCanNotBeEmpty": "Supabase url'si boş olamaz", - "cloudSupabaseAnonKey": "Supabase anonim anahtarı", - "cloudSupabaseAnonKeyCanNotBeEmpty": "Anonim anahtar boş olamaz", - "cloudAppFlowy": "AppFlowy Bulutu Beta", - "cloudAppFlowySelfHost": "AppFlowy Bulutu Kendi Sunucunuzda", - "appFlowyCloudUrlCanNotBeEmpty": "Bulut url'si boş olamaz", - "clickToCopy": "Kopyalamak için tıklayın", + "cloudAppFlowy": "@:appName Cloud", + "cloudAppFlowySelfHost": "@:appName Cloud Kendi Kendine Barındırma", + "appFlowyCloudUrlCanNotBeEmpty": "Bulut URL'si boş olamaz", + "clickToCopy": "Panoya kopyala", "selfHostStart": "Bir sunucunuz yoksa, lütfen", - "selfHostContent": "belgeye", - "selfHostEnd": "bakın. Kendi sunucunuzu nasıl kuracağınız konusunda rehberlik için", + "selfHostContent": "belgesine", + "selfHostEnd": "bakarak kendi sunucunuzu nasıl barındıracağınızı öğrenin", + "pleaseInputValidURL": "Lütfen geçerli bir URL girin", + "changeUrl": "Kendi kendine barındırılan URL'yi {} olarak değiştir", "cloudURLHint": "Sunucunuzun temel URL'sini girin", + "webURLHint": "Web sunucunuzun temel URL'sini girin", "cloudWSURL": "Websocket URL'si", "cloudWSURLHint": "Sunucunuzun websocket adresini girin", "restartApp": "Yeniden Başlat", - "restartAppTip": "Değişikliklerin etkili olması için uygulamayı yeniden başlatın. Lütfen bunun geçerli hesabınızdan çıkış yapabileceğini unutmayın", - "changeServerTip": "Sunucuyu değiştirdikten sonra, değişikliklerin etkili olması için yeniden başlat düğmesine tıklamanız gerekir", - "enableEncryptPrompt": "Verilerinizi bu anahtarla güvence altına almak için şifrelemeyi etkinleştirin. Güvenli bir şekilde saklayın; etkinleştirildikten sonra kapatılamaz. Kaybedilirse, verilerinize ulaşılamaz. Kopyalamak için tıklayın", - "inputEncryptPrompt": "Lütfen şifreleme anahtarınızı girin", + "restartAppTip": "Değişikliklerin etkili olması için uygulamayı yeniden başlatın. Bu işlemin mevcut hesabınızdan çıkış yapabileceğini unutmayın.", + "changeServerTip": "Sunucuyu değiştirdikten sonra, değişikliklerin etkili olması için yeniden başlat düğmesine tıklamalısınız", + "enableEncryptPrompt": "Verilerinizi bu anahtar ile güvence altına almak için şifrelemeyi etkinleştirin. Güvenli bir şekilde saklayın; etkinleştirildikten sonra kapatılamaz. Kaybedilirse, verileriniz kurtarılamaz hale gelir. Kopyalamak için tıklayın", + "inputEncryptPrompt": "Lütfen şifreleme anahtarlarını girin", "clickToCopySecret": "Anahtarı kopyalamak için tıklayın", "configServerSetting": "Sunucu ayarlarınızı yapılandırın", - "configServerGuide": "`Hızlı Başlangıç`ı seçtikten sonra, kendi sunucunuzu yapılandırmak için `Ayarlar` ve ardından \"Bulut Ayarları\"na gidin.", + "configServerGuide": "'Hızlı Başlangıç'ı seçtikten sonra, 'Ayarlar'a ve ardından \"Bulut Ayarları\"na giderek kendi kendine barındırılan sunucunuzu yapılandırın.", "inputTextFieldHint": "Anahtarınız", "historicalUserList": "Kullanıcı giriş geçmişi", - "historicalUserListTooltip": "Bu liste anonim hesaplarınızı görüntüler. Ayrıntılarını görüntülemek için bir hesaba tıklayabilirsiniz. Anonim hesaplar 'Başlayın' düğmesine tıklanarak oluşturulur", + "historicalUserListTooltip": "Bu liste anonim hesaplarınızı gösterir. Detaylarını görüntülemek için bir hesaba tıklayabilirsiniz. Anonim hesaplar 'Başla' düğmesine tıklanarak oluşturulur", "openHistoricalUser": "Anonim hesabı açmak için tıklayın", - "customPathPrompt": "AppFlowy veri klasörünü Google Drive gibi bulutla senkronize edilmiş bir klasörde saklamak risk oluşturabilir. Bu klasördeki veritabanına aynı anda birden fazla konumdan erişilir veya değiştirilirse, senkronizasyon çakışmaları ve olası veri bozulmasıyla sonuçlanabilir", - "importAppFlowyData": "Harici AppFlowy Klasöründen Veri Al", - "importingAppFlowyDataTip": "Veri aktarımı devam ediyor. Lütfen uygulamayı kapatmayın", - "importAppFlowyDataDescription": "Harici bir AppFlowy veri klasöründen veri kopyalayın ve geçerli AppFlowy veri klasörüne aktarın", - "importSuccess": "AppFlowy veri klasörü başarıyla alındı", - "importFailed": "AppFlowy veri klasörü alınamadı", - "importGuide": "Daha fazla bilgi için lütfen referans belgeyi kontrol edin" + "customPathPrompt": "@:appName veri klasörünü Google Drive gibi bulut senkronizasyonlu bir klasörde saklamak risk oluşturabilir. Bu klasördeki veritabanına aynı anda birden fazla konumdan erişilir veya değiştirilirse, senkronizasyon çakışmaları ve olası veri bozulmaları meydana gelebilir", + "importAppFlowyData": "Harici @:appName Klasöründen Veri İçe Aktar", + "importingAppFlowyDataTip": "Veri içe aktarma devam ediyor. Lütfen uygulamayı kapatmayın", + "importAppFlowyDataDescription": "Harici bir @:appName veri klasöründen veri kopyalayın ve mevcut AppFlowy veri klasörüne aktarın", + "importSuccess": "@:appName veri klasörü başarıyla içe aktarıldı", + "importFailed": "@:appName veri klasörünün içe aktarılması başarısız oldu", + "importGuide": "Daha fazla detay için lütfen referans belgeyi kontrol edin" }, "notifications": { "enableNotifications": { "label": "Bildirimleri etkinleştir", "hint": "Yerel bildirimlerin görünmesini durdurmak için kapatın." + }, + "showNotificationsIcon": { + "label": "Bildirim simgesini göster", + "hint": "Kenar çubuğundaki bildirim simgesini gizlemek için kapatın." + }, + "archiveNotifications": { + "allSuccess": "Tüm bildirimler başarıyla arşivlendi", + "success": "Bildirim başarıyla arşivlendi" + }, + "markAsReadNotifications": { + "allSuccess": "Tümü okundu olarak işaretlendi", + "success": "Okundu olarak işaretlendi" + }, + "action": { + "markAsRead": "Okundu olarak işaretle", + "multipleChoice": "Daha fazla seç", + "archive": "Arşivle" + }, + "settings": { + "settings": "Ayarlar", + "markAllAsRead": "Tümünü okundu olarak işaretle", + "archiveAll": "Tümünü arşivle" + }, + "emptyInbox": { + "title": "Gelen Kutusu Boş!", + "description": "Burada bildirim almak için hatırlatıcılar ayarlayın." + }, + "emptyUnread": { + "title": "Okunmamış bildirim yok", + "description": "Hepsini okudunuz!" + }, + "emptyArchived": { + "title": "Arşivlenmiş öğe yok", + "description": "Arşivlenen bildirimler burada görünecek." + }, + "tabs": { + "inbox": "Gelen Kutusu", + "unread": "Okunmamış", + "archived": "Arşivlenmiş" + }, + "refreshSuccess": "Bildirimler başarıyla yenilendi", + "titles": { + "notifications": "Bildirimler", + "reminder": "Hatırlatıcı" } }, "appearance": { "resetSetting": "Sıfırla", "fontFamily": { "label": "Yazı Tipi Ailesi", - "search": "Ara" + "search": "Ara", + "defaultFont": "Sistem" }, "themeMode": { "label": "Tema Modu", - "light": "Açık Mod", - "dark": "Koyu Mod", - "system": "Sisteme Uyarla" + "light": "Aydınlık Mod", + "dark": "Karanlık Mod", + "system": "Sisteme Uyum Sağla" }, - "fontScaleFactor": "Yazı Tipi Ölçeklendirme Faktörü", + "fontScaleFactor": "Yazı Tipi Ölçek Faktörü", + "displaySize": "Görüntüleme Boyutu", "documentSettings": { "cursorColor": "Belge imleç rengi", - "selectionColor": "Belge seçimi rengi", - "hexEmptyError": "Hex rengi boş olamaz", - "hexLengthError": "Hex değeri 6 haneli olmalıdır", - "hexInvalidError": "Geçersiz hex değeri", + "selectionColor": "Belge seçim rengi", + "width": "Belge genişliği", + "changeWidth": "Değiştir", + "pickColor": "Bir renk seç", + "colorShade": "Renk tonu", + "opacity": "Opaklık", + "hexEmptyError": "Hex renk boş olamaz", + "hexLengthError": "Hex renk 6 haneli olmalıdır", + "hexInvalidError": "Geçersiz hex renk", "opacityEmptyError": "Opaklık boş olamaz", "opacityRangeError": "Opaklık 1 ile 100 arasında olmalıdır", "app": "Uygulama", @@ -393,97 +1209,110 @@ }, "layoutDirection": { "label": "Düzen Yönü", - "hint": "Ekranınızdaki içeriğin akışını soldan sağa veya sağdan sola doğru kontrol edin.", - "ltr": "LTR", - "rtl": "RTL" + "hint": "Ekranınızdaki içeriğin akışını soldan sağa veya sağdan sola olarak kontrol edin.", + "ltr": "Soldan Sağa", + "rtl": "Sağdan Sola" }, "textDirection": { "label": "Varsayılan Metin Yönü", - "hint": "Metnin varsayılan olarak soldan mı yoksa sağdan mı başlaması gerektiğini belirtin.", - "ltr": "LTR", - "rtl": "RTL", + "hint": "Metnin varsayılan olarak soldan mı yoksa sağdan mı başlayacağını belirtin.", + "ltr": "Soldan Sağa", + "rtl": "Sağdan Sola", "auto": "OTOMATİK", - "fallback": "Düzen yönüyle aynı" + "fallback": "Düzen yönü ile aynı" }, "themeUpload": { "button": "Yükle", - "uploadTheme": "Temayı yükle", - "description": "Aşağıdaki düğmeyi kullanarak kendi AppFlowy temanızı yükleyin.", - "loading": "Lütfen temanızı doğrularken ve yüklerken bekleyin...", + "uploadTheme": "Tema yükle", + "description": "Aşağıdaki düğmeyi kullanarak kendi @:appName temanızı yükleyin.", + "loading": "Temanızı doğrulayıp yüklerken lütfen bekleyin...", "uploadSuccess": "Temanız başarıyla yüklendi", - "deletionFailure": "Temayı silinemedi. Manuel olarak silmeyi deneyin.", - "filePickerDialogTitle": ".flowy_plugin dosyası seçin", + "deletionFailure": "Tema silinemedi. Manuel olarak silmeyi deneyin.", + "filePickerDialogTitle": "Bir .flowy_plugin dosyası seçin", "urlUploadFailure": "URL açılamadı: {}" }, "theme": "Tema", "builtInsLabel": "Yerleşik Temalar", "pluginsLabel": "Eklentiler", "dateFormat": { - "label": "Tarih formatı", + "label": "Tarih biçimi", "local": "Yerel", "us": "ABD", "iso": "ISO", - "friendly": "Dostça", + "friendly": "Kullanıcı dostu", "dmy": "G/A/Y" }, "timeFormat": { - "label": "Saat formatı", - "twelveHour": "12 saatlik", - "twentyFourHour": "24 saatlik" + "label": "Saat biçimi", + "twelveHour": "12 saat", + "twentyFourHour": "24 saat" }, "showNamingDialogWhenCreatingPage": "Sayfa oluştururken adlandırma iletişim kutusunu göster", - "enableRTLToolbarItems": "RTL araç çubuğu öğelerini etkinleştir", + "enableRTLToolbarItems": "Sağdan sola araç çubuğu öğelerini etkinleştir", "members": { - "title": "Üye Ayarları", - "inviteMembers": "Üye Davet Et", - "sendInvite": "Davetiye Gönder", - "copyInviteLink": "Davetiye Bağlantısını Kopyala", + "title": "Üye ayarları", + "inviteMembers": "Üye davet et", + "inviteHint": "E-posta ile davet et", + "sendInvite": "Davet gönder", + "copyInviteLink": "Davet bağlantısını kopyala", "label": "Üyeler", "user": "Kullanıcı", "role": "Rol", "removeFromWorkspace": "Çalışma Alanından Kaldır", + "removeFromWorkspaceSuccess": "Çalışma alanından başarıyla kaldırıldı", + "removeFromWorkspaceFailed": "Çalışma alanından kaldırma başarısız oldu", "owner": "Sahip", "guest": "Misafir", "member": "Üye", - "memberHintText": "Bir üye sayfaları okuyabilir, yorum yapabilir ve düzenleyebilir. Üye ve misafir davet edin.", + "memberHintText": "Bir üye sayfaları okuyabilir ve düzenleyebilir", "guestHintText": "Bir Misafir okuyabilir, tepki verebilir, yorum yapabilir ve izin verilen belirli sayfaları düzenleyebilir.", - "emailInvalidError": "Geçersiz e-posta, lütfen kontrol edin ve tekrar deneyin", + "emailInvalidError": "Geçersiz e-posta, lütfen kontrol edip tekrar deneyin", "emailSent": "E-posta gönderildi, lütfen gelen kutunuzu kontrol edin", - "members": "üyeler", + "members": "üye", "membersCount": { "zero": "{} üye", "one": "{} üye", "other": "{} üye" }, - "memberLimitExceeded": "Hesabınız için izin verilen maksimum üye sınırına ulaştınız. Çalışmanıza devam etmek için ek üye eklemek istiyorsanız, lütfen GitHub'da talepte bulunun", + "inviteFailedDialogTitle": "Davet gönderilemedi", + "inviteFailedMemberLimit": "Üye sınırına ulaşıldı, daha fazla üye davet etmek için lütfen yükseltin.", + "inviteFailedMemberLimitMobile": "Çalışma alanınız üye sınırına ulaştı.", + "memberLimitExceeded": "Üye sınırına ulaşıldı, daha fazla üye davet etmek için lütfen ", + "memberLimitExceededUpgrade": "yükseltin", + "memberLimitExceededPro": "Üye sınırına ulaşıldı, daha fazla üye gerekiyorsa ", + "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Üye eklenemedi", "addMemberSuccess": "Üye başarıyla eklendi", "removeMember": "Üyeyi Kaldır", - "areYouSureToRemoveMember": "Bu üyeyi kaldırmak istediğinizden emin misiniz?" + "areYouSureToRemoveMember": "Bu üyeyi kaldırmak istediğinizden emin misiniz?", + "inviteMemberSuccess": "Davet başarıyla gönderildi", + "failedToInviteMember": "Üye davet edilemedi", + "workspaceMembersError": "Hay aksi, bir şeyler yanlış gitti", + "workspaceMembersErrorDescription": "Şu anda üye listesini yükleyemedik. Lütfen daha sonra tekrar deneyin" } }, "files": { "copy": "Kopyala", - "defaultLocation": "Dosyaları ve verileri okuma konumu", + "defaultLocation": "Dosyaları ve veri depolama konumunu oku", "exportData": "Verilerinizi dışa aktarın", - "doubleTapToCopy": "Yolu kopyalamak için iki kez dokunun", - "restoreLocation": "AppFlowy varsayılan yoluna geri yükle", + "doubleTapToCopy": "Yolu kopyalamak için çift dokunun", + "restoreLocation": "@:appName varsayılan yoluna geri yükle", "customizeLocation": "Başka bir klasör aç", "restartApp": "Değişikliklerin etkili olması için lütfen uygulamayı yeniden başlatın.", "exportDatabase": "Veritabanını dışa aktar", "selectFiles": "Dışa aktarılması gereken dosyaları seçin", "selectAll": "Tümünü seç", - "deselectAll": "Tüm seçimleri kaldır", - "createNewFolder": "Yeni bir klasör oluştur", - "createNewFolderDesc": "Verilerinizi nerede saklamak istediğinizi bize bildirin", - "defineWhereYourDataIsStored": "Verilerinizin nerede saklandığını tanımlayın", + "deselectAll": "Tüm seçimi kaldır", + "createNewFolder": "Yeni klasör oluştur", + "createNewFolderDesc": "Verilerinizi nerede saklamak istediğinizi bize söyleyin", + "defineWhereYourDataIsStored": "Verilerinizin nerede saklanacağını tanımlayın", "open": "Aç", - "openFolder": "Mevcut bir klasörü aç", - "openFolderDesc": "Mevcut AppFlowy klasörünüze okuyun ve yazın", + "openFolder": "Mevcut bir klasör aç", + "openFolderDesc": "Mevcut @:appName klasörünüzü okuyun ve yazın", "folderHintText": "klasör adı", - "location": "Yeni bir klasör oluşturuluyor", - "locationDesc": "AppFlowy veri klasörünüz için bir ad seçin", - "browser": "Gözat", + "location": "Yeni klasör oluşturma", + "locationDesc": "@:appName veri klasörünüz için bir ad seçin", + "browser": "Göz at", "create": "Oluştur", "set": "Ayarla", "folderPath": "Klasörünüzü saklamak için yol", @@ -492,13 +1321,13 @@ "changeLocationTooltips": "Veri dizinini değiştir", "change": "Değiştir", "openLocationTooltips": "Başka bir veri dizini aç", - "openCurrentDataFolder": "Geçerli veri dizinini aç", - "recoverLocationTooltips": "AppFlowy'nin varsayılan veri dizinine sıfırla", + "openCurrentDataFolder": "Mevcut veri dizinini aç", + "recoverLocationTooltips": "@:appName'in varsayılan veri dizinine sıfırla", "exportFileSuccess": "Dosya başarıyla dışa aktarıldı!", "exportFileFail": "Dosya dışa aktarılamadı!", - "export": "Dışa Aktar", + "export": "Dışa aktar", "clearCache": "Önbelleği temizle", - "clearCacheDesc": "Görüntülerin yüklenmemesinde veya yazı tiplerinin düzgün görüntülenmemesi gibi sorunlarla karşılaşırsanız, önbelleğinizi temizlemeyi deneyin. Bu işlem kullanıcı verilerinizi kaldırmaz.", + "clearCacheDesc": "Resimlerin yüklenmemesi veya yazı tiplerinin doğru görüntülenmemesi gibi sorunlarla karşılaşırsanız, önbelleği temizlemeyi deneyin. Bu işlem kullanıcı verilerinizi kaldırmayacaktır.", "areYouSureToClearCache": "Önbelleği temizlemek istediğinizden emin misiniz?", "clearCacheSuccess": "Önbellek başarıyla temizlendi!" }, @@ -506,49 +1335,25 @@ "name": "Ad", "email": "E-posta", "tooltipSelectIcon": "Simge seç", - "selectAnIcon": "Bir simge seçin", - "pleaseInputYourOpenAIKey": "Lütfen OpenAI anahtarınızı girin", - "pleaseInputYourStabilityAIKey": "Lütfen Stability AI anahtarınızı girin", - "clickToLogout": "Geçerli kullanıcıdan çıkış yapmak için tıklayın" - }, - "shortcuts": { - "shortcutsLabel": "Kısayollar", - "command": "Komut", - "keyBinding": "Tuş Bağlantısı", - "addNewCommand": "Yeni Komut Ekle", - "updateShortcutStep": "İstediğiniz tuş kombinasyonuna basın ve ENTER tuşuna basın", - "shortcutIsAlreadyUsed": "Bu kısayol zaten şunun için kullanılıyor: {conflict}", - "resetToDefault": "Varsayılan tuş bağlantılarını sıfırla", - "couldNotLoadErrorMsg": "Kısayollar yüklenemedi, tekrar deneyin", - "couldNotSaveErrorMsg": "Kısayollar kaydedilemedi, tekrar deneyin", - "commands": { - "codeBlockNewParagraph": "Kod bloğunun yanına yeni bir paragraf ekle", - "codeBlockIndentLines": "Tablonuzdaki kod bloğunda satır başında iki boşluk ekleyin", - "codeBlockOutdentLines": "Kod bloğundaki satır başındaki iki boşluğu silin", - "codeBlockAddTwoSpaces": "Kod bloğunda satır başına iki boşluk ekle", - "codeBlockSelectAll": "Kod bloğunun içindeki tüm içeriği seç", - "codeBlockPasteText": "Kod bloğuna metin yapıştır", - "textAlignLeft": "Metni sola hizala", - "textAlignCenter": "Metni ortaya hizala", - "textAlignRight": "Metni sağa hizala", - "codeBlockDeleteTwoSpaces": "Kod bloğunda satır başındaki iki boşluğu sil" - } + "selectAnIcon": "Bir simge seç", + "pleaseInputYourOpenAIKey": "lütfen Yapay Zeka anahtarınızı girin", + "clickToLogout": "Mevcut kullanıcının oturumunu kapatmak için tıklayın" }, "mobile": { "personalInfo": "Kişisel Bilgiler", "username": "Kullanıcı Adı", "usernameEmptyError": "Kullanıcı adı boş olamaz", "about": "Hakkında", - "pushNotifications": "Anında Bildirimler", + "pushNotifications": "Anlık Bildirimler", "support": "Destek", "joinDiscord": "Discord'da bize katılın", "privacyPolicy": "Gizlilik Politikası", "userAgreement": "Kullanıcı Sözleşmesi", "termsAndConditions": "Şartlar ve Koşullar", "userprofileError": "Kullanıcı profili yüklenemedi", - "userprofileErrorDescription": "Lütfen sorunun devam edip etmediğini kontrol etmek için çıkış yapıp tekrar giriş yapmayı deneyin.", - "selectLayout": "Düzen seçin", - "selectStartingDay": "Başlangıç gününü seçin", + "userprofileErrorDescription": "Lütfen sorunun devam edip etmediğini kontrol etmek için oturumu kapatıp yeniden giriş yapmayı deneyin.", + "selectLayout": "Düzen seç", + "selectStartingDay": "Başlangıç gününü seç", "version": "Sürüm" } }, @@ -556,18 +1361,18 @@ "deleteView": "Bu görünümü silmek istediğinizden emin misiniz?", "createView": "Yeni", "title": { - "placeholder": "İsimsiz" + "placeholder": "Başlıksız" }, "settings": { "filter": "Filtre", "sort": "Sırala", - "sortBy": "Şuna göre sırala", + "sortBy": "Sıralama ölçütü", "properties": "Özellikler", "reorderPropertiesTooltip": "Özellikleri yeniden sıralamak için sürükleyin", "group": "Grupla", "addFilter": "Filtre Ekle", "deleteFilter": "Filtreyi sil", - "filterBy": "Şuna göre filtrele...", + "filterBy": "Filtreleme ölçütü", "typeAValue": "Bir değer yazın...", "layout": "Düzen", "databaseLayout": "Düzen", @@ -580,95 +1385,113 @@ "boardSettings": "Pano ayarları", "calendarSettings": "Takvim ayarları", "createView": "Yeni görünüm", - "duplicateView": "Görünümü kopyala", + "duplicateView": "Görünümü çoğalt", "deleteView": "Görünümü sil", "numberOfVisibleFields": "{} gösteriliyor" }, + "filter": { + "empty": "Aktif filtre yok", + "addFilter": "Filtre ekle", + "cannotFindCreatableField": "Filtrelenecek uygun bir alan bulunamadı", + "conditon": "Koşul", + "where": "Koşul" + }, "textFilter": { "contains": "İçerir", "doesNotContain": "İçermez", - "endsWith": "Şununla biter", - "startWith": "Şununla başlar", - "is": "Şudur", - "isNot": "Şu değildir", - "isEmpty": "Boş", - "isNotEmpty": "Boş değil", + "endsWith": "İle biter", + "startWith": "İle başlar", + "is": "Eşittir", + "isNot": "Eşit değildir", + "isEmpty": "Boştur", + "isNotEmpty": "Boş değildir", "choicechipPrefix": { "isNot": "Değil", - "startWith": "Şununla başlar", - "endWith": "Şununla biter", - "isEmpty": "boş", - "isNotEmpty": "boş değil" + "startWith": "İle başlar", + "endWith": "İle biter", + "isEmpty": "boştur", + "isNotEmpty": "boş değildir" } }, "checkboxFilter": { "isChecked": "İşaretli", "isUnchecked": "İşaretsiz", "choicechipPrefix": { - "is": "durum" + "is": "eşittir" } }, "checklistFilter": { - "isComplete": "tamamlandı", - "isIncomplted": "tamamlanmadı" + "isComplete": "Tamamlandı", + "isIncomplted": "Tamamlanmadı" }, "selectOptionFilter": { - "is": "Şudur", - "isNot": "Şu değildir", + "is": "Eşittir", + "isNot": "Eşit değildir", "contains": "İçerir", "doesNotContain": "İçermez", - "isEmpty": "Boş", - "isNotEmpty": "Boş değil" + "isEmpty": "Boştur", + "isNotEmpty": "Boş değildir" }, "dateFilter": { - "is": "Şudur", - "before": "Şundan önce", - "after": "Şundan sonra", - "onOrBefore": "Şunda veya önce", - "onOrAfter": "Şunda veya sonra", - "between": "Şunlar arasında", - "empty": "Boş", - "notEmpty": "Boş değil", + "is": "Tarihinde", + "before": "Öncesinde", + "after": "Sonrasında", + "onOrBefore": "Tarihinde veya öncesinde", + "onOrAfter": "Tarihinde veya sonrasında", + "between": "Arasında", + "empty": "Boştur", + "notEmpty": "Boş değildir", + "startDate": "Başlangıç tarihi", + "endDate": "Bitiş tarihi", "choicechipPrefix": { "before": "Önce", "after": "Sonra", - "onOrBefore": "Şunda veya önce", - "onOrAfter": "Şunda veya sonra", - "isEmpty": "Boş", - "isNotEmpty": "Boş değil" + "between": "Arasında", + "onOrBefore": "Tarihinde veya önce", + "onOrAfter": "Tarihinde veya sonra", + "isEmpty": "Boştur", + "isNotEmpty": "Boş değildir" } }, "numberFilter": { "equal": "Eşittir", "notEqual": "Eşit değildir", - "lessThan": "Şundan küçüktür", - "greaterThan": "Şundan büyüktür", - "lessThanOrEqualTo": "Şundan küçük veya eşittir", - "greaterThanOrEqualTo": "Şundan büyük veya eşittir", - "isEmpty": "Boş", - "isNotEmpty": "Boş değil" + "lessThan": "Küçüktür", + "greaterThan": "Büyüktür", + "lessThanOrEqualTo": "Küçük veya eşittir", + "greaterThanOrEqualTo": "Büyük veya eşittir", + "isEmpty": "Boştur", + "isNotEmpty": "Boş değildir" }, "field": { - "hide": "Gizle", - "show": "Göster", - "insertLeft": "Sola Ekle", - "insertRight": "Sağa Ekle", - "duplicate": "Kopyala", + "label": "Özellik", + "hide": "Özelliği gizle", + "show": "Özelliği göster", + "insertLeft": "Sola ekle", + "insertRight": "Sağa ekle", + "duplicate": "Çoğalt", "delete": "Sil", + "wrapCellContent": "Metni kaydır", "clear": "Hücreleri temizle", + "switchPrimaryFieldTooltip": "Birincil alanın alan türü değiştirilemez", "textFieldName": "Metin", - "checkboxFieldName": "Onay Kutusu", + "checkboxFieldName": "Onay kutusu", "dateFieldName": "Tarih", "updatedAtFieldName": "Son değiştirilme", "createdAtFieldName": "Oluşturulma tarihi", "numberFieldName": "Sayılar", - "singleSelectFieldName": "Seçenek", - "multiSelectFieldName": "Çoklu Seçenek", + "singleSelectFieldName": "Seçim", + "multiSelectFieldName": "Çoklu seçim", "urlFieldName": "URL", - "checklistFieldName": "Kontrol Listesi", + "checklistFieldName": "Kontrol listesi", "relationFieldName": "İlişki", - "numberFormat": "Sayı formatı", - "dateFormat": "Tarih formatı", + "summaryFieldName": "Yapay Zeka Özeti", + "timeFieldName": "Saat", + "mediaFieldName": "Dosyalar ve medya", + "translateFieldName": "Yapay Zeka Çeviri", + "translateTo": "Şu dile çevir", + "numberFormat": "Sayı biçimi", + "dateFormat": "Tarih biçimi", "includeTime": "Saati dahil et", "isRange": "Bitiş tarihi", "dateFormatFriendly": "Ay Gün, Yıl", @@ -676,8 +1499,8 @@ "dateFormatLocal": "Ay/Gün/Yıl", "dateFormatUS": "Yıl/Ay/Gün", "dateFormatDayMonthYear": "Gün/Ay/Yıl", - "timeFormat": "Saat formatı", - "invalidTimeFormat": "Geçersiz format", + "timeFormat": "Saat biçimi", + "invalidTimeFormat": "Geçersiz biçim", "timeFormatTwelveHour": "12 saat", "timeFormatTwentyFourHour": "24 saat", "clearDate": "Tarihi temizle", @@ -695,16 +1518,16 @@ "addOption": "Seçenek ekle", "editProperty": "Özelliği düzenle", "newProperty": "Yeni özellik", - "deleteFieldPromptMessage": "Emin misiniz? Bu özellik silinecek", + "openRowDocument": "Sayfa olarak aç", + "deleteFieldPromptMessage": "Emin misiniz? Bu özellik ve tüm verileri silinecek", "clearFieldPromptMessage": "Emin misiniz? Bu sütundaki tüm hücreler boşaltılacak", - "newColumn": "Yeni Sütun", - "format": "Format", - "reminderOnDateTooltip": "Bu hücrenin planlanmış bir hatırlatıcısı var", - "optionAlreadyExist": "Seçenek zaten mevcut", - "wrap": "Sar" + "newColumn": "Yeni sütun", + "format": "Biçim", + "reminderOnDateTooltip": "Bu hücrede planlanmış bir hatırlatıcı var", + "optionAlreadyExist": "Seçenek zaten mevcut" }, "rowPage": { - "newField": "Yeni bir alan ekle", + "newField": "Yeni alan ekle", "fieldDragElementTooltip": "Menüyü açmak için tıklayın", "showHiddenFields": { "one": "{count} gizli alanı göster", @@ -715,33 +1538,44 @@ "one": "{count} gizli alanı gizle", "many": "{count} gizli alanı gizle", "other": "{count} gizli alanı gizle" - } + }, + "openAsFullPage": "Tam sayfa olarak aç", + "moreRowActions": "Daha fazla satır işlemi" }, "sort": { "ascending": "Artan", "descending": "Azalan", - "by": "Şuna göre", + "by": "Göre", "empty": "Aktif sıralama yok", - "cannotFindCreatableField": "Sıralama yapmak için uygun bir alan bulunamadı", + "cannotFindCreatableField": "Sıralanacak uygun bir alan bulunamadı", "deleteAllSorts": "Tüm sıralamaları sil", - "addSort": "Yeni sıralama ekle", - "removeSorting": "Sıralamayı kaldırmak ister misiniz?", - "fieldInUse": "Zaten bu alana göre sıralama yapıyorsunuz" + "addSort": "Sıralama ekle", + "sortsActive": "Sıralama yaparken {intention} yapılamaz", + "removeSorting": "Bu görünümdeki tüm sıralamaları kaldırıp devam etmek istiyor musunuz?", + "fieldInUse": "Bu alana göre zaten sıralama yapıyorsunuz" }, "row": { - "duplicate": "Kopyala", + "label": "Satır", + "duplicate": "Çoğalt", "delete": "Sil", - "titlePlaceholder": "İsimsiz", + "titlePlaceholder": "Başlıksız", "textPlaceholder": "Boş", "copyProperty": "Özellik panoya kopyalandı", "count": "Sayı", "newRow": "Yeni satır", - "action": "Eylem", - "add": "Alta eklemek için tıklayın", + "loadMore": "Daha fazla yükle", + "action": "İşlem", + "add": "Aşağıya eklemek için tıklayın", "drag": "Taşımak için sürükleyin", + "deleteRowPrompt": "Bu satırı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "deleteCardPrompt": "Bu kartı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "dragAndClick": "Taşımak için sürükleyin, menüyü açmak için tıklayın", "insertRecordAbove": "Üste kayıt ekle", - "insertRecordBelow": "Alta kayıt ekle" + "insertRecordBelow": "Alta kayıt ekle", + "noContent": "İçerik yok", + "reorderRowDescription": "satırı yeniden sırala", + "createRowAboveDescription": "üste bir satır oluştur", + "createRowBelowDescription": "alta bir satır ekle" }, "selectOption": { "create": "Oluştur", @@ -750,15 +1584,15 @@ "lightPinkColor": "Açık Pembe", "orangeColor": "Turuncu", "yellowColor": "Sarı", - "limeColor": "Limoni", + "limeColor": "Limon", "greenColor": "Yeşil", - "aquaColor": "Su yeşili", + "aquaColor": "Su Mavisi", "blueColor": "Mavi", "deleteTag": "Etiketi sil", "colorPanelTitle": "Renk", - "panelTitle": "Bir seçenek seçin veya yeni bir tane oluşturun", + "panelTitle": "Bir seçenek seçin veya oluşturun", "searchOption": "Bir seçenek arayın", - "searchOrCreateOption": "Bir seçenek arayın veya yeni bir tane oluşturun", + "searchOrCreateOption": "Bir seçenek arayın veya oluşturun", "createNew": "Yeni oluştur", "orSelectOne": "Veya bir seçenek seçin", "typeANewOption": "Yeni bir seçenek yazın", @@ -766,7 +1600,7 @@ }, "checklist": { "taskHint": "Görev açıklaması", - "addNew": "Yeni bir görev ekle", + "addNew": "Yeni görev ekle", "submitNewTask": "Oluştur", "hideComplete": "Tamamlanan görevleri gizle", "showComplete": "Tüm görevleri göster" @@ -774,18 +1608,17 @@ "url": { "launch": "Bağlantıyı tarayıcıda aç", "copy": "Bağlantıyı panoya kopyala", - "textFieldHint": "Bir URL girin", - "copiedNotification": "Panoya kopyalandı!" + "textFieldHint": "Bir URL girin" }, "relation": { - "relatedDatabasePlaceLabel": "İlgili Veritabanı", + "relatedDatabasePlaceLabel": "İlişkili Veritabanı", "relatedDatabasePlaceholder": "Yok", "inRelatedDatabase": "İçinde", "rowSearchTextFieldPlaceholder": "Ara", - "noDatabaseSelected": "Veritabanı seçilmedi, lütfen önce aşağıdaki listeden bir tane seçin:", + "noDatabaseSelected": "Veritabanı seçilmedi, lütfen aşağıdaki listeden önce bir tane seçin:", "emptySearchResult": "Kayıt bulunamadı", - "linkedRowListLabel": "{count} bağlı satır", - "unlinkedRowListLabel": "Başka bir satırı bağla" + "linkedRowListLabel": "{count} bağlantılı satır", + "unlinkedRowListLabel": "Başka bir satır bağla" }, "menuName": "Tablo", "referencedGridPrefix": "Görünümü", @@ -793,15 +1626,33 @@ "calculationTypeLabel": { "none": "Yok", "average": "Ortalama", - "max": "Maksimum", + "max": "En büyük", "median": "Medyan", - "min": "Minimum", + "min": "En küçük", "sum": "Toplam", "count": "Sayı", "countEmpty": "Boş sayısı", "countEmptyShort": "BOŞ", - "countNonEmpty": "Dolu sayısı", + "countNonEmpty": "Boş olmayan sayısı", "countNonEmptyShort": "DOLU" + }, + "media": { + "rename": "Yeniden adlandır", + "download": "İndir", + "expand": "Genişlet", + "delete": "Sil", + "moreFilesHint": "+{}", + "addFileOrImage": "Dosya veya bağlantı ekle", + "attachmentsHint": "{}", + "addFileMobile": "Dosya ekle", + "extraCount": "+{}", + "deleteFileDescription": "Bu dosyayı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "showFileNames": "Dosya adını göster", + "downloadSuccess": "Dosya indirildi", + "downloadFailedToken": "Dosya indirilemedi, kullanıcı jetonu mevcut değil", + "setAsCover": "Kapak olarak ayarla", + "openInBrowser": "Tarayıcıda aç", + "embedLink": "Dosya bağlantısını yerleştir" } }, "document": { @@ -810,21 +1661,67 @@ "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, + "creating": "Oluşturuluyor...", "slashMenu": { "board": { - "selectABoardToLinkTo": "Bağlantı kurulacak bir Pano seçin", - "createANewBoard": "Yeni bir Pano oluşturun" + "selectABoardToLinkTo": "Bağlanacak bir Pano seçin", + "createANewBoard": "Yeni bir Pano oluştur" }, "grid": { - "selectAGridToLinkTo": "Bağlantı kurulacak bir Tablo seçin", - "createANewGrid": "Yeni bir Tablo oluşturun" + "selectAGridToLinkTo": "Bağlanacak bir Tablo seçin", + "createANewGrid": "Yeni bir Tablo oluştur" }, "calendar": { - "selectACalendarToLinkTo": "Bağlantı kurulacak bir Takvim seçin", - "createANewCalendar": "Yeni bir Takvim oluşturun" + "selectACalendarToLinkTo": "Bağlanacak bir Takvim seçin", + "createANewCalendar": "Yeni bir Takvim oluştur" }, "document": { - "selectADocumentToLinkTo": "Bağlantı kurulacak bir Belge seçin" + "selectADocumentToLinkTo": "Bağlanacak bir Belge seçin" + }, + "name": { + "text": "Metin", + "heading1": "Başlık 1", + "heading2": "Başlık 2", + "heading3": "Başlık 3", + "image": "Görsel", + "bulletedList": "Madde işaretli liste", + "numberedList": "Numaralı liste", + "todoList": "Yapılacaklar listesi", + "doc": "Belge", + "linkedDoc": "Sayfaya bağlantı", + "grid": "Tablo", + "linkedGrid": "Bağlantılı Tablo", + "kanban": "Kanban", + "linkedKanban": "Bağlantılı Kanban", + "calendar": "Takvim", + "linkedCalendar": "Bağlantılı Takvim", + "quote": "Alıntı", + "divider": "Ayırıcı", + "table": "Tablo", + "callout": "Not Kutusu", + "outline": "Ana Hat", + "mathEquation": "Matematik Denklemi", + "code": "Kod", + "toggleList": "Açılır liste", + "toggleHeading1": "Açılır başlık 1", + "toggleHeading2": "Açılır başlık 2", + "toggleHeading3": "Açılır başlık 3", + "emoji": "Emoji", + "aiWriter": "Yapay Zeka Yazar", + "dateOrReminder": "Tarih veya Hatırlatıcı", + "photoGallery": "Fotoğraf Galerisi", + "file": "Dosya" + }, + "subPage": { + "name": "Belge", + "keyword1": "alt sayfa", + "keyword2": "sayfa", + "keyword3": "alt sayfa", + "keyword4": "sayfa ekle", + "keyword5": "sayfa yerleştir", + "keyword6": "yeni sayfa", + "keyword7": "sayfa oluştur", + "keyword8": "belge" } }, "selectionMenu": { @@ -832,61 +1729,95 @@ "codeBlock": "Kod Bloğu" }, "plugins": { - "referencedBoard": "Referans Gösterilen Pano", - "referencedGrid": "Referans Gösterilen Tablo", - "referencedCalendar": "Referans Gösterilen Takvim", - "referencedDocument": "Referans Gösterilen Belge", - "autoGeneratorMenuItemName": "OpenAI Yazar", - "autoGeneratorTitleName": "OpenAI: AI'dan istediğinizi yazmasını isteyin...", - "autoGeneratorLearnMore": "Daha fazla bilgi edinin", + "referencedBoard": "Referans Pano", + "referencedGrid": "Referans Tablo", + "referencedCalendar": "Referans Takvim", + "referencedDocument": "Referans Belge", + "autoGeneratorMenuItemName": "Yapay Zeka Yazar", + "autoGeneratorTitleName": "Yapay Zeka: Yapay zekadan herhangi bir şey yazmasını isteyin...", + "autoGeneratorLearnMore": "Daha fazla bilgi", "autoGeneratorGenerate": "Oluştur", - "autoGeneratorHintText": "OpenAI'ya sorun ...", - "autoGeneratorCantGetOpenAIKey": "OpenAI anahtarı alınamıyor", + "autoGeneratorHintText": "Yapay zekaya sorun ...", + "autoGeneratorCantGetOpenAIKey": "Yapay zeka anahtarı alınamıyor", "autoGeneratorRewrite": "Yeniden yaz", - "smartEdit": "AI Asistanları", - "openAI": "OpenAI", - "smartEditFixSpelling": "Yazımı düzelt", - "warning": "⚠️ AI yanıtları yanlış veya yanıltıcı olabilir.", + "smartEdit": "Yapay Zekaya Sor", + "aI": "Yapay Zeka", + "smartEditFixSpelling": "Yazım ve dilbilgisini düzelt", + "warning": "⚠️ Yapay zeka yanıtları yanlış veya yanıltıcı olabilir.", "smartEditSummarize": "Özetle", "smartEditImproveWriting": "Yazımı geliştir", "smartEditMakeLonger": "Daha uzun yap", - "smartEditCouldNotFetchResult": "OpenAI'dan sonuç alınamadı", - "smartEditCouldNotFetchKey": "OpenAI anahtarı alınamadı", - "smartEditDisabled": "Ayarlar'da OpenAI'yı bağlayın", - "discardResponse": "AI yanıtlarını silmek ister misiniz?", + "smartEditCouldNotFetchResult": "Yapay zekadan sonuç alınamadı", + "smartEditCouldNotFetchKey": "Yapay zeka anahtarı alınamadı", + "smartEditDisabled": "Ayarlar'dan Yapay Zeka'yı bağlayın", + "appflowyAIEditDisabled": "Yapay Zeka özelliklerini etkinleştirmek için giriş yapın", + "discardResponse": "Yapay Zeka yanıtlarını silmek istiyor musunuz?", "createInlineMathEquation": "Denklem oluştur", - "fonts": "Yazı Tipleri", + "fonts": "Yazı tipleri", "insertDate": "Tarih ekle", "emoji": "Emoji", - "toggleList": "Listeyi değiştir", + "toggleList": "Açılır liste", + "emptyToggleHeading": "Boş açılır başlık {}. İçerik eklemek için tıklayın.", + "emptyToggleList": "Boş açılır liste. İçerik eklemek için tıklayın.", + "emptyToggleHeadingWeb": "Boş açılır başlık {level}. İçerik eklemek için tıklayın", "quoteList": "Alıntı listesi", "numberedList": "Numaralı liste", "bulletedList": "Madde işaretli liste", - "todoList": "Yapılacaklar Listesi", - "callout": "Bilgi Kutusu", + "todoList": "Yapılacaklar listesi", + "callout": "Not Kutusu", + "simpleTable": { + "moreActions": { + "color": "Renk", + "align": "Hizala", + "delete": "Sil", + "duplicate": "Çoğalt", + "insertLeft": "Sola ekle", + "insertRight": "Sağa ekle", + "insertAbove": "Üste ekle", + "insertBelow": "Alta ekle", + "headerColumn": "Başlık sütunu", + "headerRow": "Başlık satırı", + "clearContents": "İçeriği temizle", + "setToPageWidth": "Sayfa genişliğine ayarla", + "distributeColumnsWidth": "Sütunları eşit dağıt", + "duplicateRow": "Satırı çoğalt", + "duplicateColumn": "Sütunu çoğalt", + "textColor": "Metin rengi", + "cellBackgroundColor": "Hücre arka plan rengi", + "duplicateTable": "Tabloyu çoğalt" + }, + "clickToAddNewRow": "Yeni satır eklemek için tıklayın", + "clickToAddNewColumn": "Yeni sütun eklemek için tıklayın", + "clickToAddNewRowAndColumn": "Yeni satır ve sütun eklemek için tıklayın", + "headerName": { + "table": "Tablo", + "alignText": "Metni hizala" + } + }, "cover": { - "changeCover": "Kapak Resmi Değiştir", + "changeCover": "Kapağı Değiştir", "colors": "Renkler", - "images": "Resimler", + "images": "Görseller", "clearAll": "Tümünü Temizle", "abstract": "Soyut", - "addCover": "Kapak Resmi Ekle", - "addLocalImage": "Yerel resim ekle", - "invalidImageUrl": "Geçersiz resim URL'si", - "failedToAddImageToGallery": "Resim galeriye eklenemedi", - "enterImageUrl": "Resim URL'sini girin", + "addCover": "Kapak Ekle", + "addLocalImage": "Yerel görsel ekle", + "invalidImageUrl": "Geçersiz görsel URL'si", + "failedToAddImageToGallery": "Görsel galeriye eklenemedi", + "enterImageUrl": "Görsel URL'si girin", "add": "Ekle", "back": "Geri", "saveToGallery": "Galeriye kaydet", "removeIcon": "Simgeyi kaldır", - "pasteImageUrl": "Resim URL'sini yapıştır", + "removeCover": "Kapağı kaldır", + "pasteImageUrl": "Görsel URL'sini yapıştırın", "or": "VEYA", "pickFromFiles": "Dosyalardan seç", - "couldNotFetchImage": "Resim alınamadı", - "imageSavingFailed": "Resim Kaydedilemedi", + "couldNotFetchImage": "Görsel alınamadı", + "imageSavingFailed": "Görsel Kaydedilemedi", "addIcon": "Simge ekle", "changeIcon": "Simgeyi değiştir", - "coverRemoveAlert": "Silindikten sonra kapak resminden kaldırılacaktır.", + "coverRemoveAlert": "Silindikten sonra kapaktan kaldırılacaktır.", "alertDialogConfirmation": "Devam etmek istediğinizden emin misiniz?" }, "mathEquation": { @@ -897,9 +1828,11 @@ "optionAction": { "click": "Tıkla", "toOpenMenu": " menüyü açmak için", + "drag": "Sürükle", + "toMove": " taşımak için", "delete": "Sil", - "duplicate": "Kopyala", - "turnInto": "Şuna dönüştür", + "duplicate": "Çoğalt", + "turnInto": "Dönüştür", "moveUp": "Yukarı taşı", "moveDown": "Aşağı taşı", "color": "Renk", @@ -908,44 +1841,113 @@ "center": "Orta", "right": "Sağ", "defaultColor": "Varsayılan", - "depth": "Derinlik" + "depth": "Derinlik", + "copyLinkToBlock": "Bloğa bağlantıyı kopyala" }, "image": { - "copiedToPasteBoard": "Resim bağlantısı panoya kopyalandı", - "addAnImage": "Bir resim ekle", - "imageUploadFailed": "Resim yükleme başarısız" + "addAnImage": "Görsel ekle", + "copiedToPasteBoard": "Görsel bağlantısı panoya kopyalandı", + "addAnImageDesktop": "Bir görsel ekle", + "addAnImageMobile": "Bir veya daha fazla görsel eklemek için tıklayın", + "dropImageToInsert": "Eklemek için görselleri bırakın", + "imageUploadFailed": "Görsel yüklenemedi", + "imageDownloadFailed": "Görsel indirilemedi, lütfen tekrar deneyin", + "imageDownloadFailedToken": "Kullanıcı jetonu eksik olduğu için görsel indirilemedi, lütfen tekrar deneyin", + "errorCode": "Hata kodu" + }, + "photoGallery": { + "name": "Fotoğraf galerisi", + "imageKeyword": "görsel", + "imageGalleryKeyword": "görsel galerisi", + "photoKeyword": "fotoğraf", + "photoBrowserKeyword": "fotoğraf tarayıcısı", + "galleryKeyword": "galeri", + "addImageTooltip": "Görsel ekle", + "changeLayoutTooltip": "Düzeni değiştir", + "browserLayout": "Tarayıcı", + "gridLayout": "Izgara", + "deleteBlockTooltip": "Tüm galeriyi sil" + }, + "math": { + "copiedToPasteBoard": "Matematik denklemi panoya kopyalandı" }, "urlPreview": { "copiedToPasteBoard": "Bağlantı panoya kopyalandı", - "convertToLink": "Gömülü bağlantıya dönüştür" + "convertToLink": "Yerleşik bağlantıya dönüştür" }, "outline": { "addHeadingToCreateOutline": "İçindekiler tablosu oluşturmak için başlıklar ekleyin.", "noMatchHeadings": "Eşleşen başlık bulunamadı." }, "table": { - "addAfter": "Sonra ekle", - "addBefore": "Önce ekle", + "addAfter": "Sonrasına ekle", + "addBefore": "Öncesine ekle", "delete": "Sil", "clear": "İçeriği temizle", - "duplicate": "Kopyala", + "duplicate": "Çoğalt", "bgColor": "Arka plan rengi" }, "contextMenu": { "copy": "Kopyala", "cut": "Kes", - "paste": "Yapıştır" + "paste": "Yapıştır", + "pasteAsPlainText": "Düz metin olarak yapıştır" }, - "action": "Eylemler", + "action": "İşlemler", "database": { - "selectDataSource": "Veri kaynağını seçin", + "selectDataSource": "Veri kaynağı seç", "noDataSource": "Veri kaynağı yok", - "selectADataSource": "Bir veri kaynağı seçin", + "selectADataSource": "Bir veri kaynağı seç", "toContinue": "devam etmek için", "newDatabase": "Yeni Veritabanı", - "linkToDatabase": "Veritabanına Bağla" + "linkToDatabase": "Veritabanına Bağlantı" }, - "date": "Tarih" + "date": "Tarih", + "video": { + "label": "Video", + "emptyLabel": "Video ekle", + "placeholder": "Video bağlantısını yapıştırın", + "copiedToPasteBoard": "Video bağlantısı panoya kopyalandı", + "insertVideo": "Video ekle", + "invalidVideoUrl": "Kaynak URL'si henüz desteklenmiyor.", + "invalidVideoUrlYouTube": "YouTube henüz desteklenmiyor.", + "supportedFormats": "Desteklenen formatlar: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" + }, + "file": { + "name": "Dosya", + "uploadTab": "Yükle", + "uploadMobile": "Bir dosya seç", + "uploadMobileGallery": "Fotoğraf Galerisinden", + "networkTab": "Bağlantı yerleştir", + "placeholderText": "Bir dosya yükleyin veya yerleştirin", + "placeholderDragging": "Yüklemek için dosyayı bırakın", + "dropFileToUpload": "Yüklemek için bir dosya bırakın", + "fileUploadHint": "Bir dosya sürükleyip bırakın veya tıklayarak ", + "fileUploadHintSuffix": "Göz atın", + "networkHint": "Bir dosya bağlantısı yapıştırın", + "networkUrlInvalid": "Geçersiz URL. URL'yi kontrol edip tekrar deneyin.", + "networkAction": "Yerleştir", + "fileTooBigError": "Dosya boyutu çok büyük, lütfen 10MB'dan küçük bir dosya yükleyin", + "renameFile": { + "title": "Dosyayı yeniden adlandır", + "description": "Bu dosya için yeni bir ad girin", + "nameEmptyError": "Dosya adı boş bırakılamaz." + }, + "uploadedAt": "{} tarihinde yüklendi", + "linkedAt": "Bağlantısı {} tarihinde eklendi", + "failedToOpenMsg": "Açılamadı, dosya bulunamadı" + }, + "subPage": { + "handlingPasteHint": " - (yapıştırma işlemi)", + "errors": { + "failedDeletePage": "Sayfa silinemedi", + "failedCreatePage": "Sayfa oluşturulamadı", + "failedMovePage": "Sayfa bu belgeye taşınamadı", + "failedDuplicatePage": "Sayfa çoğaltılamadı", + "failedDuplicateFindView": "Sayfa çoğaltılamadı - orijinal görünüm bulunamadı" + } + }, + "cannotMoveToItsChildren": "Alt öğelerine taşınamaz" }, "outlineBlock": { "placeholder": "İçindekiler" @@ -954,51 +1956,66 @@ "placeholder": "Komutlar için '/' yazın" }, "title": { - "placeholder": "İsimsiz" + "placeholder": "Başlıksız" }, "imageBlock": { - "placeholder": "Resim eklemek için tıklayın", + "placeholder": "Görsel eklemek için tıklayın", "upload": { "label": "Yükle", - "placeholder": "Resim yüklemek için tıklayın" + "placeholder": "Görsel yüklemek için tıklayın" }, "url": { - "label": "Resim URL'si", - "placeholder": "Resim URL'sini girin" + "label": "Görsel URL'si", + "placeholder": "Görsel URL'si girin" }, "ai": { - "label": "OpenAI ile resim oluştur", - "placeholder": "Lütfen OpenAI'nin resim oluşturması için komutu girin" + "label": "Yapay zeka ile görsel oluştur", + "placeholder": "Yapay zekanın görsel oluşturması için bir istek girin" }, "stability_ai": { - "label": "Stability AI ile resim oluştur", - "placeholder": "Lütfen Stability AI'nin resim oluşturması için komutu girin" + "label": "Stability AI ile görsel oluştur", + "placeholder": "Stability AI'nın görsel oluşturması için bir istek girin" }, - "support": "Resim boyutu sınırı 5MB'dir. Desteklenen formatlar: JPEG, PNG, GIF, SVG", + "support": "Görsel boyut sınırı 5MB'dır. Desteklenen formatlar: JPEG, PNG, GIF, SVG", "error": { - "invalidImage": "Geçersiz resim", - "invalidImageSize": "Resim boyutu 5MB'den küçük olmalıdır", - "invalidImageFormat": "Resim formatı desteklenmiyor. Desteklenen formatlar: JPEG, PNG, JPG, GIF, SVG, WEBP", - "invalidImageUrl": "Geçersiz resim URL'si", - "noImage": "Böyle bir dosya veya dizin yok" + "invalidImage": "Geçersiz görsel", + "invalidImageSize": "Görsel boyutu 5MB'dan küçük olmalıdır", + "invalidImageFormat": "Görsel formatı desteklenmiyor. Desteklenen formatlar: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "Geçersiz görsel URL'si", + "noImage": "Böyle bir dosya veya dizin yok", + "multipleImagesFailed": "Bir veya daha fazla görsel yüklenemedi, lütfen tekrar deneyin" }, "embedLink": { - "label": "Bağlantıyı göm", - "placeholder": "Bir resim bağlantısını yapıştırın veya yazın" + "label": "Bağlantı yerleştir", + "placeholder": "Bir görsel bağlantısı yapıştırın veya yazın" }, "unsplash": { "label": "Unsplash" }, - "searchForAnImage": "Bir resim arayın", - "pleaseInputYourOpenAIKey": "Lütfen Ayarlar sayfasında OpenAI anahtarınızı girin", - "pleaseInputYourStabilityAIKey": "Lütfen Ayarlar sayfasında Stability AI anahtarınızı girin", - "saveImageToGallery": "Resmi kaydet", - "failedToAddImageToGallery": "Resim galeriye eklenemedi", - "successToAddImageToGallery": "Resim başarıyla galeriye eklendi", - "unableToLoadImage": "Resim yüklenemedi", - "maximumImageSize": "Desteklenen maksimum yükleme resim boyutu 10MB'dir", - "uploadImageErrorImageSizeTooBig": "Resim boyutu 10MB'den küçük olmalıdır", - "imageIsUploading": "Resim yükleniyor" + "searchForAnImage": "Bir görsel ara", + "pleaseInputYourOpenAIKey": "lütfen Ayarlar sayfasından yapay zeka anahtarınızı girin", + "saveImageToGallery": "Görseli kaydet", + "failedToAddImageToGallery": "Görsel galeriye eklenemedi", + "successToAddImageToGallery": "Görsel başarıyla galeriye eklendi", + "unableToLoadImage": "Görsel yüklenemedi", + "maximumImageSize": "Desteklenen maksimum görsel yükleme boyutu 10MB'dır", + "uploadImageErrorImageSizeTooBig": "Görsel boyutu 10MB'dan küçük olmalıdır", + "imageIsUploading": "Görsel yükleniyor", + "openFullScreen": "Tam ekran aç", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "Önceki görsel", + "nextImageTooltip": "Sonraki görsel", + "zoomOutTooltip": "Uzaklaştır", + "zoomInTooltip": "Yakınlaştır", + "changeZoomLevelTooltip": "Yakınlaştırma seviyesini değiştir", + "openLocalImage": "Görseli aç", + "downloadImage": "Görseli indir", + "closeViewer": "Etkileşimli görüntüleyiciyi kapat", + "scalePercentage": "%{}", + "deleteImageTooltip": "Görseli sil" + } + } }, "codeBlock": { "language": { @@ -1006,53 +2023,71 @@ "placeholder": "Dil seçin", "auto": "Otomatik" }, - "copyTooltip": "Kod bloğunun içeriğini kopyala", + "copyTooltip": "Kopyala", "searchLanguageHint": "Bir dil arayın", "codeCopiedSnackbar": "Kod panoya kopyalandı!" }, "inlineLink": { - "placeholder": "Bir bağlantıyı yapıştırın veya yazın", + "placeholder": "Bir bağlantı yapıştırın veya yazın", "openInNewTab": "Yeni sekmede aç", "copyLink": "Bağlantıyı kopyala", "removeLink": "Bağlantıyı kaldır", "url": { "label": "Bağlantı URL'si", - "placeholder": "Bağlantı URL'sini girin" + "placeholder": "Bağlantı URL'si girin" }, "title": { "label": "Bağlantı Başlığı", - "placeholder": "Bağlantı başlığını girin" + "placeholder": "Bağlantı başlığı girin" } }, "mention": { - "placeholder": "Bir kişiye, sayfaya veya tarihe bahset...", + "placeholder": "Bir kişiden, sayfadan veya tarihten bahsedin...", "page": { "label": "Sayfaya bağlantı", "tooltip": "Sayfayı açmak için tıklayın" }, "deleted": "Silindi", - "deletedContent": "Bu içerik mevcut değil veya silinmiş" + "deletedContent": "Bu içerik mevcut değil veya silinmiş", + "noAccess": "Erişim Yok", + "deletedPage": "Silinmiş sayfa", + "trashHint": " - çöp kutusunda", + "morePages": "daha fazla sayfa" }, "toolbar": { "resetToDefaultFont": "Varsayılana sıfırla" }, "errorBlock": { - "theBlockIsNotSupported": "Geçerli sürüm bu bloğu desteklemiyor.", - "blockContentHasBeenCopied": "Blok içeriği kopyalandı." + "theBlockIsNotSupported": "Mevcut sürüm bu Bloğu desteklemiyor.", + "clickToCopyTheBlockContent": "Blok içeriğini kopyalamak için tıklayın", + "blockContentHasBeenCopied": "Blok içeriği kopyalandı.", + "parseError": "{} bloğu ayrıştırılırken bir hata oluştu.", + "copyBlockContent": "Blok içeriğini kopyala" + }, + "mobilePageSelector": { + "title": "Sayfa seç", + "failedToLoad": "Sayfa listesi yüklenemedi", + "noPagesFound": "Sayfa bulunamadı" + }, + "attachmentMenu": { + "choosePhoto": "Fotoğraf seç", + "takePicture": "Fotoğraf çek", + "chooseFile": "Dosya seç" } }, "board": { "column": { + "label": "Sütun", "createNewCard": "Yeni", "renameGroupTooltip": "Grubu yeniden adlandırmak için basın", "createNewColumn": "Yeni bir grup ekle", "addToColumnTopTooltip": "Üste yeni bir kart ekle", "addToColumnBottomTooltip": "Alta yeni bir kart ekle", - "renameColumn": "Yeniden Adlandır", + "renameColumn": "Yeniden adlandır", "hideColumn": "Gizle", - "newGroup": "Yeni Grup", + "newGroup": "Yeni grup", "deleteColumn": "Sil", - "deleteColumnConfirmation": "Bu, bu grubu ve içindeki tüm kartları silecektir.\nDevam etmek istediğinizden emin misiniz?" + "deleteColumnConfirmation": "Bu işlem bu grubu ve içindeki tüm kartları silecektir. Devam etmek istediğinizden emin misiniz?" }, "hiddenGroupSection": { "sectionTitle": "Gizli Gruplar", @@ -1060,47 +2095,70 @@ "expandTooltip": "Gizli grupları görüntüle" }, "cardDetail": "Kart Detayı", - "cardActions": "Kart Eylemleri", - "cardDuplicated": "Kart kopyalandı", + "cardActions": "Kart İşlemleri", + "cardDuplicated": "Kart çoğaltıldı", "cardDeleted": "Kart silindi", "showOnCard": "Kart detayında göster", "setting": "Ayar", "propertyName": "Özellik adı", "menuName": "Pano", - "showUngrouped": "Grupsuz öğeleri göster", - "ungroupedButtonText": "Grupsuz", + "showUngrouped": "Gruplanmamış öğeleri göster", + "ungroupedButtonText": "Gruplanmamış", "ungroupedButtonTooltip": "Herhangi bir gruba ait olmayan kartları içerir", "ungroupedItemsTitle": "Panoya eklemek için tıklayın", - "groupBy": "Şuna göre grupla", + "groupBy": "Grupla", + "groupCondition": "Gruplama koşulu", "referencedBoardPrefix": "Görünümü", - "notesTooltip": "İçindeki notlar", + "notesTooltip": "İçerideki notlar", "mobile": { - "editURL": "URL'yi Düzenle", + "editURL": "URL'yi düzenle", "showGroup": "Grubu göster", "showGroupContent": "Bu grubu panoda göstermek istediğinizden emin misiniz?", "failedToLoad": "Pano görünümü yüklenemedi" + }, + "dateCondition": { + "weekOf": "{} - {} haftası", + "today": "Bugün", + "yesterday": "Dün", + "tomorrow": "Yarın", + "lastSevenDays": "Son 7 gün", + "nextSevenDays": "Gelecek 7 gün", + "lastThirtyDays": "Son 30 gün", + "nextThirtyDays": "Gelecek 30 gün" + }, + "noGroup": "Özelliğe göre gruplama yok", + "noGroupDesc": "Pano görünümleri görüntülemek için gruplamak üzere bir özellik gerektirir", + "media": { + "cardText": "{} {}", + "fallbackName": "dosyalar" } }, "calendar": { "menuName": "Takvim", - "defaultNewCalendarTitle": "İsimsiz", - "newEventButtonTooltip": "Yeni bir etkinlik ekle", + "defaultNewCalendarTitle": "Başlıksız", + "newEventButtonTooltip": "Yeni etkinlik ekle", "navigation": { "today": "Bugün", - "jumpToday": "Bugüne Git", + "jumpToday": "Bugüne git", "previousMonth": "Önceki Ay", - "nextMonth": "Sonraki Ay" + "nextMonth": "Sonraki Ay", + "views": { + "day": "Gün", + "week": "Hafta", + "month": "Ay", + "year": "Yıl" + } }, "mobileEventScreen": { "emptyTitle": "Henüz etkinlik yok", - "emptyBody": "Bu güne bir etkinlik oluşturmak için artı düğmesine basın." + "emptyBody": "Bu güne etkinlik eklemek için artı düğmesine basın." }, "settings": { "showWeekNumbers": "Hafta numaralarını göster", "showWeekends": "Hafta sonlarını göster", - "firstDayOfWeek": "Haftayı şunda başlat", + "firstDayOfWeek": "Haftanın başlangıç günü", "layoutDateField": "Takvimi şuna göre düzenle", - "changeLayoutDateField": "Düzenleme alanını değiştir", + "changeLayoutDateField": "Düzen alanını değiştir", "noDateTitle": "Tarih Yok", "noDateHint": { "zero": "Planlanmamış etkinlikler burada görünecek", @@ -1109,19 +2167,23 @@ }, "unscheduledEventsTitle": "Planlanmamış etkinlikler", "clickToAdd": "Takvime eklemek için tıklayın", - "name": "Takvim ayarları" + "name": "Takvim ayarları", + "clickToOpen": "Kaydı açmak için tıklayın" }, "referencedCalendarPrefix": "Görünümü", "quickJumpYear": "Şuraya git", - "duplicateEvent": "Etkinliği kopyala" + "duplicateEvent": "Etkinliği çoğalt" }, "errorDialog": { - "title": "AppFlowy Hatası", - "howToFixFallback": "Verdiğimiz rahatsızlıktan dolayı özür dileriz! Lütfen GitHub sayfamızda hatanızı açıklayan bir sorun bildirin.", - "github": "GitHub'da Görüntüle" + "title": "@:appName Hatası", + "howToFixFallback": "Rahatsızlık için özür dileriz! GitHub sayfamızda hatanızı açıklayan bir sorun bildirin.", + "howToFixFallbackHint1": "Rahatsızlık için özür dileriz! ", + "howToFixFallbackHint2": " sayfamızda hatanızı açıklayan bir sorun bildirin.", + "github": "GitHub'da görüntüle" }, "search": { "label": "Ara", + "sidebarSearchIcon": "Ara ve hızlıca bir sayfaya git", "placeholder": { "actions": "Eylemleri ara..." } @@ -1132,7 +2194,7 @@ "fail": "Kopyalanamadı" } }, - "unSupportBlock": "Geçerli sürüm bu Bloğu desteklemiyor.", + "unSupportBlock": "Mevcut sürüm bu Bloğu desteklemiyor.", "views": { "deleteContentTitle": "{pageType} silmek istediğinizden emin misiniz?", "deleteContentCaption": "Bu {pageType} silerseniz, çöp kutusundan geri yükleyebilirsiniz." @@ -1155,22 +2217,22 @@ "search": "Emoji ara", "noRecent": "Son kullanılan emoji yok", "noEmojiFound": "Emoji bulunamadı", - "filter": "Filtre", + "filter": "Filtrele", "random": "Rastgele", - "selectSkinTone": "Cilt tonunu seç", + "selectSkinTone": "Ten rengi seç", "remove": "Emojiyi kaldır", "categories": { - "smileys": "Gülen Yüzler & Duygular", - "people": "İnsanlar & Vücut", - "animals": "Hayvanlar & Doğa", - "food": "Yiyecek & İçecek", - "activities": "Aktiviteler", - "places": "Seyahat & Yerler", - "objects": "Nesneler", - "symbols": "Semboller", - "flags": "Bayraklar", - "nature": "Doğa", - "frequentlyUsed": "Sık Kullanılanlar" + "smileys": "İfadeler ve Duygular", + "people": "insanlar", + "animals": "doğa", + "food": "yiyecekler", + "activities": "aktiviteler", + "places": "yerler", + "objects": "nesneler", + "symbols": "semboller", + "flags": "bayraklar", + "nature": "doğa", + "frequentlyUsed": "sık kullanılanlar" }, "skinTone": { "default": "Varsayılan", @@ -1179,10 +2241,12 @@ "medium": "Orta", "mediumDark": "Orta-Koyu", "dark": "Koyu" - } + }, + "openSourceIconsFrom": "Açık kaynak ikonlar" }, "inlineActions": { "noResults": "Sonuç yok", + "recentPages": "Son sayfalar", "pageReference": "Sayfa referansı", "docReference": "Belge referansı", "boardReference": "Pano referansı", @@ -1192,20 +2256,21 @@ "reminder": { "groupTitle": "Hatırlatıcı", "shortKeyword": "hatırlat" - } + }, + "createPage": "\"{}\"-alt sayfası oluştur" }, "datePicker": { - "dateTimeFormatTooltip": "Ayarlardan tarih ve saat formatını değiştirin", - "dateFormat": "Tarih formatı", + "dateTimeFormatTooltip": "Tarih ve saat biçimini ayarlardan değiştirin", + "dateFormat": "Tarih biçimi", "includeTime": "Saati dahil et", "isRange": "Bitiş tarihi", - "timeFormat": "Saat formatı", + "timeFormat": "Saat biçimi", "clearDate": "Tarihi temizle", "reminderLabel": "Hatırlatıcı", "selectReminder": "Hatırlatıcı seç", "reminderOptions": { "none": "Yok", - "atTimeOfEvent": "Etkinlik zamanı", + "atTimeOfEvent": "Etkinlik zamanında", "fiveMinsBefore": "5 dakika önce", "tenMinsBefore": "10 dakika önce", "fifteenMinsBefore": "15 dakika önce", @@ -1230,8 +2295,8 @@ "mobile": { "title": "Güncellemeler" }, - "emptyTitle": "Her şey tamam!", - "emptyBody": "Bekleyen bildirim veya eylem yok. Sakinliğin tadını çıkarın.", + "emptyTitle": "Hepsi tamamlandı!", + "emptyBody": "Bekleyen bildirim veya eylem yok. Huzurun tadını çıkarın.", "tabs": { "inbox": "Gelen Kutusu", "upcoming": "Yaklaşan" @@ -1245,16 +2310,16 @@ "ascending": "Artan", "descending": "Azalan", "groupByDate": "Tarihe göre grupla", - "showUnreadsOnly": "Yalnızca okunmamışları göster", + "showUnreadsOnly": "Sadece okunmamışları göster", "resetToDefault": "Varsayılana sıfırla" } }, "reminderNotification": { "title": "Hatırlatıcı", - "message": "Unutmadan önce bunu kontrol etmeyi unutma!", + "message": "Unutmadan önce bunu kontrol etmeyi unutmayın!", "tooltipDelete": "Sil", "tooltipMarkRead": "Okundu olarak işaretle", - "tooltipMarkUnread": "Okunmamış olarak işaretle" + "tooltipMarkUnread": "Okunmadı olarak işaretle" }, "findAndReplace": { "find": "Bul", @@ -1265,33 +2330,40 @@ "replaceAll": "Tümünü değiştir", "noResult": "Sonuç yok", "caseSensitive": "Büyük/küçük harf duyarlı", - "searchMore": "Daha fazla sonuç bulmak için ara" + "searchMore": "Daha fazla sonuç bulmak için arama yapın" }, "error": { "weAreSorry": "Üzgünüz", - "loadingViewError": "Bu görünümü yüklemede sorun yaşıyoruz. Lütfen internet bağlantınızı kontrol edin, uygulamayı yenileyin ve sorun devam ederse ekibe ulaşmaktan çekinmeyin." + "loadingViewError": "Bu görünümü yüklerken sorun yaşıyoruz. Lütfen internet bağlantınızı kontrol edin, uygulamayı yenileyin ve sorun devam ederse ekiple iletişime geçmekten çekinmeyin.", + "syncError": "Veriler başka bir cihazdan senkronize edilmedi", + "syncErrorHint": "Lütfen bu sayfayı son düzenlemenin yapıldığı cihazda yeniden açın, ardından mevcut cihazda tekrar açın.", + "clickToCopy": "Hata kodunu kopyalamak için tıklayın" }, "editor": { "bold": "Kalın", - "bulletedList": "Madde İşaretli Liste", - "bulletedListShortForm": "Madde", - "checkbox": "Onay Kutusu", - "embedCode": "Kodu Göm", + "bulletedList": "Madde işaretli liste", + "bulletedListShortForm": "Madde işaretli", + "checkbox": "Onay kutusu", + "embedCode": "Kod Yerleştir", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Vurgula", "color": "Renk", - "image": "Resim", + "image": "Görsel", "date": "Tarih", + "page": "Sayfa", "italic": "İtalik", "link": "Bağlantı", - "numberedList": "Numaralı Liste", + "numberedList": "Numaralı liste", "numberedListShortForm": "Numaralı", + "toggleHeading1ShortForm": "B1'i aç/kapat", + "toggleHeading2ShortForm": "B2'yi aç/kapat", + "toggleHeading3ShortForm": "B3'ü aç/kapat", "quote": "Alıntı", - "strikethrough": "Üstü Çizili", + "strikethrough": "Üstü çizili", "text": "Metin", - "underline": "Altı Çizili", + "underline": "Altı çizili", "fontColorDefault": "Varsayılan", "fontColorGray": "Gri", "fontColorBrown": "Kahverengi", @@ -1312,9 +2384,9 @@ "backgroundColorPurple": "Mor arka plan", "backgroundColorPink": "Pembe arka plan", "backgroundColorRed": "Kırmızı arka plan", - "backgroundColorLime": "Yeşil arka plan", - "backgroundColorAqua": "Su yeşili arka plan", - "done": "Bitti", + "backgroundColorLime": "Limon yeşili arka plan", + "backgroundColorAqua": "Su mavisi arka plan", + "done": "Tamam", "cancel": "İptal", "tint1": "Ton 1", "tint2": "Ton 2", @@ -1330,14 +2402,17 @@ "lightLightTint3": "Açık Pembe", "lightLightTint4": "Turuncu", "lightLightTint5": "Sarı", - "lightLightTint6": "Limoni", + "lightLightTint6": "Limon yeşili", "lightLightTint7": "Yeşil", - "lightLightTint8": "Su yeşili", + "lightLightTint8": "Su mavisi", "lightLightTint9": "Mavi", "urlHint": "URL", "mobileHeading1": "Başlık 1", "mobileHeading2": "Başlık 2", "mobileHeading3": "Başlık 3", + "mobileHeading4": "Başlık 4", + "mobileHeading5": "Başlık 5", + "mobileHeading6": "Başlık 6", "textColor": "Metin Rengi", "backgroundColor": "Arka Plan Rengi", "addYourLink": "Bağlantınızı ekleyin", @@ -1348,14 +2423,14 @@ "linkText": "Metin", "linkTextHint": "Lütfen metin girin", "linkAddressHint": "Lütfen URL girin", - "highlightColor": "Vurgu rengi", - "clearHighlightColor": "Vurgu rengini temizle", + "highlightColor": "Vurgulama rengi", + "clearHighlightColor": "Vurgulama rengini temizle", "customColor": "Özel renk", "hexValue": "Hex değeri", "opacity": "Opaklık", "resetToDefaultColor": "Varsayılan renge sıfırla", - "ltr": "LTR", - "rtl": "RTL", + "ltr": "Soldan sağa", + "rtl": "Sağdan sola", "auto": "Otomatik", "cut": "Kes", "copy": "Kopyala", @@ -1368,15 +2443,15 @@ "closeFind": "Kapat", "replace": "Değiştir", "replaceAll": "Tümünü değiştir", - "regex": "Regex", + "regex": "Düzenli ifade", "caseSensitive": "Büyük/küçük harf duyarlı", - "uploadImage": "Resim Yükle", - "urlImage": "URL Resmi", + "uploadImage": "Görsel Yükle", + "urlImage": "URL Görseli", "incorrectLink": "Hatalı Bağlantı", "upload": "Yükle", - "chooseImage": "Bir resim seçin", + "chooseImage": "Bir görsel seçin", "loading": "Yükleniyor", - "imageLoadFailed": "Resim yüklenemedi", + "imageLoadFailed": "Görsel yüklenemedi", "divider": "Ayırıcı", "table": "Tablo", "colAddBefore": "Önce ekle", @@ -1385,23 +2460,25 @@ "rowAddAfter": "Sonra ekle", "colRemove": "Kaldır", "rowRemove": "Kaldır", - "colDuplicate": "Kopyala", - "rowDuplicate": "Kopyala", + "colDuplicate": "Çoğalt", + "rowDuplicate": "Çoğalt", "colClear": "İçeriği Temizle", "rowClear": "İçeriği Temizle", - "slashPlaceHolder": "Bir blok eklemek için '/' yazın veya yazmaya başlayın", + "slashPlaceHolder": "Blok eklemek için '/' yazın veya yazmaya başlayın", "typeSomething": "Bir şeyler yazın...", - "toggleListShortForm": "Değiştir", + "toggleListShortForm": "Aç/Kapat", "quoteListShortForm": "Alıntı", "mathEquationShortForm": "Formül", "codeBlockShortForm": "Kod" }, "favorite": { "noFavorite": "Favori sayfa yok", - "noFavoriteHintText": "Sayfayı favorilerinize eklemek için sola kaydırın" + "noFavoriteHintText": "Favorilerinize eklemek için sayfayı sola kaydırın", + "removeFromSidebar": "Kenar çubuğundan kaldır", + "addToSidebar": "Kenar çubuğuna sabitle" }, "cardDetails": { - "notesPlaceholder": "Bir blok eklemek için '/' yazın veya yazmaya başlayın" + "notesPlaceholder": "Blok eklemek için / yazın veya yazmaya başlayın" }, "blockPlaceholders": { "todoList": "Yapılacaklar", @@ -1411,51 +2488,60 @@ "heading": "Başlık {}" }, "titleBar": { - "pageIcon": "Sayfa simgesi", + "pageIcon": "Sayfa ikonu", "language": "Dil", - "font": "Yazı Tipi", + "font": "Yazı tipi", "actions": "Eylemler", "date": "Tarih", "addField": "Alan ekle", - "userIcon": "Kullanıcı simgesi" + "userIcon": "Kullanıcı ikonu" }, "noLogFiles": "Günlük dosyası yok", "newSettings": { "myAccount": { "title": "Hesabım", - "subtitle": "Profilinizi özelleştirin, hesap güvenliğini yönetin, Open AI anahtarlarını yönetin veya hesabınıza giriş yapın.", - "profileLabel": "Hesap adı & Profil resmi", + "subtitle": "Profilinizi özelleştirin, hesap güvenliğini yönetin, yapay zeka anahtarlarını ayarlayın veya hesabınıza giriş yapın.", + "profileLabel": "Hesap adı ve Profil resmi", "profileNamePlaceholder": "Adınızı girin", "accountSecurity": "Hesap güvenliği", "2FA": "2 Adımlı Doğrulama", - "aiKeys": "AI anahtarları", + "aiKeys": "Yapay zeka anahtarları", "accountLogin": "Hesap Girişi", "updateNameError": "Ad güncellenemedi", - "updateIconError": "Simge güncellenemedi", + "updateIconError": "İkon güncellenemedi", "deleteAccount": { "title": "Hesabı Sil", "subtitle": "Hesabınızı ve tüm verilerinizi kalıcı olarak silin.", + "description": "Hesabınızı kalıcı olarak silin ve tüm çalışma alanlarından erişimi kaldırın.", "deleteMyAccount": "Hesabımı sil", "dialogTitle": "Hesabı sil", "dialogContent1": "Hesabınızı kalıcı olarak silmek istediğinizden emin misiniz?", - "dialogContent2": "Bu işlem geri alınamaz ve tüm ekip alanlarına erişiminizi kaldıracak, hesabınızı (özel çalışma alanları dahil) tamamen silecek ve sizi tüm paylaşılan çalışma alanlarından çıkaracaktır." + "dialogContent2": "Bu işlem GERİ ALINAMAZ ve tüm çalışma alanlarından erişiminizi kaldıracak, özel çalışma alanları dahil tüm hesabınızı silecek ve sizi tüm paylaşılan çalışma alanlarından çıkaracaktır.", + "confirmHint1": "Onaylamak için lütfen \"@:newSettings.myAccount.deleteAccount.confirmHint3\" yazın.", + "confirmHint2": "Bu işlemin GERİ ALINAMAZ olduğunu ve hesabımı ve ilişkili tüm verileri kalıcı olarak sileceğini anlıyorum.", + "confirmHint3": "HESABIMI SİL", + "checkToConfirmError": "Silme işlemini onaylamak için kutuyu işaretlemelisiniz", + "failedToGetCurrentUser": "Mevcut kullanıcı e-postası alınamadı", + "confirmTextValidationFailed": "Onay metniniz \"@:newSettings.myAccount.deleteAccount.confirmHint3\" ile eşleşmiyor", + "deleteAccountSuccess": "Hesap başarıyla silindi" } }, "workplace": { - "name": "Çalışma Alanı", + "name": "Çalışma alanı", "title": "Çalışma Alanı Ayarları", - "subtitle": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarihini, saatini ve dilini özelleştirin.", + "subtitle": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarih, saat ve dilini özelleştirin.", "workplaceName": "Çalışma alanı adı", "workplaceNamePlaceholder": "Çalışma alanı adını girin", - "workplaceIcon": "Çalışma alanı simgesi", - "workplaceIconSubtitle": "Çalışma alanınız için bir resim yükleyin veya bir emoji kullanın. Simge kenar çubuğunuzda ve bildirimlerinizde görünecektir", + "workplaceIcon": "Çalışma alanı ikonu", + "workplaceIconSubtitle": "Bir görsel yükleyin veya çalışma alanınız için bir emoji kullanın. İkon kenar çubuğunuzda ve bildirimlerde görünecektir.", "renameError": "Çalışma alanı yeniden adlandırılamadı", - "updateIconError": "Simge güncellenemedi", + "updateIconError": "İkon güncellenemedi", + "chooseAnIcon": "Bir ikon seçin", "appearance": { "name": "Görünüm", "themeMode": { "auto": "Otomatik", - "light": "Açık", + "light": "Aydınlık", "dark": "Koyu" }, "language": "Dil" @@ -1467,14 +2553,531 @@ "noNetworkConnected": "Ağ bağlantısı yok" } }, + "pageStyle": { + "title": "Sayfa stili", + "layout": "Düzen", + "coverImage": "Kapak görseli", + "pageIcon": "Sayfa ikonu", + "colors": "Renkler", + "gradient": "Gradyan", + "backgroundImage": "Arka plan görseli", + "presets": "Hazır ayarlar", + "photo": "Fotoğraf", + "unsplash": "Unsplash", + "pageCover": "Sayfa kapağı", + "none": "Yok", + "openSettings": "Ayarları Aç", + "photoPermissionTitle": "@:appName fotoğraf kitaplığınıza erişmek istiyor", + "photoPermissionDescription": "@:appName belgelerinize görsel ekleyebilmeniz için fotoğraflarınıza erişmeye ihtiyaç duyuyor", + "cameraPermissionTitle": "@:appName kameranıza erişmek istiyor", + "cameraPermissionDescription": "@:appName belgelerinize kameradan görsel ekleyebilmeniz için kameranıza erişmeye ihtiyaç duyuyor", + "doNotAllow": "İzin Verme", + "image": "Görsel" + }, "commandPalette": { - "placeholder": "Görünümleri aramak için yazın...", + "placeholder": "Ara veya bir soru sor...", "bestMatches": "En iyi eşleşmeler", "recentHistory": "Son geçmiş", "navigateHint": "gezinmek için", - "loadingTooltip": "Sonuçlar aranıyor...", + "loadingTooltip": "Sonuçları arıyoruz...", "betaLabel": "BETA", - "betaTooltip": "Şu anda yalnızca sayfaları aramayı destekliyoruz", - "fromTrashHint": "Çöp kutusundan" + "betaTooltip": "Şu anda yalnızca sayfalarda ve belgelerde içerik aramayı destekliyoruz", + "fromTrashHint": "Çöp kutusundan", + "noResultsHint": "Aradığınızı bulamadık, başka bir terim aramayı deneyin.", + "clearSearchTooltip": "Arama alanını temizle" + }, + "space": { + "delete": "Sil", + "deleteConfirmation": "Sil: ", + "deleteConfirmationDescription": "Bu Alan içindeki tüm sayfalar silinecek ve Çöp Kutusuna taşınacak, yayınlanmış sayfaların yayını kaldırılacaktır.", + "rename": "Alanı Yeniden Adlandır", + "changeIcon": "İkonu değiştir", + "manage": "Alanı Yönet", + "addNewSpace": "Alan Oluştur", + "collapseAllSubPages": "Tüm alt sayfaları daralt", + "createNewSpace": "Yeni bir alan oluştur", + "createSpaceDescription": "İşlerinizi daha iyi organize etmek için birden fazla genel ve özel alan oluşturun.", + "spaceName": "Alan adı", + "spaceNamePlaceholder": "örn. Pazarlama, Mühendislik, İK", + "permission": "Alan izni", + "publicPermission": "Genel", + "publicPermissionDescription": "Tam erişime sahip tüm çalışma alanı üyeleri", + "privatePermission": "Özel", + "privatePermissionDescription": "Bu alana yalnızca siz erişebilirsiniz", + "spaceIconBackground": "Arka plan rengi", + "spaceIcon": "İkon", + "dangerZone": "Tehlikeli Bölge", + "unableToDeleteLastSpace": "Son Alan silinemez", + "unableToDeleteSpaceNotCreatedByYou": "Başkaları tarafından oluşturulan alanlar silinemez", + "enableSpacesForYourWorkspace": "Çalışma alanınız için Alanları etkinleştirin", + "title": "Alanlar", + "defaultSpaceName": "Genel", + "upgradeSpaceTitle": "Alanları Etkinleştir", + "upgradeSpaceDescription": "Çalışma alanınızı daha iyi organize etmek için birden fazla genel ve özel Alan oluşturun.", + "upgrade": "Güncelle", + "upgradeYourSpace": "Birden fazla Alan oluştur", + "quicklySwitch": "Hızlıca sonraki alana geç", + "duplicate": "Alanı Çoğalt", + "movePageToSpace": "Sayfayı alana taşı", + "cannotMovePageToDatabase": "Sayfa veritabanına taşınamaz", + "switchSpace": "Alan değiştir", + "spaceNameCannotBeEmpty": "Alan adı boş olamaz", + "success": { + "deleteSpace": "Alan başarıyla silindi", + "renameSpace": "Alan başarıyla yeniden adlandırıldı", + "duplicateSpace": "Alan başarıyla çoğaltıldı", + "updateSpace": "Alan başarıyla güncellendi" + }, + "error": { + "deleteSpace": "Alan silinemedi", + "renameSpace": "Alan yeniden adlandırılamadı", + "duplicateSpace": "Alan çoğaltılamadı", + "updateSpace": "Alan güncellenemedi" + }, + "createSpace": "Alan oluştur", + "manageSpace": "Alanı yönet", + "renameSpace": "Alanı yeniden adlandır", + "mSpaceIconColor": "Alan ikonu rengi", + "mSpaceIcon": "Alan ikonu" + }, + "publish": { + "hasNotBeenPublished": "Bu sayfa henüz yayınlanmadı", + "spaceHasNotBeenPublished": "Henüz bir alanın yayınlanması desteklenmiyor", + "reportPage": "Sayfayı bildir", + "databaseHasNotBeenPublished": "Veritabanı yayınlama henüz desteklenmiyor.", + "createdWith": "Şununla oluşturuldu", + "downloadApp": "AppFlowy'yi İndir", + "copy": { + "codeBlock": "Kod bloğunun içeriği panoya kopyalandı", + "imageBlock": "Görsel bağlantısı panoya kopyalandı", + "mathBlock": "Matematik denklemi panoya kopyalandı", + "fileBlock": "Dosya bağlantısı panoya kopyalandı" + }, + "containsPublishedPage": "Bu sayfa bir veya daha fazla yayınlanmış sayfa içeriyor. Devam ederseniz, yayından kaldırılacaklar. Silme işlemine devam etmek istiyor musunuz?", + "publishSuccessfully": "Başarıyla yayınlandı", + "unpublishSuccessfully": "Yayından kaldırma başarılı", + "publishFailed": "Yayınlanamadı", + "unpublishFailed": "Yayından kaldırılamadı", + "noAccessToVisit": "Bu sayfaya erişim yok...", + "createWithAppFlowy": "AppFlowy ile bir web sitesi oluşturun", + "fastWithAI": "Yapay zeka ile hızlı ve kolay.", + "tryItNow": "Şimdi deneyin", + "onlyGridViewCanBePublished": "Yalnızca Tablo görünümü yayınlanabilir", + "database": { + "zero": "{} seçili görünümü yayınla", + "one": "{} seçili görünümü yayınla", + "many": "{} seçili görünümü yayınla", + "other": "{} seçili görünümü yayınla" + }, + "mustSelectPrimaryDatabase": "Ana görünüm seçilmelidir", + "noDatabaseSelected": "Veritabanı seçilmedi, lütfen en az bir veritabanı seçin.", + "unableToDeselectPrimaryDatabase": "Ana veritabanının seçimi kaldırılamaz", + "saveThisPage": "Bu şablonla başlayın", + "duplicateTitle": "Nereye eklemek istersiniz", + "selectWorkspace": "Bir çalışma alanı seçin", + "addTo": "Şuraya ekle", + "duplicateSuccessfully": "Çalışma alanınıza eklendi", + "duplicateSuccessfullyDescription": "AppFlowy yüklü değil mi? 'İndir'e tıkladıktan sonra indirme otomatik olarak başlayacak.", + "downloadIt": "İndir", + "openApp": "Uygulamada aç", + "duplicateFailed": "Çoğaltma başarısız", + "membersCount": { + "zero": "Üye yok", + "one": "1 üye", + "many": "{count} üye", + "other": "{count} üye" + }, + "useThisTemplate": "Bu şablonu kullan" + }, + "web": { + "continue": "Devam et", + "or": "veya", + "continueWithGoogle": "Google ile devam et", + "continueWithGithub": "GitHub ile devam et", + "continueWithDiscord": "Discord ile devam et", + "continueWithApple": "Apple ile devam et", + "moreOptions": "Daha fazla seçenek", + "collapse": "Daralt", + "signInAgreement": "Yukarıdaki \"Devam et\" düğmesine tıklayarak AppFlowy'nin şunlarını kabul etmiş olursunuz:", + "and": "ve", + "termOfUse": "Kullanım Koşulları", + "privacyPolicy": "Gizlilik Politikası", + "signInError": "Giriş hatası", + "login": "Kaydol veya giriş yap", + "fileBlock": { + "uploadedAt": "{time} tarihinde yüklendi", + "linkedAt": "Bağlantısı {time} tarihinde eklendi", + "empty": "Bir dosya yükleyin veya yerleştirin", + "uploadFailed": "Yükleme başarısız, lütfen tekrar deneyin", + "retry": "Tekrar dene" + }, + "importNotion": "Notion'dan içe aktar", + "import": "İçe aktar", + "importSuccess": "Başarıyla yüklendi", + "importSuccessMessage": "İçe aktarma tamamlandığında size bildirim göndereceğiz. Bundan sonra, içe aktarılan sayfalarınızı kenar çubuğunda görüntüleyebilirsiniz.", + "importFailed": "İçe aktarma başarısız, lütfen dosya formatını kontrol edin", + "dropNotionFile": "Yüklemek için Notion zip dosyanızı buraya bırakın veya göz atmak için tıklayın", + "error": { + "pageNameIsEmpty": "Sayfa adı boş, lütfen başka bir tane deneyin" + } + }, + "globalComment": { + "comments": "Yorumlar", + "addComment": "Yorum ekle", + "reactedBy": "tepki verenler", + "addReaction": "Tepki ekle", + "reactedByMore": "ve {count} diğer", + "showSeconds": { + "one": "1 saniye önce", + "other": "{count} saniye önce", + "zero": "Az önce", + "many": "{count} saniye önce" + }, + "showMinutes": { + "one": "1 dakika önce", + "other": "{count} dakika önce", + "many": "{count} dakika önce" + }, + "showHours": { + "one": "1 saat önce", + "other": "{count} saat önce", + "many": "{count} saat önce" + }, + "showDays": { + "one": "1 gün önce", + "other": "{count} gün önce", + "many": "{count} gün önce" + }, + "showMonths": { + "one": "1 ay önce", + "other": "{count} ay önce", + "many": "{count} ay önce" + }, + "showYears": { + "one": "1 yıl önce", + "other": "{count} yıl önce", + "many": "{count} yıl önce" + }, + "reply": "Yanıtla", + "deleteComment": "Yorumu sil", + "youAreNotOwner": "Bu yorumun sahibi siz değilsiniz", + "confirmDeleteDescription": "Bu yorumu silmek istediğinizden emin misiniz?", + "hasBeenDeleted": "Silindi", + "replyingTo": "Yanıtlanıyor", + "noAccessDeleteComment": "Bu yorumu silme izniniz yok", + "collapse": "Daralt", + "readMore": "Devamını oku", + "failedToAddComment": "Yorum eklenemedi", + "commentAddedSuccessfully": "Yorum başarıyla eklendi.", + "commentAddedSuccessTip": "Az önce bir yorum eklediniz veya yanıtladınız. En son yorumları görmek için başa dönmek ister misiniz?" + }, + "template": { + "asTemplate": "Şablon olarak kaydet", + "name": "Şablon adı", + "description": "Şablon Açıklaması", + "about": "Şablon Hakkında", + "deleteFromTemplate": "Şablonlardan sil", + "preview": "Şablon Önizleme", + "categories": "Şablon Kategorileri", + "isNewTemplate": "Yeni şablona SABİTLE", + "featured": "Öne Çıkanlara SABİTLE", + "relatedTemplates": "İlgili Şablonlar", + "requiredField": "{field} gereklidir", + "addCategory": "\"{category}\" ekle", + "addNewCategory": "Yeni kategori ekle", + "addNewCreator": "Yeni oluşturucu ekle", + "deleteCategory": "Kategoriyi sil", + "editCategory": "Kategoriyi düzenle", + "editCreator": "Oluşturucuyu düzenle", + "category": { + "name": "Kategori adı", + "icon": "Kategori ikonu", + "bgColor": "Kategori arka plan rengi", + "priority": "Kategori önceliği", + "desc": "Kategori açıklaması", + "type": "Kategori türü", + "icons": "Kategori İkonları", + "colors": "Kategori Renkleri", + "byUseCase": "Kullanım Durumuna Göre", + "byFeature": "Özelliğe Göre", + "deleteCategory": "Kategoriyi sil", + "deleteCategoryDescription": "Bu kategoriyi silmek istediğinizden emin misiniz?", + "typeToSearch": "Kategorilerde arama yapmak için yazın..." + }, + "creator": { + "label": "Şablon Oluşturucu", + "name": "Oluşturucu adı", + "avatar": "Oluşturucu avatarı", + "accountLinks": "Oluşturucu hesap bağlantıları", + "uploadAvatar": "Avatar yüklemek için tıklayın", + "deleteCreator": "Oluşturucuyu sil", + "deleteCreatorDescription": "Bu oluşturucuyu silmek istediğinizden emin misiniz?", + "typeToSearch": "Oluşturucularda arama yapmak için yazın..." + }, + "uploadSuccess": "Şablon başarıyla yüklendi", + "uploadSuccessDescription": "Şablonunuz başarıyla yüklendi. Artık şablon galerisinde görüntüleyebilirsiniz.", + "viewTemplate": "Şablonu görüntüle", + "deleteTemplate": "Şablonu sil", + "deleteSuccess": "Şablon başarıyla silindi", + "deleteTemplateDescription": "Bu işlem mevcut sayfayı veya yayınlanma durumunu etkilemeyecektir. Bu şablonu silmek istediğinizden emin misiniz?", + "addRelatedTemplate": "İlgili şablon ekle", + "removeRelatedTemplate": "İlgili şablonu kaldır", + "uploadAvatar": "Avatar yükle", + "searchInCategory": "{category} içinde ara", + "label": "Şablonlar" + }, + "fileDropzone": { + "dropFile": "Yüklemek için dosyayı bu alana tıklayın veya sürükleyin", + "uploading": "Yükleniyor...", + "uploadFailed": "Yükleme başarısız", + "uploadSuccess": "Yükleme başarılı", + "uploadSuccessDescription": "Dosya başarıyla yüklendi", + "uploadFailedDescription": "Dosya yüklenemedi", + "uploadingDescription": "Dosya yükleniyor" + }, + "gallery": { + "preview": "Tam ekran aç", + "copy": "Kopyala", + "download": "İndir", + "prev": "Önceki", + "next": "Sonraki", + "resetZoom": "Yakınlaştırmayı sıfırla", + "zoomIn": "Yakınlaştır", + "zoomOut": "Uzaklaştır" + }, + "invitation": { + "join": "Katıl", + "on": "tarihinde", + "invitedBy": "Davet eden", + "membersCount": { + "zero": "{count} üye", + "one": "{count} üye", + "many": "{count} üye", + "other": "{count} üye" + }, + "tip": "Aşağıdaki iletişim bilgileriyle bu çalışma alanına katılmaya davet edildiniz. Bu bilgiler yanlışsa, davetiyeyi yeniden göndermesi için yöneticinizle iletişime geçin.", + "joinWorkspace": "Çalışma alanına katıl", + "success": "Çalışma alanına başarıyla katıldınız", + "successMessage": "Artık içindeki tüm sayfalara ve çalışma alanlarına erişebilirsiniz.", + "openWorkspace": "AppFlowy'yi Aç", + "alreadyAccepted": "Bu daveti zaten kabul ettiniz", + "errorModal": { + "title": "Bir hata oluştu", + "description": "Mevcut hesabınızın {email} bu çalışma alanına erişimi olmayabilir. Lütfen doğru hesapla giriş yapın veya yardım için çalışma alanı sahibiyle iletişime geçin.", + "contactOwner": "Sahiple iletişime geç", + "close": "Ana sayfaya dön", + "changeAccount": "Hesap değiştir" + } + }, + "requestAccess": { + "title": "Bu sayfaya erişim yok", + "subtitle": "Bu sayfanın sahibinden erişim talep edebilirsiniz. Onaylandığında, sayfayı görüntüleyebilirsiniz.", + "requestAccess": "Erişim talep et", + "backToHome": "Ana sayfaya dön", + "tip": "Şu anda olarak giriş yapmış durumdasınız.", + "mightBe": "Farklı bir hesapla yapmanız gerekebilir.", + "successful": "Talep başarıyla gönderildi", + "successfulMessage": "Sahibi talebinizi onayladığında size bildirim gönderilecek.", + "requestError": "Erişim talebi başarısız oldu", + "repeatRequestError": "Bu sayfa için zaten erişim talebinde bulundunuz" + }, + "approveAccess": { + "title": "Çalışma Alanı Katılım Talebini Onayla", + "requestSummary": ", 'a katılmak ve 'a erişmek istiyor", + "upgrade": "yükselt", + "downloadApp": "AppFlowy'yi İndir", + "approveButton": "Onayla", + "approveSuccess": "Başarıyla onaylandı", + "approveError": "Onaylama başarısız, çalışma alanı plan limitinin aşılmadığından emin olun", + "getRequestInfoError": "Talep bilgisi alınamadı", + "memberCount": { + "zero": "Üye yok", + "one": "1 üye", + "many": "{count} üye", + "other": "{count} üye" + }, + "alreadyProTitle": "Çalışma alanı plan limitine ulaştınız", + "alreadyProMessage": "Daha fazla üye eklemek için ile iletişime geçmelerini isteyin", + "repeatApproveError": "Bu talebi zaten onayladınız", + "ensurePlanLimit": "Çalışma alanı plan limitinin aşılmadığından emin olun. Limit aşılırsa, çalışma alanı planını veya .", + "requestToJoin": "katılmak için talep etti", + "asMember": "üye olarak" + }, + "upgradePlanModal": { + "title": "Pro'ya Yükselt", + "message": "{name} ücretsiz üye limitine ulaştı. Daha fazla üye davet etmek için Pro Plana yükseltin.", + "upgradeSteps": "AppFlowy'de planınızı nasıl yükseltirsiniz:", + "step1": "1. Ayarlar'a gidin", + "step2": "2. 'Plan'a tıklayın", + "step3": "3. 'Planı Değiştir'i seçin", + "appNote": "Not: ", + "actionButton": "Yükselt", + "downloadLink": "Uygulamayı İndir", + "laterButton": "Sonra", + "refreshNote": "Başarılı yükseltmeden sonra, yeni özelliklerinizi etkinleştirmek için tıklayın.", + "refresh": "buraya" + }, + "breadcrumbs": { + "label": "Gezinti menüsü" + }, + "time": { + "justNow": "Az önce", + "seconds": { + "one": "1 saniye", + "other": "{count} saniye" + }, + "minutes": { + "one": "1 dakika", + "other": "{count} dakika" + }, + "hours": { + "one": "1 saat", + "other": "{count} saat" + }, + "days": { + "one": "1 gün", + "other": "{count} gün" + }, + "weeks": { + "one": "1 hafta", + "other": "{count} hafta" + }, + "months": { + "one": "1 ay", + "other": "{count} ay" + }, + "years": { + "one": "1 yıl", + "other": "{count} yıl" + }, + "ago": "önce", + "yesterday": "Dün", + "today": "Bugün" + }, + "members": { + "zero": "Üye yok", + "one": "1 üye", + "many": "{count} üye", + "other": "{count} üye" + }, + "tabMenu": { + "close": "Kapat", + "closeDisabledHint": "Sabitlenmiş bir sekme kapatılamaz, lütfen önce sabitlemeyi kaldırın", + "closeOthers": "Diğer sekmeleri kapat", + "closeOthersHint": "Bu işlem, bu sekme dışındaki tüm sabitlenmemiş sekmeleri kapatacaktır", + "closeOthersDisabledHint": "Tüm sekmeler sabitlenmiş, kapatılacak sekme bulunamadı", + "favorite": "Favorilere ekle", + "unfavorite": "Favorilerden kaldır", + "favoriteDisabledHint": "Bu görünüm favorilere eklenemez", + "pinTab": "Sabitle", + "unpinTab": "Sabitlemeyi kaldır" + }, + "openFileMessage": { + "success": "Dosya başarıyla açıldı", + "fileNotFound": "Dosya bulunamadı", + "noAppToOpenFile": "Bu dosyayı açacak uygulama yok", + "permissionDenied": "Bu dosyayı açma izni yok", + "unknownError": "Dosya açılamadı" + }, + "inviteMember": { + "requestInviteMembers": "Çalışma alanınıza davet et", + "inviteFailedMemberLimit": "Üye limitine ulaşıldı, lütfen ", + "upgrade": "yükselt", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "Davetleri gönder", + "inviteAlready": "Bu e-postayı zaten davet ettiniz: {email}", + "inviteSuccess": "Davet başarıyla gönderildi", + "description": "E-postaları aralarına virgül koyarak aşağıya girin. Ücretlendirme üye sayısına göre yapılır.", + "emails": "E-posta" + }, + "quickNote": { + "label": "Hızlı Not", + "quickNotes": "Hızlı Notlar", + "search": "Hızlı Notlarda Ara", + "collapseFullView": "Tam görünümü daralt", + "expandFullView": "Tam görünümü genişlet", + "createFailed": "Hızlı Not oluşturulamadı", + "quickNotesEmpty": "Hızlı Not yok", + "emptyNote": "Boş not", + "deleteNotePrompt": "Seçilen not kalıcı olarak silinecektir. Silmek istediğinizden emin misiniz?", + "addNote": "Yeni Not", + "noAdditionalText": "Ek metin yok" + }, + "subscribe": { + "upgradePlanTitle": "Planları karşılaştır ve seç", + "yearly": "Yıllık", + "save": "%{discount} tasarruf", + "monthly": "Aylık", + "priceIn": "Fiyat: ", + "free": "Ücretsiz", + "pro": "Pro", + "freeDescription": "2 üyeye kadar bireyler için her şeyi organize etmek için", + "proDescription": "Küçük ekipler için projeleri ve ekip bilgisini yönetmek için", + "proDuration": { + "monthly": "üye başına aylık\naylık faturalandırma", + "yearly": "üye başına aylık\nyıllık faturalandırma" + }, + "cancel": "Alt plana geç", + "changePlan": "Pro Plana yükselt", + "everythingInFree": "Ücretsiz plandaki her şey +", + "currentPlan": "Mevcut", + "freeDuration": "süresiz", + "freePoints": { + "first": "2 üyeye kadar 1 işbirliği çalışma alanı", + "second": "Sınırsız sayfa ve blok", + "three": "5 GB depolama", + "four": "Akıllı arama", + "five": "20 yapay zeka yanıtı", + "six": "Mobil uygulama", + "seven": "Gerçek zamanlı işbirliği" + }, + "proPoints": { + "first": "Sınırsız depolama", + "second": "10 çalışma alanı üyesine kadar", + "three": "Sınırsız yapay zeka yanıtı", + "four": "Sınırsız dosya yükleme", + "five": "Özel alan adı" + }, + "cancelPlan": { + "title": "Gitmenize üzüldük", + "success": "Aboneliğiniz başarıyla iptal edildi", + "description": "Gitmenize üzüldük. AppFlowy'yi geliştirmemize yardımcı olmak için geri bildiriminizi almak isteriz. Lütfen birkaç soruyu yanıtlamak için zaman ayırın.", + "commonOther": "Diğer", + "otherHint": "Yanıtınızı buraya yazın", + "questionOne": { + "question": "AppFlowy Pro aboneliğinizi iptal etmenize ne sebep oldu?", + "answerOne": "Maliyet çok yüksek", + "answerTwo": "Özellikler beklentileri karşılamadı", + "answerThree": "Daha iyi bir alternatif buldum", + "answerFour": "Maliyeti karşılayacak kadar kullanmadım", + "answerFive": "Hizmet sorunu veya teknik zorluklar" + }, + "questionTwo": { + "question": "Gelecekte AppFlowy Pro'ya yeniden abone olma olasılığınız nedir?", + "answerOne": "Çok muhtemel", + "answerTwo": "Biraz muhtemel", + "answerThree": "Emin değilim", + "answerFour": "Muhtemel değil", + "answerFive": "Hiç muhtemel değil" + }, + "questionThree": { + "question": "Aboneliğiniz sırasında en çok hangi Pro özelliğine değer verdiniz?", + "answerOne": "Çoklu kullanıcı işbirliği", + "answerTwo": "Daha uzun süreli versiyon geçmişi", + "answerThree": "Sınırsız yapay zeka yanıtları", + "answerFour": "Yerel yapay zeka modellerine erişim" + }, + "questionFour": { + "question": "AppFlowy ile genel deneyiminizi nasıl tanımlarsınız?", + "answerOne": "Harika", + "answerTwo": "İyi", + "answerThree": "Ortalama", + "answerFour": "Ortalamanın altında", + "answerFive": "Memnun değilim" + } + } + }, + "ai": { + "contentPolicyViolation": "Hassas içerik nedeniyle görsel oluşturma başarısız oldu. Lütfen girdinizi yeniden düzenleyip tekrar deneyin" } } diff --git a/frontend/resources/translations/uk-UA.json b/frontend/resources/translations/uk-UA.json index 3c36875f96..394801ed21 100644 --- a/frontend/resources/translations/uk-UA.json +++ b/frontend/resources/translations/uk-UA.json @@ -2,12 +2,14 @@ "appName": "AppFlowy", "defaultUsername": "Я", "welcomeText": "Ласкаво просимо в @:appName", + "welcomeTo": "Ласкаво просимо до", "githubStarText": "Поставити зірку на GitHub", "subscribeNewsletterText": "Підпишіться на розсилку новин", "letsGoButtonText": "Почнемо", "title": "Заголовок", "youCanAlso": "Ви також можете", "and": "та", + "failedToOpenUrl": "Не вдалося відкрити URL-адресу: {}", "blockActions": { "addBelowTooltip": "Клацніть, щоб додати нижче", "addAboveCmd": "Alt+click", @@ -34,39 +36,92 @@ "loginButtonText": "Увійти", "loginStartWithAnonymous": "Почати анонімну сесію", "continueAnonymousUser": "Продовжити анонімну сесію", + "anonymous": "Анонім", "buttonText": "Увійти", + "signingInText": "Вхід...", "forgotPassword": "Забули пароль?", "emailHint": "Електронна пошта", "passwordHint": "Пароль", "dontHaveAnAccount": "Немаєте облікового запису?", + "createAccount": "Створити акаунт", "repeatPasswordEmptyError": "Повторний пароль не може бути порожнім", "unmatchedPasswordError": "Повторний пароль не співпадає з паролем", "syncPromptMessage": "Синхронізація даних може зайняти трохи часу. Будь ласка, не закривайте цю сторінку", "or": "АБО", + "signInWithGoogle": "Продовжуйте з Google", + "signInWithGithub": "Продовжуйте з Github", + "signInWithDiscord": "Продовжуйте з Discord", + "signInWithApple": "Продовжуйте з Apple", + "continueAnotherWay": "Продовжуйте іншим шляхом", + "signUpWithGoogle": "Зареєструватися в Google", + "signUpWithGithub": "Зареєструйтеся на Github", + "signUpWithDiscord": "Зареєструйтеся в Discord", + "signInWith": "Увійти за допомогою:", + "signInWithEmail": "Продовжити з електронною поштою", + "signInWithMagicLink": "Продовжити", + "signUpWithMagicLink": "Зареєструйтеся за допомогою Magic Link", + "pleaseInputYourEmail": "Будь ласка, введіть адресу електронної пошти", + "settings": "Налаштування", + "magicLinkSent": "Magic Link надіслано!", + "invalidEmail": "Будь ласка, введіть дійсну адресу електронної пошти", + "alreadyHaveAnAccount": "Вже є акаунт?", + "logIn": "Авторизуватися", + "generalError": "Щось пішло не так. Будь ласка спробуйте пізніше", + "limitRateError": "З міркувань безпеки ви можете запитувати чарівне посилання лише кожні 60 секунд", + "magicLinkSentDescription": "Чарівне посилання надіслано на вашу електронну адресу. Натисніть посилання, щоб завершити вхід. Термін дії посилання закінчиться через 5 хвилин.", "LogInWithGoogle": "Увійти за допомогою Google", "LogInWithGithub": "Увійти за допомогою Github", - "LogInWithDiscord": "Увійти за допомогою Discord", - "signInWith": "Увійти за допомогою:" + "LogInWithDiscord": "Увійти за допомогою Discord" }, "workspace": { "chooseWorkspace": "Виберіть свій робочий простір", "create": "Створити робочий простір", "reset": "Скинути робочий простір", + "renameWorkspace": "Перейменувати робочу область", "resetWorkspacePrompt": "Скидання робочого простору призведе до видалення всіх сторінок та даних у ньому. Ви впевнені, що хочете скинути робочий простір? Також ви можете звернутися до служби підтримки для відновлення робочого простору", "hint": "робочий простір", "notFoundError": "Робочий простір не знайдено", "failedToLoad": "Щось пішло не так! Не вдалося завантажити робочий простір. Спробуйте закрити будь-який відкритий екземпляр AppFlowy та спробуйте ще раз.", "errorActions": { "reportIssue": "Повідомити про проблему", + "reportIssueOnGithub": "Повідомити про проблему на Github", + "exportLogFiles": "Експорт файлів журналу", "reachOut": "Звернутися на Discord" - } + }, + "menuTitle": "Робочі області", + "deleteWorkspaceHintText": "Ви впевнені, що хочете видалити робочу область? Цю дію неможливо скасувати, і всі опубліковані вами сторінки буде скасовано.", + "createSuccess": "Робочу область успішно створено", + "createFailed": "Не вдалося створити робочу область", + "createLimitExceeded": "Ви досягли максимального ліміту робочого простору, дозволеного для вашого облікового запису. Якщо вам потрібні додаткові робочі області, щоб продовжити роботу, надішліть запит на Github", + "deleteSuccess": "Робочу область успішно видалено", + "deleteFailed": "Не вдалося видалити робочу область", + "openSuccess": "Робочу область відкрито", + "openFailed": "Не вдалося відкрити робочу область", + "renameSuccess": "Робочу область успішно перейменовано", + "renameFailed": "Не вдалося перейменувати робочу область", + "updateIconSuccess": "Значок робочої області успішно оновлено", + "updateIconFailed": "Не вдалося оновити значок робочої області", + "cannotDeleteTheOnlyWorkspace": "Неможливо видалити єдину робочу область", + "fetchWorkspacesFailed": "Не вдалося отримати робочі області", + "leaveCurrentWorkspace": "Залишити робочу область", + "leaveCurrentWorkspacePrompt": "Ви впевнені, що бажаєте залишити поточну робочу область?" }, "shareAction": { "buttonText": "Поділитися", "workInProgress": "Скоро буде доступно", "markdown": "Markdown", + "html": "HTML", + "clipboard": "Копіювати в буфер обміну", "csv": "CSV", - "copyLink": "Скопіювати посилання" + "copyLink": "Скопіювати посилання", + "publishToTheWeb": "Опублікувати в Інтернеті", + "publishToTheWebHint": "Створіть веб-сайт за допомогою AppFlowy", + "publish": "Опублікувати", + "unPublish": "Скасувати публікацію", + "visitSite": "Відвідайте сайт", + "exportAsTab": "Експортувати як", + "publishTab": "Опублікувати", + "shareTab": "Поділіться" }, "moreAction": { "small": "малий", @@ -74,7 +129,12 @@ "large": "великий", "fontSize": "Розмір шрифту", "import": "Імпортувати", - "moreOptions": "Більше опцій" + "moreOptions": "Більше опцій", + "wordCount": "Кількість слів: {}", + "charCount": "Кількість символів: {}", + "createdAt": "Створено: {}", + "deleteView": "Видалити", + "duplicateView": "Дублікат" }, "importPanel": { "textAndMarkdown": "Текст і Markdown", @@ -92,7 +152,9 @@ "openNewTab": "Відкрити в новій вкладці", "moveTo": "Перемістити в", "addToFavorites": "Додати до обраного", - "copyLink": "Скопіювати посилання" + "copyLink": "Скопіювати посилання", + "changeIcon": "Змінити значок", + "collapseAllPages": "Згорнути всі підсторінки" }, "blankPageTitle": "Порожня сторінка", "newPageText": "Нова сторінка", @@ -100,6 +162,34 @@ "newGridText": "Нова таблиця", "newCalendarText": "Новий календар", "newBoardText": "Нова дошка", + "chat": { + "newChat": "ШІ Чат", + "inputMessageHint": "Запитайте @:appName AI", + "inputLocalAIMessageHint": "Запитайте @:appName локальний AI", + "unsupportedCloudPrompt": "Ця функція доступна лише під час використання @:appName Cloud", + "relatedQuestion": "Пов'язані", + "serverUnavailable": "Сервіс тимчасово недоступний. Будь ласка спробуйте пізніше.", + "aiServerUnavailable": "🌈 Ой-ой! 🌈. Єдиноріг з'їв нашу відповідь. Будь ласка, повторіть спробу!", + "clickToRetry": "Натисніть, щоб повторити спробу", + "regenerateAnswer": "Регенерувати", + "question1": "Як використовувати Kanban для керування завданнями", + "question2": "Поясніть метод GTD", + "question3": "Навіщо використовувати Rust", + "question4": "Рецепт з тим, що є на моїй кухні", + "aiMistakePrompt": "ШІ може помилятися. Перевірте важливу інформацію.", + "chatWithFilePrompt": "Хочете поспілкуватися з файлом?", + "indexFileSuccess": "Файл успішно індексується", + "inputActionNoPages": "Сторінка не знайдена", + "referenceSource": { + "zero": "0 джерел знайдено", + "one": "{count} джерело знайдено", + "other": "{count} джерел знайдено" + }, + "clickToMention": "Натисніть, щоб згадати сторінку", + "uploadFile": "Завантажуйте PDF-файли, файли md або txt для спілкування в чаті", + "questionDetail": "Привіт {}! Чим я можу тобі допомогти сьогодні?", + "indexingFile": "Індексація {}" + }, "trash": { "text": "Смітник", "restoreAll": "Відновити все", @@ -116,7 +206,15 @@ "confirmRestoreAll": { "title": "Ви впевнені, що хочете відновити всі сторінки у кошику?", "caption": "Цю дію неможливо скасувати." - } + }, + "mobile": { + "actions": "Дії щодо сміття", + "empty": "Кошик порожній", + "emptyDescription": "У вас немає видалених файлів", + "isDeleted": "видалено", + "isRestored": "відновлено" + }, + "confirmDeleteTitle": "Ви впевнені, що хочете остаточно видалити цю сторінку?" }, "deletePagePrompt": { "text": "Ця сторінка знаходиться у кошику", @@ -127,14 +225,14 @@ "questionBubble": { "shortcuts": "Комбінації клавіш", "whatsNew": "Що нового?", - "help": "Довідка та підтримка", "markdown": "Markdown", "debug": { "name": "Інформація для налагодження", "success": "Інформацію для налагодження скопійовано в буфер обміну!", "fail": "Не вдалося скопіювати інформацію для налагодження в буфер обміну" }, - "feedback": "Зворотний зв'язок" + "feedback": "Зворотний зв'язок", + "help": "Довідка та підтримка" }, "menuAppHeader": { "moreButtonToolTip": "Видалити, перейменувати та інше...", @@ -142,6 +240,7 @@ "defaultNewPageName": "Без назви", "renameDialog": "Перейменувати" }, + "noPagesInside": "Всередині немає сторінок", "toolbar": { "undo": "Скасувати", "redo": "Повторити", @@ -169,16 +268,53 @@ "dragRow": "Тримайте натиснутим для зміни порядку рядка", "viewDataBase": "Переглянути базу даних", "referencePage": "Ця {name} знаходиться у зв'язку", - "addBlockBelow": "Додати блок нижче" + "addBlockBelow": "Додати блок нижче", + "aiGenerate": "Генерувати" }, "sideBar": { "closeSidebar": "Закрити бічну панель", "openSidebar": "Відкрити бічну панель", "personal": "Особисте", + "private": "Приватний", + "workspace": "Робоча область", "favorites": "Вибране", + "clickToHidePrivate": "Натисніть, щоб приховати особистий простір\nСторінки, які ви тут створили, бачите лише ви", + "clickToHideWorkspace": "Натисніть, щоб приховати робочу область\nСторінки, які ви тут створили, бачать усі учасники", "clickToHidePersonal": "Натисніть, щоб приховати особистий розділ", "clickToHideFavorites": "Натисніть, щоб приховати вибраний розділ", - "addAPage": "Додати сторінку" + "addAPage": "Додати сторінку", + "addAPageToPrivate": "Додайте сторінку до особистого простору", + "addAPageToWorkspace": "Додати сторінку до робочої області", + "recent": "Останні", + "today": "Сьогодні", + "thisWeek": "Цього тижня", + "others": "Раніше улюблені", + "justNow": "прямо зараз", + "minutesAgo": "{count} хвилин тому", + "lastViewed": "Останній перегляд", + "favoriteAt": "Вибране", + "emptyRecent": "Немає останніх документів", + "emptyRecentDescription": "Під час перегляду документів вони з’являтимуться тут, щоб їх було легко знайти", + "emptyFavorite": "Немає вибраних документів", + "emptyFavoriteDescription": "Почніть досліджувати та позначайте документи як вибрані. Вони будуть перераховані тут для швидкого доступу!", + "removePageFromRecent": "Видалити цю сторінку з останніх?", + "removeSuccess": "Успішно видалено", + "favoriteSpace": "Вибране", + "RecentSpace": "Останні", + "Spaces": "Пробіли", + "upgradeToPro": "Оновлення до Pro", + "upgradeToAIMax": "Розблокуйте необмежений ШІ", + "storageLimitDialogTitle": "У вас вичерпано безкоштовне сховище. Оновіть, щоб розблокувати необмежений обсяг пам’яті", + "aiResponseLimitTitle": "У вас закінчилися безкоштовні відповіді ШІ. Перейдіть до плану Pro або придбайте доповнення AI, щоб розблокувати необмежену кількість відповідей", + "aiResponseLimitDialogTitle": "Досягнуто ліміту відповідей ШІ", + "aiResponseLimit": "У вас закінчилися безкоштовні відповіді ШІ.\nПерейдіть до Налаштування -> План -> Натисніть AI Max або Pro Plan, щоб отримати більше відповідей AI", + "askOwnerToUpgradeToPro": "У вашому робочому просторі закінчується безкоштовна пам’ять. Попросіть свого власника робочого місця перейти на план Pro", + "askOwnerToUpgradeToAIMax": "У вашій робочій області закінчуються безкоштовні відповіді ШІ. Будь ласка, попросіть свого власника робочого простору оновити план або придбати додатки AI", + "purchaseStorageSpace": "Придбайте місце для зберігання", + "purchaseAIResponse": "Придбати", + "askOwnerToUpgradeToLocalAI": "Попросіть власника робочої області ввімкнути ШІ на пристрої", + "upgradeToAILocal": "Запустіть локальні моделі на своєму пристрої для повної конфіденційності", + "upgradeToAILocalDesc": "Спілкуйтеся в чаті з PDF-файлами, вдосконалюйте свій текст і автоматично заповнюйте таблиці за допомогою локального штучного інтелекту" }, "notifications": { "export": { @@ -193,7 +329,9 @@ "editContact": "Редагувати контакт" }, "button": { - "ok": "OK", + "ok": "Oк", + "confirm": "Підтвердити", + "done": "Готово", "cancel": "Скасувати", "signIn": "Увійти", "signOut": "Вийти", @@ -206,12 +344,44 @@ "discard": "Відхилити", "replace": "Замінити", "insertBelow": "Вставити нижче", + "insertAbove": "Вставте вище", "upload": "Завантажити", "edit": "Редагувати", "delete": "Видалити", "duplicate": "Дублювати", - "done": "Готово", - "putback": "Повернути" + "putback": "Повернути", + "update": "Оновлення", + "share": "Поділіться", + "removeFromFavorites": "Видалити з вибраного", + "removeFromRecent": "Вилучити з останніх", + "addToFavorites": "Додати в обране", + "favoriteSuccessfully": "Улюблений успіх", + "unfavoriteSuccessfully": "Неулюблений успіх", + "duplicateSuccessfully": "Продубльовано успішно", + "rename": "Перейменувати", + "helpCenter": "Центр допомоги", + "add": "Додати", + "yes": "Так", + "no": "Ні", + "clear": "Очистити", + "remove": "Видалити", + "dontRemove": "Не видаляйте", + "copyLink": "Копіювати посилання", + "align": "Вирівняти", + "login": "Логін", + "logout": "Вийти", + "deleteAccount": "Видалити аккаунт", + "back": "Назад", + "signInGoogle": "Продовжуйте з Google", + "signInGithub": "Продовжуйте з Github", + "signInDiscord": "Продовжуйте з Discord", + "more": "Більше", + "create": "Створити", + "close": "Закрити", + "next": "Далі", + "previous": "Попередній", + "submit": "Надіслати", + "download": "Завантажити" }, "label": { "welcome": "Ласкаво просимо!", @@ -235,31 +405,628 @@ }, "settings": { "title": "Налаштування", + "popupMenuItem": { + "settings": "Налаштування", + "members": "Члени", + "trash": "Cміття", + "helpAndSupport": "Допомога та підтримка" + }, + "accountPage": { + "menuLabel": "Мій рахунок", + "title": "Мій рахунок", + "general": { + "title": "Назва облікового запису та зображення профілю", + "changeProfilePicture": "Змінити зображення профілю" + }, + "email": { + "title": "Електронна пошта", + "actions": { + "change": "Змінити електронну адресу" + } + }, + "login": { + "title": "Вхід в обліковий запис", + "loginLabel": "Авторизуватися", + "logoutLabel": "Вийти" + } + }, + "workspacePage": { + "menuLabel": "Робоча область", + "title": "Робоча область", + "description": "Налаштуйте зовнішній вигляд робочої області, тему, шрифт, макет тексту, формат дати/часу та мову.", + "workspaceName": { + "title": "Назва робочої області" + }, + "workspaceIcon": { + "title": "Значок робочої області", + "description": "Завантажте зображення або використовуйте емодзі для свого робочого простору. Значок відображатиметься на бічній панелі та в сповіщеннях." + }, + "appearance": { + "title": "Зовнішній вигляд", + "description": "Налаштуйте зовнішній вигляд робочої області, тему, шрифт, макет тексту, дату, час і мову.", + "options": { + "system": "Авто", + "light": "Світлий", + "dark": "Темний" + } + }, + "resetCursorColor": { + "title": "Скинути колір курсора документа", + "description": "Ви впевнені, що хочете скинути колір курсору?" + }, + "resetSelectionColor": { + "title": "Скинути колір вибору документа", + "description": "Ви впевнені, що бажаєте скинути колір виділення?" + }, + "theme": { + "title": "Тема", + "description": "Виберіть попередньо встановлену тему або завантажте власну тему.", + "uploadCustomThemeTooltip": "Завантажте спеціальну тему" + }, + "workspaceFont": { + "title": "Шрифт робочої області", + "noFontHint": "Шрифт не знайдено, спробуйте інший термін." + }, + "textDirection": { + "title": "Напрямок тексту", + "leftToRight": "Зліва направо", + "rightToLeft": "Справа наліво", + "auto": "Авто", + "enableRTLItems": "Увімкнути елементи панелі інструментів RTL" + }, + "layoutDirection": { + "title": "Напрямок макета", + "leftToRight": "Зліва направо", + "rightToLeft": "Справа наліво" + }, + "dateTime": { + "title": "Дата, час", + "example": "{} у {} ({})", + "24HourTime": "24-годинний час", + "dateFormat": { + "label": "Формат дати", + "local": "Місцевий", + "us": "US", + "iso": "ISO", + "friendly": "дружній", + "dmy": "Д/М/Р" + } + }, + "language": { + "title": "Мова" + }, + "deleteWorkspacePrompt": { + "title": "Видалити робочу область", + "content": "Ви впевнені, що хочете видалити цю робочу область? Цю дію неможливо скасувати, і всі опубліковані вами сторінки буде скасовано." + }, + "leaveWorkspacePrompt": { + "title": "Залиште робочу область", + "content": "Ви впевнені, що бажаєте залишити цю робочу область? Ви втратите доступ до всіх сторінок і даних на них." + }, + "manageWorkspace": { + "title": "Керуйте робочим простором", + "leaveWorkspace": "Залиште робочу область", + "deleteWorkspace": "Видалити робочу область" + } + }, + "manageDataPage": { + "menuLabel": "Керувати даними", + "title": "Керувати даними", + "description": "Керуйте локальним сховищем даних або імпортуйте наявні дані в @:appName .", + "dataStorage": { + "title": "Місце зберігання файлів", + "tooltip": "Місце, де зберігаються ваші файли", + "actions": { + "change": "Змінити шлях", + "open": "Відкрити папку", + "openTooltip": "Відкрити поточне розташування папки даних", + "copy": "Копіювати шлях", + "copiedHint": "Шлях скопійовано!", + "resetTooltip": "Відновити розташування за умовчанням" + }, + "resetDialog": { + "title": "Ти впевнений?", + "description": "Скидання шляху до розташування даних за умовчанням не призведе до видалення даних. Якщо ви хочете повторно імпортувати поточні дані, вам слід спочатку скопіювати шлях вашого поточного місцезнаходження." + } + }, + "importData": { + "title": "Імпорт даних", + "tooltip": "Імпорт даних із резервних копій/папок даних @:appName", + "description": "Скопіюйте дані із зовнішньої папки даних @:appName", + "action": "Переглянути файл" + }, + "encryption": { + "title": "Шифрування", + "tooltip": "Керуйте тим, як ваші дані зберігаються та шифруються", + "descriptionNoEncryption": "Якщо ввімкнути шифрування, усі дані будуть зашифровані. Це не може бути скасоване.", + "descriptionEncrypted": "Ваші дані зашифровані.", + "action": "Шифрувати дані", + "dialog": { + "title": "Зашифрувати всі ваші дані?", + "description": "Шифрування всіх ваших даних збереже ваші дані в безпеці. Цю дію НЕ можна скасувати. Ви впевнені, що бажаєте продовжити?" + } + }, + "cache": { + "title": "Очистити кеш", + "description": "Допоможіть вирішити такі проблеми, як не завантаження зображення, відсутність сторінок у просторі та не завантаження шрифтів. Це не вплине на ваші дані.", + "dialog": { + "title": "Очистити кеш", + "description": "Допоможіть вирішити такі проблеми, як не завантаження зображення, відсутність сторінок у просторі та не завантаження шрифтів. Це не вплине на ваші дані.", + "successHint": "Кеш очищено!" + } + }, + "data": { + "fixYourData": "Виправте свої дані", + "fixButton": "Виправити", + "fixYourDataDescription": "Якщо у вас виникли проблеми з даними, ви можете спробувати їх виправити тут." + } + }, + "shortcutsPage": { + "menuLabel": "Ярлики", + "title": "Ярлики", + "editBindingHint": "Введіть нову прив'язку", + "searchHint": "Пошук", + "actions": { + "resetDefault": "Скинути за замовчуванням" + }, + "errorPage": { + "message": "Не вдалося завантажити ярлики: {}", + "howToFix": "Спробуйте ще раз. Якщо проблема не зникне, зв’яжіться з GitHub." + }, + "resetDialog": { + "title": "Скинути ярлики", + "description": "Це скине всі ваші прив’язки клавіш до стандартних, ви не зможете скасувати це пізніше. Ви впевнені, що бажаєте продовжити?", + "buttonLabel": "Скинути" + }, + "conflictDialog": { + "title": "{} зараз використовується", + "descriptionPrefix": "Цю комбінацію клавіш зараз використовує ", + "descriptionSuffix": ". Якщо ви заміните цю комбінацію клавіш, її буде видалено з {}.", + "confirmLabel": "Продовжити" + }, + "editTooltip": "Натисніть, щоб почати редагування сполучення клавіш", + "keybindings": { + "toggleToDoList": "Перемкнути список справ", + "insertNewParagraphInCodeblock": "Вставте новий абзац", + "pasteInCodeblock": "Вставте кодовий блок", + "selectAllCodeblock": "Вибрати все", + "indentLineCodeblock": "Вставте два пропуски на початку рядка", + "outdentLineCodeblock": "Видалити два пропуски на початку рядка", + "twoSpacesCursorCodeblock": "Вставте два пробіли біля курсору", + "copy": "Вибір копії", + "paste": "Вставте вміст", + "cut": "Вирізати виділення", + "alignLeft": "Вирівняти текст по лівому краю", + "alignCenter": "Вирівняти текст по центру", + "alignRight": "Вирівняти текст праворуч", + "undo": "Скасувати", + "redo": "Повторити", + "convertToParagraph": "Перетворити блок на абзац", + "backspace": "Видалити", + "deleteLeftWord": "Видалити ліве слово", + "deleteLeftSentence": "Видалити ліве речення", + "delete": "Видалити правильний символ", + "deleteMacOS": "Видалити лівий символ", + "deleteRightWord": "Видалити правильне слово", + "moveCursorLeft": "Перемістити курсор ліворуч", + "moveCursorBeginning": "Перемістіть курсор на початок", + "moveCursorLeftWord": "Перемістити курсор на одне слово вліво", + "moveCursorLeftSelect": "Виберіть і перемістіть курсор ліворуч", + "moveCursorBeginSelect": "Виберіть і перемістіть курсор на початок", + "moveCursorLeftWordSelect": "Виберіть і перемістіть курсор ліворуч на одне слово", + "moveCursorRight": "Перемістіть курсор праворуч", + "moveCursorEnd": "Перемістіть курсор до кінця", + "moveCursorRightWord": "Перемістіть курсор на одне слово вправо", + "moveCursorRightSelect": "Виберіть і перемістіть курсор праворуч", + "moveCursorEndSelect": "Виберіть і перемістіть курсор до кінця", + "moveCursorRightWordSelect": "Виділіть і перемістіть курсор на одне слово вправо", + "moveCursorUp": "Перемістіть курсор вгору", + "moveCursorTopSelect": "Виберіть і перемістіть курсор угору", + "moveCursorTop": "Перемістіть курсор вгору", + "moveCursorUpSelect": "Виберіть і перемістіть курсор вгору", + "moveCursorBottomSelect": "Виберіть і перемістіть курсор униз", + "moveCursorBottom": "Перемістіть курсор униз", + "moveCursorDown": "Перемістіть курсор вниз", + "moveCursorDownSelect": "Виберіть і перемістіть курсор вниз", + "home": "Прокрутіть до верху", + "end": "Прокрутіть донизу", + "toggleBold": "Переключити жирний шрифт", + "toggleItalic": "Перемкнути курсив", + "toggleUnderline": "Перемкнути підкреслення", + "toggleStrikethrough": "Переключити закреслене", + "toggleCode": "Перемкнути вбудований код", + "toggleHighlight": "Перемкнути виділення", + "showLinkMenu": "Показати меню посилань", + "openInlineLink": "Відкрити вбудоване посилання", + "openLinks": "Відкрити всі вибрані посилання", + "indent": "Відступ", + "outdent": "Відступ", + "exit": "Вийти з редагування", + "pageUp": "Прокрутіть одну сторінку вгору", + "pageDown": "Прокрутіть одну сторінку вниз", + "selectAll": "Вибрати все", + "pasteWithoutFormatting": "Вставте вміст без форматування", + "showEmojiPicker": "Показати засіб вибору емодзі", + "enterInTableCell": "Додати розрив рядка в таблицю", + "leftInTableCell": "Перемістити таблицю на одну клітинку вліво", + "rightInTableCell": "Перемістити таблицю на одну клітинку праворуч", + "upInTableCell": "Перейти на одну клітинку вгору в таблиці", + "downInTableCell": "Перемістіть таблицю на одну клітинку вниз", + "tabInTableCell": "Перейти до наступної доступної клітинки в таблиці", + "shiftTabInTableCell": "Перейти до попередньо доступної клітинки в таблиці", + "backSpaceInTableCell": "Зупинка на початку клітини" + }, + "commands": { + "codeBlockNewParagraph": "Вставте новий абзац поруч із блоком коду", + "codeBlockIndentLines": "Вставте два пробіли на початку рядка в блоці коду", + "codeBlockOutdentLines": "Видалити два пробіли на початку рядка в блоці коду", + "codeBlockAddTwoSpaces": "Вставте два пробіли в позиції курсора в блоці коду", + "codeBlockSelectAll": "Виберіть весь вміст у блоці коду", + "codeBlockPasteText": "Вставте текст у кодовий блок", + "textAlignLeft": "Вирівняти текст по лівому краю", + "textAlignCenter": "Вирівняти текст по центру", + "textAlignRight": "Вирівняти текст праворуч" + }, + "couldNotLoadErrorMsg": "Не вдалося завантажити ярлики. Повторіть спробу", + "couldNotSaveErrorMsg": "Не вдалося зберегти ярлики. Повторіть спробу" + }, + "aiPage": { + "title": "Налаштування AI", + "menuLabel": "Налаштування AI", + "keys": { + "enableAISearchTitle": "AI Пошук", + "aiSettingsDescription": "Виберіть або налаштуйте моделі ШІ, які використовуються в @:appName . Для найкращої продуктивності ми рекомендуємо використовувати параметри моделі за замовчуванням", + "loginToEnableAIFeature": "Функції ШІ вмикаються лише після входу в @:appName Cloud. Якщо у вас немає облікового запису @:appName , перейдіть до «Мого облікового запису», щоб зареєструватися", + "llmModel": "Модель мови", + "llmModelType": "Тип мовної моделі", + "downloadLLMPrompt": "Завантажити {}", + "downloadAppFlowyOfflineAI": "Завантаження офлайн-пакету AI дозволить працювати AI на вашому пристрої. Ви хочете продовжити?", + "downloadLLMPromptDetail": "Завантаження {} локальної моделі займе до {} пам’яті. Ви хочете продовжити?", + "downloadBigFilePrompt": "Завантаження може зайняти близько 10 хвилин", + "downloadAIModelButton": "Завантажити", + "downloadingModel": "Завантаження", + "localAILoaded": "Локальну модель AI успішно додано та готово до використання", + "localAIStart": "Розпочинається локальний AI Chat...", + "localAILoading": "Локальна модель чату ШІ завантажується...", + "localAIStopped": "Місцевий ШІ зупинився", + "failToLoadLocalAI": "Не вдалося запустити локальний ШІ", + "restartLocalAI": "Перезапустіть локальний AI", + "disableLocalAITitle": "Вимкнути локальний ШІ", + "disableLocalAIDescription": "Ви бажаєте вимкнути локальний ШІ?", + "localAIToggleTitle": "Перемикач, щоб увімкнути або вимкнути локальний ШІ", + "offlineAIInstruction1": "Дотримуйтесь", + "offlineAIInstruction2": "інструкція", + "offlineAIInstruction3": "увімкнути автономний AI.", + "offlineAIDownload1": "Якщо ви не завантажили AppFlowy AI, будь ласка", + "offlineAIDownload2": "завантажити", + "offlineAIDownload3": "це перше", + "activeOfflineAI": "Активний", + "downloadOfflineAI": "Завантажити", + "openModelDirectory": "Відкрити папку" + } + }, + "planPage": { + "menuLabel": "План", + "title": "Ціновий план", + "planUsage": { + "title": "Підсумок використання плану", + "storageLabel": "Зберігання", + "storageUsage": "{} із {} Гб", + "unlimitedStorageLabel": "Необмежене зберігання", + "collaboratorsLabel": "Члени", + "collaboratorsUsage": "{} з {}", + "aiResponseLabel": "Відповіді ШІ", + "aiResponseUsage": "{} з {}", + "unlimitedAILabel": "Необмежена кількість відповідей", + "proBadge": "Pro", + "aiMaxBadge": "AI Макс", + "aiOnDeviceBadge": "ШІ на пристрої для Mac", + "memberProToggle": "Більше учасників і необмежений ШІ", + "aiMaxToggle": "Необмежений ШІ та доступ до вдосконалених моделей", + "aiOnDeviceToggle": "Локальний штучний інтелект для повної конфіденційності", + "aiCredit": { + "title": "Додати кредит @:appName AI", + "price": "{}", + "priceDescription": "за 1000 кредитів", + "purchase": "Придбайте ШІ", + "info": "Додайте 1000 кредитів штучного інтелекту на робочий простір і плавно інтегруйте настроюваний штучний інтелект у свій робочий процес, щоб отримати розумніші та швидші результати з до:", + "infoItemOne": "10 000 відповідей на базу даних", + "infoItemTwo": "1000 відповідей на робочу область" + }, + "currentPlan": { + "bannerLabel": "Поточний план", + "freeTitle": "Безкоштовно", + "proTitle": "Pro", + "teamTitle": "Команда", + "freeInfo": "Ідеально підходить для окремих осіб до 2 учасників, щоб організувати все", + "proInfo": "Ідеально підходить для малих і середніх команд до 10 учасників.", + "teamInfo": "Ідеально підходить для всіх продуктивних і добре організованих команд.", + "upgrade": "Змінити план", + "canceledInfo": "Ваш план скасовано, вас буде переведено на безкоштовний план {}." + }, + "addons": { + "title": "Додатки", + "addLabel": "Додати", + "activeLabel": "Додано", + "aiMax": { + "title": "AI Макс", + "description": "Необмежені відповіді AI на основі GPT-4o, Claude 3.5 Sonnet тощо", + "price": "{}", + "priceInfo": "на користувача на місяць", + "billingInfo": "рахунок виставляється щорічно або {} щомісяця" + }, + "aiOnDevice": { + "title": "ШІ на пристрої для Mac", + "description": "Запустіть Mistral 7B, LLAMA 3 та інші локальні моделі на вашому комп’ютері", + "price": "{}", + "priceInfo": "Оплата за користувача на місяць виставляється щорічно", + "recommend": "Рекомендовано M1 або новіше", + "billingInfo": "рахунок виставляється щорічно або {} щомісяця" + } + }, + "deal": { + "bannerLabel": "Новорічна угода!", + "title": "Розвивайте свою команду!", + "info": "Оновіть та заощаджуйте 10% на планах Pro та Team! Підвищте продуктивність робочого простору за допомогою нових потужних функцій, зокрема @:appName .", + "viewPlans": "Переглянути плани" + } + } + }, + "billingPage": { + "menuLabel": "Виставлення рахунків", + "title": "Виставлення рахунків", + "plan": { + "title": "План", + "freeLabel": "Безкоштовно", + "proLabel": "Pro", + "planButtonLabel": "Змінити план", + "billingPeriod": "Розрахунковий період", + "periodButtonLabel": "Період редагування" + }, + "paymentDetails": { + "title": "Платіжні реквізити", + "methodLabel": "Спосіб оплати", + "methodButtonLabel": "Метод редагування" + }, + "addons": { + "title": "Додатки", + "addLabel": "Додати", + "removeLabel": "Видалити", + "renewLabel": "Відновити", + "aiMax": { + "label": "AI Макс", + "description": "Розблокуйте необмежену кількість штучного інтелекту та вдосконалених моделей", + "activeDescription": "Наступний рахунок-фактура має бути виставлено {}", + "canceledDescription": "AI Max буде доступний до {}" + }, + "aiOnDevice": { + "label": "ШІ на пристрої для Mac", + "description": "Розблокуйте необмежений ШІ офлайн на своєму пристрої", + "activeDescription": "Наступний рахунок-фактура має бути виставлено {}", + "canceledDescription": "AI On-device для Mac буде доступний до {}" + }, + "removeDialog": { + "title": "Видалити {}", + "description": "Ви впевнені, що хочете видалити {plan}? Ви негайно втратите доступ до функцій і переваг {plan}." + } + }, + "currentPeriodBadge": "ПОТОЧНИЙ", + "changePeriod": "Період зміни", + "planPeriod": "{} період", + "monthlyInterval": "Щомісяця", + "monthlyPriceInfo": "за місце сплачується щомісяця", + "annualInterval": "Щорічно", + "annualPriceInfo": "за місце, що виставляється щорічно" + }, + "comparePlanDialog": { + "title": "Порівняйте та виберіть план", + "planFeatures": "План\nособливості", + "current": "Поточний", + "actions": { + "upgrade": "Оновлення", + "downgrade": "Понизити", + "current": "Поточний" + }, + "freePlan": { + "title": "Безкоштовно", + "description": "Для окремих осіб до 2 учасників, щоб організувати все", + "price": "{}", + "priceInfo": "Безплатний назавжди" + }, + "proPlan": { + "title": "Pro", + "description": "Для невеликих команд для управління проектами та знаннями команди", + "price": "{}", + "priceInfo": "на користувача на місяць\nвиставляється щорічно\n{} виставляється щомісяця" + }, + "planLabels": { + "itemOne": "Робочі області", + "itemTwo": "Члени", + "itemThree": "Зберігання", + "itemFour": "Співпраця в реальному часі", + "itemFive": "Мобільний додаток", + "itemSix": "Відповіді ШІ", + "itemSeven": "Спеціальний простір імен", + "itemFileUpload": "Завантаження файлів", + "tooltipSix": "Термін служби означає кількість відповідей, які ніколи не скидаються", + "intelligentSearch": "Інтелектуальний пошук", + "tooltipSeven": "Дозволяє налаштувати частину URL-адреси для вашої робочої області" + }, + "freeLabels": { + "itemOne": "сплачується за робоче місце", + "itemTwo": "До 2", + "itemThree": "5 ГБ", + "itemFour": "так", + "itemFive": "так", + "itemSix": "10 років життя", + "itemFileUpload": "До 7 Мб", + "intelligentSearch": "Інтелектуальний пошук" + }, + "proLabels": { + "itemOne": "Оплата за робоче місце", + "itemTwo": "До 10", + "itemThree": "Необмежений", + "itemFour": "так", + "itemFive": "так", + "itemSix": "Необмежений", + "itemFileUpload": "Необмежений", + "intelligentSearch": "Інтелектуальний пошук" + }, + "paymentSuccess": { + "title": "Тепер ви на плані {}!", + "description": "Ваш платіж успішно оброблено, і ваш план оновлено до @:appName {}. Ви можете переглянути деталі свого плану на сторінці План" + }, + "downgradeDialog": { + "title": "Ви впевнені, що хочете понизити свій план?", + "description": "Пониження вашого плану призведе до повернення до безкоштовного плану. Учасники можуть втратити доступ до цього робочого простору, і вам може знадобитися звільнити місце, щоб досягти лімітів пам’яті безкоштовного плану.", + "downgradeLabel": "Пониження плану" + } + }, + "cancelSurveyDialog": { + "title": "Шкода, що ви йдете", + "description": "Нам шкода, що ви йдете. Ми будемо раді почути ваш відгук, щоб допомогти нам покращити @:appName . Знайдіть хвилинку, щоб відповісти на кілька запитань.", + "commonOther": "Інший", + "otherHint": "Напишіть свою відповідь тут", + "questionOne": { + "question": "Що спонукало вас скасувати підписку на AppFlowy Pro?", + "answerOne": "Зависока вартість", + "answerTwo": "Характеристики не виправдали очікувань", + "answerThree": "Знайшов кращу альтернативу", + "answerFour": "Використовував недостатньо, щоб виправдати витрати", + "answerFive": "Проблема з обслуговуванням або технічні проблеми" + }, + "questionTwo": { + "question": "Наскільки ймовірно, що ви подумаєте про повторну підписку на AppFlowy Pro у майбутньому?", + "answerOne": "Ймовірно", + "answerTwo": "Певною мірою ймовірно", + "answerThree": "Не впевнений", + "answerFour": "Малоймовірно", + "answerFive": "Дуже малоймовірно" + }, + "questionThree": { + "question": "Яку функцію Pro ви цінували найбільше під час підписки?", + "answerOne": "Багатокористувацька співпраця", + "answerTwo": "Довша історія версій", + "answerThree": "Необмежена кількість відповідей ШІ", + "answerFour": "Доступ до локальних моделей ШІ" + }, + "questionFour": { + "question": "Як би ви описали свій загальний досвід роботи з AppFlowy?", + "answerOne": "Чудово", + "answerTwo": "Добре", + "answerThree": "Середній", + "answerFour": "Нижче середнього", + "answerFive": "Незадоволений" + } + }, + "common": { + "reset": "Скинути" + }, "menu": { "appearance": "Вигляд", "language": "Мова", "user": "Користувач", "files": "Файли", + "notifications": "Сповіщення", "open": "Відкрити налаштування", "logout": "Вийти", "logoutPrompt": "Ви впевнені, що хочете вийти?", "selfEncryptionLogoutPrompt": "Ви впевнені, що хочете вийти? Будь ласка, переконайтеся, що ви скопіювали секрет шифрування", "syncSetting": "Налаштування синхронізації", + "cloudSettings": "Налаштування хмари", "enableSync": "Увімкнути синхронізацію", "enableEncrypt": "Шифрувати дані", + "cloudURL": "Базовий URL", + "invalidCloudURLScheme": "Недійсна схема", + "cloudServerType": "Хмарний сервер", + "cloudServerTypeTip": "Зауважте, що після перемикання хмарного сервера ваш поточний обліковий запис може вийти з системи", + "cloudLocal": "Місцевий", + "cloudAppFlowy": "@:appName Cloud Beta", + "cloudAppFlowySelfHost": "@:appName Cloud на власному сервері", + "appFlowyCloudUrlCanNotBeEmpty": "URL-адреса хмари не може бути пустою", + "clickToCopy": "Натисніть, щоб скопіювати", + "selfHostStart": "Якщо у вас немає сервера, зверніться до", + "selfHostContent": "документ", + "selfHostEnd": "для вказівок щодо самостійного розміщення власного сервера", + "cloudURLHint": "Введіть базову URL-адресу вашого сервера", + "cloudWSURL": "URL-адреса Websocket", + "cloudWSURLHint": "Введіть адресу веб-сокета вашого сервера", + "restartApp": "Перезапустіть", + "restartAppTip": "Перезапустіть програму, щоб зміни набули чинності. Зауважте, що це може привести до виходу з вашого поточного облікового запису.", + "changeServerTip": "Після зміни сервера необхідно натиснути кнопку перезавантаження, щоб зміни набули чинності", "enableEncryptPrompt": "Активуйте шифрування для захисту ваших даних за допомогою цього секретного ключа. Зберігайте його надійно; після увімкнення його неможливо вимкнути. Якщо втрачено, ваші дані стають недоступними. Клацніть, щоб скопіювати", "inputEncryptPrompt": "Будь ласка, введіть свій секрет для шифрування для", "clickToCopySecret": "Клацніть, щоб скопіювати секрет", + "configServerSetting": "Налаштуйте параметри свого сервера", + "configServerGuide": "Вибравши `Швидкий старт`, перейдіть до `Налаштування`, а потім до «Налаштування хмари», щоб налаштувати свій автономний сервер.", "inputTextFieldHint": "Ваш секрет", "historicalUserList": "Історія входу користувача", "historicalUserListTooltip": "У цьому списку відображаються ваші анонімні облікові записи. Ви можете клацнути на обліковий запис, щоб переглянути його деталі. Анонімні облікові записи створюються, натискаючи кнопку 'Розпочати роботу'", - "openHistoricalUser": "Клацніть, щоб відкрити анонімний обліковий запис" + "openHistoricalUser": "Клацніть, щоб відкрити анонімний обліковий запис", + "customPathPrompt": "Зберігання папки даних @:appName у папці, що синхронізується з хмарою, як-от Google Drive, може становити ризик. Якщо база даних у цій папці доступна або змінена з кількох місць одночасно, це може призвести до конфліктів синхронізації та можливого пошкодження даних", + "importAppFlowyData": "Імпорт даних із зовнішньої @:appName", + "importingAppFlowyDataTip": "Виконується імпорт даних. Будь ласка, не закривайте додаток", + "importAppFlowyDataDescription": "Скопіюйте дані із зовнішньої папки @:appName та імпортуйте їх у поточну папку даних AppFlowy", + "importSuccess": "Папку даних @:appName успішно імпортовано", + "importFailed": "Не вдалося імпортувати папку даних @:appName", + "importGuide": "Щоб отримати додаткові відомості, перегляньте документ, на який посилається" + }, + "notifications": { + "enableNotifications": { + "label": "Увімкнути сповіщення", + "hint": "Вимкніть, щоб локальні сповіщення не з’являлися." + }, + "showNotificationsIcon": { + "label": "Показати значок сповіщень", + "hint": "Вимкніть, щоб приховати піктограму сповіщень на бічній панелі." + }, + "archiveNotifications": { + "allSuccess": "Усі сповіщення успішно заархівовано", + "success": "Сповіщення успішно заархівовано" + }, + "markAsReadNotifications": { + "allSuccess": "Усі позначені як успішно прочитані", + "success": "Позначено як успішно прочитане" + }, + "action": { + "markAsRead": "Відзначити як прочитане", + "multipleChoice": "Виберіть більше", + "archive": "Архів" + }, + "settings": { + "settings": "Налаштування", + "markAllAsRead": "Позначити все як прочитане", + "archiveAll": "Архівувати все" + }, + "emptyInbox": { + "title": "Ще немає сповіщень", + "description": "Тут ви отримуватимете сповіщення про @згадки" + }, + "emptyUnread": { + "title": "Немає непрочитаних сповіщень", + "description": "Ви все наздогнали!" + }, + "emptyArchived": { + "title": "Немає архівованих сповіщень", + "description": "Ви ще не заархівували жодного сповіщення" + }, + "tabs": { + "inbox": "Вхідні", + "unread": "Непрочитаний", + "archived": "Архівовано" + }, + "refreshSuccess": "Сповіщення успішно оновлено", + "titles": { + "notifications": "Сповіщення", + "reminder": "Нагадування" + } }, "appearance": { "resetSetting": "Скинути це налаштування", "fontFamily": { "label": "Шрифт", - "search": "Пошук" + "search": "Пошук", + "defaultFont": "Система" }, "themeMode": { "label": "Режим теми", @@ -267,6 +1034,22 @@ "dark": "Темний режим", "system": "Системна" }, + "fontScaleFactor": "Коефіцієнт масштабування шрифту", + "documentSettings": { + "cursorColor": "Колір курсору документа", + "selectionColor": "Колір виділення документа", + "pickColor": "Виберіть колір", + "colorShade": "Колірний відтінок", + "opacity": "Непрозорість", + "hexEmptyError": "Шістнадцяткове поле кольору не може бути порожнім", + "hexLengthError": "Шістнадцяткове значення має містити 6 цифр", + "hexInvalidError": "Недійсне шістнадцяткове значення", + "opacityEmptyError": "Непрозорість не може бути пустою", + "opacityRangeError": "Непрозорість має бути від 1 до 100", + "app": "Додаток", + "flowy": "Текучий", + "apply": "Застосувати" + }, "layoutDirection": { "label": "Напрямок макету", "hint": "Контролюйте напрямок контенту на вашому екрані, зліва направо або справа наліво.", @@ -284,13 +1067,13 @@ "themeUpload": { "button": "Завантажити", "uploadTheme": "Завантажити тему", - "description": "Завантажте свою власну тему AppFlowy, скориставшись кнопкою нижче.", - "failure": "Тему, яка була завантажена, має неправильний формат.", + "description": "Завантажте свою власну тему @:appName, скориставшись кнопкою нижче.", "loading": "Будь ласка, зачекайте, поки ми перевіряємо та завантажуємо вашу тему...", "uploadSuccess": "Вашу тему успішно завантажено", "deletionFailure": "Не вдалося видалити тему. Спробуйте видалити її вручну.", "filePickerDialogTitle": "Виберіть файл .flowy_plugin", - "urlUploadFailure": "Не вдалося відкрити URL: {}" + "urlUploadFailure": "Не вдалося відкрити URL: {}", + "failure": "Тему, яка була завантажена, має неправильний формат." }, "theme": "Тема", "builtInsLabel": "Вбудовані теми", @@ -298,7 +1081,7 @@ "dateFormat": { "label": "Формат дати", "local": "Локальний", - "us": "США", + "us": "US", "iso": "ISO", "friendly": "Дружній", "dmy": "Д/М/Р" @@ -308,14 +1091,56 @@ "twelveHour": "Дванадцятигодинний", "twentyFourHour": "Двадцять чотири години" }, - "showNamingDialogWhenCreatingPage": "Показувати діалогове вікно імені при створенні сторінки" + "showNamingDialogWhenCreatingPage": "Показувати діалогове вікно імені при створенні сторінки", + "enableRTLToolbarItems": "Увімкнути елементи панелі інструментів RTL", + "members": { + "title": "Налаштування учасників", + "inviteMembers": "Запросити учасників", + "inviteHint": "Запросити електронною поштою", + "sendInvite": "Надіслати запрошення", + "copyInviteLink": "Копіювати посилання для запрошення", + "label": "Члени", + "user": "Користувач", + "role": "Роль", + "removeFromWorkspace": "Видалити з робочої області", + "removeFromWorkspaceSuccess": "Успішно видалено з робочої області", + "removeFromWorkspaceFailed": "Не вдалося видалити з робочої області", + "owner": "Власник", + "guest": "Гість", + "member": "Член", + "memberHintText": "Учасник може читати та редагувати сторінки", + "guestHintText": "Гість може читати, реагувати, коментувати та редагувати певні сторінки з дозволу.", + "emailInvalidError": "Недійсна електронна адреса, будь ласка, перевірте та повторіть спробу", + "emailSent": "Електронний лист надіслано, перевірте папку \"Вхідні\".", + "members": "членів", + "membersCount": { + "zero": "{} учасників", + "one": "{} член", + "other": "{} учасників" + }, + "inviteFailedDialogTitle": "Не вдалося надіслати запрошення", + "inviteFailedMemberLimit": "Досягнуто ліміту учасників. Оновіть його, щоб запросити більше учасників.", + "inviteFailedMemberLimitMobile": "Ваша робоча область досягла ліміту учасників. Оновіть комп’ютер, щоб розблокувати більше функцій.", + "memberLimitExceeded": "Досягнуто обмеження кількості учасників, будь ласка, запросіть більше учасників ", + "memberLimitExceededUpgrade": "оновлення", + "memberLimitExceededPro": "Досягнуто ліміту учасників, якщо вам потрібно більше учасників, зв’яжіться ", + "memberLimitExceededProContact": "support@appflowy.io", + "failedToAddMember": "Не вдалося додати учасника", + "addMemberSuccess": "Учасника успішно додано", + "removeMember": "Видалити учасника", + "areYouSureToRemoveMember": "Ви впевнені, що хочете видалити цього учасника?", + "inviteMemberSuccess": "Запрошення успішно надіслано", + "failedToInviteMember": "Не вдалося запросити учасника", + "workspaceMembersError": "На жаль, сталася помилка", + "workspaceMembersErrorDescription": "Не вдалося завантажити список учасників. Спробуйте пізніше" + } }, "files": { "copy": "Копіювати", "defaultLocation": "Де зараз зберігаються ваші дані", "exportData": "Експортуйте свої дані", "doubleTapToCopy": "Подвійний натиск для копіювання шляху", - "restoreLocation": "Відновити до шляху за замовчуванням AppFlowy", + "restoreLocation": "Відновити до шляху за замовчуванням @:appName", "customizeLocation": "Відкрити іншу папку", "restartApp": "Будь ласка, перезапустіть програму для врахування змін.", "exportDatabase": "Експорт бази даних", @@ -327,10 +1152,10 @@ "defineWhereYourDataIsStored": "Визначте, де зберігаються ваші дані", "open": "Відкрити", "openFolder": "Відкрити існуючу папку", - "openFolderDesc": "Читати та записувати в вашу існуючу папку AppFlowy", + "openFolderDesc": "Читати та записувати в вашу існуючу папку @:appName", "folderHintText": "ім'я папки", "location": "Створення нової папки", - "locationDesc": "Оберіть ім'я для папки з даними AppFlowy", + "locationDesc": "Оберіть ім'я для папки з даними @:appName", "browser": "Перегляд", "create": "Створити", "set": "Встановити", @@ -341,18 +1166,40 @@ "change": "Змінити", "openLocationTooltips": "Відкрити інший каталог даних", "openCurrentDataFolder": "Відкрити поточний каталог даних", - "recoverLocationTooltips": "Скинути до каталогу даних за замовчуванням AppFlowy", + "recoverLocationTooltips": "Скинути до каталогу даних за замовчуванням @:appName", "exportFileSuccess": "Файл успішно експортовано!", "exportFileFail": "Помилка експорту файлу!", - "export": "Експорт" + "export": "Експорт", + "clearCache": "Очистити кеш", + "clearCacheDesc": "Якщо у вас виникли проблеми із завантаженням зображень або неправильним відображенням шрифтів, спробуйте очистити кеш. Ця дія не видалить ваші дані користувача.", + "areYouSureToClearCache": "Ви впевнені, що хочете очистити кеш?", + "clearCacheSuccess": "Кеш успішно очищено!" }, "user": { "name": "Ім'я", "email": "Електронна пошта", "tooltipSelectIcon": "Обрати значок", "selectAnIcon": "Обрати значок", - "pleaseInputYourOpenAIKey": "Будь ласка, введіть ваш ключ OpenAI", - "clickToLogout": "Натисніть, щоб вийти з поточного облікового запису" + "pleaseInputYourOpenAIKey": "Будь ласка, введіть ваш ключ AI", + "clickToLogout": "Натисніть, щоб вийти з поточного облікового запису", + "pleaseInputYourStabilityAIKey": "будь ласка, введіть ключ стабільності ШІ" + }, + "mobile": { + "personalInfo": "Персональна інформація", + "username": "Ім'я користувача", + "usernameEmptyError": "Ім'я користувача не може бути пустим", + "about": "Про", + "pushNotifications": "Push-сповіщення", + "support": "Підтримка", + "joinDiscord": "Приєднуйтесь до нас у Discord", + "privacyPolicy": "Політика конфіденційності", + "userAgreement": "Угода користувача", + "termsAndConditions": "Правила та умови", + "userprofileError": "Не вдалося завантажити профіль користувача", + "userprofileErrorDescription": "Будь ласка, спробуйте вийти та увійти знову, щоб перевірити, чи проблема не зникає.", + "selectLayout": "Виберіть макет", + "selectStartingDay": "Виберіть день початку", + "version": "Версія" }, "shortcuts": { "shortcutsLabel": "Сполучення клавіш", @@ -384,15 +1231,27 @@ "filterBy": "Фільтрувати за...", "typeAValue": "Введіть значення...", "layout": "Макет", - "databaseLayout": "Вид бази даних" + "databaseLayout": "Вид бази даних", + "viewList": { + "zero": "0 переглядів", + "one": "{count} перегляд", + "other": "{count} переглядів" + }, + "editView": "Редагувати перегляд", + "boardSettings": "Налаштування дошки", + "calendarSettings": "Налаштування календаря", + "createView": "Новий вигляд", + "duplicateView": "Дубльований перегляд", + "deleteView": "Видалити перегляд", + "numberOfVisibleFields": "показано {}" }, "textFilter": { "contains": "Містить", "doesNotContain": "Не містить", "endsWith": "Закінчується на", "startWith": "Починається з", - "is": "Дорівнює", - "isNot": "Не дорівнює", + "is": "Є", + "isNot": "Не є", "isEmpty": "Порожнє", "isNotEmpty": "Не порожнє", "choicechipPrefix": { @@ -419,8 +1278,36 @@ "isNot": "не є", "contains": "Містить", "doesNotContain": "Не містить", - "isEmpty": "порожнє", - "isNotEmpty": "не порожнє" + "isEmpty": "Порожнє", + "isNotEmpty": "Не порожнє" + }, + "dateFilter": { + "is": "Є", + "before": "Є раніше", + "after": "Є після", + "onOrBefore": "Увімкнено або раніше", + "onOrAfter": "Увімкнено або після", + "between": "Знаходиться між", + "empty": "Пусто", + "notEmpty": "Не порожній", + "choicechipPrefix": { + "before": "Раніше", + "after": "Після", + "onOrBefore": "На або раніше", + "onOrAfter": "На або після", + "isEmpty": "Пусто", + "isNotEmpty": "Не порожній" + } + }, + "numberFilter": { + "equal": "Дорівнює", + "notEqual": "Не дорівнює", + "lessThan": "Менше ніж", + "greaterThan": "Більше ніж", + "lessThanOrEqualTo": "Менше або дорівнює", + "greaterThanOrEqualTo": "Більше або дорівнює", + "isEmpty": "Пусто", + "isNotEmpty": "Не порожній" }, "field": { "hide": "Сховати", @@ -429,6 +1316,8 @@ "insertRight": "Вставити справа", "duplicate": "Дублювати", "delete": "Видалити", + "wrapCellContent": "Обернути текст", + "clear": "Очистити комірки", "textFieldName": "Текст", "checkboxFieldName": "Чекбокс", "dateFieldName": "Дата", @@ -439,6 +1328,11 @@ "multiSelectFieldName": "Вибір кількох", "urlFieldName": "URL", "checklistFieldName": "Чек-лист", + "relationFieldName": "Відношення", + "summaryFieldName": "AI Резюме", + "timeFieldName": "Час", + "translateFieldName": "ШІ Перекладач", + "translateTo": "Перекласти на", "numberFormat": "Формат числа", "dateFormat": "Формат дати", "includeTime": "Включити час", @@ -453,16 +1347,31 @@ "timeFormatTwelveHour": "12 годин", "timeFormatTwentyFourHour": "24 години", "clearDate": "Очистити дату", + "dateTime": "Дата, час", + "startDateTime": "Дата початку час", + "endDateTime": "Кінцева дата час", + "failedToLoadDate": "Не вдалося завантажити значення дати", + "selectTime": "Виберіть час", + "selectDate": "Виберіть дату", + "visibility": "Видимість", + "propertyType": "Тип власності", "addSelectOption": "Додати опцію", + "typeANewOption": "Введіть новий параметр", "optionTitle": "Опції", "addOption": "Додати опцію", "editProperty": "Редагувати властивість", "newProperty": "Додати колонку", + "openRowDocument": "Відкрити як сторінку", "deleteFieldPromptMessage": "Ви впевнені? Ця властивість буде видалена", - "newColumn": "Нова колонка" + "clearFieldPromptMessage": "Ти впевнений? Усі клітинки в цьому стовпці будуть порожні", + "newColumn": "Нова колонка", + "format": "Формат", + "reminderOnDateTooltip": "Ця клітинка має заплановане нагадування", + "optionAlreadyExist": "Варіант вже існує" }, "rowPage": { "newField": "Додати нове поле", + "fieldDragElementTooltip": "Натисніть, щоб відкрити меню", "showHiddenFields": { "one": "Показати {} приховане поле", "many": "Показати {} приховані поля", @@ -472,13 +1381,20 @@ "one": "Сховати {} приховане поле", "many": "Сховати {} приховані поля", "other": "Сховати {} приховані поля" - } + }, + "openAsFullPage": "Відкрити як повну сторінку", + "moreRowActions": "Більше дій рядків" }, "sort": { "ascending": "У висхідному порядку", "descending": "У спадному порядку", + "by": "За", + "empty": "Немає активних сортувань", + "cannotFindCreatableField": "Не вдається знайти відповідне поле для сортування", "deleteAllSorts": "Видалити всі сортування", - "addSort": "Додати сортування" + "addSort": "Додати сортування", + "removeSorting": "Ви хочете видалити сортування?", + "fieldInUse": "Ви вже сортуєте за цим полем" }, "row": { "duplicate": "Дублювати", @@ -490,7 +1406,13 @@ "newRow": "Новий рядок", "action": "Дія", "add": "Клацніть, щоб додати нижче", - "drag": "Перетягніть для переміщення" + "drag": "Перетягніть для переміщення", + "deleteRowPrompt": "Ви впевнені, що хочете видалити цей рядок? Цю дію не можна скасувати", + "deleteCardPrompt": "Ви впевнені, що хочете видалити цю картку? Цю дію не можна скасувати", + "dragAndClick": "Перетягніть, щоб перемістити, натисніть, щоб відкрити меню", + "insertRecordAbove": "Вставте запис вище", + "insertRecordBelow": "Вставте запис нижче", + "noContent": "Немає вмісту" }, "selectOption": { "create": "Створити", @@ -509,15 +1431,48 @@ "searchOption": "Шукати опцію", "searchOrCreateOption": "Шукати чи створити опцію...", "createNew": "Створити нову", - "orSelectOne": "Або виберіть опцію" + "orSelectOne": "Або виберіть опцію", + "typeANewOption": "Введіть новий параметр", + "tagName": "Назва тегу" }, "checklist": { "taskHint": "Опис завдання", "addNew": "Додати нове завдання", - "submitNewTask": "Створити" + "submitNewTask": "Створити", + "hideComplete": "Сховати виконані завдання", + "showComplete": "Показати всі завдання" + }, + "url": { + "launch": "Відкрити посилання в браузері", + "copy": "Копіювати посилання в буфер обміну", + "textFieldHint": "Введіть URL" + }, + "relation": { + "relatedDatabasePlaceLabel": "Пов'язана база даних", + "relatedDatabasePlaceholder": "Жодного", + "inRelatedDatabase": "В", + "rowSearchTextFieldPlaceholder": "Пошук", + "noDatabaseSelected": "Базу даних не вибрано, будь ласка, спочатку виберіть одну зі списку нижче:", + "emptySearchResult": "Записів не знайдено", + "linkedRowListLabel": "{count} пов’язаних рядків", + "unlinkedRowListLabel": "Зв'яжіть інший ряд" }, "menuName": "Сітка", - "referencedGridPrefix": "Вигляд" + "referencedGridPrefix": "Вигляд", + "calculate": "Обчислити", + "calculationTypeLabel": { + "none": "Жодного", + "average": "Середній", + "max": "Макс", + "median": "Медіана", + "min": "Мін", + "sum": "Сума", + "count": "Рахувати", + "countEmpty": "Рахунок порожній", + "countEmptyShort": "ПУСТИЙ", + "countNonEmpty": "Граф не пустий", + "countNonEmptyShort": "ЗАПОВНЕНО" + } }, "document": { "menuName": "Документ", @@ -537,6 +1492,41 @@ "calendar": { "selectACalendarToLinkTo": "Виберіть календар для посилання", "createANewCalendar": "Створити новий календар" + }, + "document": { + "selectADocumentToLinkTo": "Виберіть документ для посилання" + }, + "name": { + "text": "Текст", + "heading1": "Заголовок 1", + "heading2": "Заголовок 2", + "heading3": "Заголовок 3", + "image": "Зображення", + "bulletedList": "Маркірований список", + "numberedList": "Нумерований список", + "todoList": "Список справ", + "doc": "Док", + "linkedDoc": "Посилання на сторінку", + "grid": "Сітка", + "linkedGrid": "Зв'язана сітка", + "kanban": "Канбан", + "linkedKanban": "Пов’язаний канбан", + "calendar": "Календар", + "linkedCalendar": "Пов’язаний календар", + "quote": "Цитата", + "divider": "Роздільник", + "table": "Таблиця", + "callout": "Виноска", + "outline": "Контур", + "mathEquation": "Математичне рівняння", + "code": "Код", + "toggleList": "Переключити список", + "emoji": "Emoji", + "aiWriter": "ШІ Письменник", + "dateOrReminder": "Дата або нагадування", + "photoGallery": "Фотогалерея", + "file": "Файл", + "checkbox": "Прапорець" } }, "selectionMenu": { @@ -547,26 +1537,36 @@ "referencedBoard": "Пов'язані дошки", "referencedGrid": "Пов'язані сітки", "referencedCalendar": "Календар посилань", - "autoGeneratorMenuItemName": "OpenAI Writer", - "autoGeneratorTitleName": "OpenAI: Запитайте штучний інтелект написати будь-що...", + "referencedDocument": "Посилальний документ", + "autoGeneratorMenuItemName": "ШІ Письменник", + "autoGeneratorTitleName": "AI: Запитайте штучний інтелект написати будь-що...", "autoGeneratorLearnMore": "Дізнатися більше", "autoGeneratorGenerate": "Генерувати", - "autoGeneratorHintText": "Запитайте OpenAI...", - "autoGeneratorCantGetOpenAIKey": "Не вдається отримати ключ OpenAI", + "autoGeneratorHintText": "Запитайте AI...", + "autoGeneratorCantGetOpenAIKey": "Не вдається отримати ключ AI", "autoGeneratorRewrite": "Переписати", - "smartEdit": "AI Асистенти", - "openAI": "OpenAI", + "smartEdit": "Запитай ШІ", + "aI": "ШІ", "smartEditFixSpelling": "Виправити правопис", - "warning": "⚠️ Відповіді AI можуть бути неточними або вводити в оману.", + "warning": "⚠️ Відповіді ШІ можуть бути неточними або вводити в оману.", "smartEditSummarize": "Підсумувати", "smartEditImproveWriting": "Покращити написання", "smartEditMakeLonger": "Зробити довше", - "smartEditCouldNotFetchResult": "Не вдалося отримати результат від OpenAI", - "smartEditCouldNotFetchKey": "Не вдалося отримати ключ OpenAI", - "smartEditDisabled": "Підключіть OpenAI в Налаштуваннях", + "smartEditCouldNotFetchResult": "Не вдалося отримати результат від ШІ", + "smartEditCouldNotFetchKey": "Не вдалося отримати ключ ШІ", + "smartEditDisabled": "Підключіть ШІ в Налаштуваннях", + "appflowyAIEditDisabled": "Увійдіть, щоб увімкнути функції ШІ", "discardResponse": "Ви хочете відкинути відповіді AI?", "createInlineMathEquation": "Створити рівняння", + "fonts": "Шрифти", + "insertDate": "Вставте дату", + "emoji": "Emoji", "toggleList": "Перемкнути список", + "quoteList": "Цитатний список", + "numberedList": "Нумерований список", + "bulletedList": "Маркірований список", + "todoList": "Список справ", + "callout": "Виноска", "cover": { "changeCover": "Змінити Обгортку", "colors": "Кольори", @@ -588,10 +1588,12 @@ "couldNotFetchImage": "Не вдалося отримати зображення", "imageSavingFailed": "Не вдалося зберегти зображення", "addIcon": "Додати іконку", + "changeIcon": "Змінити значок", "coverRemoveAlert": "Це буде видалено з обгортки після видалення.", "alertDialogConfirmation": "Ви впевнені, що хочете продовжити?" }, "mathEquation": { + "name": "Математичне рівняння", "addMathEquation": "Додати математичне рівняння", "editMathEquation": "Редагувати математичне рівняння" }, @@ -608,14 +1610,43 @@ "left": "Ліворуч", "center": "По центру", "right": "По праву", - "defaultColor": "За замовчуванням" + "defaultColor": "За замовчуванням", + "depth": "Глибина" }, "image": { + "addAnImage": "Додати зображення", "copiedToPasteBoard": "Посилання на зображення скопійовано в буфер обміну", - "addAnImage": "Додати зображення" + "addAnImageDesktop": "Перетягніть зображення або натисніть, щоб додати зображення", + "addAnImageMobile": "Натисніть, щоб додати одне або кілька зображень", + "dropImageToInsert": "Перетягніть зображення, щоб вставити", + "imageUploadFailed": "Помилка завантаження зображення", + "imageDownloadFailed": "Помилка завантаження зображення, повторіть спробу", + "imageDownloadFailedToken": "Помилка завантаження зображення через відсутність маркера користувача. Повторіть спробу", + "errorCode": "Код помилки" + }, + "photoGallery": { + "name": "Фотогалерея", + "imageKeyword": "зображення", + "imageGalleryKeyword": "галерея зображень", + "photoKeyword": "фото", + "photoBrowserKeyword": "браузер фотографій", + "galleryKeyword": "галерея", + "addImageTooltip": "Додайте зображення", + "changeLayoutTooltip": "Змінити макет", + "browserLayout": "Браузер", + "gridLayout": "Сітка", + "deleteBlockTooltip": "Видалити всю галерею" + }, + "math": { + "copiedToPasteBoard": "Математичне рівняння скопійовано в буфер обміну" + }, + "urlPreview": { + "copiedToPasteBoard": "Посилання скопійовано в буфер обміну", + "convertToLink": "Перетворити на вбудоване посилання" }, "outline": { - "addHeadingToCreateOutline": "Додайте заголовки, щоб створити зміст." + "addHeadingToCreateOutline": "Додайте заголовки, щоб створити зміст.", + "noMatchHeadings": "Відповідних заголовків не знайдено." }, "table": { "addAfter": "Додати після", @@ -629,8 +1660,51 @@ "copy": "Копіювати", "cut": "Вирізати", "paste": "Вставити" + }, + "action": "Дії", + "database": { + "selectDataSource": "Виберіть джерело даних", + "noDataSource": "Немає джерела даних", + "selectADataSource": "Виберіть джерело даних", + "toContinue": "продовжувати", + "newDatabase": "Нова база даних", + "linkToDatabase": "Посилання на базу даних" + }, + "date": "Дата", + "video": { + "label": "Відео", + "emptyLabel": "Додайте відео", + "placeholder": "Вставте посилання на відео", + "copiedToPasteBoard": "Посилання на відео скопійовано в буфер обміну", + "insertVideo": "Додайте відео", + "invalidVideoUrl": "Вихідна URL-адреса ще не підтримується.", + "invalidVideoUrlYouTube": "YouTube ще не підтримується.", + "supportedFormats": "Підтримувані формати: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" + }, + "file": { + "name": "Файл", + "uploadTab": "Завантажити", + "networkTab": "Інтегрувати посилання", + "placeholderText": "Натисніть або перетягніть, щоб завантажити файл", + "placeholderDragging": "Перетягніть файл, щоб завантажити", + "dropFileToUpload": "Перетягніть файл, щоб завантажити", + "fileUploadHint": "Перетягніть файл сюди\nабо натисніть, щоб вибрати файл.", + "networkHint": "Введіть посилання на файл", + "networkUrlInvalid": "Недійсна URL-адреса, виправте URL-адресу та повторіть спробу", + "networkAction": "Вставити посилання на файл", + "fileTooBigError": "Розмір файлу завеликий, будь ласка, завантажте файл розміром менше 10 МБ", + "renameFile": { + "title": "Перейменувати файл", + "description": "Введіть нову назву для цього файлу", + "nameEmptyError": "Ім'я файлу не може бути пустим." + }, + "uploadedAt": "Завантажено {}", + "linkedAt": "Посилання додано {}" } }, + "outlineBlock": { + "placeholder": "Зміст" + }, "textBlock": { "placeholder": "Тип '/' для команд" }, @@ -647,24 +1721,65 @@ "label": "URL зображення", "placeholder": "Введіть URL зображення" }, + "ai": { + "label": "Створення зображення за допомогою AI", + "placeholder": "Будь ласка, введіть підказку для AI для створення зображення" + }, + "stability_ai": { + "label": "Створюйте зображення за допомогою Stability AI", + "placeholder": "Будь ласка, введіть підказку для ШІ стабільності, щоб створити зображення" + }, "support": "Розмір зображення обмежений 5 МБ. Підтримувані формати: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Невірне зображення", "invalidImageSize": "Розмір зображення повинен бути менше 5 МБ", "invalidImageFormat": "Формат зображення не підтримується. Підтримувані формати: JPEG, PNG, GIF, SVG", - "invalidImageUrl": "Невірний URL зображення" + "invalidImageUrl": "Невірний URL зображення", + "noImage": "Такого файлу чи каталогу немає", + "multipleImagesFailed": "Не вдалося завантажити одне чи кілька зображень. Повторіть спробу" }, "embedLink": { "label": "Вставити посилання", "placeholder": "Вставте або введіть посилання на зображення" }, - "searchForAnImage": "Пошук зображення" + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "Пошук зображення", + "pleaseInputYourOpenAIKey": "будь ласка, введіть ключ ШІ на сторінці налаштувань", + "saveImageToGallery": "Зберегти зображення", + "failedToAddImageToGallery": "Не вдалося додати зображення до галереї", + "successToAddImageToGallery": "Зображення додано до галереї", + "unableToLoadImage": "Не вдалося завантажити зображення", + "maximumImageSize": "Максимальний підтримуваний розмір зображення для завантаження становить 10 МБ", + "uploadImageErrorImageSizeTooBig": "Розмір зображення має бути менше 10 Мб", + "imageIsUploading": "Зображення завантажується", + "openFullScreen": "Відкрити на весь екран", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "Попереднє зображення", + "nextImageTooltip": "Наступне зображення", + "zoomOutTooltip": "Зменшення", + "zoomInTooltip": "Збільшувати", + "changeZoomLevelTooltip": "Змінити рівень масштабування", + "openLocalImage": "Відкрите зображення", + "downloadImage": "Завантажити зображення", + "closeViewer": "Закрити інтерактивний засіб перегляду", + "scalePercentage": "{}%", + "deleteImageTooltip": "Видалити зображення" + } + }, + "pleaseInputYourStabilityAIKey": "будь ласка, введіть ключ стабільності AI на сторінці налаштувань" }, "codeBlock": { "language": { "label": "Мова", - "placeholder": "Виберіть мову" - } + "placeholder": "Виберіть мову", + "auto": "Авто" + }, + "copyTooltip": "Скопіюйте вміст блоку коду", + "searchLanguageHint": "Пошук мови", + "codeCopiedSnackbar": "Код скопійовано в буфер обміну!" }, "inlineLink": { "placeholder": "Вставте або введіть посилання", @@ -685,11 +1800,24 @@ "page": { "label": "Посилання на сторінку", "tooltip": "Клацніть, щоб відкрити сторінку" - } + }, + "deleted": "Видалено", + "deletedContent": "Цей вміст не існує або його видалено", + "noAccess": "Немає доступу" }, "toolbar": { "resetToDefaultFont": "Скинути до стандартного" }, + "errorBlock": { + "theBlockIsNotSupported": "Не вдалося проаналізувати вміст блоку", + "clickToCopyTheBlockContent": "Натисніть, щоб скопіювати вміст блоку", + "blockContentHasBeenCopied": "Вміст блоку скопійовано." + }, + "mobilePageSelector": { + "title": "Виберіть сторінку", + "failedToLoad": "Не вдалося завантажити список сторінок", + "noPagesFound": "Сторінок не знайдено" + }, "board": { "column": { "createNewCard": "Нова" @@ -720,7 +1848,7 @@ "referencedCalendarPrefix": "Вид на" }, "errorDialog": { - "title": "Помилка в AppFlowy", + "title": "Помилка в @:appName", "howToFixFallback": "Вибачте за незручності! Надішліть звіт про помилку на нашу сторінку GitHub, де ви опишіть свою помилку.", "github": "Переглянути на GitHub" }, @@ -755,7 +1883,140 @@ "gray": "Сірий" } }, + "board": { + "column": { + "createNewCard": "Новий", + "renameGroupTooltip": "Натисніть, щоб перейменувати групу", + "createNewColumn": "Додайте нову групу", + "addToColumnTopTooltip": "Додайте нову картку вгорі", + "addToColumnBottomTooltip": "Додайте нову картку внизу", + "renameColumn": "Перейменувати", + "hideColumn": "Сховати", + "newGroup": "Нова група", + "deleteColumn": "Видалити", + "deleteColumnConfirmation": "Це призведе до видалення цієї групи та всіх карток у ній.\nВи впевнені, що бажаєте продовжити?" + }, + "hiddenGroupSection": { + "sectionTitle": "Приховані групи", + "collapseTooltip": "Приховати приховані групи", + "expandTooltip": "Переглянути приховані групи" + }, + "cardDetail": "Деталі картки", + "cardActions": "Дії з картками", + "cardDuplicated": "Картку дублювали", + "cardDeleted": "Картку видалено", + "showOnCard": "Показати на реквізитах картки", + "setting": "Налаштування", + "propertyName": "Назва власності", + "menuName": "Дошка", + "showUngrouped": "Показати не згруповані елементи", + "ungroupedButtonText": "Розгрупований", + "ungroupedButtonTooltip": "Містить картки, які не належать до жодної групи", + "ungroupedItemsTitle": "Натисніть, щоб додати до дошки", + "groupBy": "Групувати за", + "groupCondition": "Стан групи", + "referencedBoardPrefix": "Вид на", + "notesTooltip": "Нотатки всередині", + "mobile": { + "editURL": "Редагувати URL", + "showGroup": "Показати групу", + "showGroupContent": "Ви впевнені, що хочете показати цю групу на дошці?", + "failedToLoad": "Не вдалося завантажити вигляд дошки" + }, + "dateCondition": { + "weekOf": "Тиждень з {} по {}", + "today": "Сьогодні", + "yesterday": "вчора", + "tomorrow": "Завтра", + "lastSevenDays": "Останні 7 днів", + "nextSevenDays": "Наступні 7 днів", + "lastThirtyDays": "Останні 30 днів", + "nextThirtyDays": "Наступні 30 днів" + }, + "noGroup": "Немає груп за властивостями", + "noGroupDesc": "Для відображення подання дошки потрібна властивість для групування" + }, + "calendar": { + "menuName": "Календар", + "defaultNewCalendarTitle": "Без назви", + "newEventButtonTooltip": "Додайте нову подію", + "navigation": { + "today": "Сьогодні", + "jumpToday": "Перейти до сьогоднішнього дня", + "previousMonth": "Попередній місяць", + "nextMonth": "Наступного місяця", + "views": { + "day": "День", + "week": "Тиждень", + "month": "Місяць", + "year": "Рік" + } + }, + "mobileEventScreen": { + "emptyTitle": "Подій ще немає", + "emptyBody": "Натисніть кнопку плюс, щоб створити подію в цей день." + }, + "settings": { + "showWeekNumbers": "Показати номери тижнів", + "showWeekends": "Показати вихідні", + "firstDayOfWeek": "Почніть тиждень", + "layoutDateField": "Верстка календаря по", + "changeLayoutDateField": "Змінити поле макета", + "noDateTitle": "Без дати", + "noDateHint": { + "zero": "Тут відображатимуться незаплановані події", + "one": "{count} незапланована подія", + "other": "{count} незапланованих подій" + }, + "unscheduledEventsTitle": "Позапланові заходи", + "clickToAdd": "Натисніть, щоб додати до календаря", + "name": "Налаштування календаря", + "clickToOpen": "Натисніть, щоб відкрити запис" + }, + "referencedCalendarPrefix": "Вид на", + "quickJumpYear": "Перейти до", + "duplicateEvent": "Дубльована подія" + }, + "errorDialog": { + "title": "@:appName Помилка", + "howToFixFallback": "Просимо вибачення за незручності! Надішліть проблему на нашій сторінці GitHub з описом вашої помилки.", + "howToFixFallbackHint1": "Просимо вибачення за незручності! Надішліть питання на нашому ", + "howToFixFallbackHint2": " сторінка, яка описує вашу помилку.", + "github": "Переглянути на GitHub" + }, + "search": { + "label": "Пошук", + "sidebarSearchIcon": "Шукайте та швидко переходьте на сторінку", + "placeholder": { + "actions": "Пошукові дії..." + } + }, + "message": { + "copy": { + "success": "Скопійовано!", + "fail": "Неможливо скопіювати" + } + }, + "unSupportBlock": "Поточна версія не підтримує цей блок.", + "views": { + "deleteContentTitle": "Ви дійсно хочете видалити {pageType}?", + "deleteContentCaption": "якщо ви видалите цей {pageType}, ви зможете відновити його з кошика." + }, + "colors": { + "custom": "Користувальницькі", + "default": "За замовчуванням", + "red": "Червоний", + "orange": "Помаранчевий", + "yellow": "Жовтий", + "green": "Зелений", + "blue": "Синій", + "purple": "Фіолетовий", + "pink": "Рожевий", + "brown": "Коричневий", + "gray": "Сірий" + }, "emoji": { + "emojiTab": "Emoji", "search": "Пошук емодзі", "noRecent": "Немає недавніх емодзі", "noEmojiFound": "Емодзі не знайдено", @@ -765,27 +2026,66 @@ "remove": "Видалити емодзі", "categories": { "smileys": "Смайли та емоції", - "people": "Люди та тіло", - "animals": "Тварини та природа", - "food": "Їжа та напої", - "activities": "Активності", - "places": "Подорожі та місця", - "objects": "Об'єкти", - "symbols": "Символи", - "flags": "Прапори", - "nature": "Природа", - "frequentlyUsed": "Часто використовувані" - } + "people": "люди", + "animals": "природа", + "food": "їжа", + "activities": "активності", + "places": "місця", + "objects": "об'єкти", + "symbols": "символи", + "flags": "прапори", + "nature": "природа", + "frequentlyUsed": "часто використовувані" + }, + "skinTone": { + "default": "За замовчуванням", + "light": "Світла", + "mediumLight": "Середньосвітлий", + "medium": "Середній", + "mediumDark": "Середньо-темний", + "dark": "Темний" + }, + "openSourceIconsFrom": "Піктограми з відкритим кодом" }, "inlineActions": { "noResults": "Немає результатів", + "recentPages": "Останні сторінки", "pageReference": "Посилання на сторінку", + "docReference": "Посилання на документ", + "boardReference": "Довідка дошки", + "calReference": "Довідка календаря", + "gridReference": "Посилання на сітку", "date": "Дата", "reminder": { "groupTitle": "Нагадування", "shortKeyword": "нагадати" } }, + "datePicker": { + "dateTimeFormatTooltip": "Змініть формат дати та часу в налаштуваннях", + "dateFormat": "Формат дати", + "includeTime": "Включіть час", + "isRange": "Дата закінчення", + "timeFormat": "Формат часу", + "clearDate": "Ясна дата", + "reminderLabel": "Нагадування", + "selectReminder": "Виберіть нагадування", + "reminderOptions": { + "none": "Жодного", + "atTimeOfEvent": "Час події", + "fiveMinsBefore": "5 хвилин до", + "tenMinsBefore": "за 10 хвилин до", + "fifteenMinsBefore": "15 хвилин до", + "thirtyMinsBefore": "30 хвилин до", + "oneHourBefore": "за 1 годину до", + "twoHoursBefore": "за 2 години до", + "onDayOfEvent": "У день події", + "oneDayBefore": "за 1 день до", + "twoDaysBefore": "за 2 дні до", + "oneWeekBefore": "1 тиждень тому", + "custom": "Налаштовуване" + } + }, "relativeDates": { "yesterday": "Вчора", "today": "Сьогодні", @@ -794,6 +2094,27 @@ }, "notificationHub": { "title": "Сповіщення", + "mobile": { + "title": "Оновлення" + }, + "emptyTitle": "Все наздогнали!", + "emptyBody": "Немає незавершених сповіщень або дій. Насолоджуйся спокоєм.", + "tabs": { + "inbox": "Вхідні", + "upcoming": "Майбутні" + }, + "actions": { + "markAllRead": "Позначити все як прочитане", + "showAll": "Все", + "showUnreads": "Непрочитаний" + }, + "filters": { + "ascending": "Висхідний", + "descending": "Спускається", + "groupByDate": "Групувати за датою", + "showUnreadsOnly": "Показати лише непрочитані", + "resetToDefault": "Скинути до замовчування" + }, "empty": "Тут нічого немає!" }, "reminderNotification": { @@ -811,6 +2132,458 @@ "replace": "Замінити", "replaceAll": "Замінити всі", "noResult": "Немає результатів", - "caseSensitive": "З урахуванням регістру" + "caseSensitive": "З урахуванням регістру", + "searchMore": "Шукайте, щоб знайти більше результатів" + }, + "error": { + "weAreSorry": "Нам шкода", + "loadingViewError": "У нас виникла проблема із завантаженням цього перегляду. Перевірте підключення до Інтернету, оновіть програму та не соромтеся зв’язатися з командою, якщо проблема не зникне.", + "syncError": "Дані не синхронізуються з іншого пристрою", + "syncErrorHint": "Будь ласка, повторно відкрийте цю сторінку на пристрої, де її востаннє редагували, а потім знову відкрийте її на поточному пристрої.", + "clickToCopy": "Натисніть, щоб скопіювати код помилки" + }, + "editor": { + "bold": "Жирний", + "bulletedList": "Маркірований список", + "bulletedListShortForm": "Маркірований", + "checkbox": "Прапорець", + "embedCode": "Вставити код", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "Виділіть", + "color": "Колір", + "image": "Зображення", + "date": "Дата", + "page": "Сторінка", + "italic": "Курсив", + "link": "Посилання", + "numberedList": "Нумерований список", + "numberedListShortForm": "Пронумерований", + "quote": "Цитата", + "strikethrough": "Закреслення", + "text": "Текст", + "underline": "Підкреслити", + "fontColorDefault": "За замовчуванням", + "fontColorGray": "Сірий", + "fontColorBrown": "Коричневий", + "fontColorOrange": "Помаранчевий", + "fontColorYellow": "Жовтий", + "fontColorGreen": "Зелений", + "fontColorBlue": "Синій", + "fontColorPurple": "Фіолетовий", + "fontColorPink": "Рожевий", + "fontColorRed": "Червоний", + "backgroundColorDefault": "Фон за умовчанням", + "backgroundColorGray": "Сірий фон", + "backgroundColorBrown": "Коричневий фон", + "backgroundColorOrange": "Помаранчевий фон", + "backgroundColorYellow": "Жовтий фон", + "backgroundColorGreen": "Зелений фон", + "backgroundColorBlue": "Блакитний фон", + "backgroundColorPurple": "Фіолетовий фон", + "backgroundColorPink": "Рожевий фон", + "backgroundColorRed": "Червоний фон", + "backgroundColorLime": "Вапно фону", + "backgroundColorAqua": "Аква фону", + "done": "Готово", + "cancel": "Скасувати", + "tint1": "Відтінок 1", + "tint2": "Відтінок 2", + "tint3": "Відтінок 3", + "tint4": "Відтінок 4", + "tint5": "Відтінок 5", + "tint6": "Відтінок 6", + "tint7": "Відтінок 7", + "tint8": "Відтінок 8", + "tint9": "Відтінок 9", + "lightLightTint1": "Фіолетовий", + "lightLightTint2": "Рожевий", + "lightLightTint3": "Світло-рожевий", + "lightLightTint4": "Помаранчевий", + "lightLightTint5": "Жовтий", + "lightLightTint6": "Вапно", + "lightLightTint7": "Зелений", + "lightLightTint8": "Аква", + "lightLightTint9": "Синій", + "urlHint": "URL", + "mobileHeading1": "Заголовок 1", + "mobileHeading2": "Заголовок 2", + "mobileHeading3": "Заголовок 3", + "textColor": "Колір тексту", + "backgroundColor": "Колір фону", + "addYourLink": "Додайте своє посилання", + "openLink": "Відкрити посилання", + "copyLink": "Копіювати посилання", + "removeLink": "Видалити посилання", + "editLink": "Редагувати посилання", + "linkText": "Текст", + "linkTextHint": "Будь ласка, введіть текст", + "linkAddressHint": "Будь ласка, введіть URL", + "highlightColor": "Колір виділення", + "clearHighlightColor": "Чіткий колір виділення", + "customColor": "Індивідуальний колір", + "hexValue": "Шістнадцяткове значення", + "opacity": "Непрозорість", + "resetToDefaultColor": "Відновити колір за замовчуванням", + "ltr": "LTR", + "rtl": "RTL", + "auto": "Авто", + "cut": "Вирізати", + "copy": "Копіювати", + "paste": "Вставити", + "find": "Знайти", + "select": "Виберіть", + "selectAll": "Вибрати все", + "previousMatch": "Попередній матч", + "nextMatch": "Наступний матч", + "closeFind": "Закрити", + "replace": "Замінити", + "replaceAll": "Замінити все", + "regex": "Регулярний вираз", + "caseSensitive": "Чутливий до регістру", + "uploadImage": "Завантажити зображення", + "urlImage": "URL зображення", + "incorrectLink": "Невірне посилання", + "upload": "Завантажити", + "chooseImage": "Виберіть зображення", + "loading": "Завантаження", + "imageLoadFailed": "Не вдалося завантажити зображення", + "divider": "Роздільник", + "table": "Таблиця", + "colAddBefore": "Додати перед", + "rowAddBefore": "Додати перед", + "colAddAfter": "Додайте після", + "rowAddAfter": "Додайте після", + "colRemove": "Видалити", + "rowRemove": "Видалити", + "colDuplicate": "Дублювати", + "rowDuplicate": "Дублювати", + "colClear": "Очистити вміст", + "rowClear": "Очистити вміст", + "slashPlaceHolder": "Введіть '/', щоб вставити блок, або почніть вводити", + "typeSomething": "Введіть щось...", + "toggleListShortForm": "Перемикач", + "quoteListShortForm": "Цитата", + "mathEquationShortForm": "Формула", + "codeBlockShortForm": "Код" + }, + "favorite": { + "noFavorite": "Немає улюбленої сторінки", + "noFavoriteHintText": "Проведіть пальцем по сторінці вліво, щоб додати її до вибраного", + "removeFromSidebar": "Видалити з бічної панелі", + "addToSidebar": "Закріпити на бічній панелі" + }, + "cardDetails": { + "notesPlaceholder": "Введіть /, щоб вставити блок, або почніть вводити текст" + }, + "blockPlaceholders": { + "todoList": "Зробити", + "bulletList": "Список", + "numberList": "Список", + "quote": "Цитата", + "heading": "Заголовок {}" + }, + "titleBar": { + "pageIcon": "Значок сторінки", + "language": "Мова", + "font": "Шрифт", + "actions": "Дії", + "date": "Дата", + "addField": "Додати поле", + "userIcon": "Значок користувача" + }, + "noLogFiles": "Немає файлів журналу", + "newSettings": { + "myAccount": { + "title": "Мій рахунок", + "subtitle": "Налаштуйте свій профіль, керуйте безпекою облікового запису, відкривайте ключі AI або ввійдіть у свій обліковий запис.", + "profileLabel": "Назва облікового запису та зображення профілю", + "profileNamePlaceholder": "Введіть ім'я", + "accountSecurity": "Безпека облікового запису", + "2FA": "2-етапна автентифікація", + "aiKeys": "AI ключі", + "accountLogin": "Вхід до облікового запису", + "updateNameError": "Не вдалося оновити назву", + "updateIconError": "Не вдалося оновити значок", + "deleteAccount": { + "title": "Видалити аккаунт", + "subtitle": "Назавжди видалити свій обліковий запис і всі ваші дані.", + "deleteMyAccount": "Видалити мій обліковий запис", + "dialogTitle": "Видалити аккаунт", + "dialogContent1": "Ви впевнені, що хочете остаточно видалити свій обліковий запис?", + "dialogContent2": "Цю дію неможливо скасувати. Вона призведе до скасування доступу до всіх командних просторів, видалення всього вашого облікового запису, включаючи приватні робочі простори, і видалення вас із усіх спільних робочих просторів." + } + }, + "workplace": { + "name": "Робоче місце", + "title": "Налаштування робочого місця", + "subtitle": "Налаштуйте зовнішній вигляд робочої області, тему, шрифт, макет тексту, дату, час і мову.", + "workplaceName": "Назва робочого місця", + "workplaceNamePlaceholder": "Введіть назву робочого місця", + "workplaceIcon": "Значок на робочому місці", + "workplaceIconSubtitle": "Завантажте зображення або використовуйте емодзі для свого робочого простору. Значок з’явиться на бічній панелі та в сповіщеннях.", + "renameError": "Не вдалося перейменувати робоче місце", + "updateIconError": "Не вдалося оновити значок", + "chooseAnIcon": "Виберіть значок", + "appearance": { + "name": "Зовнішній вигляд", + "themeMode": { + "auto": "Авто", + "light": "Світла", + "dark": "Темна" + }, + "language": "Мова" + } + }, + "syncState": { + "syncing": "Синхронізація", + "synced": "Синхронізовано", + "noNetworkConnected": "Немає підключення до мережі" + } + }, + "pageStyle": { + "title": "Стиль сторінки", + "layout": "Макет", + "coverImage": "Зображення обкладинки", + "pageIcon": "Значок сторінки", + "colors": "Кольори", + "gradient": "Градієнт", + "backgroundImage": "Фонове зображення", + "presets": "Предустановки", + "photo": "Фото", + "unsplash": "Unsplash", + "pageCover": "Обкладинка сторінки", + "none": "Жодного", + "openSettings": "Відкрийте налаштування", + "photoPermissionTitle": "@:appName хоче отримати доступ до вашої бібліотеки фотографій", + "photoPermissionDescription": "Дозвольте доступ до бібліотеки фотографій для завантаження зображень.", + "doNotAllow": "Не дозволяти", + "image": "Зображення" + }, + "commandPalette": { + "placeholder": "Введіть для пошуку...", + "bestMatches": "Найкращі збіги", + "recentHistory": "Новітня історія", + "navigateHint": "для навігації", + "loadingTooltip": "Чекаємо результатів...", + "betaLabel": "БЕТА", + "betaTooltip": "Зараз ми підтримуємо лише пошук сторінок і вмісту в документах", + "fromTrashHint": "Зі сміття", + "noResultsHint": "Ми не знайшли те, що ви шукали, спробуйте знайти інший термін.", + "clearSearchTooltip": "Очистити поле пошуку" + }, + "space": { + "delete": "Видалити", + "deleteConfirmation": "Видалити: ", + "deleteConfirmationDescription": "Усі сторінки в цьому просторі буде видалено та переміщено до кошика, а опубліковані сторінки буде скасовано.", + "rename": "Перейменувати простір", + "changeIcon": "Змінити значок", + "manage": "Керуйте простором", + "addNewSpace": "Створити простір", + "collapseAllSubPages": "Згорнути всі підсторінки", + "createNewSpace": "Створіть новий простір", + "createSpaceDescription": "Створіть кілька публічних і приватних просторів, щоб краще організувати свою роботу.", + "spaceName": "Назва простору", + "spaceNamePlaceholder": "наприклад, маркетинг, інженерія, HR", + "permission": "Дозвіл", + "publicPermission": "Громадський", + "publicPermissionDescription": "Усі учасники робочої області з повним доступом", + "privatePermission": "Приватний", + "privatePermissionDescription": "Тільки ви маєте доступ до цього простору", + "spaceIconBackground": "Колір фону", + "spaceIcon": "Значок", + "dangerZone": "НЕБЕЗПЕЧНА ЗОНА", + "unableToDeleteLastSpace": "Неможливо видалити останній пробіл", + "unableToDeleteSpaceNotCreatedByYou": "Неможливо видалити простори, створені іншими", + "enableSpacesForYourWorkspace": "Увімкніть Spaces для своєї робочої області", + "title": "Пробіли", + "defaultSpaceName": "Загальний", + "upgradeSpaceTitle": "Увімкнути Spaces", + "upgradeSpaceDescription": "Створіть кілька публічних і приватних просторів, щоб краще організувати свій робочий простір.", + "upgrade": "Оновити", + "upgradeYourSpace": "Створіть кілька просторів", + "quicklySwitch": "Швидко перейдіть до наступного місця", + "duplicate": "Дубльований простір", + "movePageToSpace": "Перемістити сторінку в простір", + "switchSpace": "Переключити простір", + "spaceNameCannotBeEmpty": "Назва групи не може бути пустою" + }, + "publish": { + "hasNotBeenPublished": "Ця сторінка ще не опублікована", + "reportPage": "Сторінка звіту", + "databaseHasNotBeenPublished": "Публікація бази даних ще не підтримується.", + "createdWith": "Створено за допомогою", + "downloadApp": "Завантажити AppFlowy", + "copy": { + "codeBlock": "Вміст блоку коду скопійовано в буфер обміну", + "imageBlock": "Посилання на зображення скопійовано в буфер обміну", + "mathBlock": "Математичне рівняння скопійовано в буфер обміну", + "fileBlock": "Посилання на файл скопійовано в буфер обміну" + }, + "containsPublishedPage": "Ця сторінка містить одну або кілька опублікованих сторінок. Якщо ви продовжите, їх публікацію буде скасовано. Ви хочете продовжити видалення?", + "publishSuccessfully": "Успішно опубліковано", + "unpublishSuccessfully": "Публікацію скасовано", + "publishFailed": "Не вдалося опублікувати", + "unpublishFailed": "Не вдалося скасувати публікацію", + "noAccessToVisit": "Немає доступу до цієї сторінки...", + "createWithAppFlowy": "Створіть веб-сайт за допомогою AppFlowy", + "fastWithAI": "Швидко та легко з AI.", + "tryItNow": "Спробуй зараз", + "onlyGridViewCanBePublished": "Можна опублікувати лише режим сітки", + "database": { + "zero": "Опублікувати {} вибране представлення", + "one": "Опублікувати {} вибраних представлень", + "many": "Опублікувати {} вибраних представлень", + "other": "Опублікувати {} вибраних представлень" + }, + "mustSelectPrimaryDatabase": "Необхідно вибрати основний вид", + "noDatabaseSelected": "Базу даних не вибрано, виберіть принаймні одну базу даних.", + "unableToDeselectPrimaryDatabase": "Неможливо скасувати вибір основної бази даних", + "saveThisPage": "Зберегти цю сторінку", + "duplicateTitle": "Куди б ви хотіли додати", + "selectWorkspace": "Виберіть робочу область", + "addTo": "Додати до", + "duplicateSuccessfully": "Дубльований успіх. Хочете переглянути документи?", + "duplicateSuccessfullyDescription": "Немає програми? Ваше завантаження розпочнеться автоматично після натискання кнопки «Завантажити».", + "downloadIt": "Завантажити", + "openApp": "Відкрити в додатку", + "duplicateFailed": "Не вдалося створити копію", + "membersCount": { + "zero": "Немає учасників", + "one": "1 учасник", + "many": "{count} учасників", + "other": "{count} учасників" + } + }, + "web": { + "continue": "Продовжити", + "or": "або", + "continueWithGoogle": "Продовжуйте з Google", + "continueWithGithub": "Продовжити з GitHub", + "continueWithDiscord": "Продовжуйте з Discord", + "signInAgreement": "Натиснувши «Продовжити» вище, ви підтверджуєте це\nви прочитали, зрозуміли та погодилися\nAppFlowy", + "and": "і", + "termOfUse": "Умови", + "privacyPolicy": "Політика конфіденційності", + "signInError": "Помилка входу", + "login": "Зареєструйтесь або увійдіть", + "fileBlock": { + "uploadedAt": "Завантажено {time}", + "linkedAt": "Посилання додано {time}", + "empty": "Завантажте або вставте файл" + } + }, + "globalComment": { + "comments": "Коментарі", + "addComment": "Додати коментар", + "reactedBy": "відреагував", + "addReaction": "Додайте реакцію", + "reactedByMore": "та {count} інших", + "showSeconds": { + "one": "1 секунду тому", + "other": "{count} секунд тому", + "zero": "Прямо зараз", + "many": "{count} секунд тому" + }, + "showMinutes": { + "one": "1 хвилину тому", + "other": "{count} хвилин тому", + "many": "{count} хвилин тому" + }, + "showHours": { + "one": "1 годину тому", + "other": "{count} годин тому", + "many": "{count} годин тому" + }, + "showDays": { + "one": "1 день тому", + "other": "{count} днів тому", + "many": "{count} днів тому" + }, + "showMonths": { + "one": "1 місяць тому", + "other": "{count} місяців тому", + "many": "{count} місяців тому" + }, + "showYears": { + "one": "1 рік тому", + "other": "{count} років тому", + "many": "{count} років тому" + }, + "reply": "Відповісти", + "deleteComment": "Видалити коментар", + "youAreNotOwner": "Ви не є власником цього коментаря", + "confirmDeleteDescription": "Ви впевнені, що хочете видалити цей коментар?", + "hasBeenDeleted": "Видалено", + "replyingTo": "Відповідаючи на", + "noAccessDeleteComment": "Ви не можете видалити цей коментар", + "collapse": "Згорнути", + "readMore": "Читати далі", + "failedToAddComment": "Не вдалося додати коментар", + "commentAddedSuccessfully": "Коментар успішно додано.", + "commentAddedSuccessTip": "Ви щойно додали коментар або відповіли на нього. Бажаєте перейти вгору, щоб переглянути останні коментарі?" + }, + "template": { + "asTemplate": "Як шаблон", + "name": "Назва шаблону", + "description": "Опис шаблону", + "about": "Шаблон Про", + "preview": "Попередній перегляд шаблону", + "categories": "Категорії шаблонів", + "isNewTemplate": "PIN для нового шаблону", + "featured": "PIN-код для рекомендованого", + "relatedTemplates": "Пов’язані шаблони", + "requiredField": "{field} є обов’язковим", + "addCategory": "Додати \"{category}\"", + "addNewCategory": "Додати нову категорію", + "addNewCreator": "Додати нового творця", + "deleteCategory": "Видалити категорію", + "editCategory": "Редагувати категорію", + "editCreator": "Редагувати творця", + "category": { + "name": "Назва категорії", + "icon": "Значок категорії", + "bgColor": "Колір фону категорії", + "priority": "Пріоритет категорії", + "desc": "Опис категорії", + "type": "Тип категорії", + "icons": "Іконки категорій", + "colors": "Категорія кольори", + "byUseCase": "За випадком використання", + "byFeature": "За ознакою", + "deleteCategory": "Видалити категорію", + "deleteCategoryDescription": "Ви впевнені, що хочете видалити цю категорію?", + "typeToSearch": "Введіть для пошуку категорій..." + }, + "creator": { + "label": "Творець шаблонів", + "name": "Ім'я творця", + "avatar": "Аватар творця", + "accountLinks": "Посилання на акаунт творця", + "uploadAvatar": "Натисніть, щоб завантажити аватар", + "deleteCreator": "Видалити творця", + "deleteCreatorDescription": "Ви впевнені, що хочете видалити цього творця?", + "typeToSearch": "Введіть для пошуку творців..." + }, + "uploadSuccess": "Шаблон успішно завантажено", + "uploadSuccessDescription": "Ваш шаблон успішно завантажено. Тепер ви можете переглянути його в галереї шаблонів.", + "viewTemplate": "Переглянути шаблон", + "deleteTemplate": "Видалити шаблон", + "deleteTemplateDescription": "Ви впевнені, що хочете видалити цей шаблон?", + "addRelatedTemplate": "Додайте відповідний шаблон", + "removeRelatedTemplate": "Видалити пов’язаний шаблон", + "uploadAvatar": "Завантажити аватар", + "searchInCategory": "Шукати в {category}", + "label": "Шаблон" + }, + "fileDropzone": { + "dropFile": "Натисніть або перетягніть файл у цю область, щоб завантажити", + "uploading": "Завантаження...", + "uploadFailed": "Помилка завантаження", + "uploadSuccess": "Завантаження успішне", + "uploadSuccessDescription": "Файл успішно завантажено", + "uploadFailedDescription": "Не вдалося завантажити файл", + "uploadingDescription": "Файл завантажується" } -} \ No newline at end of file +} diff --git a/frontend/resources/translations/ur.json b/frontend/resources/translations/ur.json index 1d4f936d37..e981a41f3b 100644 --- a/frontend/resources/translations/ur.json +++ b/frontend/resources/translations/ur.json @@ -331,7 +331,7 @@ "name": "نام", "email": "ای میل", "selectAnIcon": "آئیکن منتخب کریں", - "pleaseInputYourOpenAIKey": "براہ کرم اپنی OpenAI کی درج کریں", + "pleaseInputYourOpenAIKey": "براہ کرم اپنی AI کی درج کریں", "clickToLogout": "موجودہ صارف سے لاگ آؤٹ کرنے کے لیے کلک کریں" }, "shortcuts": { @@ -511,23 +511,23 @@ "referencedBoard": "حوالہ شدہ بورڈ", "referencedGrid": "حوالہ شدہ گرِڈ", "referencedCalendar": "حوالہ شدہ کیلنڈر", - "autoGeneratorMenuItemName": "OpenAI رائٹر", - "autoGeneratorTitleName": "OpenAI: AI سے کچھ بھی لکھنے کے لیے کہیں...", + "autoGeneratorMenuItemName": "AI رائٹر", + "autoGeneratorTitleName": "AI: AI سے کچھ بھی لکھنے کے لیے کہیں...", "autoGeneratorLearnMore": "مزید جانئے", "autoGeneratorGenerate": "جنریٹ کریں", - "autoGeneratorHintText": "OpenAI سے پوچھیں...", - "autoGeneratorCantGetOpenAIKey": "OpenAI کی حاصل نہیں کر سکتا", + "autoGeneratorHintText": "AI سے پوچھیں...", + "autoGeneratorCantGetOpenAIKey": "AI کی حاصل نہیں کر سکتا", "autoGeneratorRewrite": "دوبارہ لکھیں", "smartEdit": "AI اسسٹنٹ", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "املا درست کریں", "warning": "⚠️ AI کی پاسخیں غلط یا گمراہ کن ہو سکتی ہیں۔", "smartEditSummarize": "سارے لکھیں", "smartEditImproveWriting": "تحریر بہتر بنائیں", "smartEditMakeLonger": "طویل تر بنائیں", - "smartEditCouldNotFetchResult": "OpenAI سے نتیجہ حاصل نہیں کر سکا", - "smartEditCouldNotFetchKey": "OpenAI کی حاصل نہیں کر سکا", - "smartEditDisabled": "Settings میں OpenAI سے منسلک کریں", + "smartEditCouldNotFetchResult": "AI سے نتیجہ حاصل نہیں کر سکا", + "smartEditCouldNotFetchKey": "AI کی حاصل نہیں کر سکا", + "smartEditDisabled": "Settings میں AI سے منسلک کریں", "discardResponse": "کیا آپ AI کی پاسخیں حذف کرنا چاہتے ہیں؟", "createInlineMathEquation": "مساوات بنائیں", "toggleList": "فہرست ٹوگل کریں", diff --git a/frontend/resources/translations/vi-VN.json b/frontend/resources/translations/vi-VN.json index 09b73db901..e60648590d 100644 --- a/frontend/resources/translations/vi-VN.json +++ b/frontend/resources/translations/vi-VN.json @@ -26,7 +26,7 @@ "repeatPasswordEmptyError": "Mật khẩu nhập lại không được để trống", "unmatchedPasswordError": "Mật khẩu nhập lại không giống mật khẩu", "alreadyHaveAnAccount": "Đã có tài khoản?", - "emailHint": "Email", + "emailHint": "E-mail", "passwordHint": "Mật khẩu", "repeatPasswordHint": "Nhập lại mật khẩu", "signUpWith": "Đăng ký với:" @@ -36,18 +36,39 @@ "loginButtonText": "Đăng nhập", "loginStartWithAnonymous": "Bắt đầu với một phiên ẩn danh", "continueAnonymousUser": "Tiếp tục với một phiên ẩn danh", + "anonymous": "Ẩn danh", "buttonText": "Đăng nhập", "signingInText": "Đang đăng nhập...", "forgotPassword": "Quên mật khẩu?", - "emailHint": "Email", + "emailHint": "E-mail", "passwordHint": "Mật khẩu", "dontHaveAnAccount": "Bạn chưa có tài khoản?", + "createAccount": "Tạo tài khoản", "repeatPasswordEmptyError": "Mật khẩu nhập lại không được để trống", "unmatchedPasswordError": "Mật khẩu nhập lại không giống mật khẩu", "syncPromptMessage": "Việc đồng bộ hóa dữ liệu có thể mất một lúc. Xin đừng đóng trang này", "or": "HOẶC", + "signInWithGoogle": "Tiếp tục với Google", + "signInWithGithub": "Tiếp tục với Github", + "signInWithDiscord": "Tiếp tục với Discord", + "signInWithApple": "Tiếp tục với Apple", + "continueAnotherWay": "Tiếp tục theo cách khác", + "signUpWithGoogle": "Đăng ký với Google", + "signUpWithGithub": "Đăng ký với Github", + "signUpWithDiscord": "Đăng ký với Discord", "signInWith": "Đăng nhập bằng:", "signInWithEmail": "Đăng nhập bằng Email", + "signInWithMagicLink": "Tiếp tục", + "signUpWithMagicLink": "Đăng ký với Magic Link", + "pleaseInputYourEmail": "Vui lòng nhập địa chỉ email của bạn", + "settings": "Cài đặt", + "magicLinkSent": "Đã gửi Magic Link!", + "invalidEmail": "Vui lòng nhập địa chỉ email hợp lệ", + "alreadyHaveAnAccount": "Bạn đã có tài khoản?", + "logIn": "Đăng nhập", + "generalError": "Có gì đó không ổn. Vui lòng thử lại sau", + "limitRateError": "Vì lý do bảo mật, bạn chỉ có thể yêu cầu Magic Link sau mỗi 60 giây", + "magicLinkSentDescription": "Một Magic Link đã được gửi đến email của bạn. Nhấp vào liên kết để hoàn tất đăng nhập. Liên kết sẽ hết hạn sau 5 phút.", "LogInWithGoogle": "Đăng nhập bằng Google", "LogInWithGithub": "Đăng nhập bằng Github", "LogInWithDiscord": "Đăng nhập bằng Discord", @@ -57,10 +78,11 @@ "chooseWorkspace": "Chọn không gian làm việc của bạn", "create": "Tạo không gian làm việc", "reset": "Đặt lại không gian làm việc", + "renameWorkspace": "Đổi tên không gian làm việc", "resetWorkspacePrompt": "Đặt lại không gian làm việc sẽ xóa tất cả các trang và dữ liệu trong đó. Bạn có chắc chắn muốn đặt lại không gian làm việc? Ngoài ra, bạn có thể liên hệ với nhóm hỗ trợ để khôi phục không gian làm việc", "hint": "không gian làm việc", "notFoundError": "Không tìm thấy không gian làm việc", - "failedToLoad": "Đã xảy ra lỗi! Không tải được không gian làm việc. Hãy thử đóng mọi phiên bản đang mở của AppFlowy và thử lại.", + "failedToLoad": "Đã xảy ra lỗi! Không tải được không gian làm việc. Hãy thử đóng mọi phiên bản đang mở của @:appName và thử lại.", "errorActions": { "reportIssue": "Báo cáo một vấn đề", "reportIssueOnGithub": "Báo cáo sự cố trên Github", @@ -89,15 +111,25 @@ "buttonText": "Chia sẻ", "workInProgress": "Sắp ra mắt", "markdown": "Markdown", + "html": "HTML", + "clipboard": "Sao chép vào clipboard", "csv": "CSV", - "copyLink": "Sao chép đường dẫn" + "copyLink": "Sao chép đường dẫn", + "publishToTheWeb": "Xuất bản lên Web", + "publishToTheWebHint": "Tạo trang web với AppFlowy", + "publish": "Xuất bản", + "unPublish": "Hủy xuất bản", + "visitSite": "Truy cập trang web", + "exportAsTab": "Xuất khẩu dưới dạng", + "publishTab": "Xuất bản", + "shareTab": "Chia sẻ" }, "moreAction": { "small": "nhỏ", "medium": "trung bình", "large": "lớn", "fontSize": "Cỡ chữ", - "import": "Import", + "import": "Nhập", "moreOptions": "Lựa chọn khác", "wordCount": "Số từ: {}", "charCount": "Số ký tự: {}", @@ -121,7 +153,9 @@ "openNewTab": "Mở trong tab mới", "moveTo": "Chuyển tới", "addToFavorites": "Thêm vào mục yêu thích", - "copyLink": "Sao chép đường dẫn" + "copyLink": "Sao chép đường dẫn", + "changeIcon": "Thay đổi biểu tượng", + "collapseAllPages": "Thu gọn tất cả các trang con" }, "blankPageTitle": "Trang trống", "newPageText": "Trang mới", @@ -129,6 +163,34 @@ "newGridText": "Lưới mới", "newCalendarText": "Lịch mới", "newBoardText": "Bảng mới", + "chat": { + "newChat": "AI Chat", + "inputMessageHint": "Hỏi @:appName AI", + "inputLocalAIMessageHint": "Hỏi @:appName AI cục bộ", + "unsupportedCloudPrompt": "Tính năng này chỉ khả dụng khi sử dụng @:appName Cloud", + "relatedQuestion": "Có liên quan", + "serverUnavailable": "Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.", + "aiServerUnavailable": "🌈 Ồ không! 🌈. Một con kỳ lân đã ăn mất câu trả lời của chúng tôi. Vui lòng thử lại!", + "clickToRetry": "Nhấp để thử lại", + "regenerateAnswer": "Tạo lại", + "question1": "Cách sử dụng Kanban để quản lý nhiệm vụ", + "question2": "Giải thích phương pháp GTD", + "question3": "Tại sao sử dụng Rust", + "question4": "Công thức với những gì đang có", + "aiMistakePrompt": "AI có thể mắc lỗi. Hãy kiểm tra thông tin quan trọng.", + "chatWithFilePrompt": "Bạn có muốn trò chuyện với tập tin không?", + "indexFileSuccess": "Đang lập chỉ mục tệp thành công", + "inputActionNoPages": "Không có kết quả trang", + "referenceSource": { + "zero": "0 nguồn được tìm thấy", + "one": "{count} nguồn đã tìm thấy", + "other": "{count} nguồn được tìm thấy" + }, + "clickToMention": "Nhấp để đề cập đến một trang", + "uploadFile": "Tải lên các tệp PDF, md hoặc txt để trò chuyện", + "questionDetail": "Xin chào {}! Tôi có thể giúp gì cho bạn hôm nay?", + "indexingFile": "Đang lập chỉ mục {}" + }, "trash": { "text": "Thùng rác", "restoreAll": "Khôi phục lại tất cả", @@ -164,14 +226,14 @@ "questionBubble": { "shortcuts": "Phím tắt", "whatsNew": "Có gì mới?", - "help": "Trợ giúp & Hỗ trợ", "markdown": "Markdown", "debug": { "name": "Thông tin gỡ lỗi", "success": "Đã sao chép thông tin gỡ lỗi vào khay nhớ tạm!", "fail": "Không thể sao chép thông tin gỡ lỗi vào khay nhớ tạm" }, - "feedback": "Nhận xét" + "feedback": "Nhận xét", + "help": "Trợ giúp & Hỗ trợ" }, "menuAppHeader": { "moreButtonToolTip": "Xóa, đổi tên và hơn thế nữa...", @@ -190,6 +252,8 @@ "numList": "Danh sách đánh số", "bulletList": "Danh sách có dấu đầu dòng", "checkList": "Danh mục", + "inlineCode": "Mã nội tuyến", + "quote": "Khối trích dẫn", "header": "Tiêu đề", "highlight": "Điểm nổi bật", "color": "Màu", @@ -205,7 +269,8 @@ "dragRow": "Nhấn và giữ để sắp xếp lại hàng", "viewDataBase": "Xem cơ sở dữ liệu", "referencePage": "{name} này được tham chiếu", - "addBlockBelow": "Thêm một khối bên dưới" + "addBlockBelow": "Thêm một khối bên dưới", + "aiGenerate": "Tạo" }, "sideBar": { "closeSidebar": "Đóng thanh bên", @@ -214,10 +279,46 @@ "private": "Riêng tư", "workspace": "Không gian làm việc", "favorites": "Yêu thích", + "clickToHidePrivate": "Nhấp để ẩn không gian riêng tư\nCác trang bạn tạo ở đây chỉ hiển thị với bạn", + "clickToHideWorkspace": "Nhấp để ẩn không gian làm việc\nCác trang bạn tạo ở đây có thể được mọi thành viên nhìn thấy", "clickToHidePersonal": "Bấm để ẩn mục riêng tư", "clickToHideFavorites": "Bấm để ẩn mục yêu thích", "addAPage": "Thêm một trang", - "recent": "Gần đây" + "addAPageToPrivate": "Thêm một trang vào không gian riêng tư", + "addAPageToWorkspace": "Thêm một trang vào không gian làm việc", + "recent": "Gần đây", + "today": "Hôm nay", + "thisWeek": "Tuần này", + "others": "Yêu thích trước đó", + "justNow": "ngay bây giờ", + "minutesAgo": "{count} phút trước", + "lastViewed": "Đã xem lần cuối", + "favoriteAt": "Đã yêu thích", + "emptyRecent": "Không có tài liệu gần đây", + "emptyRecentDescription": "Khi bạn xem tài liệu, chúng sẽ xuất hiện ở đây để dễ dàng truy xuất", + "emptyFavorite": "Không có tài liệu yêu thích", + "emptyFavoriteDescription": "Bắt đầu khám phá và đánh dấu tài liệu là mục yêu thích. Chúng sẽ được liệt kê ở đây để truy cập nhanh!", + "removePageFromRecent": "Xóa trang này khỏi mục Gần đây?", + "removeSuccess": "Đã xóa thành công", + "favoriteSpace": "Yêu thích", + "RecentSpace": "Gần đây", + "Spaces": "Khoảng cách", + "upgradeToPro": "Nâng cấp lên Pro", + "upgradeToAIMax": "Mở khóa AI không giới hạn", + "storageLimitDialogTitle": "Bạn đã hết dung lượng lưu trữ miễn phí. Nâng cấp để mở khóa dung lượng lưu trữ không giới hạn", + "storageLimitDialogTitleIOS": "Bạn đã hết dung lượng lưu trữ miễn phí.", + "aiResponseLimitTitle": "Bạn đã hết phản hồi AI miễn phí. Nâng cấp lên Gói Pro hoặc mua tiện ích bổ sung AI để mở khóa phản hồi không giới hạn", + "aiResponseLimitDialogTitle": "Đã đạt đến giới hạn sử dụng AI", + "aiResponseLimit": "Bạn đã hết lượt dùng AI miễn phí.\nVào Cài đặt -> Gói đăng ký -> Nhấp vào AI Max hoặc Gói Pro để có thêm lượt dùng AI", + "askOwnerToUpgradeToPro": "Không gian làm việc của bạn sắp hết dung lượng lưu trữ miễn phí. Vui lòng yêu cầu chủ sở hữu không gian làm việc của bạn nâng cấp lên Gói Pro", + "askOwnerToUpgradeToProIOS": "Không gian làm việc của bạn sắp hết dung lượng lưu trữ miễn phí.", + "askOwnerToUpgradeToAIMax": "Không gian làm việc của bạn sắp hết phản hồi AI miễn phí. Vui lòng yêu cầu chủ sở hữu không gian làm việc của bạn nâng cấp gói hoặc mua tiện ích bổ sung AI", + "askOwnerToUpgradeToAIMaxIOS": "Không gian làm việc của bạn sắp hết lượt sử dụng AI miễn phí.", + "purchaseStorageSpace": "Mua không gian lưu trữ", + "purchaseAIResponse": "Mua ", + "askOwnerToUpgradeToLocalAI": "Yêu cầu chủ sở hữu không gian làm việc bật AI trên thiết bị", + "upgradeToAILocal": "Chạy các mô hình cục bộ trên thiết bị của bạn để có quyền riêng tư tối đa", + "upgradeToAILocalDesc": "Trò chuyện với PDF, cải thiện khả năng viết và tự động điền bảng bằng AI cục bộ" }, "notifications": { "export": { @@ -233,6 +334,7 @@ }, "button": { "ok": "OK", + "confirm": "Xác nhận", "done": "Xong", "cancel": "Hủy", "signIn": "Đăng nhập", @@ -255,19 +357,35 @@ "update": "Cập nhật", "share": "Chia sẻ", "removeFromFavorites": "Loại bỏ khỏi mục yêu thích", + "removeFromRecent": "Xóa khỏi gần đây", "addToFavorites": "Thêm vào mục yêu thích", + "favoriteSuccessfully": "Đã thêm vào yêu thích", + "unfavoriteSuccessfully": "Đã xoá khỏi yêu thích", + "duplicateSuccessfully": "Đã sao chép thành công", "rename": "Đổi tên", "helpCenter": "Trung tâm trợ giúp", "add": "Thêm", "yes": "Đúng", + "no": "Không", + "clear": "Xoá", + "remove": "Di chuyển", + "dontRemove": "Không xóa", "copyLink": "Sao chép đường dẫn", "align": "Căn chỉnh", "login": "Đăng nhập", "logout": "Đăng xuất", "deleteAccount": "Xóa tài khoản", + "back": "Quay lại", "signInGoogle": "Đăng nhập bằng Google", "signInGithub": "Đăng nhập bằng Github", "signInDiscord": "Đăng nhập bằng Discord", + "more": "Hơn nữa", + "create": "Tạo mới", + "close": "Đóng", + "next": "Kế tiếp", + "previous": "Trước đó", + "submit": "Gửi", + "download": "Tải về", "tryAGain": "Thử lại" }, "label": { @@ -292,6 +410,520 @@ }, "settings": { "title": "Cài đặt", + "popupMenuItem": { + "settings": "Cài đặt", + "members": "Thành viên", + "trash": "Thùng Rác", + "helpAndSupport": "Trợ giúp & Hỗ trợ" + }, + "accountPage": { + "menuLabel": "Tài khoản của tôi", + "title": "Tài khoản của tôi", + "general": { + "title": "Tên tài khoản & ảnh đại diện", + "changeProfilePicture": "Thay đổi ảnh đại diện" + }, + "email": { + "title": "E-mail", + "actions": { + "change": "Thay đổi email" + } + }, + "login": { + "title": "Đăng nhập tài khoản", + "loginLabel": "Đăng nhập", + "logoutLabel": "Đăng xuất" + } + }, + "workspacePage": { + "menuLabel": "Không gian làm việc", + "title": "Không gian làm việc", + "description": "Tùy chỉnh giao diện không gian làm việc, chủ đề, phông chữ, bố cục văn bản, định dạng ngày/giờ và ngôn ngữ.", + "workspaceName": { + "title": "Tên không gian làm việc" + }, + "workspaceIcon": { + "title": "Biểu tượng không gian làm việc", + "description": "Tải lên hình ảnh hoặc sử dụng biểu tượng cảm xúc cho không gian làm việc của bạn. Biểu tượng sẽ hiển thị trên thanh bên và thông báo của bạn." + }, + "appearance": { + "title": "Vẻ bề ngoài", + "description": "Tùy chỉnh giao diện không gian làm việc, chủ đề, phông chữ, bố cục văn bản, ngày, giờ và ngôn ngữ.", + "options": { + "system": "Tự động", + "light": "Sáng", + "dark": "Tối" + } + }, + "resetCursorColor": { + "title": "Đặt lại màu con trỏ tài liệu", + "description": "Bạn có chắc chắn muốn thiết lập lại màu con trỏ không?" + }, + "resetSelectionColor": { + "title": "Đặt lại màu lựa chọn tài liệu", + "description": "Bạn có chắc chắn muốn thiết lập lại màu đã chọn không?" + }, + "theme": { + "title": "Chủ đề", + "description": "Chọn chủ đề có sẵn hoặc tải lên chủ đề tùy chỉnh của riêng bạn.", + "uploadCustomThemeTooltip": "Tải lên một chủ đề tùy chỉnh" + }, + "workspaceFont": { + "title": "Phông chữ không gian làm việc", + "noFontHint": "Không tìm thấy phông chữ, hãy thử thuật ngữ khác." + }, + "textDirection": { + "title": "Hướng văn bản", + "leftToRight": "Từ trái sang phải", + "rightToLeft": "Từ phải sang trái", + "auto": "Tự động", + "enableRTLItems": "Bật các mục thanh công cụ RTL" + }, + "layoutDirection": { + "title": "Hướng bố trí", + "leftToRight": "Từ trái sang phải", + "rightToLeft": "Từ phải sang trái" + }, + "dateTime": { + "title": "Ngày & giờ", + "example": "{} tại {} ({})", + "24HourTime": "thời gian 24 giờ", + "dateFormat": { + "label": "Định dạng ngày tháng", + "local": "Địa phương", + "us": "US", + "iso": "ISO", + "friendly": "Thân thiện", + "dmy": "D/M/Y" + } + }, + "language": { + "title": "Ngôn ngữ" + }, + "deleteWorkspacePrompt": { + "title": "Xóa không gian làm việc", + "content": "Bạn có chắc chắn muốn xóa không gian làm việc này không? Hành động này không thể hoàn tác và bất kỳ trang nào bạn đã xuất bản sẽ bị hủy xuất bản." + }, + "leaveWorkspacePrompt": { + "title": "Rời khỏi không gian làm việc", + "content": "Bạn có chắc chắn muốn rời khỏi không gian làm việc này không? Bạn sẽ mất quyền truy cập vào tất cả các trang và dữ liệu trong đó." + }, + "manageWorkspace": { + "title": "Quản lý không gian làm việc", + "leaveWorkspace": "Rời khỏi không gian làm việc", + "deleteWorkspace": "Xóa không gian làm việc" + } + }, + "manageDataPage": { + "menuLabel": "Quản lý dữ liệu", + "title": "Quản lý dữ liệu", + "description": "Quản lý dữ liệu lưu trữ cục bộ hoặc Nhập dữ liệu hiện có của bạn vào @:appName .", + "dataStorage": { + "title": "Vị trí lưu trữ tập tin", + "tooltip": "Vị trí lưu trữ các tập tin của bạn", + "actions": { + "change": "Thay đổi đường dẫn", + "open": "Mở thư mục", + "openTooltip": "Mở vị trí thư mục dữ liệu hiện tại", + "copy": "Sao chép đường dẫn", + "copiedHint": "Đã sao chép đường dẫn!", + "resetTooltip": "Đặt lại về vị trí mặc định" + }, + "resetDialog": { + "title": "Bạn có chắc không?", + "description": "Đặt lại đường dẫn đến vị trí dữ liệu mặc định sẽ không xóa dữ liệu của bạn. Nếu bạn muốn nhập lại dữ liệu hiện tại, trước tiên bạn nên sao chép đường dẫn đến vị trí hiện tại của mình." + } + }, + "importData": { + "title": "Nhập dữ liệu", + "tooltip": "Nhập dữ liệu từ các thư mục sao lưu/dữ liệu @:appName", + "description": "Sao chép dữ liệu từ thư mục dữ liệu @:appName bên ngoài", + "action": "Duyệt tập tin" + }, + "encryption": { + "title": "Mã hóa", + "tooltip": "Quản lý cách dữ liệu của bạn được lưu trữ và mã hóa", + "descriptionNoEncryption": "Bật mã hóa sẽ mã hóa toàn bộ dữ liệu. Không thể hoàn tác thao tác này.", + "descriptionEncrypted": "Dữ liệu của bạn được mã hóa.", + "action": "Mã hóa dữ liệu", + "dialog": { + "title": "Mã hóa toàn bộ dữ liệu của bạn?", + "description": "Mã hóa tất cả dữ liệu của bạn sẽ giữ cho dữ liệu của bạn an toàn và bảo mật. Hành động này KHÔNG THỂ hoàn tác. Bạn có chắc chắn muốn tiếp tục không?" + } + }, + "cache": { + "title": "Xóa bộ nhớ đệm", + "description": "Giúp giải quyết các vấn đề như hình ảnh không tải được, thiếu trang trong một khoảng trắng và phông chữ không tải được. Điều này sẽ không ảnh hưởng đến dữ liệu của bạn.", + "dialog": { + "title": "Xóa bộ nhớ đệm", + "description": "Giúp giải quyết các vấn đề như hình ảnh không tải được, thiếu trang trong một khoảng trắng và phông chữ không tải được. Điều này sẽ không ảnh hưởng đến dữ liệu của bạn.", + "successHint": "Đã xóa bộ nhớ đệm!" + } + }, + "data": { + "fixYourData": "Sửa dữ liệu của bạn", + "fixButton": "Sửa", + "fixYourDataDescription": "Nếu bạn gặp sự cố với dữ liệu, bạn có thể thử khắc phục tại đây." + } + }, + "shortcutsPage": { + "menuLabel": "Phím tắt", + "title": "Phím tắt", + "editBindingHint": "Nhập phím tắt mới", + "searchHint": "Tìm kiếm", + "actions": { + "resetDefault": "Đặt lại mặc định" + }, + "errorPage": { + "message": "Không tải được phím tắt: {}", + "howToFix": "Vui lòng thử lại. Nếu sự cố vẫn tiếp diễn, vui lòng liên hệ trên GitHub." + }, + "resetDialog": { + "title": "Đặt lại phím tắt", + "description": "Thao tác này sẽ khôi phục tất cả các phím tắt của bạn về mặc định, bạn không thể hoàn tác thao tác này sau đó, bạn có chắc chắn muốn tiếp tục không?", + "buttonLabel": "Cài lại" + }, + "conflictDialog": { + "title": "{} hiện đang được sử dụng", + "descriptionPrefix": "Phím tắt này hiện đang được sử dụng bởi ", + "descriptionSuffix": ". Nếu bạn thay thế phím tắt này, nó sẽ bị xóa khỏi {}.", + "confirmLabel": "Tiếp tục" + }, + "editTooltip": "Nhấn để bắt đầu chỉnh sửa phím tắt", + "keybindings": { + "toggleToDoList": "Chuyển sang danh sách việc cần làm", + "insertNewParagraphInCodeblock": "Chèn đoạn văn mới", + "pasteInCodeblock": "Dán vào codeblock", + "selectAllCodeblock": "Chọn tất cả", + "indentLineCodeblock": "Chèn hai khoảng trắng vào đầu dòng", + "outdentLineCodeblock": "Xóa hai khoảng trắng ở đầu dòng", + "twoSpacesCursorCodeblock": "Chèn hai khoảng trắng vào con trỏ", + "copy": "Sao chép lựa chọn", + "paste": "Dán vào nội dung", + "cut": "Cắt lựa chọn", + "alignLeft": "Căn chỉnh văn bản sang trái", + "alignCenter": "Căn giữa văn bản", + "alignRight": "Căn chỉnh văn bản bên phải", + "undo": "Hoàn tác", + "redo": "Làm lại", + "convertToParagraph": "Chuyển đổi khối thành đoạn văn", + "backspace": "Xóa bỏ", + "deleteLeftWord": "Xóa từ bên trái", + "deleteLeftSentence": "Xóa câu bên trái", + "delete": "Xóa ký tự bên phải", + "deleteMacOS": "Xóa ký tự bên trái", + "deleteRightWord": "Xóa từ bên phải", + "moveCursorLeft": "Di chuyển con trỏ sang trái", + "moveCursorBeginning": "Di chuyển con trỏ đến đầu", + "moveCursorLeftWord": "Di chuyển con trỏ sang trái một từ", + "moveCursorLeftSelect": "Chọn và di chuyển con trỏ sang trái", + "moveCursorBeginSelect": "Chọn và di chuyển con trỏ đến đầu", + "moveCursorLeftWordSelect": "Chọn và di chuyển con trỏ sang trái một từ", + "moveCursorRight": "Di chuyển con trỏ sang phải", + "moveCursorEnd": "Di chuyển con trỏ đến cuối", + "moveCursorRightWord": "Di chuyển con trỏ sang phải một từ", + "moveCursorRightSelect": "Chọn và di chuyển con trỏ sang phải một", + "moveCursorEndSelect": "Chọn và di chuyển con trỏ đến cuối", + "moveCursorRightWordSelect": "Chọn và di chuyển con trỏ sang phải một từ", + "moveCursorUp": "Di chuyển con trỏ lên", + "moveCursorTopSelect": "Chọn và di chuyển con trỏ lên trên cùng", + "moveCursorTop": "Di chuyển con trỏ lên trên cùng", + "moveCursorUpSelect": "Chọn và di chuyển con trỏ lên", + "moveCursorBottomSelect": "Chọn và di chuyển con trỏ xuống dưới cùng", + "moveCursorBottom": "Di chuyển con trỏ xuống dưới cùng", + "moveCursorDown": "Di chuyển con trỏ xuống", + "moveCursorDownSelect": "Chọn và di chuyển con trỏ xuống", + "home": "Cuộn lên đầu trang", + "end": "Cuộn xuống dưới cùng", + "toggleBold": "Chuyển đổi chữ đậm", + "toggleItalic": "Chuyển đổi nghiêng", + "toggleUnderline": "Chuyển đổi gạch chân", + "toggleStrikethrough": "Chuyển đổi gạch ngang", + "toggleCode": "Chuyển đổi mã trong dòng", + "toggleHighlight": "Chuyển đổi nổi bật", + "showLinkMenu": "Hiển thị menu liên kết", + "openInlineLink": "Mở liên kết nội tuyến", + "openLinks": "Mở tất cả các liên kết đã chọn", + "indent": "thụt lề", + "outdent": "Nhô ra ngoài", + "exit": "Thoát khỏi chỉnh sửa", + "pageUp": "Cuộn lên một trang", + "pageDown": "Cuộn xuống một trang", + "selectAll": "Chọn tất cả", + "pasteWithoutFormatting": "Dán nội dung không định dạng", + "showEmojiPicker": "Hiển thị bộ chọn biểu tượng cảm xúc", + "enterInTableCell": "Thêm ngắt dòng trong bảng", + "leftInTableCell": "Di chuyển sang trái một ô trong bảng", + "rightInTableCell": "Di chuyển sang phải một ô trong bảng", + "upInTableCell": "Di chuyển lên một ô trong bảng", + "downInTableCell": "Di chuyển xuống một ô trong bảng", + "tabInTableCell": "Đi tới ô có sẵn tiếp theo trong bảng", + "shiftTabInTableCell": "Đi đến ô có sẵn trước đó trong bảng", + "backSpaceInTableCell": "Dừng lại ở đầu ô" + }, + "commands": { + "codeBlockNewParagraph": "Chèn một đoạn văn mới bên cạnh khối mã", + "codeBlockIndentLines": "Chèn hai khoảng trắng vào đầu dòng trong khối mã", + "codeBlockOutdentLines": "Xóa hai khoảng trắng ở đầu dòng trong khối mã", + "codeBlockAddTwoSpaces": "Chèn hai khoảng trắng vào vị trí con trỏ trong khối mã", + "codeBlockSelectAll": "Chọn tất cả nội dung bên trong một khối mã", + "codeBlockPasteText": "Dán văn bản vào codeblock", + "textAlignLeft": "Căn chỉnh văn bản sang trái", + "textAlignCenter": "Căn chỉnh văn bản vào giữa", + "textAlignRight": "Căn chỉnh văn bản sang phải" + }, + "couldNotLoadErrorMsg": "Không thể tải phím tắt, hãy thử lại", + "couldNotSaveErrorMsg": "Không thể lưu phím tắt, hãy thử lại" + }, + "aiPage": { + "title": "Cài đặt AI", + "menuLabel": "Cài đặt AI", + "keys": { + "enableAISearchTitle": "Tìm kiếm AI", + "aiSettingsDescription": "Chọn mô hình ưa thích của bạn để hỗ trợ AppFlowy AI. Bây giờ bao gồm GPT 4-o, Claude 3,5, Llama 3.1 và Mistral 7B", + "loginToEnableAIFeature": "Các tính năng AI chỉ được bật sau khi đăng nhập bằng @:appName Cloud. Nếu bạn không có tài khoản @:appName , hãy vào 'Tài khoản của tôi' để đăng ký", + "llmModel": "Mô hình ngôn ngữ", + "llmModelType": "Kiểu mô hình ngôn ngữ", + "downloadLLMPrompt": "Tải xuống {}", + "downloadAppFlowyOfflineAI": "Tải xuống gói AI ngoại tuyến sẽ cho phép AI chạy trên thiết bị của bạn. Bạn có muốn tiếp tục không?", + "downloadLLMPromptDetail": "Tải xuống mô hình cục bộ {} sẽ chiếm tới {} dung lượng lưu trữ. Bạn có muốn tiếp tục không?", + "downloadBigFilePrompt": "Có thể mất khoảng 10 phút để hoàn tất việc tải xuống", + "downloadAIModelButton": "Tải về", + "downloadingModel": "Đang tải xuống", + "localAILoaded": "Mô hình AI cục bộ đã được thêm thành công và sẵn sàng sử dụng", + "localAIStart": "Trò chuyện AI cục bộ đang bắt đầu...", + "localAILoading": "Mô hình trò chuyện AI cục bộ đang tải...", + "localAIStopped": "AI cục bộ đã dừng", + "failToLoadLocalAI": "Không thể khởi động AI cục bộ", + "restartLocalAI": "Khởi động lại AI cục bộ", + "disableLocalAITitle": "Vô hiệu hóa AI cục bộ", + "disableLocalAIDescription": "Bạn có muốn tắt AI cục bộ không?", + "localAIToggleTitle": "Chuyển đổi để bật hoặc tắt AI cục bộ", + "offlineAIInstruction1": "Theo dõi", + "offlineAIInstruction2": "chỉ dẫn", + "offlineAIInstruction3": "để kích hoạt AI ngoại tuyến.", + "offlineAIDownload1": "Nếu bạn chưa tải xuống AppFlowy AI, vui lòng", + "offlineAIDownload2": "tải về", + "offlineAIDownload3": "nó đầu tiên", + "activeOfflineAI": "Tích cực", + "downloadOfflineAI": "Tải về", + "openModelDirectory": "Mở thư mục" + } + }, + "planPage": { + "menuLabel": "Kế hoạch", + "title": "Giá gói", + "planUsage": { + "title": "Tóm tắt sử dụng kế hoạch", + "storageLabel": "Kho", + "storageUsage": "{} của {} GB", + "unlimitedStorageLabel": "Lưu trữ không giới hạn", + "collaboratorsLabel": "Thành viên", + "collaboratorsUsage": "{} của {}", + "aiResponseLabel": "Phản hồi của AI", + "aiResponseUsage": "{} của {}", + "unlimitedAILabel": "Phản hồi không giới hạn", + "proBadge": "Chuyên nghiệp", + "aiMaxBadge": "AI Tối đa", + "aiOnDeviceBadge": "AI trên thiết bị dành cho máy Mac", + "memberProToggle": "Thêm thành viên và AI không giới hạn", + "aiMaxToggle": "AI không giới hạn và quyền truy cập vào các mô hình tiên tiến", + "aiOnDeviceToggle": "AI cục bộ cho sự riêng tư tối đa", + "aiCredit": { + "title": "Thêm @:appName Tín dụng AI", + "price": "{}", + "priceDescription": "cho 1.000 tín dụng", + "purchase": "Mua AI", + "info": "Thêm 1.000 tín dụng Ai cho mỗi không gian làm việc và tích hợp AI tùy chỉnh vào quy trình làm việc của bạn một cách liền mạch để có kết quả thông minh hơn, nhanh hơn với tối đa:", + "infoItemOne": "10.000 phản hồi cho mỗi cơ sở dữ liệu", + "infoItemTwo": "1.000 phản hồi cho mỗi không gian làm việc" + }, + "currentPlan": { + "bannerLabel": "Gói hiện tại", + "freeTitle": "Miễn phí", + "proTitle": "Pro", + "teamTitle": "Nhóm", + "freeInfo": "Hoàn hảo cho cá nhân có tối đa 2 thành viên để sắp xếp mọi thứ", + "proInfo": "Hoàn hảo cho các nhóm vừa và nhỏ có tối đa 10 thành viên.", + "teamInfo": "Hoàn hảo cho tất cả các nhóm làm việc hiệu quả và có tổ chức tốt.", + "upgrade": "Thay đổi gói đăng ký", + "canceledInfo": "Gói đăng ký của bạn đã bị hủy, bạn sẽ được hạ cấp xuống gói Miễn phí vào ngày {}." + }, + "addons": { + "title": "Tiện ích bổ sung", + "addLabel": "Thêm vào", + "activeLabel": "Đã thêm", + "aiMax": { + "title": "AI Max", + "description": "Phản hồi AI không giới hạn được hỗ trợ bởi GPT-4o, Claude 3.5 Sonnet và nhiều hơn nữa", + "price": "{}", + "priceInfo": "cho mỗi người dùng mỗi tháng được thanh toán hàng năm" + }, + "aiOnDevice": { + "title": "AI trên thiết bị dành cho máy Mac", + "description": "Chạy Mistral 7B, LLAMA 3 và nhiều mô hình cục bộ khác trên máy của bạn", + "price": "{}", + "priceInfo": "cho mỗi người dùng mỗi tháng được thanh toán hàng năm", + "recommend": "Khuyến nghị M1 hoặc mới hơn" + } + }, + "deal": { + "bannerLabel": "Khuyến mãi năm mới!", + "title": "Phát triển nhóm của bạn!", + "info": "Nâng cấp và tiết kiệm 10% cho gói Pro và Team! Tăng năng suất làm việc của bạn với các tính năng mới mạnh mẽ bao gồm @:appName AI.", + "viewPlans": "Xem các gói đăng ký" + } + } + }, + "billingPage": { + "menuLabel": "Thanh toán", + "title": "Thanh toán", + "plan": { + "title": "Gói đăng ký", + "freeLabel": "Miễn phí", + "proLabel": "Pro", + "planButtonLabel": "Thay đổi gói đăng ký", + "billingPeriod": "Chu kỳ thanh toán", + "periodButtonLabel": "Chỉnh sửa chu kỳ" + }, + "paymentDetails": { + "title": "Chi tiết thanh toán", + "methodLabel": "Phương thức thanh toán", + "methodButtonLabel": "Phương pháp chỉnh sửa" + }, + "addons": { + "title": "Tiện ích bổ sung", + "addLabel": "Thêm vào", + "removeLabel": "Xoá", + "renewLabel": "Làm mới", + "aiMax": { + "label": "AI Max", + "description": "Mở khóa AI không giới hạn và các mô hình tiên tiến", + "activeDescription": "Hóa đơn tiếp theo phải trả vào ngày {}", + "canceledDescription": "AI Max sẽ có sẵn cho đến {}" + }, + "aiOnDevice": { + "label": "AI trên thiết bị dành cho máy Mac", + "description": "Mở khóa AI không giới hạn trên thiết bị của bạn", + "activeDescription": "Hóa đơn tiếp theo phải trả vào ngày {}", + "canceledDescription": "AI On-device dành cho Mac sẽ khả dụng cho đến {}" + }, + "removeDialog": { + "title": "Xoá {}", + "description": "Bạn có chắc chắn muốn xóa {plan} không? Bạn sẽ mất quyền truy cập vào các tính năng và lợi ích của {plan} ngay lập tức." + } + }, + "currentPeriodBadge": "HIỆN TẠI", + "changePeriod": "Thay đổi chu kỳ", + "planPeriod": "{} chu kỳ", + "monthlyInterval": "Hàng tháng", + "monthlyPriceInfo": "mỗi thành viên được thanh toán hàng tháng", + "annualInterval": "Hàng năm", + "annualPriceInfo": "mỗi thành viên được thanh toán hàng năm" + }, + "comparePlanDialog": { + "title": "So sánh và lựa chọn gói đăng ký", + "planFeatures": "Gói đăng ký\nTính năng", + "current": "Hiện tại", + "actions": { + "upgrade": "Nâng cấp", + "downgrade": "Hạ cấp", + "current": "Hiện tại" + }, + "freePlan": { + "title": "Miễn phí", + "description": "Dành cho cá nhân có tối đa 2 thành viên để tổ chức mọi thứ", + "price": "{}", + "priceInfo": "miễn phí mãi mãi" + }, + "proPlan": { + "title": "Pro", + "description": "Dành cho các nhóm nhỏ để quản lý dự án và kiến thức của nhóm", + "price": "{}", + "priceInfo": "cho mỗi người dùng mỗi tháng\nđược thanh toán hàng năm\n\n{} được thanh toán hàng tháng" + }, + "planLabels": { + "itemOne": "Không gian làm việc", + "itemTwo": "Thành viên", + "itemThree": "Kho", + "itemFour": "Chỉnh sửa thời gian thực", + "itemFive": "Ứng dụng di động", + "itemSix": "Phản hồi của AI", + "itemFileUpload": "Tải tập tin lên", + "tooltipSix": "Trọn đời có nghĩa là số lượng phản hồi không bao giờ được thiết lập lại", + "intelligentSearch": "Tìm kiếm thông minh", + "tooltipSeven": "Cho phép bạn tùy chỉnh một phần URL cho không gian làm việc của bạn" + }, + "freeLabels": { + "itemOne": "tính phí theo không gian làm việc", + "itemTwo": "lên đến 2", + "itemThree": "5 GB", + "itemFour": "Đúng", + "itemFive": "Đúng", + "itemSix": "10 trọn đời", + "itemFileUpload": "Lên đến 7 MB", + "intelligentSearch": "Tìm kiếm thông minh" + }, + "proLabels": { + "itemOne": "tính phí theo không gian làm việc", + "itemTwo": "lên đến 10", + "itemThree": "không giới hạn", + "itemFour": "Đúng", + "itemFive": "Đúng", + "itemSix": "không giới hạn", + "itemFileUpload": "Không giới hạn", + "intelligentSearch": "Tìm kiếm thông minh" + }, + "paymentSuccess": { + "title": "Bạn hiện đang sử dụng gói {}!", + "description": "Thanh toán của bạn đã được xử lý thành công và gói của bạn đã được nâng cấp lên @:appName {}. Bạn có thể xem chi tiết gói đăng ký của mình trên trang Gói đăng ký" + }, + "downgradeDialog": { + "title": "Bạn có chắc chắn muốn hạ cấp gói đăng ký của mình không?", + "description": "Việc hạ cấp gói đăng ký của bạn sẽ đưa bạn trở lại gói Miễn phí. Các thành viên có thể mất quyền truy cập vào không gian làm việc này và bạn có thể cần giải phóng dung lượng để đáp ứng giới hạn lưu trữ của gói Miễn phí.", + "downgradeLabel": "Hạ cấp gói đăng ký" + } + }, + "cancelSurveyDialog": { + "title": "Rất tiếc khi phải tạm biệt bạn", + "description": "Chúng tôi rất tiếc khi phải tạm biệt bạn. Chúng tôi rất muốn nghe phản hồi của bạn để giúp chúng tôi cải thiện @:appName . Vui lòng dành chút thời gian để trả lời một vài câu hỏi.", + "commonOther": "Khác", + "otherHint": "Viết câu trả lời của bạn ở đây", + "questionOne": { + "question": "Điều gì khiến bạn hủy đăng ký @:appName Pro?", + "answerOne": "Chi phí quá cao", + "answerTwo": "Các tính năng không đáp ứng được kỳ vọng", + "answerThree": "Đã tìm thấy một giải pháp thay thế tốt hơn", + "answerFour": "Không sử dụng đủ để bù đắp chi phí", + "answerFive": "Vấn đề dịch vụ hoặc khó khăn kỹ thuật" + }, + "questionTwo": { + "question": "Bạn có khả năng cân nhắc đăng ký lại @:appName Pro trong tương lai không?", + "answerOne": "Rất có thể", + "answerTwo": "Có khả năng", + "answerThree": "Không chắc chắn", + "answerFour": "Không có khả năng", + "answerFive": "Rất không có khả năng" + }, + "questionThree": { + "question": "Bạn đánh giá cao tính năng Pro nào nhất trong suốt thời gian đăng ký?", + "answerOne": "Sự hợp tác của nhiều người dùng", + "answerTwo": "Lịch sử phiên bản thời gian dài hơn", + "answerThree": "Phản hồi AI không giới hạn", + "answerFour": "Truy cập vào các mô hình AI cục bộ" + }, + "questionFour": { + "question": "Bạn sẽ mô tả trải nghiệm chung của bạn với @:appName như thế nào?", + "answerOne": "Tuyệt", + "answerTwo": "Tốt", + "answerThree": "Trung bình", + "answerFour": "Dưới mức trung bình", + "answerFive": "Không hài lòng" + } + }, + "common": { + "reset": "Đặt lại" + }, "menu": { "appearance": "Giao diện", "language": "Ngôn ngữ", @@ -302,7 +934,7 @@ "logout": "Đăng xuất", "logoutPrompt": "Bạn chắc chắn muốn đăng xuất?", "selfEncryptionLogoutPrompt": "Bạn có chắc chắn bạn muốn thoát? Hãy đảm bảo bạn đã sao chép bí mật mã hóa", - "syncSetting": "Đồng bộ cài đặt", + "syncSetting": "Cài đặt đồng bộ", "cloudSettings": "Cài đặt đám mây", "enableSync": "Bật tính năng đồng bộ", "enableEncrypt": "Mã hoá dữ liệu", @@ -310,11 +942,10 @@ "invalidCloudURLScheme": "Scheme không hợp lệ", "cloudServerType": "Máy chủ đám mây", "cloudServerTypeTip": "Xin lưu ý rằng có thể bạn sẽ bị đăng xuất sau khi chuyển máy chủ đám mây", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "Supabase URL", - "cloudSupabaseAnonKey": "Supabase anon key", - "cloudSupabaseAnonKeyCanNotBeEmpty": "Anon key không được để trống nếu url supabase không trống", - "cloudAppFlowy": "AppFlowy Cloud Beta", + "cloudLocal": "Cục bộ", + "cloudAppFlowy": "@:appName Cloud Beta", + "cloudAppFlowySelfHost": "@:appName Cloud Tự lưu trữ", + "appFlowyCloudUrlCanNotBeEmpty": "URL đám mây không được để trống", "clickToCopy": "Bấm để sao chép", "selfHostStart": "Nếu bạn không có máy chủ, vui lòng tham khảo", "selfHostContent": "tài liệu", @@ -324,6 +955,7 @@ "cloudWSURLHint": "Nhập địa chỉ websocket của máy chủ của bạn", "restartApp": "Khởi động lại", "restartAppTip": "Khởi động lại ứng dụng để thay đổi có hiệu lực. Xin lưu ý rằng điều này có thể đăng xuất tài khoản hiện tại của bạn", + "changeServerTip": "Sau khi thay đổi máy chủ, bạn phải nhấp vào nút khởi động lại để những thay đổi có hiệu lực", "enableEncryptPrompt": "Kích hoạt mã hóa để bảo mật dữ liệu của bạn với bí mật này. Lưu trữ nó một cách an toàn; một khi đã bật thì không thể tắt được. Nếu bị mất, dữ liệu của bạn sẽ không thể phục hồi được. Bấm để sao chép", "inputEncryptPrompt": "Vui lòng nhập bí mật mã hóa của bạn cho", "clickToCopySecret": "Bấm để sao chép bí mật", @@ -331,25 +963,72 @@ "configServerGuide": "Sau khi chọn `Bắt đầu nhanh`, điều hướng đến `Cài đặt` rồi đến \"Cài đặt đám mây\" để định cấu hình máy chủ tự lưu trữ của bạn.", "inputTextFieldHint": "Bí mật của bạn", "historicalUserList": "Lịch sử đăng nhập", + "historicalUserListTooltip": "Danh sách này hiển thị các tài khoản ẩn danh của bạn. Bạn có thể nhấp vào một tài khoản để xem thông tin chi tiết của tài khoản đó. Các tài khoản ẩn danh được tạo bằng cách nhấp vào nút 'Bắt đầu'", "openHistoricalUser": "Ấn để mở tài khoản ẩn danh", - "importAppFlowyData": "Nhập dữ liệu từ thư mục AppFlowy bên ngoài", + "customPathPrompt": "Lưu trữ thư mục dữ liệu @:appName trong thư mục được đồng bộ hóa trên đám mây như Google Drive có thể gây ra rủi ro. Nếu cơ sở dữ liệu trong thư mục này được truy cập hoặc sửa đổi từ nhiều vị trí cùng một lúc, có thể dẫn đến xung đột đồng bộ hóa và hỏng dữ liệu tiềm ẩn", + "importAppFlowyData": "Nhập dữ liệu từ thư mục @:appName bên ngoài", "importingAppFlowyDataTip": "Quá trình nhập dữ liệu đang diễn ra. Vui lòng không đóng ứng dụng", - "importAppFlowyDataDescription": "Sao chép dữ liệu từ thư mục dữ liệu AppFlowy bên ngoài và nhập dữ liệu đó vào thư mục dữ liệu AppFlowy hiện tại", - "importSuccess": "Đã nhập thành công thư mục dữ liệu AppFlowy", - "importFailed": "Nhập thư mục dữ liệu AppFlowy không thành công", + "importAppFlowyDataDescription": "Sao chép dữ liệu từ thư mục dữ liệu @:appName bên ngoài và nhập dữ liệu đó vào thư mục dữ liệu @:appName hiện tại", + "importSuccess": "Đã nhập thành công thư mục dữ liệu @:appName", + "importFailed": "Nhập thư mục dữ liệu @:appName không thành công", "importGuide": "Để biết thêm chi tiết, vui lòng kiểm tra tài liệu được tham chiếu" }, "notifications": { "enableNotifications": { "label": "Bật thông báo", "hint": "Tắt để ngăn thông báo xuất hiện." + }, + "showNotificationsIcon": { + "label": "Hiển thị biểu tượng thông báo", + "hint": "Tắt để ẩn biểu tượng thông báo ở thanh bên." + }, + "archiveNotifications": { + "allSuccess": "Đã lưu trữ tất cả thông báo thành công", + "success": "Đã lưu trữ thông báo thành công" + }, + "markAsReadNotifications": { + "allSuccess": "Đã đánh dấu tất cả là đã đọc thành công", + "success": "Đã đánh dấu là đã đọc thành công" + }, + "action": { + "markAsRead": "Đánh dấu là đã đọc", + "multipleChoice": "Chọn thêm", + "archive": "Lưu trữ" + }, + "settings": { + "settings": "Cài đặt", + "markAllAsRead": "Đánh dấu tất cả là đã đọc", + "archiveAll": "Lưu trữ tất cả" + }, + "emptyInbox": { + "title": "Chưa có thông báo nào", + "description": "Bạn sẽ được thông báo ở đây về @đề cập" + }, + "emptyUnread": { + "title": "Không có thông báo chưa đọc", + "description": "Bạn đã hiểu hết rồi!" + }, + "emptyArchived": { + "title": "Không có thông báo lưu trữ", + "description": "Bạn chưa lưu trữ bất kỳ thông báo nào" + }, + "tabs": { + "inbox": "Hộp thư đến", + "unread": "Chưa đọc", + "archived": "Đã lưu trữ" + }, + "refreshSuccess": "Thông báo đã được làm mới thành công", + "titles": { + "notifications": "Thông báo", + "reminder": "Lời nhắc nhở" } }, "appearance": { "resetSetting": "Đặt lại cài đặt", "fontFamily": { "label": "Phông chữ", - "search": "Tìm kiếm" + "search": "Tìm kiếm", + "defaultFont": "Hệ thống" }, "themeMode": { "label": "Chế độ Theme", @@ -357,9 +1036,13 @@ "dark": "Chế độ tối", "system": "Thích ứng với hệ thống" }, + "fontScaleFactor": "Hệ số tỷ lệ phông chữ", "documentSettings": { "cursorColor": "Màu con trỏ", "selectionColor": "Màu lựa chọn tài liệu", + "pickColor": "Chọn một màu", + "colorShade": "Màu sắc bóng râm", + "opacity": "Độ mờ đục", "hexEmptyError": "Màu hex không được để trống", "hexLengthError": "Giá trị hex phải dài 6 chữ số", "hexInvalidError": "Giá trị hex không hợp lệ", @@ -386,16 +1069,16 @@ "themeUpload": { "button": "Tải lên", "uploadTheme": "Tải theme lên", - "description": "Tải lên AppFlowy theme của riêng bạn bằng nút bên dưới.", + "description": "Tải lên @:appName theme của riêng bạn bằng nút bên dưới.", "loading": "Vui lòng đợi trong khi chúng tôi xác thực và tải theme của bạn lên...", "uploadSuccess": "Theme của bạn đã được tải lên thành công", "deletionFailure": "Không xóa được theme. Hãy thử xóa nó bằng tay.", "filePickerDialogTitle": "Chọn tệp .flowy_plugin", "urlUploadFailure": "Không mở được url: {}" }, - "theme": "Theme", + "theme": "Chủ đề", "builtInsLabel": "Theme có sẵn", - "pluginsLabel": "Plugins", + "pluginsLabel": "Các plugin", "dateFormat": { "label": "Định dạng ngày", "local": "Địa phương", @@ -410,19 +1093,47 @@ "twentyFourHour": "Hai mươi bốn tiếng" }, "showNamingDialogWhenCreatingPage": "Hiển thị hộp thoại đặt tên khi tạo trang", + "enableRTLToolbarItems": "Bật các mục thanh công cụ RTL", "members": { "title": "Cài đặt thành viên", "inviteMembers": "Mời thành viên", + "inviteHint": "Mời qua email", "sendInvite": "Gửi lời mời", "copyInviteLink": "Sao chép liên kết mời", "label": "Các thành viên", "user": "Người dùng", "role": "Vai trò", "removeFromWorkspace": "Xóa khỏi Workspace", + "removeFromWorkspaceSuccess": "Xóa khỏi không gian làm việc thành công", + "removeFromWorkspaceFailed": "Xóa khỏi không gian làm việc không thành công", "owner": "Người sở hữu", "guest": "Khách", "member": "Thành viên", - "members": "các thành viên" + "memberHintText": "Một thành viên có thể đọc và chỉnh sửa các trang", + "guestHintText": "Khách có thể đọc, phản hồi, bình luận và chỉnh sửa một số trang nhất định khi được phép.", + "emailInvalidError": "Email không hợp lệ, vui lòng kiểm tra và thử lại", + "emailSent": "Email đã được gửi, vui lòng kiểm tra hộp thư đến", + "members": "các thành viên", + "membersCount": { + "zero": "{} thành viên", + "one": "{} thành viên", + "other": "{} thành viên" + }, + "inviteFailedDialogTitle": "Không gửi được lời mời", + "inviteFailedMemberLimit": "Đã đạt đến giới hạn thành viên, vui lòng nâng cấp để mời thêm thành viên.", + "inviteFailedMemberLimitMobile": "Không gian làm việc của bạn đã đạt đến giới hạn thành viên. Nâng cấp trên Desktop để mở khóa thêm nhiều tính năng.", + "memberLimitExceeded": "Đã đạt đến giới hạn thành viên, vui lòng mời thêm thành viên ", + "memberLimitExceededUpgrade": "nâng cấp", + "memberLimitExceededPro": "Đã đạt đến giới hạn thành viên, nếu bạn cần thêm thành viên hãy liên hệ ", + "memberLimitExceededProContact": "support@appflowy.io", + "failedToAddMember": "Không thêm được thành viên", + "addMemberSuccess": "Thành viên đã được thêm thành công", + "removeMember": "Xóa thành viên", + "areYouSureToRemoveMember": "Bạn có chắc chắn muốn xóa thành viên này không?", + "inviteMemberSuccess": "Lời mời đã được gửi thành công", + "failedToInviteMember": "Không mời được thành viên", + "workspaceMembersError": "Ồ, có gì đó không ổn", + "workspaceMembersErrorDescription": "Chúng tôi không thể tải danh sách thành viên vào lúc này. Vui lòng thử lại sau" } }, "files": { @@ -430,7 +1141,7 @@ "defaultLocation": "Đọc tập tin và vị trí lưu trữ dữ liệu", "exportData": "Xuất dữ liệu của bạn", "doubleTapToCopy": "Nhấn đúp để sao chép đường dẫn", - "restoreLocation": "Khôi phục về đường dẫn mặc định của AppFlowy", + "restoreLocation": "Khôi phục về đường dẫn mặc định của @:appName", "customizeLocation": "Mở thư mục khác", "restartApp": "Vui lòng khởi động lại ứng dụng để những thay đổi có hiệu lực.", "exportDatabase": "Xuất cơ sở dữ liệu", @@ -442,12 +1153,13 @@ "defineWhereYourDataIsStored": "Xác định nơi dữ liệu của bạn được lưu trữ", "open": "Mở", "openFolder": "Mở một thư mục hiện có", - "openFolderDesc": "Đọc và ghi nó vào thư mục AppFlowy hiện có của bạn", + "openFolderDesc": "Đọc và ghi nó vào thư mục @:appName hiện có của bạn", "folderHintText": "tên thư mục", "location": "Tạo một thư mục mới", - "locationDesc": "Chọn tên cho thư mục dữ liệu AppFlowy của bạn", + "locationDesc": "Chọn tên cho thư mục dữ liệu @:appName của bạn", "browser": "Duyệt", "create": "Tạo", + "set": "Bộ", "folderPath": "Đường dẫn lưu trữ thư mục của bạn", "locationCannotBeEmpty": "Đường dẫn không thể trống", "pathCopiedSnackbar": "Đã sao chép đường dẫn lưu trữ tệp vào khay nhớ tạm!", @@ -455,30 +1167,29 @@ "change": "Thay đổi", "openLocationTooltips": "Mở thư mục dữ liệu khác", "openCurrentDataFolder": "Mở thư mục dữ liệu hiện tại", - "recoverLocationTooltips": "Đặt lại về thư mục dữ liệu mặc định của AppFlowy", + "recoverLocationTooltips": "Đặt lại về thư mục dữ liệu mặc định của @:appName", "exportFileSuccess": "Xuất tập tin thành công!", "exportFileFail": "Xuất tập tin thất bại!", - "export": "Xuất" + "export": "Xuất", + "clearCache": "Xóa bộ nhớ đệm", + "clearCacheDesc": "Nếu bạn gặp sự cố với hình ảnh không tải hoặc phông chữ không hiển thị đúng, hãy thử xóa bộ nhớ đệm. Hành động này sẽ không xóa dữ liệu người dùng của bạn.", + "areYouSureToClearCache": "Bạn có chắc chắn muốn xóa bộ nhớ đệm không?", + "clearCacheSuccess": "Đã xóa bộ nhớ đệm thành công!" }, "user": { "name": "Tên", - "email": "Email", + "email": "E-mail", "tooltipSelectIcon": "Chọn biểu tượng", "selectAnIcon": "Chọn một biểu tượng", - "pleaseInputYourOpenAIKey": "vui lòng nhập khóa OpenAI của bạn", - "pleaseInputYourStabilityAIKey": "vui lòng nhập khóa Stability AI của bạn", - "clickToLogout": "Nhấn để đăng xuất" - }, - "shortcuts": { - "shortcutsLabel": "Phím tắt", - "updateShortcutStep": "Nhấn tổ hợp phím mong muốn và nhấn ENTER", - "shortcutIsAlreadyUsed": "Phím tắt này đã được sử dụng cho: {conflict}" + "pleaseInputYourOpenAIKey": "vui lòng nhập khóa AI của bạn", + "clickToLogout": "Nhấn để đăng xuất", + "pleaseInputYourStabilityAIKey": "vui lòng nhập khóa Stability AI của bạn" }, "mobile": { "personalInfo": "Thông tin cá nhân", "username": "Tên người dùng ", "usernameEmptyError": "Tên người dùng không được để trống", - "about": "About", + "about": "Về", "pushNotifications": "Thông báo", "support": "Ủng hộ", "joinDiscord": "Tham gia cùng chúng tôi trên Discord", @@ -490,6 +1201,11 @@ "selectLayout": "Chọn bố cục", "selectStartingDay": "Chọn ngày bắt đầu", "version": "Phiên bản" + }, + "shortcuts": { + "shortcutsLabel": "Phím tắt", + "updateShortcutStep": "Nhấn tổ hợp phím mong muốn và nhấn ENTER", + "shortcutIsAlreadyUsed": "Phím tắt này đã được sử dụng cho: {conflict}" } }, "grid": { @@ -509,12 +1225,15 @@ "deleteFilter": "Xóa bộ lọc", "filterBy": "Lọc bằng...", "typeAValue": "Nhập một giá trị...", - "layout": "Layout", - "databaseLayout": "Layout", + "layout": "Bố cục", + "databaseLayout": "Bố cục", "editView": "Chỉnh sửa chế độ xem", "boardSettings": "Cài đặt bảng", "calendarSettings": "Cài đặt lịch", + "createView": "Góc nhìn mới", + "duplicateView": "Xem trùng lặp", "deleteView": "Xóa chế độ xem", + "numberOfVisibleFields": "{} đã hiển thị", "Properties": "Thuộc tính", "viewList": "Database Views" }, @@ -558,25 +1277,56 @@ "is": "Là", "before": "Trước", "after": "Sau", + "onOrBefore": "Đang diễn ra hoặc trước đó", + "onOrAfter": "Đang bật hoặc sau", "between": "Ở giữa", "empty": "Rỗng", - "notEmpty": "Không rỗng" + "notEmpty": "Không rỗng", + "choicechipPrefix": { + "before": "Trước", + "after": "Sau đó", + "onOrBefore": "Vào hoặc trước", + "onOrAfter": "Vào hoặc sau", + "isEmpty": "Đang trống", + "isNotEmpty": "Không trống" + } + }, + "numberFilter": { + "equal": "Bằng nhau", + "notEqual": "Không bằng", + "lessThan": "Ít hơn", + "greaterThan": "Lớn hơn", + "lessThanOrEqualTo": "Nhỏ hơn hoặc bằng", + "greaterThanOrEqualTo": "Lớn hơn hoặc bằng", + "isEmpty": "Đang trống", + "isNotEmpty": "Không trống" }, "field": { + "label": "Thuộc tính", "hide": "Ẩn", "show": "Hiện", "insertLeft": "Chèn trái", "insertRight": "Chèn phải", "duplicate": "Nhân bản", "delete": "Xóa", + "wrapCellContent": "Bao quanh văn bản", + "clear": "Xóa tế bào", "textFieldName": "Chữ", "checkboxFieldName": "Hộp kiểm", "dateFieldName": "Ngày", "updatedAtFieldName": "Sửa đổi lần cuối", "createdAtFieldName": "Được tạo vào lúc", "numberFieldName": "Số", + "singleSelectFieldName": "Lựa chọn", + "multiSelectFieldName": "Chọn nhiều", "urlFieldName": "URL", "checklistFieldName": "Danh mục", + "relationFieldName": "Mối quan hệ", + "summaryFieldName": "Tóm tắt AI", + "timeFieldName": "Thời gian", + "mediaFieldName": "Tệp tin & phương tiện", + "translateFieldName": "AI Dịch", + "translateTo": "Dịch sang", "numberFormat": "Định dạng số", "dateFormat": "Định dạng ngày tháng", "includeTime": "Bao gồm thời gian", @@ -605,10 +1355,13 @@ "addOption": "Thêm tùy chọn", "editProperty": "Chỉnh sửa thuộc tính", "newProperty": "Thuộc tính mới", + "openRowDocument": "Mở như một trang", "deleteFieldPromptMessage": "Bạn có chắc không? Thuộc tính này sẽ bị xóa", + "clearFieldPromptMessage": "Bạn có chắc chắn không? Tất cả các ô trong cột này sẽ được làm trống", "newColumn": "Cột mới", "format": "Định dạng", - "reminderOnDateTooltip": "Ô này có lời nhắc được lên lịch" + "reminderOnDateTooltip": "Ô này có lời nhắc được lên lịch", + "optionAlreadyExist": "Tùy chọn đã tồn tại" }, "rowPage": { "newField": "Thêm một trường mới", @@ -622,13 +1375,20 @@ "one": "Ẩn {count} trường ẩn", "many": "Ẩn {count} trường ẩn", "other": "Ẩn {count} trường ẩn" - } + }, + "openAsFullPage": "Mở dưới dạng trang đầy đủ", + "moreRowActions": "Thêm hành động hàng" }, "sort": { "ascending": "Tăng dần", "descending": "Giảm dần", + "by": "Qua", + "empty": "Không có loại hoạt động", + "cannotFindCreatableField": "Không tìm thấy trường phù hợp để sắp xếp theo", "deleteAllSorts": "Xóa tất cả sắp xếp", - "addSort": "Thêm sắp xếp" + "addSort": "Thêm sắp xếp", + "removeSorting": "Bạn có muốn xóa chế độ sắp xếp không?", + "fieldInUse": "Bạn đang sắp xếp theo trường này" }, "row": { "duplicate": "Nhân bản", @@ -639,10 +1399,14 @@ "count": "Số lượng", "newRow": "Hàng mới", "action": "Hành động", + "add": "Nhấp vào thêm vào bên dưới", "drag": "Kéo để di chuyển", + "deleteRowPrompt": "Bạn có chắc chắn muốn xóa hàng này không? Hành động này không thể hoàn tác", + "deleteCardPrompt": "Bạn có chắc chắn muốn xóa thẻ này không? Hành động này không thể hoàn tác", "dragAndClick": "Kéo để di chuyển, nhấp để mở menu", "insertRecordAbove": "Chèn bản ghi ở trên", - "insertRecordBelow": "Chèn bản ghi bên dưới" + "insertRecordBelow": "Chèn bản ghi bên dưới", + "noContent": "Không có nội dung" }, "selectOption": { "create": "Tạo", @@ -660,33 +1424,247 @@ "panelTitle": "Chọn một tùy chọn hoặc tạo mới", "searchOption": "Tìm kiếm một lựa chọn", "searchOrCreateOption": "Tìm kiếm hoặc tạo một tùy chọn...", + "createNew": "Tạo một cái mới", + "orSelectOne": "Hoặc chọn một tùy chọn", + "typeANewOption": "Nhập một tùy chọn mới", "tagName": "Tên thẻ" }, - "url": { - "copy": "Sao chép URL" + "checklist": { + "taskHint": "Mô tả nhiệm vụ", + "addNew": "Thêm một nhiệm vụ mới", + "submitNewTask": "Tạo nên", + "hideComplete": "Ẩn các tác vụ đã hoàn thành", + "showComplete": "Hiển thị tất cả các nhiệm vụ" }, - "menuName": "Lưới" + "url": { + "launch": "Mở liên kết trong trình duyệt", + "copy": "Sao chép URL", + "textFieldHint": "Nhập một URL" + }, + "relation": { + "relatedDatabasePlaceLabel": "Cơ sở dữ liệu liên quan", + "relatedDatabasePlaceholder": "Không có", + "inRelatedDatabase": "TRONG", + "rowSearchTextFieldPlaceholder": "Tìm kiếm", + "noDatabaseSelected": "Chưa chọn cơ sở dữ liệu, vui lòng chọn một cơ sở dữ liệu trước từ danh sách bên dưới:", + "emptySearchResult": "Không tìm thấy hồ sơ nào", + "linkedRowListLabel": "{count} hàng được liên kết", + "unlinkedRowListLabel": "Liên kết một hàng khác" + }, + "menuName": "Lưới", + "referencedGridPrefix": "Xem của", + "calculate": "Tính toán", + "calculationTypeLabel": { + "none": "Không có", + "average": "Trung bình", + "max": "Tối đa", + "median": "Trung vị", + "min": "Tối thiểu", + "sum": "Tổng", + "count": "Đếm", + "countEmpty": "Đếm trống", + "countEmptyShort": "TRỐNG", + "countNonEmpty": "Đếm không trống", + "countNonEmptyShort": "ĐIỀN" + }, + "media": { + "rename": "Đổi tên", + "download": "Tải về", + "delete": "Xóa bỏ", + "moreFilesHint": "+{}", + "addFileOrImage": "Thêm tệp, hình ảnh hoặc liên kết", + "attachmentsHint": "{}", + "addFileMobile": "Thêm tập tin", + "deleteFileDescription": "Bạn có chắc chắn muốn xóa tệp này không? Hành động này không thể hoàn tác.", + "downloadSuccess": "Đã lưu tập tin thành công", + "downloadFailedToken": "Không tải được tệp, mã thông báo người dùng không khả dụng", + "open": "Mở", + "hideFileNames": "Ẩn tên tập tin", + "showFile": "Hiển thị 1 tập tin", + "showFiles": "Hiển thị {} tập tin", + "hideFile": "Ẩn 1 tập tin", + "hideFiles": "Ẩn {} tập tin" + } }, "document": { + "menuName": "Tài liệu", + "date": { + "timeHintTextInTwelveHour": "01:00 Chiều", + "timeHintTextInTwentyFourHour": "13:00" + }, "slashMenu": { "board": { "selectABoardToLinkTo": "Chọn một bảng để liên kết", "createANewBoard": "Tạo một bảng mới" + }, + "grid": { + "selectAGridToLinkTo": "Chọn một lưới để liên kết đến", + "createANewGrid": "Tạo một lưới mới" + }, + "calendar": { + "selectACalendarToLinkTo": "Chọn Lịch để liên kết đến", + "createANewCalendar": "Tạo một Lịch mới" + }, + "document": { + "selectADocumentToLinkTo": "Chọn một Tài liệu để liên kết đến" + }, + "name": { + "text": "Chữ", + "heading1": "Tiêu đề 1", + "heading2": "Tiêu đề 2", + "heading3": "Tiêu đề 3", + "image": "Hình ảnh", + "bulletedList": "Danh sách có dấu đầu dòng", + "numberedList": "Danh sách được đánh số", + "todoList": "Danh sách việc cần làm", + "doc": "Tài liệu", + "linkedDoc": "Liên kết đến trang", + "grid": "Lưới", + "linkedGrid": "Lưới liên kết", + "kanban": "Kanban", + "linkedKanban": "Kanban liên kết", + "calendar": "Lịch", + "linkedCalendar": "Lịch liên kết", + "quote": "Trích dẫn", + "divider": "Bộ chia", + "table": "Bàn", + "callout": "Chú thích", + "outline": "phác thảo", + "mathEquation": "Phương trình toán học", + "code": "Mã số", + "toggleList": "Chuyển đổi danh sách", + "emoji": "Biểu tượng cảm xúc", + "aiWriter": "Nhà văn AI", + "dateOrReminder": "Ngày hoặc nhắc nhở", + "photoGallery": "Thư viện ảnh", + "file": "Tài liệu" } }, + "selectionMenu": { + "outline": "phác thảo", + "codeBlock": "Khối mã" + }, "plugins": { - "openAI": "OpenAI", + "referencedBoard": "Hội đồng tham khảo", + "referencedGrid": "Lưới tham chiếu", + "referencedCalendar": "Lịch tham khảo", + "referencedDocument": "Tài liệu tham khảo", + "autoGeneratorMenuItemName": "Nhà văn AI", + "autoGeneratorTitleName": "AI: Yêu cầu AI viết bất cứ điều gì...", + "autoGeneratorLearnMore": "Tìm hiểu thêm", + "autoGeneratorGenerate": "Phát ra", + "autoGeneratorHintText": "Hỏi AI ...", + "autoGeneratorCantGetOpenAIKey": "Không thể lấy được chìa khóa AI", + "autoGeneratorRewrite": "Viết lại", + "smartEdit": "Hỏi AI", + "aI": "AI", + "smartEditFixSpelling": "Sửa lỗi chính tả và ngữ pháp", + "warning": "⚠️ Phản hồi của AI có thể không chính xác hoặc gây hiểu lầm.", + "smartEditSummarize": "Tóm tắt", + "smartEditImproveWriting": "Cải thiện khả năng viết", + "smartEditMakeLonger": "Làm dài hơn", + "smartEditCouldNotFetchResult": "Không thể lấy kết quả từ AI", + "smartEditCouldNotFetchKey": "Không thể lấy được khóa AI", + "smartEditDisabled": "Kết nối AI trong Cài đặt", + "appflowyAIEditDisabled": "Đăng nhập để bật tính năng AI", + "discardResponse": "Bạn có muốn loại bỏ phản hồi của AI không?", + "createInlineMathEquation": "Tạo phương trình", + "fonts": "Phông chữ", + "insertDate": "Chèn ngày", + "emoji": "Biểu tượng cảm xúc", + "toggleList": "Chuyển đổi danh sách", + "quoteList": "Danh sách trích dẫn", + "numberedList": "Danh sách được đánh số", + "bulletedList": "Danh sách có dấu đầu dòng", + "todoList": "Danh sách việc cần làm", + "callout": "Chú thích", + "cover": { + "changeCover": "Thay đổi bìa", + "colors": "Màu sắc", + "images": "Hình ảnh", + "clearAll": "Xóa tất cả", + "abstract": "Tóm tắt", + "addCover": "Thêm Bìa", + "addLocalImage": "Thêm hình ảnh cục bộ", + "invalidImageUrl": "URL hình ảnh không hợp lệ", + "failedToAddImageToGallery": "Không thể thêm hình ảnh vào thư viện", + "enterImageUrl": "Nhập URL hình ảnh", + "add": "Thêm vào", + "back": "Quay lại", + "saveToGallery": "Lưu vào thư viện", + "removeIcon": "Xóa biểu tượng", + "pasteImageUrl": "Dán URL hình ảnh", + "or": "HOẶC", + "pickFromFiles": "Chọn từ các tập tin", + "couldNotFetchImage": "Không thể tải hình ảnh", + "imageSavingFailed": "Lưu hình ảnh không thành công", + "addIcon": "Thêm biểu tượng", + "changeIcon": "Thay đổi biểu tượng", + "coverRemoveAlert": "Nó sẽ được gỡ bỏ khỏi trang bìa sau khi bị xóa.", + "alertDialogConfirmation": "Bạn có chắc chắn muốn tiếp tục không?" + }, + "mathEquation": { + "name": "Phương trình toán học", + "addMathEquation": "Thêm một phương trình TeX", + "editMathEquation": "Chỉnh sửa phương trình toán học" + }, "optionAction": { + "click": "Nhấp chuột", + "toOpenMenu": " để mở menu", "delete": "Xóa", + "duplicate": "Nhân bản", + "turnInto": "Biến thành", + "moveUp": "Di chuyển lên", + "moveDown": "Di chuyển xuống", "color": "Màu", "align": "Căn chỉnh", "left": "Trái", "center": "Giữa", "right": "Phải", - "defaultColor": "Mặc định" + "defaultColor": "Mặc định", + "depth": "Độ sâu" + }, + "image": { + "addAnImage": "Thêm hình ảnh", + "copiedToPasteBoard": "Liên kết hình ảnh đã được sao chép vào clipboard", + "addAnImageDesktop": "Thêm một hình ảnh", + "addAnImageMobile": "Nhấp để thêm một hoặc nhiều hình ảnh", + "dropImageToInsert": "Thả hình ảnh để chèn", + "imageUploadFailed": "Tải hình ảnh lên không thành công", + "imageDownloadFailed": "Tải hình ảnh lên không thành công, vui lòng thử lại", + "imageDownloadFailedToken": "Tải lên hình ảnh không thành công do thiếu mã thông báo người dùng, vui lòng thử lại", + "errorCode": "Mã lỗi" + }, + "photoGallery": { + "name": "Thư viện ảnh", + "imageKeyword": "hình ảnh", + "imageGalleryKeyword": "thư viện hình ảnh", + "photoKeyword": "ảnh", + "photoBrowserKeyword": "trình duyệt ảnh", + "galleryKeyword": "phòng trưng bày", + "addImageTooltip": "Thêm hình ảnh", + "changeLayoutTooltip": "Thay đổi bố cục", + "browserLayout": "Trình duyệt", + "gridLayout": "Lưới", + "deleteBlockTooltip": "Xóa toàn bộ thư viện" + }, + "math": { + "copiedToPasteBoard": "Phương trình toán học đã được sao chép vào clipboard" + }, + "urlPreview": { + "copiedToPasteBoard": "Liên kết đã được sao chép vào clipboard", + "convertToLink": "Chuyển đổi thành liên kết nhúng" + }, + "outline": { + "addHeadingToCreateOutline": "Thêm tiêu đề để tạo mục lục.", + "noMatchHeadings": "Không tìm thấy tiêu đề phù hợp." }, "table": { + "addAfter": "Thêm sau", + "addBefore": "Thêm trước", "delete": "Xoá", + "clear": "Xóa nội dung", + "duplicate": "Nhân bản", "bgColor": "Màu nền" }, "contextMenu": { @@ -694,7 +1672,54 @@ "cut": "Cắt", "paste": "Dán" }, - "action": "Hành động" + "action": "Hành động", + "database": { + "selectDataSource": "Chọn nguồn dữ liệu", + "noDataSource": "Không có nguồn dữ liệu", + "selectADataSource": "Chọn nguồn dữ liệu", + "toContinue": "để tiếp tục", + "newDatabase": "Cơ sở dữ liệu mới", + "linkToDatabase": "Liên kết đến Cơ sở dữ liệu" + }, + "date": "Ngày", + "video": { + "label": "Băng hình", + "emptyLabel": "Thêm video", + "placeholder": "Dán liên kết video", + "copiedToPasteBoard": "Liên kết video đã được sao chép vào clipboard", + "insertVideo": "Thêm video", + "invalidVideoUrl": "URL nguồn chưa được hỗ trợ.", + "invalidVideoUrlYouTube": "YouTube hiện chưa được hỗ trợ.", + "supportedFormats": "Các định dạng được hỗ trợ: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" + }, + "file": { + "name": "Tài liệu", + "uploadTab": "Tải lên", + "uploadMobile": "Chọn tệp", + "networkTab": "Nhúng liên kết", + "placeholderText": "Tải lên hoặc nhúng một tập tin", + "placeholderDragging": "Thả tệp để tải lên", + "dropFileToUpload": "Thả tệp để tải lên", + "fileUploadHint": "Thả một tập tin ở đây để tải lên\nhoặc nhấp để duyệt", + "networkHint": "Dán liên kết tệp", + "networkUrlInvalid": "URL không hợp lệ, vui lòng sửa URL và thử lại", + "networkAction": "Nhúng liên kết tập tin", + "fileTooBigError": "Kích thước tệp quá lớn, vui lòng tải lên tệp có kích thước nhỏ hơn 10MB", + "renameFile": { + "title": "Đổi tên tập tin", + "description": "Nhập tên mới cho tập tin này", + "nameEmptyError": "Tên tệp không được để trống." + }, + "uploadedAt": "Đã tải lên vào {}", + "linkedAt": "Liên kết đã được thêm vào {}", + "failedToOpenMsg": "Không mở được, không tìm thấy tệp" + } + }, + "outlineBlock": { + "placeholder": "Mục lục" + }, + "textBlock": { + "placeholder": "Gõ '/' cho lệnh" }, "title": { "placeholder": "Trống" @@ -709,17 +1734,68 @@ "label": "Đường dẫn đến ảnh", "placeholder": "Nhập đường dẫn đến ảnh" }, + "ai": { + "label": "Tạo hình ảnh từ AI", + "placeholder": "Vui lòng nhập lời nhắc để AI tạo hình ảnh" + }, + "stability_ai": { + "label": "Tạo hình ảnh từ Stability AI", + "placeholder": "Vui lòng nhập lời nhắc cho Stability AI để tạo hình ảnh" + }, + "support": "Giới hạn kích thước hình ảnh là 5MB. Định dạng được hỗ trợ: JPEG, PNG, GIF, SVG", "error": { - "invalidImage": "Ảnh không hợp lệ" + "invalidImage": "Ảnh không hợp lệ", + "invalidImageSize": "Kích thước hình ảnh phải nhỏ hơn 5MB", + "invalidImageFormat": "Định dạng hình ảnh không được hỗ trợ. Các định dạng được hỗ trợ: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "URL hình ảnh không hợp lệ", + "noImage": "Không có tập tin hoặc thư mục như vậy", + "multipleImagesFailed": "Một hoặc nhiều hình ảnh không tải lên được, vui lòng thử lại" + }, + "embedLink": { + "label": "Nhúng liên kết", + "placeholder": "Dán hoặc nhập liên kết hình ảnh" + }, + "unsplash": { + "label": "Bỏ qua" + }, + "searchForAnImage": "Tìm kiếm hình ảnh", + "pleaseInputYourOpenAIKey": "vui lòng nhập khóa AI của bạn vào trang Cài đặt", + "saveImageToGallery": "Lưu hình ảnh", + "failedToAddImageToGallery": "Không thể thêm hình ảnh vào thư viện", + "successToAddImageToGallery": "Hình ảnh đã được thêm vào thư viện thành công", + "unableToLoadImage": "Không thể tải hình ảnh", + "maximumImageSize": "Kích thước hình ảnh tải lên được hỗ trợ tối đa là 10MB", + "uploadImageErrorImageSizeTooBig": "Kích thước hình ảnh phải nhỏ hơn 10MB", + "imageIsUploading": "Hình ảnh đang được tải lên", + "openFullScreen": "Mở toàn màn hình", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "Hình ảnh trước đó", + "nextImageTooltip": "Hình ảnh tiếp theo", + "zoomOutTooltip": "Thu nhỏ", + "zoomInTooltip": "Phóng to", + "changeZoomLevelTooltip": "Thay đổi mức độ thu phóng", + "openLocalImage": "Mở hình ảnh", + "downloadImage": "Tải xuống hình ảnh", + "closeViewer": "Đóng trình xem tương tác", + "scalePercentage": "{}%", + "deleteImageTooltip": "Xóa hình ảnh" + } } }, "codeBlock": { "language": { "label": "Ngôn ngữ", - "placeholder": "Chọn ngôn ngữ" - } + "placeholder": "Chọn ngôn ngữ", + "auto": "Tự động" + }, + "copyTooltip": "Sao chép", + "searchLanguageHint": "Tìm kiếm một ngôn ngữ", + "codeCopiedSnackbar": "Đã sao chép mã vào bảng tạm!" }, "inlineLink": { + "placeholder": "Dán hoặc nhập liên kết", + "openInNewTab": "Mở trong tab mới", "copyLink": "Sao chép liên kết", "removeLink": "Xóa liên kết", "url": { @@ -727,50 +1803,148 @@ "placeholder": "Nhập URL liên kết" }, "title": { - "label": "Tiêu đề liên kết" + "label": "Tiêu đề liên kết", + "placeholder": "Nhập tiêu đề liên kết" } }, + "mention": { + "placeholder": "Nhắc đến một người, một trang hoặc một ngày...", + "page": { + "label": "Liên kết đến trang", + "tooltip": "Nhấp để mở trang" + }, + "deleted": "Đã xóa", + "deletedContent": "Nội dung này không tồn tại hoặc đã bị xóa", + "noAccess": "Không có quyền truy cập" + }, "toolbar": { "resetToDefaultFont": "Chỉnh về mặc định" + }, + "errorBlock": { + "theBlockIsNotSupported": "Không thể phân tích nội dung khối", + "clickToCopyTheBlockContent": "Nhấp để sao chép nội dung khối", + "blockContentHasBeenCopied": "Nội dung khối đã được sao chép.", + "parseError": "Đã xảy ra lỗi khi phân tích khối {}.", + "copyBlockContent": "Sao chép nội dung khối" + }, + "mobilePageSelector": { + "title": "Chọn trang", + "failedToLoad": "Không tải được danh sách trang", + "noPagesFound": "Không tìm thấy trang nào" } }, "board": { "column": { + "label": "Cột", "createNewCard": "Mới", + "renameGroupTooltip": "Nhấn để đổi tên nhóm", + "createNewColumn": "Thêm một nhóm mới", + "addToColumnTopTooltip": "Thêm một thẻ mới ở trên cùng", + "addToColumnBottomTooltip": "Thêm một thẻ mới ở phía dưới", "renameColumn": "Đổi tên", "hideColumn": "Ẩn", - "deleteColumn": "Xoá" + "newGroup": "Nhóm mới", + "deleteColumn": "Xoá", + "deleteColumnConfirmation": "Thao tác này sẽ xóa nhóm này và tất cả các thẻ trong đó.\nBạn có chắc chắn muốn tiếp tục không?" }, + "hiddenGroupSection": { + "sectionTitle": "Nhóm ẩn", + "collapseTooltip": "Ẩn các nhóm ẩn", + "expandTooltip": "Xem các nhóm ẩn" + }, + "cardDetail": "Chi tiết thẻ", + "cardActions": "Hành động thẻ", + "cardDuplicated": "Thẻ đã được sao chép", + "cardDeleted": "Thẻ đã bị xóa", + "showOnCard": "Hiển thị trên chi tiết thẻ", + "setting": "Cài đặt", + "propertyName": "Tên tài sản", "menuName": "Bảng", + "showUngrouped": "Hiển thị các mục chưa nhóm", + "ungroupedButtonText": "Không nhóm", + "ungroupedButtonTooltip": "Chứa các thẻ không thuộc bất kỳ nhóm nào", + "ungroupedItemsTitle": "Nhấp để thêm vào bảng", + "groupBy": "Nhóm theo", + "groupCondition": "Điều kiện nhóm", + "referencedBoardPrefix": "Xem của", + "notesTooltip": "Ghi chú bên trong", "mobile": { + "editURL": "Chỉnh sửa URL", "showGroup": "Hiển thị nhóm", "showGroupContent": "Bạn có chắc chắn muốn hiển thị nhóm này trên bảng không?", "failedToLoad": "Không tải được chế độ xem bảng" + }, + "dateCondition": { + "weekOf": "Tuần của {} - {}", + "today": "Hôm nay", + "yesterday": "Hôm qua", + "tomorrow": "Ngày mai", + "lastSevenDays": "7 ngày qua", + "nextSevenDays": "7 ngày tiếp theo", + "lastThirtyDays": "30 ngày qua", + "nextThirtyDays": "30 ngày tiếp theo" + }, + "noGroup": "Không có nhóm theo tài sản", + "noGroupDesc": "Các chế độ xem bảng yêu cầu một thuộc tính để nhóm theo để hiển thị", + "media": { + "cardText": "{} {}", + "fallbackName": "tập tin" } }, "calendar": { "menuName": "Lịch", "defaultNewCalendarTitle": "Trống", + "newEventButtonTooltip": "Thêm sự kiện mới", "navigation": { "today": "Hôm nay", + "jumpToday": "Nhảy tới Hôm nay", "previousMonth": "Tháng trước", - "nextMonth": "Tháng sau" + "nextMonth": "Tháng sau", + "views": { + "day": "Ngày", + "week": "Tuần", + "month": "Tháng", + "year": "Năm" + } + }, + "mobileEventScreen": { + "emptyTitle": "Chưa có sự kiện nào", + "emptyBody": "Nhấn nút dấu cộng để tạo sự kiện vào ngày này." }, "settings": { "showWeekNumbers": "Hiện thứ tự của tuần", "showWeekends": "Hiện cuối tuần", "firstDayOfWeek": "Ngày bắt đầu trong tuần", + "layoutDateField": "Bố trí lịch theo", + "changeLayoutDateField": "Thay đổi trường bố trí", "noDateTitle": "Không có ngày", - "clickToAdd": "Ấn để thêm lịch" - } + "noDateHint": { + "zero": "Các sự kiện không theo lịch trình sẽ hiển thị ở đây", + "one": "{count} sự kiện không theo lịch trình", + "other": "{count} sự kiện không theo lịch trình" + }, + "unscheduledEventsTitle": "Sự kiện không theo lịch trình", + "clickToAdd": "Ấn để thêm lịch", + "name": "Cài đặt lịch", + "clickToOpen": "Nhấp để mở hồ sơ" + }, + "referencedCalendarPrefix": "Xem của", + "quickJumpYear": "Nhảy tới", + "duplicateEvent": "Sự kiện trùng lặp" }, "errorDialog": { - "title": "Lỗi của AppFlowy", + "title": "Lỗi của @:appName", "howToFixFallback": "Chúng tôi xin lỗi vì sự cố này! Vui lòng mở sự cố trên GitHub để báo lỗi.", + "howToFixFallbackHint1": "Chúng tôi xin lỗi vì sự bất tiện này! Gửi một vấn đề trên ", + "howToFixFallbackHint2": " trang mô tả lỗi của bạn.", "github": "Xem trên GitHub" }, "search": { - "label": "Tìm kiếm" + "label": "Tìm kiếm", + "sidebarSearchIcon": "Tìm kiếm và nhanh chóng chuyển đến một trang", + "placeholder": { + "actions": "Hành động tìm kiếm..." + } }, "message": { "copy": { @@ -797,12 +1971,16 @@ "gray": "Xám" }, "emoji": { + "emojiTab": "Biểu tượng cảm xúc", "search": "Tìm kiếm biểu tượng", + "noRecent": "Không có biểu tượng cảm xúc gần đây", "noEmojiFound": "Không tìm thấy biểu tượng", "filter": "Bộ lọc", "random": "Ngẫu nhiên", + "selectSkinTone": "Chọn tông màu da", "remove": "Loại bỏ biểu tượng", "categories": { + "smileys": "Biểu tượng mặt cười & Cảm xúc", "people": "Con người và cơ thể", "animals": "Động vật và thiên nhiên", "food": "Đồ ăn và thức uống", @@ -811,21 +1989,58 @@ "objects": "Vật thể", "symbols": "Biểu tượng", "flags": "Cờ", - "nature": "Thiên nhiên", + "nature": "Tự nhiên", "frequentlyUsed": "Thường dùng" }, "skinTone": { - "default": "Mặc định" - } + "default": "Mặc định", + "light": "Sáng", + "mediumLight": "Sáng-Vừa", + "medium": "Vừa", + "mediumDark": "Tối-Vừa", + "dark": "Tối" + }, + "openSourceIconsFrom": "Biểu tượng nguồn mở từ" }, "inlineActions": { "noResults": "Không có kết quả", + "recentPages": "Các trang gần đây", + "pageReference": "Trang tham khảo", + "docReference": "Tài liệu tham khảo", + "boardReference": "Tham khảo bảng", + "calReference": "Tham khảo lịch", + "gridReference": "Tham chiếu lưới", "date": "Ngày", "reminder": { "groupTitle": "Lời nhắc", "shortKeyword": "nhắc nhở" } }, + "datePicker": { + "dateTimeFormatTooltip": "Thay đổi định dạng ngày và giờ trong cài đặt", + "dateFormat": "Định dạng ngày tháng", + "includeTime": "Bao gồm thời gian", + "isRange": "Ngày kết thúc", + "timeFormat": "Định dạng thời gian", + "clearDate": "Ngày xóa", + "reminderLabel": "Lời nhắc nhở", + "selectReminder": "Chọn lời nhắc", + "reminderOptions": { + "none": "Không có", + "atTimeOfEvent": "Thời gian diễn ra sự kiện", + "fiveMinsBefore": "5 phút trước", + "tenMinsBefore": "10 phút trước", + "fifteenMinsBefore": "15 phút trước", + "thirtyMinsBefore": "30 phút trước", + "oneHourBefore": "1 giờ trước", + "twoHoursBefore": "2 giờ trước", + "onDayOfEvent": "Vào ngày diễn ra sự kiện", + "oneDayBefore": "1 ngày trước", + "twoDaysBefore": "2 ngày trước", + "oneWeekBefore": "1 tuần trước", + "custom": "Phong tục" + } + }, "relativeDates": { "yesterday": "Hôm qua", "today": "Hôm nay", @@ -834,18 +2049,35 @@ }, "notificationHub": { "title": "Thông báo", + "mobile": { + "title": "Cập nhật" + }, + "emptyTitle": "Đã cập nhật đầy đủ!", + "emptyBody": "Không có thông báo hoặc hành động nào đang chờ xử lý. Hãy tận hưởng sự bình yên.", + "tabs": { + "inbox": "Hộp thư đến", + "upcoming": "Sắp tới" + }, "actions": { + "markAllRead": "Đánh dấu tất cả là đã đọc", + "showAll": "Tất cả", "showUnreads": "Chưa đọc" }, "filters": { "ascending": "Tăng dần", - "descending": "Giảm dần" + "descending": "Giảm dần", + "groupByDate": "Nhóm theo ngày", + "showUnreadsOnly": "Chỉ hiển thị những tin chưa đọc", + "resetToDefault": "Đặt lại về mặc định" }, "empty": "Không có gì ở đây!" }, "reminderNotification": { "title": "Lời nhắc", - "tooltipDelete": "Xoá" + "message": "Hãy nhớ kiểm tra điều này trước khi bạn quên nhé!", + "tooltipDelete": "Xoá", + "tooltipMarkRead": "Đánh dấu là đã đọc", + "tooltipMarkUnread": "Đánh dấu là chưa đọc" }, "findAndReplace": { "find": "Tìm kiếm", @@ -854,18 +2086,482 @@ "close": "Đóng", "replace": "Thay thế", "replaceAll": "Thay thế tất cả", - "noResult": "Không thấy kết quả" + "noResult": "Không thấy kết quả", + "caseSensitive": "Phân biệt chữ hoa chữ thường", + "searchMore": "Tìm kiếm để tìm thêm kết quả" + }, + "error": { + "weAreSorry": "Chúng tôi xin lỗi", + "loadingViewError": "Chúng tôi đang gặp sự cố khi tải chế độ xem này. Vui lòng kiểm tra kết nối internet, làm mới ứng dụng và đừng ngần ngại liên hệ với nhóm nếu sự cố vẫn tiếp diễn.", + "syncError": "Dữ liệu không được đồng bộ từ thiết bị khác", + "syncErrorHint": "Vui lòng mở lại trang này trên thiết bị mà bạn đã chỉnh sửa lần cuối, sau đó mở lại trên thiết bị hiện tại.", + "clickToCopy": "Nhấp để sao chép mã lỗi" }, "editor": { + "bold": "In đậm", + "bulletedList": "Danh sách có dấu đầu dòng", + "bulletedListShortForm": "Có dấu đầu dòng", + "checkbox": "Đánh dấu", + "embedCode": "Mã nhúng", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "Điểm nổi bật", + "color": "Màu sắc", + "image": "Hình ảnh", + "date": "Ngày", + "page": "Trang", + "italic": "nghiêng", + "link": "Liên kết", + "numberedList": "Danh sách được đánh số", + "numberedListShortForm": "Đã đánh số", + "quote": "Trích dẫn", + "strikethrough": "gạch xuyên", + "text": "Chữ", + "underline": "Gạch chân", + "fontColorDefault": "Mặc định", + "fontColorGray": "Xám", + "fontColorBrown": "Màu nâu", + "fontColorOrange": "Quả cam", + "fontColorYellow": "Màu vàng", + "fontColorGreen": "Màu xanh lá", + "fontColorBlue": "Màu xanh da trời", + "fontColorPurple": "Màu tím", + "fontColorPink": "Hồng", + "fontColorRed": "Màu đỏ", + "backgroundColorDefault": "Nền mặc định", + "backgroundColorGray": "Nền xám", + "backgroundColorBrown": "Nền màu nâu", + "backgroundColorOrange": "Nền màu cam", + "backgroundColorYellow": "Nền vàng", + "backgroundColorGreen": "Nền xanh", + "backgroundColorBlue": "Nền xanh", + "backgroundColorPurple": "Nền màu tím", + "backgroundColorPink": "Nền màu hồng", + "backgroundColorRed": "Nền đỏ", + "backgroundColorLime": "Nền vôi", + "backgroundColorAqua": "Nền màu nước", + "done": "Xong", + "cancel": "Hủy bỏ", + "tint1": "Màu 1", + "tint2": "Màu 2", + "tint3": "Màu 3", + "tint4": "Màu 4", + "tint5": "Màu 5", + "tint6": "Màu 6", + "tint7": "Màu 7", + "tint8": "Màu 8", + "tint9": "Màu 9", + "lightLightTint1": "Màu tím", + "lightLightTint2": "Hồng", + "lightLightTint3": "Hồng nhạt", + "lightLightTint4": "Quả cam", + "lightLightTint5": "Màu vàng", + "lightLightTint6": "Chanh xanh", + "lightLightTint7": "Màu xanh lá", + "lightLightTint8": "Nước", + "lightLightTint9": "Màu xanh da trời", + "urlHint": "Địa chỉ URL", + "mobileHeading1": "Tiêu đề 1", + "mobileHeading2": "Tiêu đề 2", + "mobileHeading3": "Tiêu đề 3", + "textColor": "Màu chữ", + "backgroundColor": "Màu nền", + "addYourLink": "Thêm liên kết của bạn", + "openLink": "Mở liên kết", + "copyLink": "Sao chép liên kết", + "removeLink": "Xóa liên kết", + "editLink": "Chỉnh sửa liên kết", + "linkText": "Chữ", + "linkTextHint": "Vui lòng nhập văn bản", + "linkAddressHint": "Vui lòng nhập URL", + "highlightColor": "Màu sắc nổi bật", + "clearHighlightColor": "Xóa màu nổi bật", + "customColor": "Màu tùy chỉnh", + "hexValue": "Giá trị hex", + "opacity": "Độ mờ đục", + "resetToDefaultColor": "Đặt lại màu mặc định", + "ltr": "LTR", + "rtl": "RTL", + "auto": "Tự động", "cut": "Cắt", "copy": "Sao chép", "paste": "Dán", + "find": "Tìm thấy", + "select": "Lựa chọn", + "selectAll": "Chọn tất cả", + "previousMatch": "Trùng khớp trước đó", + "nextMatch": "Trùng khớp tiếp theo", + "closeFind": "Đóng", + "replace": "Thay thế", + "replaceAll": "Thay thế tất cả", + "regex": "Biểu thức chính quy", + "caseSensitive": "Phân biệt chữ hoa chữ thường", + "uploadImage": "Tải lên hình ảnh", + "urlImage": "Hình ảnh URL", + "incorrectLink": "Liên kết không đúng", + "upload": "Tải lên", + "chooseImage": "Chọn một hình ảnh", + "loading": "Đang tải", + "imageLoadFailed": "Tải hình ảnh không thành công", + "divider": "Bộ chia", + "table": "Bàn", + "colAddBefore": "Thêm trước", + "rowAddBefore": "Thêm trước", + "colAddAfter": "Thêm sau", + "rowAddAfter": "Thêm sau", "colRemove": "Xoá", - "rowRemove": "Xoá" + "rowRemove": "Xoá", + "colDuplicate": "Nhân bản", + "rowDuplicate": "Nhân bản", + "colClear": "Xóa nội dung", + "rowClear": "Xóa nội dung", + "slashPlaceHolder": "Nhập '/' để chèn một khối hoặc bắt đầu nhập", + "typeSomething": "Hãy nhập gì đó...", + "toggleListShortForm": "Chuyển đổi", + "quoteListShortForm": "Trích dẫn", + "mathEquationShortForm": "Công thức", + "codeBlockShortForm": "Mã" + }, + "favorite": { + "noFavorite": "Không có trang yêu thích", + "noFavoriteHintText": "Vuốt trang sang trái để thêm vào mục yêu thích của bạn", + "removeFromSidebar": "Xóa khỏi thanh bên", + "addToSidebar": "Ghim vào thanh bên" + }, + "cardDetails": { + "notesPlaceholder": "Nhập / để chèn một khối hoặc bắt đầu nhập" + }, + "blockPlaceholders": { + "todoList": "Việc cần làm", + "bulletList": "Danh sách", + "numberList": "Danh sách", + "quote": "Trích dẫn", + "heading": "Tiêu đề {}" }, "titleBar": { + "pageIcon": "Biểu tượng trang", "language": "Ngôn ngữ", "font": "Phông chữ", - "date": "Ngày" + "actions": "Hành động", + "date": "Ngày", + "addField": "Thêm trường", + "userIcon": "Biểu tượng người dùng" + }, + "noLogFiles": "Không có tệp nhật ký nào", + "newSettings": { + "myAccount": { + "title": "Tài khoản của tôi", + "subtitle": "Tùy chỉnh hồ sơ, quản lý bảo mật tài khoản, mở khóa AI hoặc đăng nhập vào tài khoản của bạn.", + "profileLabel": "Tên tài khoản & Ảnh đại diện", + "profileNamePlaceholder": "Nhập tên của bạn", + "accountSecurity": "Bảo mật tài khoản", + "2FA": "Xác thực 2 bước", + "aiKeys": "Phím AI", + "accountLogin": "Đăng nhập tài khoản", + "updateNameError": "Không cập nhật được tên", + "updateIconError": "Không cập nhật được biểu tượng", + "deleteAccount": { + "title": "Xóa tài khoản", + "subtitle": "Xóa vĩnh viễn tài khoản và toàn bộ dữ liệu của bạn.", + "description": "Xóa vĩnh viễn tài khoản của bạn và xóa quyền truy cập khỏi mọi không gian làm việc.", + "deleteMyAccount": "Xóa tài khoản của tôi", + "dialogTitle": "Xóa tài khoản", + "dialogContent1": "Bạn có chắc chắn muốn xóa vĩnh viễn tài khoản của mình không?", + "dialogContent2": "Không thể hoàn tác hành động này và sẽ xóa quyền truy cập khỏi mọi không gian làm việc nhóm, xóa toàn bộ tài khoản của bạn, bao gồm cả không gian làm việc riêng tư và xóa bạn khỏi mọi không gian làm việc được chia sẻ.", + "confirmHint1": "Vui lòng nhập \"@:newSettings.myAccount.deleteAccount.confirmHint3\" để xác nhận.", + "confirmHint2": "Tôi hiểu rằng hành động này là không thể đảo ngược và sẽ xóa vĩnh viễn tài khoản của tôi cùng mọi dữ liệu liên quan.", + "confirmHint3": "XÓA TÀI KHOẢN CỦA TÔI", + "checkToConfirmError": "Bạn phải đánh dấu vào ô để xác nhận việc xóa", + "failedToGetCurrentUser": "Không lấy được email người dùng hiện tại", + "confirmTextValidationFailed": "Văn bản xác nhận của bạn không khớp với \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", + "deleteAccountSuccess": "Tài khoản đã được xóa thành công" + } + }, + "workplace": { + "name": "Nơi làm việc", + "title": "Cài đặt nơi làm việc", + "subtitle": "Tùy chỉnh giao diện không gian làm việc, chủ đề, phông chữ, bố cục văn bản, ngày, giờ và ngôn ngữ.", + "workplaceName": "Tên nơi làm việc", + "workplaceNamePlaceholder": "Nhập tên nơi làm việc", + "workplaceIcon": "Biểu tượng nơi làm việc", + "workplaceIconSubtitle": "Tải lên hình ảnh hoặc sử dụng biểu tượng cảm xúc cho không gian làm việc của bạn. Biểu tượng sẽ hiển thị trên thanh bên và thông báo của bạn.", + "renameError": "Không đổi được tên nơi làm việc", + "updateIconError": "Không cập nhật được biểu tượng", + "chooseAnIcon": "Chọn một biểu tượng", + "appearance": { + "name": "Vẻ bề ngoài", + "themeMode": { + "auto": "Tự động", + "light": "Ánh sáng", + "dark": "Tối tăm" + }, + "language": "Ngôn ngữ" + } + }, + "syncState": { + "syncing": "Đồng bộ hóa", + "synced": "Đã đồng bộ", + "noNetworkConnected": "Không có kết nối mạng" + } + }, + "pageStyle": { + "title": "Kiểu trang", + "layout": "Cách trình bày", + "coverImage": "Ảnh bìa", + "pageIcon": "Biểu tượng trang", + "colors": "Màu sắc", + "gradient": "Độ dốc", + "backgroundImage": "Hình nền", + "presets": "Cài đặt trước", + "photo": "Ảnh", + "unsplash": "Bỏ qua", + "pageCover": "Trang bìa", + "none": "Không có", + "openSettings": "Mở Cài đặt", + "photoPermissionTitle": "@:appName muốn truy cập vào thư viện ảnh của bạn", + "photoPermissionDescription": "Cho phép truy cập vào thư viện ảnh để tải ảnh lên.", + "doNotAllow": "Không cho phép", + "image": "Hình ảnh" + }, + "commandPalette": { + "placeholder": "Nhập để tìm kiếm...", + "bestMatches": "Trận đấu hay nhất", + "recentHistory": "Lịch sử gần đây", + "navigateHint": "để điều hướng", + "loadingTooltip": "Chúng tôi đang tìm kiếm kết quả...", + "betaLabel": "BETA", + "betaTooltip": "Hiện tại chúng tôi chỉ hỗ trợ tìm kiếm các trang và nội dung trong tài liệu", + "fromTrashHint": "Từ rác", + "noResultsHint": "Chúng tôi không tìm thấy những gì bạn đang tìm kiếm, hãy thử tìm kiếm thuật ngữ khác.", + "clearSearchTooltip": "Xóa trường tìm kiếm" + }, + "space": { + "delete": "Xóa bỏ", + "deleteConfirmation": "Xóa bỏ: ", + "deleteConfirmationDescription": "Tất cả các trang trong Không gian này sẽ bị xóa và chuyển vào Thùng rác, và bất kỳ trang nào đã xuất bản sẽ bị hủy xuất bản.", + "rename": "Đổi tên không gian", + "changeIcon": "Thay đổi biểu tượng", + "manage": "Quản lý không gian", + "addNewSpace": "Tạo không gian", + "collapseAllSubPages": "Thu gọn tất cả các trang con", + "createNewSpace": "Tạo không gian mới", + "createSpaceDescription": "Tạo nhiều không gian công cộng và riêng tư để sắp xếp công việc tốt hơn.", + "spaceName": "Tên không gian", + "spaceNamePlaceholder": "ví dụ Marketing, Kỹ thuật, Nhân sự", + "permission": "Sự cho phép", + "publicPermission": "Công cộng", + "publicPermissionDescription": "Tất cả các thành viên không gian làm việc có quyền truy cập đầy đủ", + "privatePermission": "Riêng tư", + "privatePermissionDescription": "Chỉ bạn mới có thể truy cập vào không gian này", + "spaceIconBackground": "Màu nền", + "spaceIcon": "Biểu tượng", + "dangerZone": "Khu vực nguy hiểm", + "unableToDeleteLastSpace": "Không thể xóa khoảng trắng cuối cùng", + "unableToDeleteSpaceNotCreatedByYou": "Không thể xóa các Không gian do người khác tạo", + "enableSpacesForYourWorkspace": "Bật Spaces cho không gian làm việc của bạn", + "title": "Khoảng cách", + "defaultSpaceName": "Tổng quan", + "upgradeSpaceTitle": "Kích hoạt Khoảng cách", + "upgradeSpaceDescription": "Tạo nhiều Không gian công cộng và riêng tư để sắp xếp không gian làm việc của bạn tốt hơn.", + "upgrade": "Cập nhật", + "upgradeYourSpace": "Tạo nhiều khoảng trống", + "quicklySwitch": "Nhanh chóng chuyển sang không gian tiếp theo", + "duplicate": "Không gian trùng lặp", + "movePageToSpace": "Di chuyển trang đến khoảng trống", + "switchSpace": "Chuyển đổi không gian", + "spaceNameCannotBeEmpty": "Tên khoảng trắng không được để trống" + }, + "publish": { + "hasNotBeenPublished": "Trang này chưa được xuất bản", + "spaceHasNotBeenPublished": "Chưa hỗ trợ xuất bản không gian", + "reportPage": "Báo cáo trang", + "databaseHasNotBeenPublished": "Việc xuất bản cơ sở dữ liệu hiện chưa được hỗ trợ.", + "createdWith": "Được tạo ra với", + "downloadApp": "Tải AppFlowy", + "copy": { + "codeBlock": "Nội dung của khối mã đã được sao chép vào bảng tạm", + "imageBlock": "Liên kết hình ảnh đã được sao chép vào clipboard", + "mathBlock": "Phương trình toán học đã được sao chép vào clipboard", + "fileBlock": "Liên kết tập tin đã được sao chép vào clipboard" + }, + "containsPublishedPage": "Trang này chứa một hoặc nhiều trang đã xuất bản. Nếu bạn tiếp tục, chúng sẽ bị hủy xuất bản. Bạn có muốn tiếp tục xóa không?", + "publishSuccessfully": "Đã xuất bản thành công", + "unpublishSuccessfully": "Đã hủy xuất bản thành công", + "publishFailed": "Không thể xuất bản", + "unpublishFailed": "Không thể hủy xuất bản", + "noAccessToVisit": "Không thể truy cập vào trang này...", + "createWithAppFlowy": "Tạo trang web với AppFlowy", + "fastWithAI": "Nhanh chóng và dễ dàng với AI.", + "tryItNow": "Thử ngay bây giờ", + "onlyGridViewCanBePublished": "Chỉ có thể xuất bản chế độ xem Lưới", + "database": { + "zero": "Xuất bản {} chế độ xem đã chọn", + "one": "Xuất bản {} chế độ xem đã chọn", + "many": "Xuất bản {} chế độ xem đã chọn", + "other": "Xuất bản {} chế độ xem đã chọn" + }, + "mustSelectPrimaryDatabase": "Phải chọn chế độ xem chính", + "noDatabaseSelected": "Chưa chọn cơ sở dữ liệu, vui lòng chọn ít nhất một cơ sở dữ liệu.", + "unableToDeselectPrimaryDatabase": "Không thể bỏ chọn cơ sở dữ liệu chính", + "saveThisPage": "Lưu trang này", + "duplicateTitle": "Bạn muốn thêm vào đâu?", + "selectWorkspace": "Chọn không gian làm việc", + "addTo": "Thêm vào", + "duplicateSuccessfully": "Tạo bản sao thành công. Bạn muốn xem tài liệu?", + "duplicateSuccessfullyDescription": "Bạn không có ứng dụng? Quá trình tải xuống của bạn sẽ tự động bắt đầu sau khi nhấp vào 'Tải xuống'.", + "downloadIt": "Tải về", + "openApp": "Mở trong ứng dụng", + "duplicateFailed": "Sao chép không thành công", + "membersCount": { + "zero": "Không có thành viên", + "one": "1 thành viên", + "many": "{đếm} thành viên", + "other": "{đếm} thành viên" + }, + "useThisTemplate": "Sử dụng mẫu" + }, + "web": { + "continue": "Tiếp tục", + "or": "hoặc", + "continueWithGoogle": "Tiếp tục với Google", + "continueWithGithub": "Tiếp tục với GitHub", + "continueWithDiscord": "Tiếp tục với Discord", + "continueWithApple": "Tiếp tục với Apple ", + "moreOptions": "Thêm tùy chọn", + "collapse": "Thu gọn", + "signInAgreement": "Bằng cách nhấp vào \"Tiếp tục\" ở trên, bạn đã đồng ý với AppFlowy", + "and": "và", + "termOfUse": "Điều khoản", + "privacyPolicy": "Chính sách bảo mật", + "signInError": "Lỗi đăng nhập", + "login": "Đăng ký hoặc đăng nhập", + "fileBlock": { + "uploadedAt": "Đã tải lên vào {time}", + "linkedAt": "Liên kết được thêm vào {time}", + "empty": "Tải lên hoặc nhúng một tập tin" + } + }, + "globalComment": { + "comments": "Bình luận", + "addComment": "Thêm bình luận", + "reactedBy": "phản ứng bởi", + "addReaction": "Thêm phản ứng", + "reactedByMore": "và {đếm} người khác", + "showSeconds": { + "one": "1 giây trước", + "other": "{count} giây trước", + "zero": "Vừa xong", + "many": "{count} giây trước" + }, + "showMinutes": { + "one": "1 phút trước", + "other": "{count} phút trước", + "many": "{count} phút trước" + }, + "showHours": { + "one": "1 giờ trước", + "other": "{count} giờ trước", + "many": "{count} giờ trước" + }, + "showDays": { + "one": "1 ngày trước", + "other": "{count} ngày trước", + "many": "{count} ngày trước" + }, + "showMonths": { + "one": "1 tháng trước", + "other": "{count} tháng trước", + "many": "{count} tháng trước" + }, + "showYears": { + "one": "1 năm trước", + "other": "{đếm} năm trước", + "many": "{đếm} năm trước" + }, + "reply": "Hồi đáp", + "deleteComment": "Xóa bình luận", + "youAreNotOwner": "Bạn không phải là chủ sở hữu của bình luận này", + "confirmDeleteDescription": "Bạn có chắc chắn muốn xóa bình luận này không?", + "hasBeenDeleted": "Đã xóa", + "replyingTo": "Trả lời cho", + "noAccessDeleteComment": "Bạn không được phép xóa bình luận này", + "collapse": "Sụp đổ", + "readMore": "Đọc thêm", + "failedToAddComment": "Không thêm được bình luận", + "commentAddedSuccessfully": "Đã thêm bình luận thành công.", + "commentAddedSuccessTip": "Bạn vừa thêm hoặc trả lời bình luận. Bạn có muốn chuyển lên đầu trang để xem các bình luận mới nhất không?" + }, + "template": { + "asTemplate": "Như mẫu", + "name": "Tên mẫu", + "description": "Mô tả mẫu", + "about": "Mẫu Giới Thiệu", + "preview": "Bản xem trước mẫu", + "categories": "Danh mục mẫu", + "isNewTemplate": "PIN vào mẫu mới", + "featured": "PIN vào mục Nổi bật", + "relatedTemplates": "Mẫu liên quan", + "requiredField": "{field} là bắt buộc", + "addCategory": "Thêm \"{category}\"", + "addNewCategory": "Thêm danh mục mới", + "addNewCreator": "Thêm người sáng tạo mới", + "deleteCategory": "Xóa danh mục", + "editCategory": "Chỉnh sửa danh mục", + "editCreator": "Chỉnh sửa người tạo", + "category": { + "name": "Tên danh mục", + "icon": "Biểu tượng danh mục", + "bgColor": "Màu nền danh mục", + "priority": "Ưu tiên danh mục", + "desc": "Mô tả danh mục", + "type": "Loại danh mục", + "icons": "Biểu tượng danh mục", + "colors": "Thể loại Màu sắc", + "byUseCase": "Theo trường hợp sử dụng", + "byFeature": "Theo tính năng", + "deleteCategory": "Xóa danh mục", + "deleteCategoryDescription": "Bạn có chắc chắn muốn xóa danh mục này không?", + "typeToSearch": "Nhập để tìm kiếm theo danh mục..." + }, + "creator": { + "label": "Người tạo mẫu", + "name": "Tên người sáng tạo", + "avatar": "Avatar của người sáng tạo", + "accountLinks": "Liên kết tài khoản người sáng tạo", + "uploadAvatar": "Nhấp để tải lên hình đại diện", + "deleteCreator": "Xóa người tạo", + "deleteCreatorDescription": "Bạn có chắc chắn muốn xóa người sáng tạo này không?", + "typeToSearch": "Nhập để tìm kiếm người sáng tạo..." + }, + "uploadSuccess": "Mẫu đã được tải lên thành công", + "uploadSuccessDescription": "Mẫu của bạn đã được tải lên thành công. Bây giờ bạn có thể xem nó trong thư viện mẫu.", + "viewTemplate": "Xem mẫu", + "deleteTemplate": "Xóa mẫu", + "deleteTemplateDescription": "Bạn có chắc chắn muốn xóa mẫu này không?", + "addRelatedTemplate": "Thêm mẫu liên quan", + "removeRelatedTemplate": "Xóa mẫu liên quan", + "uploadAvatar": "Tải lên hình đại diện", + "searchInCategory": "Tìm kiếm trong {category}", + "label": "Bản mẫu" + }, + "fileDropzone": { + "dropFile": "Nhấp hoặc kéo tệp vào khu vực này để tải lên", + "uploading": "Đang tải lên...", + "uploadFailed": "Tải lên không thành công", + "uploadSuccess": "Tải lên thành công", + "uploadSuccessDescription": "Tệp đã được tải lên thành công", + "uploadFailedDescription": "Tải tệp lên không thành công", + "uploadingDescription": "Tập tin đang được tải lên" + }, + "gallery": { + "preview": "Mở toàn màn hình", + "copy": "Sao chép", + "download": "Tải về", + "prev": "Trước", + "next": "Kế tiếp", + "resetZoom": "Đặt lại chế độ thu phóng", + "zoomIn": "Phóng to", + "zoomOut": "Thu nhỏ" } } diff --git a/frontend/resources/translations/zh-CN.json b/frontend/resources/translations/zh-CN.json index 8d342913c2..d1433929bc 100644 --- a/frontend/resources/translations/zh-CN.json +++ b/frontend/resources/translations/zh-CN.json @@ -36,21 +36,39 @@ "loginButtonText": "登录", "loginStartWithAnonymous": "从匿名会话开始", "continueAnonymousUser": "继续匿名会话", + "anonymous": "匿名", "buttonText": "登录", "signingInText": "登录中...", "forgotPassword": "忘记密码?", "emailHint": "邮箱", "passwordHint": "密码", "dontHaveAnAccount": "没有账户?", + "createAccount": "新建账户", "repeatPasswordEmptyError": "确认密码不能为空", "unmatchedPasswordError": "两次密码输入不一致", "syncPromptMessage": "同步数据可能需要一段时间,请不要关闭此页面", "or": "或", + "signInWithGoogle": "使用 Google 账户登录", + "signInWithGithub": "使用 Github 账户登录", + "signInWithDiscord": "使用 Discord 账户登录", + "signInWithApple": "使用 Apple 账户登录", + "continueAnotherWay": "使用其他方式登录", + "signUpWithGoogle": "使用 Google 账户注册", + "signUpWithGithub": "使用 Github 账户注册", + "signUpWithDiscord": "使用 Discord 账户注册", "signInWith": "用其他方式登入:", "signInWithEmail": "使用邮箱登录", + "signInWithMagicLink": "使用魔法链接登录", + "signUpWithMagicLink": "使用魔法链接注册", "pleaseInputYourEmail": "请输入邮箱地址", + "settings": "设置", "magicLinkSent": "魔法链接已经发送到您的邮箱,请检查!", "invalidEmail": "请输入一个有效的邮箱地址", + "alreadyHaveAnAccount": "已经有账户了?", + "logIn": "登陆", + "generalError": "出现错误,请稍后再试", + "limitRateError": "出于安全原因,每60秒仅可以发送一次链接", + "magicLinkSentDescription": "一个验证链接已发送到您的电子邮箱。点击该链接即可完成登录。该链接将在 5 分钟后失效。", "LogInWithGoogle": "使用 Google 登录", "LogInWithGithub": "使用 Github 登录", "LogInWithDiscord": "使用 Discord 登录", @@ -59,12 +77,18 @@ }, "workspace": { "chooseWorkspace": "选择您的工作区", + "defaultName": "我的工作区", "create": "新建工作区", + "new": "新的工作空间", + "importFromNotion": "从 Notion 导入", + "learnMore": "了解更多", "reset": "重置工作区", + "renameWorkspace": "重命名工作区", + "workspaceNameCannotBeEmpty": "工作区名称不可为空", "resetWorkspacePrompt": "重置工作区将删除其中的所有页面和数据。您确定要重置工作区吗?您也可以联系技术支持团队来恢复工作区", "hint": "工作区", "notFoundError": "找不到工作区", - "failedToLoad": "出了些问题!我们无法加载工作区。请尝试关闭所有打开的 AppFlowy 实例,然后重试。", + "failedToLoad": "出了些问题!我们无法加载工作区。请尝试关闭所有打开的 @:appName 实例,然后重试。", "errorActions": { "reportIssue": "上报问题", "reportIssueOnGithub": "在 Github 上报告问题", @@ -96,7 +120,25 @@ "html": "HTML", "clipboard": "拷贝到剪贴板", "csv": "CSV", - "copyLink": "复制链接" + "copyLink": "复制链接", + "publishToTheWeb": "发布至网络", + "publishToTheWebHint": "利用AppFlowy创建一个网站", + "publish": "发布", + "unPublish": "取消发布", + "visitSite": "访问网站", + "exportAsTab": "导出为", + "publishTab": "发布", + "shareTab": "分享", + "publishOnAppFlowy": "在 AppFlowy 发布", + "shareTabTitle": "邀请他人来协作", + "shareTabDescription": "与任何人轻松协作", + "copyLinkSuccess": "链接已复制到剪贴板", + "copyShareLink": "复制分享链接", + "copyLinkFailed": "无法将链接复制到剪贴板", + "copyLinkToBlockSuccess": "区块链接已复制到剪贴板", + "copyLinkToBlockFailed": "无法将区块链接复制到剪贴板", + "manageAllSites": "管理所有站点", + "updatePathName": "更新路径名称" }, "moreAction": { "small": "小", @@ -109,12 +151,18 @@ "charCount": "字符数:{}", "createdAt": "创建于 :{}", "deleteView": "删除", - "duplicateView": "复制" + "duplicateView": "复制", + "wordCountLabel": "词总数:", + "charCountLabel": "字符总数:", + "createdAtLabel": "已创建的:", + "syncedAtLabel": "已同步的:", + "saveAsNewPage": "在页面中添加消息" }, "importPanel": { "textAndMarkdown": "文本 和 Markdown", "documentFromV010": "来自 v0.1.0 的文档", "databaseFromV010": "来自 v0.1.0 的数据库", + "notionZip": "Notion 导出的 Zip 文件", "csv": "CSV", "database": "数据库" }, @@ -127,7 +175,12 @@ "openNewTab": "在新选项卡中打开", "moveTo": "移动", "addToFavorites": "添加到收藏夹", - "copyLink": "复制链接" + "copyLink": "复制链接", + "changeIcon": "更改图标", + "collapseAllPages": "收起全部子页面", + "movePageTo": "将页面移动至", + "move": "移动", + "lockPage": "锁定页面" }, "blankPageTitle": "空白页", "newPageText": "新页面", @@ -135,9 +188,51 @@ "newGridText": "新建网格", "newCalendarText": "新日历", "newBoardText": "新建看板", + "chat": { + "newChat": "AI 对话", + "inputMessageHint": "问 @:appName AI", + "inputLocalAIMessageHint": "问 @:appName 本地 AI", + "unsupportedCloudPrompt": "该功能仅在使用 @:appName Cloud 时可用", + "relatedQuestion": "相关问题", + "serverUnavailable": "服务暂时不可用,请稍后再试", + "aiServerUnavailable": "🌈 不妙!🌈 一只独角兽吃掉了我们的回复。请重试!", + "retry": "重试", + "clickToRetry": "点击重试", + "regenerateAnswer": "重新生成", + "question1": "如何使用 Kanban 来管理任务", + "question2": "介绍一下 GTD 工作法", + "question3": "为什么使用 Rust", + "question4": "使用现有食材制定一份菜谱", + "aiMistakePrompt": "AI 可能出错,请验证重要信息", + "chatWithFilePrompt": "你想与文件对话吗?", + "indexFileSuccess": "文件索引成功", + "inputActionNoPages": "无页面结果", + "clickToMention": "点击以提及页面", + "uploadFile": "上传聊天使用的 PDF、md 或 txt 文件", + "questionDetail": "{} 你好!我能怎么帮到你?", + "indexingFile": "正在索引 {}", + "generatingResponse": "正在生成相应", + "sourceUnsupported": "我们当前不支持基于数据库的交流", + "regenerate": "请重试", + "addToPageTitle": "添加消息至......", + "addToNewPage": "创建新的页面", + "openPagePreviewFailedToast": "打开页面失败", + "changeFormat": { + "actionButton": "变更样式", + "confirmButton": "基于该样式重新生成", + "imageOnly": "仅图片", + "textAndImage": "文本与图片", + "text": "段落", + "table": "表格" + }, + "referenceSource": "找到 {} 个来源", + "referenceSources": "找到 {} 个来源", + "questionTitle": "想法" + }, "trash": { "text": "回收站", "restoreAll": "全部恢复", + "restore": "恢复", "deleteAll": "全部删除", "pageHeader": { "fileName": "文件名", @@ -152,6 +247,10 @@ "title": "您确定要恢复回收站中的所有页面吗?", "caption": "此操作无法撤消。" }, + "restorePage": { + "title": "恢复:{}", + "caption": "你确定要恢复此页面吗?" + }, "mobile": { "actions": "垃圾桶操作", "empty": "垃圾桶是空的", @@ -164,26 +263,28 @@ "deletePagePrompt": { "text": "此页面已被移动至垃圾桶", "restore": "恢复页面", - "deletePermanent": "彻底删除" + "deletePermanent": "彻底删除", + "deletePermanentDescription": "你确定要永久删除此页面吗?此操作无法撤销。" }, "dialogCreatePageNameHint": "页面名称", "questionBubble": { "shortcuts": "快捷键", "whatsNew": "新功能", - "help": "帮助和支持", "markdown": "Markdown", "debug": { "name": "调试信息", "success": "将调试信息复制到剪贴板!", "fail": "无法将调试信息复制到剪贴板" }, - "feedback": "反馈" + "feedback": "反馈", + "help": "帮助和支持" }, "menuAppHeader": { "moreButtonToolTip": "删除、重命名等等...", "addPageTooltip": "在其中快速添加页面", "defaultNewPageName": "未命名页面", - "renameDialog": "重命名" + "renameDialog": "重命名", + "pageNameSuffix": "复制" }, "noPagesInside": "里面没有页面", "toolbar": { @@ -214,6 +315,7 @@ "viewDataBase": "查看数据库", "referencePage": "这个 {name} 正在被引用", "addBlockBelow": "在下面添加一个块", + "aiGenerate": "生成", "urlLaunchAccessory": "在浏览器中打开", "urlCopyAccessory": "复制链接" }, @@ -224,11 +326,48 @@ "private": "私人的", "workspace": "工作区", "favorites": "收藏夹", + "clickToHidePrivate": "点击以隐藏私人空间\n您在此处创建的页面仅对您可见", + "clickToHideWorkspace": "点击以隐藏私人空间\n您在此处创建的页面对所有人可见", "clickToHidePersonal": "点击隐藏个人部分", "clickToHideFavorites": "单击隐藏收藏夹栏目", "addAPage": "添加页面", "addAPageToPrivate": "添加页面到私人空间", - "recent": "最近的" + "addAPageToWorkspace": "添加页面到工作空间", + "recent": "最近的", + "today": "今日", + "thisWeek": "本周", + "others": "其他", + "earlier": "更早", + "justNow": "现在", + "minutesAgo": "{count} 分钟以前", + "lastViewed": "最近一次查看", + "favoriteAt": "已收藏", + "emptyRecent": "没有最近页面", + "emptyRecentDescription": "在你查看页面时,它们会出现在这里,方便检索。", + "emptyFavorite": "无收藏页面", + "emptyFavoriteDescription": "将页面收藏起来——它们会列在这里,方便快速访问!", + "removePageFromRecent": "从最近页移除此页面吗?", + "removeSuccess": "成功移除", + "favoriteSpace": "收藏", + "RecentSpace": "最近", + "Spaces": "空间", + "upgradeToPro": "升级至专业版", + "upgradeToAIMax": "解锁无限制 AI", + "storageLimitDialogTitle": "你已用尽免费存储。升级以解锁无限制存储", + "storageLimitDialogTitleIOS": "你已用尽免费存储。", + "aiResponseLimitTitle": "你已用尽免费 AI 回应。升级到专业版或者购买 AI 插件来解锁无限制回应", + "aiResponseLimitDialogTitle": "已达到 AI 回应限额", + "aiResponseLimit": "你已用尽了免费 AI 回应。\n\n转到“设置 -> 计划 -> 点击 AI Max 或 Pro 计划”获取更多 AI 回应", + "askOwnerToUpgradeToPro": "你的工作区即将用尽免费存储。请联系工作区所有者升级到专业版计划", + "askOwnerToUpgradeToProIOS": "你的工作区即将用尽免费存储。", + "askOwnerToUpgradeToAIMax": "你的工作区即将用尽免费 AI 回应。请联系工作区所有者升级计划或购买 AI 插件", + "askOwnerToUpgradeToAIMaxIOS": "你的工作区即将用尽免费 AI 回应限额。", + "aiImageResponseLimit": "你已消耗完你的 AI 图像响应额度。\n转到 设置 -> 方案 -> 点击 AI Max 去获得更多的图像响应额度", + "purchaseStorageSpace": "购买存储空间", + "purchaseAIResponse": "购买", + "askOwnerToUpgradeToLocalAI": "联系工作区所有者启用设备上 AI", + "upgradeToAILocal": "在你的设备上运行本地模型,极致保护隐私", + "upgradeToAILocalDesc": "使用本地 AI 用 PDF 聊天、改善写作、自动填充表格" }, "notifications": { "export": { @@ -244,6 +383,7 @@ }, "button": { "ok": "OK", + "confirm": "确认", "done": "完成", "cancel": "取消", "signIn": "登录", @@ -261,16 +401,22 @@ "upload": "上传", "edit": "编辑", "delete": "删除", + "copy": "复制", "duplicate": "复制", "putback": "放回去", "update": "更新", "share": "分享", "removeFromFavorites": "从收藏夹中", + "removeFromRecent": "从最近页移除", "addToFavorites": "添加到收藏夹", + "favoriteSuccessfully": "收藏成功", + "unfavoriteSuccessfully": "取消收藏成功", + "duplicateSuccessfully": "副本创建成功", "rename": "重命名", "helpCenter": "帮助中心", "add": "添加", "yes": "是", + "no": "否", "clear": "清空", "remove": "移除", "dontRemove": "不移除", @@ -283,6 +429,19 @@ "signInGoogle": "使用 Google 账户登录", "signInGithub": "使用 Github 账户登录", "signInDiscord": "使用 Discord 账户登录", + "more": "更多", + "create": "创建", + "close": "关闭", + "next": "下一个", + "previous": "上一个", + "submit": "提交", + "download": "下载", + "backToHome": "返回主页", + "viewing": "查看", + "editing": "编辑", + "gotIt": "我知道了", + "retry": "重试", + "uploadFailed": "上传失败", "Done": "完成", "Cancel": "取消", "OK": "确认" @@ -309,6 +468,319 @@ }, "settings": { "title": "设置", + "popupMenuItem": { + "settings": "设置", + "members": "成员", + "trash": "回收站", + "helpAndSupport": "帮助与支持" + }, + "sites": { + "title": "站点", + "namespaceTitle": "名字空间", + "namespaceDescription": "管理你的名字空间与主页", + "namespaceHeader": "名字空间", + "homepageHeader": "主页", + "updateNamespace": "更新名字空间", + "removeHomepage": "移除主页", + "selectHomePage": "选择一个页面", + "customUrl": "自定义 URL", + "namespace": { + "updateExistingNamespace": "更新现有的名称空间", + "upgradeToPro": "请升级至 Pro 订阅计划以设置主页", + "redirectToPayment": "正在重定向至付款页面......", + "onlyWorkspaceOwnerCanSetHomePage": "仅工作空间的所有者能为其设置主页", + "pleaseAskOwnerToSetHomePage": "请联系工作空间所有者更新至 Pro 订阅计划" + }, + "publishedPage": { + "title": "所有已发布页面", + "description": "管理您已发布的页面", + "date": "已发布的数据", + "emptyHinText": "在当前工作空间没有已发布的页面", + "noPublishedPages": "没有已发布的页面", + "settings": "发布设置", + "clickToOpenPageInApp": "在 App 中打开页面", + "clickToOpenPageInBrowser": "在浏览器中打开页面" + }, + "error": { + "failedToUpdateNamespace": "更新名称空间失败", + "proPlanLimitation": "您需要升级至 Pro 方案以更新名称空间", + "namespaceAlreadyInUse": "该名称空间已被占用你,请尝试其他名称空间", + "invalidNamespace": "无效的名称空间,请尝试其他的名称空间", + "namespaceLengthAtLeast2Characters": "名称空间应不少于 2 个字符长度", + "onlyWorkspaceOwnerCanUpdateNamespace": "仅工作空间所有者可更新名称空间", + "onlyWorkspaceOwnerCanRemoveHomepage": "仅工作空间所有者可移除主页", + "setHomepageFailed": "设置主页失败", + "namespaceTooLong": "该名称空间过长,请尝试其他名称空间", + "namespaceTooShort": "该名称空间过短,请尝试其他名称空间", + "namespaceIsReserved": "该名称空间已被占用,请尝试其他名称空间", + "updatePathNameFailed": "更新路径名称失败", + "removeHomePageFailed": "移除主页失败", + "publishNameContainsInvalidCharacters": "该路径名称包含无效的字符,请尝试其他的路径名称", + "publishNameTooShort": "路径名称过短,请尝试其他路径名称", + "publishNameTooLong": "路径名称过长,请尝试其他路径名称", + "publishNameAlreadyInUse": "该路径名称已被使用,请尝试其他路径名称", + "namespaceContainsInvalidCharacters": "该名称空间包含无效的字符,请尝试其他名称空间", + "publishPermissionDenied": "仅工作空间所有者或页面发布者可管理发布设置", + "publishNameCannotBeEmpty": "该路径名称不能为空,请尝试其他路径名称" + }, + "success": { + "namespaceUpdated": "更新名称空间成功", + "setHomepageSuccess": "成功设置主页", + "updatePathNameSuccess": "更新路径名称成功", + "removeHomePageSuccess": "成功删除主页" + } + }, + "accountPage": { + "menuLabel": "我的账户", + "title": "我的账户", + "general": { + "title": "账户名与资料图像", + "changeProfilePicture": "更换头像" + }, + "email": { + "title": "邮箱", + "actions": { + "change": "更改邮箱" + } + }, + "login": { + "title": "登录账户", + "loginLabel": "登录", + "logoutLabel": "退出登录" + }, + "description": "自定义您的简介,管理账户安全信息和 AI API keys,或登陆您的账户" + }, + "workspacePage": { + "menuLabel": "工作区", + "title": "工作区", + "description": "自定义你的工作区外观、主题、字体、文本布局、日期/时间格式和语言。", + "workspaceName": { + "title": "工作区名称", + "savedMessage": "已保存的工作区名称", + "editTooltip": "编辑工作区名称" + }, + "workspaceIcon": { + "title": "工作区图标", + "description": "上传图片或使用表情符号到你的工作区。图标将显示在您的侧边栏和通知中。" + }, + "appearance": { + "title": "外观", + "description": "自定义你的工作区外观、主题、字体、文本布局、日期、时间和语言。", + "options": { + "system": "自动", + "light": "明亮", + "dark": "黑暗" + } + }, + "resetCursorColor": { + "title": "重置文档光标颜色", + "description": "确定要重置光标颜色吗?" + }, + "resetSelectionColor": { + "title": "重置文档选取颜色", + "description": "确定要重置选区颜色吗?" + }, + "resetWidth": { + "resetSuccess": "文档宽度重置成功" + }, + "theme": { + "title": "主题", + "description": "选择预设主题,或上传你自己的自定义主题。", + "uploadCustomThemeTooltip": "上传自定义主题" + }, + "workspaceFont": { + "title": "工作区字体", + "noFontHint": "找不到字体,换个词试试。" + }, + "textDirection": { + "title": "文字方向", + "leftToRight": "从左到右", + "rightToLeft": "从右到左", + "auto": "自动", + "enableRTLItems": "启用从右向左工具栏项目" + }, + "layoutDirection": { + "title": "布局方向", + "leftToRight": "从左到右", + "rightToLeft": "从右到左" + }, + "dateTime": { + "title": "日期 & 时间", + "example": "{} 于 {} ({})", + "24HourTime": "24 小时制", + "dateFormat": { + "label": "日期格式", + "local": "本地", + "us": "US", + "iso": "ISO", + "friendly": "友好", + "dmy": "日/月/年" + } + }, + "language": { + "title": "语言" + }, + "deleteWorkspacePrompt": { + "title": "删除工作区", + "content": "你确定要删除此工作区吗?此操作无法撤消。" + }, + "leaveWorkspacePrompt": { + "title": "离开工作区", + "content": "你确定要离开此工作区吗?你将无法访问其中的所有页面和数据。", + "success": "你已经成功离开工作区。", + "fail": "无法离开工作区。" + }, + "manageWorkspace": { + "title": "管理工作区", + "leaveWorkspace": "离开工作区", + "deleteWorkspace": "删除工作区" + } + }, + "manageDataPage": { + "menuLabel": "管理数据", + "title": "管理数据", + "description": "管理数据本地存储或将现有数据导入@:appName 。", + "dataStorage": { + "title": "文件存储位置", + "tooltip": "你的文件存储位置", + "actions": { + "change": "更改路径", + "open": "打开文件夹", + "openTooltip": "打开当前数据文件夹位置", + "copy": "复制路径", + "copiedHint": "路径已复制!", + "resetTooltip": "重置为默认位置" + }, + "resetDialog": { + "title": "你确定吗?", + "description": "将数据路径重置为默认位置不会删除你的数据。如果你想重新导入当前数据,你应该先复制当前位置的路径。" + } + }, + "importData": { + "title": "导入数据", + "tooltip": "从 @:appName 备份/数据文件夹导入数据", + "description": "从外部 @:appName 数据文件夹复制数据", + "action": "浏览文件夹" + }, + "encryption": { + "title": "加密", + "tooltip": "管理你的数据存储和加密方式", + "descriptionNoEncryption": "开启加密将会加密所有数据。此操作无法撤消。", + "descriptionEncrypted": "你的数据已加密。", + "action": "加密数据", + "dialog": { + "title": "加密您的所有数据?", + "description": "加密所有数据将保证数据安全。此操作无法撤消。您确定要继续吗?" + } + }, + "cache": { + "title": "清除缓存", + "description": "如果你遇到图片无法加载或字体无法正确显示的问题,请尝试清除缓存。此操作不会删除你的用户数据。", + "dialog": { + "title": "清除缓存", + "description": "清除缓存会导致加载时重新下载图像和字体。此操作不会删除或修改你的数据。", + "successHint": "缓存已清除!" + } + }, + "data": { + "fixYourData": "修复数据", + "fixButton": "修复" + } + }, + "shortcutsPage": { + "menuLabel": "快捷键", + "title": "快捷键", + "actions": { + "resetDefault": "重置为默认" + }, + "errorPage": { + "howToFix": "请再次尝试,如果该问题依然存在,请在 Github 上联系我们" + }, + "resetDialog": { + "title": "重置快捷键", + "description": "这将会将所有按键绑定重置为默认,之后无法撤销。你确定要继续吗?" + }, + "conflictDialog": { + "confirmLabel": "继续" + }, + "keybindings": { + "insertNewParagraphInCodeblock": "插入新的段落", + "pasteInCodeblock": "粘贴为代码块", + "selectAllCodeblock": "全选", + "copy": "复制选中的内容", + "alignLeft": "文本居左对齐", + "alignCenter": "文本居中对齐", + "alignRight": "文本居右对齐", + "undo": "撤销", + "redo": "重做", + "convertToParagraph": "将块转换为段落", + "backspace": "删除", + "deleteLeftWord": "删除左侧文字", + "deleteLeftSentence": "删除左侧句子", + "delete": "删除右侧字符", + "deleteMacOS": "删除左侧字符", + "deleteRightWord": "删除右侧文字", + "moveCursorLeft": "将光标移至左侧", + "moveCursorBeginning": "将光标移至开头", + "moveCursorLeftWord": "将光标移至文字左侧", + "moveCursorRight": "将光标移至右侧", + "moveCursorEnd": "将光标移至末尾", + "moveCursorRightWord": "将光标移至文字右侧", + "home": "滚动至顶部", + "end": "滚动至底部" + } + }, + "aiPage": { + "title": "AI 设置", + "menuLabel": "AI 设置", + "keys": { + "enableAISearchTitle": "AI 搜索", + "aiSettingsDescription": "选择你偏好的模型来赋能 AppFlowy AI。目前可以使用 GPT 4-o、Claude 3,5、Llama 3.1 与 Mistral 7B", + "llmModel": "语言模型", + "llmModelType": "语言模型类别" + } + }, + "planPage": { + "planUsage": { + "currentPlan": { + "freeTitle": "免费" + } + } + }, + "comparePlanDialog": { + "proLabels": { + "itemTwo": "最多十个", + "itemThree": "无限", + "itemFour": "是", + "itemFive": "是", + "itemSix": "无限", + "itemFileUpload": "无限" + } + }, + "cancelSurveyDialog": { + "questionOne": { + "answerOne": "价格太高", + "answerTwo": "功能未达预期", + "answerThree": "找到更好替代方案" + }, + "questionTwo": { + "answerOne": "非常可能", + "answerTwo": "稍有可能", + "answerThree": "不确定", + "answerFour": "不太可能", + "answerFive": "非常不可能" + }, + "questionFour": { + "answerOne": "极好", + "answerTwo": "良好", + "answerThree": "一般", + "answerFour": "较差", + "answerFive": "未满足" + } + }, + "common": { + "reset": "重置" + }, "menu": { "appearance": "外观", "language": "语言", @@ -328,13 +800,8 @@ "cloudServerType": "云服务器", "cloudServerTypeTip": "请注意,切换云服务器后可能会登出您当前的账户", "cloudLocal": "本地", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "Supabase URL", - "cloudSupabaseUrlCanNotBeEmpty": "supabase url 不能为空", - "cloudSupabaseAnonKey": "Supabase Anon key", - "cloudSupabaseAnonKeyCanNotBeEmpty": "如果 Supabase url 不为空,则 Anon key 不能为空", - "cloudAppFlowy": "AppFlowy Cloud", - "cloudAppFlowySelfHost": "AppFlowy Cloud 自托管", + "cloudAppFlowy": "@:appName Cloud Beta", + "cloudAppFlowySelfHost": "@:appName Cloud 自托管", "appFlowyCloudUrlCanNotBeEmpty": "云地址不能为空", "clickToCopy": "点击复制", "selfHostStart": "如果您没有服务器,请参阅", @@ -355,27 +822,50 @@ "historicalUserList": "用户登录历史记录", "historicalUserListTooltip": "此列表显示您的匿名帐户。您可以单击某个帐户来查看其详细信息。单击“开始”按钮即可创建匿名帐户", "openHistoricalUser": "点击开设匿名账户", - "customPathPrompt": "将 AppFlowy 数据文件夹存储在云同步文件夹(例如 Google Drive)中可能会带来风险。如果同时从多个位置访问或修改此文件夹中的数据库,可能会导致同步冲突和潜在的数据损坏", - "importAppFlowyData": "从外部 AppFlowy 文件夹导入数据", + "customPathPrompt": "将 @:appName 数据文件夹存储在云同步文件夹(例如 Google Drive)中可能会带来风险。如果同时从多个位置访问或修改此文件夹中的数据库,可能会导致同步冲突和潜在的数据损坏", + "importAppFlowyData": "从外部 @:appName 文件夹导入数据", "importingAppFlowyDataTip": "数据导入正在进行中。请不要关闭应用程序", - "importAppFlowyDataDescription": "从外部 AppFlowy 数据文件夹复制数据并将其导入到当前 AppFlowy 数据文件夹中", - "importSuccess": "成功导入AppFlowy数据文件夹", - "importFailed": "导入 AppFlowy 数据文件夹失败", + "importAppFlowyDataDescription": "从外部 @:appName 数据文件夹复制数据并将其导入到当前 @:appName 数据文件夹中", + "importSuccess": "成功导入@:appName数据文件夹", + "importFailed": "导入 @:appName 数据文件夹失败", "importGuide": "有关详细信息,请参阅参考文档", - "supabaseSetting": "Supabase 设置", "cloudSetting": "云设置" }, "notifications": { "enableNotifications": { "label": "启用通知", "hint": "关闭以阻止本地通知出现。" + }, + "showNotificationsIcon": { + "label": "显示通知图标", + "hint": "关闭开关以隐藏侧边栏中的通知图标。" + }, + "markAsReadNotifications": { + "allSuccess": "成功全部标为已读", + "success": "成功标为已读" + }, + "action": { + "markAsRead": "标为已读", + "multipleChoice": "选择更多" + }, + "settings": { + "settings": "设置" + }, + "tabs": { + "unread": "未读" + }, + "refreshSuccess": "成功刷新通知", + "titles": { + "notifications": "通知", + "reminder": "提醒" } }, "appearance": { "resetSetting": "重置此设置", "fontFamily": { "label": "字体系列", - "search": "搜索" + "search": "搜索", + "defaultFont": "系统" }, "themeMode": { "label": "主题模式", @@ -387,6 +877,9 @@ "documentSettings": { "cursorColor": "文档光标颜色", "selectionColor": "文档选择颜色", + "pickColor": "选择颜色", + "colorShade": "色深", + "opacity": "透明度", "hexEmptyError": "十六进制颜色不能为空", "hexLengthError": "十六进制值必须为 6 位数字", "hexInvalidError": "十六进制值无效", @@ -413,7 +906,7 @@ "themeUpload": { "button": "上传", "uploadTheme": "上传主题", - "description": "使用下面的按钮上传您自己的 AppFlowy 主题。", + "description": "使用下面的按钮上传您自己的 @:appName 主题。", "loading": "我们正在验证并上传您的主题,请稍候...", "uploadSuccess": "您的主题已上传成功", "deletionFailure": "删除主题失败,请尝试手动删除。", @@ -442,6 +935,7 @@ "members": { "title": "成员设置", "inviteMembers": "添加成员", + "inviteHint": "使用电子邮件邀请", "sendInvite": "发送邀请", "copyInviteLink": "复制邀请链接", "label": "成员", @@ -452,10 +946,14 @@ "guest": "访客", "member": "成员", "emailSent": "邮件已发送,请检查您的邮箱", + "memberLimitExceededUpgrade": "升级", + "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "添加成员失败", "addMemberSuccess": "添加成员成功", "removeMember": "移除成员", - "areYouSureToRemoveMember": "您确定要删除该成员吗?" + "areYouSureToRemoveMember": "您确定要删除该成员吗?", + "inviteMemberSuccess": "成功发送邀请", + "failedToInviteMember": "邀请成员失败" } }, "files": { @@ -463,7 +961,7 @@ "defaultLocation": "读取文件和数据存储位置", "exportData": "导出您的数据", "doubleTapToCopy": "双击复制路径", - "restoreLocation": "恢复为 AppFlowy 默认路径", + "restoreLocation": "恢复为 @:appName 默认路径", "customizeLocation": "打开另一个文件夹", "restartApp": "请重启 App 使设置生效", "exportDatabase": "导出数据库", @@ -475,10 +973,10 @@ "defineWhereYourDataIsStored": "定义数据存储位置", "open": "打开", "openFolder": "打开现有文件夹", - "openFolderDesc": "读取并将其写入您现有的 AppFlowy 文件夹", + "openFolderDesc": "读取并将其写入您现有的 @:appName 文件夹", "folderHintText": "文件夹名", "location": "正在新建文件夹", - "locationDesc": "为您的 AppFlowy 数据文件夹选择一个名称", + "locationDesc": "为您的 @:appName 数据文件夹选择一个名称", "browser": "浏览", "create": "新建", "set": "设置", @@ -489,7 +987,7 @@ "change": "更改", "openLocationTooltips": "打开另一个数据目录", "openCurrentDataFolder": "打开当前数据目录", - "recoverLocationTooltips": "恢复为 AppFlowy 默认数据目录", + "recoverLocationTooltips": "恢复为 @:appName 默认数据目录", "exportFileSuccess": "导出成功!", "exportFileFail": "导出失败!", "export": "导出", @@ -503,9 +1001,26 @@ "email": "电子邮件", "tooltipSelectIcon": "选择图标", "selectAnIcon": "选择一个图标", - "pleaseInputYourOpenAIKey": "请输入您的 OpenAI 密钥", - "pleaseInputYourStabilityAIKey": "请输入您的 Stability AI 密钥", - "clickToLogout": "点击退出当前用户" + "pleaseInputYourOpenAIKey": "请输入您的 AI 密钥", + "clickToLogout": "点击退出当前用户", + "pleaseInputYourStabilityAIKey": "请输入您的 Stability AI 密钥" + }, + "mobile": { + "personalInfo": "个人信息", + "username": "用户名", + "usernameEmptyError": "用户名不能为空", + "about": "关于", + "pushNotifications": "推送通知", + "support": "支持", + "joinDiscord": "在 Discord 中加入我们", + "privacyPolicy": "隐私政策", + "userAgreement": "用户协议", + "termsAndConditions": "条款和条件", + "userprofileError": "无法加载用户配置文件", + "userprofileErrorDescription": "请尝试注销并重新登录以检查问题是否仍然存在。", + "selectLayout": "选择布局", + "selectStartingDay": "选择开始日期", + "version": "版本" }, "shortcuts": { "shortcutsLabel": "快捷方式", @@ -527,23 +1042,6 @@ "textAlignRight": "右对齐文本", "codeBlockDeleteTwoSpaces": "删除代码块中行首的两个空格" } - }, - "mobile": { - "personalInfo": "个人信息", - "username": "用户名", - "usernameEmptyError": "用户名不能为空", - "about": "关于", - "pushNotifications": "推送通知", - "support": "支持", - "joinDiscord": "在 Discord 中加入我们", - "privacyPolicy": "隐私政策", - "userAgreement": "用户协议", - "termsAndConditions": "条款和条件", - "userprofileError": "无法加载用户配置文件", - "userprofileErrorDescription": "请尝试注销并重新登录以检查问题是否仍然存在。", - "selectLayout": "选择布局", - "selectStartingDay": "选择开始日期", - "version": "版本" } }, "grid": { @@ -644,6 +1142,7 @@ "isNotEmpty": "不为空" }, "field": { + "label": "属性", "hide": "隐藏", "show": "展示", "insertLeft": "左侧插入", @@ -661,6 +1160,8 @@ "multiSelectFieldName": "多项选择器", "urlFieldName": "链接", "checklistFieldName": "清单", + "summaryFieldName": "AI 总结", + "timeFieldName": "时间", "numberFormat": "数字格式", "dateFormat": "日期格式", "includeTime": "包含时间", @@ -733,6 +1234,7 @@ "action": "执行", "add": "点击添加到下方", "drag": "拖动以移动", + "deleteRowPrompt": "您确定要删除此行吗?此操作无法撤消", "dragAndClick": "拖拽移动,点击打开菜单", "insertRecordAbove": "在上方插入记录", "insertRecordBelow": "点击添加到下方" @@ -768,8 +1270,10 @@ "url": { "launch": "在浏览器中打开链接", "copy": "将链接复制到剪贴板", - "textFieldHint": "输入 URL", - "copiedNotification": "已复制到剪贴板!" + "textFieldHint": "输入 URL" + }, + "relation": { + "rowSearchTextFieldPlaceholder": "搜索" }, "menuName": "网格", "referencedGridPrefix": "视图", @@ -782,6 +1286,12 @@ "min": "分钟", "sum": "和" }, + "media": { + "rename": "重命名", + "download": "下载", + "delete": "删除", + "open": "打开" + }, "singleSelectOptionFilter": { "is": "等于", "isNot": "不等于", @@ -816,6 +1326,62 @@ }, "document": { "selectADocumentToLinkTo": "选择要链接到的文档" + }, + "name": { + "textStyle": "文本样式", + "list": "列表", + "toggle": "切换", + "fileAndMedia": "文件与媒体", + "simpleTable": "简单表格", + "visuals": "视觉元素", + "document": "文档", + "advanced": "高级", + "text": "文本", + "heading1": "一级标题", + "heading2": "二级标题", + "heading3": "三级标题", + "image": "图片", + "bulletedList": "项目符号列表", + "numberedList": "编号列表", + "todoList": "待办事项列表", + "doc": "文档", + "linkedDoc": "链接到页面", + "grid": "网格", + "linkedGrid": "链接网格", + "kanban": "看板", + "linkedKanban": "链接看板", + "calendar": "日历", + "linkedCalendar": "链接日历", + "quote": "引用", + "divider": "分隔符", + "table": "表格", + "callout": "提示框", + "outline": "大纲", + "mathEquation": "数学公式", + "code": "代码", + "toggleList": "切换列表", + "toggleHeading1": "切换标题1", + "toggleHeading2": "切换标题2", + "toggleHeading3": "切换标题3", + "emoji": "表情符号", + "aiWriter": "向AI提问", + "dateOrReminder": "日期或提醒", + "photoGallery": "图片库", + "file": "文件", + "twoColumns": "两列", + "threeColumns": "三列", + "fourColumns": "四列" + }, + "subPage": { + "name": "文档", + "keyword1": "子页面", + "keyword2": "页面", + "keyword3": "子页面", + "keyword4": "插入页面", + "keyword5": "嵌入页面", + "keyword6": "新页面", + "keyword7": "创建页面", + "keyword8": "文档" } }, "selectionMenu": { @@ -827,23 +1393,33 @@ "referencedGrid": "引用的网格", "referencedCalendar": "引用的日历", "referencedDocument": "参考文档", - "autoGeneratorMenuItemName": "OpenAI 创作", - "autoGeneratorTitleName": "OpenAI: 让 AI 写些什么...", + "aiWriter": { + "userQuestion": "向AI提问", + "continueWriting": "继续写作", + "fixSpelling": "修正拼写和语法", + "improveWriting": "优化写作", + "summarize": "总结", + "explain": "解释", + "makeShorter": "缩短", + "makeLonger": "扩展" + }, + "autoGeneratorMenuItemName": "AI 创作", + "autoGeneratorTitleName": "AI: 让 AI 写些什么...", "autoGeneratorLearnMore": "学习更多", "autoGeneratorGenerate": "生成", - "autoGeneratorHintText": "让 OpenAI ...", - "autoGeneratorCantGetOpenAIKey": "无法获得 OpenAI 密钥", + "autoGeneratorHintText": "让 AI ...", + "autoGeneratorCantGetOpenAIKey": "无法获得 AI 密钥", "autoGeneratorRewrite": "重写", "smartEdit": "AI 助手", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "修正拼写", "warning": "⚠️ AI 可能不准确或具有误导性.", "smartEditSummarize": "总结", "smartEditImproveWriting": "提高写作水平", "smartEditMakeLonger": "丰富内容", - "smartEditCouldNotFetchResult": "无法从 OpenAI 获取到结果", - "smartEditCouldNotFetchKey": "无法获取到 OpenAI 密钥", - "smartEditDisabled": "在设置中连接 OpenAI", + "smartEditCouldNotFetchResult": "无法从 AI 获取到结果", + "smartEditCouldNotFetchKey": "无法获取到 AI 密钥", + "smartEditDisabled": "在设置中连接 AI", "discardResponse": "您是否要放弃 AI 继续写作?", "createInlineMathEquation": "创建方程", "fonts": "字体", @@ -887,7 +1463,7 @@ }, "optionAction": { "click": "点击", - "toOpenMenu": " 来打开菜单", + "toOpenMenu": "打开菜单", "delete": "删除", "duplicate": "复制", "turnInto": "变成", @@ -897,12 +1473,14 @@ "align": "对齐", "left": "左", "center": "中心", - "right": "又", - "defaultColor": "默认" + "right": "右", + "defaultColor": "默认", + "depth": "深度", + "copyLinkToBlock": "粘贴块链接" }, "image": { - "copiedToPasteBoard": "图片链接已复制到剪贴板", - "addAnImage": "添加图像" + "addAnImage": "添加图像", + "copiedToPasteBoard": "图片链接已复制到剪贴板" }, "urlPreview": { "copiedToPasteBoard": "链接已复制到剪贴板" @@ -911,8 +1489,8 @@ "addHeadingToCreateOutline": "添加标题以创建目录。" }, "table": { - "addAfter": "在前面添加", - "addBefore": "在后面添加", + "addAfter": "在后面添加", + "addBefore": "在前面添加", "delete": "删除", "clear": "清空内容", "duplicate": "创建副本", @@ -932,7 +1510,35 @@ "newDatabase": "新建数据库", "linkToDatabase": "链接至数据库" }, - "date": "日期" + "date": "日期", + "video": { + "label": "视频", + "emptyLabel": "添加视频", + "placeholder": "粘贴视频链接", + "copiedToPasteBoard": "视频链接已复制到剪贴板", + "insertVideo": "添加视频" + }, + "linkPreview": { + "typeSelection": { + "pasteAs": "粘贴为", + "mention": "提及", + "URL": "URL", + "bookmark": "书签", + "embed": "嵌入" + }, + "linkPreviewMenu": { + "toMetion": "转换为提及", + "toUrl": "转换为URL", + "toEmbed": "转换为嵌入", + "toBookmark": "转换为书签", + "copyLink": "复制链接", + "replace": "替换", + "reload": "重新加载", + "removeLink": "移除链接", + "pasteHint": "粘贴 https://...", + "unableToDisplay": "无法显示" + } + } }, "outlineBlock": { "placeholder": "目录" @@ -954,8 +1560,8 @@ "placeholder": "输入图片网址" }, "ai": { - "label": "从 OpenAI 生成图像", - "placeholder": "请输入 OpenAI 生成图像的提示" + "label": "从 AI 生成图像", + "placeholder": "请输入 AI 生成图像的提示" }, "stability_ai": { "label": "从 Stability AI 生成图像", @@ -977,15 +1583,15 @@ "label": "Unsplash" }, "searchForAnImage": "搜索图像", - "pleaseInputYourOpenAIKey": "请在设置页面输入您的 OpenAI 密钥", - "pleaseInputYourStabilityAIKey": "请在设置页面输入您的 Stability AI 密钥", + "pleaseInputYourOpenAIKey": "请在设置页面输入您的 AI 密钥", "saveImageToGallery": "保存图片", "failedToAddImageToGallery": "无法将图像添加到图库", "successToAddImageToGallery": "图片已成功添加到图库", "unableToLoadImage": "无法加载图像", "maximumImageSize": "支持的最大上传图片大小为 10MB", "uploadImageErrorImageSizeTooBig": "图片大小必须小于 10MB", - "imageIsUploading": "图片正在上传" + "imageIsUploading": "图片正在上传", + "pleaseInputYourStabilityAIKey": "请在设置页面输入您的 Stability AI 密钥" }, "codeBlock": { "language": { @@ -1066,6 +1672,15 @@ "unhideGroup": "显示隐藏的组", "unhideGroupContent": "您确定要在看板上显示该组吗?", "faildToLoad": "无法加载看板视图" + }, + "dateCondition": { + "today": "今天", + "yesterday": "昨天", + "tomorrow": "明天", + "lastSevenDays": "过去 7 天", + "nextSevenDays": "未来 7 天", + "lastThirtyDays": "过去 30 天", + "nextThirtyDays": "未来 30 天" } }, "calendar": { @@ -1076,7 +1691,13 @@ "today": "今天", "jumpToday": "跳转到今天", "previousMonth": "上一月", - "nextMonth": "下一月" + "nextMonth": "下一月", + "views": { + "day": "天", + "week": "周", + "month": "月", + "year": "年" + } }, "settings": { "showWeekNumbers": "显示周数", @@ -1093,7 +1714,7 @@ "quickJumpYear": "跳转到" }, "errorDialog": { - "title": "AppFlowy 错误", + "title": "@:appName 错误", "howToFixFallback": "对于给您带来的不便, 我们深表歉意! 请在我们的 GitHub 页面上提交 issue 并描述您遇到的错误。", "github": "在 GitHub 查看" }, @@ -1168,7 +1789,18 @@ } }, "datePicker": { - "dateTimeFormatTooltip": "在设置中更改日期和时间格式" + "dateTimeFormatTooltip": "在设置中更改日期和时间格式", + "reminderOptions": { + "fiveMinsBefore": "5 分钟以前", + "tenMinsBefore": "10 分钟以前", + "fifteenMinsBefore": "15 分钟以前", + "thirtyMinsBefore": "30 分钟以前", + "oneHourBefore": "1 小时以前", + "twoHoursBefore": "2 小时以前", + "oneDayBefore": "1 天以前", + "twoDaysBefore": "2 天以前", + "oneWeekBefore": "1 周以前" + } }, "relativeDates": { "yesterday": "昨天", @@ -1356,5 +1988,108 @@ "date": "日期", "addField": "添加字段", "userIcon": "用户图标" + }, + "newSettings": { + "myAccount": { + "title": "我的账户", + "subtitle": "自定义您的个人资料,管理账户安全,设置 AI keys,或登录您的账户。", + "profileLabel": "帐户名称 & 头像", + "profileNamePlaceholder": "输入你的名字", + "accountSecurity": "帐户安全", + "2FA": "两步验证", + "aiKeys": "AI keys", + "accountLogin": "登录账户", + "updateNameError": "更新名称失败", + "updateIconError": "更新图标失败", + "deleteAccount": { + "title": "删除帐户", + "subtitle": "永久删除你的帐户和所有数据。", + "description": "永久删除你的账户,并移除所有工作区的访问权限。", + "deleteMyAccount": "删除我的账户", + "dialogTitle": "删除帐户", + "dialogContent1": "你确定要永久删除您的帐户吗?", + "dialogContent2": "此操作无法撤消,并且将删除所有团队空间的访问权限,删除你的整个帐户(包括私人工作区),并将你从所有共享工作区中删除。", + "confirmHint1": "请输入 \"@:newSettings.myAccount.deleteAccount.confirmHint3\" 以确认。", + "confirmHint2": "我理解此操作是不可逆的,并且将永久删除我的帐户和所有关联数据。", + "confirmHint3": "删除我的账户", + "checkToConfirmError": "你必须勾选以确认删除。", + "failedToGetCurrentUser": "获取当前用户邮箱失败", + "confirmTextValidationFailed": "你的确认文本不匹配 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", + "deleteAccountSuccess": "账户删除成功" + } + }, + "workplace": { + "updateIconError": "图标更新失败", + "chooseAnIcon": "选择一个图标", + "appearance": { + "name": "外观", + "themeMode": { + "auto": "自动", + "light": "明亮", + "dark": "黑暗" + }, + "language": "语言" + } + } + }, + "pageStyle": { + "pageIcon": "页面图标", + "openSettings": "打开设置", + "image": "图像" + }, + "commandPalette": { + "placeholder": "输入你要搜索的内容..." + }, + "space": { + "defaultSpaceName": "一般" + }, + "publish": { + "saveThisPage": "使用此模板创建" + }, + "web": { + "continueWithGoogle": "使用 Google 账户登录", + "continueWithGithub": "使用 GitHub 账户登录", + "continueWithDiscord": "使用 Discord 账户登录" + }, + "template": { + "deleteFromTemplate": "从模板中删除", + "relatedTemplates": "相关模板", + "deleteTemplate": "删除模板", + "removeRelatedTemplate": "移除相关模板", + "label": "模板" + }, + "time": { + "justNow": "刚刚", + "seconds": { + "one": "1 秒", + "other": "{count} 秒" + }, + "minutes": { + "one": "1 分钟", + "other": "{count} 分钟" + }, + "hours": { + "one": "1 小时", + "other": "{count} 小时" + }, + "days": { + "one": "1 天", + "other": "{count} 天" + }, + "weeks": { + "one": "1 周", + "other": "{count} 周" + }, + "months": { + "one": "1 月", + "other": "{count} 月" + }, + "years": { + "one": "1 年", + "other": "{count} 年" + }, + "ago": "前", + "yesterday": "昨天", + "today": "今天" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/zh-TW.json b/frontend/resources/translations/zh-TW.json index aea8585650..b5f4ff3d5f 100644 --- a/frontend/resources/translations/zh-TW.json +++ b/frontend/resources/translations/zh-TW.json @@ -9,6 +9,7 @@ "title": "標題", "youCanAlso": "你也可以", "and": "和", + "failedToOpenUrl": "無法開啟網址:{}", "blockActions": { "addBelowTooltip": "點選以在下方新增", "addAboveCmd": "Alt+點選", @@ -35,42 +36,92 @@ "loginButtonText": "登入", "loginStartWithAnonymous": "以匿名身份開始", "continueAnonymousUser": "以匿名身份繼續", + "anonymous": "匿名的", "buttonText": "登入", "signingInText": "正在登入...", "forgotPassword": "忘記密碼?", "emailHint": "電子郵件", "passwordHint": "密碼", "dontHaveAnAccount": "還沒有帳號?", + "createAccount": "建立帳戶", "repeatPasswordEmptyError": "確認密碼不能為空", "unmatchedPasswordError": "密碼重複輸入不一致", "syncPromptMessage": "同步資料可能需要一些時間。請不要關閉此頁面", "or": "或", + "signInWithGoogle": "使用Google 登入", + "signInWithGithub": "使用Github 登入", + "signInWithDiscord": "使用Discord 登入", + "signInWithApple": "使用 Apple 帳號登入", + "continueAnotherWay": "使用其他方式登入", + "signUpWithGoogle": "使用Google 註冊", + "signUpWithGithub": "使用Github 註冊", + "signUpWithDiscord": "使用Discord 註冊", "signInWith": "透過以下方式登入:", + "signInWithEmail": "使用電子郵件登入", + "signInWithMagicLink": "使用Magic Link 登入", + "signUpWithMagicLink": "使用Magic Link 註冊", + "pleaseInputYourEmail": "請輸入您的電郵地址", + "settings": "設定", + "magicLinkSent": "我們己發送Magic Link 到您的電子郵件,點擊連結登入", + "invalidEmail": "請輸入有效的電郵地址", + "alreadyHaveAnAccount": "已經有帳戶?", + "logIn": "登入", + "generalError": "出了些問題。請稍後再試", + "limitRateError": "出於安全原因,您只能每60 秒申請一次Magic Link", + "magicLinkSentDescription": "連結已發送到您的電子信箱。點擊連結即可登入。連結將在 5 分鐘後過期。", "LogInWithGoogle": "使用 Google 登入", "LogInWithGithub": "使用 Github 登入", - "LogInWithDiscord": "使用 Discord 登入" + "LogInWithDiscord": "使用 Discord 登入", + "loginAsGuestButtonText": "以訪客身分登入" }, "workspace": { "chooseWorkspace": "選擇你的工作區", "create": "建立工作區", "reset": "重設工作區", + "renameWorkspace": "重新命名工作區", "resetWorkspacePrompt": "重設工作區將刪除其中所有頁面和資料。你確定要重設工作區嗎?或者,你可以聯絡支援團隊來恢復工作區。", "hint": "工作區", "notFoundError": "找不到工作區", - "failedToLoad": "出了些問題!無法載入工作區。請嘗試關閉 AppFlowy 的任何開啟執行個體,然後再試一次。", + "failedToLoad": "出了些問題!無法載入工作區。請嘗試關閉 @:appName 的任何開啟執行個體,然後再試一次。", "errorActions": { "reportIssue": "回報問題", "reportIssueOnGithub": "在 Github 提交 issue", "exportLogFiles": "匯出日誌記錄檔案", "reachOut": "在 Discord 上聯絡我們" - } + }, + "menuTitle": "工作區", + "deleteWorkspaceHintText": "您確定要刪除工作區嗎?此操作無法撤銷", + "createSuccess": "成功創建工作區", + "createFailed": "無法創建工作區", + "createLimitExceeded": "您已達到帳戶允許的最大工作區限制。如果您需要額外的工作空間,請在 Github 上申請", + "deleteSuccess": "工作區刪除成功", + "deleteFailed": "工作區刪除失敗", + "openSuccess": "成功開啟工作區", + "openFailed": "無法開啟工作區", + "renameSuccess": "工作區重命名成功", + "renameFailed": "無法重命名工作區", + "updateIconSuccess": "更新工作區圖標成功", + "updateIconFailed": "無法更新工作區圖標", + "cannotDeleteTheOnlyWorkspace": "無法刪除唯一的工作區", + "fetchWorkspacesFailed": "無法取得工作區", + "leaveCurrentWorkspace": "離開工作區", + "leaveCurrentWorkspacePrompt": "您確定要離開當前工作區嗎?" }, "shareAction": { "buttonText": "分享", "workInProgress": "即將推出", "markdown": "Markdown", + "html": "HTML", + "clipboard": "複製到剪貼簿", "csv": "CSV", - "copyLink": "複製連結" + "copyLink": "複製連結", + "publishToTheWeb": "發佈到公開網路", + "publishToTheWebHint": "使用 AppFlowy 建立網站", + "publish": "發佈", + "unPublish": "取消發佈", + "exportAsTab": "導出為", + "publishTab": "發佈", + "shareTab": "分享" }, "moreAction": { "small": "小", @@ -78,7 +129,12 @@ "large": "大", "fontSize": "字型大小", "import": "匯入", - "moreOptions": "更多選項" + "moreOptions": "更多選項", + "wordCount": "字數:{}", + "charCount": "字符數:{}", + "createdAt": "建立於:{}", + "deleteView": "刪除", + "duplicateView": "複製" }, "importPanel": { "textAndMarkdown": "文字 & Markdown", @@ -96,7 +152,9 @@ "openNewTab": "在新分頁中開啟", "moveTo": "移動到", "addToFavorites": "加入最愛", - "copyLink": "複製連結" + "copyLink": "複製連結", + "changeIcon": "更改圖示", + "collapseAllPages": "折疊所有子頁面" }, "blankPageTitle": "空白頁面", "newPageText": "新增頁面", @@ -104,6 +162,25 @@ "newGridText": "新增網格", "newCalendarText": "新增日曆", "newBoardText": "新增看板", + "chat": { + "newChat": "AI 聊天", + "inputMessageHint": "詢問 @:appName AI", + "inputLocalAIMessageHint": "詢問 @:appName 本機 AI", + "unsupportedCloudPrompt": "此功能僅在使用 @:appName Cloud 時可用", + "relatedQuestion": "相關問題", + "serverUnavailable": "服務暫時無法使用,請稍後再試。", + "aiServerUnavailable": "🌈 哎呀! 🌈 我們的回覆被一隻獨角獸吃掉了!請重新試一次吧! ", + "clickToRetry": "點擊重試", + "regenerateAnswer": "重新產生", + "question1": "如何使用 Kanban 來管理任務", + "question2": "解釋 GTD 工作法", + "question3": "為什麼要使用 Rust", + "question4": "清冰箱食譜", + "aiMistakePrompt": "AI 可能會犯錯,請檢查重要資訊。", + "clickToMention": "單擊以提及頁面", + "uploadFile": "上傳 PDF、md 或 txt 檔案以進行聊天", + "questionDetail": "{} 你好!我能為您提供什麼幫助?" + }, "trash": { "text": "垃圾桶", "restoreAll": "全部還原", @@ -127,7 +204,8 @@ "emptyDescription": "您沒有任何已刪除的檔案", "isDeleted": "已刪除", "isRestored": "已還原" - } + }, + "confirmDeleteTitle": "確定永久刪除此頁面" }, "deletePagePrompt": { "text": "此頁面在垃圾桶中", @@ -138,14 +216,14 @@ "questionBubble": { "shortcuts": "快捷鍵", "whatsNew": "有什麼新功能?", - "help": "幫助 & 支援", "markdown": "Markdown", "debug": { "name": "除錯資訊", "success": "已將除錯資訊複製到剪貼簿!", "fail": "無法將除錯資訊複製到剪貼簿" }, - "feedback": "意見回饋" + "feedback": "意見回饋", + "help": "幫助 & 支援" }, "menuAppHeader": { "moreButtonToolTip": "移除、重新命名等等...", @@ -181,17 +259,47 @@ "dragRow": "長按以重新排序列", "viewDataBase": "檢視資料庫", "referencePage": "這個 {name} 已被引用", - "addBlockBelow": "在下方新增一個區塊" + "addBlockBelow": "在下方新增一個區塊", + "aiGenerate": "產生", + "genSummary": "產成摘要" }, "sideBar": { "closeSidebar": "關閉側欄", "openSidebar": "開啟側欄", "personal": "個人", + "private": "私人", + "workspace": "工作區", "favorites": "最愛", + "clickToHidePrivate": "點擊以隱藏私人空間\n您在此處建立的頁面只有您自己可見", + "clickToHideWorkspace": "點擊以隱藏工作區\n您在此處建立的頁面對每個成員都可見", "clickToHidePersonal": "點選以隱藏個人區塊", "clickToHideFavorites": "點選以隱藏最愛區塊", "addAPage": "新增頁面", - "recent": "最近" + "addAPageToPrivate": "新增頁面到私人空間", + "addAPageToWorkspace": "將頁面新增至工作區", + "recent": "最近", + "today": "今天", + "thisWeek": "本週", + "justNow": "剛才", + "minutesAgo": "{count} 分鐘前", + "lastViewed": "上次查看", + "favoriteAt": "已收藏", + "emptyRecent": "沒有最近的頁面", + "emptyFavorite": "沒有收藏的頁面", + "emptyFavoriteDescription": "將頁面標記為收藏 - 它們將列在此處以便您可以快速存取!", + "removePageFromRecent": "要從最近的頁面中刪除此頁面嗎?", + "removeSuccess": "刪除成功", + "favoriteSpace": "收藏", + "RecentSpace": "最近的", + "Spaces": "空間", + "upgradeToPro": "升級到 Pro", + "upgradeToAIMax": "解鎖無限 AI", + "storageLimitDialogTitle": "您的免費儲存空間已用完,升級以解鎖無限儲存空間", + "storageLimitDialogTitleIOS": "您的免費儲存空間已用完。", + "aiResponseLimitTitle": "您的免費 AI 回覆已用完,升級到 Pro 或購買 AI 附加方案以解鎖無限回覆", + "aiResponseLimitDialogTitle": "AI 回覆已達到限制", + "purchaseStorageSpace": "購買儲存空間", + "purchaseAIResponse": "購買" }, "notifications": { "export": { @@ -207,6 +315,7 @@ }, "button": { "ok": "確定", + "confirm": "確認", "done": "完成", "cancel": "取消", "signIn": "登入", @@ -229,11 +338,35 @@ "update": "更新", "share": "分享", "removeFromFavorites": "從最愛中移除", + "removeFromRecent": "從最近刪除", "addToFavorites": "加入最愛", + "favoriteSuccessfully": "收藏成功", + "duplicateSuccessfully": "複製成功", "rename": "重新命名", "helpCenter": "支援中心", "add": "新增", - "yes": "是" + "yes": "是", + "no": "否", + "clear": "清除", + "remove": "刪除", + "dontRemove": "不要刪除", + "copyLink": "複製連結", + "align": "對齊", + "login": "登入", + "logout": "登出", + "deleteAccount": "刪除帳號", + "back": "返回", + "signInGoogle": "使用Google 登入", + "signInGithub": "使用Github 登入", + "signInDiscord": "使用Discord 登入", + "more": "更多", + "create": "新增", + "close": "關閉", + "next": "下一個", + "previous": "上一個", + "download": "下載", + "backToHome": "回首頁", + "tryAGain": "再試一次" }, "label": { "welcome": "歡迎!", @@ -257,6 +390,31 @@ }, "settings": { "title": "設定", + "popupMenuItem": { + "settings": "設定", + "members": "成員", + "trash": "垃圾桶" + }, + "accountPage": { + "menuLabel": "我的帳號", + "title": "我的帳號", + "general": { + "title": "帳號名稱和個人資料圖片", + "changeProfilePicture": "更改個人資料圖片" + }, + "email": { + "title": "電子郵件", + "actions": { + "change": "更改電子郵件" + } + }, + "login": { + "title": "帳號登入", + "loginLabel": "登入", + "logoutLabel": "登出" + }, + "description": "自訂您的個人資料、管理帳戶安全性和 AI API 金鑰,或登入您的帳號" + }, "menu": { "appearance": "外觀", "language": "語言", @@ -276,13 +434,8 @@ "cloudServerType": "雲端伺服器種類", "cloudServerTypeTip": "請注意,切換雲端伺服器後可能會登出您目前的帳號", "cloudLocal": "本地", - "cloudSupabase": "Supabase", - "cloudSupabaseUrl": "Supabase 網址", - "cloudSupabaseUrlCanNotBeEmpty": "Supabase 網址不能為空", - "cloudSupabaseAnonKey": "Supabase 匿名金鑰", - "cloudSupabaseAnonKeyCanNotBeEmpty": "如果 Supabase 網址不為空,則匿名金鑰不得為空", - "cloudAppFlowy": "AppFlowy 雲端測試版 (Beta)", - "cloudAppFlowySelfHost": "自架 AppFlowy 雲端伺服器", + "cloudAppFlowy": "@:appName 雲端測試版 (Beta)", + "cloudAppFlowySelfHost": "自架 @:appName 雲端伺服器", "appFlowyCloudUrlCanNotBeEmpty": "雲端網址不能為空", "clickToCopy": "點選以複製", "selfHostStart": "若您尚未設定伺服器,請參閱", @@ -303,12 +456,12 @@ "historicalUserList": "使用者登入歷史", "historicalUserListTooltip": "此列表顯示您的匿名帳號。您可以點選帳號以檢視其詳細資訊。透過點選「開始使用」按鈕來建立匿名帳號", "openHistoricalUser": "點選以開啟匿名帳號", - "customPathPrompt": "將 AppFlowy 資料資料夾儲存在如 Google 雲端硬碟等雲端同步資料夾中可能會帶來風險。如果該資料庫在多處同時被存取或修改,可能會導致同步衝突和潛在的資料損壞", - "importAppFlowyData": "從外部 AppFlowy 資料夾匯入資料", + "customPathPrompt": "將 @:appName 資料資料夾儲存在如 Google 雲端硬碟等雲端同步資料夾中可能會帶來風險。如果該資料庫在多處同時被存取或修改,可能會導致同步衝突和潛在的資料損壞", + "importAppFlowyData": "從外部 @:appName 資料夾匯入資料", "importingAppFlowyDataTip": "資料正在匯入中。請勿關閉應用程式", - "importAppFlowyDataDescription": "從外部 AppFlowy 資料夾複製資料並匯入到目前的 AppFlowy 資料夾", - "importSuccess": "成功匯入 AppFlowy 資料夾", - "importFailed": "匯入 AppFlowy 資料夾失敗", + "importAppFlowyDataDescription": "從外部 @:appName 資料夾複製資料並匯入到目前的 @:appName 資料夾", + "importSuccess": "成功匯入 @:appName 資料夾", + "importFailed": "匯入 @:appName 資料夾失敗", "importGuide": "欲瞭解更多詳細資訊,請查閱參考文件" }, "notifications": { @@ -321,7 +474,8 @@ "resetSetting": "重設", "fontFamily": { "label": "字型", - "search": "搜尋" + "search": "搜尋", + "defaultFont": "系統預設" }, "themeMode": { "label": "主題模式", @@ -329,6 +483,7 @@ "dark": "深色模式", "system": "依照系統設定" }, + "fontScaleFactor": "字體比例", "documentSettings": { "cursorColor": "文件游標顏色", "selectionColor": "文件選取顏色", @@ -358,7 +513,7 @@ "themeUpload": { "button": "上傳", "uploadTheme": "上傳主題", - "description": "使用下方的按鈕上傳您自己的 AppFlowy 主題。", + "description": "使用下方的按鈕上傳您自己的 @:appName 主題。", "loading": "我們正在驗證並上傳您的主題,請稍候...", "uploadSuccess": "您的主題已成功上傳", "deletionFailure": "刪除主題失敗。請嘗試手動刪除。", @@ -381,14 +536,38 @@ "twelveHour": "12 小時制", "twentyFourHour": "24 小時制" }, - "showNamingDialogWhenCreatingPage": "建立頁面時顯示命名對話框" + "showNamingDialogWhenCreatingPage": "建立頁面時顯示命名對話框", + "members": { + "title": "成員設定", + "inviteMembers": "邀請成員", + "sendInvite": "發送邀請", + "copyInviteLink": "複製邀請連結", + "label": "成員", + "user": "使用者", + "role": "身分組", + "removeFromWorkspace": "從工作區中刪除", + "owner": "擁有者", + "guest": "訪客", + "member": "成員", + "memberHintText": "成員可以閱讀、評論和編輯頁面。邀請其他成員和訪客", + "guestHintText": "訪客可以閱讀、做出回應、發表評論,並且可以在獲得許可的情況下編輯頁面", + "emailInvalidError": "電郵無效,請檢查並重試", + "emailSent": "郵件已發送,請查看您的收件匣", + "members": "成員", + "failedToAddMember": "新增成員失敗", + "addMemberSuccess": "成員新增成功", + "removeMember": "刪除成員", + "areYouSureToRemoveMember": "確定要刪除該成員?", + "inviteMemberSuccess": "邀請已成功發送", + "failedToInviteMember": "邀請成員失敗" + } }, "files": { "copy": "複製", - "defaultLocation": "AppFlowy 資料儲存位置", + "defaultLocation": "@:appName 資料儲存位置", "exportData": "匯出您的資料", "doubleTapToCopy": "點選兩下以複製路徑", - "restoreLocation": "恢復為 AppFlowy 預設路徑", + "restoreLocation": "恢復為 @:appName 預設路徑", "customizeLocation": "開啟其他資料夾", "restartApp": "請重新啟動應用程式以使變更生效。", "exportDatabase": "匯出資料庫", @@ -400,10 +579,10 @@ "defineWhereYourDataIsStored": "定義您的資料儲存位置", "open": "開啟", "openFolder": "開啟一個已經存在的資料夾", - "openFolderDesc": "讀取並寫入到現有的 AppFlowy 資料夾", + "openFolderDesc": "讀取並寫入到現有的 @:appName 資料夾", "folderHintText": "資料夾名稱", "location": "建立新資料夾", - "locationDesc": "為您的 AppFlowy 資料夾選擇一個名稱", + "locationDesc": "為您的 @:appName 資料夾選擇一個名稱", "browser": "瀏覽", "create": "建立", "set": "設定", @@ -414,30 +593,23 @@ "change": "更改", "openLocationTooltips": "開啟另一個資料目錄", "openCurrentDataFolder": "開啟目前資料目錄", - "recoverLocationTooltips": "重設為 AppFlowy 的預設資料目錄", + "recoverLocationTooltips": "重設為 @:appName 的預設資料目錄", "exportFileSuccess": "匯出檔案成功!", "exportFileFail": "匯出檔案失敗!", - "export": "匯出" + "export": "匯出", + "clearCache": "清除快取", + "clearCacheDesc": "如果您遇到圖像無法載入或字體無法正確顯示等問題,請嘗試清除快取。此操作不會刪除您的使用者資料", + "areYouSureToClearCache": "確定清除快取?", + "clearCacheSuccess": "快取清除成功" }, "user": { "name": "名稱", "email": "電子郵件", "tooltipSelectIcon": "選擇圖示", "selectAnIcon": "選擇圖示", - "pleaseInputYourOpenAIKey": "請輸入您的 OpenAI 金鑰", - "pleaseInputYourStabilityAIKey": "請輸入您的 Stability AI 金鑰", - "clickToLogout": "點選以登出目前使用者" - }, - "shortcuts": { - "shortcutsLabel": "快捷鍵", - "command": "指令", - "keyBinding": "鍵盤綁定", - "addNewCommand": "新增指令", - "updateShortcutStep": "按下您想要的鍵盤組合並按下 ENTER", - "shortcutIsAlreadyUsed": "此快捷鍵已被使用於:{conflict}", - "resetToDefault": "重設為預設鍵盤綁定", - "couldNotLoadErrorMsg": "無法載入快捷鍵,請再試一次", - "couldNotSaveErrorMsg": "無法儲存快捷鍵,請再試一次" + "pleaseInputYourOpenAIKey": "請輸入您的 AI 金鑰", + "clickToLogout": "點選以登出目前使用者", + "pleaseInputYourStabilityAIKey": "請輸入您的 Stability AI 金鑰" }, "mobile": { "personalInfo": "個人資料", @@ -455,6 +627,20 @@ "selectLayout": "選擇版面配置", "selectStartingDay": "選擇一週的起始日", "version": "版本" + }, + "shortcuts": { + "shortcutsLabel": "快捷鍵", + "command": "指令", + "keyBinding": "鍵盤綁定", + "addNewCommand": "新增指令", + "updateShortcutStep": "按下您想要的鍵盤組合並按下 ENTER", + "shortcutIsAlreadyUsed": "此快捷鍵已被使用於:{conflict}", + "resetToDefault": "重設為預設鍵盤綁定", + "couldNotLoadErrorMsg": "無法載入快捷鍵,請再試一次", + "couldNotSaveErrorMsg": "無法儲存快捷鍵,請再試一次", + "commands": { + "textAlignRight": "向右對齊文字" + } } }, "grid": { @@ -548,6 +734,7 @@ "multiSelectFieldName": "多選", "urlFieldName": "網址", "checklistFieldName": "核取清單", + "summaryFieldName": "AI 總結", "numberFormat": "數字格式", "dateFormat": "日期格式", "includeTime": "包含時間", @@ -577,9 +764,11 @@ "editProperty": "編輯屬性", "newProperty": "新增屬性", "deleteFieldPromptMessage": "您確定嗎?這個屬性將被刪除", + "clearFieldPromptMessage": "確定操作,該列中的所有單元格都將被清空", "newColumn": "新增欄位", "format": "格式", - "reminderOnDateTooltip": "此欄位設有預定提醒" + "reminderOnDateTooltip": "此欄位設有預定提醒", + "optionAlreadyExist": "選項已存在" }, "rowPage": { "newField": "新增欄位", @@ -593,7 +782,8 @@ "one": "隱藏 {count} 個隱藏欄位", "many": "隱藏 {count} 個隱藏欄位", "other": "隱藏 {count} 個隱藏欄位" - } + }, + "openAsFullPage": "以整頁形式打開" }, "sort": { "ascending": "升冪", @@ -614,7 +804,8 @@ "drag": "拖曳以移動", "dragAndClick": "拖曳以移動,點選以開啟選單", "insertRecordAbove": "在上方插入記錄", - "insertRecordBelow": "在下方插入記錄" + "insertRecordBelow": "在下方插入記錄", + "noContent": "無內容" }, "selectOption": { "create": "建立", @@ -646,7 +837,8 @@ }, "url": { "launch": "在瀏覽器中開啟", - "copy": "複製網址" + "copy": "複製網址", + "textFieldHint": "輸入網址" }, "menuName": "網格", "referencedGridPrefix": "檢視", @@ -692,26 +884,27 @@ "referencedGrid": "已連結的網格", "referencedCalendar": "已連結的日曆", "referencedDocument": "已連結的文件", - "autoGeneratorMenuItemName": "OpenAI 寫手", - "autoGeneratorTitleName": "OpenAI:讓 AI 撰寫任何內容……", + "autoGeneratorMenuItemName": "AI 寫手", + "autoGeneratorTitleName": "AI:讓 AI 撰寫任何內容……", "autoGeneratorLearnMore": "瞭解更多", "autoGeneratorGenerate": "產生", - "autoGeneratorHintText": "問 OpenAI……", - "autoGeneratorCantGetOpenAIKey": "無法取得 OpenAI 金鑰", + "autoGeneratorHintText": "問 AI……", + "autoGeneratorCantGetOpenAIKey": "無法取得 AI 金鑰", "autoGeneratorRewrite": "改寫", "smartEdit": "AI 助理", - "openAI": "OpenAI", + "aI": "AI", "smartEditFixSpelling": "修正拼寫", "warning": "⚠️ AI 的回覆可能不準確或具有誤導性。", "smartEditSummarize": "總結", "smartEditImproveWriting": "提高寫作水準", "smartEditMakeLonger": "做得更長", - "smartEditCouldNotFetchResult": "無法取得 OpenAI 的結果", - "smartEditCouldNotFetchKey": "無法取得 OpenAI 金鑰", - "smartEditDisabled": "在設定連結 OpenAI ", + "smartEditCouldNotFetchResult": "無法取得 AI 的結果", + "smartEditCouldNotFetchKey": "無法取得 AI 金鑰", + "smartEditDisabled": "在設定連結 AI ", "discardResponse": "確定捨棄 AI 的回覆?", "createInlineMathEquation": "建立公式", "fonts": "字型", + "insertDate": "插入日期", "emoji": "表情符號", "toggleList": "切換列表", "quoteList": "引述列表", @@ -765,14 +958,18 @@ "defaultColor": "預設" }, "image": { + "addAnImage": "新增圖片", "copiedToPasteBoard": "圖片連結已複製到剪貼簿", - "addAnImage": "新增圖片" + "imageUploadFailed": "圖片上傳失敗", + "errorCode": "錯誤代碼" }, "urlPreview": { - "copiedToPasteBoard": "連結已複製到剪貼簿" + "copiedToPasteBoard": "連結已複製到剪貼簿", + "convertToLink": "轉換為嵌入鏈接" }, "outline": { - "addHeadingToCreateOutline": "新增標題以建立目錄。" + "addHeadingToCreateOutline": "新增標題以建立目錄。", + "noMatchHeadings": "未找到匹配的標題" }, "table": { "addAfter": "在後方新增", @@ -798,6 +995,9 @@ }, "date": "日期" }, + "outlineBlock": { + "placeholder": "目錄" + }, "textBlock": { "placeholder": "輸入“/”作為命令" }, @@ -815,8 +1015,8 @@ "placeholder": "輸入圖片網址" }, "ai": { - "label": "由 OpenAI 生成圖片", - "placeholder": "請輸入提示讓 OpenAI 生成圖片" + "label": "由 AI 生成圖片", + "placeholder": "請輸入提示讓 AI 生成圖片" }, "stability_ai": { "label": "由 Stability AI 生成圖片", @@ -827,7 +1027,8 @@ "invalidImage": "無效的圖片", "invalidImageSize": "圖片大小必須小於 5MB", "invalidImageFormat": "不支援的圖片格式。支援的格式:JPEG、PNG、GIF、SVG", - "invalidImageUrl": "無效的圖片網址" + "invalidImageUrl": "無效的圖片網址", + "noImage": "沒有該檔案或目錄" }, "embedLink": { "label": "嵌入連結", @@ -837,20 +1038,25 @@ "label": "Unsplash" }, "searchForAnImage": "搜尋圖片", - "pleaseInputYourOpenAIKey": "請在設定頁面輸入您的 OpenAI 金鑰", - "pleaseInputYourStabilityAIKey": "請在設定頁面輸入您的 Stability AI 金鑰", + "pleaseInputYourOpenAIKey": "請在設定頁面輸入您的 AI 金鑰", "saveImageToGallery": "儲存圖片", "failedToAddImageToGallery": "無法將圖片新增到相簿", "successToAddImageToGallery": "圖片已成功新增到相簿", "unableToLoadImage": "無法載入圖片", "maximumImageSize": "支援的最大上傳圖片大小為 10MB", - "uploadImageErrorImageSizeTooBig": "圖片大小必須小於 10MB" + "uploadImageErrorImageSizeTooBig": "圖片大小必須小於 10MB", + "imageIsUploading": "圖片上傳中", + "pleaseInputYourStabilityAIKey": "請在設定頁面輸入您的 Stability AI 金鑰" }, "codeBlock": { "language": { "label": "語言", - "placeholder": "選擇語言" - } + "placeholder": "選擇語言", + "auto": "自動" + }, + "copyTooltip": "複製區塊的內容", + "searchLanguageHint": "搜尋語言", + "codeCopiedSnackbar": "程式碼已複製到剪貼簿" }, "inlineLink": { "placeholder": "貼上或輸入連結", @@ -881,6 +1087,11 @@ "errorBlock": { "theBlockIsNotSupported": "目前版本不支援此區塊。", "blockContentHasBeenCopied": "區塊內容已被複製。" + }, + "mobilePageSelector": { + "title": "選擇頁面", + "failedToLoad": "載入頁面清單失敗", + "noPagesFound": "沒有找到該頁面" } }, "board": { @@ -958,7 +1169,7 @@ "quickJumpYear": "跳到" }, "errorDialog": { - "title": "AppFlowy 錯誤", + "title": "@:appName 錯誤", "howToFixFallback": "對於給您帶來的不便,我們深表歉意!在我們的 GitHub 頁面上提交描述您的錯誤的問題。", "github": "在 GitHub 上檢視" }, @@ -1025,6 +1236,7 @@ }, "inlineActions": { "noResults": "無結果", + "recentPages": "最近的頁面", "pageReference": "頁面參照", "docReference": "文件參照", "boardReference": "看板參照", @@ -1106,7 +1318,8 @@ "replace": "取代", "replaceAll": "全部取代", "noResult": "無結果", - "caseSensitive": "區分大小寫" + "caseSensitive": "區分大小寫", + "searchMore": "搜尋以查找更多結果" }, "error": { "weAreSorry": "我們很抱歉", @@ -1125,6 +1338,7 @@ "color": "顏色", "image": "圖片", "date": "日期", + "page": "頁面", "italic": "斜體", "link": "連結", "numberedList": "編號清單", @@ -1200,6 +1414,8 @@ "copy": "複製", "paste": "貼上", "find": "尋找", + "select": "選取", + "selectAll": "選取所有", "previousMatch": "上一個符合", "nextMatch": "下一個符合", "closeFind": "關閉", @@ -1256,5 +1472,75 @@ "addField": "新增欄位", "userIcon": "使用者圖示" }, - "noLogFiles": "這裡沒有日誌記錄檔案" + "noLogFiles": "這裡沒有日誌記錄檔案", + "newSettings": { + "myAccount": { + "title": "我的帳戶", + "subtitle": "自訂您的個人資料、管理帳戶安全性、Open AI 金鑰或登入您的帳戶", + "profileLabel": "帳號名稱和個人資料圖片", + "profileNamePlaceholder": "輸入你的名字", + "accountSecurity": "帳戶安全性", + "2FA": "兩步驟驗證", + "accountLogin": "帳號登入", + "updateNameError": "名稱更新失敗", + "updateIconError": "個人頭像更新失敗", + "deleteAccount": { + "title": "刪除帳號", + "subtitle": "永久刪除您的帳號和所有資料", + "deleteMyAccount": "刪除我的帳號", + "dialogTitle": "刪除帳號", + "dialogContent1": "確定要永久刪除您的帳號", + "dialogContent2": "此操作無法撤銷,此操作將刪除所有團隊空間的存取權限,刪除您的整個帳戶(包括私人工作區),並將您從所有共用工作區中刪除" + } + }, + "workplace": { + "name": "工作區", + "title": "工作區設定", + "subtitle": "自訂您的工作區外觀、主題、字體、文字佈局、日期、時間和語言", + "workplaceName": "工作區名稱", + "workplaceNamePlaceholder": "輸入工作區名稱", + "workplaceIcon": "工作區圖標", + "workplaceIconSubtitle": "為您的工作區上傳圖像或表情符號。圖示將顯示在您的側邊欄和通知中", + "renameError": "工作區重新命名失敗", + "updateIconError": "更新圖像失敗", + "appearance": { + "name": "外觀", + "themeMode": { + "auto": "自動", + "light": "亮白", + "dark": "黑暗" + }, + "language": "語言" + } + }, + "syncState": { + "syncing": "同步中", + "synced": "已同步", + "noNetworkConnected": "沒有連線網絡" + } + }, + "pageStyle": { + "title": "頁面樣式", + "layout": "佈局", + "coverImage": "封面圖片", + "pageIcon": "頁面圖片", + "colors": "顏色", + "gradient": "漸變", + "backgroundImage": "背景圖片", + "presets": "預設", + "photo": "圖片", + "pageCover": "封面", + "none": "無", + "openSettings": "打開設定", + "photoPermissionTitle": "@:appName 希望存取您的圖片庫", + "photoPermissionDescription": "允許存取圖片庫以上傳圖片", + "doNotAllow": "不允許" + }, + "commandPalette": { + "bestMatches": "最佳匹配", + "recentHistory": "最近歷史", + "loadingTooltip": "我們正在尋找結果...", + "betaLabel": "BETA", + "betaTooltip": "目前我們只支援搜尋頁面" + } } diff --git a/frontend/rust-lib/.vscode/launch.json b/frontend/rust-lib/.vscode/launch.json new file mode 100644 index 0000000000..3b1e6e62a7 --- /dev/null +++ b/frontend/rust-lib/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "AF-desktop: Debug Rust", + "type": "lldb", + // "request": "attach", + // "pid": "${command:pickMyProcess}" + // To launch the application directly, use the following configuration: + "request": "launch", + "program": "/Users/lucas.xu/Desktop/appflowy_backup/frontend/appflowy_flutter/build/macos/Build/Products/Debug/AppFlowy.app", + }, + ] + } diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 046c52c854..51a3f1a3b2 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -11,7 +11,285 @@ dependencies = [ "macroific", "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", +] + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.4.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-cors" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0346d8c1f762b41b458ed3145eea914966bb9ad20b9be0d6d463b20d45586370" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + +[[package]] +name = "actix-http" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129d4c88e98860e1758c5de288d1632b07970a16d59bdf7b8d66053d582bb71f" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-tls", + "actix-utils", + "ahash 0.8.6", + "base64 0.21.5", + "bitflags 2.4.0", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "futures-core", + "h2 0.3.21", + "http 0.2.9", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.8.5", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd 0.13.2", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn 2.0.94", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.9", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6398974fd4284f4768af07965701efbbb5fdc0616bff20cade1bb14b77675e24" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio 1.0.3", + "socket2 0.5.5", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-tls" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac453898d866cdbecdbc2334fe1738c747b4eba14a677261f2b768ba05329389" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "impl-more", + "pin-project-lite", + "tokio", + "tokio-rustls 0.23.4", + "tokio-util", + "tracing", + "webpki-roots 0.22.6", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e43428f3bf11dee6d166b00ec2df4e3aa8cc1606aaa0b7433c146852e2f4e03b" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-tls", + "actix-utils", + "actix-web-codegen", + "ahash 0.8.6", + "bytes", + "bytestring", + "cfg-if", + "cookie 0.16.2", + "derive_more", + "encoding_rs", + "futures-core", + "futures-util", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.5.5", + "time", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 2.0.94", +] + +[[package]] +name = "actix-web-lab" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7675c1a84eec1b179c844cdea8488e3e409d8e4984026e92fa96c87dd86f33c6" +dependencies = [ + "actix-http", + "actix-router", + "actix-service", + "actix-utils", + "actix-web", + "actix-web-lab-derive", + "ahash 0.8.6", + "arc-swap", + "async-trait", + "bytes", + "bytestring", + "csv", + "derive_more", + "futures-core", + "futures-util", + "http 0.2.9", + "impl-more", + "itertools 0.12.1", + "local-channel", + "mediatype", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "serde", + "serde_html_form", + "serde_json", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "actix-web-lab-derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa0b287c8de4a76b691f29dbb5451e8dd5b79d777eaf87350c9b0cbfdb5e968" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.94", +] + +[[package]] +name = "actix-ws" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535aec173810be3ca6f25dd5b4d431ae7125d62000aa3cbae1ec739921b02cf3" +dependencies = [ + "actix-codec", + "actix-http", + "actix-web", + "futures-core", + "tokio", ] [[package]] @@ -41,9 +319,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", @@ -64,6 +342,58 @@ dependencies = [ "subtle", ] +[[package]] +name = "af-local-ai" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" +dependencies = [ + "af-plugin", + "anyhow", + "bytes", + "reqwest 0.11.27", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "af-mcp" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" +dependencies = [ + "anyhow", + "futures-util", + "mcp_daemon", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "af-plugin" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" +dependencies = [ + "anyhow", + "cfg-if", + "crossbeam-utils", + "log", + "once_cell", + "parking_lot 0.12.1", + "serde", + "serde_json", + "thiserror 1.0.64", + "tokio", + "tokio-stream", + "tracing", + "winreg 0.55.0", + "xattr", +] + [[package]] name = "again" version = "0.1.2" @@ -156,23 +486,23 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.79" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "bincode", "getrandom 0.2.10", - "reqwest", + "reqwest 0.12.15", "serde", "serde_json", "serde_repr", - "thiserror", + "thiserror 1.0.64", "tokio", "tsify", "url", @@ -180,6 +510,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "appflowy-ai-client" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" +dependencies = [ + "anyhow", + "bytes", + "futures", + "serde", + "serde_json", + "serde_repr", + "thiserror 1.0.64", + "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" @@ -203,10 +557,84 @@ dependencies = [ ] [[package]] -name = "async-stream" -version = "0.3.5" +name = "async-compression" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +checksum = "df895a515f70646414f4b45c0b79082783b80552b373a68283012928df56f522" +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-convert" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d416feee97712e43152cd42874de162b8f9b77295b1c85e5d92725cc8310bae" +dependencies = [ + "async-trait", +] + +[[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-openai" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc0b1877fb1bc415caa14d1899f0f477e8eb38f2fe16f54be196d7c4a92e15c" +dependencies = [ + "async-convert", + "backoff", + "base64 0.21.5", + "bytes", + "derive_builder 0.12.0", + "futures", + "rand 0.8.5", + "reqwest 0.11.27", + "reqwest-eventsource", + "secrecy", + "serde", + "serde_json", + "thiserror 1.0.64", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[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.94", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ "async-stream-impl", "futures-core", @@ -215,24 +643,40 @@ dependencies = [ [[package]] name = "async-stream-impl" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] name = "async-trait" -version = "0.1.77" +version = "0.1.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" +checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", +] + +[[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 1.0.64", + "tokio", + "tokio-util", ] [[package]] @@ -241,6 +685,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "atomic_refcell" version = "0.1.11" @@ -264,9 +714,9 @@ dependencies = [ "bitflags 1.3.2", "bytes", "futures-util", - "http", - "http-body", - "hyper", + "http 0.2.9", + "http-body 0.4.5", + "hyper 0.14.27", "itoa", "matchit", "memchr", @@ -275,8 +725,8 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", - "sync_wrapper", - "tower", + "sync_wrapper 0.1.2", + "tower 0.4.13", "tower-layer", "tower-service", ] @@ -290,14 +740,28 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", - "http-body", + "http 0.2.9", + "http-body 0.4.5", "mime", "rustversion", "tower-layer", "tower-service", ] +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "futures-core", + "getrandom 0.2.10", + "instant", + "pin-project-lite", + "rand 0.8.5", + "tokio", +] + [[package]] name = "backtrace" version = "0.3.69" @@ -325,6 +789,12 @@ 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" @@ -342,23 +812,22 @@ dependencies = [ [[package]] name = "bindgen" -version = "0.65.1" +version = "0.69.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" +checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.4.0", "cexpr", "clang-sys", + "itertools 0.12.1", "lazy_static", "lazycell", - "peeking_take_while", - "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -390,9 +859,9 @@ checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" [[package]] name = "bitpacking" -version = "0.8.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c7d2ac73c167c06af4a5f37e6e59d84148d57ccbe4480b76f0273eefea82d7" +checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92" dependencies = [ "crunchy", ] @@ -419,48 +888,52 @@ dependencies = [ ] [[package]] -name = "borsh" -version = "0.10.3" +name = "bon" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4114279215a005bc675e386011e594e1d9b800918cea18fcadadcce864a2046b" +checksum = "92c5f8abc69af414cbd6f2103bb668b91e584072f2105e4b38bed79b6ad0975f" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b69edf39b6f321cb2699a93fc20c256adb839719c42676d03f7aa975e4e5581d" +dependencies = [ + "darling 0.20.11", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.94", +] + +[[package]] +name = "borsh" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6362ed55def622cddc70a4746a68554d7b687713770de539e59a739b249f8ed" dependencies = [ "borsh-derive", - "hashbrown 0.13.2", + "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "0.10.3" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0754613691538d51f329cce9af41d7b7ca150bc973056f1156611489475f54f7" +checksum = "c3ef8005764f53cd4dca619f5bf64cafd4664dada50ece25e4d81de54c80cc0b" dependencies = [ - "borsh-derive-internal", - "borsh-schema-derive-internal", + "once_cell", "proc-macro-crate", - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "borsh-derive-internal" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afb438156919598d2c7bad7e1c0adf3d26ed3840dbc010db1a882a65583ca2fb" -dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", -] - -[[package]] -name = "borsh-schema-derive-internal" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634205cc43f74a1b9046ef87c4540ebda95696ec0f315024860cad7c5b0f5ccd" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "syn 2.0.94", + "syn_derive", ] [[package]] @@ -496,9 +969,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.13.0" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytecheck" @@ -530,13 +1003,22 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bytes" -version = "1.5.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" dependencies = [ "serde", ] +[[package]] +name = "bytestring" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e465647ae23b2823b0753f50decb2d5a86d2bb2cac04788fafd1f80e45378e5f" +dependencies = [ + "bytes", +] + [[package]] name = "bzip2" version = "0.4.4" @@ -590,10 +1072,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "chrono" -version = "0.4.33" +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f13690e35a5e4ace198e7beea2895d29f3a9cc55015fcebe6336bd2010af9eb" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[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", @@ -601,7 +1089,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.0", + "windows-targets 0.52.6", ] [[package]] @@ -611,7 +1099,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1369bc6b9e9a7dfdae2055f6ec151fe9c554a9d23d357c0237cee2e25eaabb7" dependencies = [ "chrono", - "chrono-tz-build", + "chrono-tz-build 0.2.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", ] @@ -626,6 +1125,16 @@ dependencies = [ "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" @@ -650,74 +1159,96 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "again", "anyhow", "app-error", + "arc-swap", "async-trait", - "bincode", + "base64 0.22.1", "brotli", "bytes", "chrono", + "client-api-entity", "client-websocket", "collab", - "collab-entity", "collab-rt-entity", "collab-rt-protocol", - "database-entity", + "futures", "futures-core", "futures-util", "getrandom 0.2.10", "gotrue", - "gotrue-entity", + "infra", + "lazy_static", + "md5", "mime", + "mime_guess", "parking_lot 0.12.1", - "prost", - "reqwest", + "percent-encoding", + "pin-project", + "prost 0.13.3", + "rayon", + "reqwest 0.12.15", "scraper 0.17.1", "semver", "serde", "serde_json", - "serde_repr", + "serde_urlencoded", "shared-entity", - "thiserror", + "thiserror 1.0.64", "tokio", "tokio-retry", "tokio-stream", + "tokio-tungstenite 0.20.1", "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=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" +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=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "futures-channel", "futures-util", - "http", "httparse", "js-sys", "percent-encoding", - "thiserror", + "thiserror 1.0.64", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.20.1", "wasm-bindgen", "web-sys", ] [[package]] name = "cmd_lib" -version = "1.3.0" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba0f413777386d37f85afa5242f277a7b461905254c1af3c339d4af06800f62" +checksum = "371c15a3c178d0117091bd84414545309ca979555b1aad573ef591ad58818d41" dependencies = [ "cmd_lib_macros", + "env_logger 0.10.2", "faccess", "lazy_static", "log", @@ -726,32 +1257,33 @@ dependencies = [ [[package]] name = "cmd_lib_macros" -version = "1.3.0" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e66605092ff6c6e37e0246601ae6c3f62dc1880e0599359b5f303497c112dc0" +checksum = "cb844bd05be34d91eb67101329aeba9d3337094c04fd8507d821db7ebb488eaf" dependencies = [ - "proc-macro-error", + "proc-macro-error2", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.94", ] [[package]] name = "collab" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", + "arc-swap", "async-trait", "bincode", "bytes", "chrono", "js-sys", - "parking_lot 0.12.1", + "lazy_static", "serde", "serde_json", "serde_repr", - "thiserror", + "thiserror 1.0.64", "tokio", "tokio-stream", "tracing", @@ -763,85 +1295,143 @@ dependencies = [ [[package]] name = "collab-database" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "chrono", + "chrono-tz 0.10.0", "collab", "collab-entity", - "collab-plugins", - "dashmap", + "csv", + "dashmap 5.5.3", + "fancy-regex 0.13.0", + "futures", "getrandom 0.2.10", + "iana-time-zone", "js-sys", "lazy_static", "nanoid", - "parking_lot 0.12.1", + "percent-encoding", "rayon", + "rust_decimal", + "rusty-money", "serde", "serde_json", "serde_repr", + "sha2", "strum", "strum_macros 0.25.2", - "thiserror", + "thiserror 1.0.64", "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=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", + "arc-swap", "collab", "collab-entity", "getrandom 0.2.10", + "markdown", "nanoid", - "parking_lot 0.12.1", "serde", "serde_json", - "thiserror", + "thiserror 1.0.64", "tokio", "tokio-stream", "tracing", + "uuid", ] [[package]] name = "collab-entity" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "bytes", "collab", "getrandom 0.2.10", + "prost 0.13.3", + "prost-build", + "protoc-bin-vendored", "serde", "serde_json", "serde_repr", + "thiserror 1.0.64", "uuid", + "walkdir", ] [[package]] name = "collab-folder" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", + "arc-swap", "chrono", "collab", "collab-entity", + "dashmap 5.5.3", "getrandom 0.2.10", - "parking_lot 0.12.1", "serde", "serde_json", "serde_repr", - "thiserror", + "thiserror 1.0.64", "tokio", "tokio-stream", "tracing", + "uuid", +] + +[[package]] +name = "collab-importer" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" +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 1.0.64", + "tokio", + "tokio-util", + "tracing", + "uuid", + "walkdir", + "zip 0.6.6", ] [[package]] @@ -849,23 +1439,29 @@ name = "collab-integrate" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", + "arc-swap", "collab", + "collab-database", + "collab-document", "collab-entity", + "collab-folder", "collab-plugins", - "futures", + "collab-user", + "diesel", + "flowy-error", + "flowy-sqlite", "lib-infra", - "parking_lot 0.12.1", "serde", "serde_json", "tokio", "tracing", + "uuid", ] [[package]] name = "collab-plugins" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "async-stream", @@ -881,14 +1477,13 @@ dependencies = [ "indexed_db_futures", "js-sys", "lazy_static", - "parking_lot 0.12.1", "rand 0.8.5", "rocksdb", "serde", "serde_json", "similar 2.2.1", "smallvec", - "thiserror", + "thiserror 1.0.64", "tokio", "tokio-retry", "tokio-stream", @@ -904,32 +1499,29 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "bincode", "bytes", "chrono", - "client-websocket", "collab", "collab-entity", "collab-rt-protocol", "database-entity", - "prost", + "prost 0.13.3", "prost-build", "protoc-bin-vendored", "serde", - "serde_json", "serde_repr", - "thiserror", - "tokio-tungstenite", + "tokio-tungstenite 0.20.1", "yrs", ] [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "async-trait", @@ -937,22 +1529,22 @@ dependencies = [ "collab", "collab-entity", "serde", - "thiserror", + "thiserror 1.0.64", "tokio", "tracing", + "uuid", "yrs", ] [[package]] name = "collab-user" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=870cd70#870cd70e31fa30bc6f94595ca040a91c685dfb4e" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3#f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" dependencies = [ "anyhow", "collab", "collab-entity", "getrandom 0.2.10", - "parking_lot 0.12.1", "serde", "serde_json", "tokio", @@ -960,6 +1552,15 @@ dependencies = [ "tracing", ] +[[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" @@ -982,7 +1583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd326812b3fd01da5bb1af7d340d0d555fd3d4b641e7f1dfcf5962a902952787" dependencies = [ "futures-core", - "prost", + "prost 0.12.3", "prost-types", "tonic", "tracing-core", @@ -1019,10 +1620,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" [[package]] -name = "cookie" -version = "0.17.0" +name = "constant_time_eq" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" +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.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ "percent-encoding", "time", @@ -1031,12 +1655,13 @@ dependencies = [ [[package]] name = "cookie_store" -version = "0.20.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "387461abbc748185c3a6e1673d826918b450b87ff22639429c694619a83b6cf6" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" dependencies = [ - "cookie", - "idna 0.3.0", + "cookie 0.18.1", + "document-features", + "idna 1.0.3", "log", "publicsuffix", "serde", @@ -1072,10 +1697,25 @@ dependencies = [ ] [[package]] -name = "crc32fast" -version = "1.3.2" +name = "crc" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +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", ] @@ -1116,12 +1756,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.16" +version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" -dependencies = [ - "cfg-if", -] +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "crunchy" @@ -1149,7 +1786,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -1160,14 +1797,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] name = "csv" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626ae34994d3d8d668f4269922248239db4ae42d538b14c398b74a52208e8086" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" dependencies = [ "csv-core", "itoa", @@ -1177,9 +1814,9 @@ dependencies = [ [[package]] name = "csv-core" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" dependencies = [ "memchr", ] @@ -1193,6 +1830,76 @@ dependencies = [ "cipher", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.94", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.94", +] + [[package]] name = "dart-ffi" version = "0.1.0" @@ -1203,7 +1910,6 @@ dependencies = [ "collab-integrate", "crossbeam-utils", "flowy-codegen", - "flowy-config", "flowy-core", "flowy-date", "flowy-derive", @@ -1213,11 +1919,12 @@ dependencies = [ "flowy-server", "flowy-server-pub", "flowy-user", + "futures", "lazy_static", "lib-dispatch", "lib-log", - "parking_lot 0.12.1", "protobuf", + "semver", "serde", "serde_json", "serde_repr", @@ -1239,6 +1946,20 @@ dependencies = [ "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" @@ -1248,20 +1969,21 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ - "anyhow", - "app-error", "bincode", + "bytes", "chrono", "collab-entity", + "infra", + "prost 0.13.3", "serde", "serde_json", "serde_repr", - "thiserror", + "thiserror 1.0.64", "tracing", "uuid", - "validator", + "validator 0.19.0", ] [[package]] @@ -1274,6 +1996,12 @@ dependencies = [ "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" @@ -1283,15 +2011,16 @@ dependencies = [ "macroific", "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] name = "deranged" -version = "0.3.8" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ + "powerfmt", "serde", ] @@ -1306,14 +2035,89 @@ dependencies = [ "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.94", +] + +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro 0.12.0", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro 0.20.2", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.94", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core 0.12.0", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core 0.20.2", + "syn 2.0.94", +] + [[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", ] @@ -1339,6 +2143,7 @@ dependencies = [ "diesel_derives", "libsqlite3-sys", "r2d2", + "serde_json", "time", ] @@ -1351,7 +2156,7 @@ dependencies = [ "diesel_table_macro_syntax", "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -1371,7 +2176,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5" dependencies = [ - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -1385,6 +2190,26 @@ dependencies = [ "subtle", ] +[[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.94", +] + +[[package]] +name = "document-features" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0" +dependencies = [ + "litrs", +] + [[package]] name = "dotenv" version = "0.15.0" @@ -1393,9 +2218,9 @@ checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" [[package]] name = "downcast-rs" -version = "1.2.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" +checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf" [[package]] name = "dtoa" @@ -1438,9 +2263,9 @@ checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" [[package]] name = "encoding_rs" -version = "0.8.33" +version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" dependencies = [ "cfg-if", ] @@ -1455,6 +2280,19 @@ dependencies = [ "regex", ] +[[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" @@ -1475,7 +2313,6 @@ dependencies = [ name = "event-integration-test" version = "0.1.0" dependencies = [ - "anyhow", "assert-json-diff", "bytes", "chrono", @@ -1484,14 +2321,11 @@ dependencies = [ "collab-document", "collab-entity", "collab-folder", - "collab-plugins", - "dotenv", + "flowy-ai", + "flowy-ai-pub", "flowy-core", - "flowy-database-pub", "flowy-database2", "flowy-document", - "flowy-document-pub", - "flowy-encrypt", "flowy-folder", "flowy-folder-pub", "flowy-notification", @@ -1499,27 +2333,56 @@ dependencies = [ "flowy-server", "flowy-server-pub", "flowy-storage", + "flowy-storage-pub", "flowy-user", "flowy-user-pub", "futures", - "futures-util", "lib-dispatch", "lib-infra", "nanoid", - "parking_lot 0.12.1", "protobuf", "rand 0.8.5", + "semver", "serde", "serde_json", "strum", - "tempdir", - "thread-id", "tokio", - "tokio-postgres", "tracing", "uuid", "walkdir", - "zip", + "zip 2.2.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 = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", ] [[package]] @@ -1543,12 +2406,6 @@ dependencies = [ "rand 0.8.5", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fancy-regex" version = "0.10.0" @@ -1569,6 +2426,17 @@ dependencies = [ "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" @@ -1578,7 +2446,7 @@ dependencies = [ "macroific", "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -1597,10 +2465,16 @@ dependencies = [ ] [[package]] -name = "finl_unicode" -version = "1.2.0" +name = "filetime" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.4.1", + "windows-sys 0.52.0", +] [[package]] name = "fixedbitset" @@ -1610,14 +2484,73 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.0.27" +version = "1.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6c98ee8095e9d1dcbf2fcc6d95acccb90d1c81db1e44725c6a984b1dbdfb010" +checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" dependencies = [ "crc32fast", "miniz_oxide", ] +[[package]] +name = "flowy-ai" +version = "0.1.0" +dependencies = [ + "af-local-ai", + "af-mcp", + "af-plugin", + "allo-isolate", + "anyhow", + "arc-swap", + "base64 0.21.5", + "bytes", + "collab-integrate", + "dashmap 6.0.1", + "dotenv", + "flowy-ai-pub", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-notification", + "flowy-sqlite", + "flowy-storage-pub", + "futures", + "futures-util", + "lib-dispatch", + "lib-infra", + "log", + "notify", + "pin-project", + "protobuf", + "reqwest 0.11.27", + "serde", + "serde_json", + "sha2", + "simsimd", + "strum_macros 0.21.1", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", + "validator 0.18.1", +] + +[[package]] +name = "flowy-ai-pub" +version = "0.1.0" +dependencies = [ + "client-api", + "flowy-error", + "flowy-sqlite", + "futures", + "lib-infra", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "flowy-ast" version = "0.1.0" @@ -1651,35 +2584,27 @@ dependencies = [ "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 = [ + "af-local-ai", + "af-plugin", "anyhow", + "arc-swap", "base64 0.21.5", "bytes", "client-api", "collab", "collab-entity", + "collab-folder", "collab-integrate", "collab-plugins", "console-subscriber", + "dashmap 6.0.1", "diesel", - "flowy-config", + "flowy-ai", + "flowy-ai-pub", "flowy-database-pub", "flowy-database2", "flowy-date", @@ -1689,18 +2614,18 @@ dependencies = [ "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", - "parking_lot 0.12.1", "semver", "serde", "serde_json", @@ -1709,18 +2634,20 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "url", "uuid", - "walkdir", ] [[package]] name = "flowy-database-pub" version = "0.1.0" dependencies = [ - "anyhow", + "client-api", "collab", "collab-entity", + "flowy-error", "lib-infra", + "uuid", ] [[package]] @@ -1728,22 +2655,24 @@ name = "flowy-database2" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "async-stream", "async-trait", "bytes", "chrono", - "chrono-tz", + "chrono-tz 0.8.3", "collab", "collab-database", "collab-entity", "collab-integrate", "collab-plugins", "csv", - "dashmap", + "dashmap 6.0.1", "event-integration-test", "fancy-regex 0.11.0", "flowy-codegen", "flowy-database-pub", + "flowy-database2", "flowy-derive", "flowy-error", "flowy-notification", @@ -1752,8 +2681,8 @@ dependencies = [ "lazy_static", "lib-dispatch", "lib-infra", + "moka", "nanoid", - "parking_lot 0.12.1", "protobuf", "rayon", "rust_decimal", @@ -1764,9 +2693,11 @@ dependencies = [ "strum", "strum_macros 0.25.2", "tokio", + "tokio-util", "tracing", "url", - "validator", + "uuid", + "validator 0.18.1", ] [[package]] @@ -1790,7 +2721,7 @@ dependencies = [ name = "flowy-derive" version = "0.1.0" dependencies = [ - "dashmap", + "dashmap 6.0.1", "flowy-ast", "flowy-codegen", "lazy_static", @@ -1812,20 +2743,19 @@ dependencies = [ "collab-entity", "collab-integrate", "collab-plugins", - "dashmap", + "dashmap 6.0.1", "flowy-codegen", "flowy-derive", "flowy-document-pub", "flowy-error", "flowy-notification", - "flowy-storage", + "flowy-storage-pub", "futures", "getrandom 0.2.10", "indexmap 2.1.0", "lib-dispatch", "lib-infra", "nanoid", - "parking_lot 0.12.1", "protobuf", "scraper 0.18.1", "serde", @@ -1837,32 +2767,18 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", - "validator", + "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", + "uuid", ] [[package]] @@ -1872,6 +2788,7 @@ dependencies = [ "anyhow", "bytes", "client-api", + "collab", "collab-database", "collab-document", "collab-folder", @@ -1883,42 +2800,50 @@ dependencies = [ "lib-dispatch", "protobuf", "r2d2", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "serde_repr", "tantivy", - "thiserror", + "thiserror 1.0.64", "tokio", "url", - "validator", + "uuid", + "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", + "flowy-user-pub", + "futures", "lazy_static", "lib-dispatch", "lib-infra", "nanoid", - "parking_lot 0.12.1", "protobuf", + "regex", + "serde", "serde_json", "strum_macros 0.21.1", "tokio", @@ -1926,7 +2851,7 @@ dependencies = [ "tracing", "unicode-segmentation", "uuid", - "validator", + "validator 0.18.1", ] [[package]] @@ -1934,10 +2859,14 @@ name = "flowy-folder-pub" version = "0.1.0" dependencies = [ "anyhow", + "client-api", "collab", "collab-entity", "collab-folder", + "flowy-error", "lib-infra", + "serde", + "serde_json", "tokio", "uuid", ] @@ -1947,7 +2876,7 @@ name = "flowy-notification" version = "0.1.0" dependencies = [ "bytes", - "dashmap", + "dashmap 6.0.1", "flowy-codegen", "flowy-derive", "lazy_static", @@ -1963,41 +2892,43 @@ dependencies = [ name = "flowy-search" version = "0.1.0" dependencies = [ + "allo-isolate", "async-stream", "bytes", "collab", "collab-folder", - "diesel", - "diesel_derives", - "diesel_migrations", + "derive_builder 0.20.2", "flowy-codegen", "flowy-derive", "flowy-error", - "flowy-notification", + "flowy-folder", "flowy-search-pub", - "flowy-sqlite", "flowy-user", "futures", "lib-dispatch", + "lib-infra", "protobuf", "serde", "serde_json", - "strsim", + "strsim 0.11.1", "strum_macros 0.26.1", "tantivy", - "tempfile", "tokio", + "tokio-stream", "tracing", - "validator", + "uuid", ] [[package]] name = "flowy-search-pub" version = "0.1.0" dependencies = [ + "client-api", "collab", "collab-folder", "flowy-error", + "lib-infra", + "uuid", ] [[package]] @@ -2005,48 +2936,46 @@ name = "flowy-server" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "assert-json-diff", "bytes", "chrono", "client-api", "collab", + "collab-database", "collab-document", "collab-entity", "collab-folder", "collab-plugins", + "collab-user", "dotenv", + "flowy-ai", + "flowy-ai-pub", "flowy-database-pub", "flowy-document-pub", - "flowy-encrypt", "flowy-error", "flowy-folder-pub", + "flowy-search-pub", "flowy-server-pub", + "flowy-sqlite", "flowy-storage", + "flowy-storage-pub", "flowy-user-pub", "futures", "futures-util", - "hex", - "hyper", "lazy_static", - "lib-dispatch", "lib-infra", - "mime_guess", - "parking_lot 0.12.1", - "postgrest", "rand 0.8.5", - "reqwest", + "semver", "serde", "serde_json", - "thiserror", + "thiserror 1.0.64", "tokio", - "tokio-retry", "tokio-stream", "tokio-util", "tracing", "tracing-subscriber", - "url", "uuid", - "yrs", ] [[package]] @@ -2069,13 +2998,12 @@ dependencies = [ "libsqlite3-sys", "openssl", "openssl-sys", - "parking_lot 0.12.1", "r2d2", "scheduled-thread-pool", "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 1.0.64", "tracing", ] @@ -2083,19 +3011,47 @@ dependencies = [ name = "flowy-storage" version = "0.1.0" dependencies = [ + "allo-isolate", "async-trait", "bytes", + "chrono", + "collab-importer", + "dashmap 6.0.1", + "flowy-codegen", + "flowy-derive", "flowy-error", - "fxhash", + "flowy-notification", + "flowy-sqlite", + "flowy-storage-pub", + "lib-dispatch", "lib-infra", - "mime", "mime_guess", - "reqwest", + "protobuf", + "rand 0.8.5", "serde", "serde_json", + "strum_macros 0.25.2", "tokio", "tracing", "url", + "uuid", +] + +[[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", + "tokio", + "uuid", ] [[package]] @@ -2103,9 +3059,11 @@ name = "flowy-user" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "base64 0.21.5", "bytes", "chrono", + "client-api", "collab", "collab-database", "collab-document", @@ -2114,13 +3072,12 @@ dependencies = [ "collab-integrate", "collab-plugins", "collab-user", + "dashmap 6.0.1", "diesel", - "diesel_derives", "fake", "fancy-regex 0.11.0", "flowy-codegen", "flowy-derive", - "flowy-encrypt", "flowy-error", "flowy-folder-pub", "flowy-notification", @@ -2130,18 +3087,15 @@ dependencies = [ "lazy_static", "lib-dispatch", "lib-infra", - "nanoid", - "once_cell", - "parking_lot 0.12.1", "protobuf", "quickcheck", "quickcheck_macros", "rand 0.8.5", "rand_core 0.6.4", + "rayon", "semver", "serde", "serde_json", - "serde_repr", "strum", "strum_macros 0.25.2", "tokio", @@ -2149,20 +3103,22 @@ dependencies = [ "tracing", "unicode-segmentation", "uuid", - "validator", + "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", + "flowy-sqlite", "lib-infra", "serde", "serde_json", @@ -2205,19 +3161,22 @@ dependencies = [ [[package]] name = "fs4" -version = "0.6.6" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eeb4ed9e12f43b7fa0baae3f9cdda28352770132ef2e09a23760c29cae8bd47" +checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" dependencies = [ "rustix", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] -name = "fuchsia-cprng" -version = "0.1.1" +name = "fsevent-sys" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] [[package]] name = "funty" @@ -2237,9 +3196,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -2252,9 +3211,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -2262,15 +3221,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -2279,38 +3238,57 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -2333,19 +3311,6 @@ dependencies = [ "byteorder", ] -[[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" @@ -2461,28 +3426,24 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", - "futures-util", "getrandom 0.2.10", "gotrue-entity", "infra", - "reqwest", + "reqwest 0.12.15", "serde", "serde_json", - "tokio", "tracing", ] [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ - "anyhow", "app-error", - "chrono", "jsonwebtoken", "lazy_static", "serde", @@ -2500,7 +3461,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.9", "indexmap 1.9.3", "slab", "tokio", @@ -2508,6 +3469,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.2.0", + "indexmap 2.1.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2517,15 +3497,6 @@ dependencies = [ "ahash 0.7.6", ] -[[package]] -name = "hashbrown" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" -dependencies = [ - "ahash 0.8.6", -] - [[package]] name = "hashbrown" version = "0.14.3" @@ -2566,9 +3537,9 @@ checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "hermit-abi" -version = "0.3.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" +checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" [[package]] name = "hex" @@ -2625,6 +3596,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-body" version = "0.4.5" @@ -2632,7 +3614,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" dependencies = [ "bytes", - "http", + "http 0.2.9", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.2.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2673,9 +3678,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.3.21", + "http 0.2.9", + "http-body 0.4.5", "httparse", "httpdate", "itoa", @@ -2687,6 +3692,26 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2 0.4.7", + "http 1.2.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + [[package]] name = "hyper-rustls" version = "0.24.1" @@ -2694,11 +3719,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d78e1e73ec14cf7375674f74d7dde185c8206fd9dea6fb6295e8a98098aaa97" dependencies = [ "futures-util", - "http", - "hyper", - "rustls", + "http 0.2.9", + "hyper 0.14.27", + "rustls 0.21.7", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +dependencies = [ + "futures-util", + "http 1.2.0", + "hyper 1.5.2", + "hyper-util", + "rustls 0.23.20", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.1", + "tower-service", + "webpki-roots 0.26.7", ] [[package]] @@ -2707,7 +3750,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" dependencies = [ - "hyper", + "hyper 0.14.27", "pin-project-lite", "tokio", "tokio-io-timeout", @@ -2720,24 +3763,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.27", "native-tls", "tokio", "tokio-native-tls", ] [[package]] -name = "iana-time-zone" -version = "0.1.57" +name = "hyper-tls" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.5.2", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "hyper 1.5.2", + "pin-project-lite", + "socket2 0.5.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperloglogplus" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3" +dependencies = [ + "serde", +] + +[[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 0.48.0", + "windows-core", ] [[package]] @@ -2749,6 +3836,130 @@ dependencies = [ "cc", ] +[[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.94", +] + +[[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" @@ -2759,16 +3970,6 @@ dependencies = [ "unicode-normalization", ] -[[package]] -name = "idna" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - [[package]] name = "idna" version = "0.5.0" @@ -2780,10 +3981,25 @@ dependencies = [ ] [[package]] -name = "if_chain" -version = "1.0.2" +name = "idna" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" +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" @@ -2803,10 +4019,16 @@ dependencies = [ ] [[package]] -name = "indexed_db_futures" -version = "0.4.1" +name = "impl-more" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cc2083760572ee02385ab8b7c02c20925d2dd1f97a1a25a8737a238608f1152" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[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", @@ -2843,13 +4065,38 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", - "reqwest", + "bytes", + "futures", + "pin-project", + "reqwest 0.12.15", "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]] @@ -2868,9 +4115,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" dependencies = [ "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", ] [[package]] @@ -2879,6 +4123,17 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "itertools" version = "0.10.5" @@ -2890,9 +4145,18 @@ dependencies = [ [[package]] name = "itertools" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -2914,10 +4178,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.67" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a1d36f1235bc969acba30b7f5990b864423a6068a10f7c90ae8f0112e3a59d1" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -2929,12 +4194,38 @@ checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" dependencies = [ "base64 0.21.5", "pem", - "ring", + "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 = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.4.0" @@ -2967,7 +4258,6 @@ dependencies = [ "futures-util", "getrandom 0.2.10", "nanoid", - "parking_lot 0.12.1", "pin-project", "protobuf", "serde", @@ -2976,7 +4266,7 @@ dependencies = [ "thread-id", "tokio", "tracing", - "validator", + "validator 0.18.1", "wasm-bindgen", "wasm-bindgen-futures", ] @@ -2985,23 +4275,31 @@ dependencies = [ name = "lib-infra" version = "0.1.0" dependencies = [ + "aes-gcm", + "allo-isolate", "anyhow", "async-trait", "atomic_refcell", + "base64 0.22.1", "brotli", "bytes", + "cfg-if", "chrono", "futures", "futures-core", + "futures-util", + "hmac", "md5", + "pbkdf2 0.12.2", "pin-project", "rand 0.8.5", + "sha2", "tempfile", "tokio", "tracing", - "validator", + "validator 0.18.1", "walkdir", - "zip", + "zip 2.2.0", ] [[package]] @@ -3022,9 +4320,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.152" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "libloading" @@ -3044,8 +4342,8 @@ checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" [[package]] name = "librocksdb-sys" -version = "0.11.0+8.1.1" -source = "git+https://github.com/LucasXu0/rust-rocksdb?rev=21cf4a23ec131b9d82dc94e178fe8efc0c147b09#21cf4a23ec131b9d82dc94e178fe8efc0c147b09" +version = "0.17.0+9.0.0" +source = "git+https://github.com/rust-rocksdb/rust-rocksdb?rev=1710120e4549e04ba3baa6a1ee5a5a801fa45a72#1710120e4549e04ba3baa6a1ee5a5a801fa45a72" dependencies = [ "bindgen", "bzip2-sys", @@ -3084,6 +4382,35 @@ 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 = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + [[package]] name = "lock_api" version = "0.4.10" @@ -3095,30 +4422,22 @@ dependencies = [ ] [[package]] -name = "log" -version = "0.4.20" +name = "lockfree-object-pool" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" [[package]] -name = "loom" -version = "0.5.6" +name = "log" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" -dependencies = [ - "cfg-if", - "generator", - "pin-utils", - "scoped-tls", - "tracing", - "tracing-subscriber", -] +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" [[package]] name = "lru" -version = "0.11.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a83fb7698b3643a0e34f9ae6f2e8f0178c0fd42f8b59d493aa271ff3a5bf21" +checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" dependencies = [ "hashbrown 0.14.3", ] @@ -3129,6 +4448,27 @@ 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" @@ -3155,7 +4495,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -3166,7 +4506,7 @@ checksum = "13198c120864097a565ccb3ff947672d969932b7975ebd4085732c9f09435e55" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -3179,7 +4519,16 @@ dependencies = [ "macroific_core", "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", +] + +[[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]] @@ -3212,12 +4561,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed1202b2a6f884ae56f04cff409ab315c5ce26b5e58d7412e484f01fd52f52ef" [[package]] -name = "md-5" -version = "0.10.5" +name = "mcp_daemon" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" +checksum = "ed0bdbb83765c69f4bf506d318119a25776dbad54906de9c17c1eae566088100" dependencies = [ - "digest", + "actix-cors", + "actix-web", + "actix-web-lab", + "actix-ws", + "anyhow", + "async-openai", + "async-trait", + "bytes", + "bytestring", + "futures", + "futures-core", + "futures-util", + "jsonwebtoken", + "pin-project-lite", + "reqwest 0.12.15", + "rustls 0.20.9", + "rustls-pemfile 1.0.3", + "serde", + "serde_json", + "thiserror 1.0.64", + "tokio", + "tokio-stream", + "tokio-tungstenite 0.21.0", + "tracing", + "url", + "uuid", ] [[package]] @@ -3228,25 +4602,30 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "measure_time" -version = "0.8.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56220900f1a0923789ecd6bf25fbae8af3b2f1ff3e9e297fc9b6b8674dd4d852" +checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" dependencies = [ - "instant", "log", ] [[package]] -name = "memchr" -version = "2.6.3" +name = "mediatype" +version = "0.19.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" +checksum = "33746aadcb41349ec291e7f2f0a3aa6834d1d7c58066fb4b01f68efc4c4b7631" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "memmap2" -version = "0.7.1" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49388d20533534cd19360ad3d6a7dadc885944aa802ba3995040c5ec11288c6" +checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322" dependencies = [ "libc", ] @@ -3289,9 +4668,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mime_guess" -version = "2.0.4" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ "mime", "unicase", @@ -3319,10 +4698,47 @@ 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 = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.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 1.0.64", + "triomphe", + "uuid", +] + [[package]] name = "multimap" version = "0.8.3" @@ -3378,6 +4794,25 @@ dependencies = [ "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 0.8.9", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "ntapi" version = "0.4.1" @@ -3408,6 +4843,12 @@ dependencies = [ "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" @@ -3425,16 +4866,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" dependencies = [ "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", + "libm", ] [[package]] @@ -3448,18 +4880,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "oneshot" -version = "0.1.6" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f6640c6bda7731b1fdbab747981a0f896dd1fedaf9f4a53fa237a04a84431f4" -dependencies = [ - "loom", -] +checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea" [[package]] name = "opaque-debug" @@ -3490,7 +4919,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -3523,12 +4952,12 @@ dependencies = [ [[package]] name = "os_pipe" -version = "0.9.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb233f06c2307e1f5ce2ecad9f8121cffbbee2c95428f44ea85222e460d0d213" +checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982" dependencies = [ "libc", - "winapi", + "windows-sys 0.59.0", ] [[package]] @@ -3539,13 +4968,19 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "ownedbytes" -version = "0.6.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e8a72b918ae8198abb3a18c190288123e1d442b6b9a7d709305fd194688b4b7" +checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" dependencies = [ "stable_deref_trait", ] +[[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" @@ -3636,12 +5071,6 @@ dependencies = [ "hmac", ] -[[package]] -name = "peeking_take_while" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" - [[package]] name = "pem" version = "1.1.1" @@ -3664,7 +5093,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a4d085fd991ac8d5b05a147b437791b4260b76326baf0fc60cf7c9c27ecd33" dependencies = [ "memchr", - "thiserror", + "thiserror 1.0.64", "ucd-trie", ] @@ -3688,7 +5117,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -3718,7 +5147,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", + "phf_macros", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -3738,7 +5167,6 @@ 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", ] @@ -3806,19 +5234,6 @@ dependencies = [ "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" @@ -3848,22 +5263,22 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.3" +version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" +checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.3" +version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" +checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -3897,42 +5312,10 @@ dependencies = [ ] [[package]] -name = "postgres-protocol" -version = "0.6.6" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b6c5ef183cd3ab4ba005f1ca64c21e8bd97ce4699cfea9e8d9a2c4958ca520" -dependencies = [ - "base64 0.21.5", - "byteorder", - "bytes", - "fallible-iterator", - "hmac", - "md-5", - "memchr", - "rand 0.8.5", - "sha2", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d2234cdee9408b523530a9b6d2d6b373d1db34f6a8e51dc03ded1828d7fb67c" -dependencies = [ - "bytes", - "fallible-iterator", - "postgres-protocol", -] - -[[package]] -name = "postgrest" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a966c650b47a064e7082170b4be74fca08c088d893244fc4b70123e3c1f3ee7" -dependencies = [ - "reqwest", -] +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" @@ -3953,16 +5336,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8832c0f9be7e3cae60727e6256cfd2cd3c3e2b6cd5dad4190ecb2fd658c9030b" dependencies = [ "proc-macro2", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] name = "proc-macro-crate" -version = "0.1.5" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" +checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284" dependencies = [ - "toml 0.5.11", + "toml_edit 0.21.1", ] [[package]] @@ -3989,6 +5372,28 @@ dependencies = [ "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.94", +] + [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" @@ -3997,9 +5402,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.75" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "907a61bd0f64c2f29cd1cf1dc34d05176426a3f504a78010f08416ddb7b13708" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -4011,7 +5416,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c289cda302b98a28d40c8b3b90498d6e526dd24ac2ecea73e4e491685b94a" dependencies = [ "bytes", - "prost-derive", + "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]] @@ -4022,16 +5437,16 @@ checksum = "c55e02e35260070b6f716a2423c2ff1c3bb1642ddca6f99e1f26d06268a0e2d2" dependencies = [ "bytes", "heck 0.4.1", - "itertools 0.11.0", + "itertools 0.10.5", "log", "multimap", "once_cell", "petgraph", "prettyplease", - "prost", + "prost 0.12.3", "prost-types", "regex", - "syn 2.0.47", + "syn 2.0.94", "tempfile", "which", ] @@ -4043,10 +5458,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.10.5", "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", +] + +[[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.94", ] [[package]] @@ -4055,7 +5483,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" dependencies = [ - "prost", + "prost 0.12.3", ] [[package]] @@ -4085,53 +5513,60 @@ dependencies = [ [[package]] name = "protoc-bin-vendored" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ca8623e5633e298ad1f917d8be0a44bcf406bf3cde3b80e63003e49a3f27d" +checksum = "dd89a830d0eab2502c81a9b8226d446a52998bb78e5e33cb2637c0cdd6068d99" 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-aarch_64", "protoc-bin-vendored-macos-x86_64", "protoc-bin-vendored-win32", ] [[package]] name = "protoc-bin-vendored-linux-aarch_64" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fb9fc9cce84c8694b6ea01cc6296617b288b703719b725b8c9c65f7c5874435" +checksum = "f563627339f1653ea1453dfbcb4398a7369b768925eb14499457aeaa45afe22c" [[package]] name = "protoc-bin-vendored-linux-ppcle_64" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d2a07dcf7173a04d49974930ccbfb7fd4d74df30ecfc8762cf2f895a094516" +checksum = "5025c949a02cd3b60c02501dd0f348c16e8fff464f2a7f27db8a9732c608b746" [[package]] name = "protoc-bin-vendored-linux-x86_32" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54fef0b04fcacba64d1d80eed74a20356d96847da8497a59b0a0a436c9165b0" +checksum = "9c9500ce67d132c2f3b572504088712db715755eb9adf69d55641caa2cb68a07" [[package]] name = "protoc-bin-vendored-linux-x86_64" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8782f2ce7d43a9a5c74ea4936f001e9e8442205c244f7a3d4286bd4c37bc924" +checksum = "5462592380cefdc9f1f14635bcce70ba9c91c1c2464c7feb2ce564726614cc41" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c637745681b68b4435484543667a37606c95ddacf15e917710801a0877506030" [[package]] name = "protoc-bin-vendored-macos-x86_64" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5de656c7ee83f08e0ae5b81792ccfdc1d04e7876b1d9a38e6876a9e09e02537" +checksum = "38943f3c90319d522f94a6dfd4a134ba5e36148b9506d2d9723a82ebc57c8b55" [[package]] name = "protoc-bin-vendored-win32" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9653c3ed92974e34c5a6e0a510864dab979760481714c172e0a34e437cb98804" +checksum = "7dc55d7dec32ecaf61e0bd90b3d2392d721a28b95cfd23c3e176eccefbeab2f2" [[package]] name = "protoc-rust" @@ -4181,13 +5616,28 @@ dependencies = [ "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 = "quickcheck" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" dependencies = [ - "env_logger", + "env_logger 0.8.4", "log", "rand 0.8.5", ] @@ -4203,6 +5653,58 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quinn" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.0", + "rustls 0.23.20", + "socket2 0.5.5", + "thiserror 2.0.9", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" +dependencies = [ + "bytes", + "getrandom 0.2.10", + "rand 0.8.5", + "ring 0.17.8", + "rustc-hash 2.1.0", + "rustls 0.23.20", + "rustls-pki-types", + "slab", + "thiserror 2.0.9", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.5", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.35" @@ -4229,19 +5731,6 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" -[[package]] -name = "rand" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" -dependencies = [ - "fuchsia-cprng", - "libc", - "rand_core 0.3.1", - "rdrand", - "winapi", -] - [[package]] name = "rand" version = "0.7.3" @@ -4287,21 +5776,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" -dependencies = [ - "rand_core 0.4.2", -] - -[[package]] -name = "rand_core" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" - [[package]] name = "rand_core" version = "0.5.1" @@ -4320,6 +5794,16 @@ 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" @@ -4339,10 +5823,19 @@ dependencies = [ ] [[package]] -name = "rayon" -version = "1.9.0" +name = "raw-cpuid" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4963ed1bc86e4f3ee217022bd855b297cef07fb9eac5dfa1f788b220b49b3bd" +checksum = "cb9ee317cfe3fbd54b36a511efc1edd42e216903c9cd575e686dd68a2ba90d8d" +dependencies = [ + "bitflags 2.4.0", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" dependencies = [ "either", "rayon-core", @@ -4358,15 +5851,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rdrand" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" -dependencies = [ - "rand_core 0.3.1", -] - [[package]] name = "redox_syscall" version = "0.1.57" @@ -4391,6 +5875,15 @@ 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 = "regex" version = "1.9.5" @@ -4423,6 +5916,23 @@ dependencies = [ "regex-syntax 0.7.5", ] +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + [[package]] name = "regex-syntax" version = "0.6.29" @@ -4436,13 +5946,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] -name = "remove_dir_all" -version = "0.5.3" +name = "regex-syntax" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] name = "rend" @@ -4461,17 +5968,15 @@ 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", + "h2 0.3.21", + "http 0.2.9", + "http-body 0.4.5", + "hyper 0.14.27", + "hyper-rustls 0.24.1", + "hyper-tls 0.5.0", "ipnet", "js-sys", "log", @@ -4481,16 +5986,17 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", - "rustls-pemfile", + "rustls 0.21.7", + "rustls-native-certs", + "rustls-pemfile 1.0.3", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", - "system-configuration", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.24.1", "tokio-util", "tower-service", "url", @@ -4498,8 +6004,77 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", - "winreg", + "winreg 0.50.0", +] + +[[package]] +name = "reqwest" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie 0.18.1", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.7", + "http 1.2.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.5.2", + "hyper-rustls 0.27.5", + "hyper-tls 0.6.0", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.20", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "system-configuration 0.6.1", + "tokio", + "tokio-native-tls", + "tokio-rustls 0.26.1", + "tokio-util", + "tower 0.5.2", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 0.26.7", + "windows-registry", +] + +[[package]] +name = "reqwest-eventsource" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f03f570355882dd8d15acc3a313841e6e90eddbc76a93c748fd82cc13ba9f51" +dependencies = [ + "eventsource-stream", + "futures-core", + "futures-timer", + "mime", + "nom", + "pin-project-lite", + "reqwest 0.11.27", + "thiserror 1.0.64", ] [[package]] @@ -4511,12 +6086,27 @@ dependencies = [ "cc", "libc", "once_cell", - "spin", - "untrusted", + "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.10", + "libc", + "spin 0.9.8", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.7.42" @@ -4547,8 +6137,8 @@ dependencies = [ [[package]] name = "rocksdb" -version = "0.21.0" -source = "git+https://github.com/LucasXu0/rust-rocksdb?rev=21cf4a23ec131b9d82dc94e178fe8efc0c147b09#21cf4a23ec131b9d82dc94e178fe8efc0c147b09" +version = "0.22.0" +source = "git+https://github.com/rust-rocksdb/rust-rocksdb?rev=1710120e4549e04ba3baa6a1ee5a5a801fa45a72#1710120e4549e04ba3baa6a1ee5a5a801fa45a72" dependencies = [ "libc", "librocksdb-sys", @@ -4566,9 +6156,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.32.0" +version = "1.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4c4216490d5a413bc6d10fa4742bd7d4955941d062c0ef873141d6b0e7b30fd" +checksum = "b082d80e3e3cc52b2ed634388d436fe1f4de6af5786cc2de9ba9737527bdf555" dependencies = [ "arrayvec", "borsh", @@ -4602,6 +6192,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.31" @@ -4615,6 +6220,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct", + "webpki", +] + [[package]] name = "rustls" version = "0.21.7" @@ -4622,11 +6239,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" dependencies = [ "log", - "ring", - "rustls-webpki", + "ring 0.16.20", + "rustls-webpki 0.101.4", "sct", ] +[[package]] +name = "rustls" +version = "0.23.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" +dependencies = [ + "once_cell", + "ring 0.17.8", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile 1.0.3", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "1.0.3" @@ -4636,14 +6279,43 @@ dependencies = [ "base64 0.21.5", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2bf47e6ff922db3825eb750c4e2ff784c6ff8fb9e13046ef6a1d1c5401b0b37" +dependencies = [ + "web-time", +] + [[package]] name = "rustls-webpki" version = "0.101.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d93931baf2d282fff8d3a532bbfd7653f734643161b87e3e01e59a04439bf0d" dependencies = [ - "ring", - "untrusted", + "ring 0.16.20", + "untrusted 0.7.1", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring 0.17.8", + "rustls-pki-types", + "untrusted 0.9.0", ] [[package]] @@ -4677,6 +6349,16 @@ 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.22" @@ -4695,12 +6377,6 @@ 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" @@ -4746,8 +6422,8 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" dependencies = [ - "ring", - "untrusted", + "ring 0.16.20", + "untrusted 0.7.1", ] [[package]] @@ -4756,6 +6432,16 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "security-framework" version = "2.9.2" @@ -4800,28 +6486,31 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] [[package]] name = "serde" -version = "1.0.195" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.195" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -4832,16 +6521,30 @@ checksum = "e578a843d40b4189a4d66bba51d7684f57da5bd7c304c64e14bd63efbef49509" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", +] + +[[package]] +name = "serde_html_form" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" +dependencies = [ + "form_urlencoded", + "indexmap 2.1.0", + "itoa", + "ryu", + "serde", ] [[package]] name = "serde_json" -version = "1.0.111" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] @@ -4854,7 +6557,7 @@ checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -4902,9 +6605,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", @@ -4919,9 +6622,9 @@ checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" [[package]] name = "sha2" -version = "0.10.7" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", @@ -4940,20 +6643,26 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ef8e6f3#ef8e6f360f9c1d4daf2283e8475894d2ca5ef2fc" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=6c6f1c5cc3ce2161c247f63f6132361da8dc0a32#6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" dependencies = [ "anyhow", "app-error", + "appflowy-ai-client", + "bytes", "chrono", "collab-entity", "database-entity", + "futures", "gotrue-entity", - "reqwest", + "infra", + "pin-project", + "reqwest 0.12.15", "serde", "serde_json", "serde_repr", - "thiserror", + "thiserror 1.0.64", "uuid", + "validator 0.19.0", ] [[package]] @@ -4971,6 +6680,12 @@ 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" @@ -4997,10 +6712,19 @@ checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 1.0.64", "time", ] +[[package]] +name = "simsimd" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efc843bc8f12d9c8e6b734a0fe8918fc497b42f6ae0f347dbfdad5b5138ab9b4" +dependencies = [ + "cc", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -5009,9 +6733,9 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "sketches-ddsketch" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" +checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a" dependencies = [ "serde", ] @@ -5075,6 +6799,12 @@ 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" @@ -5108,21 +6838,16 @@ dependencies = [ ] [[package]] -name = "stringprep" -version = "0.1.4" +name = "strsim" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" -dependencies = [ - "finl_unicode", - "unicode-bidi", - "unicode-normalization", -] +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "strsim" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" @@ -5152,7 +6877,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -5165,7 +6890,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -5187,21 +6912,53 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.47" +version = "2.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1726efe18f42ae774cc644f330953a5e7b3c3003d3edcecf18850fe9d4dd9afb" +checksum = "987bc0be1cdea8b10216bd06e2ca407d40b9543468fafd3ddfb02f36e77f71f3" 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.94", +] + [[package]] name = "sync_wrapper" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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.94", +] + [[package]] name = "sysinfo" version = "0.30.5" @@ -5214,7 +6971,7 @@ dependencies = [ "ntapi", "once_cell", "rayon", - "windows 0.52.0", + "windows", ] [[package]] @@ -5225,7 +6982,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.4.0", + "core-foundation", + "system-configuration-sys 0.6.0", ] [[package]] @@ -5239,39 +7007,55 @@ dependencies = [ ] [[package]] -name = "tantivy" -version = "0.21.1" +name = "system-configuration-sys" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6083cd777fa94271b8ce0fe4533772cb8110c3044bab048d20f70108329a1f2" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tantivy" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b21ad8b222d71c57aa979353ed702f0bc6d97e66d368962cbded57fbd19eedd7" dependencies = [ "aho-corasick", "arc-swap", - "async-trait", - "base64 0.21.5", + "base64 0.22.1", "bitpacking", + "bon", "byteorder", "census", "crc32fast", "crossbeam-channel", "downcast-rs", "fastdivide", + "fnv", "fs4", "htmlescape", - "itertools 0.11.0", + "hyperloglogplus", + "itertools 0.14.0", "levenshtein_automata", "log", "lru", "lz4_flex", "measure_time", "memmap2", - "murmurhash32", - "num_cpus", "once_cell", "oneshot", "rayon", "regex", "rust-stemmers", - "rustc-hash", + "rustc-hash 2.1.0", "serde", "serde_json", "sketches-ddsketch", @@ -5284,7 +7068,7 @@ dependencies = [ "tantivy-stacker", "tantivy-tokenizer-api", "tempfile", - "thiserror", + "thiserror 2.0.9", "time", "uuid", "winapi", @@ -5292,22 +7076,22 @@ dependencies = [ [[package]] name = "tantivy-bitpacker" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecb164321482301f514dd582264fa67f70da2d7eb01872ccd71e35e0d96655a" +checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494" dependencies = [ "bitpacking", ] [[package]] name = "tantivy-columnar" -version = "0.2.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d85f8019af9a78b3118c11298b36ffd21c2314bd76bbcd9d12e00124cbb7e70" +checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344" dependencies = [ + "downcast-rs", "fastdivide", - "fnv", - "itertools 0.11.0", + "itertools 0.14.0", "serde", "tantivy-bitpacker", "tantivy-common", @@ -5317,9 +7101,9 @@ dependencies = [ [[package]] name = "tantivy-common" -version = "0.6.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4a3a975e604a2aba6b1106a04505e1e7a025e6def477fab6e410b4126471e1" +checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f" dependencies = [ "async-trait", "byteorder", @@ -5330,50 +7114,56 @@ dependencies = [ [[package]] name = "tantivy-fst" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3c506b1a8443a3a65352df6382a1fb6a7afe1a02e871cee0d25e2c3d5f3944" +checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" dependencies = [ "byteorder", - "regex-syntax 0.6.29", + "regex-syntax 0.8.4", "utf8-ranges", ] [[package]] name = "tantivy-query-grammar" -version = "0.21.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d39c5a03100ac10c96e0c8b07538e2ab8b17da56434ab348309b31f23fada77" +checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" dependencies = [ "nom", + "serde", + "serde_json", ] [[package]] name = "tantivy-sstable" -version = "0.2.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0c1bb43e5e8b8e05eb8009610344dbf285f06066c844032fbb3e546b3c71df" +checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" dependencies = [ + "futures-util", + "itertools 0.14.0", + "tantivy-bitpacker", "tantivy-common", "tantivy-fst", - "zstd 0.12.4", + "zstd 0.13.2", ] [[package]] name = "tantivy-stacker" -version = "0.2.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2c078595413f13f218cf6f97b23dcfd48936838f1d3d13a1016e05acd64ed6c" +checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" dependencies = [ "murmurhash32", + "rand_distr", "tantivy-common", ] [[package]] name = "tantivy-tokenizer-api" -version = "0.2.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "347b6fb212b26d3505d224f438e3c4b827ab8bd847fe9953ad5ac6b8f9443b66" +checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d" dependencies = [ "serde", ] @@ -5384,26 +7174,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "tempdir" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" -dependencies = [ - "rand 0.4.6", - "remove_dir_all", -] - [[package]] name = "tempfile" -version = "3.10.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a365e8cd18e44762ef95d87f284f4b5cd04107fec2ff3052bd6a3e6069669e67" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" dependencies = [ "cfg-if", "fastrand", + "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5424,7 +7205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8" dependencies = [ "chrono", - "chrono-tz", + "chrono-tz 0.8.3", "globwalk", "humansize", "lazy_static", @@ -5439,6 +7220,15 @@ dependencies = [ "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" @@ -5451,22 +7241,42 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.56" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.64", +] + +[[package]] +name = "thiserror" +version = "2.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" +dependencies = [ + "thiserror-impl 2.0.9", ] [[package]] name = "thiserror-impl" -version = "1.0.56" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.94", ] [[package]] @@ -5492,12 +7302,14 @@ dependencies = [ [[package]] name = "time" -version = "0.3.28" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f6bb557fd245c28e6411aa56b6403c689ad95061f50e4be16c274e70a17e48" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", + "num-conv", + "powerfmt", "serde", "time-core", "time-macros", @@ -5505,19 +7317,30 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a942f44339478ef67935ab2bbaec2fb0322496cf3cbe84b261e06ac3814c572" +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" @@ -5535,22 +7358,21 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.36.0" +version = "1.44.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" dependencies = [ "backtrace", "bytes", "libc", - "mio", - "num_cpus", + "mio 1.0.3", "parking_lot 0.12.1", "pin-project-lite", "signal-hook-registry", "socket2 0.5.5", "tokio-macros", "tracing", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -5565,13 +7387,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -5584,32 +7406,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d340244b32d920260ae7448cb72b6e238bddc3d4f7603394e7dd46ed8e48f5b8" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator", - "futures-channel", - "futures-util", - "log", - "parking_lot 0.12.1", - "percent-encoding", - "phf 0.11.2", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.8.5", - "socket2 0.5.5", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-retry" version = "0.3.0" @@ -5621,21 +7417,42 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls 0.20.9", + "tokio", + "webpki", +] + [[package]] name = "tokio-rustls" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls", + "rustls 0.21.7", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" +dependencies = [ + "rustls 0.23.20", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" dependencies = [ "futures-core", "pin-project-lite", @@ -5654,21 +7471,38 @@ dependencies = [ "native-tls", "tokio", "tokio-native-tls", - "tungstenite", + "tungstenite 0.20.1", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.21.0", ] [[package]] name = "tokio-util" -version = "0.7.8" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", + "futures-util", + "hashbrown 0.14.3", "pin-project-lite", + "slab", "tokio", - "tracing", ] [[package]] @@ -5689,7 +7523,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_edit 0.19.15", ] [[package]] @@ -5714,6 +7548,17 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_edit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" +dependencies = [ + "indexmap 2.1.0", + "toml_datetime", + "winnow", +] + [[package]] name = "tonic" version = "0.10.2" @@ -5725,17 +7570,17 @@ dependencies = [ "axum", "base64 0.21.5", "bytes", - "h2", - "http", - "http-body", - "hyper", + "h2 0.3.21", + "http 0.2.9", + "http-body 0.4.5", + "hyper 0.14.27", "hyper-timeout", "percent-encoding", "pin-project", - "prost", + "prost 0.12.3", "tokio", "tokio-stream", - "tower", + "tower 0.4.13", "tower-layer", "tower-service", "tracing", @@ -5762,22 +7607,37 @@ dependencies = [ ] [[package]] -name = "tower-layer" -version = "0.3.2" +name = "tower" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", @@ -5792,27 +7652,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" dependencies = [ "crossbeam-channel", - "thiserror", + "thiserror 1.0.64", "time", "tracing-subscriber", ] [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] name = "tracing-bunyan-formatter" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c266b9ac83dedf0e0385ad78514949e6d89491269e7065bee51d2bb8ec7373" +checksum = "2d637245a0d8774bd48df6482e086c59a8b5348a910c3b0579354045a9d82411" dependencies = [ "ahash 0.8.6", "gethostname", @@ -5828,9 +7688,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", @@ -5860,9 +7720,9 @@ dependencies = [ [[package]] name = "tracing-serde" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ "serde", "tracing-core", @@ -5870,9 +7730,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "matchers", "nu-ansi-term", @@ -5900,6 +7760,12 @@ dependencies = [ "wasm-bindgen", ] +[[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" @@ -5928,7 +7794,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.47", + "syn 2.0.94", ] [[package]] @@ -5940,13 +7806,33 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http", + "http 0.2.9", "httparse", "log", "native-tls", "rand 0.8.5", "sha1", - "thiserror", + "thiserror 1.0.64", + "url", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.2.0", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror 1.0.64", "url", "utf-8", ] @@ -6028,6 +7914,12 @@ 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.11" @@ -6077,6 +7969,12 @@ 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" @@ -6086,6 +7984,7 @@ dependencies = [ "form_urlencoded", "idna 0.5.0", "percent-encoding", + "serde", ] [[package]] @@ -6094,6 +7993,12 @@ 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" @@ -6101,10 +8006,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" [[package]] -name = "uuid" -version = "1.6.1" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" +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", @@ -6114,44 +8025,62 @@ dependencies = [ [[package]] name = "validator" -version = "0.16.1" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" +checksum = "db79c75af171630a3148bd3e6d7c4f42b6a9a014c2945bc5ed0020cbb8d9478e" dependencies = [ - "idna 0.4.0", - "lazy_static", + "idna 0.5.0", + "once_cell", "regex", "serde", "serde_derive", "serde_json", "url", - "validator_derive", + "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.16.0" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc44ca3088bb3ba384d9aecf40c6a23a676ce23e09bdaca2073d99c207f864af" +checksum = "df0bcf92720c40105ac4b2dda2a4ea3aa717d4d6a862cc217da653a4bd5c6b10" dependencies = [ - "if_chain", - "lazy_static", + "darling 0.20.11", + "once_cell", "proc-macro-error", "proc-macro2", "quote", - "regex", - "syn 1.0.109", - "validator_types", + "syn 2.0.94", ] [[package]] -name = "validator_types" -version = "0.16.0" +name = "validator_derive" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111abfe30072511849c5910134e8baf8dc05de4c0e5903d681cbd5c9c4d611e3" +checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77" dependencies = [ + "darling 0.20.11", + "once_cell", + "proc-macro-error2", "proc-macro2", - "syn 1.0.109", + "quote", + "syn 2.0.94", ] [[package]] @@ -6205,26 +8134,27 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.90" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1223296a201415c7fad14792dbefaace9bd52b62d33453ade1c5b5f07555406" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", + "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.90" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcdc935b63408d58a32f8cc9738a0bffd8f05cc7c002086c6ef20b7312ad9dcd" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", "wasm-bindgen-shared", ] @@ -6242,9 +8172,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.90" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e4c238561b2d428924c49815533a8b9121c664599558a5d9ec51f8a1740a999" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6252,22 +8182,25 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.90" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.90" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "wasm-streams" @@ -6308,10 +8241,42 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "0.25.2" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.8", + "untrusted 0.9.0", +] + +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] + +[[package]] +name = "webpki-roots" +version = "0.26.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +dependencies = [ + "rustls-pki-types", +] [[package]] name = "which" @@ -6325,16 +8290,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "whoami" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -6366,15 +8321,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[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" @@ -6382,7 +8328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ "windows-core", - "windows-targets 0.52.0", + "windows-targets 0.52.6", ] [[package]] @@ -6391,7 +8337,42 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets 0.53.0", +] + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link", ] [[package]] @@ -6409,7 +8390,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -6429,17 +8419,34 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 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", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] [[package]] @@ -6450,9 +8457,15 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" [[package]] name = "windows_aarch64_msvc" @@ -6462,9 +8475,15 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" [[package]] name = "windows_i686_gnu" @@ -6474,9 +8493,27 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" [[package]] name = "windows_i686_msvc" @@ -6486,9 +8523,15 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" [[package]] name = "windows_x86_64_gnu" @@ -6498,9 +8541,15 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" [[package]] name = "windows_x86_64_gnullvm" @@ -6510,9 +8559,15 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" [[package]] name = "windows_x86_64_msvc" @@ -6522,9 +8577,15 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" @@ -6545,6 +8606,28 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.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 = "wyz" version = "0.5.1" @@ -6555,19 +8638,65 @@ dependencies = [ ] [[package]] -name = "yrs" -version = "0.18.7" +name = "xattr" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d58fbc807677598fedfab76f99f6e1aa5c644411255002b5438ea0ab14672398" +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.94", + "synstructure", +] + +[[package]] +name = "yrs" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81de5913bca29f43a1d12ca92a7b39a2945e9420e01602a7563917c7bfc60f70" dependencies = [ "arc-swap", - "atomic_refcell", + "async-lock", + "async-trait", + "dashmap 6.0.1", "fastrand", "serde", "serde_json", "smallstr", "smallvec", - "thiserror", + "thiserror 1.0.64", ] [[package]] @@ -6587,7 +8716,70 @@ checksum = "dd7e48ccf166952882ca8bd778a43502c64f33bf94c12ebe2a7f08e5a0f6689f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.47", + "syn 2.0.94", +] + +[[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.94", + "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.94", +] + +[[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.94", ] [[package]] @@ -6599,7 +8791,7 @@ dependencies = [ "aes", "byteorder", "bzip2", - "constant_time_eq", + "constant_time_eq 0.1.5", "crc32fast", "crossbeam-utils", "flate2", @@ -6610,6 +8802,49 @@ dependencies = [ "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 1.0.64", + "time", + "zeroize", + "zopfli", + "zstd 0.13.2", +] + +[[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" @@ -6621,11 +8856,11 @@ dependencies = [ [[package]] name = "zstd" -version = "0.12.4" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a27595e173641171fc74a1232b7b1c7a7cb6e18222c11e9dfb9888fa424c53c" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" dependencies = [ - "zstd-safe 6.0.6", + "zstd-safe 7.2.0", ] [[package]] @@ -6640,21 +8875,19 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "6.0.6" +version = "7.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee98ffd0b48ee95e6c5168188e44a54550b1564d9d530ee21d5f0eaed1069581" +checksum = "fa556e971e7b568dc775c136fc9de8c779b1c2fc3a63defaafadffdbd3181afa" dependencies = [ - "libc", "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.8+zstd.1.5.5" +version = "2.0.12+zstd.1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5556e6ee25d32df2586c098bbfa278803692a20d0ab9565e049480d52707ec8c" +checksum = "0a4e40c320c3cb459d9a9ff6de98cff88f4751ee9275d140e2be94a2b74e4c13" dependencies = [ "cc", - "libc", "pkg-config", ] diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 26de92e4ce..1561c7ea7d 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -1,108 +1,118 @@ [workspace] members = [ - "lib-dispatch", - "lib-log", - "flowy-core", - "dart-ffi", - "flowy-user", - "flowy-user-pub", - "event-integration-test", - "flowy-sqlite", - "flowy-folder", - "flowy-folder-pub", - "flowy-notification", - "flowy-document", - "flowy-document-pub", - "flowy-error", - "flowy-database2", - "flowy-database-pub", - "flowy-server", - "flowy-server-pub", - "flowy-config", - "flowy-encrypt", - "flowy-storage", - "collab-integrate", - "flowy-date", - "flowy-search", - "lib-infra", - "build-tool/flowy-ast", - "build-tool/flowy-codegen", - "build-tool/flowy-derive", - "flowy-search-pub", + "lib-dispatch", + "lib-log", + "flowy-core", + "dart-ffi", + "flowy-user", + "flowy-user-pub", + "event-integration-test", + "flowy-sqlite", + "flowy-folder", + "flowy-folder-pub", + "flowy-notification", + "flowy-document", + "flowy-document-pub", + "flowy-error", + "flowy-database2", + "flowy-database-pub", + "flowy-server", + "flowy-server-pub", + "flowy-storage", + "collab-integrate", + "flowy-date", + "flowy-search", + "lib-infra", + "build-tool/flowy-ast", + "build-tool/flowy-codegen", + "build-tool/flowy-derive", + "flowy-search-pub", + "flowy-ai", + "flowy-ai-pub", + "flowy-storage-pub", ] + resolver = "2" [workspace.dependencies] -lib-dispatch = { workspace = true, path = "lib-dispatch" } -lib-log = { workspace = true, path = "lib-log" } -lib-infra = { workspace = true, path = "lib-infra" } -flowy-ast = { workspace = true, path = "build-tool/flowy-ast" } -flowy-codegen = { workspace = true, path = "build-tool/flowy-codegen" } -flowy-derive = { workspace = true, path = "build-tool/flowy-derive" } -flowy-core = { workspace = true, path = "flowy-core" } -dart-ffi = { workspace = true, path = "dart-ffi" } -flowy-user = { workspace = true, path = "flowy-user" } -flowy-user-pub = { workspace = true, path = "flowy-user-pub" } -flowy-sqlite = { workspace = true, path = "flowy-sqlite" } -flowy-folder = { workspace = true, path = "flowy-folder" } -flowy-folder-pub = { workspace = true, path = "flowy-folder-pub" } -flowy-notification = { workspace = true, path = "flowy-notification" } -flowy-document = { workspace = true, path = "flowy-document" } -flowy-document-pub = { workspace = true, path = "flowy-document-pub" } -flowy-error = { workspace = true, path = "flowy-error" } -flowy-database2 = { workspace = true, path = "flowy-database2" } -flowy-database-pub = { workspace = true, path = "flowy-database-pub" } -flowy-server = { workspace = true, path = "flowy-server" } -flowy-server-pub = { workspace = true, path = "flowy-server-pub" } -flowy-config = { workspace = true, path = "flowy-config" } -flowy-encrypt = { workspace = true, path = "flowy-encrypt" } -flowy-storage = { workspace = true, path = "flowy-storage" } -flowy-search = { workspace = true, path = "flowy-search" } -flowy-search-pub = { workspace = true, path = "flowy-search-pub" } -collab-integrate = { workspace = true, path = "collab-integrate" } -flowy-date = { workspace = true, path = "flowy-date" } +lib-dispatch = { path = "lib-dispatch" } +lib-log = { path = "lib-log" } +lib-infra = { path = "lib-infra" } +flowy-ast = { path = "build-tool/flowy-ast" } +flowy-codegen = { path = "build-tool/flowy-codegen" } +flowy-derive = { path = "build-tool/flowy-derive" } +flowy-core = { path = "flowy-core" } +dart-ffi = { path = "dart-ffi" } +flowy-user = { path = "flowy-user" } +flowy-user-pub = { path = "flowy-user-pub" } +flowy-sqlite = { path = "flowy-sqlite" } +flowy-folder = { path = "flowy-folder" } +flowy-folder-pub = { path = "flowy-folder-pub" } +flowy-notification = { path = "flowy-notification" } +flowy-document = { path = "flowy-document" } +flowy-document-pub = { path = "flowy-document-pub" } +flowy-error = { path = "flowy-error" } +flowy-database2 = { path = "flowy-database2" } +flowy-database-pub = { path = "flowy-database-pub" } +flowy-server = { path = "flowy-server" } +flowy-server-pub = { path = "flowy-server-pub" } +flowy-storage = { path = "flowy-storage" } +flowy-storage-pub = { path = "flowy-storage-pub" } +flowy-search = { path = "flowy-search" } +flowy-search-pub = { path = "flowy-search-pub" } +collab-integrate = { path = "collab-integrate" } +flowy-date = { path = "flowy-date" } +flowy-ai = { path = "flowy-ai" } +flowy-ai-pub = { path = "flowy-ai-pub" } anyhow = "1.0" +arc-swap = "1.7" tracing = "0.1.40" bytes = "1.5.0" serde_json = "1.0.108" serde = "1.0.194" protobuf = { version = "2.28.0" } -diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2"] } +diesel = { version = "2.1.0", features = [ + "sqlite", + "chrono", + "r2d2", + "serde_json", +] } +diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } uuid = { version = "1.5.0", features = ["serde", "v4", "v5"] } serde_repr = "0.1" -parking_lot = "0.12" -futures = "0.3.29" -tokio = "1.34.0" +futures = "0.3.31" +tokio = "1.38.0" tokio-stream = "0.1.14" -async-trait = "0.1.74" +async-trait = "0.1.81" chrono = { version = "0.4.31", default-features = false, features = ["clock"] } -# 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 = "870cd70" } -collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "870cd70" } -yrs = "0.18.7" +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" } +yrs = "0.21.0" +validator = { version = "0.18", features = ["derive"] } +tokio-util = "0.7.11" +zip = "2.2.0" +dashmap = "6.0.1" +derive_builder = "0.20.2" +tantivy = { version = "0.24.0" } +af-plugin = { version = "0.1" } +af-local-ai = { version = "0.1" } # Please using the following command to update the revision id # Current directory: frontend # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ef8e6f3" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" } +client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6c6f1c5cc3ce2161c247f63f6132361da8dc0a32" } [profile.dev] -opt-level = 1 +opt-level = 0 lto = false codegen-units = 16 debug = true @@ -118,14 +128,35 @@ debug = true codegen-units = 16 lto = false -## debuginfo — it makes ./target much bigger, which again harms caching. Depending on your preferred workflow, -## you might consider disabling debuginfo unconditionally, this brings some benefits for local builds as well. #strip = "debuginfo" -## For from-scratch builds, incremental adds an extra dependency-tracking overhead. It also significantly increases -## the amount of IO and the size of ./target, which make caching less effective. -incremental = false +incremental = true [patch.crates-io] -# TODO(Lucas.Xu) Upgrade to the latest version of RocksDB once PR(https://github.com/rust-rocksdb/rust-rocksdb/pull/869) is merged. -# Currently, using the following revision id. This commit is patched to fix the 32-bit build issue and it's checked out from 0.21.0, not 0.22.0. -rocksdb = { git = "https://github.com/LucasXu0/rust-rocksdb", rev = "21cf4a23ec131b9d82dc94e178fe8efc0c147b09" } +# We're using a specific commit here because rust-rocksdb doesn't publish the latest version that includes the memory alignment fix. +# For more details, see https://github.com/rust-rocksdb/rust-rocksdb/pull/868 +rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120e4549e04ba3baa6a1ee5a5a801fa45a72" } +# 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 = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } +collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" } + +# Working directory: frontend +# To update the commit ID, run: +# scripts/tool/update_local_ai_rev.sh new_rev_id +# ⚠️⚠️⚠️️ +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } diff --git a/frontend/rust-lib/build-tool/flowy-codegen/Cargo.toml b/frontend/rust-lib/build-tool/flowy-codegen/Cargo.toml index 92a273ad04..ae07268ee9 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/Cargo.toml +++ b/frontend/rust-lib/build-tool/flowy-codegen/Cargo.toml @@ -7,41 +7,42 @@ edition = "2021" [dependencies] log = "0.4.17" -serde = { workspace = true, features = ["derive"]} +serde = { workspace = true, features = ["derive"] } serde_json.workspace = true flowy-ast.workspace = true quote = "1.0" -cmd_lib = { version = "1.3.0", optional = true } -protoc-rust = { version = "2", optional = true } +cmd_lib = { version = "1.9.5", optional = true } +protoc-rust = { version = "2.28.0", optional = true } +#protobuf-codegen = { version = "3.7.1" } walkdir = { version = "2", optional = true } similar = { version = "1.3.0", optional = true } syn = { version = "1.0.109", features = ["extra-traits", "parsing", "derive", "full"] } fancy-regex = { version = "0.10.0", optional = true } lazy_static = { version = "1.4.0", optional = true } -tera = { version = "1.17.1", optional = true} +tera = { version = "1.17.1", optional = true } itertools = { version = "0.10", optional = true } phf = { version = "0.8.0", features = ["macros"], optional = true } -console = {version = "0.14.1", optional = true} -protoc-bin-vendored = { version = "3.0", optional = true } -toml = {version = "0.5.11", optional = true} +console = { version = "0.14.1", optional = true } +protoc-bin-vendored = { version = "3.1.0", optional = true } +toml = { version = "0.5.11", optional = true } [features] proto_gen = [ - "similar", - "fancy-regex", - "lazy_static", - "tera", - "itertools", - "phf", - "walkdir", - "console", - "toml", - "cmd_lib", - "protoc-rust", - "walkdir", - "protoc-bin-vendored", + "similar", + "fancy-regex", + "lazy_static", + "tera", + "itertools", + "phf", + "walkdir", + "console", + "toml", + "cmd_lib", + "protoc-rust", + "walkdir", + "protoc-bin-vendored", ] dart_event = ["walkdir", "tera", ] dart = ["proto_gen", "dart_event"] diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/dart_event.rs b/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/dart_event.rs index 8ab6d3fb59..0a1849f877 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/dart_event.rs +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/dart_event.rs @@ -143,8 +143,7 @@ pub fn parse_event_crate(event_crate: &DartEventCrate) -> Vec { attrs .iter() .filter(|attr| !attr.attrs.event_attrs.ignore) - .enumerate() - .map(|(_index, variant)| EventASTContext::from(&variant.attrs)) + .map(|variant| EventASTContext::from(&variant.attrs)) .collect::>() }, _ => vec![], diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/lib.rs b/frontend/rust-lib/build-tool/flowy-codegen/src/lib.rs index 768147c10a..2df1e98ee9 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/lib.rs +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/lib.rs @@ -23,7 +23,6 @@ pub struct ProtoCache { pub enum Project { Tauri, TauriApp, - Web { relative_path: String }, Native, } @@ -34,7 +33,6 @@ impl Project { Project::TauriApp => { "appflowy_web_app/src/application/services/tauri-services/backend".to_string() }, - Project::Web { .. } => "appflowy_web/src/services/backend".to_string(), Project::Native => panic!("Native project is not supported yet."), } } @@ -42,7 +40,6 @@ impl Project { pub fn event_root(&self) -> String { match self { Project::Tauri | Project::TauriApp => "../../".to_string(), - Project::Web { relative_path } => relative_path.to_string(), Project::Native => panic!("Native project is not supported yet."), } } @@ -50,7 +47,6 @@ impl Project { pub fn model_root(&self) -> String { match self { Project::Tauri | Project::TauriApp => "../../".to_string(), - Project::Web { relative_path } => relative_path.to_string(), Project::Native => panic!("Native project is not supported yet."), } } @@ -62,13 +58,6 @@ impl Project { import { Ok, Err, Result } from "ts-results"; import { invoke } from "@tauri-apps/api/tauri"; import * as pb from "../.."; -"# - .to_string(), - Project::Web { .. } => r#" -/// Auto generate. Do not edit -import { Ok, Err, Result } from "ts-results"; -import { invoke } from "@/application/app.ts"; -import * as pb from "../.."; "# .to_string(), Project::Native => panic!("Native project is not supported yet."), diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs b/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs index 75b4b8eb4f..677e7bcddf 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs @@ -12,8 +12,9 @@ use itertools::Itertools; use log::info; pub use proto_gen::*; pub use proto_info::*; +use std::fs; use std::fs::File; -use std::io::Write; +use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use walkdir::WalkDir; @@ -75,64 +76,64 @@ pub fn dart_gen(crate_name: &str) { } } -#[allow(unused_variables)] -pub fn ts_gen(crate_name: &str, dest_folder_name: &str, project: Project) { - // 1. generate the proto files to proto_file_dir - #[cfg(feature = "proto_gen")] - let proto_crates = gen_proto_files(crate_name); - - for proto_crate in proto_crates { - let mut proto_file_paths = vec![]; - let mut file_names = vec![]; - let proto_file_output_path = proto_crate - .proto_output_path() - .to_str() - .unwrap() - .to_string(); - let protobuf_output_path = proto_crate - .protobuf_crate_path() - .to_str() - .unwrap() - .to_string(); - - for (path, file_name) in WalkDir::new(&proto_file_output_path) - .into_iter() - .filter_map(|e| e.ok()) - .map(|e| { - let path = e.path().to_str().unwrap().to_string(); - let file_name = e.path().file_stem().unwrap().to_str().unwrap().to_string(); - (path, file_name) - }) - { - if path.ends_with(".proto") { - // https://stackoverflow.com/questions/49077147/how-can-i-force-build-rs-to-run-again-without-cleaning-my-whole-project - println!("cargo:rerun-if-changed={}", path); - proto_file_paths.push(path); - file_names.push(file_name); - } - } - let protoc_bin_path = protoc_bin_vendored::protoc_bin_path().unwrap(); - - // 2. generate the protobuf files(Dart) - #[cfg(feature = "ts")] - generate_ts_protobuf_files( - dest_folder_name, - &proto_file_output_path, - &proto_file_paths, - &file_names, - &protoc_bin_path, - &project, - ); - - // 3. generate the protobuf files(Rust) - generate_rust_protobuf_files( - &protoc_bin_path, - &proto_file_paths, - &proto_file_output_path, - &protobuf_output_path, - ); - } -} +// #[allow(unused_variables)] +// fn ts_gen(crate_name: &str, dest_folder_name: &str, project: Project) { +// // 1. generate the proto files to proto_file_dir +// #[cfg(feature = "proto_gen")] +// let proto_crates = gen_proto_files(crate_name); +// +// for proto_crate in proto_crates { +// let mut proto_file_paths = vec![]; +// let mut file_names = vec![]; +// let proto_file_output_path = proto_crate +// .proto_output_path() +// .to_str() +// .unwrap() +// .to_string(); +// let protobuf_output_path = proto_crate +// .protobuf_crate_path() +// .to_str() +// .unwrap() +// .to_string(); +// +// for (path, file_name) in WalkDir::new(&proto_file_output_path) +// .into_iter() +// .filter_map(|e| e.ok()) +// .map(|e| { +// let path = e.path().to_str().unwrap().to_string(); +// let file_name = e.path().file_stem().unwrap().to_str().unwrap().to_string(); +// (path, file_name) +// }) +// { +// if path.ends_with(".proto") { +// // https://stackoverflow.com/questions/49077147/how-can-i-force-build-rs-to-run-again-without-cleaning-my-whole-project +// println!("cargo:rerun-if-changed={}", path); +// proto_file_paths.push(path); +// file_names.push(file_name); +// } +// } +// let protoc_bin_path = protoc_bin_vendored::protoc_bin_path().unwrap(); +// +// // 2. generate the protobuf files(Dart) +// #[cfg(feature = "ts")] +// generate_ts_protobuf_files( +// dest_folder_name, +// &proto_file_output_path, +// &proto_file_paths, +// &file_names, +// &protoc_bin_path, +// &project, +// ); +// +// // 3. generate the protobuf files(Rust) +// generate_rust_protobuf_files( +// &protoc_bin_path, +// &proto_file_paths, +// &proto_file_output_path, +// &protobuf_output_path, +// ); +// } +// } fn generate_rust_protobuf_files( protoc_bin_path: &Path, @@ -147,6 +148,38 @@ fn generate_rust_protobuf_files( .include(proto_file_output_path) .run() .expect("Running rust protoc failed."); + remove_box_pointers_lint_from_all_except_mod(protobuf_output_path); +} +fn remove_box_pointers_lint_from_all_except_mod(dir_path: &str) { + let dir = fs::read_dir(dir_path).expect("Failed to read directory"); + for entry in dir { + let entry = entry.expect("Failed to read directory entry"); + let path = entry.path(); + + // Skip directories and mod.rs + if path.is_file() { + if let Some(file_name) = path.file_name().and_then(|f| f.to_str()) { + if file_name != "mod.rs" { + remove_box_pointers_lint(&path); + } + } + } + } +} + +fn remove_box_pointers_lint(file_path: &Path) { + let file = File::open(file_path).expect("Failed to open file"); + let reader = BufReader::new(file); + let lines: Vec = reader + .lines() + .map_while(Result::ok) + .filter(|line| !line.contains("#![allow(box_pointers)]")) + .collect(); + + let mut file = File::create(file_path).expect("Failed to create file"); + for line in lines { + writeln!(file, "{}", line).expect("Failed to write line"); + } } #[cfg(feature = "ts")] diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs b/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs index ff51ff952b..97a7f5f529 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs @@ -153,8 +153,7 @@ pub fn parse_event_crate(event_crate: &TsEventCrate) -> Vec { attrs .iter() .filter(|attr| !attr.attrs.event_attrs.ignore) - .enumerate() - .map(|(_index, variant)| EventASTContext::from(&variant.attrs)) + .map(|variant| EventASTContext::from(&variant.attrs)) .collect::>() }, _ => vec![], diff --git a/frontend/rust-lib/build-tool/flowy-derive/Cargo.toml b/frontend/rust-lib/build-tool/flowy-derive/Cargo.toml index ce84acd0eb..763210c558 100644 --- a/frontend/rust-lib/build-tool/flowy-derive/Cargo.toml +++ b/frontend/rust-lib/build-tool/flowy-derive/Cargo.toml @@ -14,8 +14,8 @@ syn = { version = "1.0.109", features = ["extra-traits", "visit"] } quote = "1.0" proc-macro2 = "1.0" flowy-ast.workspace = true -lazy_static = {version = "1.4.0"} -dashmap = "5" +lazy_static = { version = "1.4.0" } +dashmap.workspace = true flowy-codegen.workspace = true serde_json.workspace = true walkdir = "2.3.2" diff --git a/frontend/rust-lib/collab-integrate/Cargo.toml b/frontend/rust-lib/collab-integrate/Cargo.toml index ffddb6a911..0cbdd41ccd 100644 --- a/frontend/rust-lib/collab-integrate/Cargo.toml +++ b/frontend/rust-lib/collab-integrate/Cargo.toml @@ -11,15 +11,21 @@ crate-type = ["cdylib", "rlib"] collab = { workspace = true } collab-plugins = { workspace = true } collab-entity = { workspace = true } +collab-document = { workspace = true } +collab-folder = { workspace = true } +collab-user = { workspace = true } +collab-database = { workspace = true } serde.workspace = true serde_json.workspace = true anyhow.workspace = true tracing.workspace = true -parking_lot.workspace = true -async-trait.workspace = true tokio = { workspace = true, features = ["sync"] } lib-infra = { workspace = true } -futures = "0.3" +arc-swap = "1.7" +flowy-sqlite = { workspace = true } +diesel.workspace = true +flowy-error.workspace = true +uuid.workspace = true [features] default = [] diff --git a/frontend/rust-lib/collab-integrate/src/collab_builder.rs b/frontend/rust-lib/collab-integrate/src/collab_builder.rs index 571264d1d2..223ebacc91 100644 --- a/frontend/rust-lib/collab-integrate/src/collab_builder.rs +++ b/frontend/rust-lib/collab-integrate/src/collab_builder.rs @@ -1,13 +1,23 @@ +use std::borrow::BorrowMut; use std::fmt::{Debug, Display}; use std::sync::{Arc, Weak}; use crate::CollabKVDB; -use anyhow::Error; -use collab::core::collab::{DataSource, MutexCollab}; -use collab::preclude::CollabBuilder; +use anyhow::{anyhow, Error}; +use arc_swap::{ArcSwap, ArcSwapOption}; +use collab::core::collab::DataSource; +use collab::core::collab_plugin::CollabPersistence; +use collab::entity::EncodedCollab; +use collab::error::CollabError; +use collab::preclude::{Collab, CollabBuilder}; +use collab_database::workspace_database::{DatabaseCollabService, WorkspaceDatabaseManager}; +use collab_document::blocks::DocumentData; +use collab_document::document::Document; use collab_entity::{CollabObject, CollabType}; +use collab_folder::{Folder, FolderData, FolderNotify}; use collab_plugins::connect_state::{CollabConnectReachability, CollabConnectState}; use collab_plugins::local_storage::kv::snapshot::SnapshotPersistence; + if_native! { use collab_plugins::local_storage::rocksdb::rocksdb_plugin::{RocksdbBackup, RocksdbDiskPlugin}; } @@ -17,17 +27,21 @@ use collab_plugins::local_storage::indexeddb::IndexeddbDiskPlugin; } pub use crate::plugin_provider::CollabCloudPluginProvider; +use collab::lock::RwLock; +use collab_plugins::local_storage::kv::doc::CollabKVAction; +use collab_plugins::local_storage::kv::KVTransactionDB; use collab_plugins::local_storage::CollabPersistenceConfig; +use collab_user::core::{UserAwareness, UserAwarenessNotifier}; +use flowy_error::FlowyError; use lib_infra::{if_native, if_wasm}; -use parking_lot::{Mutex, RwLock}; -use tracing::{instrument, trace}; +use tracing::{error, instrument, trace, warn}; +use uuid::Uuid; #[derive(Clone, Debug)] pub enum CollabPluginProviderType { Local, AppFlowyCloud, - Supabase, } pub enum CollabPluginProviderContext { @@ -35,13 +49,7 @@ pub enum CollabPluginProviderContext { AppFlowyCloud { uid: i64, collab_object: CollabObject, - local_collab: Weak, - }, - Supabase { - uid: i64, - collab_object: CollabObject, - local_collab: Weak, - local_collab_db: Weak, + local_collab: Weak + Send + Sync + 'static>>, }, } @@ -52,13 +60,7 @@ impl Display for CollabPluginProviderContext { CollabPluginProviderContext::AppFlowyCloud { uid: _, collab_object, - local_collab: _, - } => collab_object.to_string(), - CollabPluginProviderContext::Supabase { - uid: _, - collab_object, - local_collab: _, - local_collab_db: _, + .. } => collab_object.to_string(), }; write!(f, "{}", str) @@ -66,16 +68,16 @@ impl Display for CollabPluginProviderContext { } pub trait WorkspaceCollabIntegrate: Send + Sync { - fn workspace_id(&self) -> Result; - fn device_id(&self) -> Result; + fn workspace_id(&self) -> Result; + fn device_id(&self) -> Result; } pub struct AppFlowyCollabBuilder { network_reachability: CollabConnectReachability, - plugin_provider: RwLock>, - snapshot_persistence: Mutex>>, + plugin_provider: ArcSwap>, + snapshot_persistence: ArcSwapOption>, #[cfg(not(target_arch = "wasm32"))] - rocksdb_backup: Mutex>>, + rocksdb_backup: ArcSwapOption>, workspace_integrate: Arc, } @@ -86,7 +88,7 @@ impl AppFlowyCollabBuilder { ) -> Self { Self { network_reachability: CollabConnectReachability::new(), - plugin_provider: RwLock::new(Arc::new(storage_provider)), + plugin_provider: ArcSwap::new(Arc::new(Arc::new(storage_provider))), snapshot_persistence: Default::default(), #[cfg(not(target_arch = "wasm32"))] rocksdb_backup: Default::default(), @@ -95,12 +97,14 @@ impl AppFlowyCollabBuilder { } pub fn set_snapshot_persistence(&self, snapshot_persistence: Arc) { - *self.snapshot_persistence.lock() = Some(snapshot_persistence); + self + .snapshot_persistence + .store(Some(snapshot_persistence.into())); } #[cfg(not(target_arch = "wasm32"))] pub fn set_rocksdb_backup(&self, rocksdb_backup: Arc) { - *self.rocksdb_backup.lock() = Some(rocksdb_backup); + self.rocksdb_backup.store(Some(rocksdb_backup.into())); } pub fn update_network(&self, reachable: bool) { @@ -115,205 +119,278 @@ impl AppFlowyCollabBuilder { } } - fn collab_object( + pub fn collab_object( &self, + workspace_id: &Uuid, uid: i64, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, ) -> Result { - let device_id = self.workspace_integrate.device_id()?; - let workspace_id = self.workspace_integrate.workspace_id()?; - Ok(CollabObject::new( - uid, - object_id.to_string(), - collab_type, - workspace_id, - device_id, - )) - } - - /// Creates a new collaboration builder with the default configuration. - /// - /// This function will initiate the creation of a [MutexCollab] object if it does not already exist. - /// To check for the existence of the object prior to creation, you should utilize a transaction - /// returned by the [read_txn] method of the [CollabKVDB]. Then, invoke the [is_exist] method - /// to confirm the object's presence. - /// - /// # Parameters - /// - `uid`: The user ID associated with the collaboration. - /// - `object_id`: A string reference representing the ID of the object. - /// - `object_type`: The type of the collaboration, defined by the [CollabType] enum. - /// - `raw_data`: The raw data of the collaboration object, defined by the [CollabDocState] type. - /// - `collab_db`: A weak reference to the [CollabKVDB]. - /// - #[allow(clippy::too_many_arguments)] - pub async fn build( - &self, - workspace_id: &str, - uid: i64, - object_id: &str, - object_type: CollabType, - collab_doc_state: DataSource, - collab_db: Weak, - build_config: CollabBuilderConfig, - ) -> Result, Error> { - self.build_with_config( - workspace_id, - uid, - object_id, - object_type, - collab_db, - collab_doc_state, - build_config, - ) - } - - /// Creates a new collaboration builder with the custom configuration. - /// - /// This function will initiate the creation of a [MutexCollab] object if it does not already exist. - /// To check for the existence of the object prior to creation, you should utilize a transaction - /// returned by the [read_txn] method of the [CollabKVDB]. Then, invoke the [is_exist] method - /// to confirm the object's presence. - /// - /// # Parameters - /// - `uid`: The user ID associated with the collaboration. - /// - `object_id`: A string reference representing the ID of the object. - /// - `object_type`: The type of the collaboration, defined by the [CollabType] enum. - /// - `raw_data`: The raw data of the collaboration object, defined by the [CollabDocState] type. - /// - `collab_db`: A weak reference to the [CollabKVDB]. - /// - #[allow(clippy::too_many_arguments)] - #[instrument(level = "trace", skip(self, collab_db, collab_doc_state, build_config))] - pub fn build_with_config( - &self, - workspace_id: &str, - uid: i64, - object_id: &str, - object_type: CollabType, - collab_db: Weak, - collab_doc_state: DataSource, - build_config: CollabBuilderConfig, - ) -> Result, Error> { - let collab = CollabBuilder::new(uid, object_id) - .with_doc_state(collab_doc_state) - .with_device_id(self.workspace_integrate.device_id()?) - .build()?; - // Compare the workspace_id with the currently opened workspace_id. Return an error if they do not match. // This check is crucial in asynchronous code contexts where the workspace_id might change during operation. let actual_workspace_id = self.workspace_integrate.workspace_id()?; - if workspace_id != actual_workspace_id { + if workspace_id != &actual_workspace_id { return Err(anyhow::anyhow!( "workspace_id not match when build collab. expect workspace_id: {}, actual workspace_id: {}", workspace_id, actual_workspace_id )); } - let persistence_config = CollabPersistenceConfig::default(); + let device_id = self.workspace_integrate.device_id()?; + Ok(CollabObject::new( + uid, + object_id.to_string(), + collab_type, + workspace_id.to_string(), + device_id, + )) + } - #[cfg(target_arch = "wasm32")] - { - collab.lock().add_plugin(Box::new(IndexeddbDiskPlugin::new( - uid, - object_id.to_string(), - object_type.clone(), - collab_db.clone(), - ))); - } + #[allow(clippy::too_many_arguments)] + #[instrument( + level = "trace", + skip(self, data_source, collab_db, builder_config, data) + )] + pub async fn create_document( + &self, + object: CollabObject, + data_source: DataSource, + collab_db: Weak, + builder_config: CollabBuilderConfig, + data: Option, + ) -> Result>, Error> { + let expected_collab_type = CollabType::Document; + assert_eq!(object.collab_type, expected_collab_type); + let mut collab = self.build_collab(&object, &collab_db, data_source).await?; + collab.enable_undo_redo(); - #[cfg(not(target_arch = "wasm32"))] - { - collab - .lock() - .add_plugin(Box::new(RocksdbDiskPlugin::new_with_config( - uid, - object_id.to_string(), - object_type.clone(), + let document = match data { + None => Document::open(collab)?, + Some(data) => { + let document = Document::create_with_data(collab, data)?; + if let Err(err) = self.write_collab_to_disk( + object.uid, + &object.workspace_id, + &object.object_id, collab_db.clone(), - persistence_config.clone(), - None, - ))); + &object.collab_type, + &document, + ) { + error!( + "build_collab: flush document collab to disk failed: {}", + err + ); + } + document + }, + }; + let document = Arc::new(RwLock::new(document)); + self.finalize(object, builder_config, document) + } + + #[allow(clippy::too_many_arguments)] + #[instrument( + level = "trace", + skip(self, object, doc_state, collab_db, builder_config, folder_notifier) + )] + pub async fn create_folder( + &self, + object: CollabObject, + doc_state: DataSource, + collab_db: Weak, + builder_config: CollabBuilderConfig, + folder_notifier: Option, + folder_data: Option, + ) -> Result>, Error> { + let expected_collab_type = CollabType::Folder; + assert_eq!(object.collab_type, expected_collab_type); + let folder = match folder_data { + None => { + let collab = self.build_collab(&object, &collab_db, doc_state).await?; + Folder::open(object.uid, collab, folder_notifier)? + }, + Some(data) => { + let collab = self.build_collab(&object, &collab_db, doc_state).await?; + let folder = Folder::create(object.uid, collab, folder_notifier, data); + if let Err(err) = self.write_collab_to_disk( + object.uid, + &object.workspace_id, + &object.object_id, + collab_db.clone(), + &object.collab_type, + &folder, + ) { + error!("build_collab: flush folder collab to disk failed: {}", err); + } + folder + }, + }; + let folder = Arc::new(RwLock::new(folder)); + self.finalize(object, builder_config, folder) + } + + #[allow(clippy::too_many_arguments)] + #[instrument( + level = "trace", + skip(self, object, doc_state, collab_db, builder_config, notifier) + )] + pub async fn create_user_awareness( + &self, + object: CollabObject, + doc_state: DataSource, + collab_db: Weak, + builder_config: CollabBuilderConfig, + notifier: Option, + ) -> Result>, Error> { + let expected_collab_type = CollabType::UserAwareness; + assert_eq!(object.collab_type, expected_collab_type); + let collab = self.build_collab(&object, &collab_db, doc_state).await?; + let user_awareness = UserAwareness::create(collab, notifier)?; + let user_awareness = Arc::new(RwLock::new(user_awareness)); + self.finalize(object, builder_config, user_awareness) + } + + #[allow(clippy::too_many_arguments)] + #[instrument(level = "trace", skip_all)] + pub fn create_workspace_database_manager( + &self, + object: CollabObject, + collab: Collab, + _collab_db: Weak, + builder_config: CollabBuilderConfig, + collab_service: impl DatabaseCollabService, + ) -> Result>, Error> { + let expected_collab_type = CollabType::WorkspaceDatabase; + assert_eq!(object.collab_type, expected_collab_type); + let workspace = WorkspaceDatabaseManager::open(&object.object_id, collab, collab_service)?; + let workspace = Arc::new(RwLock::new(workspace)); + self.finalize(object, builder_config, workspace) + } + + pub async fn build_collab( + &self, + object: &CollabObject, + collab_db: &Weak, + data_source: DataSource, + ) -> Result { + let object = object.clone(); + let collab_db = collab_db.clone(); + let device_id = self.workspace_integrate.device_id()?; + let collab = tokio::task::spawn_blocking(move || { + let collab = CollabBuilder::new(object.uid, &object.object_id, data_source) + .with_device_id(device_id) + .build()?; + let persistence_config = CollabPersistenceConfig::default(); + let db_plugin = RocksdbDiskPlugin::new_with_config( + object.uid, + object.workspace_id.clone(), + object.object_id.to_string(), + object.collab_type, + collab_db, + persistence_config, + ); + collab.add_plugin(Box::new(db_plugin)); + Ok::<_, Error>(collab) + }) + .await??; + + Ok(collab) + } + + pub fn finalize( + &self, + object: CollabObject, + build_config: CollabBuilderConfig, + collab: Arc>, + ) -> Result>, Error> + where + T: BorrowMut + Send + Sync + 'static, + { + let mut write_collab = collab.try_write()?; + let has_cloud_plugin = write_collab.borrow().has_cloud_plugin(); + if has_cloud_plugin { + drop(write_collab); + return Ok(collab); } - let arc_collab = Arc::new(collab); + if build_config.sync_enable { + trace!("🚀finalize collab:{}", object); + let plugin_provider = self.plugin_provider.load_full(); + let provider_type = plugin_provider.provider_type(); + let span = + tracing::span!(tracing::Level::TRACE, "collab_builder", object_id = %object.object_id); + let _enter = span.enter(); + match provider_type { + CollabPluginProviderType::AppFlowyCloud => { + let local_collab = Arc::downgrade(&collab); + let plugins = plugin_provider.get_plugins(CollabPluginProviderContext::AppFlowyCloud { + uid: object.uid, + collab_object: object, + local_collab, + }); - { - let collab_object = self.collab_object(uid, object_id, object_type.clone())?; - if build_config.sync_enable { - let provider_type = self.plugin_provider.read().provider_type(); - let span = tracing::span!(tracing::Level::TRACE, "collab_builder", object_id = %object_id); - let _enter = span.enter(); - match provider_type { - CollabPluginProviderType::AppFlowyCloud => { - let local_collab = Arc::downgrade(&arc_collab); - let plugins = - self - .plugin_provider - .read() - .get_plugins(CollabPluginProviderContext::AppFlowyCloud { - uid, - collab_object, - local_collab, - }); - - for plugin in plugins { - arc_collab.lock().add_plugin(plugin); - } - }, - CollabPluginProviderType::Supabase => { - #[cfg(not(target_arch = "wasm32"))] - { - trace!("init supabase collab plugins"); - let local_collab = Arc::downgrade(&arc_collab); - let local_collab_db = collab_db.clone(); - let plugins = - self - .plugin_provider - .read() - .get_plugins(CollabPluginProviderContext::Supabase { - uid, - collab_object, - local_collab, - local_collab_db, - }); - for plugin in plugins { - arc_collab.lock().add_plugin(plugin); - } - } - }, - CollabPluginProviderType::Local => {}, - } + // at the moment when we get the lock, the collab object is not yet exposed outside + for plugin in plugins { + write_collab.borrow().add_plugin(plugin); + } + }, + CollabPluginProviderType::Local => {}, } } - if build_config.auto_initialize { - #[cfg(target_arch = "wasm32")] - futures::executor::block_on(arc_collab.lock().initialize()); + (*write_collab).borrow_mut().initialize(); + drop(write_collab); + Ok(collab) + } - #[cfg(not(target_arch = "wasm32"))] - arc_collab.lock().initialize(); + /// Remove all updates in disk and write the final state vector to disk. + #[instrument(level = "trace", skip_all, err)] + pub fn write_collab_to_disk( + &self, + uid: i64, + workspace_id: &str, + object_id: &str, + collab_db: Weak, + collab_type: &CollabType, + collab: &T, + ) -> Result<(), Error> + where + T: BorrowMut + Send + Sync + 'static, + { + if let Some(collab_db) = collab_db.upgrade() { + let write_txn = collab_db.write_txn(); + trace!( + "flush workspace: {} {}:collab:{} to disk", + workspace_id, + collab_type, + object_id + ); + let collab: &Collab = collab.borrow(); + let encode_collab = + collab.encode_collab_v1(|collab| collab_type.validate_require_data(collab))?; + write_txn.flush_doc( + uid, + workspace_id, + object_id, + encode_collab.state_vector.to_vec(), + encode_collab.doc_state.to_vec(), + )?; + write_txn.commit_transaction()?; + } else { + error!("collab_db is dropped"); } - trace!("collab initialized: {}:{}", object_type, object_id); - Ok(arc_collab) + Ok(()) } } pub struct CollabBuilderConfig { pub sync_enable: bool, - /// If auto_initialize is false, the collab object will not be initialized automatically. - /// You need to call collab.initialize() manually. - /// - /// Default is true. - pub auto_initialize: bool, } impl Default for CollabBuilderConfig { fn default() -> Self { - Self { - sync_enable: true, - auto_initialize: true, - } + Self { sync_enable: true } } } @@ -322,9 +399,85 @@ impl CollabBuilderConfig { self.sync_enable = sync_enable; self } +} - pub fn auto_initialize(mut self, auto_initialize: bool) -> Self { - self.auto_initialize = auto_initialize; - self +pub struct CollabPersistenceImpl { + pub db: Weak, + pub uid: i64, + pub workspace_id: Uuid, +} + +impl CollabPersistenceImpl { + pub fn new(db: Weak, uid: i64, workspace_id: Uuid) -> Self { + Self { + db, + uid, + workspace_id, + } + } + + pub fn into_data_source(self) -> DataSource { + DataSource::Disk(Some(Box::new(self))) + } +} + +impl CollabPersistence for CollabPersistenceImpl { + fn load_collab_from_disk(&self, collab: &mut Collab) -> Result<(), CollabError> { + let collab_db = self + .db + .upgrade() + .ok_or_else(|| CollabError::Internal(anyhow!("collab_db is dropped")))?; + + let object_id = collab.object_id().to_string(); + let rocksdb_read = collab_db.read_txn(); + let workspace_id = self.workspace_id.to_string(); + + if rocksdb_read.is_exist(self.uid, &workspace_id, &object_id) { + let mut txn = collab.transact_mut(); + match rocksdb_read.load_doc_with_txn(self.uid, &workspace_id, &object_id, &mut txn) { + Ok(update_count) => { + trace!( + "did load collab:{}-{} from disk, update_count:{}", + self.uid, + object_id, + update_count + ); + }, + Err(err) => { + error!("🔴 load doc:{} failed: {}", object_id, err); + }, + } + drop(rocksdb_read); + txn.commit(); + drop(txn); + } + Ok(()) + } + + fn save_collab_to_disk( + &self, + object_id: &str, + encoded_collab: EncodedCollab, + ) -> Result<(), CollabError> { + let workspace_id = self.workspace_id.to_string(); + let collab_db = self + .db + .upgrade() + .ok_or_else(|| CollabError::Internal(anyhow!("collab_db is dropped")))?; + let write_txn = collab_db.write_txn(); + write_txn + .flush_doc( + self.uid, + workspace_id.as_str(), + object_id, + encoded_collab.state_vector.to_vec(), + encoded_collab.doc_state.to_vec(), + ) + .map_err(|err| CollabError::Internal(err.into()))?; + + write_txn + .commit_transaction() + .map_err(|err| CollabError::Internal(err.into()))?; + Ok(()) } } diff --git a/frontend/rust-lib/collab-integrate/src/lib.rs b/frontend/rust-lib/collab-integrate/src/lib.rs index a7df75d72e..afa6f0c2a8 100644 --- a/frontend/rust-lib/collab-integrate/src/lib.rs +++ b/frontend/rust-lib/collab-integrate/src/lib.rs @@ -1,25 +1,11 @@ -pub use collab::core::collab::MutexCollab; pub use collab::preclude::Snapshot; pub use collab_plugins::local_storage::CollabPersistenceConfig; pub use collab_plugins::CollabKVDB; -use collab_plugins::{if_native, if_wasm}; pub mod collab_builder; pub mod config; - -if_native! { - mod native; - mod plugin_provider { - pub use crate::native::plugin_provider::*; - } -} - -if_wasm! { - mod wasm; - mod plugin_provider { - pub use crate::wasm::plugin_provider::*; - } -} +pub mod persistence; +mod plugin_provider; pub use collab_plugins::local_storage::kv::doc::CollabKVAction; pub use collab_plugins::local_storage::kv::error::PersistenceError; diff --git a/frontend/rust-lib/collab-integrate/src/native/mod.rs b/frontend/rust-lib/collab-integrate/src/native/mod.rs deleted file mode 100644 index 59b6b263d5..0000000000 --- a/frontend/rust-lib/collab-integrate/src/native/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod plugin_provider; diff --git a/frontend/rust-lib/collab-integrate/src/native/plugin_provider.rs b/frontend/rust-lib/collab-integrate/src/native/plugin_provider.rs deleted file mode 100644 index a26fb8d933..0000000000 --- a/frontend/rust-lib/collab-integrate/src/native/plugin_provider.rs +++ /dev/null @@ -1,56 +0,0 @@ -use crate::collab_builder::{CollabPluginProviderContext, CollabPluginProviderType}; -use collab::preclude::CollabPlugin; - -#[cfg(target_arch = "wasm32")] -pub trait CollabCloudPluginProvider: 'static { - fn provider_type(&self) -> CollabPluginProviderType; - - fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec>; - - fn is_sync_enabled(&self) -> bool; -} - -#[cfg(target_arch = "wasm32")] -impl CollabCloudPluginProvider for std::rc::Rc -where - T: CollabCloudPluginProvider, -{ - fn provider_type(&self) -> CollabPluginProviderType { - (**self).provider_type() - } - - fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { - (**self).get_plugins(context) - } - - fn is_sync_enabled(&self) -> bool { - (**self).is_sync_enabled() - } -} - -#[cfg(not(target_arch = "wasm32"))] -pub trait CollabCloudPluginProvider: Send + Sync + 'static { - fn provider_type(&self) -> CollabPluginProviderType; - - fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec>; - - fn is_sync_enabled(&self) -> bool; -} - -#[cfg(not(target_arch = "wasm32"))] -impl CollabCloudPluginProvider for std::sync::Arc -where - T: CollabCloudPluginProvider, -{ - fn provider_type(&self) -> CollabPluginProviderType { - (**self).provider_type() - } - - fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { - (**self).get_plugins(context) - } - - fn is_sync_enabled(&self) -> bool { - (**self).is_sync_enabled() - } -} diff --git a/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs b/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs new file mode 100644 index 0000000000..adb8b72de1 --- /dev/null +++ b/frontend/rust-lib/collab-integrate/src/persistence/collab_metadata_sql.rs @@ -0,0 +1,62 @@ +use diesel::upsert::excluded; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::{ + diesel, insert_into, + query_dsl::*, + schema::{af_collab_metadata, af_collab_metadata::dsl}, + DBConnection, ExpressionMethods, Identifiable, Insertable, Queryable, +}; +use std::collections::HashMap; +use std::str::FromStr; +use uuid::Uuid; + +#[derive(Queryable, Insertable, Identifiable)] +#[diesel(table_name = af_collab_metadata)] +#[diesel(primary_key(object_id))] +pub struct AFCollabMetadata { + pub object_id: String, + pub updated_at: i64, + pub prev_sync_state_vector: Vec, + pub collab_type: i32, +} + +pub fn batch_insert_collab_metadata( + mut conn: DBConnection, + new_metadata: &[AFCollabMetadata], +) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + for metadata in new_metadata { + let _ = insert_into(af_collab_metadata::table) + .values(metadata) + .on_conflict(af_collab_metadata::object_id) + .do_update() + .set(( + af_collab_metadata::updated_at.eq(excluded(af_collab_metadata::updated_at)), + af_collab_metadata::prev_sync_state_vector + .eq(excluded(af_collab_metadata::prev_sync_state_vector)), + )) + .execute(conn)?; + } + Ok::<(), FlowyError>(()) + })?; + + Ok(()) +} + +pub fn batch_select_collab_metadata( + mut conn: DBConnection, + object_ids: &[Uuid], +) -> FlowyResult> { + let object_ids = object_ids + .iter() + .map(|id| id.to_string()) + .collect::>(); + + let metadata = dsl::af_collab_metadata + .filter(af_collab_metadata::object_id.eq_any(&object_ids)) + .load::(&mut conn)? + .into_iter() + .flat_map(|m| Uuid::from_str(&m.object_id).map(|v| (v, m))) + .collect(); + Ok(metadata) +} diff --git a/frontend/rust-lib/collab-integrate/src/persistence/mod.rs b/frontend/rust-lib/collab-integrate/src/persistence/mod.rs new file mode 100644 index 0000000000..dc0eb77c28 --- /dev/null +++ b/frontend/rust-lib/collab-integrate/src/persistence/mod.rs @@ -0,0 +1 @@ +pub mod collab_metadata_sql; diff --git a/frontend/rust-lib/collab-integrate/src/plugin_provider.rs b/frontend/rust-lib/collab-integrate/src/plugin_provider.rs new file mode 100644 index 0000000000..fcbcbbe9ee --- /dev/null +++ b/frontend/rust-lib/collab-integrate/src/plugin_provider.rs @@ -0,0 +1,28 @@ +use collab::preclude::CollabPlugin; + +use crate::collab_builder::{CollabPluginProviderContext, CollabPluginProviderType}; + +pub trait CollabCloudPluginProvider: Send + Sync + 'static { + fn provider_type(&self) -> CollabPluginProviderType; + + fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec>; + + fn is_sync_enabled(&self) -> bool; +} + +impl CollabCloudPluginProvider for std::sync::Arc +where + U: CollabCloudPluginProvider, +{ + fn provider_type(&self) -> CollabPluginProviderType { + (**self).provider_type() + } + + fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { + (**self).get_plugins(context) + } + + fn is_sync_enabled(&self) -> bool { + (**self).is_sync_enabled() + } +} diff --git a/frontend/rust-lib/collab-integrate/src/wasm/mod.rs b/frontend/rust-lib/collab-integrate/src/wasm/mod.rs deleted file mode 100644 index 59b6b263d5..0000000000 --- a/frontend/rust-lib/collab-integrate/src/wasm/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod plugin_provider; diff --git a/frontend/rust-lib/collab-integrate/src/wasm/plugin_provider.rs b/frontend/rust-lib/collab-integrate/src/wasm/plugin_provider.rs deleted file mode 100644 index 545e6c461c..0000000000 --- a/frontend/rust-lib/collab-integrate/src/wasm/plugin_provider.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::collab_builder::{CollabPluginProviderContext, CollabPluginProviderType}; -use collab::preclude::CollabPlugin; -use lib_infra::future::Fut; -use std::rc::Rc; - -pub trait CollabCloudPluginProvider: 'static { - fn provider_type(&self) -> CollabPluginProviderType; - - fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec>; - - fn is_sync_enabled(&self) -> bool; -} - -impl CollabCloudPluginProvider for Rc -where - T: CollabCloudPluginProvider, -{ - fn provider_type(&self) -> CollabPluginProviderType { - (**self).provider_type() - } - - fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { - (**self).get_plugins(context) - } - - fn is_sync_enabled(&self) -> bool { - (**self).is_sync_enabled() - } -} diff --git a/frontend/rust-lib/dart-ffi/Cargo.toml b/frontend/rust-lib/dart-ffi/Cargo.toml index 4e5148da95..969f64e6f9 100644 --- a/frontend/rust-lib/dart-ffi/Cargo.toml +++ b/frontend/rust-lib/dart-ffi/Cargo.toml @@ -22,21 +22,20 @@ serde_json.workspace = true bytes.workspace = true crossbeam-utils = "0.8.15" lazy_static = "1.4.0" -parking_lot.workspace = true tracing.workspace = true lib-log.workspace = true +semver = "1.0.22" # workspace -lib-dispatch = { workspace = true } +lib-dispatch = { workspace = true, features = ["local_set"] } # Core #flowy-core = { workspace = true, features = ["profiling"] } -flowy-core = { workspace = true, features = ["verbose_log"] } -#flowy-core = { workspace = true } +#flowy-core = { workspace = true, features = ["verbose_log"] } +flowy-core = { workspace = true } flowy-notification = { workspace = true, features = ["dart"] } flowy-document = { workspace = true, features = ["dart"] } -flowy-config = { workspace = true, features = ["dart"] } flowy-user = { workspace = true, features = ["dart"] } flowy-date = { workspace = true, features = ["dart"] } flowy-server = { workspace = true } @@ -45,6 +44,7 @@ collab-integrate = { workspace = true } flowy-derive.workspace = true serde_yaml = "0.9.27" flowy-error = { workspace = true, features = ["impl_from_sqlite", "impl_from_dispatch_error", "impl_from_appflowy_cloud", "impl_from_reqwest", "impl_from_serde", "dart"] } +futures = "0.3.31" [features] default = ["dart"] diff --git a/frontend/rust-lib/dart-ffi/src/env_serde.rs b/frontend/rust-lib/dart-ffi/src/env_serde.rs index db443a78f7..a59b28361f 100644 --- a/frontend/rust-lib/dart-ffi/src/env_serde.rs +++ b/frontend/rust-lib/dart-ffi/src/env_serde.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use serde::Deserialize; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; -use flowy_server_pub::supabase_config::SupabaseConfiguration; use flowy_server_pub::AuthenticatorType; #[derive(Deserialize, Debug)] @@ -17,7 +16,6 @@ pub struct AppFlowyDartConfiguration { pub device_id: String, pub platform: String, pub authenticator_type: AuthenticatorType, - pub(crate) supabase_config: SupabaseConfiguration, pub(crate) appflowy_cloud_config: AFCloudConfiguration, #[serde(default)] pub(crate) envs: HashMap, @@ -31,7 +29,6 @@ impl AppFlowyDartConfiguration { pub fn write_env(&self) { self.authenticator_type.write_env(); self.appflowy_cloud_config.write_env(); - self.supabase_config.write_env(); for (k, v) in self.envs.iter() { std::env::set_var(k, v); diff --git a/frontend/rust-lib/dart-ffi/src/lib.rs b/frontend/rust-lib/dart-ffi/src/lib.rs index 48f3e485a2..6c3d08ae01 100644 --- a/frontend/rust-lib/dart-ffi/src/lib.rs +++ b/frontend/rust-lib/dart-ffi/src/lib.rs @@ -1,11 +1,16 @@ #![allow(clippy::not_unsafe_ptr_arg_deref)] use allo_isolate::Isolate; -use std::sync::Arc; -use std::{ffi::CStr, os::raw::c_char}; - +use futures::ready; use lazy_static::lazy_static; -use parking_lot::Mutex; +use semver::Version; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, RwLock}; +use std::task::{Context, Poll}; +use std::{ffi::CStr, os::raw::c_char}; +use tokio::sync::mpsc; +use tokio::task::LocalSet; use tracing::{debug, error, info, trace, warn}; use flowy_core::config::AppFlowyCoreConfig; @@ -33,33 +38,77 @@ mod notification; mod protobuf; lazy_static! { - static ref APPFLOWY_CORE: MutexAppFlowyCore = MutexAppFlowyCore::new(); - static ref LOG_STREAM_ISOLATE: Mutex> = Mutex::new(None); + static ref DART_APPFLOWY_CORE: DartAppFlowyCore = DartAppFlowyCore::new(); + static ref LOG_STREAM_ISOLATE: RwLock> = RwLock::new(None); } -struct MutexAppFlowyCore(Arc>>); +pub struct Task { + dispatcher: Arc, + request: AFPluginRequest, + port: i64, + ret: Option>, +} -impl MutexAppFlowyCore { +unsafe impl Send for Task {} +unsafe impl Sync for DartAppFlowyCore {} + +struct DartAppFlowyCore { + core: Arc>>, + handle: RwLock>>, + sender: RwLock>>, +} + +impl DartAppFlowyCore { fn new() -> Self { - Self(Arc::new(Mutex::new(None))) + Self { + #[allow(clippy::arc_with_non_send_sync)] + core: Arc::new(RwLock::new(None)), + handle: RwLock::new(None), + sender: RwLock::new(None), + } } fn dispatcher(&self) -> Option> { - let binding = self.0.lock(); + let binding = self + .core + .read() + .expect("Failed to acquire read lock for core"); let core = binding.as_ref(); core.map(|core| core.event_dispatcher.clone()) } -} -unsafe impl Sync for MutexAppFlowyCore {} -unsafe impl Send for MutexAppFlowyCore {} + fn dispatch( + &self, + request: AFPluginRequest, + port: i64, + ret: Option>, + ) { + if let Ok(sender_guard) = self.sender.read() { + if let Err(e) = sender_guard.as_ref().unwrap().send(Task { + dispatcher: self.dispatcher().unwrap(), + request, + port, + ret, + }) { + error!("Failed to send task: {}", e); + } + } else { + warn!("Failed to acquire read lock for sender"); + } + } +} #[no_mangle] pub extern "C" fn init_sdk(_port: i64, data: *mut c_char) -> i64 { - // and sent it the `Rust's` result - // no need to convert anything :) - let c_str = unsafe { CStr::from_ptr(data) }; - let serde_str = c_str.to_str().unwrap(); + let c_str = unsafe { + if data.is_null() { + return -1; + } + CStr::from_ptr(data) + }; + let serde_str = c_str + .to_str() + .expect("Failed to convert C string to Rust string"); let configuration = AppFlowyDartConfiguration::from_str(serde_str); configuration.write_env(); @@ -67,8 +116,16 @@ pub extern "C" fn init_sdk(_port: i64, data: *mut c_char) -> i64 { let _ = save_appflowy_cloud_config(&configuration.root, &configuration.appflowy_cloud_config); } + let mut app_version = + Version::parse(&configuration.app_version).unwrap_or_else(|_| Version::new(0, 5, 8)); + + let min_version = Version::new(0, 5, 8); + if app_version < min_version { + app_version = min_version; + } + let config = AppFlowyCoreConfig::new( - configuration.app_version, + app_version, configuration.custom_app_path, configuration.origin_app_path, configuration.device_id, @@ -76,25 +133,28 @@ pub extern "C" fn init_sdk(_port: i64, data: *mut c_char) -> i64 { DEFAULT_NAME.to_string(), ); - // Ensure that the database is closed before initialization. Also, verify that the init_sdk function can be called - // multiple times (is reentrant). Currently, only the database resource is exclusive. - if let Some(core) = &*APPFLOWY_CORE.0.lock() { + if let Some(core) = &*DART_APPFLOWY_CORE.core.write().unwrap() { core.close_db(); } - let runtime = Arc::new(AFPluginRuntime::new().unwrap()); - let cloned_runtime = runtime.clone(); - let log_stream = LOG_STREAM_ISOLATE - .lock() + .write() + .unwrap() .take() .map(|isolate| Arc::new(LogStreamSenderImpl { isolate }) as Arc); - - // let isolate = allo_isolate::Isolate::new(port); - *APPFLOWY_CORE.0.lock() = runtime.block_on(async move { - Some(AppFlowyCore::new(config, cloned_runtime, log_stream).await) - // isolate.post("".to_string()); + let (sender, task_rx) = mpsc::unbounded_channel::(); + let runtime = Arc::new(AFPluginRuntime::new().unwrap()); + let cloned_runtime = runtime.clone(); + let handle = std::thread::spawn(move || { + let local_set = LocalSet::new(); + cloned_runtime.block_on(local_set.run_until(Runner { rx: task_rx })); }); + + *DART_APPFLOWY_CORE.sender.write().unwrap() = Some(sender); + *DART_APPFLOWY_CORE.handle.write().unwrap() = Some(handle); + let cloned_runtime = runtime.clone(); + *DART_APPFLOWY_CORE.core.write().unwrap() = runtime + .block_on(async move { Some(AppFlowyCore::new(config, cloned_runtime, log_stream).await) }); 0 } @@ -102,7 +162,7 @@ pub extern "C" fn init_sdk(_port: i64, data: *mut c_char) -> i64 { #[allow(clippy::let_underscore_future)] pub extern "C" fn async_event(port: i64, input: *const u8, len: usize) { let request: AFPluginRequest = FFIRequest::from_u8_pointer(input, len).into(); - #[cfg(feature = "sync_verbose_log")] + #[cfg(feature = "verbose_log")] trace!( "[FFI]: {} Async Event: {:?} with {} port", &request.id, @@ -110,40 +170,55 @@ pub extern "C" fn async_event(port: i64, input: *const u8, len: usize) { port ); - let dispatcher = match APPFLOWY_CORE.dispatcher() { - None => { - error!("sdk not init yet."); - return; - }, - Some(dispatcher) => dispatcher, - }; - AFPluginDispatcher::boxed_async_send_with_callback( - dispatcher.as_ref(), - request, - move |resp: AFPluginEventResponse| { - #[cfg(feature = "sync_verbose_log")] - trace!("[FFI]: Post data to dart through {} port", port); - Box::pin(post_to_flutter(resp, port)) - }, - ); + DART_APPFLOWY_CORE.dispatch(request, port, None); +} + +/// A persistent future that processes [Arbiter] commands. +struct Runner { + rx: mpsc::UnboundedReceiver, +} + +impl Future for Runner { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + loop { + match ready!(self.rx.poll_recv(cx)) { + None => return Poll::Ready(()), + Some(task) => { + let Task { + dispatcher, + request, + port, + ret, + } = task; + + tokio::task::spawn_local(async move { + let resp = AFPluginDispatcher::boxed_async_send_with_callback( + dispatcher.as_ref(), + request, + move |resp: AFPluginEventResponse| { + #[cfg(feature = "verbose_log")] + trace!("[FFI]: Post data to dart through {} port", port); + Box::pin(post_to_flutter(resp, port)) + }, + ) + .await; + + if let Some(ret) = ret { + let _ = ret.send(resp).await; + } + }); + }, + } + } + } } #[no_mangle] -pub extern "C" fn sync_event(input: *const u8, len: usize) -> *const u8 { - let request: AFPluginRequest = FFIRequest::from_u8_pointer(input, len).into(); - #[cfg(feature = "sync_verbose_log")] - trace!("[FFI]: {} Sync Event: {:?}", &request.id, &request.event,); +pub extern "C" fn sync_event(_input: *const u8, _len: usize) -> *const u8 { + error!("unimplemented sync_event"); - let dispatcher = match APPFLOWY_CORE.dispatcher() { - None => { - error!("sdk not init yet."); - return forget_rust(Vec::default()); - }, - Some(dispatcher) => dispatcher, - }; - let _response = AFPluginDispatcher::sync_send(dispatcher, request); - - // FFIResponse { } let response_bytes = vec![]; let result = extend_front_four_bytes_into_bytes(&response_bytes); forget_rust(result) @@ -151,7 +226,6 @@ pub extern "C" fn sync_event(input: *const u8, len: usize) -> *const u8 { #[no_mangle] pub extern "C" fn set_stream_port(notification_port: i64) -> i32 { - // Make sure hot reload won't register the notification sender twice unregister_all_notification_sender(); register_notification_sender(DartNotificationSender::new(notification_port)); 0 @@ -159,14 +233,7 @@ pub extern "C" fn set_stream_port(notification_port: i64) -> i32 { #[no_mangle] pub extern "C" fn set_log_stream_port(port: i64) -> i32 { - *LOG_STREAM_ISOLATE.lock() = Some(Isolate::new(port)); - - LOG_STREAM_ISOLATE - .lock() - .as_ref() - .unwrap() - .post("hello log".to_string().as_bytes().to_vec()); - + *LOG_STREAM_ISOLATE.write().unwrap() = Some(Isolate::new(port)); 0 } @@ -175,9 +242,9 @@ pub extern "C" fn set_log_stream_port(port: i64) -> i32 { pub extern "C" fn link_me_please() {} #[inline(always)] +#[allow(clippy::blocks_in_conditions)] async fn post_to_flutter(response: AFPluginEventResponse, port: i64) { let isolate = allo_isolate::Isolate::new(port); - #[allow(clippy::blocks_in_conditions)] match isolate .catch_unwind(async { let ffi_resp = FFIResponse::from(response); @@ -185,23 +252,18 @@ async fn post_to_flutter(response: AFPluginEventResponse, port: i64) { }) .await { - Ok(_success) => { - #[cfg(feature = "sync_verbose_log")] + Ok(_) => { + #[cfg(feature = "verbose_log")] trace!("[FFI]: Post data to dart success"); }, - Err(e) => { - if let Some(msg) = e.downcast_ref::<&str>() { - error!("[FFI]: {:?}", msg); - } else { - error!("[FFI]: allo_isolate post panic"); - } + Err(err) => { + error!("[FFI]: allo_isolate post failed: {:?}", err); }, } } #[no_mangle] pub extern "C" fn rust_log(level: i64, data: *const c_char) { - // Check if the data pointer is not null if data.is_null() { error!("[flutter error]: null pointer provided to backend_log"); return; @@ -209,7 +271,6 @@ pub extern "C" fn rust_log(level: i64, data: *const c_char) { let log_result = unsafe { CStr::from_ptr(data) }.to_str(); - // Handle potential UTF-8 conversion error let log_str = match log_result { Ok(str) => str, Err(e) => { @@ -221,29 +282,13 @@ pub extern "C" fn rust_log(level: i64, data: *const c_char) { }, }; - // Simplify logging by determining the log level outside of the match - let log_level = match level { - 0 => "info", - 1 => "debug", - 2 => "trace", - 3 => "warn", - 4 => "error", - _ => { - warn!("[flutter error]: Unsupported log level: {}", level); - return; - }, - }; - - // Log the message at the appropriate level - match log_level { - "info" => info!("[Flutter]: {}", log_str), - "debug" => debug!("[Flutter]: {}", log_str), - "trace" => trace!("[Flutter]: {}", log_str), - "warn" => warn!("[Flutter]: {}", log_str), - "error" => error!("[Flutter]: {}", log_str), - _ => { - warn!("[flutter error]: Unsupported log level: {}", log_level); - }, + match level { + 0 => info!("[Flutter]: {}", log_str), + 1 => debug!("[Flutter]: {}", log_str), + 2 => trace!("[Flutter]: {}", log_str), + 3 => warn!("[Flutter]: {}", log_str), + 4 => error!("[Flutter]: {}", log_str), + _ => warn!("[flutter error]: Unsupported log level: {}", level), } } diff --git a/frontend/rust-lib/event-integration-test/Cargo.toml b/frontend/rust-lib/event-integration-test/Cargo.toml index 26d561e993..6b2d5af7ba 100644 --- a/frontend/rust-lib/event-integration-test/Cargo.toml +++ b/frontend/rust-lib/event-integration-test/Cargo.toml @@ -12,49 +12,42 @@ flowy-user-pub = { workspace = true } flowy-folder = { path = "../flowy-folder", features = ["test_helper"] } flowy-folder-pub = { workspace = true } flowy-database2 = { path = "../flowy-database2" } -flowy-database-pub = { workspace = true } flowy-document = { path = "../flowy-document" } -flowy-document-pub = { workspace = true } -flowy-encrypt = { workspace = true } +flowy-ai = { workspace = true } lib-dispatch = { workspace = true } lib-infra = { workspace = true } flowy-server = { path = "../flowy-server" } flowy-server-pub = { workspace = true } flowy-notification = { workspace = true } -anyhow.workspace = true flowy-storage = { workspace = true } +flowy-storage-pub = { workspace = true } flowy-search = { workspace = true } +semver = "1.0.23" serde.workspace = true serde_json.workspace = true protobuf.workspace = true tokio = { workspace = true, features = ["full"] } -futures-util = "0.3.26" -thread-id = "3.3.0" bytes.workspace = true nanoid = "0.4.0" tracing.workspace = true -parking_lot.workspace = true uuid.workspace = true collab = { workspace = true } collab-document = { workspace = true } collab-folder = { workspace = true } collab-database = { workspace = true } -collab-plugins = { workspace = true } collab-entity = { workspace = true } rand = { version = "0.8.5", features = [] } strum = "0.25.0" [dev-dependencies] -dotenv = "0.15.0" -tempdir = "0.3.7" uuid.workspace = true assert-json-diff = "2.0.2" -tokio-postgres = { version = "0.7.8" } chrono = "0.4.31" -zip = "0.6.6" +zip.workspace = true walkdir = "2.5.0" -futures = "0.3.30" +futures = "0.3.31" +flowy-ai-pub = { workspace = true } [features] default = ["supabase_cloud_test"] diff --git a/frontend/rust-lib/event-integration-test/src/chat_event.rs b/frontend/rust-lib/event-integration-test/src/chat_event.rs new file mode 100644 index 0000000000..c8c638bc85 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/src/chat_event.rs @@ -0,0 +1,90 @@ +use crate::event_builder::EventBuilder; +use crate::EventIntegrationTest; +use flowy_ai::entities::{ + ChatMessageListPB, ChatMessageTypePB, LoadNextChatMessagePB, LoadPrevChatMessagePB, + SendChatPayloadPB, +}; +use flowy_ai::event_map::AIEvent; +use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; +use flowy_folder::event_map::FolderEvent; + +impl EventIntegrationTest { + pub async fn create_chat(&self, parent_id: &str) -> ViewPB { + let payload = CreateViewPayloadPB { + parent_view_id: parent_id.to_string(), + name: "chat".to_string(), + thumbnail: None, + layout: ViewLayoutPB::Chat, + initial_data: vec![], + meta: Default::default(), + set_as_current: true, + index: None, + section: None, + view_id: None, + extra: None, + }; + EventBuilder::new(self.clone()) + .event(FolderEvent::CreateView) + .payload(payload) + .async_send() + .await + .parse::() + } + + pub async fn send_message( + &self, + chat_id: &str, + message: impl ToString, + message_type: ChatMessageTypePB, + ) { + let payload = SendChatPayloadPB { + chat_id: chat_id.to_string(), + message: message.to_string(), + message_type, + }; + + EventBuilder::new(self.clone()) + .event(AIEvent::StreamMessage) + .payload(payload) + .async_send() + .await; + } + + pub async fn load_prev_message( + &self, + chat_id: &str, + limit: i64, + before_message_id: Option, + ) -> ChatMessageListPB { + let payload = LoadPrevChatMessagePB { + chat_id: chat_id.to_string(), + limit, + before_message_id, + }; + EventBuilder::new(self.clone()) + .event(AIEvent::LoadPrevMessage) + .payload(payload) + .async_send() + .await + .parse::() + } + + pub async fn load_next_message( + &self, + chat_id: &str, + limit: i64, + after_message_id: Option, + ) -> ChatMessageListPB { + let payload = LoadNextChatMessagePB { + chat_id: chat_id.to_string(), + limit, + after_message_id, + }; + EventBuilder::new(self.clone()) + .event(AIEvent::LoadNextMessage) + .payload(payload) + .async_send() + .await + .parse::() + } +} diff --git a/frontend/rust-lib/event-integration-test/src/database_event.rs b/frontend/rust-lib/event-integration-test/src/database_event.rs index 30104ab711..fa195863a1 100644 --- a/frontend/rust-lib/event-integration-test/src/database_event.rs +++ b/frontend/rust-lib/event-integration-test/src/database_event.rs @@ -3,14 +3,15 @@ use std::convert::TryFrom; use bytes::Bytes; use collab_database::database::timestamp; +use collab_database::fields::select_type_option::{ + MultiSelectTypeOption, SelectOption, SingleSelectTypeOption, +}; use collab_database::fields::Field; use collab_database::rows::{Row, RowId}; use flowy_database2::entities::*; use flowy_database2::event_map::DatabaseEvent; use flowy_database2::services::cell::CellBuilder; -use flowy_database2::services::field::{ - MultiSelectTypeOption, SelectOption, SingleSelectTypeOption, -}; +use flowy_database2::services::field::checklist_filter::ChecklistCellInsertChangeset; use flowy_database2::services::share::csv::CSVFormat; use flowy_folder::entities::*; use flowy_folder::event_map::FolderEvent; @@ -24,7 +25,7 @@ impl EventIntegrationTest { self .appflowy_core .database_manager - .get_database_with_view_id(database_view_id) + .get_database_editor_with_view_id(database_view_id) .await .unwrap() .export_csv(CSVFormat::Original) @@ -37,7 +38,6 @@ impl EventIntegrationTest { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name, - desc: "".to_string(), thumbnail: None, layout: ViewLayoutPB::Grid, initial_data, @@ -45,6 +45,8 @@ impl EventIntegrationTest { set_as_current: true, index: None, section: None, + view_id: None, + extra: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -54,21 +56,21 @@ impl EventIntegrationTest { .parse::() } - pub async fn open_database(&self, view_id: &str) { + pub async fn open_database(&self, view_id: &str) -> DatabasePB { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetDatabase) .payload(DatabaseViewIdPB { value: view_id.to_string(), }) .async_send() - .await; + .await + .parse::() } pub async fn create_board(&self, parent_id: &str, name: String, initial_data: Vec) -> ViewPB { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name, - desc: "".to_string(), thumbnail: None, layout: ViewLayoutPB::Board, initial_data, @@ -76,6 +78,8 @@ impl EventIntegrationTest { set_as_current: true, index: None, section: None, + view_id: None, + extra: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -94,7 +98,6 @@ impl EventIntegrationTest { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name, - desc: "".to_string(), thumbnail: None, layout: ViewLayoutPB::Calendar, initial_data, @@ -102,6 +105,8 @@ impl EventIntegrationTest { set_as_current: true, index: None, section: None, + view_id: None, + extra: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -167,6 +172,41 @@ impl EventIntegrationTest { .error() } + pub async fn remove_calculate( + &self, + changeset: RemoveCalculationChangesetPB, + ) -> Option { + EventBuilder::new(self.clone()) + .event(DatabaseEvent::RemoveCalculation) + .payload(changeset) + .async_send() + .await + .error() + } + + pub async fn get_all_calculations(&self, database_view_id: &str) -> RepeatedCalculationsPB { + EventBuilder::new(self.clone()) + .event(DatabaseEvent::GetAllCalculations) + .payload(DatabaseViewIdPB { + value: database_view_id.to_string(), + }) + .async_send() + .await + .parse::() + } + + pub async fn update_calculation( + &self, + changeset: UpdateCalculationChangesetPB, + ) -> Option { + EventBuilder::new(self.clone()) + .event(DatabaseEvent::UpdateCalculation) + .payload(changeset) + .async_send() + .await + .error() + } + pub async fn update_field_type( &self, view_id: &str, @@ -179,6 +219,7 @@ impl EventIntegrationTest { view_id: view_id.to_string(), field_id: field_id.to_string(), field_type, + field_name: None, }) .async_send() .await @@ -215,6 +256,14 @@ impl EventIntegrationTest { .await; } + pub async fn translate_row(&self, data: TranslateRowPB) { + EventBuilder::new(self.clone()) + .event(DatabaseEvent::TranslateRow) + .payload(data) + .async_send() + .await; + } + pub async fn create_row( &self, view_id: &str, @@ -236,11 +285,10 @@ impl EventIntegrationTest { pub async fn delete_row(&self, view_id: &str, row_id: &str) -> Option { EventBuilder::new(self.clone()) - .event(DatabaseEvent::DeleteRow) - .payload(RowIdPB { + .event(DatabaseEvent::DeleteRows) + .payload(RepeatedRowIdPB { view_id: view_id.to_string(), - row_id: row_id.to_string(), - group_id: None, + row_ids: vec![row_id.to_string()], }) .async_send() .await @@ -250,7 +298,7 @@ impl EventIntegrationTest { pub async fn get_row(&self, view_id: &str, row_id: &str) -> OptionalRowPB { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetRow) - .payload(RowIdPB { + .payload(DatabaseViewRowIdPB { view_id: view_id.to_string(), row_id: row_id.to_string(), group_id: None, @@ -263,7 +311,7 @@ impl EventIntegrationTest { pub async fn get_row_meta(&self, view_id: &str, row_id: &str) -> RowMetaPB { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetRowMeta) - .payload(RowIdPB { + .payload(DatabaseViewRowIdPB { view_id: view_id.to_string(), row_id: row_id.to_string(), group_id: None, @@ -285,7 +333,7 @@ impl EventIntegrationTest { pub async fn duplicate_row(&self, view_id: &str, row_id: &str) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::DuplicateRow) - .payload(RowIdPB { + .payload(DatabaseViewRowIdPB { view_id: view_id.to_string(), row_id: row_id.to_string(), group_id: None, @@ -437,12 +485,18 @@ impl EventIntegrationTest { .error() } - pub async fn set_group_by_field(&self, view_id: &str, field_id: &str) -> Option { + pub async fn set_group_by_field( + &self, + view_id: &str, + field_id: &str, + setting_content: Vec, + ) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::SetGroupByField) .payload(GroupByFieldPayloadPB { field_id: field_id.to_string(), view_id: view_id.to_string(), + setting_content, }) .async_send() .await @@ -453,7 +507,6 @@ impl EventIntegrationTest { &self, view_id: &str, group_id: &str, - field_id: &str, name: Option, visible: Option, ) -> Option { @@ -462,7 +515,6 @@ impl EventIntegrationTest { .payload(UpdateGroupPB { view_id: view_id.to_string(), group_id: group_id.to_string(), - field_id: field_id.to_string(), name, visible, }) @@ -523,7 +575,7 @@ impl EventIntegrationTest { ) -> Vec { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetRelatedRowDatas) - .payload(RepeatedRowIdPB { + .payload(GetRelatedRowDataPB { database_id, row_ids, }) @@ -571,15 +623,14 @@ impl<'a> TestRowBuilder<'a> { pub fn insert_date_cell( &mut self, - date: i64, - time: Option, + timestamp: i64, include_time: Option, field_type: &FieldType, ) -> String { let date_field = self.field_with_type(field_type); self .cell_build - .insert_date_cell(&date_field.id, date, time, include_time); + .insert_date_cell(&date_field.id, timestamp, include_time); date_field.id.clone() } @@ -607,7 +658,8 @@ impl<'a> TestRowBuilder<'a> { let single_select_field = self.field_with_type(&FieldType::SingleSelect); let type_option = single_select_field .get_type_option::(FieldType::SingleSelect) - .unwrap(); + .unwrap() + .0; let option = f(type_option.options); self .cell_build @@ -623,7 +675,8 @@ impl<'a> TestRowBuilder<'a> { let multi_select_field = self.field_with_type(&FieldType::MultiSelect); let type_option = multi_select_field .get_type_option::(FieldType::MultiSelect) - .unwrap(); + .unwrap() + .0; let options = f(type_option.options); let ops_ids = options .iter() @@ -636,14 +689,26 @@ impl<'a> TestRowBuilder<'a> { multi_select_field.id.clone() } - pub fn insert_checklist_cell(&mut self, options: Vec<(String, bool)>) -> String { + pub fn insert_checklist_cell(&mut self, new_tasks: Vec) -> String { let checklist_field = self.field_with_type(&FieldType::Checklist); self .cell_build - .insert_checklist_cell(&checklist_field.id, options); + .insert_checklist_cell(&checklist_field.id, new_tasks); checklist_field.id.clone() } + pub fn insert_time_cell(&mut self, time: i64) -> String { + let time_field = self.field_with_type(&FieldType::Time); + self.cell_build.insert_number_cell(&time_field.id, time); + time_field.id.clone() + } + + pub fn insert_media_cell(&mut self, media: String) -> String { + let media_field = self.field_with_type(&FieldType::Media); + self.cell_build.insert_text_cell(&media_field.id, media); + media_field.id.clone() + } + pub fn field_with_type(&self, field_type: &FieldType) -> Field { self .fields diff --git a/frontend/rust-lib/event-integration-test/src/document/document_event.rs b/frontend/rust-lib/event-integration-test/src/document/document_event.rs index 93d3ccc80f..28fb03e9ed 100644 --- a/frontend/rust-lib/event-integration-test/src/document/document_event.rs +++ b/frontend/rust-lib/event-integration-test/src/document/document_event.rs @@ -1,8 +1,6 @@ use collab::entity::EncodedCollab; use std::collections::HashMap; -use serde_json::Value; - use flowy_document::entities::*; use flowy_document::event_map::DocumentEvent; use flowy_document::parser::parser_entities::{ @@ -11,6 +9,8 @@ use flowy_document::parser::parser_entities::{ }; use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; use flowy_folder::event_map::FolderEvent; +use serde_json::Value; +use uuid::Uuid; use crate::document::utils::{gen_delta_str, gen_id, gen_text_block_data}; use crate::event_builder::EventBuilder; @@ -37,18 +37,31 @@ impl DocumentEventTest { Self { event_test: core } } - pub async fn get_encoded_v1(&self, doc_id: &str) -> EncodedCollab { + pub async fn get_encoded_v1(&self, doc_id: &Uuid) -> EncodedCollab { let doc = self .event_test .appflowy_core .document_manager - .get_document(doc_id) + .editable_document(doc_id) .await .unwrap(); - let guard = doc.lock(); + let guard = doc.read().await; guard.encode_collab().unwrap() } + pub async fn get_encoded_collab(&self, doc_id: &str) -> EncodedCollabPB { + let core = &self.event_test; + let payload = OpenDocumentPayloadPB { + document_id: doc_id.to_string(), + }; + EventBuilder::new(core.clone()) + .event(DocumentEvent::GetDocEncodedCollab) + .payload(payload) + .async_send() + .await + .parse::() + } + pub async fn create_document(&self) -> ViewPB { let core = &self.event_test; let current_workspace = core.get_current_workspace().await; @@ -57,7 +70,6 @@ impl DocumentEventTest { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name: "document".to_string(), - desc: "".to_string(), thumbnail: None, layout: ViewLayoutPB::Document, initial_data: vec![], @@ -65,6 +77,8 @@ impl DocumentEventTest { set_as_current: true, index: None, section: None, + view_id: None, + extra: None, }; EventBuilder::new(core.clone()) .event(FolderEvent::CreateView) diff --git a/frontend/rust-lib/event-integration-test/src/document_event.rs b/frontend/rust-lib/event-integration-test/src/document_event.rs index 6f99998e50..5928c223b8 100644 --- a/frontend/rust-lib/event-integration-test/src/document_event.rs +++ b/frontend/rust-lib/event-integration-test/src/document_event.rs @@ -1,6 +1,3 @@ -use std::sync::Arc; - -use collab::core::collab::MutexCollab; use collab::core::origin::CollabOrigin; use collab::preclude::updates::decoder::Decode; use collab::preclude::{Collab, Update}; @@ -34,7 +31,6 @@ impl EventIntegrationTest { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name, - desc: "".to_string(), thumbnail: None, layout: ViewLayoutPB::Document, initial_data, @@ -42,6 +38,8 @@ impl EventIntegrationTest { set_as_current: true, index: None, section: None, + view_id: None, + extra: None, }; let view = EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -63,6 +61,7 @@ impl EventIntegrationTest { view } + pub async fn open_document(&self, doc_id: String) -> OpenDocumentData { let payload = OpenDocumentPayloadPB { document_id: doc_id.clone(), @@ -104,17 +103,13 @@ impl EventIntegrationTest { } pub fn assert_document_data_equal(doc_state: &[u8], doc_id: &str, expected: DocumentData) { - let collab = MutexCollab::new(Collab::new_with_origin( - CollabOrigin::Server, - doc_id, - vec![], - false, - )); - collab.lock().with_origin_transact_mut(|txn| { + let mut collab = Collab::new_with_origin(CollabOrigin::Server, doc_id, vec![], false); + { let update = Update::decode_v1(doc_state).unwrap(); - txn.apply_update(update); - }); - let document = Document::open(Arc::new(collab)).unwrap(); + let mut txn = collab.transact_mut(); + txn.apply_update(update).unwrap(); + }; + let document = Document::open(collab).unwrap(); let actual = document.get_document_data().unwrap(); assert_eq!(actual, expected); } diff --git a/frontend/rust-lib/event-integration-test/src/event_builder.rs b/frontend/rust-lib/event-integration-test/src/event_builder.rs index 0d083b1037..b3d4a313f0 100644 --- a/frontend/rust-lib/event-integration-test/src/event_builder.rs +++ b/frontend/rust-lib/event-integration-test/src/event_builder.rs @@ -1,26 +1,27 @@ -use std::{ - convert::TryFrom, - fmt::{Debug, Display}, - hash::Hash, - sync::Arc, -}; - +use crate::EventIntegrationTest; use flowy_user::errors::{internal_error, FlowyError}; use lib_dispatch::prelude::{ AFPluginDispatcher, AFPluginEventResponse, AFPluginFromBytes, AFPluginRequest, ToBytes, *, }; +use std::sync::Arc; +use std::{ + convert::TryFrom, + fmt::{Debug, Display}, + hash::Hash, +}; +use tokio::task::LocalSet; -use crate::EventIntegrationTest; - -#[derive(Clone)] +// #[derive(Clone)] pub struct EventBuilder { context: TestContext, + local_set: LocalSet, } impl EventBuilder { pub fn new(sdk: EventIntegrationTest) -> Self { Self { context: TestContext::new(sdk), + local_set: Default::default(), } } @@ -50,7 +51,13 @@ impl EventBuilder { pub async fn async_send(mut self) -> Self { let request = self.get_request(); - let resp = AFPluginDispatcher::async_send(self.dispatch().as_ref(), request).await; + let resp = self + .local_set + .run_until(AFPluginDispatcher::async_send( + self.dispatch().as_ref(), + request, + )) + .await; self.context.response = Some(resp); self } diff --git a/frontend/rust-lib/event-integration-test/src/folder_event.rs b/frontend/rust-lib/event-integration-test/src/folder_event.rs index 280e91e008..26515ab5af 100644 --- a/frontend/rust-lib/event-integration-test/src/folder_event.rs +++ b/frontend/rust-lib/event-integration-test/src/folder_event.rs @@ -1,3 +1,5 @@ +use flowy_folder::view_operation::{GatherEncodedCollab, ViewData}; +use std::str::FromStr; use std::sync::Arc; use collab_folder::{FolderData, View}; @@ -5,35 +7,22 @@ use flowy_folder::entities::icon::UpdateViewIconPayloadPB; use flowy_folder::event_map::FolderEvent; use flowy_folder::event_map::FolderEvent::*; use flowy_folder::{entities::*, ViewLayout}; +use flowy_folder_pub::entities::PublishPayload; use flowy_search::services::manager::{SearchHandler, SearchType}; use flowy_user::entities::{ - AcceptWorkspaceInvitationPB, AddWorkspaceMemberPB, QueryWorkspacePB, RemoveWorkspaceMemberPB, - RepeatedWorkspaceInvitationPB, RepeatedWorkspaceMemberPB, WorkspaceMemberInvitationPB, - WorkspaceMemberPB, + AcceptWorkspaceInvitationPB, QueryWorkspacePB, RemoveWorkspaceMemberPB, + RepeatedWorkspaceInvitationPB, RepeatedWorkspaceMemberPB, UserWorkspaceIdPB, UserWorkspacePB, + WorkspaceMemberInvitationPB, WorkspaceMemberPB, }; use flowy_user::errors::FlowyError; use flowy_user::event_map::UserEvent; use flowy_user_pub::entities::Role; +use uuid::Uuid; use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; impl EventIntegrationTest { - pub async fn add_workspace_member(&self, workspace_id: &str, email: &str) { - if let Some(err) = EventBuilder::new(self.clone()) - .event(UserEvent::AddWorkspaceMember) - .payload(AddWorkspaceMemberPB { - workspace_id: workspace_id.to_string(), - email: email.to_string(), - }) - .async_send() - .await - .error() - { - panic!("Add workspace member failed: {:?}", err); - } - } - pub async fn invite_workspace_member(&self, workspace_id: &str, email: &str, role: Role) { EventBuilder::new(self.clone()) .event(UserEvent::InviteWorkspaceMember) @@ -46,6 +35,26 @@ impl EventIntegrationTest { .await; } + // convenient function to add workspace member by inviting and accepting the invitation + pub async fn add_workspace_member(&self, workspace_id: &str, other: &EventIntegrationTest) { + let other_email = other.get_user_profile().await.unwrap().email; + + self + .invite_workspace_member(workspace_id, &other_email, Role::Member) + .await; + + let invitations = other.list_workspace_invitations().await; + let target_invi = invitations + .items + .into_iter() + .find(|i| i.workspace_id == workspace_id) + .unwrap(); + + other + .accept_workspace_invitation(&target_invi.invite_id) + .await; + } + pub async fn list_workspace_invitations(&self) -> RepeatedWorkspaceInvitationPB { EventBuilder::new(self.clone()) .event(UserEvent::ListWorkspaceInvitations) @@ -85,7 +94,7 @@ impl EventIntegrationTest { pub async fn get_workspace_members(&self, workspace_id: &str) -> Vec { EventBuilder::new(self.clone()) - .event(UserEvent::GetWorkspaceMember) + .event(UserEvent::GetWorkspaceMembers) .payload(QueryWorkspacePB { workspace_id: workspace_id.to_string(), }) @@ -103,6 +112,18 @@ impl EventIntegrationTest { .parse::() } + pub async fn get_user_workspace(&self, workspace_id: &str) -> UserWorkspacePB { + let payload = UserWorkspaceIdPB { + workspace_id: workspace_id.to_string(), + }; + EventBuilder::new(self.clone()) + .event(UserEvent::GetUserWorkspace) + .payload(payload) + .async_send() + .await + .parse::() + } + pub fn get_folder_search_handler(&self) -> &Arc { self .appflowy_core @@ -116,16 +137,17 @@ impl EventIntegrationTest { let create_view_params = views .into_iter() .map(|view| CreateViewParams { - parent_view_id: view.parent_view_id, + parent_view_id: Uuid::from_str(&view.parent_view_id).unwrap(), name: view.name, - desc: "".to_string(), layout: view.layout.into(), - view_id: view.id, - initial_data: vec![], + view_id: Uuid::from_str(&view.id).unwrap(), + initial_data: ViewData::Empty, meta: Default::default(), set_as_current: false, index: None, section: None, + icon: view.icon, + extra: view.extra, }) .collect::>(); @@ -133,18 +155,66 @@ impl EventIntegrationTest { self .appflowy_core .folder_manager - .create_view_with_params(params) + .create_view_with_params(params, true) .await .unwrap(); } } - pub fn get_folder_data(&self) -> FolderData { - let mutex_folder = self.appflowy_core.folder_manager.get_mutex_folder().clone(); - let folder_lock_guard = mutex_folder.read(); - let folder = folder_lock_guard.as_ref().unwrap(); - let workspace_id = self.appflowy_core.user_manager.workspace_id().unwrap(); - folder.get_folder_data(&workspace_id).clone().unwrap() + /// Create orphan views in the folder. + /// Orphan view: the parent_view_id equal to the view_id + /// Normally, the orphan view will be created in nested database + pub async fn create_orphan_view(&self, name: &str, view_id: &str, layout: ViewLayoutPB) { + let payload = CreateOrphanViewPayloadPB { + name: name.to_string(), + layout, + view_id: view_id.to_string(), + initial_data: vec![], + }; + EventBuilder::new(self.clone()) + .event(FolderEvent::CreateOrphanView) + .payload(payload) + .async_send() + .await; + } + + pub async fn get_folder_data(&self) -> FolderData { + self + .appflowy_core + .folder_manager + .get_folder_data() + .await + .unwrap() + } + + pub async fn get_publish_payload( + &self, + view_id: &str, + include_children: bool, + ) -> Vec { + let manager = self.folder_manager.clone(); + let payload = manager + .get_batch_publish_payload(view_id, None, include_children) + .await; + + if payload.is_err() { + panic!("Get publish payload failed") + } + + payload.unwrap() + } + + pub async fn gather_encode_collab_from_disk( + &self, + view_id: &str, + layout: ViewLayout, + ) -> GatherEncodedCollab { + let view_id = Uuid::from_str(view_id).unwrap(); + self + .folder_manager + .gather_publish_encode_collab(&view_id, &layout) + .await + .unwrap() } pub async fn get_all_workspace_views(&self) -> Vec { @@ -156,6 +226,16 @@ impl EventIntegrationTest { .items } + // get all the views in the current workspace, including the views in the trash and the orphan views + pub async fn get_all_views(&self) -> Vec { + EventBuilder::new(self.clone()) + .event(FolderEvent::GetAllViews) + .async_send() + .await + .parse::() + .items + } + pub async fn get_trash(&self) -> RepeatedTrashPB { EventBuilder::new(self.clone()) .event(FolderEvent::ListTrashItems) @@ -201,17 +281,29 @@ impl EventIntegrationTest { } pub async fn create_view(&self, parent_id: &str, name: String) -> ViewPB { + self + .create_view_with_layout(parent_id, name, Default::default()) + .await + } + + pub async fn create_view_with_layout( + &self, + parent_id: &str, + name: String, + layout: ViewLayoutPB, + ) -> ViewPB { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name, - desc: "".to_string(), thumbnail: None, - layout: Default::default(), + layout, initial_data: vec![], meta: Default::default(), set_as_current: false, index: None, section: None, + view_id: None, + extra: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -232,13 +324,26 @@ impl EventIntegrationTest { .parse::() } - pub async fn import_data(&self, data: ImportPB) -> ViewPB { + pub async fn import_data(&self, data: ImportPayloadPB) -> Vec { EventBuilder::new(self.clone()) .event(FolderEvent::ImportData) .payload(data) .async_send() .await - .parse::() + .parse::() + .items + } + + pub async fn get_view_ancestors(&self, view_id: &str) -> Vec { + EventBuilder::new(self.clone()) + .event(FolderEvent::GetViewAncestors) + .payload(ViewIdPB { + value: view_id.to_string(), + }) + .async_send() + .await + .parse::() + .items } } @@ -255,7 +360,6 @@ impl ViewTest { let payload = CreateViewPayloadPB { parent_view_id: workspace.id.clone(), name: "View A".to_string(), - desc: "".to_string(), thumbnail: Some("http://1.png".to_string()), layout: layout.into(), initial_data: data, @@ -263,6 +367,8 @@ impl ViewTest { set_as_current: true, index: None, section: None, + view_id: None, + extra: None, }; let view = EventBuilder::new(sdk.clone()) @@ -291,18 +397,3 @@ impl ViewTest { Self::new(sdk, ViewLayout::Calendar, data).await } } - -#[allow(dead_code)] -async fn create_workspace(sdk: &EventIntegrationTest, name: &str, desc: &str) -> WorkspacePB { - let request = CreateWorkspacePayloadPB { - name: name.to_owned(), - desc: desc.to_owned(), - }; - - EventBuilder::new(sdk.clone()) - .event(CreateFolderWorkspace) - .payload(request) - .async_send() - .await - .parse::() -} diff --git a/frontend/rust-lib/event-integration-test/src/lib.rs b/frontend/rust-lib/event-integration-test/src/lib.rs index 180623d8d5..ff0a3847df 100644 --- a/frontend/rust-lib/event-integration-test/src/lib.rs +++ b/frontend/rust-lib/event-integration-test/src/lib.rs @@ -1,28 +1,30 @@ +use crate::user_event::TestNotificationSender; use collab::core::collab::DataSource; use collab::core::origin::CollabOrigin; +use collab::preclude::Collab; use collab_document::blocks::DocumentData; use collab_document::document::Document; use collab_entity::CollabType; -use std::env::temp_dir; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use nanoid::nanoid; -use parking_lot::RwLock; -use tokio::select; -use tokio::time::sleep; - use flowy_core::config::AppFlowyCoreConfig; use flowy_core::AppFlowyCore; use flowy_notification::register_notification_sender; -use flowy_server::AppFlowyServer; -use flowy_user::entities::AuthenticatorPB; +use flowy_user::entities::AuthTypePB; use flowy_user::errors::FlowyError; use lib_dispatch::runtime::AFPluginRuntime; +use nanoid::nanoid; +use semver::Version; +use std::env::temp_dir; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::select; +use tokio::task::LocalSet; +use tokio::time::sleep; +use uuid::Uuid; -use crate::user_event::TestNotificationSender; - +mod chat_event; pub mod database_event; pub mod document; pub mod document_event; @@ -32,13 +34,16 @@ pub mod user_event; #[derive(Clone)] pub struct EventIntegrationTest { - pub authenticator: Arc>, + pub authenticator: Arc, pub appflowy_core: AppFlowyCore, #[allow(dead_code)] cleaner: Arc, pub notification_sender: TestNotificationSender, + local_set: Arc, } +pub const SINGLE_FILE_UPLOAD_SIZE: usize = 15 * 1024 * 1024; + impl EventIntegrationTest { pub async fn new() -> Self { Self::new_with_name(nanoid!(6)).await @@ -50,12 +55,30 @@ impl EventIntegrationTest { Self::new_with_user_data_path(temp_dir, name.to_string()).await } + pub async fn new_with_config(config: AppFlowyCoreConfig) -> Self { + let clean_path = config.storage_path.clone(); + let inner = init_core(config).await; + let notification_sender = TestNotificationSender::new(); + let authenticator = Arc::new(AtomicU8::new(AuthTypePB::Local as u8)); + register_notification_sender(notification_sender.clone()); + + // In case of dropping the runtime that runs the core, we need to forget the dispatcher + std::mem::forget(inner.dispatcher()); + Self { + appflowy_core: inner, + authenticator, + notification_sender, + cleaner: Arc::new(Cleaner::new(PathBuf::from(clean_path))), + #[allow(clippy::arc_with_non_send_sync)] + local_set: Arc::new(Default::default()), + } + } + pub async fn new_with_user_data_path(path_buf: PathBuf, name: String) -> Self { let path = path_buf.to_str().unwrap().to_string(); let device_id = uuid::Uuid::new_v4().to_string(); - - let config = AppFlowyCoreConfig::new( - "".to_string(), + let mut config = AppFlowyCoreConfig::new( + Version::new(0, 7, 0), path.clone(), path, device_id, @@ -71,19 +94,14 @@ impl EventIntegrationTest { ], ); - let inner = init_core(config).await; - let notification_sender = TestNotificationSender::new(); - let authenticator = Arc::new(RwLock::new(AuthenticatorPB::Local)); - register_notification_sender(notification_sender.clone()); - - // In case of dropping the runtime that runs the core, we need to forget the dispatcher - std::mem::forget(inner.dispatcher()); - Self { - appflowy_core: inner, - authenticator, - notification_sender, - cleaner: Arc::new(Cleaner(path_buf)), + if let Some(cloud_config) = config.cloud_config.as_mut() { + cloud_config.maximum_upload_file_size_in_bytes = Some(SINGLE_FILE_UPLOAD_SIZE as u64); } + Self::new_with_config(config).await + } + + pub fn skip_clean(&mut self) { + self.cleaner.should_clean.store(false, Ordering::Release); } pub fn instance_name(&self) -> String { @@ -94,16 +112,25 @@ impl EventIntegrationTest { self.appflowy_core.config.application_path.clone() } - pub fn get_server(&self) -> Arc { - self.appflowy_core.server_provider.get_server().unwrap() - } - pub async fn wait_ws_connected(&self) { - if self.get_server().get_ws_state().is_connected() { + if self + .appflowy_core + .server_provider + .get_server() + .unwrap() + .get_ws_state() + .is_connected() + { return; } - let mut ws_state = self.get_server().subscribe_ws_state().unwrap(); + let mut ws_state = self + .appflowy_core + .server_provider + .get_server() + .unwrap() + .subscribe_ws_state() + .unwrap(); loop { select! { _ = sleep(Duration::from_secs(20)) => { @@ -125,12 +152,19 @@ impl EventIntegrationTest { oid: &str, collab_type: CollabType, ) -> Result, FlowyError> { - let server = self.server_provider.get_server().unwrap(); + let server = self.server_provider.get_server()?; + let workspace_id = self.get_current_workspace().await.id; + let oid = Uuid::from_str(oid)?; let uid = self.get_user_profile().await?.id; let doc_state = server .folder_service() - .get_folder_doc_state(&workspace_id, uid, collab_type, oid) + .get_folder_doc_state( + &Uuid::from_str(&workspace_id).unwrap(), + uid, + collab_type, + &oid, + ) .await?; Ok(doc_state) @@ -144,23 +178,21 @@ pub fn document_data_from_document_doc_state(doc_id: &str, doc_state: Vec) - } pub fn document_from_document_doc_state(doc_id: &str, doc_state: Vec) -> Document { - Document::from_doc_state( + let collab = Collab::new_with_source( CollabOrigin::Empty, - DataSource::DocStateV1(doc_state), doc_id, + DataSource::DocStateV1(doc_state), vec![], + true, ) - .unwrap() + .unwrap(); + Document::open(collab).unwrap() } async fn init_core(config: AppFlowyCoreConfig) -> AppFlowyCore { - std::thread::spawn(|| { - let runtime = Arc::new(AFPluginRuntime::new().unwrap()); - let cloned_runtime = runtime.clone(); - runtime.block_on(async move { AppFlowyCore::new(config, cloned_runtime, None).await }) - }) - .join() - .unwrap() + let runtime = Arc::new(AFPluginRuntime::new().unwrap()); + let cloned_runtime = runtime.clone(); + AppFlowyCore::new(config, cloned_runtime, None).await } impl std::ops::Deref for EventIntegrationTest { @@ -171,11 +203,17 @@ impl std::ops::Deref for EventIntegrationTest { } } -pub struct Cleaner(PathBuf); +pub struct Cleaner { + dir: PathBuf, + should_clean: AtomicBool, +} impl Cleaner { pub fn new(dir: PathBuf) -> Self { - Cleaner(dir) + Self { + dir, + should_clean: AtomicBool::new(true), + } } fn cleanup(dir: &PathBuf) { @@ -185,6 +223,8 @@ impl Cleaner { impl Drop for Cleaner { fn drop(&mut self) { - Self::cleanup(&self.0) + if self.should_clean.load(Ordering::Acquire) { + Self::cleanup(&self.dir) + } } } diff --git a/frontend/rust-lib/event-integration-test/src/user_event.rs b/frontend/rust-lib/event-integration-test/src/user_event.rs index a2e4dda7c5..ab10bb7083 100644 --- a/frontend/rust-lib/event-integration-test/src/user_event.rs +++ b/frontend/rust-lib/event-integration-test/src/user_event.rs @@ -1,11 +1,10 @@ use std::collections::HashMap; use std::convert::TryFrom; +use std::sync::atomic::Ordering; use std::sync::Arc; use bytes::Bytes; - use flowy_folder::entities::{RepeatedViewPB, WorkspacePB}; -use nanoid::nanoid; use protobuf::ProtobufError; use tokio::sync::broadcast::{channel, Sender}; use tracing::error; @@ -18,14 +17,15 @@ use flowy_server::af_cloud::define::{USER_DEVICE_ID, USER_EMAIL, USER_SIGN_IN_UR use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_server_pub::AuthenticatorType; use flowy_user::entities::{ - AuthenticatorPB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, - OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, - SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB, - UserWorkspaceIdPB, UserWorkspacePB, + AuthTypePB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, + OauthSignInPB, OpenUserWorkspacePB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, + SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, + UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB, }; use flowy_user::errors::{FlowyError, FlowyResult}; use flowy_user::event_map::UserEvent; -use lib_dispatch::prelude::{af_spawn, AFPluginDispatcher, AFPluginRequest, ToBytes}; +use flowy_user_pub::entities::AuthType; +use lib_dispatch::prelude::{AFPluginDispatcher, AFPluginRequest, ToBytes}; use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; @@ -65,14 +65,19 @@ impl EventIntegrationTest { email, name: "appflowy".to_string(), password: password.clone(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: uuid::Uuid::new_v4().to_string(), } .into_bytes() .unwrap(); let request = AFPluginRequest::new(UserEvent::SignUp).payload(payload); - let user_profile = AFPluginDispatcher::async_send(&self.appflowy_core.dispatcher(), request) + let user_profile = self + .local_set + .run_until(AFPluginDispatcher::async_send( + &self.appflowy_core.dispatcher(), + request, + )) .await .parse::() .unwrap() @@ -87,22 +92,18 @@ impl EventIntegrationTest { pub async fn af_cloud_sign_up(&self) -> UserProfilePB { let email = unique_email(); - self.af_cloud_sign_in_with_email(&email).await.unwrap() - } - - pub async fn supabase_party_sign_up(&self) -> UserProfilePB { - let map = third_party_sign_up_param(Uuid::new_v4().to_string()); - let payload = OauthSignInPB { - map, - authenticator: AuthenticatorPB::Supabase, - }; - - EventBuilder::new(self.clone()) - .event(UserEvent::OauthSignIn) - .payload(payload) - .async_send() - .await - .parse::() + match self.af_cloud_sign_in_with_email(&email).await { + Ok(profile) => profile, + Err(err) => { + tracing::warn!( + "Failed to sign up with email: {}, error: {}, retrying", + email, + err + ); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + self.af_cloud_sign_in_with_email(&email).await.unwrap() + }, + } } pub async fn sign_out(&self) { @@ -112,8 +113,8 @@ impl EventIntegrationTest { .await; } - pub fn set_auth_type(&self, auth_type: AuthenticatorPB) { - *self.authenticator.write() = auth_type; + pub fn set_auth_type(&self, auth_type: AuthTypePB) { + self.authenticator.store(auth_type as u8, Ordering::Release); } pub async fn init_anon_user(&self) -> UserProfilePB { @@ -139,7 +140,7 @@ impl EventIntegrationTest { pub async fn af_cloud_sign_in_with_email(&self, email: &str) -> FlowyResult { let payload = SignInUrlPayloadPB { email: email.to_string(), - authenticator: AuthenticatorPB::AppFlowyCloud, + authenticator: AuthTypePB::Server, }; let sign_in_url = EventBuilder::new(self.clone()) .event(UserEvent::GenerateSignInURL) @@ -154,34 +155,7 @@ impl EventIntegrationTest { map.insert(USER_DEVICE_ID.to_string(), Uuid::new_v4().to_string()); let payload = OauthSignInPB { map, - authenticator: AuthenticatorPB::AppFlowyCloud, - }; - - let user_profile = EventBuilder::new(self.clone()) - .event(UserEvent::OauthSignIn) - .payload(payload) - .async_send() - .await - .try_parse::()?; - - Ok(user_profile) - } - - pub async fn supabase_sign_up_with_uuid( - &self, - uuid: &str, - email: Option, - ) -> FlowyResult { - let mut map = HashMap::new(); - map.insert(USER_UUID.to_string(), uuid.to_string()); - map.insert(USER_DEVICE_ID.to_string(), uuid.to_string()); - map.insert( - USER_EMAIL.to_string(), - email.unwrap_or_else(|| format!("{}@appflowy.io", nanoid!(10))), - ); - let payload = OauthSignInPB { - map, - authenticator: AuthenticatorPB::Supabase, + authenticator: AuthTypePB::Server, }; let user_profile = EventBuilder::new(self.clone()) @@ -202,6 +176,7 @@ impl EventIntegrationTest { let payload = ImportAppFlowyDataPB { path, import_container_name: name, + parent_view_id: None, }; match EventBuilder::new(self.clone()) .event(UserEvent::ImportAppFlowyDataFolder) @@ -215,9 +190,10 @@ impl EventIntegrationTest { } } - pub async fn create_workspace(&self, name: &str) -> UserWorkspacePB { + pub async fn create_workspace(&self, name: &str, auth_type: AuthType) -> UserWorkspacePB { let payload = CreateWorkspacePB { name: name.to_string(), + auth_type: auth_type.into(), }; EventBuilder::new(self.clone()) .event(UserEvent::CreateWorkspace) @@ -304,9 +280,10 @@ impl EventIntegrationTest { .await; } - pub async fn open_workspace(&self, workspace_id: &str) { - let payload = UserWorkspaceIdPB { + pub async fn open_workspace(&self, workspace_id: &str, auth_type: AuthTypePB) { + let payload = OpenUserWorkspacePB { workspace_id: workspace_id.to_string(), + auth_type, }; EventBuilder::new(self.clone()) .event(UserEvent::OpenWorkspace) @@ -354,7 +331,7 @@ impl TestNotificationSender { let (tx, rx) = tokio::sync::mpsc::channel::(10); let mut receiver = self.sender.subscribe(); let ty = ty.into(); - af_spawn(async move { + tokio::spawn(async move { // DatabaseNotification::DidUpdateDatabaseSnapshotState while let Ok(value) = receiver.recv().await { if value.id == id && value.ty == ty { @@ -378,6 +355,26 @@ impl TestNotificationSender { rx } + pub fn subscribe_without_payload( + &self, + id: &str, + ty: impl Into + Send, + ) -> tokio::sync::mpsc::Receiver<()> { + let id = id.to_string(); + let (tx, rx) = tokio::sync::mpsc::channel::<()>(10); + let mut receiver = self.sender.subscribe(); + let ty = ty.into(); + tokio::spawn(async move { + // DatabaseNotification::DidUpdateDatabaseSnapshotState + while let Ok(value) = receiver.recv().await { + if value.id == id && value.ty == ty { + let _ = tx.send(()).await; + } + } + }); + rx + } + pub fn subscribe_with_condition(&self, id: &str, when: F) -> tokio::sync::mpsc::Receiver where T: TryFrom + Send + 'static, @@ -386,7 +383,7 @@ impl TestNotificationSender { let id = id.to_string(); let (tx, rx) = tokio::sync::mpsc::channel::(1); let mut receiver = self.sender.subscribe(); - af_spawn(async move { + tokio::spawn(async move { while let Ok(value) = receiver.recv().await { if value.id == id { if let Some(payload) = value.payload { @@ -438,7 +435,7 @@ pub struct SignUpContext { pub password: String, } -pub async fn user_localhost_af_cloud() { +pub async fn use_localhost_af_cloud() { AuthenticatorType::AppFlowyCloud.write_env(); let base_url = std::env::var("af_cloud_test_base_url").unwrap_or("http://localhost:8000".to_string()); @@ -450,6 +447,8 @@ pub async fn user_localhost_af_cloud() { base_url, ws_base_url, gotrue_url, + enable_sync_trace: true, + maximum_upload_file_size_in_bytes: None, } .write_env(); std::env::set_var("GOTRUE_ADMIN_EMAIL", "admin@example.com"); @@ -461,5 +460,5 @@ pub async fn user_localhost_af_cloud_with_nginx() { std::env::set_var("af_cloud_test_base_url", "http://localhost"); std::env::set_var("af_cloud_test_ws_url", "ws://localhost/ws/v1"); std::env::set_var("af_cloud_test_gotrue_url", "http://localhost/gotrue"); - user_localhost_af_cloud().await + use_localhost_af_cloud().await } diff --git a/frontend/rust-lib/event-integration-test/tests/asset/064_database_publish.zip b/frontend/rust-lib/event-integration-test/tests/asset/064_database_publish.zip new file mode 100644 index 0000000000..835bfdf527 Binary files /dev/null and b/frontend/rust-lib/event-integration-test/tests/asset/064_database_publish.zip differ diff --git a/frontend/rust-lib/event-integration-test/tests/asset/data_ref_doc.zip b/frontend/rust-lib/event-integration-test/tests/asset/data_ref_doc.zip new file mode 100644 index 0000000000..43fc7dde2f Binary files /dev/null and b/frontend/rust-lib/event-integration-test/tests/asset/data_ref_doc.zip differ diff --git a/frontend/rust-lib/event-integration-test/tests/asset/project.csv b/frontend/rust-lib/event-integration-test/tests/asset/project.csv new file mode 100644 index 0000000000..7f55c29797 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/asset/project.csv @@ -0,0 +1,5 @@ +Project name,Status,Owner,Dates,Priority,Completion,Tasks,Blocked By,Is Blocking,Summary,Delay,Checkbox,Files & media +Research study,In Progress,Nate Martins,"April 23, 2024 → May 22, 2024",High,0.25,"Develop survey questions (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Develop%20survey%20questions%2086ce25c6bb214b4b9e35beed8bc8b821.md), Interpret findings (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Interpret%20findings%20c418ae6385f94dcdadca1423ad60146b.md), Write research report (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Write%20research%20report%20cca8f4321cd44dceba9c266cdf544f15.md), Conduct interviews (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Conduct%20interviews%208f40c17883904d769fee19baeb0d46a5.md)",,Marketing campaign (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Projects%2058b8977d6e4444a98ec4d64176a071e5/Marketing%20campaign%2088ac0cea4cb245efb44d63ace0a37d1e.md),"A research study is underway to understand customer satisfaction and identify improvement areas. The team is developing a survey to gather data from customers, aiming to analyze insights for strategic decision-making and enhance satisfaction.",7,No,Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/DO010003572.jpeg +Marketing campaign,In Progress,Sohrab Amin,"April 24, 2024 → May 7, 2024",Low,0.5,"Define target audience (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Define%20target%20audience%2045e2d3342bfe44848009f8e19e65b2af.md), Develop advertising plan (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Develop%20advertising%20plan%20a8e534ad763040029d0feb27fdb1820d.md), Report on marketing ROI (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Report%20on%20marketing%20ROI%202458b6a0f0174f72af3e5daac8b36b08.md), Create social media plan (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Create%20social%20media%20plan%204e70ea0b7d40427a9648bcf554a121f6.md), Create performance marketing plan (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Create%20performance%20marketing%20plan%20b6aa6a9e9cc1446490984eaecc4930c7.md), Develop new creative assets (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Develop%20new%20creative%20assets%203268a1d63424427ba1f7e3cac109cefa.md)",Research study (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Projects%2058b8977d6e4444a98ec4d64176a071e5/Research%20study%20e445ee1fb7ff4591be2de17d906df97e.md),Website redesign (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Projects%2058b8977d6e4444a98ec4d64176a071e5/Website%20redesign%20bb934cb9f0e44daab4368247d62b0f39.md),"The marketing campaign, led by Sohrab Amin, focuses on enhancing mobile performance, building on last year's success in reducing page load times by 50%. The team aims to further improve performance by investing in native components for iOS and Android from April 24 to May 7, 2024.",19,No,Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/appflowy_2x.png +Website redesign,Planning,Nate Martins,"May 10, 2024 → May 26, 2024",Medium,,"Conduct website audit (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Conduct%20website%20audit%20aee812f0c962462e8b91e8044a268eb5.md), Write website copy (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Write%20website%20copy%209ab9309ceae34f6f8dc19b2eeda8f8f2.md), Test website functionality (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Test%20website%20functionality%208ccf7153028747b19a351f6d36100f0d.md), Develop creative assets (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Develop%20creative%20assets%206fe86230e30843ebb60c67724f0f922f.md)",Marketing campaign (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Projects%2058b8977d6e4444a98ec4d64176a071e5/Marketing%20campaign%2088ac0cea4cb245efb44d63ace0a37d1e.md),,"The website redesign project, led by Nate Martins from May 10 to May 26, 2024, aims to create a more effective onboarding process to enhance user experience and increase 7-day retention by 25%.",,Yes, +Product launch,In Progress,Ben Lang,"May 8, 2024 → May 22, 2024",High,0.6666666666666666,"Create product demo video (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Create%20product%20demo%20video%202e2e2eb53df84019a613fe386e4c79da.md), Create product positioning (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Create%20product%20positioning%20c0d2728f79594cc3a1869a3c67bcdf45.md), Monitor launch performance (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Monitor%20launch%20performance%207c0b846d1da2463892eff490f06e0d0b.md)",,,"A new product launch is scheduled from May 8 to May 22, 2024, initiated to fill a market gap and expand the product line. The development team is focused on creating a high-quality product, while the marketing team is crafting a strategy to achieve a 10% market share within the first six months post-launch.",16,Yes, \ No newline at end of file diff --git a/frontend/rust-lib/event-integration-test/tests/asset/publish_grid_primary.csv.zip b/frontend/rust-lib/event-integration-test/tests/asset/publish_grid_primary.csv.zip new file mode 100644 index 0000000000..785449c8be Binary files /dev/null and b/frontend/rust-lib/event-integration-test/tests/asset/publish_grid_primary.csv.zip differ diff --git a/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs new file mode 100644 index 0000000000..aacba827c4 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs @@ -0,0 +1,164 @@ +use crate::util::receive_with_timeout; +use event_integration_test::user_event::use_localhost_af_cloud; +use event_integration_test::EventIntegrationTest; +use flowy_ai::entities::ChatMessageListPB; +use flowy_ai::notification::ChatNotification; +use std::str::FromStr; + +use flowy_ai_pub::cloud::ChatMessageType; + +use std::time::Duration; +use uuid::Uuid; + +#[tokio::test] +async fn af_cloud_create_chat_message_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.af_cloud_sign_up().await; + + let current_workspace = test.get_current_workspace().await; + let view = test.create_chat(¤t_workspace.id).await; + let chat_id = view.id.clone(); + let chat_service = test + .appflowy_core + .server_provider + .get_server() + .unwrap() + .chat_service(); + for i in 0..10 { + let _ = chat_service + .create_question( + &Uuid::from_str(¤t_workspace.id).unwrap(), + &Uuid::from_str(&chat_id).unwrap(), + &format!("hello world {}", i), + ChatMessageType::System, + ) + .await + .unwrap(); + } + let rx = test + .notification_sender + .subscribe::(&chat_id, ChatNotification::DidLoadLatestChatMessage); + let _ = test.load_next_message(&chat_id, 10, None).await; + let all = receive_with_timeout(rx, Duration::from_secs(30)) + .await + .unwrap(); + assert_eq!(all.messages.len(), 10); + // in desc order + assert_eq!(all.messages[4].content, "hello world 5"); + assert_eq!(all.messages[5].content, "hello world 4"); + + let list = test + .load_next_message(&chat_id, 5, Some(all.messages[4].message_id)) + .await; + assert_eq!(list.messages.len(), 4); + assert_eq!(list.messages[0].content, "hello world 9"); + assert_eq!(list.messages[1].content, "hello world 8"); + assert_eq!(list.messages[2].content, "hello world 7"); + assert_eq!(list.messages[3].content, "hello world 6"); + + assert_eq!(all.messages[6].content, "hello world 3"); + + // Load from local + let list = test + .load_prev_message(&chat_id, 5, Some(all.messages[6].message_id)) + .await; + assert_eq!(list.messages.len(), 3); + assert_eq!(list.messages[0].content, "hello world 2"); + assert_eq!(list.messages[1].content, "hello world 1"); + assert_eq!(list.messages[2].content, "hello world 0"); +} + +#[tokio::test] +async fn af_cloud_load_remote_system_message_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.af_cloud_sign_up().await; + + let current_workspace = test.get_current_workspace().await; + let view = test.create_chat(¤t_workspace.id).await; + let chat_id = view.id.clone(); + + let chat_service = test + .appflowy_core + .server_provider + .get_server() + .unwrap() + .chat_service(); + for i in 0..10 { + let _ = chat_service + .create_question( + &Uuid::from_str(¤t_workspace.id).unwrap(), + &Uuid::from_str(&chat_id).unwrap(), + &format!("hello server {}", i), + ChatMessageType::System, + ) + .await + .unwrap(); + } + + let rx = test + .notification_sender + .subscribe::(&chat_id, ChatNotification::DidLoadLatestChatMessage); + + let all = test.load_next_message(&chat_id, 5, None).await; + assert_eq!(all.messages.len(), 5); + + // Wait for the messages to be loaded. + let next_back_five = receive_with_timeout(rx, Duration::from_secs(60)) + .await + .unwrap(); + assert_eq!(next_back_five.messages.len(), 5); + assert!(next_back_five.has_more); + assert_eq!(next_back_five.total, 10); + assert_eq!(next_back_five.messages[0].content, "hello server 9"); + assert_eq!(next_back_five.messages[1].content, "hello server 8"); + assert_eq!(next_back_five.messages[2].content, "hello server 7"); + assert_eq!(next_back_five.messages[3].content, "hello server 6"); + assert_eq!(next_back_five.messages[4].content, "hello server 5"); + + // Load first five messages + let rx = test + .notification_sender + .subscribe::(&chat_id, ChatNotification::DidLoadPrevChatMessage); + test + .load_prev_message(&chat_id, 5, Some(next_back_five.messages[4].message_id)) + .await; + let first_five_messages = receive_with_timeout(rx, Duration::from_secs(60)) + .await + .unwrap(); + assert_eq!(first_five_messages.messages[0].content, "hello server 4"); + assert_eq!(first_five_messages.messages[1].content, "hello server 3"); + assert_eq!(first_five_messages.messages[2].content, "hello server 2"); + assert_eq!(first_five_messages.messages[3].content, "hello server 1"); + assert_eq!(first_five_messages.messages[4].content, "hello server 0"); +} + +// #[tokio::test] +// async fn af_cloud_load_remote_user_message_test() { +// user_localhost_af_cloud().await; +// let test = EventIntegrationTest::new().await; +// test.af_cloud_sign_up().await; +// +// let current_workspace = test.get_current_workspace().await; +// let view = test.create_chat(¤t_workspace.id).await; +// let chat_id = view.id.clone(); +// let rx = test +// .notification_sender +// .subscribe_without_payload(&chat_id, ChatNotification::FinishAnswerQuestion); +// test +// .send_message(&chat_id, "hello world", ChatMessageTypePB::User) +// .await; +// let _ = receive_with_timeout(rx, Duration::from_secs(60)) +// .await +// .unwrap(); +// +// let all = test.load_next_message(&chat_id, 5, None).await; +// assert_eq!(all.messages.len(), 2); +// // 3 means AI +// assert_eq!(all.messages[0].author_type, 3); +// // 2 means User +// assert_eq!(all.messages[1].author_type, 1); +// // The message ID is incremented by 1. +// assert_eq!(all.messages[1].message_id + 1, all.messages[0].message_id); +// } diff --git a/frontend/rust-lib/event-integration-test/tests/chat/mod.rs b/frontend/rust-lib/event-integration-test/tests/chat/mod.rs new file mode 100644 index 0000000000..773bdab81f --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/chat/mod.rs @@ -0,0 +1 @@ +mod chat_message_test; diff --git a/frontend/rust-lib/event-integration-test/tests/database/af_cloud/mod.rs b/frontend/rust-lib/event-integration-test/tests/database/af_cloud/mod.rs index 36f850dd92..788864be1e 100644 --- a/frontend/rust-lib/event-integration-test/tests/database/af_cloud/mod.rs +++ b/frontend/rust-lib/event-integration-test/tests/database/af_cloud/mod.rs @@ -1,2 +1,3 @@ -// mod summarize_row; +// mod summarize_row_test; +// mod translate_row_test; mod util; diff --git a/frontend/rust-lib/event-integration-test/tests/database/af_cloud/summarize_row.rs b/frontend/rust-lib/event-integration-test/tests/database/af_cloud/summarize_row_test.rs similarity index 100% rename from frontend/rust-lib/event-integration-test/tests/database/af_cloud/summarize_row.rs rename to frontend/rust-lib/event-integration-test/tests/database/af_cloud/summarize_row_test.rs diff --git a/frontend/rust-lib/event-integration-test/tests/database/af_cloud/translate_row_test.rs b/frontend/rust-lib/event-integration-test/tests/database/af_cloud/translate_row_test.rs new file mode 100644 index 0000000000..8de361b8b3 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/database/af_cloud/translate_row_test.rs @@ -0,0 +1,54 @@ +use crate::database::af_cloud::util::make_test_summary_grid; +use std::time::Duration; +use tokio::time::sleep; + +use event_integration_test::user_event::user_localhost_af_cloud; +use event_integration_test::EventIntegrationTest; +use flowy_database2::entities::{FieldType, TranslateRowPB}; + +#[tokio::test] +async fn af_cloud_translate_row_test() { + user_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.af_cloud_sign_up().await; + + // create document and then insert content + let current_workspace = test.get_current_workspace().await; + let initial_data = make_test_summary_grid().to_json_bytes().unwrap(); + let view = test + .create_grid( + ¤t_workspace.id, + "translate database".to_string(), + initial_data, + ) + .await; + + let database_pb = test.get_database(&view.id).await; + let field = test + .get_all_database_fields(&view.id) + .await + .items + .into_iter() + .find(|field| field.field_type == FieldType::Translate) + .unwrap(); + + let row_id = database_pb.rows[0].id.clone(); + let data = TranslateRowPB { + view_id: view.id.clone(), + row_id: row_id.clone(), + field_id: field.id.clone(), + }; + test.translate_row(data).await; + + sleep(Duration::from_secs(1)).await; + let cell = test + .get_text_cell(&view.id, &row_id, &field.id) + .await + .to_lowercase(); + println!("cell: {}", cell); + // default translation is in French. So it should be something like this: + // Prix:2,6 $,Nom du produit:Pomme,Statut:TERMINÉ + assert!(cell.contains("pomme")); + assert!(cell.contains("produit")); + assert!(cell.contains("prix")); +} diff --git a/frontend/rust-lib/event-integration-test/tests/database/af_cloud/util.rs b/frontend/rust-lib/event-integration-test/tests/database/af_cloud/util.rs index 3bfd07cab4..e040b3d23b 100644 --- a/frontend/rust-lib/event-integration-test/tests/database/af_cloud/util.rs +++ b/frontend/rust-lib/event-integration-test/tests/database/af_cloud/util.rs @@ -1,15 +1,18 @@ use collab_database::database::{gen_database_id, gen_database_view_id, gen_row_id, DatabaseData}; -use collab_database::views::{DatabaseLayout, DatabaseView}; +use collab_database::entity::DatabaseView; +use collab_database::views::DatabaseLayout; use event_integration_test::database_event::TestRowBuilder; +use collab_database::fields::number_type_option::{NumberFormat, NumberTypeOption}; +use collab_database::fields::select_type_option::{ + SelectOption, SelectOptionColor, SingleSelectTypeOption, +}; +use collab_database::fields::summary_type_option::SummarizationTypeOption; +use collab_database::fields::translate_type_option::TranslateTypeOption; use collab_database::fields::Field; use collab_database::rows::Row; use flowy_database2::entities::FieldType; -use flowy_database2::services::field::summary_type_option::summary::SummarizationTypeOption; -use flowy_database2::services::field::{ - FieldBuilder, NumberFormat, NumberTypeOption, SelectOption, SelectOptionColor, - SingleSelectTypeOption, -}; +use flowy_database2::services::field::FieldBuilder; use flowy_database2::services::field_settings::default_field_settings_for_fields; use strum::IntoEnumIterator; @@ -28,15 +31,14 @@ pub fn make_test_summary_grid() -> DatabaseData { .type_options .get(&FieldType::SingleSelect.to_string()) .cloned() - .map(|t| SingleSelectTypeOption::from(t).options) + .map(|t| SingleSelectTypeOption::from(t).0.options) .unwrap(); let rows = create_rows(&database_id, &fields, options); - let inline_view_id = gen_database_view_id(); let view = DatabaseView { database_id: database_id.clone(), - id: inline_view_id.clone(), + id: gen_database_view_id(), name: "".to_string(), layout: DatabaseLayout::Grid, field_settings, @@ -45,7 +47,6 @@ pub fn make_test_summary_grid() -> DatabaseData { DatabaseData { database_id, - inline_view_id, views: vec![view], fields, rows, @@ -61,6 +62,7 @@ fn create_fields() -> Vec { FieldType::Number => fields.push(create_number_field("Price", NumberFormat::USD)), FieldType::SingleSelect => fields.push(create_single_select_field("Status")), FieldType::Summary => fields.push(create_summary_field("AI summary")), + FieldType::Translate => fields.push(create_translate_field("AI Translate")), _ => {}, } } @@ -124,3 +126,14 @@ fn create_summary_field(name: &str) -> Field { .name(name) .build() } + +#[allow(dead_code)] +fn create_translate_field(name: &str) -> Field { + let type_option = TranslateTypeOption { + auto_fill: false, + language_type: 2, + }; + FieldBuilder::new(FieldType::Translate, type_option) + .name(name) + .build() +} diff --git a/frontend/rust-lib/event-integration-test/tests/database/local_test/calculate_test.rs b/frontend/rust-lib/event-integration-test/tests/database/local_test/calculate_test.rs new file mode 100644 index 0000000000..7412f54951 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/database/local_test/calculate_test.rs @@ -0,0 +1,90 @@ +use std::time::Duration; + +use crate::util::gen_csv_import_data; +use event_integration_test::EventIntegrationTest; +use flowy_database2::entities::{ + CalculationType, CellChangesetPB, DatabasePB, RemoveCalculationChangesetPB, + UpdateCalculationChangesetPB, +}; +use tokio::time::sleep; + +#[tokio::test] +async fn calculation_integration_test1() { + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let workspace_id = test.get_current_workspace().await.id; + let payload = gen_csv_import_data("project.csv", &workspace_id); + let view = test.import_data(payload).await.pop().unwrap(); + let database = test.open_database(&view.id).await; + + average_calculation(test, database, &view.id).await; +} + +// Tests for the CalculationType::Average +// Is done on the Delay column in the project.csv +async fn average_calculation( + test: EventIntegrationTest, + database: DatabasePB, + database_view_id: &str, +) { + // Delay column is the 11th column (index 10) in the project.csv + let delay_field = database.fields.get(10).unwrap(); + + let calculation_changeset = UpdateCalculationChangesetPB { + view_id: database_view_id.to_string(), + calculation_id: None, + field_id: delay_field.field_id.clone(), + calculation_type: CalculationType::Average, + }; + + test.update_calculation(calculation_changeset).await; + + // Wait for calculation update + sleep(Duration::from_secs(1)).await; + + let all_calculations = test.get_all_calculations(database_view_id).await; + assert!(all_calculations.items.len() == 1); + + let average_calc = all_calculations.items.first().unwrap(); + assert!( + average_calc.value == "14.00", + "Expected 14.00, got {}", + average_calc.value + ); + + // Update a cell in the delay column at fourth row (3rd index) + let cell_changeset = CellChangesetPB { + view_id: database_view_id.to_string(), + row_id: database.rows.get(3).unwrap().id.clone(), + field_id: delay_field.field_id.clone(), + cell_changeset: "22".to_string(), + }; + test.update_cell(cell_changeset).await; + + // wait for awhile because the calculate is done in the background + tokio::time::sleep(Duration::from_secs(6)).await; + + let all_calculations = test.get_all_calculations(database_view_id).await; + assert!( + all_calculations.items.len() == 1, + "Expected 1, got {}", + all_calculations.items.len() + ); + + let average_calc = all_calculations.items.first().unwrap(); + assert!( + average_calc.value == "16.00", + "Expected 16.00, got {}", + average_calc.value + ); + + // Remove the calculation + test + .remove_calculate(RemoveCalculationChangesetPB { + view_id: database_view_id.to_string(), + field_id: delay_field.field_id.clone(), + calculation_id: average_calc.id.clone(), + }) + .await; +} diff --git a/frontend/rust-lib/event-integration-test/tests/database/local_test/event_test.rs b/frontend/rust-lib/event-integration-test/tests/database/local_test/event_test.rs new file mode 100644 index 0000000000..885d1b6817 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/database/local_test/event_test.rs @@ -0,0 +1,917 @@ +use std::convert::TryFrom; + +use bytes::Bytes; + +use event_integration_test::event_builder::EventBuilder; +use event_integration_test::EventIntegrationTest; +use flowy_database2::entities::{ + CellChangesetPB, CellIdPB, CheckboxCellDataPB, ChecklistCellDataChangesetPB, + ChecklistCellInsertPB, DatabaseLayoutPB, DatabaseSettingChangesetPB, DatabaseViewIdPB, + DateCellChangesetPB, FieldType, OrderObjectPositionPB, RelationCellChangesetPB, + SelectOptionCellDataPB, UpdateRowMetaChangesetPB, +}; +use lib_infra::util::timestamp; + +#[tokio::test] +async fn get_database_id_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + // The view id can be used to get the database id. + let database_id = EventBuilder::new(test.clone()) + .event(flowy_database2::event_map::DatabaseEvent::GetDatabaseId) + .payload(DatabaseViewIdPB { + value: grid_view.id.clone(), + }) + .async_send() + .await + .parse::() + .value; + + assert_ne!(database_id, grid_view.id); +} + +#[tokio::test] +async fn get_database_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.fields.len(), 3); + assert_eq!(database.rows.len(), 3); + assert_eq!(database.layout_type, DatabaseLayoutPB::Grid); +} + +#[tokio::test] +async fn get_field_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + let fields = test.get_all_database_fields(&grid_view.id).await.items; + assert_eq!(fields[0].field_type, FieldType::RichText); + assert_eq!(fields[1].field_type, FieldType::SingleSelect); + assert_eq!(fields[2].field_type, FieldType::Checkbox); + assert_eq!(fields.len(), 3); +} + +#[tokio::test] +async fn create_field_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + test.create_field(&grid_view.id, FieldType::Checkbox).await; + let fields = test.get_all_database_fields(&grid_view.id).await.items; + assert_eq!(fields.len(), 4); + assert_eq!(fields[3].field_type, FieldType::Checkbox); +} + +#[tokio::test] +async fn delete_field_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + let fields = test.get_all_database_fields(&grid_view.id).await.items; + assert_eq!(fields[0].field_type, FieldType::RichText); + assert_eq!(fields[1].field_type, FieldType::SingleSelect); + assert_eq!(fields[2].field_type, FieldType::Checkbox); + + let error = test.delete_field(&grid_view.id, &fields[1].id).await; + assert!(error.is_none()); + + let fields = test.get_all_database_fields(&grid_view.id).await.items; + assert_eq!(fields.len(), 2); +} + +// The primary field is not allowed to be deleted. +#[tokio::test] +async fn delete_primary_field_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + let fields = test.get_all_database_fields(&grid_view.id).await.items; + // the primary field is not allowed to be deleted. + assert!(fields[0].is_primary); + let error = test.delete_field(&grid_view.id, &fields[0].id).await; + assert!(error.is_some()); +} + +#[tokio::test] +async fn update_field_type_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + let fields = test.get_all_database_fields(&grid_view.id).await.items; + let error = test + .update_field_type(&grid_view.id, &fields[1].id, FieldType::Checklist) + .await; + assert!(error.is_none()); + + let fields = test.get_all_database_fields(&grid_view.id).await.items; + assert_eq!(fields[1].field_type, FieldType::Checklist); +} + +#[tokio::test] +async fn update_primary_field_type_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + let fields = test.get_all_database_fields(&grid_view.id).await.items; + // the primary field is not allowed to be deleted. + assert!(fields[0].is_primary); + + // the primary field is not allowed to be updated. + let error = test + .update_field_type(&grid_view.id, &fields[0].id, FieldType::Checklist) + .await; + assert!(error.is_some()); +} + +#[tokio::test] +async fn duplicate_field_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + let fields = test.get_all_database_fields(&grid_view.id).await.items; + // the primary field is not allowed to be updated. + let error = test.duplicate_field(&grid_view.id, &fields[1].id).await; + assert!(error.is_none()); + + let fields = test.get_all_database_fields(&grid_view.id).await.items; + assert_eq!(fields.len(), 4); +} + +// The primary field is not allowed to be duplicated. So this test should return an error. +#[tokio::test] +async fn duplicate_primary_field_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + let fields = test.get_all_database_fields(&grid_view.id).await.items; + // the primary field is not allowed to be duplicated. + let error = test.duplicate_field(&grid_view.id, &fields[0].id).await; + assert!(error.is_some()); +} + +#[tokio::test] +async fn get_primary_field_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + // By default the primary field type is RichText. + let field = test.get_primary_field(&grid_view.id).await; + assert_eq!(field.field_type, FieldType::RichText); +} + +#[tokio::test] +async fn create_row_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + let _ = test + .create_row(&grid_view.id, OrderObjectPositionPB::default(), None) + .await; + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.rows.len(), 4); +} + +#[tokio::test] +async fn delete_row_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + // delete the row + let database = test.get_database(&grid_view.id).await; + let remove_row_id = database.rows[0].id.clone(); + assert_eq!(database.rows.len(), 3); + let error = test.delete_row(&grid_view.id, &remove_row_id).await; + assert!(error.is_none()); + + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.rows.len(), 2); + + // get the row again and check if it is deleted. + let optional_row = test.get_row(&grid_view.id, &remove_row_id).await; + assert!(optional_row.row.is_none()); +} + +#[tokio::test] +async fn get_row_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + + let row = test.get_row(&grid_view.id, &database.rows[0].id).await.row; + assert!(row.is_some()); + + let row = test.get_row_meta(&grid_view.id, &database.rows[0].id).await; + assert!(row.document_id.is_some()); +} + +#[tokio::test] +async fn update_row_meta_event_with_url_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + + // By default the row icon is None. + let row = test.get_row_meta(&grid_view.id, &database.rows[0].id).await; + assert_eq!(row.icon, None); + + // Insert icon url to the row. + let changeset = UpdateRowMetaChangesetPB { + id: database.rows[0].id.clone(), + view_id: grid_view.id.clone(), + icon_url: Some("icon_url".to_owned()), + cover: None, + is_document_empty: None, + attachment_count: None, + }; + let error = test.update_row_meta(changeset).await; + assert!(error.is_none()); + + // Check if the icon is updated. + let row = test.get_row_meta(&grid_view.id, &database.rows[0].id).await; + assert_eq!(row.icon, Some("icon_url".to_owned())); +} + +#[tokio::test] +async fn update_row_meta_event_with_cover_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + + // By default the row icon is None. + let row = test.get_row_meta(&grid_view.id, &database.rows[0].id).await; + assert_eq!(row.icon, None); + + // Insert cover to the row. + let changeset = UpdateRowMetaChangesetPB { + id: database.rows[0].id.clone(), + view_id: grid_view.id.clone(), + icon_url: Some("cover url".to_owned()), + cover: None, + is_document_empty: None, + attachment_count: None, + }; + let error = test.update_row_meta(changeset).await; + assert!(error.is_none()); + + // Check if the icon is updated. + let row = test.get_row_meta(&grid_view.id, &database.rows[0].id).await; + assert_eq!(row.icon, Some("cover url".to_owned())); +} + +#[tokio::test] +async fn delete_row_event_with_invalid_row_id_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + // delete the row with empty row_id. It should do nothing + let error = test.delete_row(&grid_view.id, "").await; + assert!(error.is_none()); + + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.rows.len(), 3); +} + +#[tokio::test] +async fn duplicate_row_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + let error = test + .duplicate_row(&grid_view.id, &database.rows[0].id) + .await; + assert!(error.is_none()); + + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.rows.len(), 4); +} + +#[tokio::test] +async fn duplicate_row_event_with_invalid_row_id_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.rows.len(), 3); + + let error = test.duplicate_row(&grid_view.id, "").await; + assert!(error.is_some()); + + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.rows.len(), 3); +} + +#[tokio::test] +async fn move_row_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + let row_1 = database.rows[0].id.clone(); + let row_2 = database.rows[1].id.clone(); + let row_3 = database.rows[2].id.clone(); + let error = test.move_row(&grid_view.id, &row_1, &row_3).await; + assert!(error.is_none()); + + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.rows[0].id, row_2); + assert_eq!(database.rows[1].id, row_3); + assert_eq!(database.rows[2].id, row_1); +} + +#[tokio::test] +async fn move_row_event_test2() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + let row_1 = database.rows[0].id.clone(); + let row_2 = database.rows[1].id.clone(); + let row_3 = database.rows[2].id.clone(); + let error = test.move_row(&grid_view.id, &row_2, &row_1).await; + assert!(error.is_none()); + + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.rows[0].id, row_2); + assert_eq!(database.rows[1].id, row_1); + assert_eq!(database.rows[2].id, row_3); +} + +#[tokio::test] +async fn move_row_event_with_invalid_row_id_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + let row_1 = database.rows[0].id.clone(); + let row_2 = database.rows[1].id.clone(); + let row_3 = database.rows[2].id.clone(); + + for i in 0..2 { + if i == 0 { + let error = test.move_row(&grid_view.id, &row_1, "").await; + assert!(error.is_some()); + } else { + let error = test.move_row(&grid_view.id, "", &row_1).await; + assert!(error.is_some()); + } + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.rows[0].id, row_1); + assert_eq!(database.rows[1].id, row_2); + assert_eq!(database.rows[2].id, row_3); + } +} + +#[tokio::test] +async fn update_text_cell_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + let fields = test.get_all_database_fields(&grid_view.id).await.items; + + let row_id = database.rows[0].id.clone(); + let field_id = fields[0].id.clone(); + assert_eq!(fields[0].field_type, FieldType::RichText); + + // Update the first cell of the first row. + let error = test + .update_cell(CellChangesetPB { + view_id: grid_view.id.clone(), + row_id: row_id.clone(), + field_id: field_id.clone(), + cell_changeset: "hello world".to_string(), + }) + .await; + assert!(error.is_none()); + + let cell = test.get_cell(&grid_view.id, &row_id, &field_id).await; + let s = String::from_utf8(cell.data).unwrap(); + assert_eq!(s, "hello world"); +} + +#[tokio::test] +async fn update_checkbox_cell_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + let fields = test.get_all_database_fields(&grid_view.id).await.items; + + let row_id = database.rows[0].id.clone(); + let field_id = fields[2].id.clone(); + assert_eq!(fields[2].field_type, FieldType::Checkbox); + + for input in &["yes", "true", "1"] { + let error = test + .update_cell(CellChangesetPB { + view_id: grid_view.id.clone(), + row_id: row_id.clone(), + field_id: field_id.clone(), + cell_changeset: input.to_string(), + }) + .await; + assert!(error.is_none()); + + let cell = test.get_cell(&grid_view.id, &row_id, &field_id).await; + let output = CheckboxCellDataPB::try_from(Bytes::from(cell.data)).unwrap(); + assert!(output.is_checked); + } +} + +#[tokio::test] +async fn update_single_select_cell_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + let fields = test.get_all_database_fields(&grid_view.id).await.items; + let row_id = database.rows[0].id.clone(); + let field_id = fields[1].id.clone(); + assert_eq!(fields[1].field_type, FieldType::SingleSelect); + + // Insert a new option. This should update the cell with the new option. + let error = test + .insert_option(&grid_view.id, &field_id, &row_id, "task 1") + .await; + assert!(error.is_none()); + + // Check that the cell data is updated. + let cell = test.get_cell(&grid_view.id, &row_id, &field_id).await; + let select_option_cell = SelectOptionCellDataPB::try_from(Bytes::from(cell.data)).unwrap(); + + assert_eq!(select_option_cell.select_options.len(), 1); +} + +#[tokio::test] +async fn update_date_cell_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + + // Create a date field + let date_field = test.create_field(&grid_view.id, FieldType::DateTime).await; + + let cell_path = CellIdPB { + view_id: grid_view.id.clone(), + field_id: date_field.id.clone(), + row_id: database.rows[0].id.clone(), + }; + + // Insert data into the date cell of the first row. + let timestamp = 1686300557; + let error = test + .update_date_cell(DateCellChangesetPB { + cell_id: cell_path, + timestamp: Some(timestamp), + ..Default::default() + }) + .await; + assert!(error.is_none()); + + // Check that the cell data is updated. + let cell = test + .get_date_cell(&grid_view.id, &database.rows[0].id, &date_field.id) + .await; + assert_eq!(cell.timestamp, Some(timestamp)); +} + +#[tokio::test] +async fn update_date_cell_event_with_empty_time_str_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let database = test.get_database(&grid_view.id).await; + let row_id = database.rows[0].id.clone(); + + // Create a date field + let date_field = test.create_field(&grid_view.id, FieldType::DateTime).await; + let cell_path = CellIdPB { + view_id: grid_view.id.clone(), + field_id: date_field.id.clone(), + row_id: row_id.clone(), + }; + + // Insert empty timestamp string + let error = test + .update_date_cell(DateCellChangesetPB { + cell_id: cell_path, + timestamp: None, + ..Default::default() + }) + .await; + assert!(error.is_none()); + + // Check that the cell data is updated. + let cell = test + .get_date_cell(&grid_view.id, &row_id, &date_field.id) + .await; + assert_eq!(cell.timestamp, None); +} + +#[tokio::test] +async fn create_checklist_field_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + // create checklist field + let checklist_field = test.create_field(&grid_view.id, FieldType::Checklist).await; + let database = test.get_database(&grid_view.id).await; + + // Get the checklist cell + let cell = test + .get_checklist_cell(&grid_view.id, &checklist_field.id, &database.rows[0].id) + .await; + assert_eq!(cell.options.len(), 0); + assert_eq!(cell.selected_options.len(), 0); + assert_eq!(cell.percentage, 0.0); +} + +#[tokio::test] +async fn update_checklist_cell_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + // create checklist field + let checklist_field = test.create_field(&grid_view.id, FieldType::Checklist).await; + let database = test.get_database(&grid_view.id).await; + + // update the checklist cell + let changeset = ChecklistCellDataChangesetPB { + cell_id: CellIdPB { + view_id: grid_view.id.clone(), + row_id: database.rows[0].id.clone(), + field_id: checklist_field.id.clone(), + }, + insert_task: vec![ + ChecklistCellInsertPB { + name: "task 1".to_string(), + index: None, + }, + ChecklistCellInsertPB { + name: "task 2".to_string(), + index: None, + }, + ChecklistCellInsertPB { + name: "task 3".to_string(), + index: None, + }, + ], + completed_tasks: vec![], + delete_tasks: vec![], + update_tasks: vec![], + reorder: "".to_string(), + }; + test.update_checklist_cell(changeset).await; + + // get the cell + let cell = test + .get_checklist_cell(&grid_view.id, &checklist_field.id, &database.rows[0].id) + .await; + + assert_eq!(cell.options.len(), 3); + assert_eq!(cell.selected_options.len(), 0); + + // select some options + let changeset = ChecklistCellDataChangesetPB { + cell_id: CellIdPB { + view_id: grid_view.id.clone(), + row_id: database.rows[0].id.clone(), + field_id: checklist_field.id.clone(), + }, + completed_tasks: vec![cell.options[0].id.clone(), cell.options[1].id.clone()], + ..Default::default() + }; + test.update_checklist_cell(changeset).await; + + // get the cell + let cell = test + .get_checklist_cell(&grid_view.id, &checklist_field.id, &database.rows[0].id) + .await; + + assert_eq!(cell.options.len(), 3); + assert_eq!(cell.selected_options.len(), 2); + assert_eq!(cell.percentage, 0.67); +} + +// Update the database layout type from grid to board +#[tokio::test] +async fn update_database_layout_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + + let error = test + .update_setting(DatabaseSettingChangesetPB { + view_id: grid_view.id.clone(), + layout_type: Some(DatabaseLayoutPB::Board), + ..Default::default() + }) + .await; + assert!(error.is_none()); + + let database = test.get_database(&grid_view.id).await; + assert_eq!(database.layout_type, DatabaseLayoutPB::Board); +} + +// Update the database layout type from grid to board. Set the checkbox field as the grouping field +#[tokio::test] +async fn update_database_layout_event_test2() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let fields = test.get_all_database_fields(&grid_view.id).await.items; + + let checkbox_field = fields + .iter() + .find(|field| field.field_type == FieldType::Checkbox) + .unwrap(); + test + .set_group_by_field(&grid_view.id, &checkbox_field.id, vec![]) + .await; + + let error = test + .update_setting(DatabaseSettingChangesetPB { + view_id: grid_view.id.clone(), + layout_type: Some(DatabaseLayoutPB::Board), + ..Default::default() + }) + .await; + assert!(error.is_none()); + + // Empty to group id + let groups = test.get_groups(&grid_view.id).await; + assert_eq!(groups.len(), 2); +} + +// Create a checkbox field in the default board and then set it as the grouping field. +#[tokio::test] +async fn set_group_by_checkbox_field_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let board_view = test + .create_board(¤t_workspace.id, "my board view".to_owned(), vec![]) + .await; + + let checkbox_field = test.create_field(&board_view.id, FieldType::Checkbox).await; + test + .set_group_by_field(&board_view.id, &checkbox_field.id, vec![]) + .await; + + let groups = test.get_groups(&board_view.id).await; + assert_eq!(groups.len(), 2); +} + +#[tokio::test] +async fn get_all_calendar_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let calendar_view = test + .create_calendar(¤t_workspace.id, "my calendar view".to_owned(), vec![]) + .await; + + // By default, there should be no events + let events = test.get_all_calendar_events(&calendar_view.id).await; + assert!(events.is_empty()); +} + +#[tokio::test] +async fn create_calendar_event_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let calendar_view = test + .create_calendar(¤t_workspace.id, "my calendar view".to_owned(), vec![]) + .await; + let fields = test.get_all_database_fields(&calendar_view.id).await.items; + let date_field = fields + .iter() + .find(|field| field.field_type == FieldType::DateTime) + .unwrap(); + + // create a new row + let row = test + .create_row(&calendar_view.id, OrderObjectPositionPB::default(), None) + .await; + + // Insert data into the date cell of the first row. + let error = test + .update_date_cell(DateCellChangesetPB { + cell_id: CellIdPB { + view_id: calendar_view.id.clone(), + field_id: date_field.id.clone(), + row_id: row.id, + }, + timestamp: Some(timestamp()), + ..Default::default() + }) + .await; + assert!(error.is_none()); + + let events = test.get_all_calendar_events(&calendar_view.id).await; + assert_eq!(events.len(), 1); +} + +#[tokio::test] +async fn update_relation_cell_test() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let relation_field = test.create_field(&grid_view.id, FieldType::Relation).await; + let database = test.get_database(&grid_view.id).await; + + // update the relation cell + let changeset = RelationCellChangesetPB { + view_id: grid_view.id.clone(), + cell_id: CellIdPB { + view_id: grid_view.id.clone(), + field_id: relation_field.id.clone(), + row_id: database.rows[0].id.clone(), + }, + inserted_row_ids: vec![ + "row1rowid".to_string(), + "row2rowid".to_string(), + "row3rowid".to_string(), + ], + ..Default::default() + }; + test.update_relation_cell(changeset).await; + + // get the cell + let cell = test + .get_relation_cell(&grid_view.id, &relation_field.id, &database.rows[0].id) + .await; + + assert_eq!(cell.row_ids.len(), 3); + + // update the relation cell + let changeset = RelationCellChangesetPB { + view_id: grid_view.id.clone(), + cell_id: CellIdPB { + view_id: grid_view.id.clone(), + field_id: relation_field.id.clone(), + row_id: database.rows[0].id.clone(), + }, + removed_row_ids: vec![ + "row1rowid".to_string(), + "row3rowid".to_string(), + "row4rowid".to_string(), + ], + ..Default::default() + }; + test.update_relation_cell(changeset).await; + + // get the cell + let cell = test + .get_relation_cell(&grid_view.id, &relation_field.id, &database.rows[0].id) + .await; + + assert_eq!(cell.row_ids.len(), 1); +} + +#[tokio::test] +async fn get_detailed_relation_cell_data() { + let test = EventIntegrationTest::new_anon().await; + let current_workspace = test.get_current_workspace().await; + + let origin_grid_view = test + .create_grid(¤t_workspace.id, "origin".to_owned(), vec![]) + .await; + let relation_grid_view = test + .create_grid(¤t_workspace.id, "relation grid".to_owned(), vec![]) + .await; + let relation_field = test + .create_field(&relation_grid_view.id, FieldType::Relation) + .await; + + let origin_database = test.get_database(&origin_grid_view.id).await; + let origin_fields = test.get_all_database_fields(&origin_grid_view.id).await; + let linked_row = origin_database.rows[0].clone(); + + test + .update_cell(CellChangesetPB { + view_id: origin_grid_view.id.clone(), + row_id: linked_row.id.clone(), + field_id: origin_fields.items[0].id.clone(), + cell_changeset: "hello world".to_string(), + }) + .await; + + let new_database = test.get_database(&relation_grid_view.id).await; + + // update the relation cell + let changeset = RelationCellChangesetPB { + view_id: relation_grid_view.id.clone(), + cell_id: CellIdPB { + view_id: relation_grid_view.id.clone(), + field_id: relation_field.id.clone(), + row_id: new_database.rows[0].id.clone(), + }, + inserted_row_ids: vec![linked_row.id.clone()], + ..Default::default() + }; + test.update_relation_cell(changeset).await; + + // get the cell + let cell = test + .get_relation_cell( + &relation_grid_view.id, + &relation_field.id, + &new_database.rows[0].id, + ) + .await; + + // using the row ids, get the row data + let rows = test + .get_related_row_data(origin_database.id.clone(), cell.row_ids) + .await; + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name, "hello world"); +} diff --git a/frontend/rust-lib/event-integration-test/tests/database/local_test/group_test.rs b/frontend/rust-lib/event-integration-test/tests/database/local_test/group_test.rs index 8c97b3e7ce..7a54deebb9 100644 --- a/frontend/rust-lib/event-integration-test/tests/database/local_test/group_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/database/local_test/group_test.rs @@ -94,7 +94,6 @@ async fn rename_group_event_test() { .update_group( &board_view.id, &groups[1].group_id, - &groups[1].field_id, Some("new name".to_owned()), None, ) @@ -115,13 +114,7 @@ async fn hide_group_event_test() { assert_eq!(groups.len(), 4); let error = test - .update_group( - &board_view.id, - &groups[0].group_id, - &groups[0].field_id, - None, - Some(false), - ) + .update_group(&board_view.id, &groups[0].group_id, None, Some(false)) .await; assert!(error.is_none()); @@ -145,7 +138,6 @@ async fn update_group_name_test() { .update_group( &board_view.id, &groups[1].group_id, - &groups[1].field_id, Some("To Do?".to_string()), None, ) diff --git a/frontend/rust-lib/event-integration-test/tests/database/local_test/mod.rs b/frontend/rust-lib/event-integration-test/tests/database/local_test/mod.rs index 8b91f85113..f1c1b64a54 100644 --- a/frontend/rust-lib/event-integration-test/tests/database/local_test/mod.rs +++ b/frontend/rust-lib/event-integration-test/tests/database/local_test/mod.rs @@ -1,2 +1,3 @@ +mod calculate_test; +mod event_test; mod group_test; -mod test; diff --git a/frontend/rust-lib/event-integration-test/tests/database/local_test/test.rs b/frontend/rust-lib/event-integration-test/tests/database/local_test/test.rs deleted file mode 100644 index f0c78d15b3..0000000000 --- a/frontend/rust-lib/event-integration-test/tests/database/local_test/test.rs +++ /dev/null @@ -1,899 +0,0 @@ -use std::convert::TryFrom; - -use bytes::Bytes; - -use event_integration_test::event_builder::EventBuilder; -use event_integration_test::EventIntegrationTest; -use flowy_database2::entities::{ - CellChangesetPB, CellIdPB, CheckboxCellDataPB, ChecklistCellDataChangesetPB, DatabaseLayoutPB, - DatabaseSettingChangesetPB, DatabaseViewIdPB, DateCellChangesetPB, FieldType, - OrderObjectPositionPB, RelationCellChangesetPB, SelectOptionCellDataPB, UpdateRowMetaChangesetPB, -}; -use lib_infra::util::timestamp; - -#[tokio::test] -async fn get_database_id_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - // The view id can be used to get the database id. - let database_id = EventBuilder::new(test.clone()) - .event(flowy_database2::event_map::DatabaseEvent::GetDatabaseId) - .payload(DatabaseViewIdPB { - value: grid_view.id.clone(), - }) - .async_send() - .await - .parse::() - .value; - - assert_ne!(database_id, grid_view.id); -} - -#[tokio::test] -async fn get_database_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - let database = test.get_database(&grid_view.id).await; - assert_eq!(database.fields.len(), 3); - assert_eq!(database.rows.len(), 3); - assert_eq!(database.layout_type, DatabaseLayoutPB::Grid); -} - -#[tokio::test] -async fn get_field_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - let fields = test.get_all_database_fields(&grid_view.id).await.items; - assert_eq!(fields[0].field_type, FieldType::RichText); - assert_eq!(fields[1].field_type, FieldType::SingleSelect); - assert_eq!(fields[2].field_type, FieldType::Checkbox); - assert_eq!(fields.len(), 3); -} - -#[tokio::test] -async fn create_field_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - test.create_field(&grid_view.id, FieldType::Checkbox).await; - let fields = test.get_all_database_fields(&grid_view.id).await.items; - assert_eq!(fields.len(), 4); - assert_eq!(fields[3].field_type, FieldType::Checkbox); -} - -#[tokio::test] -async fn delete_field_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - let fields = test.get_all_database_fields(&grid_view.id).await.items; - assert_eq!(fields[0].field_type, FieldType::RichText); - assert_eq!(fields[1].field_type, FieldType::SingleSelect); - assert_eq!(fields[2].field_type, FieldType::Checkbox); - - let error = test.delete_field(&grid_view.id, &fields[1].id).await; - assert!(error.is_none()); - - let fields = test.get_all_database_fields(&grid_view.id).await.items; - assert_eq!(fields.len(), 2); -} - -// The primary field is not allowed to be deleted. -#[tokio::test] -async fn delete_primary_field_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - let fields = test.get_all_database_fields(&grid_view.id).await.items; - // the primary field is not allowed to be deleted. - assert!(fields[0].is_primary); - let error = test.delete_field(&grid_view.id, &fields[0].id).await; - assert!(error.is_some()); -} - -#[tokio::test] -async fn update_field_type_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - let fields = test.get_all_database_fields(&grid_view.id).await.items; - let error = test - .update_field_type(&grid_view.id, &fields[1].id, FieldType::Checklist) - .await; - assert!(error.is_none()); - - let fields = test.get_all_database_fields(&grid_view.id).await.items; - assert_eq!(fields[1].field_type, FieldType::Checklist); -} - -#[tokio::test] -async fn update_primary_field_type_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - let fields = test.get_all_database_fields(&grid_view.id).await.items; - // the primary field is not allowed to be deleted. - assert!(fields[0].is_primary); - - // the primary field is not allowed to be updated. - let error = test - .update_field_type(&grid_view.id, &fields[0].id, FieldType::Checklist) - .await; - assert!(error.is_some()); -} - -#[tokio::test] -async fn duplicate_field_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - let fields = test.get_all_database_fields(&grid_view.id).await.items; - // the primary field is not allowed to be updated. - let error = test.duplicate_field(&grid_view.id, &fields[1].id).await; - assert!(error.is_none()); - - let fields = test.get_all_database_fields(&grid_view.id).await.items; - assert_eq!(fields.len(), 4); -} - -// The primary field is not allowed to be duplicated. So this test should return an error. -#[tokio::test] -async fn duplicate_primary_field_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - let fields = test.get_all_database_fields(&grid_view.id).await.items; - // the primary field is not allowed to be duplicated. - let error = test.duplicate_field(&grid_view.id, &fields[0].id).await; - assert!(error.is_some()); -} - -#[tokio::test] -async fn get_primary_field_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - // By default the primary field type is RichText. - let field = test.get_primary_field(&grid_view.id).await; - assert_eq!(field.field_type, FieldType::RichText); -} - -#[tokio::test] -async fn create_row_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - let _ = test - .create_row(&grid_view.id, OrderObjectPositionPB::default(), None) - .await; - let database = test.get_database(&grid_view.id).await; - assert_eq!(database.rows.len(), 4); -} - -#[tokio::test] -async fn delete_row_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - // delete the row - let database = test.get_database(&grid_view.id).await; - let remove_row_id = database.rows[0].id.clone(); - assert_eq!(database.rows.len(), 3); - let error = test.delete_row(&grid_view.id, &remove_row_id).await; - assert!(error.is_none()); - - let database = test.get_database(&grid_view.id).await; - assert_eq!(database.rows.len(), 2); - - // get the row again and check if it is deleted. - let optional_row = test.get_row(&grid_view.id, &remove_row_id).await; - assert!(optional_row.row.is_none()); -} - -#[tokio::test] -async fn get_row_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - - let row = test.get_row(&grid_view.id, &database.rows[0].id).await.row; - assert!(row.is_some()); - - let row = test.get_row_meta(&grid_view.id, &database.rows[0].id).await; - assert!(!row.document_id.is_empty()); -} - -#[tokio::test] -async fn update_row_meta_event_with_url_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - - // By default the row icon is None. - let row = test.get_row_meta(&grid_view.id, &database.rows[0].id).await; - assert_eq!(row.icon, None); - - // Insert icon url to the row. - let changeset = UpdateRowMetaChangesetPB { - id: database.rows[0].id.clone(), - view_id: grid_view.id.clone(), - icon_url: Some("icon_url".to_owned()), - cover_url: None, - is_document_empty: None, - }; - let error = test.update_row_meta(changeset).await; - assert!(error.is_none()); - - // Check if the icon is updated. - let row = test.get_row_meta(&grid_view.id, &database.rows[0].id).await; - assert_eq!(row.icon, Some("icon_url".to_owned())); -} - -#[tokio::test] -async fn update_row_meta_event_with_cover_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - - // By default the row icon is None. - let row = test.get_row_meta(&grid_view.id, &database.rows[0].id).await; - assert_eq!(row.cover, None); - - // Insert cover to the row. - let changeset = UpdateRowMetaChangesetPB { - id: database.rows[0].id.clone(), - view_id: grid_view.id.clone(), - cover_url: Some("cover url".to_owned()), - icon_url: None, - is_document_empty: None, - }; - let error = test.update_row_meta(changeset).await; - assert!(error.is_none()); - - // Check if the icon is updated. - let row = test.get_row_meta(&grid_view.id, &database.rows[0].id).await; - assert_eq!(row.cover, Some("cover url".to_owned())); -} - -#[tokio::test] -async fn delete_row_event_with_invalid_row_id_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - // delete the row with empty row_id. It should return an error. - let error = test.delete_row(&grid_view.id, "").await; - assert!(error.is_some()); -} - -#[tokio::test] -async fn duplicate_row_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - let error = test - .duplicate_row(&grid_view.id, &database.rows[0].id) - .await; - assert!(error.is_none()); - - let database = test.get_database(&grid_view.id).await; - assert_eq!(database.rows.len(), 4); -} - -#[tokio::test] -async fn duplicate_row_event_with_invalid_row_id_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - assert_eq!(database.rows.len(), 3); - - let error = test.duplicate_row(&grid_view.id, "").await; - assert!(error.is_some()); - - let database = test.get_database(&grid_view.id).await; - assert_eq!(database.rows.len(), 3); -} - -#[tokio::test] -async fn move_row_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - let row_1 = database.rows[0].id.clone(); - let row_2 = database.rows[1].id.clone(); - let row_3 = database.rows[2].id.clone(); - let error = test.move_row(&grid_view.id, &row_1, &row_3).await; - assert!(error.is_none()); - - let database = test.get_database(&grid_view.id).await; - assert_eq!(database.rows[0].id, row_2); - assert_eq!(database.rows[1].id, row_3); - assert_eq!(database.rows[2].id, row_1); -} - -#[tokio::test] -async fn move_row_event_test2() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - let row_1 = database.rows[0].id.clone(); - let row_2 = database.rows[1].id.clone(); - let row_3 = database.rows[2].id.clone(); - let error = test.move_row(&grid_view.id, &row_2, &row_1).await; - assert!(error.is_none()); - - let database = test.get_database(&grid_view.id).await; - assert_eq!(database.rows[0].id, row_2); - assert_eq!(database.rows[1].id, row_1); - assert_eq!(database.rows[2].id, row_3); -} - -#[tokio::test] -async fn move_row_event_with_invalid_row_id_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - let row_1 = database.rows[0].id.clone(); - let row_2 = database.rows[1].id.clone(); - let row_3 = database.rows[2].id.clone(); - - for i in 0..2 { - if i == 0 { - let error = test.move_row(&grid_view.id, &row_1, "").await; - assert!(error.is_some()); - } else { - let error = test.move_row(&grid_view.id, "", &row_1).await; - assert!(error.is_some()); - } - let database = test.get_database(&grid_view.id).await; - assert_eq!(database.rows[0].id, row_1); - assert_eq!(database.rows[1].id, row_2); - assert_eq!(database.rows[2].id, row_3); - } -} - -#[tokio::test] -async fn update_text_cell_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - let fields = test.get_all_database_fields(&grid_view.id).await.items; - - let row_id = database.rows[0].id.clone(); - let field_id = fields[0].id.clone(); - assert_eq!(fields[0].field_type, FieldType::RichText); - - // Update the first cell of the first row. - let error = test - .update_cell(CellChangesetPB { - view_id: grid_view.id.clone(), - row_id: row_id.clone(), - field_id: field_id.clone(), - cell_changeset: "hello world".to_string(), - }) - .await; - assert!(error.is_none()); - - let cell = test.get_cell(&grid_view.id, &row_id, &field_id).await; - let s = String::from_utf8(cell.data).unwrap(); - assert_eq!(s, "hello world"); -} - -#[tokio::test] -async fn update_checkbox_cell_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - let fields = test.get_all_database_fields(&grid_view.id).await.items; - - let row_id = database.rows[0].id.clone(); - let field_id = fields[2].id.clone(); - assert_eq!(fields[2].field_type, FieldType::Checkbox); - - for input in &["yes", "true", "1"] { - let error = test - .update_cell(CellChangesetPB { - view_id: grid_view.id.clone(), - row_id: row_id.clone(), - field_id: field_id.clone(), - cell_changeset: input.to_string(), - }) - .await; - assert!(error.is_none()); - - let cell = test.get_cell(&grid_view.id, &row_id, &field_id).await; - let output = CheckboxCellDataPB::try_from(Bytes::from(cell.data)).unwrap(); - assert!(output.is_checked); - } -} - -#[tokio::test] -async fn update_single_select_cell_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - let fields = test.get_all_database_fields(&grid_view.id).await.items; - let row_id = database.rows[0].id.clone(); - let field_id = fields[1].id.clone(); - assert_eq!(fields[1].field_type, FieldType::SingleSelect); - - // Insert a new option. This should update the cell with the new option. - let error = test - .insert_option(&grid_view.id, &field_id, &row_id, "task 1") - .await; - assert!(error.is_none()); - - // Check that the cell data is updated. - let cell = test.get_cell(&grid_view.id, &row_id, &field_id).await; - let select_option_cell = SelectOptionCellDataPB::try_from(Bytes::from(cell.data)).unwrap(); - - assert_eq!(select_option_cell.select_options.len(), 1); -} - -#[tokio::test] -async fn update_date_cell_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - - // Create a date field - let date_field = test.create_field(&grid_view.id, FieldType::DateTime).await; - - let cell_path = CellIdPB { - view_id: grid_view.id.clone(), - field_id: date_field.id.clone(), - row_id: database.rows[0].id.clone(), - }; - - // Insert data into the date cell of the first row. - let timestamp = 1686300557; - let error = test - .update_date_cell(DateCellChangesetPB { - cell_id: cell_path, - date: Some(timestamp), - ..Default::default() - }) - .await; - assert!(error.is_none()); - - // Check that the cell data is updated. - let cell = test - .get_date_cell(&grid_view.id, &database.rows[0].id, &date_field.id) - .await; - assert_eq!(cell.date, "Jun 09, 2023"); - assert_eq!(cell.timestamp, timestamp); -} - -#[tokio::test] -async fn update_date_cell_event_with_empty_time_str_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let database = test.get_database(&grid_view.id).await; - let row_id = database.rows[0].id.clone(); - - // Create a date field - let date_field = test.create_field(&grid_view.id, FieldType::DateTime).await; - let cell_path = CellIdPB { - view_id: grid_view.id.clone(), - field_id: date_field.id.clone(), - row_id: row_id.clone(), - }; - - // Insert empty timestamp string - let error = test - .update_date_cell(DateCellChangesetPB { - cell_id: cell_path, - date: None, - ..Default::default() - }) - .await; - assert!(error.is_none()); - - // Check that the cell data is updated. - let cell = test - .get_date_cell(&grid_view.id, &row_id, &date_field.id) - .await; - assert_eq!(cell.date, ""); - assert_eq!(cell.timestamp, 0); -} - -#[tokio::test] -async fn create_checklist_field_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - // create checklist field - let checklist_field = test.create_field(&grid_view.id, FieldType::Checklist).await; - let database = test.get_database(&grid_view.id).await; - - // Get the checklist cell - let cell = test - .get_checklist_cell(&grid_view.id, &checklist_field.id, &database.rows[0].id) - .await; - assert_eq!(cell.options.len(), 0); - assert_eq!(cell.selected_options.len(), 0); - assert_eq!(cell.percentage, 0.0); -} - -#[tokio::test] -async fn update_checklist_cell_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - // create checklist field - let checklist_field = test.create_field(&grid_view.id, FieldType::Checklist).await; - let database = test.get_database(&grid_view.id).await; - - // update the checklist cell - let changeset = ChecklistCellDataChangesetPB { - view_id: grid_view.id.clone(), - row_id: database.rows[0].id.clone(), - field_id: checklist_field.id.clone(), - insert_options: vec![ - "task 1".to_string(), - "task 2".to_string(), - "task 3".to_string(), - ], - selected_option_ids: vec![], - delete_option_ids: vec![], - update_options: vec![], - }; - test.update_checklist_cell(changeset).await; - - // get the cell - let cell = test - .get_checklist_cell(&grid_view.id, &checklist_field.id, &database.rows[0].id) - .await; - - assert_eq!(cell.options.len(), 3); - assert_eq!(cell.selected_options.len(), 0); - - // select some options - let changeset = ChecklistCellDataChangesetPB { - view_id: grid_view.id.clone(), - row_id: database.rows[0].id.clone(), - field_id: checklist_field.id.clone(), - selected_option_ids: vec![cell.options[0].id.clone(), cell.options[1].id.clone()], - ..Default::default() - }; - test.update_checklist_cell(changeset).await; - - // get the cell - let cell = test - .get_checklist_cell(&grid_view.id, &checklist_field.id, &database.rows[0].id) - .await; - - assert_eq!(cell.options.len(), 3); - assert_eq!(cell.selected_options.len(), 2); - assert_eq!(cell.percentage, 0.67); -} - -// Update the database layout type from grid to board -#[tokio::test] -async fn update_database_layout_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - - let error = test - .update_setting(DatabaseSettingChangesetPB { - view_id: grid_view.id.clone(), - layout_type: Some(DatabaseLayoutPB::Board), - ..Default::default() - }) - .await; - assert!(error.is_none()); - - let database = test.get_database(&grid_view.id).await; - assert_eq!(database.layout_type, DatabaseLayoutPB::Board); -} - -// Update the database layout type from grid to board. Set the checkbox field as the grouping field -#[tokio::test] -async fn update_database_layout_event_test2() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let fields = test.get_all_database_fields(&grid_view.id).await.items; - - let checkbox_field = fields - .iter() - .find(|field| field.field_type == FieldType::Checkbox) - .unwrap(); - test - .set_group_by_field(&grid_view.id, &checkbox_field.id) - .await; - - let error = test - .update_setting(DatabaseSettingChangesetPB { - view_id: grid_view.id.clone(), - layout_type: Some(DatabaseLayoutPB::Board), - ..Default::default() - }) - .await; - assert!(error.is_none()); - - // Empty to group id - let groups = test.get_groups(&grid_view.id).await; - assert_eq!(groups.len(), 2); -} - -// Create a checkbox field in the default board and then set it as the grouping field. -#[tokio::test] -async fn set_group_by_checkbox_field_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let board_view = test - .create_board(¤t_workspace.id, "my board view".to_owned(), vec![]) - .await; - - let checkbox_field = test.create_field(&board_view.id, FieldType::Checkbox).await; - test - .set_group_by_field(&board_view.id, &checkbox_field.id) - .await; - - let groups = test.get_groups(&board_view.id).await; - assert_eq!(groups.len(), 2); -} - -#[tokio::test] -async fn get_all_calendar_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let calendar_view = test - .create_calendar(¤t_workspace.id, "my calendar view".to_owned(), vec![]) - .await; - - // By default, there should be no events - let events = test.get_all_calendar_events(&calendar_view.id).await; - assert!(events.is_empty()); -} - -#[tokio::test] -async fn create_calendar_event_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let calendar_view = test - .create_calendar(¤t_workspace.id, "my calendar view".to_owned(), vec![]) - .await; - let fields = test.get_all_database_fields(&calendar_view.id).await.items; - let date_field = fields - .iter() - .find(|field| field.field_type == FieldType::DateTime) - .unwrap(); - - // create a new row - let row = test - .create_row(&calendar_view.id, OrderObjectPositionPB::default(), None) - .await; - - // Insert data into the date cell of the first row. - let error = test - .update_date_cell(DateCellChangesetPB { - cell_id: CellIdPB { - view_id: calendar_view.id.clone(), - field_id: date_field.id.clone(), - row_id: row.id, - }, - date: Some(timestamp()), - ..Default::default() - }) - .await; - assert!(error.is_none()); - - let events = test.get_all_calendar_events(&calendar_view.id).await; - assert_eq!(events.len(), 1); -} - -#[tokio::test] -async fn update_relation_cell_test() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - let grid_view = test - .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) - .await; - let relation_field = test.create_field(&grid_view.id, FieldType::Relation).await; - let database = test.get_database(&grid_view.id).await; - - // update the relation cell - let changeset = RelationCellChangesetPB { - view_id: grid_view.id.clone(), - cell_id: CellIdPB { - view_id: grid_view.id.clone(), - field_id: relation_field.id.clone(), - row_id: database.rows[0].id.clone(), - }, - inserted_row_ids: vec![ - "row1rowid".to_string(), - "row2rowid".to_string(), - "row3rowid".to_string(), - ], - ..Default::default() - }; - test.update_relation_cell(changeset).await; - - // get the cell - let cell = test - .get_relation_cell(&grid_view.id, &relation_field.id, &database.rows[0].id) - .await; - - assert_eq!(cell.row_ids.len(), 3); - - // update the relation cell - let changeset = RelationCellChangesetPB { - view_id: grid_view.id.clone(), - cell_id: CellIdPB { - view_id: grid_view.id.clone(), - field_id: relation_field.id.clone(), - row_id: database.rows[0].id.clone(), - }, - removed_row_ids: vec![ - "row1rowid".to_string(), - "row3rowid".to_string(), - "row4rowid".to_string(), - ], - ..Default::default() - }; - test.update_relation_cell(changeset).await; - - // get the cell - let cell = test - .get_relation_cell(&grid_view.id, &relation_field.id, &database.rows[0].id) - .await; - - assert_eq!(cell.row_ids.len(), 1); -} - -#[tokio::test] -async fn get_detailed_relation_cell_data() { - let test = EventIntegrationTest::new_anon().await; - let current_workspace = test.get_current_workspace().await; - - let origin_grid_view = test - .create_grid(¤t_workspace.id, "origin".to_owned(), vec![]) - .await; - let relation_grid_view = test - .create_grid(¤t_workspace.id, "relation grid".to_owned(), vec![]) - .await; - let relation_field = test - .create_field(&relation_grid_view.id, FieldType::Relation) - .await; - - let origin_database = test.get_database(&origin_grid_view.id).await; - let origin_fields = test.get_all_database_fields(&origin_grid_view.id).await; - let linked_row = origin_database.rows[0].clone(); - - test - .update_cell(CellChangesetPB { - view_id: origin_grid_view.id.clone(), - row_id: linked_row.id.clone(), - field_id: origin_fields.items[0].id.clone(), - cell_changeset: "hello world".to_string(), - }) - .await; - - let new_database = test.get_database(&relation_grid_view.id).await; - - // update the relation cell - let changeset = RelationCellChangesetPB { - view_id: relation_grid_view.id.clone(), - cell_id: CellIdPB { - view_id: relation_grid_view.id.clone(), - field_id: relation_field.id.clone(), - row_id: new_database.rows[0].id.clone(), - }, - inserted_row_ids: vec![linked_row.id.clone()], - ..Default::default() - }; - test.update_relation_cell(changeset).await; - - // get the cell - let cell = test - .get_relation_cell( - &relation_grid_view.id, - &relation_field.id, - &new_database.rows[0].id, - ) - .await; - - // using the row ids, get the row data - let rows = test - .get_related_row_data(origin_database.id.clone(), cell.row_ids) - .await; - - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].name, "hello world"); -} diff --git a/frontend/rust-lib/event-integration-test/tests/database/mod.rs b/frontend/rust-lib/event-integration-test/tests/database/mod.rs index ee1335d7ff..0468498a58 100644 --- a/frontend/rust-lib/event-integration-test/tests/database/mod.rs +++ b/frontend/rust-lib/event-integration-test/tests/database/mod.rs @@ -1,5 +1,2 @@ mod af_cloud; mod local_test; - -// #[cfg(feature = "supabase_cloud_test")] -// mod supabase_test; diff --git a/frontend/rust-lib/event-integration-test/tests/database/supabase_test/helper.rs b/frontend/rust-lib/event-integration-test/tests/database/supabase_test/helper.rs index 8c668afeac..c1874a5004 100644 --- a/frontend/rust-lib/event-integration-test/tests/database/supabase_test/helper.rs +++ b/frontend/rust-lib/event-integration-test/tests/database/supabase_test/helper.rs @@ -90,7 +90,7 @@ pub fn assert_database_collab_content( )); collab.lock().with_origin_transact_mut(|txn| { let update = Update::decode_v1(collab_update).unwrap(); - txn.apply_update(update); + txn.apply_update(update).unwrap(); }); let json = collab.to_json_value(); diff --git a/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/edit_test.rs b/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/edit_test.rs index 50a8fd3fd8..fb0417bdd0 100644 --- a/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/edit_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/edit_test.rs @@ -1,18 +1,17 @@ +use crate::util::{receive_with_timeout, unzip}; use collab_document::blocks::DocumentData; -use serde_json::json; -use std::time::Duration; - +use collab_folder::SpaceInfo; use event_integration_test::document_event::assert_document_data_equal; -use event_integration_test::user_event::user_localhost_af_cloud; +use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; use flowy_core::DEFAULT_NAME; use flowy_document::entities::{DocumentSyncState, DocumentSyncStatePB}; - -use crate::util::{receive_with_timeout, unzip}; +use serde_json::json; +use std::time::Duration; #[tokio::test] async fn af_cloud_edit_document_test() { - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test = EventIntegrationTest::new().await; test.af_cloud_sign_up().await; test.wait_ws_connected().await; @@ -31,7 +30,7 @@ async fn af_cloud_edit_document_test() { let rx = test .notification_sender .subscribe_with_condition::(&document_id, |pb| { - pb.value != DocumentSyncState::Syncing + pb.value == DocumentSyncState::SyncFinished }); let _ = receive_with_timeout(rx, Duration::from_secs(30)).await; @@ -43,8 +42,8 @@ async fn af_cloud_edit_document_test() { #[tokio::test] async fn af_cloud_sync_anon_user_document_test() { - let (cleaner, user_db_path) = unzip("./tests/asset", "040_sync_local_document").unwrap(); - user_localhost_af_cloud().await; + let user_db_path = unzip("./tests/asset", "040_sync_local_document").unwrap(); + use_localhost_af_cloud().await; let test = EventIntegrationTest::new_with_user_data_path(user_db_path.clone(), DEFAULT_NAME.to_string()) .await; @@ -55,8 +54,13 @@ async fn af_cloud_sync_anon_user_document_test() { // workspace: // view: SyncDocument let views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 2); - let document_id = views[1].id.clone(); + assert_eq!(views.len(), 3); + for view in views.iter() { + let space_info = serde_json::from_str::(view.extra.as_ref().unwrap()).unwrap(); + assert!(space_info.is_space); + } + + let document_id = views[2].id.clone(); test.open_document(document_id.clone()).await; // wait all update are send to the remote @@ -73,8 +77,6 @@ async fn af_cloud_sync_anon_user_document_test() { &document_id, expected_040_sync_local_document_data(), ); - - drop(cleaner); } fn expected_040_sync_local_document_data() -> DocumentData { diff --git a/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs b/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs new file mode 100644 index 0000000000..7d8ecc9680 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs @@ -0,0 +1,207 @@ +use crate::document::generate_random_bytes; +use event_integration_test::user_event::use_localhost_af_cloud; +use event_integration_test::{EventIntegrationTest, SINGLE_FILE_UPLOAD_SIZE}; +use flowy_storage_pub::storage::FileUploadState; +use lib_infra::util::md5; +use std::env::temp_dir; +use std::sync::Arc; +use std::time::Duration; +use tokio::fs; +use tokio::fs::File; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; +use tokio::time::timeout; + +#[tokio::test] +async fn af_cloud_upload_big_file_test() { + use_localhost_af_cloud().await; + let mut test = EventIntegrationTest::new().await; + test.af_cloud_sign_up().await; + tokio::time::sleep(Duration::from_secs(6)).await; + let parent_dir = "temp_test"; + let workspace_id = test.get_current_workspace().await.id; + let (file_path, upload_data) = generate_file_with_bytes_len(15 * 1024 * 1024).await; + let (created_upload, rx) = test + .storage_manager + .storage_service + .create_upload(&workspace_id, parent_dir, &file_path) + .await + .unwrap(); + + let mut rx = rx.unwrap(); + while let Ok(state) = rx.recv().await { + if let FileUploadState::Uploading { progress } = state { + if progress > 0.1 { + break; + } + } + } + + // Simulate a restart + let config = test.config.clone(); + test.skip_clean(); + drop(test); + tokio::time::sleep(Duration::from_secs(3)).await; + + // Restart the test. It will load unfinished uploads + let test = EventIntegrationTest::new_with_config(config).await; + if let Some(mut rx) = test + .storage_manager + .subscribe_file_state(parent_dir, &created_upload.file_id) + .await + .unwrap() + { + let timeout_duration = Duration::from_secs(180); + while let Ok(state) = match timeout(timeout_duration, rx.recv()).await { + Ok(result) => result, + Err(_) => { + panic!("Timed out waiting for file upload completion"); + }, + } { + if let FileUploadState::Finished { .. } = state { + break; + } + } + } + + // download the file and then compare the data. + let file_service = test + .appflowy_core + .server_provider + .get_server() + .unwrap() + .file_storage() + .unwrap(); + let file = file_service.get_object(created_upload.url).await.unwrap(); + assert_eq!(md5(file.raw), md5(upload_data)); + let _ = fs::remove_file(file_path).await; +} + +#[tokio::test] +async fn af_cloud_upload_6_files_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.af_cloud_sign_up().await; + let workspace_id = test.get_current_workspace().await.id; + + let mut created_uploads = vec![]; + let mut receivers = vec![]; + for file_size in [1, 2, 5, 8, 10, 12] { + let file_path = generate_file_with_bytes_len(file_size * 1024 * 1024) + .await + .0; + let (created_upload, rx) = test + .storage_manager + .storage_service + .create_upload(&workspace_id, "temp_test", &file_path) + .await + .unwrap(); + receivers.push(rx.unwrap()); + created_uploads.push(created_upload); + let _ = fs::remove_file(file_path).await; + } + + // Wait for all uploads to finish + let uploads = Arc::new(Mutex::new(created_uploads)); + let mut handles = vec![]; + + for mut receiver in receivers { + let cloned_uploads = uploads.clone(); + let state = test.storage_manager.get_file_state(&receiver.file_id).await; + let handle = tokio::spawn(async move { + if let Some(FileUploadState::Finished { file_id }) = state { + cloned_uploads + .lock() + .await + .retain(|upload| upload.file_id != file_id); + } + while let Ok(value) = receiver.recv().await { + if let FileUploadState::Finished { file_id } = value { + cloned_uploads + .lock() + .await + .retain(|upload| upload.file_id != file_id); + break; + } + } + }); + handles.push(handle); + } + + // join all handles + futures::future::join_all(handles).await; + assert_eq!(uploads.lock().await.len(), 0); +} + +#[tokio::test] +async fn af_cloud_delete_upload_file_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.af_cloud_sign_up().await; + let workspace_id = test.get_current_workspace().await.id; + + // Pause sync + test.storage_manager.update_network_reachable(false); + let mut created_uploads = vec![]; + let mut receivers = vec![]; + + // file that exceeds limit will be deleted automatically + let exceed_file_limit_size = SINGLE_FILE_UPLOAD_SIZE + 1; + for file_size in [8 * 1024 * 1024, exceed_file_limit_size, 10 * 1024 * 1024] { + let file_path = generate_file_with_bytes_len(file_size).await.0; + let (created_upload, rx) = test + .storage_manager + .storage_service + .create_upload(&workspace_id, "temp_test", &file_path) + .await + .unwrap(); + receivers.push(rx.unwrap()); + created_uploads.push(created_upload); + let _ = fs::remove_file(file_path).await; + } + + test + .storage_manager + .storage_service + .delete_object(created_uploads[0].clone().url) + .await + .unwrap(); + + let mut handles = vec![]; + // start sync + test.storage_manager.update_network_reachable(true); + + for mut receiver in receivers { + let handle = tokio::spawn(async move { + while let Ok(Ok(value)) = timeout(Duration::from_secs(60), receiver.recv()).await { + if let FileUploadState::Finished { file_id } = value { + println!("file_id: {} was uploaded", file_id); + break; + } + } + }); + handles.push(handle); + } + + // join all handles + futures::future::join_all(handles).await; + let tasks = test.storage_manager.get_all_tasks().await.unwrap(); + assert!(tasks.is_empty()); + + let state = test + .storage_manager + .get_file_state(&created_uploads[2].file_id) + .await + .unwrap(); + assert!(matches!(state, FileUploadState::Finished { .. })); +} + +async fn generate_file_with_bytes_len(len: usize) -> (String, Vec) { + let data = generate_random_bytes(len); + let file_dir = temp_dir().join(uuid::Uuid::new_v4().to_string()); + let file_path = file_dir.to_str().unwrap().to_string(); + let mut file = File::create(file_dir).await.unwrap(); + file.write_all(&data).await.unwrap(); + + (file_path, data) +} diff --git a/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/mod.rs b/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/mod.rs index 0e50d38f75..c63deb8798 100644 --- a/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/mod.rs +++ b/frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/mod.rs @@ -1 +1,2 @@ mod edit_test; +mod file_upload_test; diff --git a/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs b/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs index cfcefeb506..d9273dbe8b 100644 --- a/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs @@ -8,6 +8,8 @@ use flowy_document::parser::parser_entities::{ }; use serde_json::{json, Value}; use std::collections::HashMap; +use std::str::FromStr; +use uuid::Uuid; #[tokio::test] async fn get_document_event_test() { @@ -19,6 +21,16 @@ async fn get_document_event_test() { assert!(document_data.blocks.len() > 1); } +#[tokio::test] +async fn get_encoded_collab_event_test() { + let test = DocumentEventTest::new().await; + let view = test.create_document().await; + let doc_id = view.id.clone(); + let encoded_v1 = test.get_encoded_collab(&doc_id).await; + assert!(!encoded_v1.doc_state.is_empty()); + assert!(!encoded_v1.state_vector.is_empty()); +} + #[tokio::test] async fn apply_document_event_test() { let test = DocumentEventTest::new().await; @@ -91,8 +103,8 @@ async fn document_size_test() { let s = generate_random_string(string_size); test.insert_index(&view.id, &s, 1, None).await; } - - let encoded_v1 = test.get_encoded_v1(&view.id).await; + let view_id = Uuid::from_str(&view.id).unwrap(); + let encoded_v1 = test.get_encoded_v1(&view_id).await; if encoded_v1.doc_state.len() > max_size { panic!( "The document size is too large. {}", diff --git a/frontend/rust-lib/event-integration-test/tests/document/mod.rs b/frontend/rust-lib/event-integration-test/tests/document/mod.rs index ba2833ee49..e11cb782f4 100644 --- a/frontend/rust-lib/event-integration-test/tests/document/mod.rs +++ b/frontend/rust-lib/event-integration-test/tests/document/mod.rs @@ -4,7 +4,7 @@ mod af_cloud_test; // #[cfg(feature = "supabase_cloud_test")] // mod supabase_test; -use rand::{distributions::Alphanumeric, Rng}; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; pub fn generate_random_string(len: usize) -> String { let rng = rand::thread_rng(); @@ -14,3 +14,12 @@ pub fn generate_random_string(len: usize) -> String { .map(char::from) .collect() } + +pub fn generate_random_bytes(size: usize) -> Vec { + let s: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(size) + .map(char::from) + .collect(); + s.into_bytes() +} diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs index 7523f5ab0f..5857190b8b 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs @@ -1,6 +1,8 @@ use collab_folder::ViewLayout; - +use event_integration_test::EventIntegrationTest; use flowy_folder::entities::icon::{ViewIconPB, ViewIconTypePB}; +use flowy_folder::entities::ViewLayoutPB; +use uuid::Uuid; use crate::folder::local_test::script::FolderScript::*; use crate::folder::local_test::script::FolderTest; @@ -331,3 +333,17 @@ async fn move_view_event_test() { assert_eq!(after_view_ids[0], view_ids[1]); assert_eq!(after_view_ids[1], view_ids[0]); } + +#[tokio::test] +async fn create_orphan_child_view_and_get_its_ancestors_test() { + let test = EventIntegrationTest::new_anon().await; + let name = "Orphan View"; + let view_id = Uuid::new_v4().to_string(); + test + .create_orphan_view(name, &view_id, ViewLayoutPB::Grid) + .await; + let ancestors = test.get_view_ancestors(&view_id).await; + assert_eq!(ancestors.len(), 1); + assert_eq!(ancestors[0].name, "Orphan View"); + assert_eq!(ancestors[0].id, view_id); +} diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/import_test.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/import_test.rs index a93a232921..28a7fdffa5 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/local_test/import_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/import_test.rs @@ -1,13 +1,13 @@ use crate::util::unzip; use event_integration_test::EventIntegrationTest; use flowy_core::DEFAULT_NAME; -use flowy_folder::entities::{ImportPB, ImportTypePB, ViewLayoutPB}; +use flowy_folder::entities::{ImportItemPayloadPB, ImportPayloadPB, ImportTypePB, ViewLayoutPB}; #[tokio::test] async fn import_492_row_csv_file_test() { // csv_500r_15c.csv is a file with 492 rows and 17 columns let file_name = "csv_492r_17c.csv".to_string(); - let (cleaner, csv_file_path) = unzip("./tests/asset", &file_name).unwrap(); + let csv_file_path = unzip("./tests/asset", &file_name).unwrap(); let csv_string = std::fs::read_to_string(csv_file_path).unwrap(); let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await; @@ -16,17 +16,17 @@ async fn import_492_row_csv_file_test() { let workspace_id = test.get_current_workspace().await.id; let import_data = gen_import_data(file_name, csv_string, workspace_id); - let view = test.import_data(import_data).await; - let database = test.get_database(&view.id).await; + let views = test.import_data(import_data).await; + let view_id = views[0].clone().id; + let database = test.get_database(&view_id).await; assert_eq!(database.rows.len(), 492); - drop(cleaner); } #[tokio::test] async fn import_10240_row_csv_file_test() { // csv_22577r_15c.csv is a file with 10240 rows and 15 columns let file_name = "csv_10240r_15c.csv".to_string(); - let (cleaner, csv_file_path) = unzip("./tests/asset", &file_name).unwrap(); + let csv_file_path = unzip("./tests/asset", &file_name).unwrap(); let csv_string = std::fs::read_to_string(csv_file_path).unwrap(); let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await; @@ -35,21 +35,21 @@ async fn import_10240_row_csv_file_test() { let workspace_id = test.get_current_workspace().await.id; let import_data = gen_import_data(file_name, csv_string, workspace_id); - let view = test.import_data(import_data).await; - let database = test.get_database(&view.id).await; + let views = test.import_data(import_data).await; + let view_id = views[0].clone().id; + let database = test.get_database(&view_id).await; assert_eq!(database.rows.len(), 10240); - - drop(cleaner); } -fn gen_import_data(file_name: String, csv_string: String, workspace_id: String) -> ImportPB { - let import_data = ImportPB { +fn gen_import_data(file_name: String, csv_string: String, workspace_id: String) -> ImportPayloadPB { + ImportPayloadPB { parent_view_id: workspace_id.clone(), - name: file_name, - data: Some(csv_string.as_bytes().to_vec()), - file_path: None, - view_layout: ViewLayoutPB::Grid, - import_type: ImportTypePB::CSV, - }; - import_data + items: vec![ImportItemPayloadPB { + name: file_name, + data: Some(csv_string.as_bytes().to_vec()), + file_path: None, + view_layout: ViewLayoutPB::Grid, + import_type: ImportTypePB::CSV, + }], + } } diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/mod.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/mod.rs index aa58a02baf..66a0a62937 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/local_test/mod.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/mod.rs @@ -3,3 +3,6 @@ mod import_test; mod script; mod subscription_test; mod test; + +mod publish_database_test; +mod publish_document_test; diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_database_test.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_database_test.rs new file mode 100644 index 0000000000..2754c027d7 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_database_test.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; + +use collab_folder::ViewLayout; +use event_integration_test::EventIntegrationTest; +use flowy_folder::entities::{ + ImportItemPayloadPB, ImportPayloadPB, ImportTypePB, ViewLayoutPB, ViewPB, +}; +use flowy_folder::view_operation::GatherEncodedCollab; + +use crate::util::unzip; + +#[tokio::test] +async fn publish_single_database_test() { + let test = EventIntegrationTest::new_anon().await; + test.sign_up_as_anon().await; + + // import a csv file and try to get its publish collab + let grid = import_csv("publish_grid_primary.csv", &test).await; + + let grid_encoded_collab = test + .gather_encode_collab_from_disk(&grid.id, ViewLayout::Grid) + .await; + + match grid_encoded_collab { + GatherEncodedCollab::Database(encoded_collab) => { + // the len of row collabs should be the same as the number of rows in the csv file + let rows_len = encoded_collab.database_row_encoded_collabs.len(); + assert_eq!(rows_len, 18); + }, + _ => panic!("Expected database collab"), + } +} + +#[tokio::test] +async fn publish_databases_from_existing_workspace() { + let test = EventIntegrationTest::new_anon().await; + test.sign_up_as_anon().await; + + // import a workspace + // there's a sample screenshot of the workspace in the asset folder, + // unzip it and check the views if needed + let _ = import_workspace("064_database_publish", &test).await; + + let publish_database_set = test.get_all_views().await; + + let publish_grid_set = publish_database_set + .iter() + // there're 8 built-in grids in the workspace with the name starting with "publish grid" + .filter(|view| view.layout == ViewLayoutPB::Grid && view.name.starts_with("publish grid")) + .collect::>(); + + let publish_calendar_set = publish_database_set + .iter() + // there's 1 built-in calender in the workspace with the name starting with "publish calendar" + .filter(|view| view.layout == ViewLayoutPB::Calendar && view.name.starts_with("publish calendar")) + .collect::>(); + + let publish_board_set = publish_database_set + .iter() + // there's 1 built-in board in the workspace with the name starting with "publish board" + .filter(|view| view.layout == ViewLayoutPB::Board && view.name.starts_with("publish board")) + .collect::>(); + + let mut expectations: HashMap<&str, usize> = HashMap::new(); + // grid + // 5 rows + expectations.insert("publish grid (deprecated)", 5); + + // the following 7 grids are the same, just with different filters or sorting or layout + // to check if the collab is correctly generated + // 18 rows + expectations.insert("publish grid", 18); + // 18 rows + expectations.insert("publish grid (with board)", 18); + // 18 rows + expectations.insert("publish grid (with calendar)", 18); + // 18 rows + expectations.insert("publish grid (with grid)", 18); + // 18 rows + expectations.insert("publish grid (filtered)", 18); + // 18 rows + expectations.insert("publish grid (sorted)", 18); + + // calendar + expectations.insert("publish calendar", 2); + + // board + expectations.insert("publish board", 15); + + test_publish_encode_collab_result(&test, publish_grid_set, expectations.clone()).await; + + test_publish_encode_collab_result(&test, publish_calendar_set, expectations.clone()).await; + + test_publish_encode_collab_result(&test, publish_board_set, expectations.clone()).await; +} + +async fn test_publish_encode_collab_result( + test: &EventIntegrationTest, + views: Vec<&ViewPB>, + expectations: HashMap<&str, usize>, +) { + for view in views { + let id = view.id.clone(); + let layout = view.layout.clone(); + + test.open_database(&id).await; + + let encoded_collab = test + .gather_encode_collab_from_disk(&id, layout.into()) + .await; + + match encoded_collab { + GatherEncodedCollab::Database(encoded_collab) => { + if let Some(rows_len) = expectations.get(&view.name.as_str()) { + assert_eq!(encoded_collab.database_row_encoded_collabs.len(), *rows_len); + } + }, + _ => panic!("Expected database collab"), + } + } +} + +async fn import_workspace(file_name: &str, test: &EventIntegrationTest) -> Vec { + let file_path = unzip("./tests/asset", file_name).unwrap(); + test + .import_appflowy_data(file_path.to_str().unwrap().to_string(), None) + .await + .unwrap(); + + test.get_all_workspace_views().await +} + +async fn import_csv(file_name: &str, test: &EventIntegrationTest) -> ViewPB { + let file_path = unzip("./tests/asset", file_name).unwrap(); + let csv_string = std::fs::read_to_string(file_path).unwrap(); + let workspace_id = test.get_current_workspace().await.id; + let import_data = gen_import_data(file_name.to_string(), csv_string, workspace_id); + let views = test.import_data(import_data).await; + views[0].clone() +} + +fn gen_import_data(file_name: String, csv_string: String, workspace_id: String) -> ImportPayloadPB { + ImportPayloadPB { + parent_view_id: workspace_id.clone(), + items: vec![ImportItemPayloadPB { + name: file_name, + data: Some(csv_string.as_bytes().to_vec()), + file_path: None, + view_layout: ViewLayoutPB::Grid, + import_type: ImportTypePB::CSV, + }], + } +} diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs new file mode 100644 index 0000000000..089310b260 --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs @@ -0,0 +1,228 @@ +use collab_folder::ViewLayout; +use event_integration_test::EventIntegrationTest; +use flowy_folder::entities::{ViewLayoutPB, ViewPB}; +use flowy_folder::publish_util::generate_publish_name; +use flowy_folder::view_operation::GatherEncodedCollab; +use flowy_folder_pub::entities::{ + PublishDocumentPayload, PublishPayload, PublishViewInfo, PublishViewMeta, PublishViewMetaData, +}; +use uuid::Uuid; + +async fn mock_single_document_view_publish_payload( + test: &EventIntegrationTest, + view: &ViewPB, + publish_name: String, +) -> Vec { + let view_id = &view.id; + let layout: ViewLayout = view.layout.clone().into(); + let view_encoded_collab = test.gather_encode_collab_from_disk(view_id, layout).await; + let publish_view_info = PublishViewInfo { + view_id: view_id.to_string(), + name: view.name.to_string(), + icon: None, + layout: ViewLayout::Document, + extra: None, + created_by: view.created_by, + last_edited_by: view.last_edited_by, + last_edited_time: view.last_edited, + created_at: view.create_time, + child_views: None, + }; + + let data = match view_encoded_collab { + GatherEncodedCollab::Document(doc) => doc.doc_state.to_vec(), + _ => panic!("Expected document collab"), + }; + + vec![PublishPayload::Document(PublishDocumentPayload { + meta: PublishViewMeta { + metadata: PublishViewMetaData { + view: publish_view_info.clone(), + child_views: vec![], + ancestor_views: vec![publish_view_info], + }, + view_id: view_id.to_string(), + publish_name, + }, + data, + })] +} + +async fn mock_nested_document_view_publish_payload( + test: &EventIntegrationTest, + view: &ViewPB, + publish_name: String, +) -> Vec { + let view_id = &view.id; + let layout: ViewLayout = view.layout.clone().into(); + let view_encoded_collab = test.gather_encode_collab_from_disk(view_id, layout).await; + let publish_view_info = PublishViewInfo { + view_id: view_id.to_string(), + name: view.name.to_string(), + icon: None, + layout: ViewLayout::Document, + extra: None, + created_by: view.created_by, + last_edited_by: view.last_edited_by, + last_edited_time: view.last_edited, + created_at: view.create_time, + child_views: None, + }; + + let child_view_id = &view.child_views[0].id; + let child_view = test.get_view(child_view_id).await; + let child_layout: ViewLayout = child_view.layout.clone().into(); + let child_view_encoded_collab = test + .gather_encode_collab_from_disk(child_view_id, child_layout) + .await; + let child_publish_view_info = PublishViewInfo { + view_id: child_view_id.to_string(), + name: child_view.name.to_string(), + icon: None, + layout: ViewLayout::Document, + extra: None, + created_by: child_view.created_by, + last_edited_by: child_view.last_edited_by, + last_edited_time: child_view.last_edited, + created_at: child_view.create_time, + child_views: None, + }; + let child_publish_name = generate_publish_name(&child_view.id, &child_view.name); + + let data = match view_encoded_collab { + GatherEncodedCollab::Document(doc) => doc.doc_state.to_vec(), + _ => panic!("Expected document collab"), + }; + + let child_data = match child_view_encoded_collab { + GatherEncodedCollab::Document(doc) => doc.doc_state.to_vec(), + _ => panic!("Expected document collab"), + }; + + vec![ + PublishPayload::Document(PublishDocumentPayload { + meta: PublishViewMeta { + metadata: PublishViewMetaData { + view: publish_view_info.clone(), + child_views: vec![child_publish_view_info.clone()], + ancestor_views: vec![publish_view_info.clone()], + }, + view_id: view_id.to_string(), + publish_name, + }, + data, + }), + PublishPayload::Document(PublishDocumentPayload { + meta: PublishViewMeta { + metadata: PublishViewMetaData { + view: child_publish_view_info.clone(), + child_views: vec![], + ancestor_views: vec![publish_view_info.clone(), child_publish_view_info.clone()], + }, + view_id: child_view_id.to_string(), + publish_name: child_publish_name, + }, + data: child_data, + }), + ] +} + +async fn create_single_document(test: &EventIntegrationTest, view_id: &str, name: &str) { + test + .create_orphan_view(name, view_id, ViewLayoutPB::Document) + .await; +} + +async fn create_nested_document(test: &EventIntegrationTest, view_id: &str, name: &str) { + create_single_document(test, view_id, name).await; + let child_name = "Child View"; + test.create_view(view_id, child_name.to_string()).await; +} +#[tokio::test] +async fn single_document_get_publish_view_payload_test() { + let test = EventIntegrationTest::new_anon().await; + let view_id = Uuid::new_v4().to_string(); + let name = "Orphan View"; + create_single_document(&test, &view_id, name).await; + let view = test.get_view(&view_id).await; + let payload = test.get_publish_payload(&view_id, true).await; + + let expect_payload = mock_single_document_view_publish_payload( + &test, + &view, + format!("{}-{}", "Orphan-View", view_id), + ) + .await; + + assert_eq!(payload, expect_payload); +} + +#[tokio::test] +async fn nested_document_get_publish_view_payload_test() { + let test = EventIntegrationTest::new_anon().await; + let name = "Orphan View"; + let view_id = Uuid::new_v4().to_string(); + create_nested_document(&test, &view_id, name).await; + let view = test.get_view(&view_id).await; + let payload = test.get_publish_payload(&view_id, true).await; + + let expect_payload = mock_nested_document_view_publish_payload( + &test, + &view, + format!("{}-{}", "Orphan-View", view_id), + ) + .await; + + assert_eq!(payload.len(), 2); + assert_eq!(payload, expect_payload); +} + +#[tokio::test] +async fn no_children_publish_view_payload_test() { + let test = EventIntegrationTest::new_anon().await; + let name = "Orphan View"; + let view_id = Uuid::new_v4().to_string(); + create_nested_document(&test, &view_id, name).await; + let view = test.get_view(&view_id).await; + let payload = test.get_publish_payload(&view_id, false).await; + + let data = mock_single_document_view_publish_payload( + &test, + &view, + format!("{}-{}", "Orphan-View", view_id), + ) + .await + .iter() + .filter_map(|p| match p { + PublishPayload::Document(payload) => Some(payload.data.clone()), + _ => None, + }) + .collect::>(); + let meta = mock_nested_document_view_publish_payload( + &test, + &view, + format!("{}-{}", "Orphan-View", view_id), + ) + .await + .iter() + .filter_map(|p| match p { + PublishPayload::Document(payload) => Some(payload.meta.clone()), + _ => None, + }) + .collect::>(); + + assert_eq!(payload.len(), 1); + + let payload_data = match &payload[0] { + PublishPayload::Document(payload) => payload.data.clone(), + _ => panic!("Expected document payload"), + }; + + let payload_meta = match &payload[0] { + PublishPayload::Document(payload) => payload.meta.clone(), + _ => panic!("Expected document payload"), + }; + + assert_eq!(&payload_data, &data[0]); + assert_eq!(&payload_meta, &meta[0]); +} diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/script.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/script.rs index eb311cfea7..22001d9973 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/local_test/script.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/script.rs @@ -126,8 +126,13 @@ impl FolderTest { let app = create_view(sdk, &self.workspace.id, &name, &desc, ViewLayout::Document).await; self.parent_view = app; }, - FolderScript::AssertParentView(app) => { - assert_eq!(self.parent_view, app, "App not equal"); + FolderScript::AssertParentView(view) => { + assert_eq!(self.parent_view.id, view.id, "view id not equal"); + assert_eq!(self.parent_view.name, view.name, "view name not equal"); + assert_eq!( + self.parent_view.is_favorite, view.is_favorite, + "view name not equal" + ); }, FolderScript::ReloadParentView(parent_view_id) => { let parent_view = read_view(sdk, &parent_view_id).await; @@ -158,7 +163,9 @@ impl FolderTest { assert_eq!(self.child_view, view, "View not equal"); }, FolderScript::ReadView(view_id) => { - let view = read_view(sdk, &view_id).await; + let mut view = read_view(sdk, &view_id).await; + // Ignore the last edited time + view.last_edited = 0; self.child_view = view; }, FolderScript::UpdateView { @@ -196,10 +203,26 @@ impl FolderTest { }, FolderScript::ReadFavorites => { let favorites = read_favorites(sdk).await; - self.favorites = favorites.to_vec(); + self.favorites = favorites.items.iter().map(|x| x.item.clone()).collect(); }, } } + + // pub async fn duplicate_view(&self, view_id: &str) { + // let payload = DuplicateViewPayloadPB { + // view_id: view_id.to_string(), + // open_after_duplicate: false, + // include_children: false, + // parent_view_id: None, + // suffix: None, + // sync_after_create: false, + // }; + // EventBuilder::new(self.sdk.clone()) + // .event(DuplicateView) + // .payload(payload) + // .async_send() + // .await; + // } } pub async fn create_workspace(sdk: &EventIntegrationTest, name: &str, desc: &str) -> WorkspacePB { let request = CreateWorkspacePayloadPB { @@ -231,13 +254,12 @@ pub async fn create_view( sdk: &EventIntegrationTest, parent_view_id: &str, name: &str, - desc: &str, + _desc: &str, layout: ViewLayout, ) -> ViewPB { let request = CreateViewPayloadPB { parent_view_id: parent_view_id.to_string(), name: name.to_string(), - desc: desc.to_string(), thumbnail: None, layout: layout.into(), initial_data: vec![], @@ -245,6 +267,8 @@ pub async fn create_view( set_as_current: true, index: None, section: None, + view_id: None, + extra: None, }; EventBuilder::new(sdk.clone()) .event(CreateView) @@ -375,10 +399,10 @@ pub async fn toggle_favorites(sdk: &EventIntegrationTest, view_id: Vec) .await; } -pub async fn read_favorites(sdk: &EventIntegrationTest) -> RepeatedViewPB { +pub async fn read_favorites(sdk: &EventIntegrationTest) -> RepeatedFavoriteViewPB { EventBuilder::new(sdk.clone()) .event(ReadFavorites) .async_send() .await - .parse::() + .parse::() } diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/subscription_test.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/subscription_test.rs index 88636febcd..c9460d9db0 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/local_test/subscription_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/subscription_test.rs @@ -24,11 +24,9 @@ async fn create_child_view_in_workspace_subscription_test() { let cloned_test = test.clone(); let cloned_workspace_id = workspace.id.clone(); - test.appflowy_core.dispatcher().spawn(async move { - cloned_test - .create_view(&cloned_workspace_id, "workspace child view".to_string()) - .await; - }); + cloned_test + .create_view(&cloned_workspace_id, "workspace child view".to_string()) + .await; let views = receive_with_timeout(rx, Duration::from_secs(30)) .await @@ -50,14 +48,17 @@ async fn create_child_view_in_view_subscription_test() { let cloned_test = test.clone(); let child_view_id = workspace_child_view.id.clone(); - test.appflowy_core.dispatcher().spawn(async move { - cloned_test - .create_view( - &child_view_id, - "workspace child view's child view".to_string(), - ) - .await; - }); + let local_set = tokio::task::LocalSet::new(); + local_set + .run_until(async move { + cloned_test + .create_view( + &child_view_id, + "workspace child view's child view".to_string(), + ) + .await; + }) + .await; let update = receive_with_timeout(rx, Duration::from_secs(30)) .await @@ -81,22 +82,11 @@ async fn delete_view_subscription_test() { let cloned_test = test.clone(); let delete_view_id = workspace.views.first().unwrap().id.clone(); let cloned_delete_view_id = delete_view_id.clone(); - test - .appflowy_core - .dispatcher() - .spawn(async move { - cloned_test.delete_view(&cloned_delete_view_id).await; - }) + + cloned_test.delete_view(&cloned_delete_view_id).await; + let update = receive_with_timeout(rx, Duration::from_secs(60)) .await .unwrap(); - - let update = test - .appflowy_core - .dispatcher() - .run_until(receive_with_timeout(rx, Duration::from_secs(30))) - .await - .unwrap(); - assert_eq!(update.delete_child_views.len(), 1); assert_eq!(update.delete_child_views[0], delete_view_id); } @@ -114,17 +104,14 @@ async fn update_view_subscription_test() { assert!(!view.is_favorite); let update_view_id = view.id.clone(); - test.appflowy_core.dispatcher().spawn(async move { - cloned_test - .update_view(UpdateViewPayloadPB { - view_id: update_view_id, - name: Some("hello world".to_string()), - is_favorite: Some(true), - ..Default::default() - }) - .await; - }); - + cloned_test + .update_view(UpdateViewPayloadPB { + view_id: update_view_id, + name: Some("hello world".to_string()), + is_favorite: Some(true), + ..Default::default() + }) + .await; let update = receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap(); diff --git a/frontend/rust-lib/event-integration-test/tests/folder/local_test/test.rs b/frontend/rust-lib/event-integration-test/tests/folder/local_test/test.rs index 09af815d65..2297324c53 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/local_test/test.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/local_test/test.rs @@ -4,23 +4,6 @@ use flowy_folder::entities::icon::{UpdateViewIconPayloadPB, ViewIconPB, ViewIcon use flowy_folder::entities::*; use flowy_user::errors::ErrorCode; -#[tokio::test] -async fn create_workspace_event_test() { - let test = EventIntegrationTest::new_anon().await; - let request = CreateWorkspacePayloadPB { - name: "my second workspace".to_owned(), - desc: "".to_owned(), - }; - let view_pb = EventBuilder::new(test) - .event(flowy_folder::event_map::FolderEvent::CreateFolderWorkspace) - .payload(request) - .async_send() - .await - .parse::(); - - assert_eq!(view_pb.parent_view_id, "my second workspace".to_owned()); -} - // #[tokio::test] // async fn open_workspace_event_test() { // let test = EventIntegrationTest::new_with_guest_user().await; @@ -464,35 +447,6 @@ async fn move_view_event_after_delete_view_test2() { assert_eq!(views[3].name, "My 1-5 view"); } -#[tokio::test] -async fn create_parent_view_with_invalid_name() { - for (name, code) in invalid_workspace_name_test_case() { - let sdk = EventIntegrationTest::new().await; - let request = CreateWorkspacePayloadPB { - name, - desc: "".to_owned(), - }; - assert_eq!( - EventBuilder::new(sdk) - .event(flowy_folder::event_map::FolderEvent::CreateFolderWorkspace) - .payload(request) - .async_send() - .await - .error() - .unwrap() - .code, - code - ) - } -} - -fn invalid_workspace_name_test_case() -> Vec<(String, ErrorCode)> { - vec![ - ("".to_owned(), ErrorCode::WorkspaceNameInvalid), - ("1234".repeat(100), ErrorCode::WorkspaceNameTooLong), - ] -} - #[tokio::test] async fn move_view_across_parent_test() { let test = EventIntegrationTest::new_anon().await; diff --git a/frontend/rust-lib/event-integration-test/tests/folder/mod.rs b/frontend/rust-lib/event-integration-test/tests/folder/mod.rs index 01d3a22023..c5566e1b80 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/mod.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/mod.rs @@ -1,4 +1,3 @@ mod local_test; - // #[cfg(feature = "supabase_cloud_test")] // mod supabase_test; diff --git a/frontend/rust-lib/event-integration-test/tests/folder/supabase_test/helper.rs b/frontend/rust-lib/event-integration-test/tests/folder/supabase_test/helper.rs index ed701fd1a7..a1179ce6cc 100644 --- a/frontend/rust-lib/event-integration-test/tests/folder/supabase_test/helper.rs +++ b/frontend/rust-lib/event-integration-test/tests/folder/supabase_test/helper.rs @@ -75,7 +75,7 @@ pub fn assert_folder_collab_content(workspace_id: &str, collab_update: &[u8], ex )); collab.lock().with_origin_transact_mut(|txn| { let update = Update::decode_v1(collab_update).unwrap(); - txn.apply_update(update); + txn.apply_update(update).unwrap(); }); let json = collab.to_json_value(); diff --git a/frontend/rust-lib/event-integration-test/tests/main.rs b/frontend/rust-lib/event-integration-test/tests/main.rs index 05d0797473..cf4c1591ac 100644 --- a/frontend/rust-lib/event-integration-test/tests/main.rs +++ b/frontend/rust-lib/event-integration-test/tests/main.rs @@ -4,5 +4,9 @@ mod folder; // TODO(Mathias): Enable tests for search // mod search; + +mod sql_test; mod user; pub mod util; + +mod chat; diff --git a/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs b/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs new file mode 100644 index 0000000000..3294ad26db --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs @@ -0,0 +1,609 @@ +use event_integration_test::user_event::use_localhost_af_cloud; +use event_integration_test::EventIntegrationTest; +use flowy_ai_pub::cloud::MessageCursor; +use flowy_ai_pub::persistence::{ + select_answer_where_match_reply_message_id, select_chat_messages, select_message, + select_message_content, total_message_count, upsert_chat_messages, ChatMessageTable, +}; +use uuid::Uuid; + +#[tokio::test] +async fn chat_message_table_insert_select_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id_1 = 1000; + let message_id_2 = 2000; + + // Create test messages + let messages = vec![ + ChatMessageTable { + message_id: message_id_1, + chat_id: chat_id.clone(), + content: "Hello, this is a test message".to_string(), + created_at: 1625097600, // 2021-07-01 + author_type: 1, // User + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }, + ChatMessageTable { + message_id: message_id_2, + chat_id: chat_id.clone(), + content: "This is a reply to the test message".to_string(), + created_at: 1625097700, // 2021-07-01, 100 seconds later + author_type: 0, // AI + author_id: "ai".to_string(), + reply_message_id: Some(message_id_1), + metadata: Some(r#"{"source": "test"}"#.to_string()), + is_sync: false, + }, + ]; + + // Test insert_chat_messages + let result = upsert_chat_messages(db_conn, &messages); + assert!( + result.is_ok(), + "Failed to insert chat messages: {:?}", + result + ); + + // Test select_chat_messages + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let messages_result = + select_chat_messages(db_conn, &chat_id, 10, MessageCursor::Offset(0)).unwrap(); + + assert_eq!(messages_result.messages.len(), 2); + assert_eq!(messages_result.total_count, 2); + assert!(!messages_result.has_more); + + // Verify the content of the returned messages + let first_message = messages_result + .messages + .iter() + .find(|m| m.message_id == message_id_1) + .unwrap(); + assert_eq!(first_message.content, "Hello, this is a test message"); + assert_eq!(first_message.author_type, 1); + + let second_message = messages_result + .messages + .iter() + .find(|m| m.message_id == message_id_2) + .unwrap(); + assert_eq!( + second_message.content, + "This is a reply to the test message" + ); + assert_eq!(second_message.reply_message_id, Some(message_id_1)); +} + +#[tokio::test] +async fn chat_message_table_cursor_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + + // Create multiple test messages with sequential IDs + let mut messages = Vec::new(); + for i in 1..6 { + messages.push(ChatMessageTable { + message_id: i * 1000, + chat_id: chat_id.clone(), + content: format!("Message {}", i), + created_at: 1625097600 + (i * 100), // Increasing timestamps + author_type: 1, // User + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }); + } + + // Insert messages + upsert_chat_messages(db_conn, &messages).unwrap(); + + // Test MessageCursor::Offset + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_offset = select_chat_messages( + db_conn, + &chat_id, + 2, // Limit to 2 messages + MessageCursor::Offset(0), + ) + .unwrap(); + + assert_eq!(result_offset.messages.len(), 2); + assert!(result_offset.has_more); + + // Test MessageCursor::AfterMessageId + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_after = select_chat_messages( + db_conn, + &chat_id, + 3, // Limit to 3 messages + MessageCursor::AfterMessageId(2000), + ) + .unwrap(); + + assert_eq!(result_after.messages.len(), 3); // Should get message IDs 3000, 4000, 5000 + assert!(result_after.messages.iter().all(|m| m.message_id > 2000)); + + // Test MessageCursor::BeforeMessageId + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_before = select_chat_messages( + db_conn, + &chat_id, + 2, // Limit to 2 messages + MessageCursor::BeforeMessageId(4000), + ) + .unwrap(); + + assert_eq!(result_before.messages.len(), 2); // Should get message IDs 1000, 2000, 3000 + assert!(result_before.messages.iter().all(|m| m.message_id < 4000)); +} + +#[tokio::test] +async fn chat_message_total_count_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + + // Create test messages + let messages = vec![ + ChatMessageTable { + message_id: 1001, + chat_id: chat_id.clone(), + content: "Message 1".to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }, + ChatMessageTable { + message_id: 1002, + chat_id: chat_id.clone(), + content: "Message 2".to_string(), + created_at: 1625097700, + author_type: 0, + author_id: "ai".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }, + ]; + + // Insert messages + upsert_chat_messages(db_conn, &messages).unwrap(); + + // Test total_message_count + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(count, 2); + + // Add one more message + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let additional_message = ChatMessageTable { + message_id: 1003, + chat_id: chat_id.clone(), + content: "Message 3".to_string(), + created_at: 1625097800, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }; + + upsert_chat_messages(db_conn, &[additional_message]).unwrap(); + + // Verify count increased + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let updated_count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(updated_count, 3); + + // Test count for non-existent chat + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let empty_count = total_message_count(db_conn, "non_existent_chat").unwrap(); + assert_eq!(empty_count, 0); +} + +#[tokio::test] +async fn chat_message_select_message_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id = 2001; + + // Create test message + let message = ChatMessageTable { + message_id, + chat_id: chat_id.clone(), + content: "This is a test message for select_message".to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: Some(r#"{"test_key": "test_value"}"#.to_string()), + is_sync: false, + }; + + // Insert message + upsert_chat_messages(db_conn, &[message]).unwrap(); + + // Test select_message + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result = select_message(db_conn, message_id).unwrap(); + assert!(result.is_some()); + + let retrieved_message = result.unwrap(); + assert_eq!(retrieved_message.message_id, message_id); + assert_eq!(retrieved_message.chat_id, chat_id); + assert_eq!( + retrieved_message.content, + "This is a test message for select_message" + ); + assert_eq!(retrieved_message.author_id, "user_1"); + assert_eq!( + retrieved_message.metadata, + Some(r#"{"test_key": "test_value"}"#.to_string()) + ); + + // Test select_message with non-existent ID + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let non_existent = select_message(db_conn, 9999).unwrap(); + assert!(non_existent.is_none()); +} + +#[tokio::test] +async fn chat_message_select_content_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id = 3001; + let message_content = "This is the content to retrieve"; + + // Create test message + let message = ChatMessageTable { + message_id, + chat_id: chat_id.clone(), + content: message_content.to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }; + + // Insert message + upsert_chat_messages(db_conn, &[message]).unwrap(); + + // Test select_message_content + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let content = select_message_content(db_conn, message_id).unwrap(); + assert!(content.is_some()); + assert_eq!(content.unwrap(), message_content); + + // Test with non-existent message + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let no_content = select_message_content(db_conn, 9999).unwrap(); + assert!(no_content.is_none()); +} + +#[tokio::test] +async fn chat_message_reply_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let question_id = 4001; + let answer_id = 4002; + + // Create question and answer messages + let question = ChatMessageTable { + message_id: question_id, + chat_id: chat_id.clone(), + content: "What is the question?".to_string(), + created_at: 1625097600, + author_type: 1, // User + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }; + + let answer = ChatMessageTable { + message_id: answer_id, + chat_id: chat_id.clone(), + content: "This is the answer".to_string(), + created_at: 1625097700, + author_type: 0, // AI + author_id: "ai".to_string(), + reply_message_id: Some(question_id), // Link to question + metadata: None, + is_sync: false, + }; + + // Insert messages + upsert_chat_messages(db_conn, &[question, answer]).unwrap(); + + // Test select_message_where_match_reply_message_id + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result = select_answer_where_match_reply_message_id(db_conn, &chat_id, question_id).unwrap(); + + assert!(result.is_some()); + let reply = result.unwrap(); + assert_eq!(reply.message_id, answer_id); + assert_eq!(reply.content, "This is the answer"); + assert_eq!(reply.reply_message_id, Some(question_id)); + + // Test with non-existent reply relation + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let no_reply = select_answer_where_match_reply_message_id( + db_conn, &chat_id, 9999, // Non-existent question ID + ) + .unwrap(); + + assert!(no_reply.is_none()); + + // Test with wrong chat_id + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let wrong_chat = + select_answer_where_match_reply_message_id(db_conn, "wrong_chat_id", question_id).unwrap(); + + assert!(wrong_chat.is_none()); +} + +#[tokio::test] +async fn chat_message_upsert_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + let message_id = 5001; + + // Create initial message + let message = ChatMessageTable { + message_id, + chat_id: chat_id.clone(), + content: "Original content".to_string(), + created_at: 1625097600, + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: None, + metadata: None, + is_sync: false, + }; + + // Insert message + upsert_chat_messages(db_conn, &[message]).unwrap(); + + // Check original content + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let original = select_message(db_conn, message_id).unwrap().unwrap(); + assert_eq!(original.content, "Original content"); + + // Create updated message with same ID but different content + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let updated_message = ChatMessageTable { + message_id, // Same ID + chat_id: chat_id.clone(), + content: "Updated content".to_string(), // New content + created_at: 1625097700, // Updated timestamp + author_type: 1, + author_id: "user_1".to_string(), + reply_message_id: Some(1000), // Added reply ID + metadata: Some(r#"{"updated": true}"#.to_string()), + is_sync: false, + }; + + // Upsert message + upsert_chat_messages(db_conn, &[updated_message]).unwrap(); + + // Verify update + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result = select_message(db_conn, message_id).unwrap().unwrap(); + assert_eq!(result.content, "Updated content"); + assert_eq!(result.created_at, 1625097700); + assert_eq!(result.reply_message_id, Some(1000)); + assert_eq!(result.metadata, Some(r#"{"updated": true}"#.to_string())); + + // Count should still be 1 (update, not insert) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn chat_message_select_with_large_dataset() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + test.sign_up_as_anon().await; + + let uid = test.user_manager.get_anon_user().await.unwrap().id; + let db_conn = test.user_manager.db_connection(uid).unwrap(); + + let chat_id = Uuid::new_v4().to_string(); + + // Create 100 test messages with sequential IDs + let mut messages = Vec::new(); + for i in 1..=100 { + messages.push(ChatMessageTable { + message_id: i * 100, + chat_id: chat_id.clone(), + content: format!("Message {}", i), + created_at: 1625097600 + (i * 10), // Increasing timestamps + author_type: if i % 2 == 0 { 0 } else { 1 }, // Alternate between AI and User + author_id: if i % 2 == 0 { + "ai".to_string() + } else { + "user_1".to_string() + }, + reply_message_id: if i > 1 && i % 2 == 0 { + Some((i - 1) * 100) + } else { + None + }, // Even messages reply to previous message + metadata: if i % 5 == 0 { + Some(format!(r#"{{"index": {}}}"#, i)) + } else { + None + }, + is_sync: false, + }); + } + + // Insert all 100 messages + upsert_chat_messages(db_conn, &messages).unwrap(); + + // Verify total count + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let count = total_message_count(db_conn, &chat_id).unwrap(); + assert_eq!(count, 100, "Should have 100 messages in the database"); + + // Test 1: MessageCursor::Offset with small page size + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let page_size = 10; + let result_offset = + select_chat_messages(db_conn, &chat_id, page_size, MessageCursor::Offset(0)).unwrap(); + + assert_eq!( + result_offset.messages.len(), + page_size as usize, + "Should return exactly {page_size} messages" + ); + assert!( + result_offset.has_more, + "Should have more messages available" + ); + assert_eq!(result_offset.total_count, 100, "Total count should be 100"); + + // Test 2: Pagination with offset + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_page2 = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::Offset(page_size), + ) + .unwrap(); + + assert_eq!(result_page2.messages.len(), page_size as usize); + assert!( + result_page2.messages[0].message_id != result_offset.messages[0].message_id, + "Second page should have different messages than first page" + ); + + // Test 3: MessageCursor::AfterMessageId (forward pagination) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let middle_message_id = 5000; // Message ID from the middle + let result_after = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::AfterMessageId(middle_message_id), + ) + .unwrap(); + + assert_eq!(result_after.messages.len(), page_size as usize); + assert!( + result_after + .messages + .iter() + .all(|m| m.message_id > middle_message_id), + "All messages should have ID greater than the cursor" + ); + + // Test 4: MessageCursor::BeforeMessageId (backward pagination) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_before = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::BeforeMessageId(middle_message_id), + ) + .unwrap(); + + assert_eq!(result_before.messages.len(), page_size as usize); + assert!( + result_before + .messages + .iter() + .all(|m| m.message_id < middle_message_id), + "All messages should have ID less than the cursor" + ); + + // Test 5: Large page size (retrieve all) + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_all = select_chat_messages( + db_conn, + &chat_id, + 200, // More than we have + MessageCursor::Offset(0), + ) + .unwrap(); + + assert_eq!( + result_all.messages.len(), + 100, + "Should return all 100 messages" + ); + assert!(!result_all.has_more, "Should not have more messages"); + + // Test 6: Empty result when using out of range cursor + let db_conn = test.user_manager.db_connection(uid).unwrap(); + let result_out_of_range = select_chat_messages( + db_conn, + &chat_id, + page_size, + MessageCursor::AfterMessageId(10000), // After the last message + ) + .unwrap(); + + assert_eq!( + result_out_of_range.messages.len(), + 0, + "Should return no messages" + ); + assert!( + !result_out_of_range.has_more, + "Should not have more messages" + ); +} diff --git a/frontend/rust-lib/event-integration-test/tests/sql_test/mod.rs b/frontend/rust-lib/event-integration-test/tests/sql_test/mod.rs new file mode 100644 index 0000000000..773bdab81f --- /dev/null +++ b/frontend/rust-lib/event-integration-test/tests/sql_test/mod.rs @@ -0,0 +1 @@ +mod chat_message_test; diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs index dbcfb7097f..301b6e5a62 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/anon_user_test.rs @@ -1,13 +1,13 @@ -use event_integration_test::user_event::user_localhost_af_cloud; +use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; use flowy_core::DEFAULT_NAME; -use flowy_user::entities::AuthenticatorPB; +use flowy_user::entities::AuthTypePB; use crate::util::unzip; #[tokio::test] async fn reading_039_anon_user_data_test() { - let (cleaner, user_db_path) = unzip("./tests/asset", "039_local").unwrap(); + let user_db_path = unzip("./tests/asset", "039_local").unwrap(); let test = EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await; let first_level_views = test.get_all_workspace_views().await; @@ -36,20 +36,18 @@ async fn reading_039_anon_user_data_test() { let trash_items = test.get_trash().await.items; assert_eq!(trash_items.len(), 1); - - drop(cleaner); } #[tokio::test] async fn migrate_anon_user_data_to_af_cloud_test() { - let (cleaner, user_db_path) = unzip("./tests/asset", "040_local").unwrap(); + let user_db_path = unzip("./tests/asset", "040_local").unwrap(); // In the 040_local, the structure is: // workspace: // view: Document1 // view: Document2 // view: Grid1 // view: Grid2 - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test = EventIntegrationTest::new_with_user_data_path(user_db_path.clone(), DEFAULT_NAME.to_string()) .await; @@ -74,14 +72,14 @@ async fn migrate_anon_user_data_to_af_cloud_test() { let user = test.af_cloud_sign_up().await; let workspace = test.get_current_workspace().await; println!("user workspace: {:?}", workspace.id); - assert_eq!(user.authenticator, AuthenticatorPB::AppFlowyCloud); + assert_eq!(user.user_auth_type, AuthTypePB::Server); let user_first_level_views = test.get_all_workspace_views().await; - assert_eq!(user_first_level_views.len(), 2); + assert_eq!(user_first_level_views.len(), 3); println!("user first level views: {:?}", user_first_level_views); let user_second_level_views = test - .get_view(&user_first_level_views[1].id) + .get_view(&user_first_level_views[2].id) .await .child_views; println!("user second level views: {:?}", user_second_level_views); @@ -95,15 +93,14 @@ async fn migrate_anon_user_data_to_af_cloud_test() { assert_eq!(anon_first_level_views.len(), 1); // the first view of user_first_level_views is the default get started view - assert_eq!(user_first_level_views.len(), 2); + assert_eq!(user_first_level_views.len(), 3); assert_ne!(anon_first_level_views[0].id, user_first_level_views[1].id); assert_eq!( anon_first_level_views[0].name, - user_first_level_views[1].name + user_first_level_views[2].name ); // check second level - assert_eq!(anon_second_level_views.len(), user_second_level_views.len()); assert_ne!(anon_second_level_views[0].id, user_second_level_views[0].id); assert_eq!( anon_second_level_views[0].name, @@ -114,6 +111,4 @@ async fn migrate_anon_user_data_to_af_cloud_test() { assert_eq!(anon_third_level_views.len(), 2); assert_eq!(user_third_level_views[0].name, "Grid1".to_string()); assert_eq!(user_third_level_views[1].name, "Grid2".to_string()); - - drop(cleaner); } diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs index 3f9a5b3f01..eaec8f7540 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs @@ -1,41 +1,14 @@ -use event_integration_test::user_event::user_localhost_af_cloud; +use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; -use flowy_user::entities::UpdateUserProfilePayloadPB; use crate::util::generate_test_email; #[tokio::test] async fn af_cloud_sign_up_test() { // user_localhost_af_cloud_with_nginx().await; - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test = EventIntegrationTest::new().await; let email = generate_test_email(); let user = test.af_cloud_sign_in_with_email(&email).await.unwrap(); assert_eq!(user.email, email); } - -#[tokio::test] -async fn af_cloud_update_user_metadata() { - user_localhost_af_cloud().await; - let test = EventIntegrationTest::new().await; - let user = test.af_cloud_sign_up().await; - - let old_profile = test.get_user_profile().await.unwrap(); - assert_eq!(old_profile.openai_key, "".to_string()); - - test - .update_user_profile(UpdateUserProfilePayloadPB { - id: user.id, - openai_key: Some("new openai key".to_string()), - stability_ai_key: Some("new stability ai key".to_string()), - ..Default::default() - }) - .await; - - let new_profile = test.get_user_profile().await.unwrap(); - assert_eq!(new_profile.openai_key, "new openai key".to_string()); - assert_eq!( - new_profile.stability_ai_key, - "new stability ai key".to_string() - ); -} diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/import_af_data_folder_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/import_af_data_folder_test.rs index 455d4db140..cc02bb1f2e 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/import_af_data_folder_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/import_af_data_folder_test.rs @@ -1,24 +1,28 @@ use crate::util::unzip; use assert_json_diff::assert_json_include; +use collab::core::collab::DataSource; +use collab::core::origin::CollabOrigin; +use collab::preclude::{Any, Collab}; use collab_database::rows::database_row_document_id_from_row_id; -use event_integration_test::user_event::user_localhost_af_cloud; +use collab_document::blocks::TextDelta; +use collab_document::document::Document; +use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; use flowy_core::DEFAULT_NAME; +use flowy_folder::entities::ViewLayoutPB; use flowy_user::errors::ErrorCode; use serde_json::{json, Value}; use std::env::temp_dir; #[tokio::test] -async fn import_appflowy_data_need_migration_test() { - // In 037, the workspace array will be migrated to view. - let import_container_name = "037_local".to_string(); - let (cleaner, user_db_path) = unzip("./tests/asset", &import_container_name).unwrap(); - // Getting started - // Document1 - // Document2(fav) - user_localhost_af_cloud().await; +async fn import_appflowy_data_with_ref_views_test() { + let import_container_name = "data_ref_doc".to_string(); + let user_db_path = unzip("./tests/asset", &import_container_name).unwrap(); + use_localhost_af_cloud().await; let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await; let _ = test.af_cloud_sign_up().await; + let views = test.get_all_workspace_views().await; + let shared_space_id = views[1].id.clone(); test .import_appflowy_data( user_db_path.to_str().unwrap().to_string(), @@ -26,44 +30,98 @@ async fn import_appflowy_data_need_migration_test() { ) .await .unwrap(); - // after import, the structure is: - // workspace: - // view: Getting Started - // view: 037_local - // view: Getting Started - // view: Document1 - // view: Document2 - let views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 2); - assert_eq!(views[1].name, import_container_name); + let general_space = test.get_view(&shared_space_id).await; + let shared_sub_views = &general_space.child_views; + assert_eq!(shared_sub_views.len(), 1); + assert_eq!(shared_sub_views[0].name, import_container_name); - let child_views = test.get_view(&views[1].id).await.child_views; - assert_eq!(child_views.len(), 1); + let imported_view_id = shared_sub_views[0].id.clone(); + let imported_sub_views = test.get_view(&imported_view_id).await.child_views; + assert_eq!(imported_sub_views.len(), 1); - let child_views = test.get_view(&child_views[0].id).await.child_views; - assert_eq!(child_views.len(), 2); - assert_eq!(child_views[0].name, "Document1"); - assert_eq!(child_views[1].name, "Document2"); - drop(cleaner); + let imported_get_started_view_id = imported_sub_views[0].id.clone(); + let doc_state = test + .get_document_doc_state(&imported_get_started_view_id) + .await; + let collab = Collab::new_with_source( + CollabOrigin::Empty, + &imported_get_started_view_id, + DataSource::DocStateV1(doc_state), + vec![], + false, + ) + .unwrap(); + let document = Document::open(collab).unwrap(); + + let page_id = document.get_page_id().unwrap(); + let block_ids = document.get_block_children_ids(&page_id); + let mut page_ids = vec![]; + let mut link_ids = vec![]; + for block_id in block_ids.iter() { + // Process block deltas + if let Some(mut block_deltas) = document.get_block_delta(block_id).map(|t| t.1) { + for d in block_deltas.iter_mut() { + if let TextDelta::Inserted(_, Some(attrs)) = d { + if let Some(Any::Map(mention)) = attrs.get_mut("mention") { + if let Some(page_id) = mention.get("page_id").map(|v| v.to_string()) { + page_ids.push(page_id); + } + } + } + } + } + + if let Some((_, data)) = document.get_block_data(block_id) { + if let Some(link_view_id) = data.get("view_id").and_then(|v| v.as_str()) { + link_ids.push(link_view_id.to_string()); + } + } + } + + assert_eq!(page_ids.len(), 1); + for page_id in page_ids { + let view = test.get_view(&page_id).await; + assert_eq!(view.name, "1"); + let data = serde_json::to_string(&test.get_document_data(&view.id).await).unwrap(); + assert!(data.contains("hello world")); + } + + assert_eq!(link_ids.len(), 1); + for link_id in link_ids { + let database_view = test.get_view(&link_id).await; + assert_eq!(database_view.layout, ViewLayoutPB::Grid); + assert_eq!(database_view.name, "Untitled"); + } } #[tokio::test] async fn import_appflowy_data_folder_into_new_view_test() { let import_container_name = "040_local".to_string(); - let (cleaner, user_db_path) = unzip("./tests/asset", &import_container_name).unwrap(); + let user_db_path = unzip("./tests/asset", &import_container_name).unwrap(); // In the 040_local, the structure is: - // workspace: - // view: Document1 - // view: Document2 - // view: Grid1 - // view: Grid2 - user_localhost_af_cloud().await; + // Document1 + // Document2 + // Grid1 + // Grid2 + use_localhost_af_cloud().await; let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await; let _ = test.af_cloud_sign_up().await; + let views = test.get_all_workspace_views().await; + assert_eq!(views[0].name, "General"); + assert_eq!(views[1].name, "Shared"); + assert_eq!(views.len(), 2); + let shared_space_id = views[1].id.clone(); + let shared_space = test.get_view(&shared_space_id).await; + + // by default, shared space is empty + assert!(shared_space.child_views.is_empty()); // after sign up, the initial workspace is created, so the structure is: // workspace: - // view: Getting Started + // General + // template_document + // template_document + // Shared test .import_appflowy_data( @@ -74,24 +132,26 @@ async fn import_appflowy_data_folder_into_new_view_test() { .unwrap(); // after import, the structure is: // workspace: - // view: Getting Started - // view: 040_local - // view: Document1 - // view: Document2 - // view: Grid1 - // view: Grid2 - let views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 2); - assert_eq!(views[1].name, import_container_name); + // General + // template_document + // template_document + // 040_local + // Shared + let general_space = test.get_view(&shared_space_id).await; + let shared_sub_views = &general_space.child_views; + assert_eq!(shared_sub_views.len(), 1); + assert_eq!(shared_sub_views[0].name, import_container_name); // the 040_local should be an empty document, so try to get the document data - let _ = test.get_document_data(&views[1].id).await; + let _ = test.get_document_data(&shared_sub_views[0].id).await; - let local_child_views = test.get_view(&views[1].id).await.child_views; - assert_eq!(local_child_views.len(), 1); - assert_eq!(local_child_views[0].name, "Document1"); + let t_040_local_child_views = test.get_view(&shared_sub_views[0].id).await.child_views; + assert_eq!(t_040_local_child_views[0].name, "Document1"); - let document1_child_views = test.get_view(&local_child_views[0].id).await.child_views; + let document1_child_views = test + .get_view(&t_040_local_child_views[0].id) + .await + .child_views; assert_eq!(document1_child_views.len(), 1); assert_eq!(document1_child_views[0].name, "Document2"); @@ -113,20 +173,18 @@ async fn import_appflowy_data_folder_into_new_view_test() { let row_document_data = test.get_document_data(&row_document_id).await; assert_json_include!(actual: json!(row_document_data), expected: expected_row_doc_json()); - drop(cleaner); } #[tokio::test] async fn import_appflowy_data_folder_into_current_workspace_test() { let import_container_name = "040_local".to_string(); - let (cleaner, user_db_path) = unzip("./tests/asset", &import_container_name).unwrap(); + let user_db_path = unzip("./tests/asset", &import_container_name).unwrap(); // In the 040_local, the structure is: - // workspace: - // view: Document1 - // view: Document2 - // view: Grid1 - // view: Grid2 - user_localhost_af_cloud().await; + // Document1 + // Document2 + // Grid1 + // Grid2 + use_localhost_af_cloud().await; let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await; let _ = test.af_cloud_sign_up().await; // after sign up, the initial workspace is created, so the structure is: @@ -137,19 +195,25 @@ async fn import_appflowy_data_folder_into_current_workspace_test() { .import_appflowy_data(user_db_path.to_str().unwrap().to_string(), None) .await .unwrap(); + let views = test.get_all_workspace_views().await; + assert_eq!(views[0].name, "General"); + assert_eq!(views[1].name, "Shared"); + assert_eq!(views.len(), 2); + let shared_space_id = views[1].id.clone(); + let shared_space_child_views = test.get_view(&shared_space_id).await.child_views; + assert_eq!(shared_space_child_views.len(), 1); + // after import, the structure is: // workspace: - // view: Getting Started - // view: Document1 - // view: Document2 - // view: Grid1 - // view: Grid2 - let views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 2); - assert_eq!(views[1].name, "Document1"); - - let document_1_child_views = test.get_view(&views[1].id).await.child_views; - assert_eq!(document_1_child_views.len(), 1); + // General + // Shared + // Document1 + // Document2 + // Grid1 + // Grid2 + let document_1 = test.get_view(&shared_space_child_views[0].id).await; + assert_eq!(document_1.name, "Document1"); + let document_1_child_views = test.get_view(&document_1.id).await.child_views; assert_eq!(document_1_child_views[0].name, "Document2"); let document2_child_views = test @@ -159,37 +223,12 @@ async fn import_appflowy_data_folder_into_current_workspace_test() { assert_eq!(document2_child_views.len(), 2); assert_eq!(document2_child_views[0].name, "Grid1"); assert_eq!(document2_child_views[1].name, "Grid2"); - - drop(cleaner); -} - -#[tokio::test] -async fn import_appflowy_data_folder_into_new_view_test2() { - let import_container_name = "040_local_2".to_string(); - let (cleaner, user_db_path) = unzip("./tests/asset", &import_container_name).unwrap(); - user_localhost_af_cloud().await; - let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await; - let _ = test.af_cloud_sign_up().await; - test - .import_appflowy_data( - user_db_path.to_str().unwrap().to_string(), - Some(import_container_name.clone()), - ) - .await - .unwrap(); - - let views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 2); - assert_eq!(views[1].name, import_container_name); - assert_040_local_2_import_content(&test, &views[1].id).await; - - drop(cleaner); } #[tokio::test] async fn import_empty_appflowy_data_folder_test() { let path = temp_dir(); - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await; let _ = test.af_cloud_sign_up().await; let error = test @@ -205,7 +244,7 @@ async fn import_empty_appflowy_data_folder_test() { #[tokio::test] async fn import_appflowy_data_folder_multiple_times_test() { let import_container_name = "040_local_2".to_string(); - let (cleaner, user_db_path) = unzip("./tests/asset", &import_container_name).unwrap(); + let user_db_path = unzip("./tests/asset", &import_container_name).unwrap(); // In the 040_local_2, the structure is: // Getting Started // Doc1 @@ -215,24 +254,17 @@ async fn import_appflowy_data_folder_multiple_times_test() { // Doc3_grid_1 // Doc3_grid_2 // Doc3_calendar_1 - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await; let _ = test.af_cloud_sign_up().await; - test - .import_appflowy_data( - user_db_path.to_str().unwrap().to_string(), - Some(import_container_name.clone()), - ) - .await - .unwrap(); - // after import, the structure is: - // Getting Started - // 040_local_2 - let views = test.get_all_workspace_views().await; + assert_eq!(views[0].name, "General"); + assert_eq!(views[1].name, "Shared"); assert_eq!(views.len(), 2); - assert_eq!(views[1].name, import_container_name); - assert_040_local_2_import_content(&test, &views[1].id).await; + let shared_space_id = views[1].id.clone(); + let shared_space = test.get_view(&shared_space_id).await; + // by default, shared space is empty + assert!(shared_space.child_views.is_empty()); test .import_appflowy_data( @@ -242,17 +274,42 @@ async fn import_appflowy_data_folder_multiple_times_test() { .await .unwrap(); // after import, the structure is: - // Getting Started - // 040_local_2 - // Getting started - // 040_local_2 - // Getting started - let views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 3); - assert_eq!(views[2].name, import_container_name); - assert_040_local_2_import_content(&test, &views[1].id).await; - assert_040_local_2_import_content(&test, &views[2].id).await; - drop(cleaner); + // General + // Shared + // 040_local_2 + // Getting Started + // Doc1 + // Doc2 + // Grid1 + // Doc3 + // Doc3_grid_1 + // Doc3_grid_2 + // Doc3_calendar_1 + + let shared_space_children_views = test.get_view(&shared_space_id).await.child_views; + assert_eq!(shared_space_children_views.len(), 1); + let _040_local_view_id = shared_space_children_views[0].id.clone(); + let _040_local_view = test.get_view(&_040_local_view_id).await; + assert_eq!(_040_local_view.name, import_container_name); + assert_040_local_2_import_content(&test, &_040_local_view_id).await; + + test + .import_appflowy_data( + user_db_path.to_str().unwrap().to_string(), + Some(import_container_name.clone()), + ) + .await + .unwrap(); + // after import, the structure is: + // Generate + // Shared + // 040_local_2 + // 040_local_2 + let shared_space_children_views = test.get_view(&shared_space_id).await.child_views; + assert_eq!(shared_space_children_views.len(), 2); + for view in shared_space_children_views { + assert_040_local_2_import_content(&test, &view.id).await; + } } async fn assert_040_local_2_import_content(test: &EventIntegrationTest, view_id: &str) { diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/member_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/member_test.rs index c5fc2479bb..e10f6b45d0 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/member_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/member_test.rs @@ -1,5 +1,5 @@ use crate::user::af_cloud_test::util::get_synced_workspaces; -use event_integration_test::user_event::user_localhost_af_cloud; +use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; #[tokio::test] @@ -35,22 +35,21 @@ async fn af_cloud_invite_workspace_member() { #[tokio::test] async fn af_cloud_add_workspace_member_test() { - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test_1 = EventIntegrationTest::new().await; let user_1 = test_1.af_cloud_sign_up().await; + let workspace_id_1 = test_1.get_current_workspace().await.id; let test_2 = EventIntegrationTest::new().await; let user_2 = test_2.af_cloud_sign_up().await; - let members = test_1.get_workspace_members(&user_1.workspace_id).await; + let members = test_1.get_workspace_members(&workspace_id_1).await; assert_eq!(members.len(), 1); assert_eq!(members[0].email, user_1.email); - test_1 - .add_workspace_member(&user_1.workspace_id, &user_2.email) - .await; + test_1.add_workspace_member(&workspace_id_1, &test_2).await; - let members = test_1.get_workspace_members(&user_1.workspace_id).await; + let members = test_1.get_workspace_members(&workspace_id_1).await; assert_eq!(members.len(), 2); assert_eq!(members[0].email, user_1.email); assert_eq!(members[1].email, user_2.email); @@ -58,45 +57,43 @@ async fn af_cloud_add_workspace_member_test() { #[tokio::test] async fn af_cloud_delete_workspace_member_test() { - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test_1 = EventIntegrationTest::new().await; let user_1 = test_1.af_cloud_sign_up().await; + let workspace_id_1 = test_1.get_current_workspace().await.id; let test_2 = EventIntegrationTest::new().await; let user_2 = test_2.af_cloud_sign_up().await; - test_1 - .add_workspace_member(&user_1.workspace_id, &user_2.email) - .await; + test_1.add_workspace_member(&workspace_id_1, &test_2).await; test_1 - .delete_workspace_member(&user_1.workspace_id, &user_2.email) + .delete_workspace_member(&workspace_id_1, &user_2.email) .await; - let members = test_1.get_workspace_members(&user_1.workspace_id).await; + let members = test_1.get_workspace_members(&workspace_id_1).await; assert_eq!(members.len(), 1); assert_eq!(members[0].email, user_1.email); } #[tokio::test] async fn af_cloud_leave_workspace_test() { - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test_1 = EventIntegrationTest::new().await; - let user_1 = test_1.af_cloud_sign_up().await; + test_1.af_cloud_sign_up().await; + let workspace_id_1 = test_1.get_current_workspace().await.id; let test_2 = EventIntegrationTest::new().await; let user_2 = test_2.af_cloud_sign_up().await; - test_1 - .add_workspace_member(&user_1.workspace_id, &user_2.email) - .await; + test_1.add_workspace_member(&workspace_id_1, &test_2).await; // test_2 should have 2 workspace let workspaces = get_synced_workspaces(&test_2, user_2.id).await; assert_eq!(workspaces.len(), 2); // user_2 leaves the workspace - test_2.leave_workspace(&user_1.workspace_id).await; + test_2.leave_workspace(&workspace_id_1).await; // user_2 should have 1 workspace let workspaces = get_synced_workspaces(&test_2, user_2.id).await; diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/util.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/util.rs index 9830656bb3..0caa9a6227 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/util.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/util.rs @@ -12,7 +12,7 @@ pub async fn get_synced_workspaces( test: &EventIntegrationTest, user_id: i64, ) -> Vec { - let _workspaces = test.get_all_workspaces().await.items; + let workspaces = test.get_all_workspaces().await.items; let sub_id = user_id.to_string(); let rx = test .notification_sender @@ -20,8 +20,9 @@ pub async fn get_synced_workspaces( &sub_id, UserNotification::DidUpdateUserWorkspaces as i32, ); - receive_with_timeout(rx, Duration::from_secs(60)) - .await - .unwrap() - .items + if let Some(result) = receive_with_timeout(rx, Duration::from_secs(10)).await { + result.items + } else { + workspaces + } } diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs index af743e7ced..625ceeb1b7 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs @@ -1,23 +1,26 @@ +use crate::user::af_cloud_test::util::get_synced_workspaces; use collab::core::collab::DataSource::DocStateV1; use collab::core::origin::CollabOrigin; use collab_entity::CollabType; use collab_folder::Folder; -use event_integration_test::user_event::user_localhost_af_cloud; +use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; +use flowy_user_pub::entities::AuthType; use std::time::Duration; +use tokio::task::LocalSet; use tokio::time::sleep; -use crate::user::af_cloud_test::util::get_synced_workspaces; - #[tokio::test] async fn af_cloud_workspace_delete() { - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test = EventIntegrationTest::new().await; let user_profile_pb = test.af_cloud_sign_up().await; let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; assert_eq!(workspaces.len(), 1); - let created_workspace = test.create_workspace("my second workspace").await; + let created_workspace = test + .create_workspace("my second workspace", AuthType::AppFlowyCloud) + .await; assert_eq!(created_workspace.name, "my second workspace"); let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; assert_eq!(workspaces.len(), 2); @@ -32,7 +35,7 @@ async fn af_cloud_workspace_delete() { #[tokio::test] async fn af_cloud_workspace_change_name_and_icon() { - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test = EventIntegrationTest::new().await; let user_profile_pb = test.af_cloud_sign_up().await; let workspaces = test.get_all_workspaces().await; @@ -57,7 +60,7 @@ async fn af_cloud_workspace_change_name_and_icon() { #[tokio::test] async fn af_cloud_create_workspace_test() { - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test = EventIntegrationTest::new().await; let user_profile_pb = test.af_cloud_sign_up().await; @@ -65,7 +68,9 @@ async fn af_cloud_create_workspace_test() { let first_workspace_id = workspaces[0].workspace_id.as_str(); assert_eq!(workspaces.len(), 1); - let created_workspace = test.create_workspace("my second workspace").await; + let created_workspace = test + .create_workspace("my second workspace", AuthType::AppFlowyCloud) + .await; assert_eq!(created_workspace.name, "my second workspace"); let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; @@ -84,7 +89,12 @@ async fn af_cloud_create_workspace_test() { } { // after opening new workspace - test.open_workspace(&created_workspace.workspace_id).await; + test + .open_workspace( + &created_workspace.workspace_id, + created_workspace.workspace_auth_type, + ) + .await; let folder_ws = test.folder_read_current_workspace().await; assert_eq!(folder_ws.id, created_workspace.workspace_id); let views = test.folder_read_current_workspace_views().await; @@ -97,42 +107,63 @@ async fn af_cloud_create_workspace_test() { #[tokio::test] async fn af_cloud_open_workspace_test() { - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test = EventIntegrationTest::new().await; let _ = test.af_cloud_sign_up().await; - let default_document_name = "Getting started"; + let default_document_name = "General"; test.create_document("A").await; test.create_document("B").await; let first_workspace = test.get_current_workspace().await; + let first_workspace = test.get_user_workspace(&first_workspace.id).await; let views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 3); + assert_eq!(views.len(), 4); assert_eq!(views[0].name, default_document_name); - assert_eq!(views[1].name, "A"); - assert_eq!(views[2].name, "B"); + assert_eq!(views[1].name, "Shared"); + assert_eq!(views[2].name, "A"); + assert_eq!(views[3].name, "B"); - let user_workspace = test.create_workspace("second workspace").await; - test.open_workspace(&user_workspace.workspace_id).await; + let user_workspace = test + .create_workspace("second workspace", AuthType::AppFlowyCloud) + .await; + test + .open_workspace( + &user_workspace.workspace_id, + user_workspace.workspace_auth_type, + ) + .await; let second_workspace = test.get_current_workspace().await; + let second_workspace = test.get_user_workspace(&second_workspace.id).await; test.create_document("C").await; test.create_document("D").await; let views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 3); + assert_eq!(views.len(), 4); assert_eq!(views[0].name, default_document_name); - assert_eq!(views[1].name, "C"); - assert_eq!(views[2].name, "D"); + assert_eq!(views[1].name, "Shared"); + assert_eq!(views[2].name, "C"); + assert_eq!(views[3].name, "D"); // simulate open workspace and check if the views are correct - for i in 0..30 { + for i in 0..10 { if i % 2 == 0 { - test.open_workspace(&first_workspace.id).await; + test + .open_workspace( + &first_workspace.workspace_id, + first_workspace.workspace_auth_type.clone(), + ) + .await; sleep(Duration::from_millis(300)).await; test .create_document(&uuid::Uuid::new_v4().to_string()) .await; } else { - test.open_workspace(&second_workspace.id).await; + test + .open_workspace( + &second_workspace.workspace_id, + second_workspace.workspace_auth_type.clone(), + ) + .await; sleep(Duration::from_millis(200)).await; test .create_document(&uuid::Uuid::new_v4().to_string()) @@ -140,30 +171,39 @@ async fn af_cloud_open_workspace_test() { } } - test.open_workspace(&first_workspace.id).await; - let views = test.get_all_workspace_views().await; - assert_eq!(views[0].name, default_document_name); - assert_eq!(views[1].name, "A"); - assert_eq!(views[2].name, "B"); + test + .open_workspace( + &first_workspace.workspace_id, + first_workspace.workspace_auth_type.clone(), + ) + .await; + let views_1 = test.get_all_workspace_views().await; + assert_eq!(views_1[0].name, default_document_name); + assert_eq!(views_1[1].name, "Shared"); + assert_eq!(views_1[2].name, "A"); + assert_eq!(views_1[3].name, "B"); - test.open_workspace(&second_workspace.id).await; - let views = test.get_all_workspace_views().await; - assert_eq!(views[0].name, default_document_name); - assert_eq!(views[1].name, "C"); - assert_eq!(views[2].name, "D"); + test + .open_workspace( + &second_workspace.workspace_id, + second_workspace.workspace_auth_type.clone(), + ) + .await; + let views_2 = test.get_all_workspace_views().await; + assert_eq!(views_2[0].name, default_document_name); + assert_eq!(views_2[1].name, "Shared"); + assert_eq!(views_2[2].name, "C"); + assert_eq!(views_2[3].name, "D"); } #[tokio::test] async fn af_cloud_different_open_same_workspace_test() { - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; // Set up the primary client and sign them up to the cloud. - let client_1 = EventIntegrationTest::new().await; - let owner_profile = client_1.af_cloud_sign_up().await; - let shared_workspace_id = client_1.get_current_workspace().await.id.clone(); - - // Verify that the workspace ID from the profile matches the current session's workspace ID. - assert_eq!(shared_workspace_id, owner_profile.workspace_id); + let test_runner = EventIntegrationTest::new().await; + let owner_profile = test_runner.af_cloud_sign_up().await; + let shared_workspace_id = test_runner.get_current_workspace().await.id.clone(); // Define the number of additional clients let num_clients = 5; @@ -176,13 +216,13 @@ async fn af_cloud_different_open_same_workspace_test() { let views = client.get_all_workspace_views().await; // only the getting started view should be present - assert_eq!(views.len(), 1); + assert_eq!(views.len(), 2); for view in views { client.delete_view(&view.id).await; } - client_1 - .add_workspace_member(&owner_profile.workspace_id, &client_profile.email) + test_runner + .add_workspace_member(&shared_workspace_id, &client) .await; clients.push((client, client_profile)); } @@ -195,18 +235,24 @@ async fn af_cloud_different_open_same_workspace_test() { // Simulate each client open different workspace 30 times let mut handles = vec![]; + let local_set = LocalSet::new(); for client in clients.clone() { let cloned_shared_workspace_id = shared_workspace_id.clone(); - let handle = tokio::spawn(async move { + let handle = local_set.spawn_local(async move { let (client, profile) = client; let all_workspaces = get_synced_workspaces(&client, profile.id).await; for i in 0..30 { let index = i % 2; let iter_workspace_id = &all_workspaces[index].workspace_id; - client.open_workspace(iter_workspace_id).await; + client + .open_workspace( + iter_workspace_id, + all_workspaces[index].workspace_auth_type.clone(), + ) + .await; if iter_workspace_id == &cloned_shared_workspace_id { let views = client.get_all_workspace_views().await; - assert_eq!(views.len(), 1); + assert_eq!(views.len(), 2); sleep(Duration::from_millis(300)).await; } else { let views = client.get_all_workspace_views().await; @@ -216,10 +262,16 @@ async fn af_cloud_different_open_same_workspace_test() { }); handles.push(handle); } - futures::future::join_all(handles).await; + let results = local_set + .run_until(futures::future::join_all(handles)) + .await; + + for result in results { + assert!(result.is_ok()); + } // Retrieve and verify the collaborative document state for Client 1's workspace. - let doc_state = client_1 + let doc_state = test_runner .get_collab_doc_state(&shared_workspace_id, CollabType::Folder) .await .unwrap(); @@ -235,8 +287,44 @@ async fn af_cloud_different_open_same_workspace_test() { // Retrieve and verify the views associated with the workspace. let views = folder.get_views_belong_to(&shared_workspace_id); let folder_workspace_id = folder.get_workspace_id(); - assert_eq!(folder_workspace_id, shared_workspace_id); + assert_eq!(folder_workspace_id, Some(shared_workspace_id)); - assert_eq!(views.len(), 1, "only get: {:?}", views); // Expecting two views. - assert_eq!(views[0].name, "Getting started"); + assert_eq!(views.len(), 2, "only get: {:?}", views); // Expecting two views. + assert_eq!(views[0].name, "General"); +} + +#[tokio::test] +async fn af_cloud_create_local_workspace_test() { + use_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + let _ = test.af_cloud_sign_up().await; + + let workspaces = test.get_all_workspaces().await.items; + assert_eq!(workspaces.len(), 1); + + let created_workspace = test + .create_workspace("my local workspace", AuthType::Local) + .await; + assert_eq!(created_workspace.name, "my local workspace"); + + let workspaces = test.get_all_workspaces().await.items; + assert_eq!(workspaces.len(), 2); + assert_eq!(workspaces[1].name, "my local workspace"); + + test + .open_workspace( + &created_workspace.workspace_id, + created_workspace.workspace_auth_type, + ) + .await; + + let views = test.get_all_views().await; + assert_eq!(views.len(), 2); + assert!(views + .iter() + .any(|view| view.parent_view_id == workspaces[1].workspace_id)); + + for view in views { + test.get_view(&view.id).await; + } } diff --git a/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs b/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs index 3cd3733837..138f6f0258 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs @@ -1,6 +1,6 @@ use event_integration_test::user_event::{login_password, unique_email}; use event_integration_test::{event_builder::EventBuilder, EventIntegrationTest}; -use flowy_user::entities::{AuthenticatorPB, SignInPayloadPB, SignUpPayloadPB}; +use flowy_user::entities::{AuthTypePB, SignInPayloadPB, SignUpPayloadPB}; use flowy_user::errors::ErrorCode; use flowy_user::event_map::UserEvent::*; @@ -14,7 +14,7 @@ async fn sign_up_with_invalid_email() { email: email.to_string(), name: valid_name(), password: login_password(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: "".to_string(), }; @@ -31,29 +31,6 @@ async fn sign_up_with_invalid_email() { ); } } -#[tokio::test] -async fn sign_up_with_long_password() { - let sdk = EventIntegrationTest::new().await; - let request = SignUpPayloadPB { - email: unique_email(), - name: valid_name(), - password: "1234".repeat(100).as_str().to_string(), - auth_type: AuthenticatorPB::Local, - device_id: "".to_string(), - }; - - assert_eq!( - EventBuilder::new(sdk) - .event(SignUp) - .payload(request) - .async_send() - .await - .error() - .unwrap() - .code, - ErrorCode::PasswordTooLong - ); -} #[tokio::test] async fn sign_in_with_invalid_email() { @@ -63,7 +40,7 @@ async fn sign_in_with_invalid_email() { email: email.to_string(), password: login_password(), name: "".to_string(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: "".to_string(), }; @@ -90,7 +67,7 @@ async fn sign_in_with_invalid_password() { email: unique_email(), password, name: "".to_string(), - auth_type: AuthenticatorPB::Local, + auth_type: AuthTypePB::Local, device_id: "".to_string(), }; diff --git a/frontend/rust-lib/event-integration-test/tests/user/local_test/import_af_data_local_test.rs b/frontend/rust-lib/event-integration-test/tests/user/local_test/import_af_data_local_test.rs index c8c4d2def1..4f1ea45fee 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/local_test/import_af_data_local_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/local_test/import_af_data_local_test.rs @@ -1,5 +1,5 @@ use crate::util::unzip; -use event_integration_test::user_event::user_localhost_af_cloud; +use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; use flowy_core::DEFAULT_NAME; use std::time::Duration; @@ -7,11 +7,10 @@ use std::time::Duration; #[tokio::test] async fn import_appflowy_data_folder_into_new_view_test() { let import_container_name = "040_local".to_string(); - let (cleaner, user_db_path) = unzip("./tests/asset", &import_container_name).unwrap(); - let (imported_af_folder_cleaner, imported_af_data_path) = - unzip("./tests/asset", &import_container_name).unwrap(); + let user_db_path = unzip("./tests/asset", &import_container_name).unwrap(); + let imported_af_data_path = unzip("./tests/asset", &import_container_name).unwrap(); - user_localhost_af_cloud().await; + use_localhost_af_cloud().await; let test = EventIntegrationTest::new_with_user_data_path(user_db_path.clone(), DEFAULT_NAME.to_string()) .await; @@ -34,16 +33,20 @@ async fn import_appflowy_data_folder_into_new_view_test() { // after import, the structure is: // workspace: - // view: Getting Started - // view: 040_local - // view: Document1 - // view: Document2 - // view: Grid1 - // view: Grid2 + // Document1 + // Document2 + // Grid1 + // Grid2 + // 040_local let views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 2); - assert_eq!(views[1].name, import_container_name); + assert_eq!(views.len(), 1); + assert_eq!(views[0].name, "Document1"); + assert_eq!(views[0].child_views.len(), 2); - drop(cleaner); - drop(imported_af_folder_cleaner); + for (index, view) in views[0].child_views.iter().enumerate() { + let view = test.get_view(&view.id).await; + if index == 1 { + assert_eq!(view.name, import_container_name); + } + } } diff --git a/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs b/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs index 798054dccf..438b120483 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs @@ -1,6 +1,6 @@ use crate::user::local_test::helper::*; use event_integration_test::{event_builder::EventBuilder, EventIntegrationTest}; -use flowy_user::entities::{AuthenticatorPB, UpdateUserProfilePayloadPB, UserProfilePB}; +use flowy_user::entities::{AuthTypePB, UpdateUserProfilePayloadPB, UserProfilePB}; use flowy_user::{errors::ErrorCode, event_map::UserEvent::*}; use nanoid::nanoid; #[tokio::test] @@ -24,10 +24,7 @@ async fn anon_user_profile_get() { .await .parse::(); assert_eq!(user_profile.id, user.id); - assert_eq!(user_profile.openai_key, user.openai_key); - assert_eq!(user_profile.stability_ai_key, user.stability_ai_key); - assert_eq!(user_profile.workspace_id, user.workspace_id); - assert_eq!(user_profile.authenticator, AuthenticatorPB::Local); + assert_eq!(user_profile.user_auth_type, AuthTypePB::Local); } #[tokio::test] @@ -51,31 +48,6 @@ async fn user_update_with_name() { assert_eq!(user_profile.name, new_name,); } -#[tokio::test] -async fn user_update_with_ai_key() { - let sdk = EventIntegrationTest::new().await; - let user = sdk.init_anon_user().await; - let openai_key = "openai_key".to_owned(); - let stability_ai_key = "stability_ai_key".to_owned(); - let request = UpdateUserProfilePayloadPB::new(user.id) - .openai_key(&openai_key) - .stability_ai_key(&stability_ai_key); - let _ = EventBuilder::new(sdk.clone()) - .event(UpdateUserProfile) - .payload(request) - .async_send() - .await; - - let user_profile = EventBuilder::new(sdk.clone()) - .event(GetUserProfile) - .async_send() - .await - .parse::(); - - assert_eq!(user_profile.openai_key, openai_key,); - assert_eq!(user_profile.stability_ai_key, stability_ai_key,); -} - #[tokio::test] async fn anon_user_update_with_email() { let sdk = EventIntegrationTest::new().await; diff --git a/frontend/rust-lib/event-integration-test/tests/user/migration_test/collab_db_restore.rs b/frontend/rust-lib/event-integration-test/tests/user/migration_test/collab_db_restore.rs deleted file mode 100644 index 45f33c3cd9..0000000000 --- a/frontend/rust-lib/event-integration-test/tests/user/migration_test/collab_db_restore.rs +++ /dev/null @@ -1,20 +0,0 @@ -use event_integration_test::EventIntegrationTest; -use flowy_core::DEFAULT_NAME; - -use crate::util::unzip; - -#[tokio::test] -async fn collab_db_restore_test() { - let (cleaner, user_db_path) = unzip( - "./tests/user/migration_test/history_user_db", - "038_collab_db_corrupt_restore", - ) - .unwrap(); - let test = - EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await; - - let views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 1); - - drop(cleaner); -} diff --git a/frontend/rust-lib/event-integration-test/tests/user/migration_test/document_test.rs b/frontend/rust-lib/event-integration-test/tests/user/migration_test/document_test.rs index 6b16407dec..f4ba5ec831 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/migration_test/document_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/migration_test/document_test.rs @@ -6,7 +6,7 @@ use crate::util::unzip; #[tokio::test] async fn migrate_historical_empty_document_test() { - let (cleaner, user_db_path) = unzip( + let user_db_path = unzip( "./tests/user/migration_test/history_user_db", "historical_empty_document", ) @@ -23,6 +23,4 @@ async fn migrate_historical_empty_document_test() { assert_eq!(data.blocks.len(), 2); assert!(!data.meta.children_map.is_empty()); } - - drop(cleaner); } diff --git a/frontend/rust-lib/event-integration-test/tests/user/migration_test/mod.rs b/frontend/rust-lib/event-integration-test/tests/user/migration_test/mod.rs index 42748e823a..940f03e64f 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/migration_test/mod.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/migration_test/mod.rs @@ -1,4 +1,2 @@ mod document_test; mod version_test; - -mod collab_db_restore; diff --git a/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs b/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs index 3e925ba0ec..61833429aa 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs @@ -1,49 +1,13 @@ use event_integration_test::EventIntegrationTest; use flowy_core::DEFAULT_NAME; -use flowy_folder::entities::ViewLayoutPB; use std::time::Duration; use crate::util::unzip; -#[tokio::test] -async fn migrate_020_historical_empty_document_test() { - let (cleaner, user_db_path) = unzip( - "./tests/user/migration_test/history_user_db", - "020_historical_user_data", - ) - .unwrap(); - let test = - EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await; - - let mut views = test.get_all_workspace_views().await; - assert_eq!(views.len(), 1); - - // Check the parent view - let parent_view = views.pop().unwrap(); - assert_eq!(parent_view.layout, ViewLayoutPB::Document); - let data = test.open_document(parent_view.id.clone()).await.data; - assert!(!data.page_id.is_empty()); - assert_eq!(data.blocks.len(), 2); - assert!(!data.meta.children_map.is_empty()); - - // Check the child views of the parent view - let child_views = test.get_view(&parent_view.id).await.child_views; - assert_eq!(child_views.len(), 4); - assert_eq!(child_views[0].layout, ViewLayoutPB::Document); - assert_eq!(child_views[1].layout, ViewLayoutPB::Grid); - assert_eq!(child_views[2].layout, ViewLayoutPB::Calendar); - assert_eq!(child_views[3].layout, ViewLayoutPB::Board); - - let database = test.get_database(&child_views[1].id).await; - assert_eq!(database.fields.len(), 8); - assert_eq!(database.rows.len(), 3); - drop(cleaner); -} - #[tokio::test] async fn migrate_036_fav_v1_workspace_array_test() { // Used to test migration: FavoriteV1AndWorkspaceArrayMigration - let (cleaner, user_db_path) = unzip( + let user_db_path = unzip( "./tests/user/migration_test/history_user_db", "036_fav_v1_workspace_array", ) @@ -59,13 +23,12 @@ async fn migrate_036_fav_v1_workspace_array_test() { let views = test.get_view(&views[1].id).await; assert_eq!(views.child_views.len(), 3); assert!(views.child_views[2].is_favorite); - drop(cleaner); } #[tokio::test] async fn migrate_038_trash_test() { // Used to test migration: WorkspaceTrashMapToSectionMigration - let (cleaner, user_db_path) = unzip("./tests/asset", "038_local").unwrap(); + let user_db_path = unzip("./tests/asset", "038_local").unwrap(); // Getting started // Document1 // Document2(deleted) @@ -95,14 +58,12 @@ async fn migrate_038_trash_test() { assert_eq!(trash_items[0].name, "Document3"); assert_eq!(trash_items[1].name, "Document2"); assert_eq!(trash_items[2].name, "Document4"); - - drop(cleaner); } #[tokio::test] async fn migrate_038_trash_test2() { // Used to test migration: WorkspaceTrashMapToSectionMigration - let (cleaner, user_db_path) = unzip("./tests/asset", "038_document_with_grid").unwrap(); + let user_db_path = unzip("./tests/asset", "038_document_with_grid").unwrap(); // Getting started // document // grid @@ -123,18 +84,19 @@ async fn migrate_038_trash_test2() { let views = test.get_view(&views[0].id).await.child_views; assert_eq!(views[0].name, "board"); - - drop(cleaner); } #[tokio::test] async fn collab_db_backup_test() { // Used to test migration: WorkspaceTrashMapToSectionMigration - let (cleaner, user_db_path) = unzip("./tests/asset", "038_local").unwrap(); + let user_db_path = unzip("./tests/asset", "038_local").unwrap(); let test = EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await; let uid = test.get_user_profile().await.unwrap().id; + // sleep a bit to make sure the backup is generated + + tokio::time::sleep(Duration::from_secs(10)).await; let backups = test.user_manager.get_collab_backup_list(uid); assert_eq!(backups.len(), 1); @@ -142,13 +104,12 @@ async fn collab_db_backup_test() { backups[0], format!("collab_db_{}", chrono::Local::now().format("%Y%m%d")) ); - drop(cleaner); } #[tokio::test] async fn delete_outdated_collab_db_backup_test() { // Used to test migration: WorkspaceTrashMapToSectionMigration - let (cleaner, user_db_path) = unzip("./tests/asset", "040_collab_backups").unwrap(); + let user_db_path = unzip("./tests/asset", "040_collab_backups").unwrap(); let test = EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await; @@ -176,5 +137,4 @@ async fn delete_outdated_collab_db_backup_test() { backups[9], format!("collab_db_{}", chrono::Local::now().format("%Y%m%d")) ); - drop(cleaner); } diff --git a/frontend/rust-lib/event-integration-test/tests/user/mod.rs b/frontend/rust-lib/event-integration-test/tests/user/mod.rs index ab778a29c1..ad053eb0f9 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/mod.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/mod.rs @@ -2,5 +2,3 @@ mod local_test; mod migration_test; mod af_cloud_test; -// #[cfg(feature = "supabase_cloud_test")] -// mod supabase_test; diff --git a/frontend/rust-lib/event-integration-test/tests/util.rs b/frontend/rust-lib/event-integration-test/tests/util.rs index 0a63ccaac7..2e2c2d578f 100644 --- a/frontend/rust-lib/event-integration-test/tests/util.rs +++ b/frontend/rust-lib/event-integration-test/tests/util.rs @@ -1,17 +1,12 @@ +use nanoid::nanoid; +use std::env::temp_dir; use std::fs::{create_dir_all, File, OpenOptions}; use std::io::copy; use std::ops::Deref; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::time::Duration; use std::{fs, io}; - -use anyhow::Error; -use collab_folder::FolderData; -use collab_plugins::cloud_storage::RemoteCollabStorage; -use nanoid::nanoid; use tokio::sync::mpsc::Receiver; - use tokio::time::timeout; use uuid::Uuid; use walkdir::WalkDir; @@ -19,24 +14,12 @@ use zip::write::FileOptions; use zip::{CompressionMethod, ZipArchive, ZipWriter}; use event_integration_test::event_builder::EventBuilder; -use event_integration_test::Cleaner; + use event_integration_test::EventIntegrationTest; -use flowy_database_pub::cloud::DatabaseCloudService; -use flowy_folder_pub::cloud::{FolderCloudService, FolderSnapshot}; -use flowy_server::supabase::api::*; -use flowy_server::{AppFlowyEncryption, EncryptionImpl}; -use flowy_server_pub::supabase_config::SupabaseConfiguration; -use flowy_user::entities::{AuthenticatorPB, UpdateUserProfilePayloadPB}; +use flowy_folder::entities::{ImportItemPayloadPB, ImportPayloadPB, ImportTypePB, ViewLayoutPB}; +use flowy_user::entities::UpdateUserProfilePayloadPB; use flowy_user::errors::FlowyError; - use flowy_user::event_map::UserEvent::*; -use flowy_user_pub::cloud::UserCloudService; -use flowy_user_pub::entities::Authenticator; - -pub fn get_supabase_config() -> Option { - dotenv::from_path(".env.ci").ok()?; - SupabaseConfiguration::from_env().ok() -} pub struct FlowySupabaseTest { event_test: EventIntegrationTest, @@ -44,13 +27,7 @@ pub struct FlowySupabaseTest { impl FlowySupabaseTest { pub async fn new() -> Option { - let _ = get_supabase_config()?; let event_test = EventIntegrationTest::new().await; - event_test.set_auth_type(AuthenticatorPB::Supabase); - event_test - .server_provider - .set_authenticator(Authenticator::Supabase); - Some(Self { event_test }) } @@ -79,93 +56,6 @@ pub async fn receive_with_timeout(mut receiver: Receiver, duration: Durati timeout(duration, receiver.recv()).await.ok()? } -pub fn get_supabase_ci_config() -> Option { - dotenv::from_filename("./.env.ci").ok()?; - SupabaseConfiguration::from_env().ok() -} - -#[allow(dead_code)] -pub fn get_supabase_dev_config() -> Option { - dotenv::from_filename("./.env.dev").ok()?; - SupabaseConfiguration::from_env().ok() -} - -pub fn collab_service() -> Arc { - let (server, encryption_impl) = appflowy_server(None); - Arc::new(SupabaseCollabStorageImpl::new( - server, - None, - Arc::downgrade(&encryption_impl), - )) -} - -pub fn database_service() -> Arc { - let (server, _encryption_impl) = appflowy_server(None); - Arc::new(SupabaseDatabaseServiceImpl::new(server)) -} - -pub fn user_auth_service() -> Arc { - let (server, _encryption_impl) = appflowy_server(None); - Arc::new(SupabaseUserServiceImpl::new(server, vec![], None)) -} - -pub fn folder_service() -> Arc { - let (server, _encryption_impl) = appflowy_server(None); - Arc::new(SupabaseFolderServiceImpl::new(server)) -} - -#[allow(dead_code)] -pub fn encryption_folder_service( - secret: Option, -) -> (Arc, Arc) { - let (server, encryption_impl) = appflowy_server(secret); - let service = Arc::new(SupabaseFolderServiceImpl::new(server)); - (service, encryption_impl) -} - -pub fn encryption_collab_service( - secret: Option, -) -> (Arc, Arc) { - let (server, encryption_impl) = appflowy_server(secret); - let service = Arc::new(SupabaseCollabStorageImpl::new( - server, - None, - Arc::downgrade(&encryption_impl), - )); - (service, encryption_impl) -} - -pub async fn get_folder_data_from_server( - uid: &i64, - folder_id: &str, - encryption_secret: Option, -) -> Result, Error> { - let (cloud_service, _encryption) = encryption_folder_service(encryption_secret); - cloud_service.get_folder_data(folder_id, uid).await -} - -pub async fn get_folder_snapshots( - folder_id: &str, - encryption_secret: Option, -) -> Vec { - let (cloud_service, _encryption) = encryption_folder_service(encryption_secret); - cloud_service - .get_folder_snapshots(folder_id, 10) - .await - .unwrap() -} - -pub fn appflowy_server( - encryption_secret: Option, -) -> (SupabaseServerServiceImpl, Arc) { - let config = SupabaseConfiguration::from_env().unwrap(); - let encryption_impl: Arc = - Arc::new(EncryptionImpl::new(encryption_secret)); - let encryption = Arc::downgrade(&encryption_impl); - let server = Arc::new(RESTfulPostgresServer::new(config, encryption)); - (SupabaseServerServiceImpl::new(server), encryption_impl) -} - /// zip the asset to the destination /// Zips the specified directory into a zip file. /// @@ -190,7 +80,7 @@ pub fn zip(src_dir: PathBuf, output_file_path: PathBuf) -> io::Result<()> { .truncate(true) .open(&output_file_path)?; - let options = FileOptions::default().compression_method(CompressionMethod::Deflated); + let options = FileOptions::<()>::default().compression_method(CompressionMethod::Deflated); let mut zip = ZipWriter::new(file); @@ -233,26 +123,23 @@ pub fn zip(src_dir: PathBuf, output_file_path: PathBuf) -> io::Result<()> { zip.finish()?; Ok(()) } -pub fn unzip_test_asset(folder_name: &str) -> io::Result<(Cleaner, PathBuf)> { +pub fn unzip_test_asset(folder_name: &str) -> io::Result { unzip("./tests/asset", folder_name) } -pub fn unzip(root: &str, folder_name: &str) -> io::Result<(Cleaner, PathBuf)> { +pub fn unzip(test_asset_dir: &str, folder_name: &str) -> io::Result { // Open the zip file - let zip_file_path = format!("{}/{}.zip", root, folder_name); + let zip_file_path = format!("{}/{}.zip", test_asset_dir, folder_name); let reader = File::open(zip_file_path)?; - let output_folder_path = format!("{}/unit_test_{}", root, nanoid!(6)); + // let output_folder_path = format!("{}/unit_test_{}", test_asset_dir, nanoid!(6)); + let output_folder_path = temp_dir().join(nanoid!(6)).to_str().unwrap().to_string(); // Create a ZipArchive from the file let mut archive = ZipArchive::new(reader)?; - - // Iterate through each file in the zip for i in 0..archive.len() { let mut file = archive.by_index(i)?; let output_path = Path::new(&output_folder_path).join(file.mangled_name()); - if file.name().ends_with('/') { - // Create directory create_dir_all(&output_path)?; } else { // Write file @@ -266,12 +153,23 @@ pub fn unzip(root: &str, folder_name: &str) -> io::Result<(Cleaner, PathBuf)> { } } let path = format!("{}/{}", output_folder_path, folder_name); - Ok(( - Cleaner::new(PathBuf::from(output_folder_path)), - PathBuf::from(path), - )) + Ok(PathBuf::from(path)) } pub fn generate_test_email() -> String { format!("{}@test.com", Uuid::new_v4()) } + +pub fn gen_csv_import_data(file_name: &str, workspace_id: &str) -> ImportPayloadPB { + let file_path = format!("./tests/asset/{}", file_name); + ImportPayloadPB { + parent_view_id: workspace_id.to_string(), + items: vec![ImportItemPayloadPB { + name: file_name.to_string(), + data: None, + file_path: Some(file_path), + view_layout: ViewLayoutPB::Grid, + import_type: ImportTypePB::CSV, + }], + } +} diff --git a/frontend/rust-lib/flowy-ai-pub/Cargo.toml b/frontend/rust-lib/flowy-ai-pub/Cargo.toml new file mode 100644 index 0000000000..93ea79bcab --- /dev/null +++ b/frontend/rust-lib/flowy-ai-pub/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "flowy-ai-pub" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +lib-infra = { workspace = true } +flowy-error = { workspace = true } +client-api = { workspace = true } +futures.workspace = true +serde_json.workspace = true +serde.workspace = true +uuid.workspace = true +flowy-sqlite = { workspace = true } \ No newline at end of file diff --git a/frontend/rust-lib/flowy-ai-pub/src/cloud.rs b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs new file mode 100644 index 0000000000..2292e0f332 --- /dev/null +++ b/frontend/rust-lib/flowy-ai-pub/src/cloud.rs @@ -0,0 +1,178 @@ +use crate::cloud::ai_dto::AvailableModel; +pub use client_api::entity::ai_dto::{ + AppFlowyOfflineAI, CompleteTextParams, CompletionMessage, CompletionMetadata, CompletionType, + CreateChatContext, CustomPrompt, LLMModel, LocalAIConfig, ModelInfo, ModelList, OutputContent, + OutputLayout, RelatedQuestion, RepeatedRelatedQuestion, ResponseFormat, StringOrMessage, +}; +pub use client_api::entity::billing_dto::SubscriptionPlan; +pub use client_api::entity::chat_dto::{ + ChatMessage, ChatMessageType, ChatRAGData, ChatSettings, ContextLoader, MessageCursor, + RepeatedChatMessage, UpdateChatParams, +}; +pub use client_api::entity::QuestionStreamValue; +pub use client_api::entity::*; +pub use client_api::error::{AppResponseError, ErrorCode as AppErrorCode}; +use flowy_error::FlowyError; +use futures::stream::BoxStream; +use lib_infra::async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; +use uuid::Uuid; + +pub type ChatMessageStream = BoxStream<'static, Result>; +pub type StreamAnswer = BoxStream<'static, Result>; +pub type StreamComplete = BoxStream<'static, Result>; + +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize, Clone)] +pub struct AIModel { + pub name: String, + pub is_local: bool, + #[serde(default)] + pub desc: String, +} + +impl From for AIModel { + fn from(value: AvailableModel) -> Self { + let desc = value + .metadata + .as_ref() + .and_then(|v| v.get("desc").map(|v| v.as_str().unwrap_or(""))) + .unwrap_or(""); + Self { + name: value.name, + is_local: false, + desc: desc.to_string(), + } + } +} + +impl AIModel { + pub fn server(name: String, desc: String) -> Self { + Self { + name, + is_local: false, + desc, + } + } + + pub fn local(name: String, desc: String) -> Self { + Self { + name, + is_local: true, + desc, + } + } +} + +pub const DEFAULT_AI_MODEL_NAME: &str = "Auto"; +impl Default for AIModel { + fn default() -> Self { + Self { + name: DEFAULT_AI_MODEL_NAME.to_string(), + is_local: false, + desc: "".to_string(), + } + } +} + +#[async_trait] +pub trait ChatCloudService: Send + Sync + 'static { + async fn create_chat( + &self, + uid: &i64, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: serde_json::Value, + ) -> Result<(), FlowyError>; + + async fn create_question( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + message_type: ChatMessageType, + ) -> Result; + + async fn create_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + question_id: i64, + metadata: Option, + ) -> Result; + + async fn stream_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + format: ResponseFormat, + ai_model: Option, + ) -> Result; + + async fn get_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + ) -> Result; + + async fn get_chat_messages( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + offset: MessageCursor, + limit: u64, + ) -> Result; + + async fn get_question_from_answer_id( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, + ) -> Result; + + async fn get_related_message( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + ai_model: Option, + ) -> Result; + + async fn stream_complete( + &self, + workspace_id: &Uuid, + params: CompleteTextParams, + ai_model: Option, + ) -> Result; + + async fn embed_file( + &self, + workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, + ) -> Result<(), FlowyError>; + + async fn get_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + ) -> Result; + + async fn update_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + params: UpdateChatParams, + ) -> Result<(), FlowyError>; + + async fn get_available_models(&self, workspace_id: &Uuid) -> Result; + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result; +} diff --git a/frontend/rust-lib/flowy-ai-pub/src/lib.rs b/frontend/rust-lib/flowy-ai-pub/src/lib.rs new file mode 100644 index 0000000000..9a7423ec3f --- /dev/null +++ b/frontend/rust-lib/flowy-ai-pub/src/lib.rs @@ -0,0 +1,2 @@ +pub mod cloud; +pub mod persistence; diff --git a/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs new file mode 100644 index 0000000000..230e5761d2 --- /dev/null +++ b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs @@ -0,0 +1,188 @@ +use crate::cloud::MessageCursor; +use client_api::entity::chat_dto::ChatMessage; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::upsert::excluded; +use flowy_sqlite::{ + diesel, insert_into, + query_dsl::*, + schema::{chat_message_table, chat_message_table::dsl}, + DBConnection, ExpressionMethods, Identifiable, Insertable, OptionalExtension, QueryResult, + Queryable, +}; + +#[derive(Queryable, Insertable, Identifiable)] +#[diesel(table_name = chat_message_table)] +#[diesel(primary_key(message_id))] +pub struct ChatMessageTable { + pub message_id: i64, + pub chat_id: String, + pub content: String, + pub created_at: i64, + pub author_type: i64, + pub author_id: String, + pub reply_message_id: Option, + pub metadata: Option, + pub is_sync: bool, +} +impl ChatMessageTable { + pub fn from_message(chat_id: String, message: ChatMessage, is_sync: bool) -> Self { + ChatMessageTable { + message_id: message.message_id, + chat_id, + content: message.content, + created_at: message.created_at.timestamp(), + author_type: message.author.author_type as i64, + author_id: message.author.author_id.to_string(), + reply_message_id: message.reply_message_id, + metadata: Some(serde_json::to_string(&message.metadata).unwrap_or_default()), + is_sync, + } + } +} + +pub fn update_chat_message_is_sync( + mut conn: DBConnection, + chat_id_val: &str, + message_id_val: i64, + is_sync_val: bool, +) -> FlowyResult<()> { + diesel::update(chat_message_table::table) + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .filter(chat_message_table::message_id.eq(message_id_val)) + .set(chat_message_table::is_sync.eq(is_sync_val)) + .execute(&mut *conn)?; + + Ok(()) +} + +pub fn upsert_chat_messages( + mut conn: DBConnection, + new_messages: &[ChatMessageTable], +) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + for message in new_messages { + let _ = insert_into(chat_message_table::table) + .values(message) + .on_conflict(chat_message_table::message_id) + .do_update() + .set(( + chat_message_table::content.eq(excluded(chat_message_table::content)), + chat_message_table::metadata.eq(excluded(chat_message_table::metadata)), + chat_message_table::created_at.eq(excluded(chat_message_table::created_at)), + chat_message_table::author_type.eq(excluded(chat_message_table::author_type)), + chat_message_table::author_id.eq(excluded(chat_message_table::author_id)), + chat_message_table::reply_message_id.eq(excluded(chat_message_table::reply_message_id)), + )) + .execute(conn)?; + } + Ok::<(), FlowyError>(()) + })?; + + Ok(()) +} + +pub struct ChatMessagesResult { + pub messages: Vec, + pub total_count: i64, + pub has_more: bool, +} + +pub fn select_chat_messages( + mut conn: DBConnection, + chat_id_val: &str, + limit_val: u64, + offset: MessageCursor, +) -> QueryResult { + let mut query = dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .into_boxed(); + + match offset { + MessageCursor::AfterMessageId(after_message_id) => { + query = query.filter(chat_message_table::message_id.gt(after_message_id)); + }, + MessageCursor::BeforeMessageId(before_message_id) => { + query = query.filter(chat_message_table::message_id.lt(before_message_id)); + }, + MessageCursor::Offset(offset_val) => { + query = query.offset(offset_val as i64); + }, + MessageCursor::NextBack => {}, + } + + // Get total count before applying limit + let total_count = dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .count() + .first::(&mut *conn)?; + + query = query + .order(( + chat_message_table::created_at.desc(), + chat_message_table::message_id.desc(), + )) + .limit(limit_val as i64); + + let messages: Vec = query.load::(&mut *conn)?; + + // Check if there are more messages + let has_more = if let Some(last_message) = messages.last() { + let remaining_count = dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .filter(chat_message_table::message_id.lt(last_message.message_id)) + .count() + .first::(&mut *conn)?; + + remaining_count > 0 + } else { + false + }; + + Ok(ChatMessagesResult { + messages, + total_count, + has_more, + }) +} + +pub fn total_message_count(mut conn: DBConnection, chat_id_val: &str) -> QueryResult { + dsl::chat_message_table + .filter(chat_message_table::chat_id.eq(chat_id_val)) + .count() + .first::(&mut *conn) +} + +pub fn select_message( + mut conn: DBConnection, + message_id_val: i64, +) -> QueryResult> { + let message = dsl::chat_message_table + .filter(chat_message_table::message_id.eq(message_id_val)) + .first::(&mut *conn) + .optional()?; + Ok(message) +} + +pub fn select_message_content( + mut conn: DBConnection, + message_id_val: i64, +) -> QueryResult> { + let message = dsl::chat_message_table + .filter(chat_message_table::message_id.eq(message_id_val)) + .select(chat_message_table::content) + .first::(&mut *conn) + .optional()?; + Ok(message) +} + +pub fn select_answer_where_match_reply_message_id( + mut conn: DBConnection, + chat_id: &str, + answer_message_id_val: i64, +) -> QueryResult> { + dsl::chat_message_table + .filter(chat_message_table::reply_message_id.eq(answer_message_id_val)) + .filter(chat_message_table::chat_id.eq(chat_id)) + .first::(&mut *conn) + .optional() +} diff --git a/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs new file mode 100644 index 0000000000..f5398c48c0 --- /dev/null +++ b/frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs @@ -0,0 +1,177 @@ +use diesel::sqlite::SqliteConnection; +use flowy_error::FlowyResult; +use flowy_sqlite::upsert::excluded; +use flowy_sqlite::{ + diesel, + query_dsl::*, + schema::{chat_table, chat_table::dsl}, + AsChangeset, DBConnection, ExpressionMethods, Identifiable, Insertable, QueryResult, Queryable, +}; +use lib_infra::util::timestamp; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +#[derive(Clone, Default, Queryable, Insertable, Identifiable)] +#[diesel(table_name = chat_table)] +#[diesel(primary_key(chat_id))] +pub struct ChatTable { + pub chat_id: String, + pub created_at: i64, + pub name: String, + pub metadata: String, + pub rag_ids: Option, + pub is_sync: bool, +} + +impl ChatTable { + pub fn new(chat_id: String, metadata: Value, rag_ids: Vec, is_sync: bool) -> Self { + let rag_ids = rag_ids.iter().map(|v| v.to_string()).collect::>(); + let metadata = serialize_chat_metadata(&metadata); + let rag_ids = Some(serialize_rag_ids(&rag_ids)); + Self { + chat_id, + created_at: timestamp(), + name: "".to_string(), + metadata, + rag_ids, + is_sync, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ChatTableMetadata { + pub files: Vec, +} + +impl ChatTableMetadata { + pub fn add_file(&mut self, name: String, id: String) { + if let Some(file) = self.files.iter_mut().find(|f| f.name == name) { + file.id = id; + } else { + self.files.push(ChatTableFile { name, id }); + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatTableFile { + pub name: String, + pub id: String, +} + +#[derive(AsChangeset, Identifiable, Default, Debug)] +#[diesel(table_name = chat_table)] +#[diesel(primary_key(chat_id))] +pub struct ChatTableChangeset { + pub chat_id: String, + pub name: Option, + pub metadata: Option, + pub rag_ids: Option, + pub is_sync: Option, +} + +pub fn serialize_rag_ids(rag_ids: &[String]) -> String { + serde_json::to_string(rag_ids).unwrap_or_default() +} + +pub fn deserialize_rag_ids(rag_ids_str: &Option) -> Vec { + match rag_ids_str { + Some(str) => serde_json::from_str(str).unwrap_or_default(), + None => Vec::new(), + } +} + +pub fn deserialize_chat_metadata(metadata: &str) -> T +where + T: serde::de::DeserializeOwned + Default, +{ + serde_json::from_str(metadata).unwrap_or_default() +} + +pub fn serialize_chat_metadata(metadata: &T) -> String +where + T: Serialize, +{ + serde_json::to_string(metadata).unwrap_or_default() +} + +pub fn upsert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult { + diesel::insert_into(chat_table::table) + .values(new_chat) + .on_conflict(chat_table::chat_id) + .do_update() + .set(( + chat_table::created_at.eq(excluded(chat_table::created_at)), + chat_table::name.eq(excluded(chat_table::name)), + chat_table::metadata.eq(excluded(chat_table::metadata)), + chat_table::rag_ids.eq(excluded(chat_table::rag_ids)), + chat_table::is_sync.eq(excluded(chat_table::is_sync)), + )) + .execute(&mut *conn) +} + +pub fn update_chat( + conn: &mut SqliteConnection, + changeset: ChatTableChangeset, +) -> QueryResult { + let filter = dsl::chat_table.filter(chat_table::chat_id.eq(changeset.chat_id.clone())); + let affected_row = diesel::update(filter).set(changeset).execute(conn)?; + Ok(affected_row) +} + +pub fn update_chat_is_sync( + mut conn: DBConnection, + chat_id_val: &str, + is_sync_val: bool, +) -> QueryResult { + diesel::update(dsl::chat_table.filter(chat_table::chat_id.eq(chat_id_val))) + .set(chat_table::is_sync.eq(is_sync_val)) + .execute(&mut *conn) +} + +pub fn read_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult { + let row = dsl::chat_table + .filter(chat_table::chat_id.eq(chat_id_val)) + .first::(&mut *conn)?; + Ok(row) +} + +pub fn read_chat_rag_ids( + conn: &mut SqliteConnection, + chat_id_val: &str, +) -> FlowyResult> { + let chat = dsl::chat_table + .filter(chat_table::chat_id.eq(chat_id_val)) + .first::(conn)?; + + Ok(deserialize_rag_ids(&chat.rag_ids)) +} + +pub fn read_chat_metadata( + conn: &mut SqliteConnection, + chat_id_val: &str, +) -> FlowyResult { + let metadata_str = dsl::chat_table + .select(chat_table::metadata) + .filter(chat_table::chat_id.eq(chat_id_val)) + .first::(&mut *conn)?; + Ok(deserialize_chat_metadata(&metadata_str)) +} + +#[allow(dead_code)] +pub fn update_chat_name( + mut conn: DBConnection, + chat_id_val: &str, + new_name: &str, +) -> QueryResult { + diesel::update(dsl::chat_table.filter(chat_table::chat_id.eq(chat_id_val))) + .set(chat_table::name.eq(new_name)) + .execute(&mut *conn) +} + +#[allow(dead_code)] +pub fn delete_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult { + diesel::delete(dsl::chat_table.filter(chat_table::chat_id.eq(chat_id_val))).execute(&mut *conn) +} diff --git a/frontend/rust-lib/flowy-ai-pub/src/persistence/mod.rs b/frontend/rust-lib/flowy-ai-pub/src/persistence/mod.rs new file mode 100644 index 0000000000..b21eb507ae --- /dev/null +++ b/frontend/rust-lib/flowy-ai-pub/src/persistence/mod.rs @@ -0,0 +1,5 @@ +mod chat_message_sql; +mod chat_sql; + +pub use chat_message_sql::*; +pub use chat_sql::*; diff --git a/frontend/rust-lib/flowy-ai/Cargo.toml b/frontend/rust-lib/flowy-ai/Cargo.toml new file mode 100644 index 0000000000..3a6aaf5898 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "flowy-ai" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +flowy-derive.workspace = true +flowy-notification = { workspace = true } +flowy-error = { path = "../flowy-error", features = [ + "impl_from_dispatch_error", + "impl_from_collab_folder", + "impl_from_sqlite", + "impl_from_appflowy_cloud", +] } +lib-dispatch = { workspace = true } +tracing.workspace = true +uuid.workspace = true +strum_macros = "0.21" +protobuf.workspace = true +bytes.workspace = true +arc-swap.workspace = true +validator = { workspace = true, features = ["derive"] } +lib-infra = { workspace = true, features = ["isolate_flutter"] } +flowy-ai-pub.workspace = true +dashmap.workspace = true +flowy-sqlite = { workspace = true } +tokio.workspace = true +futures.workspace = true +allo-isolate = { version = "^0.1", features = ["catch-unwind"] } +log = "0.4.21" +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +anyhow = "1.0.86" +tokio-stream = "0.1.15" +tokio-util = { workspace = true, features = ["full"] } +af-local-ai = { workspace = true } +af-plugin = { workspace = true } +reqwest = { version = "0.11.27", features = ["json"] } +sha2 = "0.10.7" +base64 = "0.21.5" +futures-util = "0.3.30" +pin-project = "1.1.5" +flowy-storage-pub = { workspace = true } +collab-integrate.workspace = true + + +[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies] +notify = "6.1.1" +af-mcp = { version = "0.1.0" } + +[dev-dependencies] +dotenv = "0.15.0" +uuid.workspace = true +tracing-subscriber = { version = "0.3.17", features = ["registry", "env-filter", "ansi", "json"] } +simsimd = "4.4.0" + +[build-dependencies] +flowy-codegen.workspace = true + +[features] +dart = ["flowy-codegen/dart", "flowy-notification/dart"] diff --git a/frontend/rust-lib/flowy-ai/Flowy.toml b/frontend/rust-lib/flowy-ai/Flowy.toml new file mode 100644 index 0000000000..1410c5951e --- /dev/null +++ b/frontend/rust-lib/flowy-ai/Flowy.toml @@ -0,0 +1,3 @@ +# Check out the FlowyConfig (located in flowy_toml.rs) for more details. +proto_input = ["src/entities.rs", "src/event_map.rs", "src/notification.rs"] +event_files = ["src/event_map.rs"] \ No newline at end of file diff --git a/frontend/rust-lib/flowy-ai/build.rs b/frontend/rust-lib/flowy-ai/build.rs new file mode 100644 index 0000000000..77c0c8125b --- /dev/null +++ b/frontend/rust-lib/flowy-ai/build.rs @@ -0,0 +1,7 @@ +fn main() { + #[cfg(feature = "dart")] + { + flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); + flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); + } +} diff --git a/frontend/rust-lib/flowy-ai/dev.env b/frontend/rust-lib/flowy-ai/dev.env new file mode 100644 index 0000000000..5cff5dd858 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/dev.env @@ -0,0 +1,5 @@ + +CHAT_BIN_PATH= +LOCAL_AI_MODEL_DIR= +LOCAL_AI_CHAT_MODEL_NAME= +LOCAL_AI_EMBEDDING_MODEL_NAME= diff --git a/frontend/rust-lib/flowy-ai/src/ai_manager.rs b/frontend/rust-lib/flowy-ai/src/ai_manager.rs new file mode 100644 index 0000000000..9055341b99 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/ai_manager.rs @@ -0,0 +1,769 @@ +use crate::chat::Chat; +use crate::entities::{ + AIModelPB, AvailableModelsPB, ChatInfoPB, ChatMessageListPB, ChatMessagePB, ChatSettingsPB, + FilePB, PredefinedFormatPB, RepeatedRelatedQuestionPB, StreamMessageParams, +}; +use crate::local_ai::controller::{LocalAIController, LocalAISetting}; +use crate::middleware::chat_service_mw::ChatServiceMiddleware; +use flowy_ai_pub::persistence::read_chat_metadata; +use std::collections::HashMap; + +use dashmap::DashMap; +use flowy_ai_pub::cloud::{ + AIModel, ChatCloudService, ChatSettings, UpdateChatParams, DEFAULT_AI_MODEL_NAME, +}; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::kv::KVStorePreferences; +use flowy_sqlite::DBConnection; + +use crate::notification::{chat_notification_builder, ChatNotification}; +use crate::util::ai_available_models_key; +use collab_integrate::persistence::collab_metadata_sql::{ + batch_insert_collab_metadata, batch_select_collab_metadata, AFCollabMetadata, +}; +use flowy_ai_pub::cloud::ai_dto::AvailableModel; +use flowy_storage_pub::storage::StorageService; +use lib_infra::async_trait::async_trait; +use lib_infra::util::timestamp; +use serde_json::json; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::{Arc, Weak}; +use tokio::sync::RwLock; +use tracing::{error, info, instrument, trace}; +use uuid::Uuid; + +#[async_trait] +pub trait AIUserService: Send + Sync + 'static { + fn user_id(&self) -> Result; + async fn is_local_model(&self) -> FlowyResult; + fn workspace_id(&self) -> Result; + fn sqlite_connection(&self, uid: i64) -> Result; + fn application_root_dir(&self) -> Result; +} + +/// AIExternalService is an interface for external services that AI plugin can interact with. +#[async_trait] +pub trait AIExternalService: Send + Sync + 'static { + async fn query_chat_rag_ids( + &self, + parent_view_id: &Uuid, + chat_id: &Uuid, + ) -> Result, FlowyError>; + + async fn sync_rag_documents( + &self, + workspace_id: &Uuid, + rag_ids: Vec, + rag_metadata_map: HashMap, + ) -> Result, FlowyError>; + + async fn notify_did_send_message(&self, chat_id: &Uuid, message: &str) -> Result<(), FlowyError>; +} + +#[derive(Debug, Default)] +struct ServerModelsCache { + models: Vec, + timestamp: Option, +} + +pub const GLOBAL_ACTIVE_MODEL_KEY: &str = "global_active_model"; + +pub struct AIManager { + pub cloud_service_wm: Arc, + pub user_service: Arc, + pub external_service: Arc, + chats: Arc>>, + pub local_ai: Arc, + pub store_preferences: Arc, + server_models: Arc>, +} + +impl AIManager { + pub fn new( + chat_cloud_service: Arc, + user_service: impl AIUserService, + store_preferences: Arc, + storage_service: Weak, + query_service: impl AIExternalService, + local_ai: Arc, + ) -> AIManager { + let user_service = Arc::new(user_service); + let cloned_local_ai = local_ai.clone(); + tokio::spawn(async move { + cloned_local_ai.observe_plugin_resource().await; + }); + + let external_service = Arc::new(query_service); + let cloud_service_wm = Arc::new(ChatServiceMiddleware::new( + user_service.clone(), + chat_cloud_service, + local_ai.clone(), + storage_service, + )); + + Self { + cloud_service_wm, + user_service, + chats: Arc::new(DashMap::new()), + local_ai, + external_service, + store_preferences, + server_models: Arc::new(Default::default()), + } + } + + #[instrument(skip_all, err)] + pub async fn initialize(&self, _workspace_id: &str) -> Result<(), FlowyError> { + let local_ai = self.local_ai.clone(); + tokio::spawn(async move { + if let Err(err) = local_ai.destroy_plugin().await { + error!("Failed to destroy plugin: {}", err); + } + + if let Err(err) = local_ai.reload().await { + error!("[AI Manager] failed to reload local AI: {:?}", err); + } + }); + Ok(()) + } + + #[instrument(skip_all, err)] + pub async fn initialize_after_open_workspace( + &self, + _workspace_id: &Uuid, + ) -> Result<(), FlowyError> { + let local_ai = self.local_ai.clone(); + tokio::spawn(async move { + if let Err(err) = local_ai.destroy_plugin().await { + error!("Failed to destroy plugin: {}", err); + } + + if let Err(err) = local_ai.reload().await { + error!("[AI Manager] failed to reload local AI: {:?}", err); + } + }); + Ok(()) + } + + pub async fn open_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> { + self.chats.entry(*chat_id).or_insert_with(|| { + Arc::new(Chat::new( + self.user_service.user_id().unwrap(), + *chat_id, + self.user_service.clone(), + self.cloud_service_wm.clone(), + )) + }); + if self.local_ai.is_running() { + trace!("[AI Plugin] notify open chat: {}", chat_id); + self.local_ai.open_chat(chat_id); + } + + let user_service = self.user_service.clone(); + let cloud_service_wm = self.cloud_service_wm.clone(); + let store_preferences = self.store_preferences.clone(); + let external_service = self.external_service.clone(); + let chat_id = *chat_id; + tokio::spawn(async move { + match refresh_chat_setting( + &user_service, + &cloud_service_wm, + &store_preferences, + &chat_id, + ) + .await + { + Ok(settings) => { + let rag_ids = settings + .rag_ids + .into_iter() + .flat_map(|r| Uuid::from_str(&r).ok()) + .collect(); + let _ = sync_chat_documents(user_service, external_service, rag_ids).await; + }, + Err(err) => { + error!("failed to refresh chat settings: {}", err); + }, + } + }); + + Ok(()) + } + + pub async fn close_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> { + trace!("close chat: {}", chat_id); + self.local_ai.close_chat(chat_id); + Ok(()) + } + + pub async fn delete_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> { + if let Some((_, chat)) = self.chats.remove(chat_id) { + chat.close(); + + if self.local_ai.is_running() { + info!("[AI Plugin] notify close chat: {}", chat_id); + self.local_ai.close_chat(chat_id); + } + } + Ok(()) + } + + pub async fn get_chat_info(&self, chat_id: &str) -> FlowyResult { + let uid = self.user_service.user_id()?; + let mut conn = self.user_service.sqlite_connection(uid)?; + let metadata = read_chat_metadata(&mut conn, chat_id)?; + let files = metadata + .files + .into_iter() + .map(|file| FilePB { + id: file.id, + name: file.name, + }) + .collect(); + + Ok(ChatInfoPB { + chat_id: chat_id.to_string(), + files, + }) + } + + pub async fn create_chat( + &self, + uid: &i64, + parent_view_id: &Uuid, + chat_id: &Uuid, + ) -> Result, FlowyError> { + let workspace_id = self.user_service.workspace_id()?; + let rag_ids = self + .external_service + .query_chat_rag_ids(parent_view_id, chat_id) + .await + .unwrap_or_default(); + info!("[Chat] create chat with rag_ids: {:?}", rag_ids); + + self + .cloud_service_wm + .create_chat(uid, &workspace_id, chat_id, rag_ids, "", json!({})) + .await?; + + let chat = Arc::new(Chat::new( + self.user_service.user_id()?, + *chat_id, + self.user_service.clone(), + self.cloud_service_wm.clone(), + )); + self.chats.insert(*chat_id, chat.clone()); + Ok(chat) + } + + pub async fn stream_chat_message( + &self, + params: StreamMessageParams, + ) -> Result { + let chat = self.get_or_create_chat_instance(¶ms.chat_id).await?; + let ai_model = self.get_active_model(¶ms.chat_id.to_string()).await; + let question = chat.stream_chat_message(¶ms, ai_model).await?; + let _ = self + .external_service + .notify_did_send_message(¶ms.chat_id, ¶ms.message) + .await; + Ok(question) + } + + pub async fn stream_regenerate_response( + &self, + chat_id: &Uuid, + answer_message_id: i64, + answer_stream_port: i64, + format: Option, + model: Option, + ) -> FlowyResult<()> { + let chat = self.get_or_create_chat_instance(chat_id).await?; + let question_message_id = chat + .get_question_id_from_answer_id(chat_id, answer_message_id) + .await?; + + let model = model.map_or_else( + || { + self + .store_preferences + .get_object::(&ai_available_models_key(&chat_id.to_string())) + }, + |model| Some(model.into()), + ); + chat + .stream_regenerate_response(question_message_id, answer_stream_port, format, model) + .await?; + Ok(()) + } + + pub async fn update_local_ai_setting(&self, setting: LocalAISetting) -> FlowyResult<()> { + let previous_model = self.local_ai.get_local_ai_setting().chat_model_name; + self.local_ai.update_local_ai_setting(setting).await?; + let current_model = self.local_ai.get_local_ai_setting().chat_model_name; + + if previous_model != current_model { + info!( + "[AI Plugin] update global active model, previous: {}, current: {}", + previous_model, current_model + ); + let source_key = ai_available_models_key(GLOBAL_ACTIVE_MODEL_KEY); + let model = AIModel::local(current_model, "".to_string()); + self.update_selected_model(source_key, model).await?; + } + + Ok(()) + } + + async fn get_workspace_select_model(&self) -> FlowyResult { + let workspace_id = self.user_service.workspace_id()?; + let model = self + .cloud_service_wm + .get_workspace_default_model(&workspace_id) + .await?; + + if model.is_empty() { + return Ok(DEFAULT_AI_MODEL_NAME.to_string()); + } + Ok(model) + } + + async fn get_server_available_models(&self) -> FlowyResult> { + let workspace_id = self.user_service.workspace_id()?; + let now = timestamp(); + + // First, try reading from the cache with expiration check + let should_fetch = { + let cached_models = self.server_models.read().await; + cached_models.models.is_empty() || cached_models.timestamp.map_or(true, |ts| now - ts >= 300) + }; + + if !should_fetch { + // Cache is still valid, return cached data + let cached_models = self.server_models.read().await; + return Ok(cached_models.models.clone()); + } + + // Cache miss or expired: fetch from the cloud. + match self + .cloud_service_wm + .get_available_models(&workspace_id) + .await + { + Ok(list) => { + let models = list.models; + if let Err(err) = self.update_models_cache(&models, now).await { + error!("Failed to update models cache: {}", err); + } + + Ok(models) + }, + Err(err) => { + error!("Failed to fetch available models: {}", err); + + // Return cached data if available, even if expired + let cached_models = self.server_models.read().await; + if !cached_models.models.is_empty() { + info!("Returning expired cached models due to fetch failure"); + return Ok(cached_models.models.clone()); + } + + // If no cached data, return empty list + Ok(Vec::new()) + }, + } + } + + async fn update_models_cache( + &self, + models: &[AvailableModel], + timestamp: i64, + ) -> FlowyResult<()> { + match self.server_models.try_write() { + Ok(mut cache) => { + cache.models = models.to_vec(); + cache.timestamp = Some(timestamp); + Ok(()) + }, + Err(_) => { + // Handle lock acquisition failure + Err(FlowyError::internal().with_context("Failed to acquire write lock for models cache")) + }, + } + } + + pub async fn update_selected_model(&self, source: String, model: AIModel) -> FlowyResult<()> { + info!( + "[Model Selection] update {} selected model: {:?}", + source, model + ); + let source_key = ai_available_models_key(&source); + self + .store_preferences + .set_object::(&source_key, &model)?; + + chat_notification_builder(&source, ChatNotification::DidUpdateSelectedModel) + .payload(AIModelPB::from(model)) + .send(); + Ok(()) + } + + #[instrument(skip_all, level = "debug")] + pub async fn toggle_local_ai(&self) -> FlowyResult<()> { + let enabled = self.local_ai.toggle_local_ai().await?; + let source_key = ai_available_models_key(GLOBAL_ACTIVE_MODEL_KEY); + if enabled { + if let Some(name) = self.local_ai.get_plugin_chat_model() { + info!("Set global active model to local ai: {}", name); + let model = AIModel::local(name, "".to_string()); + self.update_selected_model(source_key, model).await?; + } + } else { + info!("Set global active model to default"); + let global_active_model = self.get_workspace_select_model().await?; + let models = self.get_server_available_models().await?; + if let Some(model) = models.into_iter().find(|m| m.name == global_active_model) { + self + .update_selected_model(source_key, AIModel::from(model)) + .await?; + } + } + + Ok(()) + } + + pub async fn get_active_model(&self, source: &str) -> Option { + let mut model = self + .store_preferences + .get_object::(&ai_available_models_key(source)); + + if model.is_none() { + if let Some(local_model) = self.local_ai.get_plugin_chat_model() { + model = Some(AIModel::local(local_model, "".to_string())); + } + } + + model + } + + pub async fn get_available_models(&self, source: String) -> FlowyResult { + let is_local_mode = self.user_service.is_local_model().await?; + if is_local_mode { + let mut selected_model = AIModel::default(); + let mut models = vec![]; + if let Some(local_model) = self.local_ai.get_plugin_chat_model() { + let model = AIModel::local(local_model, "".to_string()); + selected_model = model.clone(); + models.push(model); + } + + Ok(AvailableModelsPB { + models: models.into_iter().map(|m| m.into()).collect(), + selected_model: AIModelPB::from(selected_model), + }) + } else { + // Build the models list from server models and mark them as non-local. + let mut models: Vec = self + .get_server_available_models() + .await? + .into_iter() + .map(AIModel::from) + .collect(); + + trace!("[Model Selection]: Available models: {:?}", models); + let mut current_active_local_ai_model = None; + + // If user enable local ai, then add local ai model to the list. + if let Some(local_model) = self.local_ai.get_plugin_chat_model() { + let model = AIModel::local(local_model, "".to_string()); + current_active_local_ai_model = Some(model.clone()); + trace!("[Model Selection] current local ai model: {}", model.name); + models.push(model); + } + + if models.is_empty() { + return Ok(AvailableModelsPB { + models: models.into_iter().map(|m| m.into()).collect(), + selected_model: AIModelPB::default(), + }); + } + + // Global active model is the model selected by the user in the workspace settings. + let mut server_active_model = self + .get_workspace_select_model() + .await + .map(|m| AIModel::server(m, "".to_string())) + .unwrap_or_else(|_| AIModel::default()); + + trace!( + "[Model Selection] server active model: {:?}", + server_active_model + ); + + let mut user_selected_model = server_active_model.clone(); + // when current select model is deprecated, reset the model to default + if !models.iter().any(|m| m.name == server_active_model.name) { + server_active_model = AIModel::default(); + } + + let source_key = ai_available_models_key(&source); + // We use source to identify user selected model. source can be document id or chat id. + match self.store_preferences.get_object::(&source_key) { + None => { + // when there is selected model and current local ai is active, then use local ai + if let Some(local_ai_model) = models.iter().find(|m| m.is_local) { + user_selected_model = local_ai_model.clone(); + } + }, + Some(mut model) => { + trace!("[Model Selection] user previous select model: {:?}", model); + // If source is provided, try to get the user-selected model from the store. User selected + // model will be used as the active model if it exists. + if model.is_local { + if let Some(local_ai_model) = ¤t_active_local_ai_model { + if local_ai_model.name != model.name { + model = local_ai_model.clone(); + } + } + } + + user_selected_model = model; + }, + } + + // If user selected model is not available in the list, use the global active model. + let active_model = models + .iter() + .find(|m| m.name == user_selected_model.name) + .cloned() + .or(Some(server_active_model.clone())); + + // Update the stored preference if a different model is used. + if let Some(ref active_model) = active_model { + if active_model.name != user_selected_model.name { + self + .store_preferences + .set_object::(&source_key, &active_model.clone())?; + } + } + + trace!("[Model Selection] final active model: {:?}", active_model); + let selected_model = AIModelPB::from(active_model.unwrap_or_default()); + Ok(AvailableModelsPB { + models: models.into_iter().map(|m| m.into()).collect(), + selected_model, + }) + } + } + + pub async fn get_or_create_chat_instance(&self, chat_id: &Uuid) -> Result, FlowyError> { + let chat = self.chats.get(chat_id).as_deref().cloned(); + match chat { + None => { + let chat = Arc::new(Chat::new( + self.user_service.user_id()?, + *chat_id, + self.user_service.clone(), + self.cloud_service_wm.clone(), + )); + self.chats.insert(*chat_id, chat.clone()); + Ok(chat) + }, + Some(chat) => Ok(chat), + } + } + + /// Load chat messages for a given `chat_id`. + /// + /// 1. When opening a chat: + /// - Loads local chat messages. + /// - `after_message_id` and `before_message_id` are `None`. + /// - Spawns a task to load messages from the remote server, notifying the user when the remote messages are loaded. + /// + /// 2. Loading more messages in an existing chat with `after_message_id`: + /// - `after_message_id` is the last message ID in the current chat messages. + /// + /// 3. Loading more messages in an existing chat with `before_message_id`: + /// - `before_message_id` is the first message ID in the current chat messages. + /// + /// 4. `after_message_id` and `before_message_id` cannot be specified at the same time. + + pub async fn load_prev_chat_messages( + &self, + chat_id: &Uuid, + limit: u64, + before_message_id: Option, + ) -> Result { + let chat = self.get_or_create_chat_instance(chat_id).await?; + let list = chat + .load_prev_chat_messages(limit, before_message_id) + .await?; + Ok(list) + } + + pub async fn load_latest_chat_messages( + &self, + chat_id: &Uuid, + limit: u64, + after_message_id: Option, + ) -> Result { + let chat = self.get_or_create_chat_instance(chat_id).await?; + let list = chat + .load_latest_chat_messages(limit, after_message_id) + .await?; + Ok(list) + } + + pub async fn get_related_questions( + &self, + chat_id: &Uuid, + message_id: i64, + ) -> Result { + let chat = self.get_or_create_chat_instance(chat_id).await?; + let ai_model = self.get_active_model(&chat_id.to_string()).await; + let resp = chat.get_related_question(message_id, ai_model).await?; + Ok(resp) + } + + pub async fn generate_answer( + &self, + chat_id: &Uuid, + question_message_id: i64, + ) -> Result { + let chat = self.get_or_create_chat_instance(chat_id).await?; + let resp = chat.generate_answer(question_message_id).await?; + Ok(resp) + } + + pub async fn stop_stream(&self, chat_id: &Uuid) -> Result<(), FlowyError> { + let chat = self.get_or_create_chat_instance(chat_id).await?; + chat.stop_stream_message().await; + Ok(()) + } + + pub async fn chat_with_file(&self, chat_id: &Uuid, file_path: PathBuf) -> FlowyResult<()> { + let chat = self.get_or_create_chat_instance(chat_id).await?; + chat.index_file(file_path).await?; + Ok(()) + } + + pub async fn get_rag_ids(&self, chat_id: &Uuid) -> FlowyResult> { + if let Some(settings) = self + .store_preferences + .get_object::(&setting_store_key(chat_id)) + { + return Ok(settings.rag_ids); + } + + let settings = refresh_chat_setting( + &self.user_service, + &self.cloud_service_wm, + &self.store_preferences, + chat_id, + ) + .await?; + Ok(settings.rag_ids) + } + + pub async fn update_rag_ids(&self, chat_id: &Uuid, rag_ids: Vec) -> FlowyResult<()> { + info!("[Chat] update chat:{} rag ids: {:?}", chat_id, rag_ids); + let workspace_id = self.user_service.workspace_id()?; + let update_setting = UpdateChatParams { + name: None, + metadata: None, + rag_ids: Some(rag_ids.clone()), + }; + self + .cloud_service_wm + .update_chat_settings(&workspace_id, chat_id, update_setting) + .await?; + + let chat_setting_store_key = setting_store_key(chat_id); + if let Some(settings) = self + .store_preferences + .get_object::(&chat_setting_store_key) + { + if let Err(err) = self.store_preferences.set_object( + &chat_setting_store_key, + &ChatSettings { + rag_ids: rag_ids.clone(), + ..settings + }, + ) { + error!("failed to set chat settings: {}", err); + } + } + + let user_service = self.user_service.clone(); + let external_service = self.external_service.clone(); + let rag_ids = rag_ids + .into_iter() + .flat_map(|r| Uuid::from_str(&r).ok()) + .collect(); + sync_chat_documents(user_service, external_service, rag_ids).await?; + Ok(()) + } +} + +async fn sync_chat_documents( + user_service: Arc, + external_service: Arc, + rag_ids: Vec, +) -> FlowyResult<()> { + if rag_ids.is_empty() { + return Ok(()); + } + + let uid = user_service.user_id()?; + let conn = user_service.sqlite_connection(uid)?; + let metadata_map = batch_select_collab_metadata(conn, &rag_ids)?; + + let user_service = user_service.clone(); + tokio::spawn(async move { + if let Ok(workspace_id) = user_service.workspace_id() { + if let Ok(metadatas) = external_service + .sync_rag_documents(&workspace_id, rag_ids, metadata_map) + .await + { + if let Ok(uid) = user_service.user_id() { + if let Ok(conn) = user_service.sqlite_connection(uid) { + info!("sync rag documents success: {}", metadatas.len()); + batch_insert_collab_metadata(conn, &metadatas).unwrap(); + } + } + } + } + }); + + Ok(()) +} + +async fn refresh_chat_setting( + user_service: &Arc, + cloud_service: &Arc, + store_preferences: &Arc, + chat_id: &Uuid, +) -> FlowyResult { + info!("[Chat] refresh chat:{} setting", chat_id); + let workspace_id = user_service.workspace_id()?; + let settings = cloud_service + .get_chat_settings(&workspace_id, chat_id) + .await?; + + if let Err(err) = store_preferences.set_object(&setting_store_key(chat_id), &settings) { + error!("failed to set chat settings: {}", err); + } + + chat_notification_builder(chat_id.to_string(), ChatNotification::DidUpdateChatSettings) + .payload(ChatSettingsPB { + rag_ids: settings.rag_ids.clone(), + }) + .send(); + + Ok(settings) +} + +fn setting_store_key(chat_id: &Uuid) -> String { + format!("chat_settings_{}", chat_id) +} diff --git a/frontend/rust-lib/flowy-ai/src/chat.rs b/frontend/rust-lib/flowy-ai/src/chat.rs new file mode 100644 index 0000000000..3180227ed0 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/chat.rs @@ -0,0 +1,668 @@ +use crate::ai_manager::AIUserService; +use crate::entities::{ + ChatMessageErrorPB, ChatMessageListPB, ChatMessagePB, PredefinedFormatPB, + RepeatedRelatedQuestionPB, StreamMessageParams, +}; +use crate::middleware::chat_service_mw::ChatServiceMiddleware; +use crate::notification::{chat_notification_builder, ChatNotification}; +use crate::stream_message::StreamMessage; +use allo_isolate::Isolate; +use flowy_ai_pub::cloud::{ + AIModel, ChatCloudService, ChatMessage, MessageCursor, QuestionStreamValue, ResponseFormat, +}; +use flowy_ai_pub::persistence::{ + select_answer_where_match_reply_message_id, select_chat_messages, upsert_chat_messages, + ChatMessageTable, +}; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_sqlite::DBConnection; +use futures::{SinkExt, StreamExt}; +use lib_infra::isolate_stream::IsolateSink; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicI64}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use tracing::{error, instrument, trace}; +use uuid::Uuid; + +enum PrevMessageState { + HasMore, + NoMore, + Loading, +} + +pub struct Chat { + chat_id: Uuid, + uid: i64, + user_service: Arc, + chat_service: Arc, + prev_message_state: Arc>, + latest_message_id: Arc, + stop_stream: Arc, + stream_buffer: Arc>, +} + +impl Chat { + pub fn new( + uid: i64, + chat_id: Uuid, + user_service: Arc, + chat_service: Arc, + ) -> Chat { + Chat { + uid, + chat_id, + chat_service, + user_service, + prev_message_state: Arc::new(RwLock::new(PrevMessageState::HasMore)), + latest_message_id: Default::default(), + stop_stream: Arc::new(AtomicBool::new(false)), + stream_buffer: Arc::new(Mutex::new(StringBuffer::default())), + } + } + + pub fn close(&self) {} + + pub async fn stop_stream_message(&self) { + self + .stop_stream + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + #[instrument(level = "info", skip_all, err)] + pub async fn stream_chat_message( + &self, + params: &StreamMessageParams, + preferred_ai_model: Option, + ) -> Result { + trace!( + "[Chat] stream chat message: chat_id={}, message={}, message_type={:?}, format={:?}", + self.chat_id, + params.message, + params.message_type, + params.format, + ); + + // clear + self + .stop_stream + .store(false, std::sync::atomic::Ordering::SeqCst); + self.stream_buffer.lock().await.clear(); + + let mut question_sink = IsolateSink::new(Isolate::new(params.question_stream_port)); + let answer_stream_buffer = self.stream_buffer.clone(); + let uid = self.user_service.user_id()?; + let workspace_id = self.user_service.workspace_id()?; + + let _ = question_sink + .send(StreamMessage::Text(params.message.to_string()).to_string()) + .await; + let question = self + .chat_service + .create_question( + &workspace_id, + &self.chat_id, + ¶ms.message, + params.message_type.clone(), + ) + .await + .map_err(|err| { + error!("Failed to send question: {}", err); + FlowyError::server_error() + })?; + + let _ = question_sink + .send(StreamMessage::MessageId(question.message_id).to_string()) + .await; + + // Save message to disk + notify_message(&self.chat_id, question.clone())?; + let format = params.format.clone().map(Into::into).unwrap_or_default(); + self.stream_response( + params.answer_stream_port, + answer_stream_buffer, + uid, + workspace_id, + question.message_id, + format, + preferred_ai_model, + ); + + let question_pb = ChatMessagePB::from(question); + Ok(question_pb) + } + + #[instrument(level = "info", skip_all, err)] + pub async fn stream_regenerate_response( + &self, + question_id: i64, + answer_stream_port: i64, + format: Option, + ai_model: Option, + ) -> FlowyResult<()> { + trace!( + "[Chat] regenerate and stream chat message: chat_id={}", + self.chat_id, + ); + + // clear + self + .stop_stream + .store(false, std::sync::atomic::Ordering::SeqCst); + self.stream_buffer.lock().await.clear(); + + let format = format.map(Into::into).unwrap_or_default(); + + let answer_stream_buffer = self.stream_buffer.clone(); + let uid = self.user_service.user_id()?; + let workspace_id = self.user_service.workspace_id()?; + + self.stream_response( + answer_stream_port, + answer_stream_buffer, + uid, + workspace_id, + question_id, + format, + ai_model, + ); + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn stream_response( + &self, + answer_stream_port: i64, + answer_stream_buffer: Arc>, + _uid: i64, + workspace_id: Uuid, + question_id: i64, + format: ResponseFormat, + ai_model: Option, + ) { + let stop_stream = self.stop_stream.clone(); + let chat_id = self.chat_id; + let cloud_service = self.chat_service.clone(); + tokio::spawn(async move { + let mut answer_sink = IsolateSink::new(Isolate::new(answer_stream_port)); + match cloud_service + .stream_answer(&workspace_id, &chat_id, question_id, format, ai_model) + .await + { + Ok(mut stream) => { + while let Some(message) = stream.next().await { + match message { + Ok(message) => { + if stop_stream.load(std::sync::atomic::Ordering::Relaxed) { + trace!("[Chat] client stop streaming message"); + break; + } + match message { + QuestionStreamValue::Answer { value } => { + answer_stream_buffer.lock().await.push_str(&value); + if let Err(err) = answer_sink + .send(StreamMessage::OnData(value).to_string()) + .await + { + error!("Failed to stream answer via IsolateSink: {}", err); + } + }, + QuestionStreamValue::Metadata { value } => { + if let Ok(s) = serde_json::to_string(&value) { + // trace!("[Chat] stream metadata: {}", s); + answer_stream_buffer.lock().await.set_metadata(value); + let _ = answer_sink + .send(StreamMessage::Metadata(s).to_string()) + .await; + } + }, + QuestionStreamValue::KeepAlive => { + // trace!("[Chat] stream keep alive"); + }, + } + }, + Err(err) => { + if err.code == ErrorCode::RequestTimeout || err.code == ErrorCode::Internal { + error!("[Chat] unexpected stream error: {}", err); + let _ = answer_sink.send(StreamMessage::Done.to_string()).await; + } else { + error!("[Chat] failed to stream answer: {}", err); + let _ = answer_sink + .send(StreamMessage::OnError(err.msg.clone()).to_string()) + .await; + let pb = ChatMessageErrorPB { + chat_id: chat_id.to_string(), + error_message: err.to_string(), + }; + chat_notification_builder(chat_id, ChatNotification::StreamChatMessageError) + .payload(pb) + .send(); + return Err(err); + } + }, + } + } + }, + Err(err) => { + error!("[Chat] failed to start streaming: {}", err); + if err.is_ai_response_limit_exceeded() { + let _ = answer_sink.send("AI_RESPONSE_LIMIT".to_string()).await; + } else if err.is_ai_image_response_limit_exceeded() { + let _ = answer_sink + .send("AI_IMAGE_RESPONSE_LIMIT".to_string()) + .await; + } else if err.is_ai_max_required() { + let _ = answer_sink + .send(format!("AI_MAX_REQUIRED:{}", err.msg)) + .await; + } else if err.is_local_ai_not_ready() { + let _ = answer_sink + .send(format!("LOCAL_AI_NOT_READY:{}", err.msg)) + .await; + } else if err.is_local_ai_disabled() { + let _ = answer_sink + .send(format!("LOCAL_AI_DISABLED:{}", err.msg)) + .await; + } else { + let _ = answer_sink + .send(StreamMessage::OnError(err.msg.clone()).to_string()) + .await; + } + + let pb = ChatMessageErrorPB { + chat_id: chat_id.to_string(), + error_message: err.to_string(), + }; + chat_notification_builder(chat_id, ChatNotification::StreamChatMessageError) + .payload(pb) + .send(); + return Err(err); + }, + } + + chat_notification_builder(chat_id, ChatNotification::FinishStreaming).send(); + trace!("[Chat] finish streaming"); + + if answer_stream_buffer.lock().await.is_empty() { + return Ok(()); + } + let content = answer_stream_buffer.lock().await.take_content(); + let metadata = answer_stream_buffer.lock().await.take_metadata(); + let answer = cloud_service + .create_answer( + &workspace_id, + &chat_id, + content.trim(), + question_id, + metadata, + ) + .await?; + notify_message(&chat_id, answer)?; + Ok::<(), FlowyError>(()) + }); + } + + /// Load chat messages for a given `chat_id`. + /// + /// 1. When opening a chat: + /// - Loads local chat messages. + /// - `after_message_id` and `before_message_id` are `None`. + /// - Spawns a task to load messages from the remote server, notifying the user when the remote messages are loaded. + /// + /// 2. Loading more messages in an existing chat with `after_message_id`: + /// - `after_message_id` is the last message ID in the current chat messages. + /// + /// 3. Loading more messages in an existing chat with `before_message_id`: + /// - `before_message_id` is the first message ID in the current chat messages. + pub async fn load_prev_chat_messages( + &self, + limit: u64, + before_message_id: Option, + ) -> Result { + trace!( + "[Chat] Loading messages from disk: chat_id={}, limit={}, before_message_id={:?}", + self.chat_id, + limit, + before_message_id + ); + + let offset = before_message_id.map_or(MessageCursor::NextBack, MessageCursor::BeforeMessageId); + let messages = self.load_local_chat_messages(limit, offset).await?; + + // If the number of messages equals the limit, then no need to load more messages from remote + if messages.len() == limit as usize { + let pb = ChatMessageListPB { + messages, + has_more: true, + total: 0, + }; + chat_notification_builder(self.chat_id, ChatNotification::DidLoadPrevChatMessage) + .payload(pb.clone()) + .send(); + return Ok(pb); + } + + if matches!( + *self.prev_message_state.read().await, + PrevMessageState::HasMore + ) { + *self.prev_message_state.write().await = PrevMessageState::Loading; + if let Err(err) = self + .load_remote_chat_messages(limit, before_message_id, None) + .await + { + error!("Failed to load previous chat messages: {}", err); + } + } + + Ok(ChatMessageListPB { + messages, + has_more: true, + total: 0, + }) + } + + pub async fn load_latest_chat_messages( + &self, + limit: u64, + after_message_id: Option, + ) -> Result { + trace!( + "[Chat] Loading new messages: chat_id={}, limit={}, after_message_id={:?}", + self.chat_id, + limit, + after_message_id, + ); + let offset = after_message_id.map_or(MessageCursor::NextBack, MessageCursor::AfterMessageId); + let messages = self.load_local_chat_messages(limit, offset).await?; + + trace!( + "[Chat] Loaded local chat messages: chat_id={}, messages={}", + self.chat_id, + messages.len() + ); + + // If the number of messages equals the limit, then no need to load more messages from remote + let has_more = !messages.is_empty(); + let _ = self + .load_remote_chat_messages(limit, None, after_message_id) + .await; + Ok(ChatMessageListPB { + messages, + has_more, + total: 0, + }) + } + + async fn load_remote_chat_messages( + &self, + limit: u64, + before_message_id: Option, + after_message_id: Option, + ) -> FlowyResult<()> { + trace!( + "[Chat] start loading messages from remote: chat_id={}, limit={}, before_message_id={:?}, after_message_id={:?}", + self.chat_id, + limit, + before_message_id, + after_message_id + ); + let workspace_id = self.user_service.workspace_id()?; + let chat_id = self.chat_id; + let cloud_service = self.chat_service.clone(); + let user_service = self.user_service.clone(); + let uid = self.uid; + let prev_message_state = self.prev_message_state.clone(); + let latest_message_id = self.latest_message_id.clone(); + tokio::spawn(async move { + let cursor = match (before_message_id, after_message_id) { + (Some(bid), _) => MessageCursor::BeforeMessageId(bid), + (_, Some(aid)) => MessageCursor::AfterMessageId(aid), + _ => MessageCursor::NextBack, + }; + match cloud_service + .get_chat_messages(&workspace_id, &chat_id, cursor.clone(), limit) + .await + { + Ok(resp) => { + // Save chat messages to local disk + if let Err(err) = save_chat_message_disk( + user_service.sqlite_connection(uid)?, + &chat_id, + resp.messages.clone(), + true, + ) { + error!("Failed to save chat:{} messages: {}", chat_id, err); + } + + // Update latest message ID + if !resp.messages.is_empty() { + latest_message_id.store( + resp.messages[0].message_id, + std::sync::atomic::Ordering::Relaxed, + ); + } + + let pb = ChatMessageListPB::from(resp); + trace!( + "[Chat] Loaded messages from remote: chat_id={}, messages={}, hasMore: {}, cursor:{:?}", + chat_id, + pb.messages.len(), + pb.has_more, + cursor, + ); + if matches!(cursor, MessageCursor::BeforeMessageId(_)) { + if pb.has_more { + *prev_message_state.write().await = PrevMessageState::HasMore; + } else { + *prev_message_state.write().await = PrevMessageState::NoMore; + } + chat_notification_builder(chat_id, ChatNotification::DidLoadPrevChatMessage) + .payload(pb) + .send(); + } else { + chat_notification_builder(chat_id, ChatNotification::DidLoadLatestChatMessage) + .payload(pb) + .send(); + } + }, + Err(err) => error!("Failed to load chat messages: {}", err), + } + Ok::<(), FlowyError>(()) + }); + Ok(()) + } + + pub async fn get_question_id_from_answer_id( + &self, + chat_id: &Uuid, + answer_message_id: i64, + ) -> Result { + let conn = self.user_service.sqlite_connection(self.uid)?; + + let local_result = + select_answer_where_match_reply_message_id(conn, &chat_id.to_string(), answer_message_id)? + .map(|message| message.message_id); + + if let Some(message_id) = local_result { + return Ok(message_id); + } + + let workspace_id = self.user_service.workspace_id()?; + let chat_id = self.chat_id; + let cloud_service = self.chat_service.clone(); + + let question = cloud_service + .get_question_from_answer_id(&workspace_id, &chat_id, answer_message_id) + .await?; + + Ok(question.message_id) + } + + pub async fn get_related_question( + &self, + message_id: i64, + ai_model: Option, + ) -> Result { + let workspace_id = self.user_service.workspace_id()?; + let resp = self + .chat_service + .get_related_message(&workspace_id, &self.chat_id, message_id, ai_model) + .await?; + + trace!( + "[Chat] related messages: chat_id={}, message_id={}, messages:{:?}", + self.chat_id, + message_id, + resp.items + ); + Ok(RepeatedRelatedQuestionPB::from(resp)) + } + + #[instrument(level = "debug", skip_all, err)] + pub async fn generate_answer(&self, question_message_id: i64) -> FlowyResult { + trace!( + "[Chat] generate answer: chat_id={}, question_message_id={}", + self.chat_id, + question_message_id + ); + let workspace_id = self.user_service.workspace_id()?; + let answer = self + .chat_service + .get_answer(&workspace_id, &self.chat_id, question_message_id) + .await?; + + notify_message(&self.chat_id, answer.clone())?; + let pb = ChatMessagePB::from(answer); + Ok(pb) + } + + async fn load_local_chat_messages( + &self, + limit: u64, + offset: MessageCursor, + ) -> Result, FlowyError> { + let conn = self.user_service.sqlite_connection(self.uid)?; + let rows = select_chat_messages(conn, &self.chat_id.to_string(), limit, offset)?.messages; + let messages = rows + .into_iter() + .map(|record| ChatMessagePB { + message_id: record.message_id, + content: record.content, + created_at: record.created_at, + author_type: record.author_type, + author_id: record.author_id, + reply_message_id: record.reply_message_id, + metadata: record.metadata, + }) + .collect::>(); + + Ok(messages) + } + + #[instrument(level = "debug", skip_all, err)] + pub async fn index_file(&self, file_path: PathBuf) -> FlowyResult<()> { + if !file_path.exists() { + return Err( + FlowyError::record_not_found().with_context(format!("{:?} not exist", file_path)), + ); + } + + if !file_path.is_file() { + return Err( + FlowyError::invalid_data().with_context(format!("{:?} is not a file ", file_path)), + ); + } + + trace!( + "[Chat] index file: chat_id={}, file_path={:?}", + self.chat_id, + file_path + ); + self + .chat_service + .embed_file( + &self.user_service.workspace_id()?, + &file_path, + &self.chat_id, + None, + ) + .await?; + + trace!( + "[Chat] created index file record: chat_id={}, file_path={:?}", + self.chat_id, + file_path + ); + + Ok(()) + } +} + +fn save_chat_message_disk( + conn: DBConnection, + chat_id: &Uuid, + messages: Vec, + is_sync: bool, +) -> FlowyResult<()> { + let records = messages + .into_iter() + .map(|message| ChatMessageTable { + message_id: message.message_id, + chat_id: chat_id.to_string(), + content: message.content, + created_at: message.created_at.timestamp(), + author_type: message.author.author_type as i64, + author_id: message.author.author_id.to_string(), + reply_message_id: message.reply_message_id, + metadata: Some(serde_json::to_string(&message.metadata).unwrap_or_default()), + is_sync, + }) + .collect::>(); + upsert_chat_messages(conn, &records)?; + Ok(()) +} + +#[derive(Debug, Default)] +struct StringBuffer { + content: String, + metadata: Option, +} + +impl StringBuffer { + fn clear(&mut self) { + self.content.clear(); + self.metadata = None; + } + + fn push_str(&mut self, value: &str) { + self.content.push_str(value); + } + + fn set_metadata(&mut self, value: serde_json::Value) { + self.metadata = Some(value); + } + + fn is_empty(&self) -> bool { + self.content.is_empty() + } + + fn take_metadata(&mut self) -> Option { + self.metadata.take() + } + + fn take_content(&mut self) -> String { + std::mem::take(&mut self.content) + } +} + +pub(crate) fn notify_message(chat_id: &Uuid, message: ChatMessage) -> Result<(), FlowyError> { + trace!("[Chat] save answer: answer={:?}", message); + let pb = ChatMessagePB::from(message); + chat_notification_builder(chat_id, ChatNotification::DidReceiveChatMessage) + .payload(pb) + .send(); + + Ok(()) +} diff --git a/frontend/rust-lib/flowy-ai/src/completion.rs b/frontend/rust-lib/flowy-ai/src/completion.rs new file mode 100644 index 0000000000..31acde4ae7 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/completion.rs @@ -0,0 +1,206 @@ +use crate::ai_manager::AIUserService; +use crate::entities::{CompleteTextPB, CompleteTextTaskPB, CompletionTypePB}; +use allo_isolate::Isolate; +use std::str::FromStr; + +use dashmap::DashMap; +use flowy_ai_pub::cloud::{ + AIModel, ChatCloudService, CompleteTextParams, CompletionMetadata, CompletionStreamValue, + CompletionType, CustomPrompt, +}; +use flowy_error::{FlowyError, FlowyResult}; + +use futures::{SinkExt, StreamExt}; +use lib_infra::isolate_stream::IsolateSink; + +use crate::stream_message::StreamMessage; +use std::sync::{Arc, Weak}; +use tokio::select; +use tracing::{error, info}; +use uuid::Uuid; + +pub struct AICompletion { + tasks: Arc>>, + cloud_service: Weak, + user_service: Weak, +} + +impl AICompletion { + pub fn new( + cloud_service: Weak, + user_service: Weak, + ) -> Self { + Self { + tasks: Arc::new(DashMap::new()), + cloud_service, + user_service, + } + } + + pub async fn create_complete_task( + &self, + complete: CompleteTextPB, + preferred_model: Option, + ) -> FlowyResult { + if matches!(complete.completion_type, CompletionTypePB::CustomPrompt) + && complete.custom_prompt.is_none() + { + return Err( + FlowyError::invalid_data() + .with_context("custom_prompt is required when completion_type is CustomPrompt"), + ); + } + + let workspace_id = self + .user_service + .upgrade() + .ok_or_else(FlowyError::internal)? + .workspace_id()?; + let (tx, rx) = tokio::sync::mpsc::channel(1); + let task = CompletionTask::new( + workspace_id, + complete, + preferred_model, + self.cloud_service.clone(), + rx, + ); + let task_id = task.task_id.clone(); + self.tasks.insert(task_id.clone(), tx); + + task.start().await; + Ok(CompleteTextTaskPB { task_id }) + } + + pub async fn cancel_complete_task(&self, task_id: &str) { + if let Some(entry) = self.tasks.remove(task_id) { + let _ = entry.1.send(()).await; + } + } +} + +pub struct CompletionTask { + workspace_id: Uuid, + task_id: String, + stop_rx: tokio::sync::mpsc::Receiver<()>, + context: CompleteTextPB, + cloud_service: Weak, + preferred_model: Option, +} + +impl CompletionTask { + pub fn new( + workspace_id: Uuid, + context: CompleteTextPB, + preferred_model: Option, + cloud_service: Weak, + stop_rx: tokio::sync::mpsc::Receiver<()>, + ) -> Self { + Self { + workspace_id, + task_id: uuid::Uuid::new_v4().to_string(), + context, + cloud_service, + stop_rx, + preferred_model, + } + } + + pub async fn start(mut self) { + tokio::spawn(async move { + let mut sink = IsolateSink::new(Isolate::new(self.context.stream_port)); + + if let Some(cloud_service) = self.cloud_service.upgrade() { + let complete_type = match self.context.completion_type { + CompletionTypePB::ImproveWriting => CompletionType::ImproveWriting, + CompletionTypePB::SpellingAndGrammar => CompletionType::SpellingAndGrammar, + CompletionTypePB::MakeShorter => CompletionType::MakeShorter, + CompletionTypePB::MakeLonger => CompletionType::MakeLonger, + CompletionTypePB::ContinueWriting => CompletionType::ContinueWriting, + CompletionTypePB::ExplainSelected => CompletionType::Explain, + CompletionTypePB::UserQuestion => CompletionType::UserQuestion, + CompletionTypePB::CustomPrompt => CompletionType::CustomPrompt, + }; + + let _ = sink.send("start:".to_string()).await; + let completion_history = Some(self.context.history.iter().map(Into::into).collect()); + let format = self.context.format.map(Into::into).unwrap_or_default(); + if let Ok(object_id) = Uuid::from_str(&self.context.object_id) { + let params = CompleteTextParams { + text: self.context.text, + completion_type: Some(complete_type), + metadata: Some(CompletionMetadata { + object_id, + workspace_id: Some(self.workspace_id), + rag_ids: Some(self.context.rag_ids), + completion_history, + custom_prompt: self + .context + .custom_prompt + .map(|v| CustomPrompt { system: v }), + }), + format, + }; + + info!("start completion: {:?}", params); + match cloud_service + .stream_complete(&self.workspace_id, params, self.preferred_model) + .await + { + Ok(mut stream) => loop { + select! { + _ = self.stop_rx.recv() => { + return; + }, + result = stream.next() => { + match result { + Some(Ok(data)) => { + match data { + CompletionStreamValue::Answer{ value } => { + let _ = sink.send(format!("data:{}", value)).await; + } + CompletionStreamValue::Comment{ value } => { + let _ = sink.send(format!("comment:{}", value)).await; + } + } + }, + Some(Err(error)) => { + handle_error(&mut sink, error).await; + return; + }, + None => { + let _ = sink.send(format!("finish:{}", self.task_id)).await; + return; + }, + } + } + } + }, + Err(error) => { + handle_error(&mut sink, error).await; + }, + } + } else { + error!("Invalid uuid: {}", self.context.object_id); + } + } + }); + } +} + +async fn handle_error(sink: &mut IsolateSink, err: FlowyError) { + if err.is_ai_response_limit_exceeded() { + let _ = sink.send("AI_RESPONSE_LIMIT".to_string()).await; + } else if err.is_ai_image_response_limit_exceeded() { + let _ = sink.send("AI_IMAGE_RESPONSE_LIMIT".to_string()).await; + } else if err.is_ai_max_required() { + let _ = sink.send(format!("AI_MAX_REQUIRED:{}", err.msg)).await; + } else if err.is_local_ai_not_ready() { + let _ = sink.send(format!("LOCAL_AI_NOT_READY:{}", err.msg)).await; + } else if err.is_local_ai_disabled() { + let _ = sink.send(format!("LOCAL_AI_DISABLED:{}", err.msg)).await; + } else { + let _ = sink + .send(StreamMessage::OnError(err.msg.clone()).to_string()) + .await; + } +} diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs new file mode 100644 index 0000000000..5a4aecbbd7 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -0,0 +1,750 @@ +use crate::local_ai::controller::LocalAISetting; +use crate::local_ai::resource::PendingResource; +use af_plugin::core::plugin::RunningState; +use flowy_ai_pub::cloud::{ + AIModel, ChatMessage, ChatMessageType, CompletionMessage, LLMModel, OutputContent, OutputLayout, + RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, +}; +use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; +use lib_infra::validator_fn::required_not_empty_str; +use std::collections::HashMap; +use uuid::Uuid; +use validator::Validate; + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct ChatId { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub value: String, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct ChatInfoPB { + #[pb(index = 1)] + pub chat_id: String, + + #[pb(index = 2)] + pub files: Vec, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct FilePB { + #[pb(index = 1)] + pub id: String, + #[pb(index = 2)] + pub name: String, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct SendChatPayloadPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub chat_id: String, + + #[pb(index = 2)] + #[validate(custom(function = "required_not_empty_str"))] + pub message: String, + + #[pb(index = 3)] + pub message_type: ChatMessageTypePB, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct StreamChatPayloadPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub chat_id: String, + + #[pb(index = 2)] + #[validate(custom(function = "required_not_empty_str"))] + pub message: String, + + #[pb(index = 3)] + pub message_type: ChatMessageTypePB, + + #[pb(index = 4)] + pub answer_stream_port: i64, + + #[pb(index = 5)] + pub question_stream_port: i64, + + #[pb(index = 6, one_of)] + pub format: Option, +} + +#[derive(Default, Debug)] +pub struct StreamMessageParams { + pub chat_id: Uuid, + pub message: String, + pub message_type: ChatMessageType, + pub answer_stream_port: i64, + pub question_stream_port: i64, + pub format: Option, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct RegenerateResponsePB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub chat_id: String, + + #[pb(index = 2)] + pub answer_message_id: i64, + + #[pb(index = 3)] + pub answer_stream_port: i64, + + #[pb(index = 4, one_of)] + pub format: Option, + + #[pb(index = 5, one_of)] + pub model: Option, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct ChatMessageMetaPB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub name: String, + + #[pb(index = 3)] + pub data: String, + + #[pb(index = 4)] + pub loader_type: ContextLoaderTypePB, + + #[pb(index = 5)] + pub source: String, +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum, PartialEq, Eq, Copy)] +pub enum ContextLoaderTypePB { + #[default] + UnknownLoaderType = 0, + Txt = 1, + Markdown = 2, + PDF = 3, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct StopStreamPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub chat_id: String, +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum, PartialEq, Eq, Copy)] +pub enum ChatMessageTypePB { + #[default] + System = 0, + User = 1, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct LoadPrevChatMessagePB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub chat_id: String, + + #[pb(index = 2)] + pub limit: i64, + + #[pb(index = 4, one_of)] + pub before_message_id: Option, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct LoadNextChatMessagePB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub chat_id: String, + + #[pb(index = 2)] + pub limit: i64, + + #[pb(index = 4, one_of)] + pub after_message_id: Option, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct ChatMessageListPB { + #[pb(index = 1)] + pub has_more: bool, + + #[pb(index = 2)] + pub messages: Vec, + + /// If the total number of messages is 0, then the total number of messages is unknown. + #[pb(index = 3)] + pub total: i64, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct ServerAvailableModelsPB { + #[pb(index = 1)] + pub models: Vec, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct AvailableModelPB { + #[pb(index = 1)] + pub name: String, + + #[pb(index = 2)] + pub is_default: bool, + + #[pb(index = 3)] + pub desc: String, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct AvailableModelsQueryPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub source: String, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct UpdateSelectedModelPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub source: String, + + #[pb(index = 2)] + pub selected_model: AIModelPB, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct AvailableModelsPB { + #[pb(index = 1)] + pub models: Vec, + + #[pb(index = 2)] + pub selected_model: AIModelPB, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct AIModelPB { + #[pb(index = 1)] + pub name: String, + + #[pb(index = 2)] + pub is_local: bool, + + #[pb(index = 3)] + pub desc: String, +} + +impl From for AIModelPB { + fn from(model: AIModel) -> Self { + Self { + name: model.name, + is_local: model.is_local, + desc: model.desc, + } + } +} + +impl From for AIModel { + fn from(value: AIModelPB) -> Self { + AIModel { + name: value.name, + is_local: value.is_local, + desc: value.desc, + } + } +} + +impl From for ChatMessageListPB { + fn from(repeated_chat_message: RepeatedChatMessage) -> Self { + let messages = repeated_chat_message + .messages + .into_iter() + .map(ChatMessagePB::from) + .collect(); + ChatMessageListPB { + has_more: repeated_chat_message.has_more, + messages, + total: repeated_chat_message.total, + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct ChatMessagePB { + #[pb(index = 1)] + pub message_id: i64, + + #[pb(index = 2)] + pub content: String, + + #[pb(index = 3)] + pub created_at: i64, + + #[pb(index = 4)] + pub author_type: i64, + + #[pb(index = 5)] + pub author_id: String, + + #[pb(index = 6, one_of)] + pub reply_message_id: Option, + + #[pb(index = 7, one_of)] + pub metadata: Option, +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct ChatMessageErrorPB { + #[pb(index = 1)] + pub chat_id: String, + + #[pb(index = 2)] + pub error_message: String, +} + +impl From for ChatMessagePB { + fn from(chat_message: ChatMessage) -> Self { + ChatMessagePB { + message_id: chat_message.message_id, + content: chat_message.content, + created_at: chat_message.created_at.timestamp(), + author_type: chat_message.author.author_type as i64, + author_id: chat_message.author.author_id.to_string(), + reply_message_id: None, + metadata: Some(serde_json::to_string(&chat_message.metadata).unwrap_or_default()), + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RepeatedChatMessagePB { + #[pb(index = 1)] + items: Vec, +} + +impl From> for RepeatedChatMessagePB { + fn from(messages: Vec) -> Self { + RepeatedChatMessagePB { + items: messages.into_iter().map(ChatMessagePB::from).collect(), + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct ChatMessageIdPB { + #[pb(index = 1)] + pub chat_id: String, + + #[pb(index = 2)] + pub message_id: i64, +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RelatedQuestionPB { + #[pb(index = 1)] + pub content: String, +} + +impl From for RelatedQuestionPB { + fn from(value: RelatedQuestion) -> Self { + RelatedQuestionPB { + content: value.content, + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RepeatedRelatedQuestionPB { + #[pb(index = 1)] + pub message_id: i64, + + #[pb(index = 2)] + pub items: Vec, +} + +impl From for RepeatedRelatedQuestionPB { + fn from(value: RepeatedRelatedQuestion) -> Self { + RepeatedRelatedQuestionPB { + message_id: value.message_id, + items: value + .items + .into_iter() + .map(RelatedQuestionPB::from) + .collect(), + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct LLMModelPB { + #[pb(index = 1)] + pub llm_id: i64, + + #[pb(index = 2)] + pub embedding_model: String, + + #[pb(index = 3)] + pub chat_model: String, + + #[pb(index = 4)] + pub requirement: String, + + #[pb(index = 5)] + pub file_size: i64, +} + +impl From for LLMModelPB { + fn from(value: LLMModel) -> Self { + LLMModelPB { + llm_id: value.llm_id, + embedding_model: value.embedding_model.name, + chat_model: value.chat_model.name, + requirement: value.chat_model.requirements, + file_size: value.chat_model.file_size, + } + } +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct CompleteTextPB { + #[pb(index = 1)] + pub text: String, + + #[pb(index = 2)] + pub completion_type: CompletionTypePB, + + #[pb(index = 3, one_of)] + pub format: Option, + + #[pb(index = 4)] + pub stream_port: i64, + + #[pb(index = 5)] + pub object_id: String, + + #[pb(index = 6)] + pub rag_ids: Vec, + + #[pb(index = 7)] + pub history: Vec, + + #[pb(index = 8, one_of)] + pub custom_prompt: Option, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct CompleteTextTaskPB { + #[pb(index = 1)] + pub task_id: String, +} + +#[derive(Clone, Debug, ProtoBuf_Enum, Default)] +pub enum CompletionTypePB { + #[default] + UserQuestion = 0, + ExplainSelected = 1, + ContinueWriting = 2, + SpellingAndGrammar = 3, + ImproveWriting = 4, + MakeShorter = 5, + MakeLonger = 6, + CustomPrompt = 7, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct CompletionRecordPB { + #[pb(index = 1)] + pub role: ChatMessageTypePB, + + #[pb(index = 2)] + pub content: String, +} + +impl From<&CompletionRecordPB> for CompletionMessage { + fn from(value: &CompletionRecordPB) -> Self { + CompletionMessage { + role: match value.role { + // Coerce ChatMessageTypePB::System to AI + ChatMessageTypePB::System => "ai".to_string(), + ChatMessageTypePB::User => "human".to_string(), + }, + content: value.content.clone(), + } + } +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct ChatStatePB { + #[pb(index = 1)] + pub model_type: ModelTypePB, + + #[pb(index = 2)] + pub available: bool, +} + +#[derive(Clone, Debug, ProtoBuf_Enum, Default)] +pub enum ModelTypePB { + LocalAI = 0, + #[default] + RemoteAI = 1, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct ChatFilePB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub file_path: String, + + #[pb(index = 2)] + #[validate(custom(function = "required_not_empty_str"))] + pub chat_id: String, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct LocalModelStatePB { + #[pb(index = 1)] + pub model_name: String, + + #[pb(index = 2)] + pub model_size: String, + + #[pb(index = 3)] + pub need_download: bool, + + #[pb(index = 4)] + pub requirements: String, + + #[pb(index = 5)] + pub is_downloading: bool, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct PendingResourcePB { + #[pb(index = 1)] + pub name: String, + + #[pb(index = 2)] + pub file_size: String, + + #[pb(index = 3)] + pub requirements: String, + + #[pb(index = 4)] + pub res_type: PendingResourceTypePB, +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum, PartialEq, Eq, Copy)] +pub enum PendingResourceTypePB { + #[default] + LocalAIAppRes = 0, + ModelRes = 1, +} + +impl From for PendingResourceTypePB { + fn from(value: PendingResource) -> Self { + match value { + PendingResource::PluginExecutableNotReady { .. } => PendingResourceTypePB::LocalAIAppRes, + _ => PendingResourceTypePB::ModelRes, + } + } +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum, PartialEq, Eq, Copy)] +pub enum RunningStatePB { + #[default] + ReadyToRun = 0, + Connecting = 1, + Connected = 2, + Running = 3, + Stopped = 4, +} + +impl From for RunningStatePB { + fn from(value: RunningState) -> Self { + match value { + RunningState::ReadyToConnect => RunningStatePB::ReadyToRun, + RunningState::Connecting => RunningStatePB::Connecting, + RunningState::Connected { .. } => RunningStatePB::Connected, + RunningState::Running { .. } => RunningStatePB::Running, + RunningState::Stopped { .. } => RunningStatePB::Stopped, + RunningState::UnexpectedStop { .. } => RunningStatePB::Stopped, + } + } +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct LocalAIPB { + #[pb(index = 1)] + pub enabled: bool, + + #[pb(index = 2, one_of)] + pub lack_of_resource: Option, + + #[pb(index = 3)] + pub state: RunningStatePB, + + #[pb(index = 4, one_of)] + pub plugin_version: Option, + + #[pb(index = 5)] + pub plugin_downloaded: bool, +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct CreateChatContextPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub content_type: String, + + #[pb(index = 2)] + #[validate(custom(function = "required_not_empty_str"))] + pub text: String, + + #[pb(index = 3)] + pub metadata: HashMap, + + #[pb(index = 4)] + #[validate(custom(function = "required_not_empty_str"))] + pub chat_id: String, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct ChatSettingsPB { + #[pb(index = 1)] + pub rag_ids: Vec, +} + +#[derive(Default, ProtoBuf, Clone, Debug, Validate)] +pub struct UpdateChatSettingsPB { + #[pb(index = 1)] + #[validate(nested)] + pub chat_id: ChatId, + + #[pb(index = 2)] + pub rag_ids: Vec, + + #[pb(index = 3)] + pub chat_model: String, +} + +#[derive(Debug, Default, Clone, ProtoBuf)] +pub struct PredefinedFormatPB { + #[pb(index = 1)] + pub image_format: ResponseImageFormatPB, + + #[pb(index = 2, one_of)] + pub text_format: Option, +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum)] +pub enum ResponseImageFormatPB { + #[default] + TextOnly = 0, + ImageOnly = 1, + TextAndImage = 2, +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum)] +pub enum ResponseTextFormatPB { + #[default] + Paragraph = 0, + BulletedList = 1, + NumberedList = 2, + Table = 3, +} + +impl From for ResponseFormat { + fn from(value: PredefinedFormatPB) -> Self { + Self { + output_layout: match value.text_format { + Some(format) => match format { + ResponseTextFormatPB::Paragraph => OutputLayout::Paragraph, + ResponseTextFormatPB::BulletedList => OutputLayout::BulletList, + ResponseTextFormatPB::NumberedList => OutputLayout::NumberedList, + ResponseTextFormatPB::Table => OutputLayout::SimpleTable, + }, + None => OutputLayout::Paragraph, + }, + output_content: match value.image_format { + ResponseImageFormatPB::TextOnly => OutputContent::TEXT, + ResponseImageFormatPB::ImageOnly => OutputContent::IMAGE, + ResponseImageFormatPB::TextAndImage => OutputContent::RichTextImage, + }, + output_content_metadata: None, + } + } +} + +#[derive(Default, ProtoBuf, Validate, Clone, Debug)] +pub struct LocalAISettingPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub server_url: String, + + #[pb(index = 2)] + #[validate(custom(function = "required_not_empty_str"))] + pub chat_model_name: String, + + #[pb(index = 3)] + #[validate(custom(function = "required_not_empty_str"))] + pub embedding_model_name: String, +} + +impl From for LocalAISettingPB { + fn from(value: LocalAISetting) -> Self { + LocalAISettingPB { + server_url: value.ollama_server_url, + chat_model_name: value.chat_model_name, + embedding_model_name: value.embedding_model_name, + } + } +} + +impl From for LocalAISetting { + fn from(value: LocalAISettingPB) -> Self { + LocalAISetting { + ollama_server_url: value.server_url, + chat_model_name: value.chat_model_name, + embedding_model_name: value.embedding_model_name, + } + } +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct LackOfAIResourcePB { + #[pb(index = 1)] + pub resource_type: LackOfAIResourceTypePB, + + #[pb(index = 2)] + pub missing_model_names: Vec, +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum)] +pub enum LackOfAIResourceTypePB { + #[default] + PluginExecutableNotReady = 0, + OllamaServerNotReady = 1, + MissingModel = 2, +} + +impl From for LackOfAIResourcePB { + fn from(value: PendingResource) -> Self { + match value { + PendingResource::PluginExecutableNotReady => Self { + resource_type: LackOfAIResourceTypePB::PluginExecutableNotReady, + missing_model_names: vec![], + }, + PendingResource::OllamaServerNotReady => Self { + resource_type: LackOfAIResourceTypePB::OllamaServerNotReady, + missing_model_names: vec![], + }, + PendingResource::MissingModel(model_name) => Self { + resource_type: LackOfAIResourceTypePB::MissingModel, + missing_model_names: vec![model_name], + }, + } + } +} diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs new file mode 100644 index 0000000000..f85858b1c2 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -0,0 +1,352 @@ +use crate::ai_manager::{AIManager, GLOBAL_ACTIVE_MODEL_KEY}; +use crate::completion::AICompletion; +use crate::entities::*; +use crate::util::ai_available_models_key; +use flowy_ai_pub::cloud::{AIModel, ChatMessageType}; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use std::fs; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::{Arc, Weak}; +use uuid::Uuid; +use validator::Validate; + +fn upgrade_ai_manager(ai_manager: AFPluginState>) -> FlowyResult> { + let ai_manager = ai_manager + .upgrade() + .ok_or(FlowyError::internal().with_context("The chat manager is already dropped"))?; + Ok(ai_manager) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn stream_chat_message_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> DataResult { + let data = data.into_inner(); + data.validate()?; + + let StreamChatPayloadPB { + chat_id, + message, + message_type, + answer_stream_port, + question_stream_port, + format, + } = data; + + let message_type = match message_type { + ChatMessageTypePB::System => ChatMessageType::System, + ChatMessageTypePB::User => ChatMessageType::User, + }; + + let chat_id = Uuid::from_str(&chat_id)?; + let params = StreamMessageParams { + chat_id, + message, + message_type, + answer_stream_port, + question_stream_port, + format, + }; + + let ai_manager = upgrade_ai_manager(ai_manager)?; + let result = ai_manager.stream_chat_message(params).await?; + data_result_ok(result) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn regenerate_response_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> FlowyResult<()> { + let data = data.try_into_inner()?; + let chat_id = Uuid::from_str(&data.chat_id)?; + + let ai_manager = upgrade_ai_manager(ai_manager)?; + ai_manager + .stream_regenerate_response( + &chat_id, + data.answer_message_id, + data.answer_stream_port, + data.format, + data.model, + ) + .await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_server_model_list_handler( + ai_manager: AFPluginState>, +) -> DataResult { + let ai_manager = upgrade_ai_manager(ai_manager)?; + let source_key = ai_available_models_key(GLOBAL_ACTIVE_MODEL_KEY); + let models = ai_manager.get_available_models(source_key).await?; + data_result_ok(models) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_chat_models_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> DataResult { + let data = data.try_into_inner()?; + let ai_manager = upgrade_ai_manager(ai_manager)?; + let models = ai_manager.get_available_models(data.source).await?; + data_result_ok(models) +} + +pub(crate) async fn update_selected_model_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> Result<(), FlowyError> { + let data = data.try_into_inner()?; + let ai_manager = upgrade_ai_manager(ai_manager)?; + ai_manager + .update_selected_model(data.source, AIModel::from(data.selected_model)) + .await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn load_prev_message_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> DataResult { + let ai_manager = upgrade_ai_manager(ai_manager)?; + let data = data.into_inner(); + data.validate()?; + + let chat_id = Uuid::from_str(&data.chat_id)?; + let messages = ai_manager + .load_prev_chat_messages(&chat_id, data.limit as u64, data.before_message_id) + .await?; + data_result_ok(messages) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn load_next_message_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> DataResult { + let ai_manager = upgrade_ai_manager(ai_manager)?; + let data = data.into_inner(); + data.validate()?; + + let chat_id = Uuid::from_str(&data.chat_id)?; + let messages = ai_manager + .load_latest_chat_messages(&chat_id, data.limit as u64, data.after_message_id) + .await?; + data_result_ok(messages) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_related_question_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> DataResult { + let ai_manager = upgrade_ai_manager(ai_manager)?; + let data = data.into_inner(); + let chat_id = Uuid::from_str(&data.chat_id)?; + let messages = ai_manager + .get_related_questions(&chat_id, data.message_id) + .await?; + data_result_ok(messages) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_answer_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> DataResult { + let ai_manager = upgrade_ai_manager(ai_manager)?; + let data = data.into_inner(); + let chat_id = Uuid::from_str(&data.chat_id)?; + let message = ai_manager + .generate_answer(&chat_id, data.message_id) + .await?; + data_result_ok(message) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn stop_stream_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> Result<(), FlowyError> { + let data = data.into_inner(); + data.validate()?; + + let ai_manager = upgrade_ai_manager(ai_manager)?; + let chat_id = Uuid::from_str(&data.chat_id)?; + ai_manager.stop_stream(&chat_id).await?; + Ok(()) +} + +pub(crate) async fn start_complete_text_handler( + data: AFPluginData, + ai_manager: AFPluginState>, + tools: AFPluginState>, +) -> DataResult { + let data = data.into_inner(); + let ai_manager = upgrade_ai_manager(ai_manager)?; + let ai_model = ai_manager.get_active_model(&data.object_id).await; + let task = tools.create_complete_task(data, ai_model).await?; + data_result_ok(task) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn stop_complete_text_handler( + data: AFPluginData, + tools: AFPluginState>, +) -> Result<(), FlowyError> { + let data = data.into_inner(); + tools.cancel_complete_task(&data.task_id).await; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn chat_file_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> Result<(), FlowyError> { + let data = data.try_into_inner()?; + let file_path = PathBuf::from(&data.file_path); + + let allowed_extensions = ["pdf", "md", "txt"]; + let extension = file_path + .extension() + .and_then(|ext| ext.to_str()) + .ok_or_else(|| { + FlowyError::new( + ErrorCode::UnsupportedFileFormat, + "Can't find file extension", + ) + })?; + + if !allowed_extensions.contains(&extension) { + return Err(FlowyError::new( + ErrorCode::UnsupportedFileFormat, + "Only support pdf,md and txt", + )); + } + let file_size = fs::metadata(&file_path) + .map_err(|_| { + FlowyError::new( + ErrorCode::UnsupportedFileFormat, + "Failed to get file metadata", + ) + })? + .len(); + + const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; + if file_size > MAX_FILE_SIZE { + return Err(FlowyError::new( + ErrorCode::PayloadTooLarge, + "File size is too large. Max file size is 10MB", + )); + } + + tracing::debug!("File size: {} bytes", file_size); + let ai_manager = upgrade_ai_manager(ai_manager)?; + let chat_id = Uuid::from_str(&data.chat_id)?; + ai_manager.chat_with_file(&chat_id, file_path).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn restart_local_ai_handler( + ai_manager: AFPluginState>, +) -> Result<(), FlowyError> { + let ai_manager = upgrade_ai_manager(ai_manager)?; + ai_manager.local_ai.restart_plugin().await; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn toggle_local_ai_handler( + ai_manager: AFPluginState>, +) -> DataResult { + let ai_manager = upgrade_ai_manager(ai_manager)?; + ai_manager.toggle_local_ai().await?; + let state = ai_manager.local_ai.get_local_ai_state().await; + data_result_ok(state) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_local_ai_state_handler( + ai_manager: AFPluginState>, +) -> DataResult { + let ai_manager = upgrade_ai_manager(ai_manager)?; + let state = ai_manager.local_ai.get_local_ai_state().await; + data_result_ok(state) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn create_chat_context_handler( + data: AFPluginData, + _ai_manager: AFPluginState>, +) -> Result<(), FlowyError> { + let _data = data.try_into_inner()?; + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_chat_info_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> DataResult { + let chat_id = data.try_into_inner()?.value; + let ai_manager = upgrade_ai_manager(ai_manager)?; + let pb = ai_manager.get_chat_info(&chat_id).await?; + data_result_ok(pb) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_chat_settings_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> DataResult { + let chat_id = data.try_into_inner()?.value; + let chat_id = Uuid::from_str(&chat_id)?; + let ai_manager = upgrade_ai_manager(ai_manager)?; + let rag_ids = ai_manager.get_rag_ids(&chat_id).await?; + let pb = ChatSettingsPB { rag_ids }; + data_result_ok(pb) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn update_chat_settings_handler( + data: AFPluginData, + ai_manager: AFPluginState>, +) -> FlowyResult<()> { + let params = data.try_into_inner()?; + let ai_manager = upgrade_ai_manager(ai_manager)?; + let chat_id = Uuid::from_str(¶ms.chat_id.value)?; + ai_manager.update_rag_ids(&chat_id, params.rag_ids).await?; + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all)] +pub(crate) async fn get_local_ai_setting_handler( + ai_manager: AFPluginState>, +) -> DataResult { + let ai_manager = upgrade_ai_manager(ai_manager)?; + let setting = ai_manager.local_ai.get_local_ai_setting(); + let pb = LocalAISettingPB::from(setting); + data_result_ok(pb) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn update_local_ai_setting_handler( + ai_manager: AFPluginState>, + data: AFPluginData, +) -> Result<(), FlowyError> { + let data = data.try_into_inner()?; + let ai_manager = upgrade_ai_manager(ai_manager)?; + ai_manager.update_local_ai_setting(data.into()).await?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-ai/src/event_map.rs b/frontend/rust-lib/flowy-ai/src/event_map.rs new file mode 100644 index 0000000000..5020836a30 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/event_map.rs @@ -0,0 +1,124 @@ +use std::sync::{Arc, Weak}; + +use strum_macros::Display; + +use crate::completion::AICompletion; +use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; +use lib_dispatch::prelude::*; + +use crate::ai_manager::AIManager; +use crate::event_handler::*; + +pub fn init(ai_manager: Weak) -> AFPlugin { + let strong_ai_manager = ai_manager.upgrade().unwrap(); + let user_service = Arc::downgrade(&strong_ai_manager.user_service); + let cloud_service = Arc::downgrade(&strong_ai_manager.cloud_service_wm); + let ai_tools = Arc::new(AICompletion::new(cloud_service, user_service)); + AFPlugin::new() + .name("flowy-ai") + .state(ai_manager) + .state(ai_tools) + .event(AIEvent::StreamMessage, stream_chat_message_handler) + .event(AIEvent::LoadPrevMessage, load_prev_message_handler) + .event(AIEvent::LoadNextMessage, load_next_message_handler) + .event(AIEvent::GetRelatedQuestion, get_related_question_handler) + .event(AIEvent::GetAnswerForQuestion, get_answer_handler) + .event(AIEvent::StopStream, stop_stream_handler) + .event(AIEvent::CompleteText, start_complete_text_handler) + .event(AIEvent::StopCompleteText, stop_complete_text_handler) + .event(AIEvent::ChatWithFile, chat_file_handler) + .event(AIEvent::RestartLocalAI, restart_local_ai_handler) + .event(AIEvent::ToggleLocalAI, toggle_local_ai_handler) + .event(AIEvent::GetLocalAIState, get_local_ai_state_handler) + .event(AIEvent::GetLocalAISetting, get_local_ai_setting_handler) + .event( + AIEvent::UpdateLocalAISetting, + update_local_ai_setting_handler, + ) + .event( + AIEvent::GetServerAvailableModels, + get_server_model_list_handler, + ) + .event(AIEvent::CreateChatContext, create_chat_context_handler) + .event(AIEvent::GetChatInfo, create_chat_context_handler) + .event(AIEvent::GetChatSettings, get_chat_settings_handler) + .event(AIEvent::UpdateChatSettings, update_chat_settings_handler) + .event(AIEvent::RegenerateResponse, regenerate_response_handler) + .event(AIEvent::GetAvailableModels, get_chat_models_handler) + .event(AIEvent::UpdateSelectedModel, update_selected_model_handler) +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] +#[event_err = "FlowyError"] +pub enum AIEvent { + /// Create a new workspace + #[event(input = "LoadPrevChatMessagePB", output = "ChatMessageListPB")] + LoadPrevMessage = 0, + + #[event(input = "LoadNextChatMessagePB", output = "ChatMessageListPB")] + LoadNextMessage = 1, + + #[event(input = "StreamChatPayloadPB", output = "ChatMessagePB")] + StreamMessage = 2, + + #[event(input = "StopStreamPB")] + StopStream = 3, + + #[event(input = "ChatMessageIdPB", output = "RepeatedRelatedQuestionPB")] + GetRelatedQuestion = 4, + + #[event(input = "ChatMessageIdPB", output = "ChatMessagePB")] + GetAnswerForQuestion = 5, + + #[event(input = "CompleteTextPB", output = "CompleteTextTaskPB")] + CompleteText = 9, + + #[event(input = "CompleteTextTaskPB")] + StopCompleteText = 10, + + #[event(input = "ChatFilePB")] + ChatWithFile = 11, + + /// Restart local AI chat. When plugin quit or user terminate in task manager or activity monitor, + /// the plugin will need to restart. + #[event()] + RestartLocalAI = 17, + + /// Enable or disable local AI + #[event(output = "LocalAIPB")] + ToggleLocalAI = 18, + + /// Return LocalAIPB that contains the current state of the local AI + #[event(output = "LocalAIPB")] + GetLocalAIState = 19, + + #[event(input = "CreateChatContextPB")] + CreateChatContext = 23, + + #[event(input = "ChatId", output = "ChatInfoPB")] + GetChatInfo = 24, + + #[event(input = "ChatId", output = "ChatSettingsPB")] + GetChatSettings = 25, + + #[event(input = "UpdateChatSettingsPB")] + UpdateChatSettings = 26, + + #[event(input = "RegenerateResponsePB")] + RegenerateResponse = 27, + + #[event(output = "AvailableModelsPB")] + GetServerAvailableModels = 28, + + #[event(output = "LocalAISettingPB")] + GetLocalAISetting = 29, + + #[event(input = "LocalAISettingPB")] + UpdateLocalAISetting = 30, + + #[event(input = "AvailableModelsQueryPB", output = "AvailableModelsPB")] + GetAvailableModels = 31, + + #[event(input = "UpdateSelectedModelPB")] + UpdateSelectedModel = 32, +} diff --git a/frontend/rust-lib/flowy-ai/src/lib.rs b/frontend/rust-lib/flowy-ai/src/lib.rs new file mode 100644 index 0000000000..5b582b2577 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/lib.rs @@ -0,0 +1,18 @@ +mod event_handler; +pub mod event_map; + +pub mod ai_manager; +mod chat; +mod completion; +pub mod entities; +pub mod local_ai; + +// #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +// pub mod mcp; + +mod middleware; +pub mod notification; +pub mod offline; +mod protobuf; +mod stream_message; +mod util; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs new file mode 100644 index 0000000000..b9dc7a73c1 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -0,0 +1,623 @@ +use crate::ai_manager::AIUserService; +use crate::entities::{LocalAIPB, RunningStatePB}; +use crate::local_ai::resource::{LLMResourceService, LocalAIResourceController}; +use crate::notification::{ + chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, +}; +use af_plugin::manager::PluginManager; +use anyhow::Error; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::kv::KVStorePreferences; +use futures::Sink; +use lib_infra::async_trait::async_trait; +use std::collections::HashMap; + +use crate::stream_message::StreamMessage; +use af_local_ai::ollama_plugin::OllamaAIPlugin; +use af_plugin::core::path::is_plugin_ready; +use af_plugin::core::plugin::RunningState; +use arc_swap::ArcSwapOption; +use futures_util::SinkExt; +use lib_infra::util::get_operating_system; +use serde::{Deserialize, Serialize}; +use std::ops::Deref; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; +use tokio::select; +use tokio_stream::StreamExt; +use tracing::{debug, error, info, instrument, warn}; +use uuid::Uuid; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LocalAISetting { + pub ollama_server_url: String, + pub chat_model_name: String, + pub embedding_model_name: String, +} + +impl Default for LocalAISetting { + fn default() -> Self { + Self { + ollama_server_url: "http://localhost:11434".to_string(), + chat_model_name: "llama3.1".to_string(), + embedding_model_name: "nomic-embed-text".to_string(), + } + } +} + +const LOCAL_AI_SETTING_KEY: &str = "appflowy_local_ai_setting:v1"; + +pub struct LocalAIController { + ai_plugin: Arc, + resource: Arc, + current_chat_id: ArcSwapOption, + store_preferences: Weak, + user_service: Arc, +} + +impl Deref for LocalAIController { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.ai_plugin + } +} + +impl LocalAIController { + pub fn new( + plugin_manager: Arc, + store_preferences: Weak, + user_service: Arc, + ) -> Self { + debug!( + "[AI Plugin] init local ai controller, thread: {:?}", + std::thread::current().id() + ); + + // Create the core plugin and resource controller + let local_ai = Arc::new(OllamaAIPlugin::new(plugin_manager)); + let res_impl = LLMResourceServiceImpl { + store_preferences: store_preferences.clone(), + }; + let local_ai_resource = Arc::new(LocalAIResourceController::new( + user_service.clone(), + res_impl, + )); + // Subscribe to state changes + let mut running_state_rx = local_ai.subscribe_running_state(); + + let cloned_llm_res = Arc::clone(&local_ai_resource); + let cloned_store_preferences = store_preferences.clone(); + let cloned_local_ai = Arc::clone(&local_ai); + let cloned_user_service = Arc::clone(&user_service); + + // Spawn a background task to listen for plugin state changes + tokio::spawn(async move { + while let Some(state) = running_state_rx.next().await { + // Skip if we can’t get workspace_id + let Ok(workspace_id) = cloned_user_service.workspace_id() else { + continue; + }; + + let key = local_ai_enabled_key(&workspace_id); + info!("[AI Plugin] state: {:?}", state); + + // Read whether plugin is enabled from store; default to true + if let Some(store_preferences) = cloned_store_preferences.upgrade() { + let enabled = store_preferences.get_bool(&key).unwrap_or(true); + // Only check resource status if the plugin isn’t in "UnexpectedStop" and is enabled + let (plugin_downloaded, lack_of_resource) = + if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled { + // Possibly check plugin readiness and resource concurrency in parallel, + // but here we do it sequentially for clarity. + let downloaded = is_plugin_ready(); + let resource_lack = cloned_llm_res.get_lack_of_resource().await; + (downloaded, resource_lack) + } else { + (false, None) + }; + + // If plugin is running, retrieve version + let plugin_version = if matches!(state, RunningState::Running { .. }) { + match cloned_local_ai.plugin_info().await { + Ok(info) => Some(info.version), + Err(_) => None, + } + } else { + None + }; + + // Broadcast the new local AI state + let new_state = RunningStatePB::from(state); + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::UpdateLocalAIState, + ) + .payload(LocalAIPB { + enabled, + plugin_downloaded, + lack_of_resource, + state: new_state, + plugin_version, + }) + .send(); + } else { + warn!("[AI Plugin] store preferences is dropped"); + } + } + }); + + Self { + ai_plugin: local_ai, + resource: local_ai_resource, + current_chat_id: ArcSwapOption::default(), + store_preferences, + user_service, + } + } + #[instrument(level = "debug", skip_all)] + pub async fn observe_plugin_resource(&self) { + debug!( + "[AI Plugin] init plugin when first run. thread: {:?}", + std::thread::current().id() + ); + let sys = get_operating_system(); + if !sys.is_desktop() { + return; + } + async fn try_init_plugin( + resource: &Arc, + ai_plugin: &Arc, + ) { + if let Err(err) = initialize_ai_plugin(ai_plugin, resource, None).await { + error!("[AI Plugin] failed to setup plugin: {:?}", err); + } + } + + // Clone what is needed for the background task. + let resource_clone = self.resource.clone(); + let ai_plugin_clone = self.ai_plugin.clone(); + let mut resource_notify = self.resource.subscribe_resource_notify(); + let mut app_state_watcher = self.resource.subscribe_app_state(); + tokio::spawn(async move { + loop { + select! { + _ = app_state_watcher.recv() => { + info!("[AI Plugin] app state changed, try to init plugin"); + try_init_plugin(&resource_clone, &ai_plugin_clone).await; + }, + _ = resource_notify.recv() => { + info!("[AI Plugin] resource changed, try to init plugin"); + try_init_plugin(&resource_clone, &ai_plugin_clone).await; + }, + else => break, + } + } + }); + } + + pub async fn reload(&self) -> FlowyResult<()> { + let is_enabled = self.is_enabled(); + self.toggle_plugin(is_enabled).await?; + Ok(()) + } + + fn upgrade_store_preferences(&self) -> FlowyResult> { + self + .store_preferences + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Store preferences is dropped")) + } + + /// Indicate whether the local AI plugin is running. + pub fn is_running(&self) -> bool { + if !self.is_enabled() { + return false; + } + self.ai_plugin.get_plugin_running_state().is_running() + } + + /// Indicate whether the local AI is enabled. + /// AppFlowy store the value in local storage isolated by workspace id. Each workspace can have + /// different settings. + pub fn is_enabled(&self) -> bool { + if !get_operating_system().is_desktop() { + return false; + } + + if let Ok(key) = self + .user_service + .workspace_id() + .map(|workspace_id| local_ai_enabled_key(&workspace_id)) + { + match self.upgrade_store_preferences() { + Ok(store) => store.get_bool(&key).unwrap_or(false), + Err(_) => false, + } + } else { + false + } + } + + pub fn get_plugin_chat_model(&self) -> Option { + if !self.is_enabled() { + return None; + } + Some(self.resource.get_llm_setting().chat_model_name) + } + + pub fn open_chat(&self, chat_id: &Uuid) { + if !self.is_enabled() { + return; + } + + // Only keep one chat open at a time. Since loading multiple models at the same time will cause + // memory issues. + if let Some(current_chat_id) = self.current_chat_id.load().as_ref() { + debug!("[AI Plugin] close previous chat: {}", current_chat_id); + self.close_chat(current_chat_id); + } + + self.current_chat_id.store(Some(Arc::new(*chat_id))); + let chat_id = chat_id.to_string(); + let weak_ctrl = Arc::downgrade(&self.ai_plugin); + tokio::spawn(async move { + if let Some(ctrl) = weak_ctrl.upgrade() { + if let Err(err) = ctrl.create_chat(&chat_id).await { + error!("[AI Plugin] failed to open chat: {:?}", err); + } + } + }); + } + + pub fn close_chat(&self, chat_id: &Uuid) { + if !self.is_running() { + return; + } + info!("[AI Plugin] notify close chat: {}", chat_id); + let weak_ctrl = Arc::downgrade(&self.ai_plugin); + let chat_id = chat_id.to_string(); + tokio::spawn(async move { + if let Some(ctrl) = weak_ctrl.upgrade() { + if let Err(err) = ctrl.close_chat(&chat_id).await { + error!("[AI Plugin] failed to close chat: {:?}", err); + } + } + }); + } + + pub fn get_local_ai_setting(&self) -> LocalAISetting { + self.resource.get_llm_setting() + } + + pub async fn update_local_ai_setting(&self, setting: LocalAISetting) -> FlowyResult<()> { + info!( + "[AI Plugin] update local ai setting: {:?}, thread: {:?}", + setting, + std::thread::current().id() + ); + + if self.resource.set_llm_setting(setting).await.is_ok() { + self.reload().await?; + } + Ok(()) + } + + #[instrument(level = "debug", skip_all)] + pub async fn get_local_ai_state(&self) -> LocalAIPB { + let start = std::time::Instant::now(); + let enabled = self.is_enabled(); + + // If not enabled, return immediately. + if !enabled { + debug!( + "[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}", + start.elapsed(), + std::thread::current().id() + ); + return LocalAIPB { + enabled: false, + plugin_downloaded: false, + state: RunningStatePB::from(RunningState::ReadyToConnect), + lack_of_resource: None, + plugin_version: None, + }; + } + + let plugin_downloaded = is_plugin_ready(); + let state = self.ai_plugin.get_plugin_running_state(); + + // If the plugin is running, run both requests in parallel. + // Otherwise, only fetch the resource info. + let (plugin_version, lack_of_resource) = if matches!(state, RunningState::Running { .. }) { + // Launch both futures at once + let plugin_info_fut = self.ai_plugin.plugin_info(); + let resource_fut = self.resource.get_lack_of_resource(); + + let (plugin_info_res, resource_res) = tokio::join!(plugin_info_fut, resource_fut); + let plugin_version = plugin_info_res.ok().map(|info| info.version); + (plugin_version, resource_res) + } else { + let resource_res = self.resource.get_lack_of_resource().await; + (None, resource_res) + }; + + let elapsed = start.elapsed(); + debug!( + "[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}", + elapsed, + std::thread::current().id() + ); + + LocalAIPB { + enabled, + plugin_downloaded, + state: RunningStatePB::from(state), + lack_of_resource, + plugin_version, + } + } + #[instrument(level = "debug", skip_all)] + pub async fn restart_plugin(&self) { + if let Err(err) = initialize_ai_plugin(&self.ai_plugin, &self.resource, None).await { + error!("[AI Plugin] failed to setup plugin: {:?}", err); + } + } + + pub fn get_model_storage_directory(&self) -> FlowyResult { + self + .resource + .user_model_folder() + .map(|path| path.to_string_lossy().to_string()) + } + + pub async fn toggle_local_ai(&self) -> FlowyResult { + let workspace_id = self.user_service.workspace_id()?; + let key = local_ai_enabled_key(&workspace_id); + let store_preferences = self.upgrade_store_preferences()?; + let enabled = !store_preferences.get_bool(&key).unwrap_or(true); + store_preferences.set_bool(&key, enabled)?; + self.toggle_plugin(enabled).await?; + Ok(enabled) + } + + // #[instrument(level = "debug", skip_all)] + // pub async fn index_message_metadata( + // &self, + // chat_id: &Uuid, + // metadata_list: &[ChatMessageMetadata], + // index_process_sink: &mut (impl Sink + Unpin), + // ) -> FlowyResult<()> { + // if !self.is_enabled() { + // info!("[AI Plugin] local ai is disabled, skip indexing"); + // return Ok(()); + // } + // + // for metadata in metadata_list { + // let mut file_metadata = HashMap::new(); + // file_metadata.insert("id".to_string(), json!(&metadata.id)); + // file_metadata.insert("name".to_string(), json!(&metadata.name)); + // file_metadata.insert("source".to_string(), json!(&metadata.source)); + // + // let file_path = Path::new(&metadata.data.content); + // if !file_path.exists() { + // return Err( + // FlowyError::record_not_found().with_context(format!("File not found: {:?}", file_path)), + // ); + // } + // info!( + // "[AI Plugin] embed file: {:?}, with metadata: {:?}", + // file_path, file_metadata + // ); + // + // match &metadata.data.content_type { + // ContextLoader::Unknown => { + // error!( + // "[AI Plugin] unsupported content type: {:?}", + // metadata.data.content_type + // ); + // }, + // ContextLoader::Text | ContextLoader::Markdown | ContextLoader::PDF => { + // self + // .process_index_file( + // chat_id, + // file_path.to_path_buf(), + // &file_metadata, + // index_process_sink, + // ) + // .await?; + // }, + // } + // } + // + // Ok(()) + // } + + #[allow(dead_code)] + async fn process_index_file( + &self, + chat_id: &Uuid, + file_path: PathBuf, + index_metadata: &HashMap, + index_process_sink: &mut (impl Sink + Unpin), + ) -> Result<(), FlowyError> { + let file_name = file_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + let _ = index_process_sink + .send( + StreamMessage::StartIndexFile { + file_name: file_name.clone(), + } + .to_string(), + ) + .await; + + let result = self + .ai_plugin + .embed_file( + &chat_id.to_string(), + file_path, + Some(index_metadata.clone()), + ) + .await; + match result { + Ok(_) => { + let _ = index_process_sink + .send(StreamMessage::EndIndexFile { file_name }.to_string()) + .await; + }, + Err(err) => { + let _ = index_process_sink + .send(StreamMessage::IndexFileError { file_name }.to_string()) + .await; + error!("[AI Plugin] failed to index file: {:?}", err); + }, + } + + Ok(()) + } + + #[instrument(level = "debug", skip_all)] + async fn toggle_plugin(&self, enabled: bool) -> FlowyResult<()> { + info!( + "[AI Plugin] enable: {}, thread id: {:?}", + enabled, + std::thread::current().id() + ); + if enabled { + let (tx, rx) = tokio::sync::oneshot::channel(); + if let Err(err) = initialize_ai_plugin(&self.ai_plugin, &self.resource, Some(tx)).await { + error!("[AI Plugin] failed to initialize local ai: {:?}", err); + } + let _ = rx.await; + } else { + if let Err(err) = self.ai_plugin.destroy_plugin().await { + error!("[AI Plugin] failed to destroy plugin: {:?}", err); + } + + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::UpdateLocalAIState, + ) + .payload(LocalAIPB { + enabled, + plugin_downloaded: true, + state: RunningStatePB::Stopped, + lack_of_resource: None, + plugin_version: None, + }) + .send(); + } + Ok(()) + } +} + +#[instrument(level = "debug", skip_all, err)] +async fn initialize_ai_plugin( + plugin: &Arc, + llm_resource: &Arc, + ret: Option>, +) -> FlowyResult<()> { + let lack_of_resource = llm_resource.get_lack_of_resource().await; + + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::UpdateLocalAIState, + ) + .payload(LocalAIPB { + enabled: true, + plugin_downloaded: true, + state: RunningStatePB::ReadyToRun, + lack_of_resource: lack_of_resource.clone(), + plugin_version: None, + }) + .send(); + + if let Some(lack_of_resource) = lack_of_resource { + info!( + "[AI Plugin] lack of resource: {:?} to initialize plugin, thread: {:?}", + lack_of_resource, + std::thread::current().id() + ); + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::LocalAIResourceUpdated, + ) + .payload(lack_of_resource) + .send(); + + return Ok(()); + } + + if let Err(err) = plugin.destroy_plugin().await { + error!( + "[AI Plugin] failed to destroy plugin when lack of resource: {:?}", + err + ); + } + + let plugin = plugin.clone(); + let cloned_llm_res = llm_resource.clone(); + tokio::task::spawn_blocking(move || { + futures::executor::block_on(async move { + match cloned_llm_res.get_plugin_config(true).await { + Ok(config) => { + info!( + "[AI Plugin] initialize plugin with config: {:?}, thread: {:?}", + config, + std::thread::current().id() + ); + + match plugin.init_plugin(config).await { + Ok(_) => {}, + Err(err) => error!("[AI Plugin] failed to setup plugin: {:?}", err), + } + + if let Some(ret) = ret { + let _ = ret.send(()); + } + }, + Err(err) => { + error!("[AI Plugin] failed to get plugin config: {:?}", err); + }, + }; + }) + }); + + Ok(()) +} + +pub struct LLMResourceServiceImpl { + store_preferences: Weak, +} + +impl LLMResourceServiceImpl { + fn upgrade_store_preferences(&self) -> FlowyResult> { + self + .store_preferences + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Store preferences is dropped")) + } +} +#[async_trait] +impl LLMResourceService for LLMResourceServiceImpl { + fn store_setting(&self, setting: LocalAISetting) -> Result<(), Error> { + let store_preferences = self.upgrade_store_preferences()?; + store_preferences.set_object(LOCAL_AI_SETTING_KEY, &setting)?; + Ok(()) + } + + fn retrieve_setting(&self) -> Option { + let store_preferences = self.upgrade_store_preferences().ok()?; + store_preferences.get_object::(LOCAL_AI_SETTING_KEY) + } +} + +const APPFLOWY_LOCAL_AI_ENABLED: &str = "appflowy_local_ai_enabled"; +fn local_ai_enabled_key(workspace_id: &Uuid) -> String { + format!("{}:{}", APPFLOWY_LOCAL_AI_ENABLED, workspace_id) +} diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/mod.rs b/frontend/rust-lib/flowy-ai/src/local_ai/mod.rs new file mode 100644 index 0000000000..c0fd967d43 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/local_ai/mod.rs @@ -0,0 +1,6 @@ +pub mod controller; +mod request; +pub mod resource; + +pub mod stream_util; +pub mod watch; diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/request.rs b/frontend/rust-lib/flowy-ai/src/local_ai/request.rs new file mode 100644 index 0000000000..6d4bd3289d --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/local_ai/request.rs @@ -0,0 +1,118 @@ +use anyhow::{anyhow, Result}; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use reqwest::{Client, Response, StatusCode}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::fs::{self, File}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; + +use tokio_util::sync::CancellationToken; +use tracing::{instrument, trace}; + +#[allow(dead_code)] +type ProgressCallback = Arc; + +#[instrument(level = "trace", skip_all, err)] +pub async fn download_model( + url: &str, + model_path: &Path, + model_filename: &str, + progress_callback: Option, + cancel_token: Option, +) -> Result { + let client = Client::new(); + let mut response = make_request(&client, url, None).await?; + let total_size_in_bytes = response.content_length().unwrap_or(0); + let partial_path = model_path.join(format!("{}.part", model_filename)); + let download_path = model_path.join(model_filename); + let mut part_file = File::create(&partial_path).await?; + let mut downloaded: u64 = 0; + + let debounce_duration = Duration::from_millis(100); + let mut last_update = Instant::now() + .checked_sub(debounce_duration) + .unwrap_or(Instant::now()); + + while let Some(chunk) = response.chunk().await? { + if let Some(cancel_token) = &cancel_token { + if cancel_token.is_cancelled() { + trace!("Download canceled by client"); + fs::remove_file(&partial_path).await?; + return Err(anyhow!("Download canceled")); + } + } + + part_file.write_all(&chunk).await?; + downloaded += chunk.len() as u64; + + if let Some(progress_callback) = &progress_callback { + let now = Instant::now(); + if now.duration_since(last_update) >= debounce_duration { + progress_callback(downloaded, total_size_in_bytes); + last_update = now; + } + } + } + + // Verify file integrity + let header_sha256 = response + .headers() + .get("SHA256") + .and_then(|value| value.to_str().ok()) + .and_then(|value| STANDARD.decode(value).ok()); + + part_file.seek(tokio::io::SeekFrom::Start(0)).await?; + let mut hasher = Sha256::new(); + let block_size = 2_usize.pow(20); // 1 MB + let mut buffer = vec![0; block_size]; + while let Ok(bytes_read) = part_file.read(&mut buffer).await { + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + let calculated_sha256 = hasher.finalize(); + if let Some(header_sha256) = header_sha256 { + if calculated_sha256.as_slice() != header_sha256.as_slice() { + trace!( + "Header Sha256: {:?}, calculated Sha256:{:?}", + header_sha256, + calculated_sha256 + ); + + fs::remove_file(&partial_path).await?; + return Err(anyhow!( + "Sha256 mismatch: expected {:?}, got {:?}", + header_sha256, + calculated_sha256 + )); + } + } + + fs::rename(&partial_path, &download_path).await?; + Ok(download_path) +} + +#[allow(dead_code)] +async fn make_request( + client: &Client, + url: &str, + offset: Option, +) -> Result { + let mut request = client.get(url); + if let Some(offset) = offset { + println!( + "\nDownload interrupted, resuming from byte position {}", + offset + ); + request = request.header("Range", format!("bytes={}-", offset)); + } + let response = request.send().await?; + if !(response.status().is_success() || response.status() == StatusCode::PARTIAL_CONTENT) { + return Err(anyhow!(response.text().await?)); + } + Ok(response) +} diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs new file mode 100644 index 0000000000..6251ef8de5 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/local_ai/resource.rs @@ -0,0 +1,289 @@ +use crate::ai_manager::AIUserService; +use crate::local_ai::controller::LocalAISetting; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use lib_infra::async_trait::async_trait; + +use crate::entities::LackOfAIResourcePB; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use crate::local_ai::watch::{watch_offline_app, WatchContext}; +use crate::notification::{ + chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, +}; +use af_local_ai::ollama_plugin::OllamaPluginConfig; +use af_plugin::core::path::{is_plugin_ready, ollama_plugin_path}; +use lib_infra::util::{get_operating_system, OperatingSystem}; +use reqwest::Client; +use serde::Deserialize; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tracing::{error, info, instrument, trace}; + +#[derive(Debug, Deserialize)] +struct TagsResponse { + models: Vec, +} + +#[derive(Debug, Deserialize)] +struct ModelEntry { + name: String, +} + +#[async_trait] +pub trait LLMResourceService: Send + Sync + 'static { + /// Get local ai configuration from remote server + fn store_setting(&self, setting: LocalAISetting) -> Result<(), anyhow::Error>; + fn retrieve_setting(&self) -> Option; +} + +const LLM_MODEL_DIR: &str = "models"; + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum WatchDiskEvent { + Create, + Remove, +} + +#[derive(Debug, Clone)] +pub enum PendingResource { + PluginExecutableNotReady, + OllamaServerNotReady, + MissingModel(String), +} + +pub struct LocalAIResourceController { + user_service: Arc, + resource_service: Arc, + resource_notify: tokio::sync::broadcast::Sender<()>, + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[allow(dead_code)] + app_disk_watch: Option, + app_state_sender: tokio::sync::broadcast::Sender, +} + +impl LocalAIResourceController { + pub fn new( + user_service: Arc, + resource_service: impl LLMResourceService, + ) -> Self { + let (resource_notify, _) = tokio::sync::broadcast::channel(1); + let (app_state_sender, _) = tokio::sync::broadcast::channel(1); + #[cfg(any(target_os = "macos", target_os = "linux"))] + let mut offline_app_disk_watch: Option = None; + + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + match watch_offline_app() { + Ok((new_watcher, mut rx)) => { + let sender = app_state_sender.clone(); + tokio::spawn(async move { + while let Some(event) = rx.recv().await { + if let Err(err) = sender.send(event) { + error!("[LLM Resource] Failed to send offline app state: {:?}", err); + } + } + }); + offline_app_disk_watch = Some(new_watcher); + }, + Err(err) => { + error!("[LLM Resource] Failed to watch offline app path: {:?}", err); + }, + } + } + + Self { + user_service, + resource_service: Arc::new(resource_service), + #[cfg(any(target_os = "macos", target_os = "linux"))] + app_disk_watch: offline_app_disk_watch, + app_state_sender, + resource_notify, + } + } + + pub fn subscribe_resource_notify(&self) -> tokio::sync::broadcast::Receiver<()> { + self.resource_notify.subscribe() + } + + pub fn subscribe_app_state(&self) -> tokio::sync::broadcast::Receiver { + self.app_state_sender.subscribe() + } + + /// Returns true when all resources are downloaded and ready to use. + pub async fn is_resource_ready(&self) -> bool { + let sys = get_operating_system(); + if !sys.is_desktop() { + return false; + } + + self + .calculate_pending_resources() + .await + .is_ok_and(|r| r.is_none()) + } + + /// Retrieves model information and updates the current model settings. + pub fn get_llm_setting(&self) -> LocalAISetting { + self.resource_service.retrieve_setting().unwrap_or_default() + } + + #[instrument(level = "info", skip_all, err)] + pub async fn set_llm_setting(&self, setting: LocalAISetting) -> FlowyResult<()> { + self.resource_service.store_setting(setting)?; + if let Some(resource) = self.calculate_pending_resources().await? { + let resource = LackOfAIResourcePB::from(resource); + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::LocalAIResourceUpdated, + ) + .payload(resource.clone()) + .send(); + return Err(FlowyError::local_ai().with_context(format!("{:?}", resource))); + } + Ok(()) + } + + pub async fn get_lack_of_resource(&self) -> Option { + self + .calculate_pending_resources() + .await + .ok()? + .map(Into::into) + } + + pub async fn calculate_pending_resources(&self) -> FlowyResult> { + let app_path = ollama_plugin_path(); + if !is_plugin_ready() { + trace!("[LLM Resource] offline app not found: {:?}", app_path); + return Ok(Some(PendingResource::PluginExecutableNotReady)); + } + + let setting = self.get_llm_setting(); + let client = Client::builder().timeout(Duration::from_secs(5)).build()?; + + match client.get(&setting.ollama_server_url).send().await { + Ok(resp) if resp.status().is_success() => { + info!( + "[LLM Resource] Ollama server is running at {}", + setting.ollama_server_url + ); + }, + _ => { + info!( + "[LLM Resource] Ollama server is not responding at {}", + setting.ollama_server_url + ); + return Ok(Some(PendingResource::OllamaServerNotReady)); + }, + } + + let required_models = vec![setting.chat_model_name, setting.embedding_model_name]; + + // Query the /api/tags endpoint to get a structured list of locally available models. + let tags_url = format!("{}/api/tags", setting.ollama_server_url); + + match client.get(&tags_url).send().await { + Ok(resp) if resp.status().is_success() => { + let tags: TagsResponse = resp.json().await.inspect_err(|e| { + log::error!("[LLM Resource] Failed to parse /api/tags JSON response: {e:?}") + })?; + // Check if each of our required models exists in the list of available models + trace!("[LLM Resource] ollama available models: {:?}", tags.models); + for required in &required_models { + if !tags + .models + .iter() + .any(|m| m.name == *required || m.name == format!("{}:latest", required)) + { + log::trace!( + "[LLM Resource] required model '{}' not found in API response", + required + ); + return Ok(Some(PendingResource::MissingModel(required.clone()))); + } + } + }, + _ => { + error!( + "[LLM Resource] Failed to fetch models from {} (GET /api/tags)", + setting.ollama_server_url + ); + return Ok(Some(PendingResource::OllamaServerNotReady)); + }, + } + + Ok(None) + } + + #[instrument(level = "info", skip_all)] + pub async fn get_plugin_config(&self, rag_enabled: bool) -> FlowyResult { + if !self.is_resource_ready().await { + return Err(FlowyError::new( + ErrorCode::AppFlowyLAINotReady, + "AppFlowyLAI not found", + )); + } + + let llm_setting = self.get_llm_setting(); + let bin_path = match get_operating_system() { + OperatingSystem::MacOS | OperatingSystem::Windows | OperatingSystem::Linux => { + ollama_plugin_path() + }, + _ => { + return Err( + FlowyError::local_ai_unavailable() + .with_context("Local AI not available on current platform"), + ); + }, + }; + + let mut config = OllamaPluginConfig::new( + bin_path, + "af_ollama_plugin".to_string(), + llm_setting.chat_model_name.clone(), + llm_setting.embedding_model_name.clone(), + Some(llm_setting.ollama_server_url.clone()), + )?; + + //config = config.with_log_level("debug".to_string()); + + if rag_enabled { + let resource_dir = self.resource_dir()?; + let persist_directory = resource_dir.join("vectorstore"); + if !persist_directory.exists() { + std::fs::create_dir_all(&persist_directory)?; + } + config.set_rag_enabled(&persist_directory)?; + } + + if cfg!(debug_assertions) { + config = config.with_verbose(true); + } + trace!("[AI Chat] config: {:?}", config); + Ok(config) + } + + pub(crate) fn user_model_folder(&self) -> FlowyResult { + self.resource_dir().map(|dir| dir.join(LLM_MODEL_DIR)) + } + + pub(crate) fn resource_dir(&self) -> FlowyResult { + let user_data_dir = self.user_service.application_root_dir()?; + Ok(user_data_dir.join("ai")) + } +} + +#[allow(dead_code)] +fn bytes_to_readable_format(bytes: u64) -> String { + const BYTES_IN_GIGABYTE: u64 = 1024 * 1024 * 1024; + const BYTES_IN_MEGABYTE: u64 = 1024 * 1024; + + if bytes >= BYTES_IN_GIGABYTE { + let gigabytes = (bytes as f64) / (BYTES_IN_GIGABYTE as f64); + format!("{:.1} GB", gigabytes) + } else { + let megabytes = (bytes as f64) / (BYTES_IN_MEGABYTE as f64); + format!("{:.2} MB", megabytes) + } +} diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs b/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs new file mode 100644 index 0000000000..fbe4157c8c --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs @@ -0,0 +1,65 @@ +use af_plugin::error::PluginError; + +use flowy_ai_pub::cloud::QuestionStreamValue; +use flowy_error::FlowyError; +use futures::{ready, Stream}; +use pin_project::pin_project; +use serde_json::Value; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tracing::error; + +pub const STEAM_METADATA_KEY: &str = "0"; +pub const STEAM_ANSWER_KEY: &str = "1"; + +#[pin_project] +pub struct QuestionStream { + stream: Pin> + Send>>, + buffer: Vec, +} + +impl QuestionStream { + pub fn new(stream: S) -> Self + where + S: Stream> + Send + 'static, + { + QuestionStream { + stream: Box::pin(stream), + buffer: Vec::new(), + } + } +} + +impl Stream for QuestionStream { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.project(); + + match ready!(this.stream.as_mut().poll_next(cx)) { + Some(Ok(value)) => match value { + Value::Object(mut value) => { + if let Some(metadata) = value.remove(STEAM_METADATA_KEY) { + return Poll::Ready(Some(Ok(QuestionStreamValue::Metadata { value: metadata }))); + } + + if let Some(answer) = value + .remove(STEAM_ANSWER_KEY) + .and_then(|s| s.as_str().map(ToString::to_string)) + { + return Poll::Ready(Some(Ok(QuestionStreamValue::Answer { value: answer }))); + } + + error!("Invalid streaming value: {:?}", value); + Poll::Ready(None) + }, + _ => { + error!("Unexpected JSON value type: {:?}", value); + Poll::Ready(None) + }, + }, + Some(Err(err)) => Poll::Ready(Some(Err(FlowyError::local_ai().with_context(err)))), + None => Poll::Ready(None), + } + } +} diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs b/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs new file mode 100644 index 0000000000..2baed3f0a5 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs @@ -0,0 +1,60 @@ +use crate::local_ai::resource::WatchDiskEvent; +use af_plugin::core::path::{install_path, ollama_plugin_path}; +use flowy_error::{FlowyError, FlowyResult}; +use std::path::PathBuf; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; +use tracing::{error, trace}; + +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[allow(dead_code)] +pub struct WatchContext { + watcher: notify::RecommendedWatcher, + pub path: PathBuf, +} + +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[allow(dead_code)] +pub fn watch_offline_app() -> FlowyResult<(WatchContext, UnboundedReceiver)> { + use notify::{Event, Watcher}; + + let install_path = install_path().ok_or_else(|| { + FlowyError::internal().with_context("Unsupported platform for offline app watching") + })?; + let (tx, rx) = unbounded_channel(); + let app_path = ollama_plugin_path(); + let mut watcher = notify::recommended_watcher(move |res: Result| match res { + Ok(event) => { + if event.paths.iter().any(|path| path == &app_path) { + trace!("watch event: {:?}", event); + match event.kind { + notify::EventKind::Create(_) => { + if let Err(err) = tx.send(WatchDiskEvent::Create) { + error!("watch send error: {:?}", err) + } + }, + notify::EventKind::Remove(_) => { + if let Err(err) = tx.send(WatchDiskEvent::Remove) { + error!("watch send error: {:?}", err) + } + }, + _ => { + trace!("unhandle watch event: {:?}", event); + }, + } + } + }, + Err(e) => error!("watch error: {:?}", e), + }) + .map_err(|err| FlowyError::internal().with_context(err))?; + watcher + .watch(&install_path, notify::RecursiveMode::NonRecursive) + .map_err(|err| FlowyError::internal().with_context(err))?; + + Ok(( + WatchContext { + watcher, + path: install_path, + }, + rx, + )) +} diff --git a/frontend/rust-lib/flowy-ai/src/mcp/manager.rs b/frontend/rust-lib/flowy-ai/src/mcp/manager.rs new file mode 100644 index 0000000000..9e40a51f68 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/mcp/manager.rs @@ -0,0 +1,39 @@ +use af_mcp::client::{MCPClient, MCPServerConfig}; +use af_mcp::entities::ToolsList; +use dashmap::DashMap; +use flowy_error::FlowyError; +use std::sync::Arc; + +pub struct MCPClientManager { + stdio_clients: Arc>, +} + +impl MCPClientManager { + pub fn new() -> MCPClientManager { + Self { + stdio_clients: Arc::new(DashMap::new()), + } + } + + pub async fn connect_server(&self, config: MCPServerConfig) -> Result<(), FlowyError> { + let client = MCPClient::new_stdio(config.clone()).await?; + self.stdio_clients.insert(config.server_cmd, client.clone()); + client.initialize().await?; + Ok(()) + } + + pub async fn remove_server(&self, config: MCPServerConfig) -> Result<(), FlowyError> { + let client = self.stdio_clients.remove(&config.server_cmd); + if let Some((_, mut client)) = client { + client.stop().await?; + } + Ok(()) + } + + pub async fn tool_list(&self, server_cmd: &str) -> Option { + let client = self.stdio_clients.get(server_cmd)?; + let tools = client.list_tools().await.ok(); + tracing::trace!("{}: tool list: {:?}", server_cmd, tools); + tools + } +} diff --git a/frontend/rust-lib/flowy-ai/src/mcp/mod.rs b/frontend/rust-lib/flowy-ai/src/mcp/mod.rs new file mode 100644 index 0000000000..8f73c8326c --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/mcp/mod.rs @@ -0,0 +1 @@ +mod manager; diff --git a/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs new file mode 100644 index 0000000000..22a2bec674 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs @@ -0,0 +1,375 @@ +use crate::ai_manager::AIUserService; +use crate::entities::{ChatStatePB, ModelTypePB}; +use crate::local_ai::controller::LocalAIController; +use crate::notification::{ + chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, +}; +use af_plugin::error::PluginError; +use flowy_ai_pub::persistence::select_message_content; +use std::collections::HashMap; + +use flowy_ai_pub::cloud::{ + AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageType, + ChatSettings, CompleteTextParams, CompletionStream, MessageCursor, ModelList, RelatedQuestion, + RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, StreamAnswer, StreamComplete, + UpdateChatParams, +}; +use flowy_error::{FlowyError, FlowyResult}; +use futures::{stream, StreamExt, TryStreamExt}; +use lib_infra::async_trait::async_trait; + +use crate::local_ai::stream_util::QuestionStream; +use flowy_storage_pub::storage::StorageService; +use serde_json::{json, Value}; +use std::path::Path; +use std::sync::{Arc, Weak}; +use tracing::{info, trace}; +use uuid::Uuid; + +pub struct ChatServiceMiddleware { + cloud_service: Arc, + user_service: Arc, + local_ai: Arc, + #[allow(dead_code)] + storage_service: Weak, +} + +impl ChatServiceMiddleware { + pub fn new( + user_service: Arc, + cloud_service: Arc, + local_ai: Arc, + storage_service: Weak, + ) -> Self { + Self { + user_service, + cloud_service, + local_ai, + storage_service, + } + } + + fn get_message_content(&self, message_id: i64) -> FlowyResult { + let uid = self.user_service.user_id()?; + let conn = self.user_service.sqlite_connection(uid)?; + let content = select_message_content(conn, message_id)?.ok_or_else(|| { + FlowyError::record_not_found().with_context(format!("Message not found: {}", message_id)) + })?; + Ok(content) + } + + fn handle_plugin_error(&self, err: PluginError) { + if matches!( + err, + PluginError::PluginNotConnected | PluginError::PeerDisconnect + ) { + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::UpdateLocalAIState, + ) + .payload(ChatStatePB { + model_type: ModelTypePB::LocalAI, + available: false, + }) + .send(); + } + } +} + +#[async_trait] +impl ChatCloudService for ChatServiceMiddleware { + async fn create_chat( + &self, + uid: &i64, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: serde_json::Value, + ) -> Result<(), FlowyError> { + self + .cloud_service + .create_chat(uid, workspace_id, chat_id, rag_ids, name, metadata) + .await + } + + async fn create_question( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + message_type: ChatMessageType, + ) -> Result { + self + .cloud_service + .create_question(workspace_id, chat_id, message, message_type) + .await + } + + async fn create_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + question_id: i64, + metadata: Option, + ) -> Result { + self + .cloud_service + .create_answer(workspace_id, chat_id, message, question_id, metadata) + .await + } + + async fn stream_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + format: ResponseFormat, + ai_model: Option, + ) -> Result { + let use_local_ai = match &ai_model { + None => false, + Some(model) => model.is_local, + }; + + info!("stream_answer use model: {:?}", ai_model); + if use_local_ai { + if self.local_ai.is_running() { + let content = self.get_message_content(question_id)?; + match self + .local_ai + .stream_question( + &chat_id.to_string(), + &content, + Some(json!(format)), + json!({}), + ) + .await + { + Ok(stream) => Ok(QuestionStream::new(stream).boxed()), + Err(err) => { + self.handle_plugin_error(err); + Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()) + }, + } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) + } + } else { + self + .cloud_service + .stream_answer(workspace_id, chat_id, question_id, format, ai_model) + .await + } + } + + async fn get_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + ) -> Result { + if self.local_ai.is_running() { + let content = self.get_message_content(question_id)?; + match self + .local_ai + .ask_question(&chat_id.to_string(), &content) + .await + { + Ok(answer) => { + let message = self + .cloud_service + .create_answer(workspace_id, chat_id, &answer, question_id, None) + .await?; + Ok(message) + }, + Err(err) => { + self.handle_plugin_error(err); + Err(FlowyError::local_ai_unavailable()) + }, + } + } else { + self + .cloud_service + .get_answer(workspace_id, chat_id, question_id) + .await + } + } + + async fn get_chat_messages( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + offset: MessageCursor, + limit: u64, + ) -> Result { + self + .cloud_service + .get_chat_messages(workspace_id, chat_id, offset, limit) + .await + } + + async fn get_question_from_answer_id( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, + ) -> Result { + self + .cloud_service + .get_question_from_answer_id(workspace_id, chat_id, answer_message_id) + .await + } + + async fn get_related_message( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + ai_model: Option, + ) -> Result { + let use_local_ai = match &ai_model { + None => false, + Some(model) => model.is_local, + }; + + if use_local_ai { + if self.local_ai.is_running() { + let questions = self + .local_ai + .get_related_question(&chat_id.to_string()) + .await + .map_err(|err| FlowyError::local_ai().with_context(err))?; + trace!("LocalAI related questions: {:?}", questions); + + let items = questions + .into_iter() + .map(|content| RelatedQuestion { + content, + metadata: None, + }) + .collect::>(); + + Ok(RepeatedRelatedQuestion { message_id, items }) + } else { + Ok(RepeatedRelatedQuestion { + message_id, + items: vec![], + }) + } + } else { + self + .cloud_service + .get_related_message(workspace_id, chat_id, message_id, ai_model) + .await + } + } + + async fn stream_complete( + &self, + workspace_id: &Uuid, + params: CompleteTextParams, + ai_model: Option, + ) -> Result { + let use_local_ai = match &ai_model { + None => false, + Some(model) => model.is_local, + }; + + info!("stream_complete use custom model: {:?}", ai_model); + if use_local_ai { + if self.local_ai.is_running() { + match self + .local_ai + .complete_text_v2( + ¶ms.text, + params.completion_type.unwrap() as u8, + Some(json!(params.format)), + Some(json!(params.metadata)), + ) + .await + { + Ok(stream) => Ok( + CompletionStream::new( + stream.map_err(|err| AppResponseError::new(AppErrorCode::Internal, err.to_string())), + ) + .map_err(FlowyError::from) + .boxed(), + ), + Err(err) => { + self.handle_plugin_error(err); + Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()) + }, + } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) + } + } else { + self + .cloud_service + .stream_complete(workspace_id, params, ai_model) + .await + } + } + + async fn embed_file( + &self, + workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, + ) -> Result<(), FlowyError> { + if self.local_ai.is_running() { + self + .local_ai + .embed_file(&chat_id.to_string(), file_path.to_path_buf(), metadata) + .await + .map_err(|err| FlowyError::local_ai().with_context(err))?; + Ok(()) + } else { + self + .cloud_service + .embed_file(workspace_id, file_path, chat_id, metadata) + .await + } + } + + async fn get_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + ) -> Result { + self + .cloud_service + .get_chat_settings(workspace_id, chat_id) + .await + } + + async fn update_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + params: UpdateChatParams, + ) -> Result<(), FlowyError> { + self + .cloud_service + .update_chat_settings(workspace_id, chat_id, params) + .await + } + + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { + self.cloud_service.get_available_models(workspace_id).await + } + + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { + self + .cloud_service + .get_workspace_default_model(workspace_id) + .await + } +} diff --git a/frontend/rust-lib/flowy-ai/src/middleware/mod.rs b/frontend/rust-lib/flowy-ai/src/middleware/mod.rs new file mode 100644 index 0000000000..e1c0f454da --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/middleware/mod.rs @@ -0,0 +1 @@ +pub(crate) mod chat_service_mw; diff --git a/frontend/rust-lib/flowy-ai/src/notification.rs b/frontend/rust-lib/flowy-ai/src/notification.rs new file mode 100644 index 0000000000..6fbf3a8e7a --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/notification.rs @@ -0,0 +1,51 @@ +use flowy_derive::ProtoBuf_Enum; +use flowy_notification::NotificationBuilder; +use tracing::trace; + +const CHAT_OBSERVABLE_SOURCE: &str = "Chat"; +pub const APPFLOWY_AI_NOTIFICATION_KEY: &str = "appflowy_ai_plugin"; +#[derive(ProtoBuf_Enum, Debug, Default)] +pub enum ChatNotification { + #[default] + Unknown = 0, + DidLoadLatestChatMessage = 1, + DidLoadPrevChatMessage = 2, + DidReceiveChatMessage = 3, + StreamChatMessageError = 4, + FinishStreaming = 5, + UpdateLocalAIState = 6, + DidUpdateChatSettings = 7, + LocalAIResourceUpdated = 8, + DidUpdateSelectedModel = 9, +} + +impl std::convert::From for i32 { + fn from(notification: ChatNotification) -> Self { + notification as i32 + } +} +impl std::convert::From for ChatNotification { + fn from(notification: i32) -> Self { + match notification { + 1 => ChatNotification::DidLoadLatestChatMessage, + 2 => ChatNotification::DidLoadPrevChatMessage, + 3 => ChatNotification::DidReceiveChatMessage, + 4 => ChatNotification::StreamChatMessageError, + 5 => ChatNotification::FinishStreaming, + 6 => ChatNotification::UpdateLocalAIState, + 7 => ChatNotification::DidUpdateChatSettings, + 8 => ChatNotification::LocalAIResourceUpdated, + _ => ChatNotification::Unknown, + } + } +} + +#[tracing::instrument(level = "trace", skip_all)] +pub(crate) fn chat_notification_builder( + id: T, + ty: ChatNotification, +) -> NotificationBuilder { + let id = id.to_string(); + trace!("chat_notification_builder: id = {id}, ty = {ty:?}"); + NotificationBuilder::new(&id, ty, CHAT_OBSERVABLE_SOURCE) +} diff --git a/frontend/rust-lib/flowy-ai/src/offline/mod.rs b/frontend/rust-lib/flowy-ai/src/offline/mod.rs new file mode 100644 index 0000000000..e55b43fdb2 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/offline/mod.rs @@ -0,0 +1 @@ +pub mod offline_message_sync; diff --git a/frontend/rust-lib/flowy-ai/src/offline/offline_message_sync.rs b/frontend/rust-lib/flowy-ai/src/offline/offline_message_sync.rs new file mode 100644 index 0000000000..8d7e8d2e42 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/offline/offline_message_sync.rs @@ -0,0 +1,258 @@ +use crate::ai_manager::AIUserService; +use flowy_ai_pub::cloud::{ + AIModel, ChatCloudService, ChatMessage, ChatMessageType, ChatSettings, CompleteTextParams, + MessageCursor, ModelList, RepeatedChatMessage, RepeatedRelatedQuestion, ResponseFormat, + StreamAnswer, StreamComplete, UpdateChatParams, +}; +use flowy_ai_pub::persistence::{ + update_chat_is_sync, update_chat_message_is_sync, upsert_chat, upsert_chat_messages, + ChatMessageTable, ChatTable, +}; +use flowy_error::FlowyError; +use lib_infra::async_trait::async_trait; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use uuid::Uuid; + +pub struct AutoSyncChatService { + cloud_service: Arc, + user_service: Arc, +} + +impl AutoSyncChatService { + pub fn new( + cloud_service: Arc, + user_service: Arc, + ) -> Self { + Self { + cloud_service, + user_service, + } + } + + async fn upsert_message( + &self, + chat_id: &Uuid, + message: ChatMessage, + is_sync: bool, + ) -> Result<(), FlowyError> { + let uid = self.user_service.user_id()?; + let conn = self.user_service.sqlite_connection(uid)?; + let row = ChatMessageTable::from_message(chat_id.to_string(), message, is_sync); + upsert_chat_messages(conn, &[row])?; + Ok(()) + } + + #[allow(dead_code)] + async fn update_message_is_sync( + &self, + chat_id: &Uuid, + message_id: i64, + ) -> Result<(), FlowyError> { + let uid = self.user_service.user_id()?; + let conn = self.user_service.sqlite_connection(uid)?; + update_chat_message_is_sync(conn, &chat_id.to_string(), message_id, true)?; + Ok(()) + } +} + +#[async_trait] +impl ChatCloudService for AutoSyncChatService { + async fn create_chat( + &self, + uid: &i64, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: Value, + ) -> Result<(), FlowyError> { + let conn = self.user_service.sqlite_connection(*uid)?; + let chat = ChatTable::new( + chat_id.to_string(), + metadata.clone(), + rag_ids.clone(), + false, + ); + upsert_chat(conn, &chat)?; + + if self + .cloud_service + .create_chat(uid, workspace_id, chat_id, rag_ids, name, metadata) + .await + .is_ok() + { + let conn = self.user_service.sqlite_connection(*uid)?; + update_chat_is_sync(conn, &chat_id.to_string(), true)?; + } + Ok(()) + } + + async fn create_question( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + message_type: ChatMessageType, + ) -> Result { + let message = self + .cloud_service + .create_question(workspace_id, chat_id, message, message_type) + .await?; + self.upsert_message(chat_id, message.clone(), true).await?; + // TODO: implement background sync + // self + // .update_message_is_sync(chat_id, message.message_id) + // .await?; + Ok(message) + } + + async fn create_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + question_id: i64, + metadata: Option, + ) -> Result { + let message = self + .cloud_service + .create_answer(workspace_id, chat_id, message, question_id, metadata) + .await?; + + // TODO: implement background sync + self.upsert_message(chat_id, message.clone(), true).await?; + Ok(message) + } + + async fn stream_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + format: ResponseFormat, + ai_model: Option, + ) -> Result { + self + .cloud_service + .stream_answer(workspace_id, chat_id, question_id, format, ai_model) + .await + } + + async fn get_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + ) -> Result { + let message = self + .cloud_service + .get_answer(workspace_id, chat_id, question_id) + .await?; + + // TODO: implement background sync + self.upsert_message(chat_id, message.clone(), true).await?; + Ok(message) + } + + async fn get_chat_messages( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + offset: MessageCursor, + limit: u64, + ) -> Result { + self + .cloud_service + .get_chat_messages(workspace_id, chat_id, offset, limit) + .await + } + + async fn get_question_from_answer_id( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, + ) -> Result { + self + .cloud_service + .get_question_from_answer_id(workspace_id, chat_id, answer_message_id) + .await + } + + async fn get_related_message( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + ai_model: Option, + ) -> Result { + self + .cloud_service + .get_related_message(workspace_id, chat_id, message_id, ai_model) + .await + } + + async fn stream_complete( + &self, + workspace_id: &Uuid, + params: CompleteTextParams, + ai_model: Option, + ) -> Result { + self + .cloud_service + .stream_complete(workspace_id, params, ai_model) + .await + } + + async fn embed_file( + &self, + workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, + ) -> Result<(), FlowyError> { + self + .cloud_service + .embed_file(workspace_id, file_path, chat_id, metadata) + .await + } + + async fn get_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + ) -> Result { + // TODO: implement background sync + self + .cloud_service + .get_chat_settings(workspace_id, chat_id) + .await + } + + async fn update_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + params: UpdateChatParams, + ) -> Result<(), FlowyError> { + // TODO: implement background sync + self + .cloud_service + .update_chat_settings(workspace_id, chat_id, params) + .await + } + + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { + self.cloud_service.get_available_models(workspace_id).await + } + + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { + self + .cloud_service + .get_workspace_default_model(workspace_id) + .await + } +} diff --git a/frontend/rust-lib/flowy-ai/src/stream_message.rs b/frontend/rust-lib/flowy-ai/src/stream_message.rs new file mode 100644 index 0000000000..3f7b37bd34 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/stream_message.rs @@ -0,0 +1,42 @@ +use std::fmt::Display; + +#[allow(dead_code)] +pub enum StreamMessage { + MessageId(i64), + IndexStart, + IndexEnd, + Text(String), + OnData(String), + OnError(String), + Metadata(String), + Done, + StartIndexFile { file_name: String }, + EndIndexFile { file_name: String }, + IndexFileError { file_name: String }, +} + +impl Display for StreamMessage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StreamMessage::MessageId(message_id) => write!(f, "message_id:{}", message_id), + StreamMessage::IndexStart => write!(f, "index_start:"), + StreamMessage::IndexEnd => write!(f, "index_end"), + StreamMessage::Text(text) => { + write!(f, "data:{}", text) + }, + StreamMessage::OnData(message) => write!(f, "data:{message}"), + StreamMessage::OnError(message) => write!(f, "error:{message}"), + StreamMessage::Done => write!(f, "done:"), + StreamMessage::Metadata(s) => write!(f, "metadata:{s}"), + StreamMessage::StartIndexFile { file_name } => { + write!(f, "start_index_file:{}", file_name) + }, + StreamMessage::EndIndexFile { file_name } => { + write!(f, "end_index_file:{}", file_name) + }, + StreamMessage::IndexFileError { file_name } => { + write!(f, "index_file_error:{}", file_name) + }, + } + } +} diff --git a/frontend/rust-lib/flowy-ai/src/util.rs b/frontend/rust-lib/flowy-ai/src/util.rs new file mode 100644 index 0000000000..a181d1b1d3 --- /dev/null +++ b/frontend/rust-lib/flowy-ai/src/util.rs @@ -0,0 +1,3 @@ +pub fn ai_available_models_key(object_id: &str) -> String { + format!("ai_models_{}", object_id) +} diff --git a/frontend/rust-lib/flowy-config/Cargo.toml b/frontend/rust-lib/flowy-config/Cargo.toml deleted file mode 100644 index 821fcda2fc..0000000000 --- a/frontend/rust-lib/flowy-config/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "flowy-config" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -# workspace -flowy-sqlite = { workspace = true } -lib-dispatch = { workspace = true } -flowy-error = { workspace = true } - -flowy-derive.workspace = true -protobuf.workspace = true -bytes.workspace = true -strum_macros = "0.21" - -[build-dependencies] -flowy-codegen.workspace = true - -[features] -dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] diff --git a/frontend/rust-lib/flowy-config/Flowy.toml b/frontend/rust-lib/flowy-config/Flowy.toml deleted file mode 100644 index 0dbe74b3e3..0000000000 --- a/frontend/rust-lib/flowy-config/Flowy.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Check out the FlowyConfig (located in flowy_toml.rs) for more details. -proto_input = ["src/event_map.rs", "src/entities.rs"] -event_files = ["src/event_map.rs"] \ No newline at end of file diff --git a/frontend/rust-lib/flowy-config/build.rs b/frontend/rust-lib/flowy-config/build.rs deleted file mode 100644 index e015eb2580..0000000000 --- a/frontend/rust-lib/flowy-config/build.rs +++ /dev/null @@ -1,23 +0,0 @@ -fn main() { - #[cfg(feature = "dart")] - { - flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); - flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); - } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } -} diff --git a/frontend/rust-lib/flowy-config/src/entities.rs b/frontend/rust-lib/flowy-config/src/entities.rs deleted file mode 100644 index 931724d542..0000000000 --- a/frontend/rust-lib/flowy-config/src/entities.rs +++ /dev/null @@ -1,16 +0,0 @@ -use flowy_derive::ProtoBuf; - -#[derive(Default, ProtoBuf)] -pub struct KeyValuePB { - #[pb(index = 1)] - pub key: String, - - #[pb(index = 2, one_of)] - pub value: Option, -} - -#[derive(Default, ProtoBuf)] -pub struct KeyPB { - #[pb(index = 1)] - pub key: String, -} diff --git a/frontend/rust-lib/flowy-config/src/event_handler.rs b/frontend/rust-lib/flowy-config/src/event_handler.rs deleted file mode 100644 index 46cd1262c3..0000000000 --- a/frontend/rust-lib/flowy-config/src/event_handler.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::sync::Weak; - -use flowy_error::{FlowyError, FlowyResult}; -use flowy_sqlite::kv::StorePreferences; -use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; - -use crate::entities::{KeyPB, KeyValuePB}; - -pub(crate) async fn set_key_value_handler( - store_preferences: AFPluginState>, - data: AFPluginData, -) -> FlowyResult<()> { - let data = data.into_inner(); - - if let Some(store_preferences) = store_preferences.upgrade() { - match data.value { - None => store_preferences.remove(&data.key), - Some(value) => { - store_preferences.set_str(&data.key, value); - }, - } - } - - Ok(()) -} - -pub(crate) async fn get_key_value_handler( - store_preferences: AFPluginState>, - data: AFPluginData, -) -> DataResult { - match store_preferences.upgrade() { - None => Err(FlowyError::internal().with_context("The store preferences is already drop"))?, - Some(store_preferences) => { - let data = data.into_inner(); - let value = store_preferences.get_str(&data.key); - data_result_ok(KeyValuePB { - key: data.key, - value, - }) - }, - } -} - -pub(crate) async fn remove_key_value_handler( - store_preferences: AFPluginState>, - data: AFPluginData, -) -> FlowyResult<()> { - match store_preferences.upgrade() { - None => Err(FlowyError::internal().with_context("The store preferences is already drop"))?, - Some(store_preferences) => { - let data = data.into_inner(); - store_preferences.remove(&data.key); - Ok(()) - }, - } -} diff --git a/frontend/rust-lib/flowy-config/src/event_map.rs b/frontend/rust-lib/flowy-config/src/event_map.rs deleted file mode 100644 index 68c6ceb454..0000000000 --- a/frontend/rust-lib/flowy-config/src/event_map.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::sync::Weak; - -use strum_macros::Display; - -use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; -use flowy_sqlite::kv::StorePreferences; -use lib_dispatch::prelude::AFPlugin; - -use crate::event_handler::*; - -pub fn init(store_preferences: Weak) -> AFPlugin { - AFPlugin::new() - .name(env!("CARGO_PKG_NAME")) - .state(store_preferences) - .event(ConfigEvent::SetKeyValue, set_key_value_handler) - .event(ConfigEvent::GetKeyValue, get_key_value_handler) - .event(ConfigEvent::RemoveKeyValue, remove_key_value_handler) -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Display, ProtoBuf_Enum, Flowy_Event)] -#[event_err = "FlowyError"] -pub enum ConfigEvent { - #[event(input = "KeyValuePB")] - SetKeyValue = 0, - - #[event(input = "KeyPB", output = "KeyValuePB")] - GetKeyValue = 1, - - #[event(input = "KeyPB")] - RemoveKeyValue = 2, -} diff --git a/frontend/rust-lib/flowy-config/src/lib.rs b/frontend/rust-lib/flowy-config/src/lib.rs deleted file mode 100644 index e08a6c9ce6..0000000000 --- a/frontend/rust-lib/flowy-config/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod entities; -mod event_handler; -pub mod event_map; -mod protobuf; diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index 289b4751b0..b4e7bd5fec 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -20,17 +20,26 @@ flowy-document-pub = { workspace = true } flowy-error = { workspace = true } flowy-server = { workspace = true, features = ["enable_supabase"] } flowy-server-pub = { workspace = true } -flowy-config = { workspace = true } flowy-date = { workspace = true } collab-integrate = { workspace = true } flowy-search = { workspace = true } +flowy-search-pub = { workspace = true } collab-entity = { workspace = true } collab-plugins = { workspace = true } +collab-folder = { workspace = true } + collab = { workspace = true } +#collab = { workspace = true, features = ["verbose_log"] } + diesel.workspace = true -uuid.workspace = true flowy-storage = { workspace = true } +flowy-storage-pub = { workspace = true } client-api.workspace = true +flowy-ai = { workspace = true } +flowy-ai-pub = { workspace = true } +af-local-ai = { workspace = true } +af-plugin = { workspace = true } + tracing.workspace = true futures-core = { version = "0.3", default-features = false } @@ -38,41 +47,38 @@ bytes.workspace = true tokio = { workspace = true, features = ["full"] } tokio-stream = { workspace = true, features = ["sync"] } console-subscriber = { version = "0.2", optional = true } -parking_lot.workspace = true anyhow.workspace = true +dashmap.workspace = true +arc-swap.workspace = true base64 = "0.21.5" lib-infra = { workspace = true } serde.workspace = true serde_json.workspace = true serde_repr.workspace = true -futures.workspace = true -walkdir = "2.4.0" +uuid.workspace = true sysinfo = "0.30.5" -semver = "1.0.22" +semver = { version = "1.0.22", features = ["serde"] } +url = "2.5.0" [features] profiling = ["console-subscriber", "tokio/tracing"] http_sync = [] native_sync = [] dart = [ - "flowy-user/dart", - "flowy-date/dart", - "flowy-search/dart", - "flowy-folder/dart", - "flowy-database2/dart", -] -ts = [ - "flowy-user/tauri_ts", - "flowy-folder/tauri_ts", - "flowy-search/tauri_ts", - "flowy-database2/ts", - "flowy-config/tauri_ts", + "flowy-user/dart", + "flowy-date/dart", + "flowy-search/dart", + "flowy-folder/dart", + "flowy-database2/dart", + "flowy-ai/dart", + "flowy-storage/dart", ] openssl_vendored = ["flowy-sqlite/openssl_vendored"] # Enable/Disable AppFlowy Verbose Log Configuration verbose_log = [ - "flowy-document/verbose_log", - "client-api/sync_verbose_log" + "flowy-document/verbose_log", + "flowy-database2/verbose_log", + "client-api/sync_verbose_log" ] diff --git a/frontend/rust-lib/flowy-core/assets/read_me.json b/frontend/rust-lib/flowy-core/assets/read_me.json index 22924bc42b..57f6b8a19b 100644 --- a/frontend/rust-lib/flowy-core/assets/read_me.json +++ b/frontend/rust-lib/flowy-core/assets/read_me.json @@ -1,10 +1,5 @@ { "type": "page", - "data": { - "delta": [ - {"insert": ""} - ] - }, "children": [ { "type": "heading", diff --git a/frontend/rust-lib/flowy-core/src/config.rs b/frontend/rust-lib/flowy-core/src/config.rs index 53919ad9b0..2bad578627 100644 --- a/frontend/rust-lib/flowy-core/src/config.rs +++ b/frontend/rust-lib/flowy-core/src/config.rs @@ -1,21 +1,21 @@ use std::fmt; -use std::path::Path; +use std::path::{Path, PathBuf}; use base64::Engine; +use semver::Version; use tracing::{error, info}; +use url::Url; +use crate::log_filter::create_log_filter; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; -use flowy_server_pub::supabase_config::SupabaseConfiguration; use flowy_user::services::entities::URL_SAFE_ENGINE; use lib_infra::file_util::copy_dir_recursive; -use lib_infra::util::Platform; - -use crate::integrate::log::create_log_filter; +use lib_infra::util::OperatingSystem; #[derive(Clone)] pub struct AppFlowyCoreConfig { /// Different `AppFlowyCoreConfig` instance should have different name - pub(crate) app_version: String, + pub(crate) app_version: Version, pub name: String, pub(crate) device_id: String, pub platform: String, @@ -27,9 +27,27 @@ pub struct AppFlowyCoreConfig { /// the origin_application_path. pub application_path: String, pub(crate) log_filter: String, - cloud_config: Option, + pub cloud_config: Option, } +impl AppFlowyCoreConfig { + pub fn ensure_path(&self) { + let create_if_needed = |path_str: &str, label: &str| { + let dir = std::path::Path::new(path_str); + if !dir.exists() { + match std::fs::create_dir_all(dir) { + Ok(_) => info!("Created {} path: {}", label, path_str), + Err(err) => error!( + "Failed to create {} path: {}. Error: {}", + label, path_str, err + ), + } + } + }; + create_if_needed(&self.storage_path, "storage"); + create_if_needed(&self.application_path, "application"); + } +} impl fmt::Debug for AppFlowyCoreConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut debug = f.debug_struct("AppFlowy Configuration"); @@ -40,42 +58,73 @@ impl fmt::Debug for AppFlowyCoreConfig { debug.field("base_url", &config.base_url); debug.field("ws_url", &config.ws_base_url); debug.field("gotrue_url", &config.gotrue_url); + debug.field("enable_sync_trace", &config.enable_sync_trace); } debug.finish() } } fn make_user_data_folder(root: &str, url: &str) -> String { - // Isolate the user data folder by using the base url of AppFlowy cloud. This is to avoid - // the user data folder being shared by different AppFlowy cloud. - let storage_path = if !url.is_empty() { - let server_base64 = URL_SAFE_ENGINE.encode(url); - format!("{}_{}", root, server_base64) + // If a URL is provided, try to parse it and extract the domain name. + // This isolates the user data folder by the domain, which prevents data sharing + // between different AppFlowy cloud instances. + print!("Creating user data folder for URL: {}, root:{}", url, root); + let mut storage_path = if url.is_empty() { + PathBuf::from(root) } else { - root.to_string() + let server_base64 = URL_SAFE_ENGINE.encode(url); + PathBuf::from(format!("{}_{}", root, server_base64)) }; + // Only use new storage path if the old one doesn't exist + if !storage_path.exists() { + let anon_path = format!("{}_anonymous", root); + // We use domain name as suffix to isolate the user data folder since version 0.8.9 + let new_storage_path = if url.is_empty() { + // if the url is empty, then it's anonymous mode + anon_path + } else { + match Url::parse(url) { + Ok(parsed_url) => { + if let Some(domain) = parsed_url.host_str() { + format!("{}_{}", root, domain) + } else { + anon_path + } + }, + Err(_) => anon_path, + } + }; + + storage_path = PathBuf::from(new_storage_path); + } + // Copy the user data folder from the root path to the isolated path // The root path without any suffix is the created by the local version AppFlowy - if !Path::new(&storage_path).exists() && Path::new(root).exists() { - info!("Copy dir from {} to {}", root, storage_path); + if !storage_path.exists() && Path::new(root).exists() { + info!("Copy dir from {} to {:?}", root, storage_path); let src = Path::new(root); - match copy_dir_recursive(src, Path::new(&storage_path)) { - Ok(_) => storage_path, + match copy_dir_recursive(src, &storage_path) { + Ok(_) => storage_path + .into_os_string() + .into_string() + .unwrap_or_else(|_| root.to_string()), Err(err) => { - // when the copy dir failed, use the root path as the storage path error!("Copy dir failed: {}", err); root.to_string() }, } } else { storage_path + .into_os_string() + .into_string() + .unwrap_or_else(|_| root.to_string()) } } impl AppFlowyCoreConfig { pub fn new( - app_version: String, + app_version: Version, custom_application_path: String, application_path: String, device_id: String, @@ -83,17 +132,18 @@ impl AppFlowyCoreConfig { name: String, ) -> Self { let cloud_config = AFCloudConfiguration::from_env().ok(); + // By default enable sync trace log + let log_crates = vec!["sync_trace_log".to_string()]; let storage_path = match &cloud_config { - None => { - let supabase_config = SupabaseConfiguration::from_env().ok(); - match &supabase_config { - None => custom_application_path, - Some(config) => make_user_data_folder(&custom_application_path, &config.url), - } - }, + None => custom_application_path, Some(config) => make_user_data_folder(&custom_application_path, &config.base_url), }; - let log_filter = create_log_filter("info".to_owned(), vec![], Platform::from(&platform)); + + let log_filter = create_log_filter( + "info".to_owned(), + log_crates, + OperatingSystem::from(&platform), + ); AppFlowyCoreConfig { app_version, @@ -111,7 +161,7 @@ impl AppFlowyCoreConfig { self.log_filter = create_log_filter( level.to_owned(), with_crates, - Platform::from(&self.platform), + OperatingSystem::from(&self.platform), ); self } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs new file mode 100644 index 0000000000..c8c93a7f4c --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs @@ -0,0 +1,190 @@ +use collab::core::collab::DataSource; +use collab::core::origin::CollabOrigin; +use collab::preclude::updates::decoder::Decode; +use collab::preclude::{Collab, StateVector}; +use collab::util::is_change_since_sv; +use collab_entity::CollabType; +use collab_integrate::persistence::collab_metadata_sql::AFCollabMetadata; +use flowy_ai::ai_manager::{AIExternalService, AIManager, AIUserService}; +use flowy_ai::local_ai::controller::LocalAIController; +use flowy_ai_pub::cloud::ChatCloudService; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_folder::ViewLayout; +use flowy_folder_pub::cloud::{FolderCloudService, FullSyncCollabParams}; +use flowy_folder_pub::query::FolderService; +use flowy_sqlite::kv::KVStorePreferences; +use flowy_sqlite::DBConnection; +use flowy_storage_pub::storage::StorageService; +use flowy_user::services::authenticate_user::AuthenticateUser; +use lib_infra::async_trait::async_trait; +use lib_infra::util::timestamp; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; +use tracing::{error, info}; +use uuid::Uuid; + +pub struct ChatDepsResolver; + +impl ChatDepsResolver { + pub fn resolve( + authenticate_user: Weak, + cloud_service: Arc, + store_preferences: Arc, + storage_service: Weak, + folder_cloud_service: Arc, + folder_service: impl FolderService, + local_ai: Arc, + ) -> Arc { + let user_service = ChatUserServiceImpl(authenticate_user); + Arc::new(AIManager::new( + cloud_service, + user_service, + store_preferences, + storage_service, + ChatQueryServiceImpl { + folder_service: Box::new(folder_service), + folder_cloud_service, + }, + local_ai, + )) + } +} + +struct ChatQueryServiceImpl { + folder_service: Box, + folder_cloud_service: Arc, +} + +#[async_trait] +impl AIExternalService for ChatQueryServiceImpl { + async fn query_chat_rag_ids( + &self, + parent_view_id: &Uuid, + chat_id: &Uuid, + ) -> Result, FlowyError> { + let mut ids = self + .folder_service + .get_surrounding_view_ids_with_view_layout(parent_view_id, ViewLayout::Document) + .await; + + if !ids.is_empty() { + ids.retain(|id| id != chat_id); + } + + Ok(ids) + } + async fn sync_rag_documents( + &self, + workspace_id: &Uuid, + rag_ids: Vec, + mut rag_metadata_map: HashMap, + ) -> Result, FlowyError> { + let mut result = Vec::new(); + + for rag_id in rag_ids { + // Retrieve the collab object for the current rag_id + let query_collab = match self + .folder_service + .get_collab(&rag_id, CollabType::Document) + .await + { + Some(collab) => collab, + None => { + continue; + }, + }; + + // Check if the state vector exists and detect changes + if let Some(metadata) = rag_metadata_map.remove(&rag_id) { + if let Ok(prev_sv) = StateVector::decode_v1(&metadata.prev_sync_state_vector) { + let collab = Collab::new_with_source( + CollabOrigin::Empty, + &rag_id.to_string(), + DataSource::DocStateV1(query_collab.encoded_collab.doc_state.to_vec()), + vec![], + false, + )?; + + if !is_change_since_sv(&collab, &prev_sv) { + info!("[Chat] no change since sv: {}", rag_id); + continue; + } + } + } + + // Perform full sync if changes are detected or no state vector is found + let params = FullSyncCollabParams { + object_id: rag_id, + collab_type: CollabType::Document, + encoded_collab: query_collab.encoded_collab.clone(), + }; + + if let Err(err) = self + .folder_cloud_service + .full_sync_collab_object(workspace_id, params) + .await + { + error!("Failed to sync rag document: {} error: {}", rag_id, err); + } else { + info!("[Chat] full sync rag document: {}", rag_id); + result.push(AFCollabMetadata { + object_id: rag_id.to_string(), + updated_at: timestamp(), + prev_sync_state_vector: query_collab.encoded_collab.state_vector.to_vec(), + collab_type: CollabType::Document as i32, + }); + } + } + + Ok(result) + } + + async fn notify_did_send_message(&self, chat_id: &Uuid, message: &str) -> Result<(), FlowyError> { + info!( + "notify_did_send_message: chat_id: {}, message: {}", + chat_id, message + ); + self + .folder_service + .set_view_title_if_empty(chat_id, message) + .await?; + Ok(()) + } +} + +struct ChatUserServiceImpl(Weak); +impl ChatUserServiceImpl { + fn upgrade_user(&self) -> Result, FlowyError> { + let user = self + .0 + .upgrade() + .ok_or(FlowyError::internal().with_context("Unexpected error: UserSession is None"))?; + Ok(user) + } +} + +#[async_trait] +impl AIUserService for ChatUserServiceImpl { + fn user_id(&self) -> Result { + self.upgrade_user()?.user_id() + } + + async fn is_local_model(&self) -> FlowyResult { + self.upgrade_user()?.is_local_mode().await + } + + fn workspace_id(&self) -> Result { + self.upgrade_user()?.workspace_id() + } + + fn sqlite_connection(&self, uid: i64) -> Result { + self.upgrade_user()?.get_sqlite_connection(uid) + } + + fn application_root_dir(&self) -> Result { + Ok(PathBuf::from( + self.upgrade_user()?.get_application_root_dir(), + )) + } +} diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs new file mode 100644 index 0000000000..6c9581d4a7 --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs @@ -0,0 +1,848 @@ +use crate::server_layer::ServerProvider; +use client_api::collab_sync::{SinkConfig, SyncObject, SyncPlugin}; +use client_api::entity::ai_dto::RepeatedRelatedQuestion; +use client_api::entity::workspace_dto::PublishInfoView; +use client_api::entity::PublishInfo; +use collab::core::origin::{CollabClient, CollabOrigin}; +use collab::entity::EncodedCollab; +use collab::preclude::CollabPlugin; +use collab_entity::CollabType; +use collab_integrate::collab_builder::{ + CollabCloudPluginProvider, CollabPluginProviderContext, CollabPluginProviderType, +}; +use flowy_ai_pub::cloud::search_dto::{ + SearchDocumentResponseItem, SearchResult, SearchSummaryResult, +}; +use flowy_ai_pub::cloud::{ + AIModel, ChatCloudService, ChatMessage, ChatMessageType, ChatSettings, CompleteTextParams, + MessageCursor, ModelList, RepeatedChatMessage, ResponseFormat, StreamAnswer, StreamComplete, + UpdateChatParams, +}; +use flowy_database_pub::cloud::{ + DatabaseAIService, DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid, SummaryRowContent, + TranslateRowContent, TranslateRowResponse, +}; +use flowy_document::deps::DocumentData; +use flowy_document_pub::cloud::{DocumentCloudService, DocumentSnapshot}; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_folder_pub::cloud::{ + FolderCloudService, FolderCollabParams, FolderSnapshot, FullSyncCollabParams, +}; +use flowy_folder_pub::entities::PublishPayload; +use flowy_search_pub::cloud::SearchCloudService; +use flowy_server_pub::af_cloud_config::AFCloudConfiguration; +use flowy_storage_pub::cloud::{ObjectIdentity, ObjectValue, StorageCloudService}; +use flowy_storage_pub::storage::{CompletedPartRequest, CreateUploadResponse, UploadPartResponse}; +use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; +use flowy_user_pub::entities::{AuthType, UserTokenState}; +use lib_infra::async_trait::async_trait; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; +use std::str::FromStr; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; +use tokio_stream::wrappers::WatchStream; +use tracing::log::error; +use tracing::{debug, info}; +use uuid::Uuid; + +#[async_trait] +impl StorageCloudService for ServerProvider { + async fn get_object_url(&self, object_id: ObjectIdentity) -> Result { + let storage = self + .get_server()? + .file_storage() + .ok_or(FlowyError::internal())?; + storage.get_object_url(object_id).await + } + + async fn put_object(&self, url: String, val: ObjectValue) -> Result<(), FlowyError> { + let storage = self + .get_server()? + .file_storage() + .ok_or(FlowyError::internal())?; + storage.put_object(url, val).await + } + + async fn delete_object(&self, url: &str) -> Result<(), FlowyError> { + let storage = self + .get_server()? + .file_storage() + .ok_or(FlowyError::internal())?; + storage.delete_object(url).await + } + + async fn get_object(&self, url: String) -> Result { + let storage = self + .get_server()? + .file_storage() + .ok_or(FlowyError::internal())?; + storage.get_object(url).await + } + + async fn get_object_url_v1( + &self, + workspace_id: &Uuid, + parent_dir: &str, + file_id: &str, + ) -> FlowyResult { + let server = self.get_server()?; + let storage = server.file_storage().ok_or(FlowyError::internal())?; + storage + .get_object_url_v1(workspace_id, parent_dir, file_id) + .await + } + + async fn parse_object_url_v1(&self, url: &str) -> Option<(Uuid, String, String)> { + self + .get_server() + .ok()? + .file_storage()? + .parse_object_url_v1(url) + .await + } + + async fn create_upload( + &self, + workspace_id: &Uuid, + parent_dir: &str, + file_id: &str, + content_type: &str, + file_size: u64, + ) -> Result { + let server = self.get_server()?; + let storage = server.file_storage().ok_or(FlowyError::internal())?; + storage + .create_upload(workspace_id, parent_dir, file_id, content_type, file_size) + .await + } + + async fn upload_part( + &self, + workspace_id: &Uuid, + parent_dir: &str, + upload_id: &str, + file_id: &str, + part_number: i32, + body: Vec, + ) -> Result { + let server = self.get_server(); + let storage = server?.file_storage().ok_or(FlowyError::internal())?; + storage + .upload_part( + workspace_id, + parent_dir, + upload_id, + file_id, + part_number, + body, + ) + .await + } + + async fn complete_upload( + &self, + workspace_id: &Uuid, + parent_dir: &str, + upload_id: &str, + file_id: &str, + parts: Vec, + ) -> Result<(), FlowyError> { + let server = self.get_server(); + let storage = server?.file_storage().ok_or(FlowyError::internal())?; + storage + .complete_upload(workspace_id, parent_dir, upload_id, file_id, parts) + .await + } +} + +impl UserCloudServiceProvider for ServerProvider { + fn set_token(&self, token: &str) -> Result<(), FlowyError> { + let server = self.get_server()?; + server.set_token(token)?; + Ok(()) + } + + fn set_ai_model(&self, ai_model: &str) -> Result<(), FlowyError> { + info!("Set AI model: {}", ai_model); + let server = self.get_server()?; + server.set_ai_model(ai_model)?; + Ok(()) + } + + fn subscribe_token_state(&self) -> Option> { + let server = self.get_server().ok()?; + server.subscribe_token_state() + } + + fn set_enable_sync(&self, uid: i64, enable_sync: bool) { + if let Ok(server) = self.get_server() { + server.set_enable_sync(uid, enable_sync); + self.user_enable_sync.store(enable_sync, Ordering::Release); + self.uid.store(Some(uid.into())); + } + } + + /// When user login, the provider type is set by the [AuthType] and save to disk for next use. + /// + /// Each [AuthType] has a corresponding [AuthType]. The [AuthType] is used + /// to create a new [AppFlowyServer] if it doesn't exist. Once the [AuthType] is set, + /// it will be used when user open the app again. + /// + fn set_server_auth_type(&self, auth_type: &AuthType) { + self.set_auth_type(*auth_type); + } + + fn get_server_auth_type(&self) -> AuthType { + self.get_auth_type() + } + + fn set_network_reachable(&self, reachable: bool) { + if let Ok(server) = self.get_server() { + server.set_network_reachable(reachable); + } + } + + fn set_encrypt_secret(&self, secret: String) { + tracing::info!("🔑Set encrypt secret"); + self.encryption.set_secret(secret); + } + + /// Returns the [UserCloudService] base on the current [AuthType]. + /// Creates a new [AppFlowyServer] if it doesn't exist. + fn get_user_service(&self) -> Result, FlowyError> { + let user_service = self.get_server()?.user_service(); + Ok(user_service) + } + + fn service_url(&self) -> String { + match self.get_auth_type() { + AuthType::Local => "".to_string(), + AuthType::AppFlowyCloud => AFCloudConfiguration::from_env() + .map(|config| config.base_url) + .unwrap_or_default(), + } + } +} + +#[async_trait] +impl FolderCloudService for ServerProvider { + async fn get_folder_snapshots( + &self, + workspace_id: &str, + limit: usize, + ) -> Result, FlowyError> { + self + .get_server()? + .folder_service() + .get_folder_snapshots(workspace_id, limit) + .await + } + + async fn get_folder_doc_state( + &self, + workspace_id: &Uuid, + uid: i64, + collab_type: CollabType, + object_id: &Uuid, + ) -> Result, FlowyError> { + self + .get_server()? + .folder_service() + .get_folder_doc_state(workspace_id, uid, collab_type, object_id) + .await + } + + async fn full_sync_collab_object( + &self, + workspace_id: &Uuid, + params: FullSyncCollabParams, + ) -> Result<(), FlowyError> { + self + .get_server()? + .folder_service() + .full_sync_collab_object(workspace_id, params) + .await + } + + async fn batch_create_folder_collab_objects( + &self, + workspace_id: &Uuid, + objects: Vec, + ) -> Result<(), FlowyError> { + let server = self.get_server()?; + + server + .folder_service() + .batch_create_folder_collab_objects(workspace_id, objects) + .await + } + + fn service_name(&self) -> String { + self + .get_server() + .map(|provider| provider.folder_service().service_name()) + .unwrap_or_default() + } + + async fn publish_view( + &self, + workspace_id: &Uuid, + payload: Vec, + ) -> Result<(), FlowyError> { + self + .get_server()? + .folder_service() + .publish_view(workspace_id, payload) + .await + } + + async fn unpublish_views( + &self, + workspace_id: &Uuid, + view_ids: Vec, + ) -> Result<(), FlowyError> { + self + .get_server()? + .folder_service() + .unpublish_views(workspace_id, view_ids) + .await + } + + async fn get_publish_info(&self, view_id: &Uuid) -> Result { + let server = self.get_server()?; + server.folder_service().get_publish_info(view_id).await + } + + async fn set_publish_name( + &self, + workspace_id: &Uuid, + view_id: Uuid, + new_name: String, + ) -> Result<(), FlowyError> { + self + .get_server()? + .folder_service() + .set_publish_name(workspace_id, view_id, new_name) + .await + } + + async fn set_publish_namespace( + &self, + workspace_id: &Uuid, + new_namespace: String, + ) -> Result<(), FlowyError> { + self + .get_server()? + .folder_service() + .set_publish_namespace(workspace_id, new_namespace) + .await + } + + /// List all published views of the current workspace. + async fn list_published_views( + &self, + workspace_id: &Uuid, + ) -> Result, FlowyError> { + let server = self.get_server()?; + server + .folder_service() + .list_published_views(workspace_id) + .await + } + + async fn get_default_published_view_info( + &self, + workspace_id: &Uuid, + ) -> Result { + let server = self.get_server()?; + server + .folder_service() + .get_default_published_view_info(workspace_id) + .await + } + + async fn set_default_published_view( + &self, + workspace_id: &Uuid, + view_id: uuid::Uuid, + ) -> Result<(), FlowyError> { + let server = self.get_server()?; + server + .folder_service() + .set_default_published_view(workspace_id, view_id) + .await + } + + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { + let server = self.get_server()?; + server + .folder_service() + .remove_default_published_view(workspace_id) + .await + } + + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result { + let server = self.get_server()?; + server + .folder_service() + .get_publish_namespace(workspace_id) + .await + } + + async fn import_zip(&self, file_path: &str) -> Result<(), FlowyError> { + self + .get_server()? + .folder_service() + .import_zip(file_path) + .await + } +} + +#[async_trait] +impl DatabaseCloudService for ServerProvider { + async fn get_database_encode_collab( + &self, + object_id: &Uuid, + collab_type: CollabType, + workspace_id: &Uuid, + ) -> Result, FlowyError> { + let server = self.get_server()?; + server + .database_service() + .get_database_encode_collab(object_id, collab_type, workspace_id) + .await + } + + async fn create_database_encode_collab( + &self, + object_id: &Uuid, + collab_type: CollabType, + workspace_id: &Uuid, + encoded_collab: EncodedCollab, + ) -> Result<(), FlowyError> { + let server = self.get_server()?; + server + .database_service() + .create_database_encode_collab(object_id, collab_type, workspace_id, encoded_collab) + .await + } + + async fn batch_get_database_encode_collab( + &self, + object_ids: Vec, + object_ty: CollabType, + workspace_id: &Uuid, + ) -> Result { + let server = self.get_server()?; + + server + .database_service() + .batch_get_database_encode_collab(object_ids, object_ty, workspace_id) + .await + } + + async fn get_database_collab_object_snapshots( + &self, + object_id: &Uuid, + limit: usize, + ) -> Result, FlowyError> { + let server = self.get_server()?; + + server + .database_service() + .get_database_collab_object_snapshots(object_id, limit) + .await + } +} + +#[async_trait] +impl DatabaseAIService for ServerProvider { + async fn summary_database_row( + &self, + _workspace_id: &Uuid, + _object_id: &Uuid, + _summary_row: SummaryRowContent, + ) -> Result { + self + .get_server()? + .database_ai_service() + .ok_or_else(FlowyError::not_support)? + .summary_database_row(_workspace_id, _object_id, _summary_row) + .await + } + + async fn translate_database_row( + &self, + _workspace_id: &Uuid, + _translate_row: TranslateRowContent, + _language: &str, + ) -> Result { + self + .get_server()? + .database_ai_service() + .ok_or_else(FlowyError::not_support)? + .translate_database_row(_workspace_id, _translate_row, _language) + .await + } +} + +#[async_trait] +impl DocumentCloudService for ServerProvider { + async fn get_document_doc_state( + &self, + document_id: &Uuid, + workspace_id: &Uuid, + ) -> Result, FlowyError> { + let server = self.get_server()?; + server + .document_service() + .get_document_doc_state(document_id, workspace_id) + .await + } + + async fn get_document_snapshots( + &self, + document_id: &Uuid, + limit: usize, + workspace_id: &str, + ) -> Result, FlowyError> { + let server = self.get_server()?; + + server + .document_service() + .get_document_snapshots(document_id, limit, workspace_id) + .await + } + + async fn get_document_data( + &self, + document_id: &Uuid, + workspace_id: &Uuid, + ) -> Result, FlowyError> { + let server = self.get_server()?; + server + .document_service() + .get_document_data(document_id, workspace_id) + .await + } + + async fn create_document_collab( + &self, + workspace_id: &Uuid, + document_id: &Uuid, + encoded_collab: EncodedCollab, + ) -> Result<(), FlowyError> { + let server = self.get_server()?; + server + .document_service() + .create_document_collab(workspace_id, document_id, encoded_collab) + .await + } +} + +impl CollabCloudPluginProvider for ServerProvider { + fn provider_type(&self) -> CollabPluginProviderType { + match self.get_auth_type() { + AuthType::Local => CollabPluginProviderType::Local, + AuthType::AppFlowyCloud => CollabPluginProviderType::AppFlowyCloud, + } + } + + fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { + // If the user is local, we don't need to create a sync plugin. + if self.get_auth_type().is_local() { + debug!( + "User authenticator is local, skip create sync plugin for: {}", + context + ); + return vec![]; + } + + match context { + CollabPluginProviderContext::Local => vec![], + CollabPluginProviderContext::AppFlowyCloud { + uid: _, + collab_object, + local_collab, + } => { + if let Ok(server) = self.get_server() { + // to_fut(async move { + let mut plugins: Vec> = vec![]; + // If the user is local, we don't need to create a sync plugin. + + match server.collab_ws_channel(&collab_object.object_id) { + Ok(Some((channel, ws_connect_state, _is_connected))) => { + let origin = CollabOrigin::Client(CollabClient::new( + collab_object.uid, + collab_object.device_id.clone(), + )); + + if let (Ok(object_id), Ok(workspace_id)) = ( + Uuid::from_str(&collab_object.object_id), + Uuid::from_str(&collab_object.workspace_id), + ) { + let sync_object = SyncObject::new( + object_id, + workspace_id, + collab_object.collab_type, + &collab_object.device_id, + ); + let (sink, stream) = (channel.sink(), channel.stream()); + let sink_config = SinkConfig::new().send_timeout(8); + let sync_plugin = SyncPlugin::new( + origin, + sync_object, + local_collab, + sink, + sink_config, + stream, + Some(channel), + ws_connect_state, + Some(Duration::from_secs(60)), + ); + plugins.push(Box::new(sync_plugin)); + } else { + error!( + "Failed to parse collab object id: {}", + collab_object.object_id + ); + } + }, + Ok(None) => { + tracing::error!("🔴Failed to get collab ws channel: channel is none"); + }, + Err(err) => tracing::error!("🔴Failed to get collab ws channel: {:?}", err), + } + plugins + } else { + vec![] + } + }, + } + } + + fn is_sync_enabled(&self) -> bool { + self.user_enable_sync.load(Ordering::Acquire) + } +} + +#[async_trait] +impl ChatCloudService for ServerProvider { + async fn create_chat( + &self, + uid: &i64, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: serde_json::Value, + ) -> Result<(), FlowyError> { + let server = self.get_server(); + server? + .chat_service() + .create_chat(uid, workspace_id, chat_id, rag_ids, name, metadata) + .await + } + + async fn create_question( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + message_type: ChatMessageType, + ) -> Result { + let message = message.to_string(); + self + .get_server()? + .chat_service() + .create_question(workspace_id, chat_id, &message, message_type) + .await + } + + async fn create_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + question_id: i64, + metadata: Option, + ) -> Result { + let server = self.get_server(); + server? + .chat_service() + .create_answer(workspace_id, chat_id, message, question_id, metadata) + .await + } + + async fn stream_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + format: ResponseFormat, + ai_model: Option, + ) -> Result { + let server = self.get_server()?; + server + .chat_service() + .stream_answer(workspace_id, chat_id, question_id, format, ai_model) + .await + } + + async fn get_chat_messages( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + offset: MessageCursor, + limit: u64, + ) -> Result { + self + .get_server()? + .chat_service() + .get_chat_messages(workspace_id, chat_id, offset, limit) + .await + } + + async fn get_question_from_answer_id( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, + ) -> Result { + self + .get_server()? + .chat_service() + .get_question_from_answer_id(workspace_id, chat_id, answer_message_id) + .await + } + + async fn get_related_message( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + ai_model: Option, + ) -> Result { + self + .get_server()? + .chat_service() + .get_related_message(workspace_id, chat_id, message_id, ai_model) + .await + } + + async fn get_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + ) -> Result { + let server = self.get_server(); + server? + .chat_service() + .get_answer(workspace_id, chat_id, question_id) + .await + } + + async fn stream_complete( + &self, + workspace_id: &Uuid, + params: CompleteTextParams, + ai_model: Option, + ) -> Result { + let server = self.get_server()?; + server + .chat_service() + .stream_complete(workspace_id, params, ai_model) + .await + } + + async fn embed_file( + &self, + workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, + ) -> Result<(), FlowyError> { + self + .get_server()? + .chat_service() + .embed_file(workspace_id, file_path, chat_id, metadata) + .await + } + + async fn get_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + ) -> Result { + self + .get_server()? + .chat_service() + .get_chat_settings(workspace_id, chat_id) + .await + } + + async fn update_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + params: UpdateChatParams, + ) -> Result<(), FlowyError> { + self + .get_server()? + .chat_service() + .update_chat_settings(workspace_id, chat_id, params) + .await + } + + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { + self + .get_server()? + .chat_service() + .get_available_models(workspace_id) + .await + } + + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { + self + .get_server()? + .chat_service() + .get_workspace_default_model(workspace_id) + .await + } +} + +#[async_trait] +impl SearchCloudService for ServerProvider { + async fn document_search( + &self, + workspace_id: &Uuid, + query: String, + ) -> Result, FlowyError> { + let server = self.get_server()?; + match server.search_service() { + Some(search_service) => search_service.document_search(workspace_id, query).await, + None => Err(FlowyError::internal().with_context("SearchCloudService not found")), + } + } + + async fn generate_search_summary( + &self, + workspace_id: &Uuid, + query: String, + search_results: Vec, + ) -> Result { + let server = self.get_server()?; + match server.search_service() { + Some(search_service) => { + search_service + .generate_search_summary(workspace_id, query, search_results) + .await + }, + None => Err(FlowyError::internal().with_context("SearchCloudService not found")), + } + } +} diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs index a8827e06b0..078ee7359b 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs @@ -13,6 +13,7 @@ use collab_integrate::collab_builder::WorkspaceCollabIntegrate; use lib_infra::util::timestamp; use std::sync::{Arc, Weak}; use tracing::debug; +use uuid::Uuid; pub struct SnapshotDBImpl(pub Weak); @@ -24,7 +25,7 @@ impl SnapshotPersistence for SnapshotDBImpl { collab_type: &CollabType, encoded_v1: Vec, ) -> Result<(), PersistenceError> { - let collab_type = collab_type.clone(); + let collab_type = *collab_type; let object_id = object_id.to_string(); let weak_user = self.0.clone(); tokio::task::spawn_blocking(move || { @@ -222,12 +223,12 @@ impl WorkspaceCollabIntegrateImpl { } impl WorkspaceCollabIntegrate for WorkspaceCollabIntegrateImpl { - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { let workspace_id = self.upgrade_user()?.workspace_id()?; Ok(workspace_id) } - fn device_id(&self) -> Result { + fn device_id(&self) -> Result { Ok(self.upgrade_user()?.user_config.device_id.clone()) } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs index 2ef0046dc7..1bd3223946 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs @@ -1,12 +1,20 @@ +use af_local_ai::ai_ops::{LocalAITranslateItem, LocalAITranslateRowData}; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; +use flowy_ai::ai_manager::AIManager; use flowy_database2::{DatabaseManager, DatabaseUser}; -use flowy_database_pub::cloud::DatabaseCloudService; +use flowy_database_pub::cloud::{ + DatabaseAIService, DatabaseCloudService, SummaryRowContent, TranslateRowContent, + TranslateRowResponse, +}; use flowy_error::FlowyError; use flowy_user::services::authenticate_user::AuthenticateUser; +use lib_infra::async_trait::async_trait; use lib_infra::priority_task::TaskDispatcher; use std::sync::{Arc, Weak}; use tokio::sync::RwLock; +use uuid::Uuid; + pub struct DatabaseDepsResolver(); impl DatabaseDepsResolver { @@ -15,6 +23,8 @@ impl DatabaseDepsResolver { task_scheduler: Arc>, collab_builder: Arc, cloud_service: Arc, + ai_service: Arc, + ai_manager: Arc, ) -> Arc { let user = Arc::new(DatabaseUserImpl(authenticate_user)); Arc::new(DatabaseManager::new( @@ -22,10 +32,76 @@ impl DatabaseDepsResolver { task_scheduler, collab_builder, cloud_service, + Arc::new(DatabaseAIServiceMiddleware { + ai_manager, + ai_service, + }), )) } } +struct DatabaseAIServiceMiddleware { + ai_manager: Arc, + ai_service: Arc, +} +#[async_trait] +impl DatabaseAIService for DatabaseAIServiceMiddleware { + async fn summary_database_row( + &self, + workspace_id: &Uuid, + object_id: &Uuid, + _summary_row: SummaryRowContent, + ) -> Result { + if self.ai_manager.local_ai.is_running() { + self + .ai_manager + .local_ai + .summary_database_row(_summary_row) + .await + .map_err(|err| FlowyError::local_ai().with_context(err)) + } else { + self + .ai_service + .summary_database_row(workspace_id, object_id, _summary_row) + .await + } + } + + async fn translate_database_row( + &self, + _workspace_id: &Uuid, + _translate_row: TranslateRowContent, + _language: &str, + ) -> Result { + if self.ai_manager.local_ai.is_running() { + let data = LocalAITranslateRowData { + cells: _translate_row + .into_iter() + .map(|row| LocalAITranslateItem { + title: row.title, + content: row.content, + }) + .collect(), + language: _language.to_string(), + include_header: false, + }; + let resp = self + .ai_manager + .local_ai + .translate_database_row(data) + .await + .map_err(|err| FlowyError::local_ai().with_context(err))?; + + Ok(TranslateRowResponse { items: resp.items }) + } else { + self + .ai_service + .translate_database_row(_workspace_id, _translate_row, _language) + .await + } + } +} + struct DatabaseUserImpl(Weak); impl DatabaseUserImpl { fn upgrade_user(&self) -> Result, FlowyError> { @@ -46,11 +122,11 @@ impl DatabaseUser for DatabaseUserImpl { self.upgrade_user()?.get_collab_db(uid) } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self.upgrade_user()?.workspace_id() } - fn workspace_database_object_id(&self) -> Result { + fn workspace_database_object_id(&self) -> Result { self.upgrade_user()?.workspace_database_object_id() } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs index 1876392eeb..3527bc42d6 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs @@ -1,5 +1,3 @@ -use std::sync::{Arc, Weak}; - use crate::deps_resolve::CollabSnapshotSql; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; @@ -8,8 +6,10 @@ use flowy_document::entities::{DocumentSnapshotData, DocumentSnapshotMeta}; use flowy_document::manager::{DocumentManager, DocumentSnapshotService, DocumentUserService}; use flowy_document_pub::cloud::DocumentCloudService; use flowy_error::{FlowyError, FlowyResult}; -use flowy_storage::ObjectStorageService; +use flowy_storage_pub::storage::StorageService; use flowy_user::services::authenticate_user::AuthenticateUser; +use std::sync::{Arc, Weak}; +use uuid::Uuid; pub struct DocumentDepsResolver(); impl DocumentDepsResolver { @@ -18,7 +18,7 @@ impl DocumentDepsResolver { _database_manager: &Arc, collab_builder: Arc, cloud_service: Arc, - storage_service: Weak, + storage_service: Weak, ) -> Arc { let user_service: Arc = Arc::new(DocumentUserImpl(authenticate_user.clone())); @@ -97,7 +97,7 @@ impl DocumentUserService for DocumentUserImpl { .device_id() } - fn workspace_id(&self) -> Result { + fn workspace_id(&self) -> Result { self .0 .upgrade() diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs new file mode 100644 index 0000000000..bee5f19ced --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs @@ -0,0 +1,55 @@ +use flowy_error::FlowyError; +use flowy_sqlite::DBConnection; +use flowy_storage::manager::{StorageManager, StorageUserService}; +use flowy_storage_pub::cloud::StorageCloudService; +use flowy_user::services::authenticate_user::AuthenticateUser; +use std::sync::{Arc, Weak}; +use uuid::Uuid; + +pub struct FileStorageResolver; + +impl FileStorageResolver { + pub fn resolve( + authenticate_user: Weak, + cloud_service: Arc, + root: &str, + ) -> Arc { + let user_service = FileStorageServiceImpl { + user: authenticate_user, + root_dir: root.to_owned(), + }; + Arc::new(StorageManager::new(cloud_service, Arc::new(user_service))) + } +} + +struct FileStorageServiceImpl { + user: Weak, + root_dir: String, +} +impl FileStorageServiceImpl { + fn upgrade_user(&self) -> Result, FlowyError> { + let user = self + .user + .upgrade() + .ok_or(FlowyError::internal().with_context("Unexpected error: UserSession is None"))?; + Ok(user) + } +} + +impl StorageUserService for FileStorageServiceImpl { + fn user_id(&self) -> Result { + self.upgrade_user()?.user_id() + } + + fn workspace_id(&self) -> Result { + self.upgrade_user()?.workspace_id() + } + + fn sqlite_connection(&self, uid: i64) -> Result { + self.upgrade_user()?.get_sqlite_connection(uid) + } + + fn get_application_root_dir(&self) -> &str { + &self.root_dir + } +} diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps.rs deleted file mode 100644 index 1a9fd4160e..0000000000 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps.rs +++ /dev/null @@ -1,460 +0,0 @@ -use bytes::Bytes; -use collab_integrate::collab_builder::AppFlowyCollabBuilder; -use collab_integrate::CollabKVDB; -use flowy_database2::entities::DatabaseLayoutPB; -use flowy_database2::services::share::csv::CSVFormat; -use flowy_database2::template::{make_default_board, make_default_calendar, make_default_grid}; -use flowy_database2::DatabaseManager; -use flowy_document::entities::DocumentDataPB; -use flowy_document::manager::DocumentManager; -use flowy_document::parser::json::parser::JsonToDocumentParser; -use flowy_error::FlowyError; -use flowy_folder::entities::ViewLayoutPB; -use flowy_folder::manager::{FolderManager, FolderUser}; -use flowy_folder::share::ImportType; -use flowy_folder::view_operation::{FolderOperationHandler, FolderOperationHandlers, View}; -use flowy_folder::ViewLayout; -use flowy_folder_pub::folder_builder::NestedViewBuilder; -use flowy_search::folder::indexer::FolderIndexManagerImpl; -use flowy_user::services::authenticate_user::AuthenticateUser; -use lib_dispatch::prelude::ToBytes; -use lib_infra::future::FutureResult; -use std::collections::HashMap; -use std::convert::TryFrom; -use std::sync::{Arc, Weak}; -use tokio::sync::RwLock; - -use crate::integrate::server::ServerProvider; - -pub struct FolderDepsResolver(); -impl FolderDepsResolver { - pub async fn resolve( - authenticate_user: Weak, - document_manager: &Arc, - database_manager: &Arc, - collab_builder: Arc, - server_provider: Arc, - folder_indexer: Arc, - ) -> Arc { - let user: Arc = Arc::new(FolderUserImpl { - authenticate_user: authenticate_user.clone(), - }); - - let handlers = folder_operation_handlers(document_manager.clone(), database_manager.clone()); - Arc::new( - FolderManager::new( - user.clone(), - collab_builder, - handlers, - server_provider.clone(), - folder_indexer, - ) - .await - .unwrap(), - ) - } -} - -fn folder_operation_handlers( - document_manager: Arc, - database_manager: Arc, -) -> FolderOperationHandlers { - let mut map: HashMap> = HashMap::new(); - - let document_folder_operation = Arc::new(DocumentFolderOperation(document_manager)); - map.insert(ViewLayout::Document, document_folder_operation); - - let database_folder_operation = Arc::new(DatabaseFolderOperation(database_manager)); - map.insert(ViewLayout::Board, database_folder_operation.clone()); - map.insert(ViewLayout::Grid, database_folder_operation.clone()); - map.insert(ViewLayout::Calendar, database_folder_operation); - Arc::new(map) -} - -struct FolderUserImpl { - authenticate_user: Weak, -} - -impl FolderUserImpl { - fn upgrade_user(&self) -> Result, FlowyError> { - let user = self - .authenticate_user - .upgrade() - .ok_or(FlowyError::internal().with_context("Unexpected error: UserSession is None"))?; - Ok(user) - } -} - -impl FolderUser for FolderUserImpl { - fn user_id(&self) -> Result { - self.upgrade_user()?.user_id() - } - - fn workspace_id(&self) -> Result { - self.upgrade_user()?.workspace_id() - } - - fn collab_db(&self, uid: i64) -> Result, FlowyError> { - self.upgrade_user()?.get_collab_db(uid) - } -} - -struct DocumentFolderOperation(Arc); -impl FolderOperationHandler for DocumentFolderOperation { - fn create_workspace_view( - &self, - uid: i64, - workspace_view_builder: Arc>, - ) -> FutureResult<(), FlowyError> { - let manager = self.0.clone(); - FutureResult::new(async move { - let mut write_guard = workspace_view_builder.write().await; - - // Create a view named "Getting started" with an icon ⭐️ and the built-in README data. - // Don't modify this code unless you know what you are doing. - write_guard - .with_view_builder(|view_builder| async { - let view = view_builder - .with_name("Getting started") - .with_icon("⭐️") - .build(); - // create a empty document - let json_str = include_str!("../../assets/read_me.json"); - let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap(); - manager - .create_document(uid, &view.parent_view.id, Some(document_pb.into())) - .await - .unwrap(); - view - }) - .await; - Ok(()) - }) - } - - fn open_view(&self, view_id: &str) -> FutureResult<(), FlowyError> { - let manager = self.0.clone(); - let view_id = view_id.to_string(); - FutureResult::new(async move { - manager.open_document(&view_id).await?; - Ok(()) - }) - } - - /// Close the document view. - fn close_view(&self, view_id: &str) -> FutureResult<(), FlowyError> { - let manager = self.0.clone(); - let view_id = view_id.to_string(); - FutureResult::new(async move { - manager.close_document(&view_id).await?; - Ok(()) - }) - } - - fn delete_view(&self, view_id: &str) -> FutureResult<(), FlowyError> { - let manager = self.0.clone(); - let view_id = view_id.to_string(); - FutureResult::new(async move { - match manager.delete_document(&view_id).await { - Ok(_) => tracing::trace!("Delete document: {}", view_id), - Err(e) => tracing::error!("🔴delete document failed: {}", e), - } - Ok(()) - }) - } - - fn duplicate_view(&self, view_id: &str) -> FutureResult { - let manager = self.0.clone(); - let view_id = view_id.to_string(); - FutureResult::new(async move { - let data: DocumentDataPB = manager.get_document_data(&view_id).await?.into(); - let data_bytes = data.into_bytes().map_err(|_| FlowyError::invalid_data())?; - Ok(data_bytes) - }) - } - - fn create_view_with_view_data( - &self, - user_id: i64, - view_id: &str, - _name: &str, - data: Vec, - layout: ViewLayout, - _meta: HashMap, - ) -> FutureResult<(), FlowyError> { - debug_assert_eq!(layout, ViewLayout::Document); - let view_id = view_id.to_string(); - let manager = self.0.clone(); - FutureResult::new(async move { - let data = DocumentDataPB::try_from(Bytes::from(data))?; - manager - .create_document(user_id, &view_id, Some(data.into())) - .await?; - Ok(()) - }) - } - - /// Create a view with built-in data. - fn create_built_in_view( - &self, - user_id: i64, - view_id: &str, - _name: &str, - layout: ViewLayout, - ) -> FutureResult<(), FlowyError> { - debug_assert_eq!(layout, ViewLayout::Document); - let view_id = view_id.to_string(); - let manager = self.0.clone(); - FutureResult::new(async move { - match manager.create_document(user_id, &view_id, None).await { - Ok(_) => Ok(()), - Err(err) => { - if err.is_already_exists() { - Ok(()) - } else { - Err(err) - } - }, - } - }) - } - - fn import_from_bytes( - &self, - uid: i64, - view_id: &str, - _name: &str, - _import_type: ImportType, - bytes: Vec, - ) -> FutureResult<(), FlowyError> { - let view_id = view_id.to_string(); - let manager = self.0.clone(); - FutureResult::new(async move { - let data = DocumentDataPB::try_from(Bytes::from(bytes))?; - manager - .create_document(uid, &view_id, Some(data.into())) - .await?; - Ok(()) - }) - } - - // will implement soon - fn import_from_file_path( - &self, - _view_id: &str, - _name: &str, - _path: String, - ) -> FutureResult<(), FlowyError> { - FutureResult::new(async move { Ok(()) }) - } -} - -struct DatabaseFolderOperation(Arc); -impl FolderOperationHandler for DatabaseFolderOperation { - fn open_view(&self, view_id: &str) -> FutureResult<(), FlowyError> { - let database_manager = self.0.clone(); - let view_id = view_id.to_string(); - FutureResult::new(async move { - database_manager.open_database_view(view_id).await?; - Ok(()) - }) - } - - fn close_view(&self, view_id: &str) -> FutureResult<(), FlowyError> { - let database_manager = self.0.clone(); - let view_id = view_id.to_string(); - FutureResult::new(async move { - database_manager.close_database_view(view_id).await?; - Ok(()) - }) - } - - fn delete_view(&self, view_id: &str) -> FutureResult<(), FlowyError> { - let database_manager = self.0.clone(); - let view_id = view_id.to_string(); - FutureResult::new(async move { - match database_manager.delete_database_view(&view_id).await { - Ok(_) => tracing::trace!("Delete database view: {}", view_id), - Err(e) => tracing::error!("🔴delete database failed: {}", e), - } - Ok(()) - }) - } - - fn duplicate_view(&self, view_id: &str) -> FutureResult { - let database_manager = self.0.clone(); - let view_id = view_id.to_owned(); - FutureResult::new(async move { - let delta_bytes = database_manager.duplicate_database(&view_id).await?; - Ok(Bytes::from(delta_bytes)) - }) - } - - /// Create a database view with duplicated data. - /// If the ext contains the {"database_id": "xx"}, then it will link - /// to the existing database. - fn create_view_with_view_data( - &self, - _user_id: i64, - view_id: &str, - name: &str, - data: Vec, - layout: ViewLayout, - meta: HashMap, - ) -> FutureResult<(), FlowyError> { - match CreateDatabaseExtParams::from_map(meta) { - None => { - let database_manager = self.0.clone(); - let view_id = view_id.to_string(); - FutureResult::new(async move { - database_manager - .create_database_with_database_data(&view_id, data) - .await?; - Ok(()) - }) - }, - Some(params) => { - let database_manager = self.0.clone(); - let layout = layout_type_from_view_layout(layout.into()); - let name = name.to_string(); - let database_view_id = view_id.to_string(); - - FutureResult::new(async move { - database_manager - .create_linked_view(name, layout.into(), params.database_id, database_view_id) - .await?; - Ok(()) - }) - }, - } - } - - /// Create a database view with build-in data. - /// If the ext contains the {"database_id": "xx"}, then it will link to - /// the existing database. The data of the database will be shared within - /// these references views. - fn create_built_in_view( - &self, - _user_id: i64, - view_id: &str, - name: &str, - layout: ViewLayout, - ) -> FutureResult<(), FlowyError> { - let name = name.to_string(); - let database_manager = self.0.clone(); - let data = match layout { - ViewLayout::Grid => make_default_grid(view_id, &name), - ViewLayout::Board => make_default_board(view_id, &name), - ViewLayout::Calendar => make_default_calendar(view_id, &name), - ViewLayout::Document => { - return FutureResult::new(async move { - Err(FlowyError::internal().with_context(format!("Can't handle {:?} layout type", layout))) - }); - }, - }; - FutureResult::new(async move { - let result = database_manager.create_database_with_params(data).await; - match result { - Ok(_) => Ok(()), - Err(err) => { - if err.is_already_exists() { - Ok(()) - } else { - Err(err) - } - }, - } - }) - } - - fn import_from_bytes( - &self, - _uid: i64, - view_id: &str, - _name: &str, - import_type: ImportType, - bytes: Vec, - ) -> FutureResult<(), FlowyError> { - let database_manager = self.0.clone(); - let view_id = view_id.to_string(); - let format = match import_type { - ImportType::CSV => CSVFormat::Original, - ImportType::HistoryDatabase => CSVFormat::META, - ImportType::RawDatabase => CSVFormat::META, - _ => CSVFormat::Original, - }; - FutureResult::new(async move { - let content = tokio::task::spawn_blocking(move || { - String::from_utf8(bytes).map_err(|err| FlowyError::internal().with_context(err)) - }) - .await??; - - database_manager - .import_csv(view_id, content, format) - .await?; - Ok(()) - }) - } - - fn import_from_file_path( - &self, - _view_id: &str, - _name: &str, - path: String, - ) -> FutureResult<(), FlowyError> { - let database_manager = self.0.clone(); - FutureResult::new(async move { - database_manager - .import_csv_from_file(path, CSVFormat::META) - .await?; - Ok(()) - }) - } - - fn did_update_view(&self, old: &View, new: &View) -> FutureResult<(), FlowyError> { - let database_layout = match new.layout { - ViewLayout::Document => { - return FutureResult::new(async { - Err(FlowyError::internal().with_context("Can't handle document layout type")) - }); - }, - ViewLayout::Grid => DatabaseLayoutPB::Grid, - ViewLayout::Board => DatabaseLayoutPB::Board, - ViewLayout::Calendar => DatabaseLayoutPB::Calendar, - }; - - let database_manager = self.0.clone(); - let view_id = new.id.clone(); - if old.layout != new.layout { - FutureResult::new(async move { - database_manager - .update_database_layout(&view_id, database_layout) - .await?; - Ok(()) - }) - } else { - FutureResult::new(async move { Ok(()) }) - } - } -} - -#[derive(Debug, serde::Deserialize)] -struct CreateDatabaseExtParams { - database_id: String, -} - -impl CreateDatabaseExtParams { - pub fn from_map(map: HashMap) -> Option { - let value = serde_json::to_value(map).ok()?; - serde_json::from_value::(value).ok() - } -} - -pub fn layout_type_from_view_layout(layout: ViewLayoutPB) -> DatabaseLayoutPB { - match layout { - ViewLayoutPB::Grid => DatabaseLayoutPB::Grid, - ViewLayoutPB::Board => DatabaseLayoutPB::Board, - ViewLayoutPB::Calendar => DatabaseLayoutPB::Calendar, - ViewLayoutPB::Document => DatabaseLayoutPB::Grid, - } -} diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs new file mode 100644 index 0000000000..e2791827ee --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs @@ -0,0 +1,79 @@ +use bytes::Bytes; +use collab::entity::EncodedCollab; +use collab_folder::ViewLayout; +use flowy_ai::ai_manager::AIManager; +use flowy_error::FlowyError; +use flowy_folder::entities::CreateViewParams; +use flowy_folder::share::ImportType; +use flowy_folder::view_operation::{FolderOperationHandler, ImportedData}; +use lib_infra::async_trait::async_trait; +use std::sync::Arc; +use uuid::Uuid; + +pub struct ChatFolderOperation(pub Arc); + +#[async_trait] +impl FolderOperationHandler for ChatFolderOperation { + fn name(&self) -> &str { + "ChatFolderOperationHandler" + } + + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + self.0.open_chat(view_id).await + } + + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + self.0.close_chat(view_id).await + } + + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + self.0.delete_chat(view_id).await + } + + async fn duplicate_view(&self, _view_id: &Uuid) -> Result { + Err(FlowyError::not_support().with_context("Duplicate view")) + } + + async fn create_view_with_view_data( + &self, + _user_id: i64, + _params: CreateViewParams, + ) -> Result, FlowyError> { + Err(FlowyError::not_support().with_context("Can't create view")) + } + + async fn create_default_view( + &self, + user_id: i64, + parent_view_id: &Uuid, + view_id: &Uuid, + _name: &str, + _layout: ViewLayout, + ) -> Result<(), FlowyError> { + self + .0 + .create_chat(&user_id, parent_view_id, view_id) + .await?; + Ok(()) + } + + async fn import_from_bytes( + &self, + _uid: i64, + _view_id: &Uuid, + _name: &str, + _import_type: ImportType, + _bytes: Vec, + ) -> Result, FlowyError> { + Err(FlowyError::not_support().with_context("import from data")) + } + + async fn import_from_file_path( + &self, + _view_id: &str, + _name: &str, + _path: String, + ) -> Result<(), FlowyError> { + Err(FlowyError::not_support().with_context("import file from path")) + } +} diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs new file mode 100644 index 0000000000..edc40c6d5b --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs @@ -0,0 +1,354 @@ +#![allow(unused_variables)] +use bytes::Bytes; +use collab::entity::EncodedCollab; +use collab_entity::CollabType; +use collab_folder::{View, ViewLayout}; +use collab_plugins::local_storage::kv::KVTransactionDB; +use flowy_database2::entities::DatabaseLayoutPB; +use flowy_database2::services::share::csv::CSVFormat; +use flowy_database2::template::{make_default_board, make_default_calendar, make_default_grid}; +use flowy_database2::DatabaseManager; +use flowy_error::FlowyError; +use flowy_folder::entities::{CreateViewParams, ViewLayoutPB}; +use flowy_folder::manager::FolderUser; +use flowy_folder::share::ImportType; +use flowy_folder::view_operation::{ + DatabaseEncodedCollab, FolderOperationHandler, GatherEncodedCollab, ImportedData, ViewData, +}; +use flowy_user::services::data_import::{load_collab_by_object_id, load_collab_by_object_ids}; +use lib_infra::async_trait::async_trait; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use uuid::Uuid; + +pub struct DatabaseFolderOperation(pub Arc); + +#[async_trait] +impl FolderOperationHandler for DatabaseFolderOperation { + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + self.0.open_database_view(view_id).await?; + Ok(()) + } + + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + self + .0 + .close_database_view(view_id.to_string().as_str()) + .await?; + Ok(()) + } + + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + match self + .0 + .delete_database_view(view_id.to_string().as_str()) + .await + { + Ok(_) => tracing::trace!("Delete database view: {}", view_id), + Err(e) => tracing::error!("🔴delete database failed: {}", e), + } + Ok(()) + } + + async fn gather_publish_encode_collab( + &self, + _user: &Arc, + view_id: &Uuid, + ) -> Result { + let workspace_id = _user.workspace_id()?; + let view_id_str = view_id.to_string(); + // get the collab_object_id for the database. + // + // the collab object_id for the database is not the view_id, + // we should use the view_id to get the database_id + let oid = self.0.get_database_id_with_view_id(&view_id_str).await?; + let row_oids = self + .0 + .get_database_row_ids_with_view_id(&view_id_str) + .await?; + let row_metas = self + .0 + .get_database_row_metas_with_view_id(view_id, row_oids.clone()) + .await?; + let row_document_ids = row_metas + .iter() + .filter_map(|meta| meta.document_id.clone()) + .collect::>(); + let row_oids = row_oids + .into_iter() + .map(|oid| oid.into_inner()) + .collect::>(); + let database_metas = self.0.get_all_databases_meta().await; + + let uid = _user + .user_id() + .map_err(|e| e.with_context("unable to get the uid: {}"))?; + + // get the collab db + let collab_db = _user + .collab_db(uid) + .map_err(|e| e.with_context("unable to get the collab"))?; + let collab_db = collab_db.upgrade().ok_or_else(|| { + FlowyError::internal().with_context( + "The collab db has been dropped, indicating that the user has switched to a new account", + ) + })?; + + tokio::task::spawn_blocking(move || { + let collab_read_txn = collab_db.read_txn(); + let database_collab = load_collab_by_object_id(uid, &collab_read_txn, &workspace_id.to_string(), &oid) + .map_err(|e| { + FlowyError::internal().with_context(format!("load database collab failed: {}", e)) + })?; + + let database_encoded_collab = database_collab + // encode the collab and check the integrity of the collab + .encode_collab_v1(|collab| CollabType::Database.validate_require_data(collab)) + .map_err(|e| { + FlowyError::internal().with_context(format!("encode database collab failed: {}", e)) + })?; + + let database_row_encoded_collabs = + load_collab_by_object_ids(uid, &workspace_id.to_string(), &collab_read_txn, &row_oids) + .0 + .into_iter() + .map(|(oid, collab)| { + collab + .encode_collab_v1(|c| CollabType::DatabaseRow.validate_require_data(c)) + .map(|encoded| (oid, encoded)) + .map_err(|e| { + FlowyError::internal().with_context(format!("Database row collab error: {}", e)) + }) + }) + .collect::, FlowyError>>()?; + + let database_relations = database_metas + .into_iter() + .filter_map(|meta| { + meta + .linked_views + .clone() + .into_iter() + .next() + .map(|lv| (meta.database_id, lv)) + }) + .collect::>(); + + let database_row_document_encoded_collabs = + load_collab_by_object_ids(uid, &workspace_id.to_string(), &collab_read_txn, &row_document_ids) + .0 + .into_iter() + .map(|(oid, collab)| { + collab + .encode_collab_v1(|c| CollabType::Document.validate_require_data(c)) + .map(|encoded| (oid, encoded)) + .map_err(|e| { + FlowyError::internal() + .with_context(format!("Database row document collab error: {}", e)) + }) + }) + .collect::, FlowyError>>()?; + + Ok(GatherEncodedCollab::Database(DatabaseEncodedCollab { + database_encoded_collab, + database_row_encoded_collabs, + database_row_document_encoded_collabs, + database_relations, + })) + }) + .await? + } + + async fn duplicate_view(&self, view_id: &Uuid) -> Result { + Ok(Bytes::from(view_id.to_string())) + } + + /// Create a database view with duplicated data. + /// If the ext contains the {"database_id": "xx"}, then it will link + /// to the existing database. + async fn create_view_with_view_data( + &self, + _user_id: i64, + params: CreateViewParams, + ) -> Result, FlowyError> { + match CreateDatabaseExtParams::from_map(params.meta.clone()) { + None => match params.initial_data { + ViewData::DuplicateData(data) => { + let duplicated_view_id = + String::from_utf8(data.to_vec()).map_err(|_| FlowyError::invalid_data())?; + let encoded_collab = self + .0 + .duplicate_database(&duplicated_view_id, ¶ms.view_id.to_string()) + .await?; + Ok(Some(encoded_collab)) + }, + ViewData::Data(data) => { + let encoded_collab = self + .0 + .create_database_with_data(¶ms.view_id.to_string(), data.to_vec()) + .await?; + Ok(Some(encoded_collab)) + }, + ViewData::Empty => Ok(None), + }, + Some(database_params) => { + let layout = match params.layout { + ViewLayoutPB::Board => DatabaseLayoutPB::Board, + ViewLayoutPB::Calendar => DatabaseLayoutPB::Calendar, + ViewLayoutPB::Grid => DatabaseLayoutPB::Grid, + ViewLayoutPB::Document | ViewLayoutPB::Chat => { + return Err( + FlowyError::invalid_data().with_context("Can't handle document layout type"), + ); + }, + }; + let name = params.name.to_string(); + let database_view_id = params.view_id.to_string(); + let database_parent_view_id = params.parent_view_id.to_string(); + self + .0 + .create_linked_view( + name, + layout.into(), + database_params.database_id, + database_view_id, + database_parent_view_id, + ) + .await?; + Ok(None) + }, + } + } + + /// Create a database view with build-in data. + /// If the ext contains the {"database_id": "xx"}, then it will link to + /// the existing database. The data of the database will be shared within + /// these references views. + async fn create_default_view( + &self, + user_id: i64, + parent_view_id: &Uuid, + view_id: &Uuid, + name: &str, + layout: ViewLayout, + ) -> Result<(), FlowyError> { + let name = name.to_string(); + let view_id = view_id.to_string(); + let data = match layout { + ViewLayout::Grid => make_default_grid(&view_id, &name), + ViewLayout::Board => make_default_board(&view_id, &name), + ViewLayout::Calendar => make_default_calendar(&view_id, &name), + ViewLayout::Document | ViewLayout::Chat => { + return Err( + FlowyError::internal().with_context(format!("Can't handle {:?} layout type", layout)), + ); + }, + }; + let result = self.0.import_database(data).await; + match result { + Ok(_) => Ok(()), + Err(err) => { + if err.is_already_exists() { + Ok(()) + } else { + Err(err) + } + }, + } + } + + async fn import_from_bytes( + &self, + uid: i64, + view_id: &Uuid, + name: &str, + import_type: ImportType, + bytes: Vec, + ) -> Result, FlowyError> { + let format = match import_type { + ImportType::CSV => CSVFormat::Original, + ImportType::AFDatabase => CSVFormat::META, + _ => CSVFormat::Original, + }; + let content = tokio::task::spawn_blocking(move || { + String::from_utf8(bytes).map_err(|err| FlowyError::internal().with_context(err)) + }) + .await??; + let result = self + .0 + .import_csv(view_id.to_string(), content, format) + .await?; + Ok( + result + .encoded_collabs + .into_iter() + .map(|encoded| { + ( + encoded.object_id, + encoded.collab_type, + encoded.encoded_collab, + ) + }) + .collect(), + ) + } + + async fn import_from_file_path( + &self, + view_id: &str, + _name: &str, + path: String, + ) -> Result<(), FlowyError> { + let file_path = Path::new(&path); + if !file_path.exists() { + return Err(FlowyError::record_not_found().with_context("File not found")); + } + + let data = tokio::fs::read(file_path).await?; + let content = + String::from_utf8(data).map_err(|e| FlowyError::invalid_data().with_context(e))?; + let _ = self + .0 + .import_csv(view_id.to_string(), content, CSVFormat::Original) + .await?; + Ok(()) + } + + async fn did_update_view(&self, old: &View, new: &View) -> Result<(), FlowyError> { + let database_layout = match new.layout { + ViewLayout::Document | ViewLayout::Chat => { + return Err(FlowyError::internal().with_context("Can't handle document layout type")); + }, + ViewLayout::Grid => DatabaseLayoutPB::Grid, + ViewLayout::Board => DatabaseLayoutPB::Board, + ViewLayout::Calendar => DatabaseLayoutPB::Calendar, + }; + + if old.layout != new.layout { + self + .0 + .update_database_layout(&new.id, database_layout) + .await?; + Ok(()) + } else { + Ok(()) + } + } + + fn name(&self) -> &str { + "DatabaseFolderOperationHandler" + } +} + +#[derive(Debug, serde::Deserialize)] +struct CreateDatabaseExtParams { + database_id: String, +} + +impl CreateDatabaseExtParams { + pub fn from_map(map: HashMap) -> Option { + let value = serde_json::to_value(map).ok()?; + serde_json::from_value::(value).ok() + } +} diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs new file mode 100644 index 0000000000..a843a8eb1f --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs @@ -0,0 +1,167 @@ +use crate::deps_resolve::folder_deps::get_encoded_collab_v1_from_disk; +use bytes::Bytes; +use collab::entity::EncodedCollab; +use collab_entity::CollabType; +use collab_folder::hierarchy_builder::NestedViewBuilder; +use collab_folder::ViewLayout; +use flowy_document::entities::DocumentDataPB; +use flowy_document::manager::DocumentManager; +use flowy_document::parser::json::parser::JsonToDocumentParser; +use flowy_error::FlowyError; +use flowy_folder::entities::{CreateViewParams, ViewLayoutPB}; +use flowy_folder::manager::FolderUser; +use flowy_folder::share::ImportType; +use flowy_folder::view_operation::{ + FolderOperationHandler, GatherEncodedCollab, ImportedData, ViewData, +}; +use lib_dispatch::prelude::ToBytes; +use lib_infra::async_trait::async_trait; +use std::convert::TryFrom; +use std::str::FromStr; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; + +pub struct DocumentFolderOperation(pub Arc); +#[async_trait] +impl FolderOperationHandler for DocumentFolderOperation { + fn name(&self) -> &str { + "DocumentFolderOperationHandler" + } + + async fn create_workspace_view( + &self, + uid: i64, + workspace_view_builder: Arc>, + ) -> Result<(), FlowyError> { + let manager = self.0.clone(); + + let mut write_guard = workspace_view_builder.write().await; + // Create a view named "Getting started" with an icon ⭐️ and the built-in README data. + // Don't modify this code unless you know what you are doing. + write_guard + .with_view_builder(|view_builder| async { + let view = view_builder + .with_name("Getting started") + .with_icon("⭐️") + .build(); + // create a empty document + let json_str = include_str!("../../../assets/read_me.json"); + let document_pb = JsonToDocumentParser::json_str_to_document(json_str).unwrap(); + let view_id = Uuid::from_str(&view.view.id).unwrap(); + manager + .create_document(uid, &view_id, Some(document_pb.into())) + .await + .unwrap(); + view + }) + .await; + Ok(()) + } + + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + self.0.open_document(view_id).await?; + Ok(()) + } + + /// Close the document view. + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + self.0.close_document(view_id).await?; + Ok(()) + } + + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError> { + match self.0.delete_document(view_id).await { + Ok(_) => tracing::trace!("Delete document: {}", view_id), + Err(e) => tracing::error!("🔴delete document failed: {}", e), + } + Ok(()) + } + + async fn duplicate_view(&self, view_id: &Uuid) -> Result { + let data: DocumentDataPB = self.0.get_document_data(view_id).await?.into(); + let data_bytes = data.into_bytes().map_err(|_| FlowyError::invalid_data())?; + Ok(data_bytes) + } + + async fn gather_publish_encode_collab( + &self, + user: &Arc, + view_id: &Uuid, + ) -> Result { + let encoded_collab = + get_encoded_collab_v1_from_disk(user, view_id.to_string().as_str(), CollabType::Document) + .await?; + Ok(GatherEncodedCollab::Document(encoded_collab)) + } + + async fn create_view_with_view_data( + &self, + user_id: i64, + params: CreateViewParams, + ) -> Result, FlowyError> { + debug_assert_eq!(params.layout, ViewLayoutPB::Document); + let data = match params.initial_data { + ViewData::DuplicateData(data) => Some(DocumentDataPB::try_from(data)?), + ViewData::Data(data) => Some(DocumentDataPB::try_from(data)?), + ViewData::Empty => None, + }; + let encoded_collab = self + .0 + .create_document(user_id, ¶ms.view_id, data.map(|d| d.into())) + .await?; + Ok(Some(encoded_collab)) + } + + /// Create a view with built-in data. + async fn create_default_view( + &self, + user_id: i64, + _parent_view_id: &Uuid, + view_id: &Uuid, + _name: &str, + layout: ViewLayout, + ) -> Result<(), FlowyError> { + debug_assert_eq!(layout, ViewLayout::Document); + match self.0.create_document(user_id, view_id, None).await { + Ok(_) => Ok(()), + Err(err) => { + if err.is_already_exists() { + Ok(()) + } else { + Err(err) + } + }, + } + } + + async fn import_from_bytes( + &self, + uid: i64, + view_id: &Uuid, + _name: &str, + _import_type: ImportType, + bytes: Vec, + ) -> Result, FlowyError> { + let data = DocumentDataPB::try_from(Bytes::from(bytes))?; + let encoded_collab = self + .0 + .create_document(uid, view_id, Some(data.into())) + .await?; + Ok(vec![( + view_id.to_string(), + CollabType::Document, + encoded_collab, + )]) + } + + async fn import_from_file_path( + &self, + _view_id: &str, + _name: &str, + _path: String, + ) -> Result<(), FlowyError> { + // TODO(lucas): import file from local markdown file + Ok(()) + } +} diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs new file mode 100644 index 0000000000..02b26e71b6 --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs @@ -0,0 +1,255 @@ +mod folder_deps_chat_impl; +mod folder_deps_database_impl; +mod folder_deps_doc_impl; + +use crate::server_layer::ServerProvider; +use collab_entity::{CollabType, EncodedCollab}; +use collab_integrate::collab_builder::AppFlowyCollabBuilder; +use collab_integrate::CollabKVDB; +use flowy_ai::ai_manager::AIManager; +use flowy_database2::DatabaseManager; +use flowy_document::manager::DocumentManager; +use flowy_error::{internal_error, FlowyError, FlowyResult}; +use flowy_folder::entities::UpdateViewParams; +use flowy_folder::manager::{FolderManager, FolderUser}; +use flowy_folder::ViewLayout; +use flowy_search::folder::indexer::FolderIndexManagerImpl; +use flowy_sqlite::kv::KVStorePreferences; +use flowy_user::services::authenticate_user::AuthenticateUser; +use flowy_user::services::data_import::load_collab_by_object_id; +use std::str::FromStr; +use std::sync::{Arc, Weak}; + +use crate::deps_resolve::folder_deps::folder_deps_chat_impl::ChatFolderOperation; +use crate::deps_resolve::folder_deps::folder_deps_database_impl::DatabaseFolderOperation; +use crate::deps_resolve::folder_deps::folder_deps_doc_impl::DocumentFolderOperation; +use collab_plugins::local_storage::kv::KVTransactionDB; +use flowy_folder_pub::query::{FolderQueryService, FolderService, FolderViewEdit, QueryCollab}; +use lib_infra::async_trait::async_trait; +use uuid::Uuid; + +pub struct FolderDepsResolver(); +#[allow(clippy::too_many_arguments)] +impl FolderDepsResolver { + pub async fn resolve( + authenticate_user: Weak, + collab_builder: Arc, + server_provider: Arc, + folder_indexer: Arc, + store_preferences: Arc, + ) -> Arc { + let user: Arc = Arc::new(FolderUserImpl { + authenticate_user: authenticate_user.clone(), + }); + + Arc::new( + FolderManager::new( + user.clone(), + collab_builder, + server_provider.clone(), + folder_indexer, + store_preferences, + ) + .unwrap(), + ) + } +} + +pub fn register_handlers( + folder_manager: &Arc, + document_manager: Arc, + database_manager: Arc, + chat_manager: Arc, +) { + let document_folder_operation = Arc::new(DocumentFolderOperation(document_manager)); + folder_manager.register_operation_handler(ViewLayout::Document, document_folder_operation); + + let database_folder_operation = Arc::new(DatabaseFolderOperation(database_manager)); + let chat_folder_operation = Arc::new(ChatFolderOperation(chat_manager)); + folder_manager.register_operation_handler(ViewLayout::Board, database_folder_operation.clone()); + folder_manager.register_operation_handler(ViewLayout::Grid, database_folder_operation.clone()); + folder_manager.register_operation_handler(ViewLayout::Calendar, database_folder_operation); + folder_manager.register_operation_handler(ViewLayout::Chat, chat_folder_operation); +} + +struct FolderUserImpl { + authenticate_user: Weak, +} + +impl FolderUserImpl { + fn upgrade_user(&self) -> Result, FlowyError> { + let user = self + .authenticate_user + .upgrade() + .ok_or(FlowyError::internal().with_context("Unexpected error: UserSession is None"))?; + Ok(user) + } +} + +impl FolderUser for FolderUserImpl { + fn user_id(&self) -> Result { + self.upgrade_user()?.user_id() + } + + fn workspace_id(&self) -> Result { + self.upgrade_user()?.workspace_id() + } + + fn collab_db(&self, uid: i64) -> Result, FlowyError> { + self.upgrade_user()?.get_collab_db(uid) + } + + fn is_folder_exist_on_disk(&self, uid: i64, workspace_id: &Uuid) -> FlowyResult { + self + .upgrade_user()? + .is_collab_on_disk(uid, workspace_id.to_string().as_str()) + } +} + +#[derive(Clone)] +pub struct FolderServiceImpl { + folder_manager: Weak, + user: Arc, +} +impl FolderService for FolderServiceImpl {} + +impl FolderServiceImpl { + pub fn new( + folder_manager: Weak, + authenticate_user: Weak, + ) -> Self { + let user: Arc = Arc::new(FolderUserImpl { authenticate_user }); + Self { + folder_manager, + user, + } + } +} + +#[async_trait] +impl FolderViewEdit for FolderServiceImpl { + async fn set_view_title_if_empty(&self, view_id: &Uuid, title: &str) -> FlowyResult<()> { + if title.is_empty() { + return Ok(()); + } + + if let Some(folder_manager) = self.folder_manager.upgrade() { + if let Ok(view) = folder_manager.get_view(view_id.to_string().as_str()).await { + if view.name.is_empty() { + let title = if title.len() > 50 { + title.chars().take(50).collect() + } else { + title.to_string() + }; + + folder_manager + .update_view_with_params(UpdateViewParams { + view_id: view_id.to_string(), + name: Some(title), + desc: None, + thumbnail: None, + layout: None, + is_favorite: None, + extra: None, + }) + .await?; + } + } + } + Ok(()) + } +} + +#[async_trait] +impl FolderQueryService for FolderServiceImpl { + async fn get_surrounding_view_ids_with_view_layout( + &self, + parent_view_id: &Uuid, + view_layout: ViewLayout, + ) -> Vec { + let folder_manager = match self.folder_manager.upgrade() { + Some(folder_manager) => folder_manager, + None => return vec![], + }; + + if let Ok(view) = folder_manager + .get_view(parent_view_id.to_string().as_str()) + .await + { + if view.space_info().is_some() { + return vec![]; + } + } + + match folder_manager + .get_untrashed_views_belong_to(parent_view_id.to_string().as_str()) + .await + { + Ok(views) => { + let mut children = views + .into_iter() + .filter_map(|child| { + if child.layout == view_layout { + Uuid::from_str(&child.id).ok() + } else { + None + } + }) + .collect::>(); + children.push(*parent_view_id); + children + }, + _ => vec![], + } + } + + async fn get_collab(&self, object_id: &Uuid, collab_type: CollabType) -> Option { + let encode_collab = + get_encoded_collab_v1_from_disk(&self.user, object_id.to_string().as_str(), collab_type) + .await + .ok(); + + encode_collab.map(|encoded_collab| QueryCollab { + collab_type, + encoded_collab, + }) + } +} + +#[inline] +async fn get_encoded_collab_v1_from_disk( + user: &Arc, + view_id: &str, + collab_type: CollabType, +) -> Result { + let workspace_id = user.workspace_id()?; + let uid = user + .user_id() + .map_err(|e| e.with_context("unable to get the uid: {}"))?; + + // get the collab db + let collab_db = user + .collab_db(uid) + .map_err(|e| e.with_context("unable to get the collab"))?; + let collab_db = collab_db.upgrade().ok_or_else(|| { + FlowyError::internal().with_context( + "The collab db has been dropped, indicating that the user has switched to a new account", + ) + })?; + let collab_read_txn = collab_db.read_txn(); + let collab = load_collab_by_object_id(uid, &collab_read_txn, &workspace_id.to_string(), view_id) + .map_err(|e| { + FlowyError::internal().with_context(format!("load document collab failed: {}", e)) + })?; + + tokio::task::spawn_blocking(move || { + let data = collab + .encode_collab_v1(|collab| collab_type.validate_require_data(collab)) + .map_err(|e| { + FlowyError::internal().with_context(format!("encode document collab failed: {}", e)) + })?; + Ok::<_, FlowyError>(data) + }) + .await + .map_err(internal_error)? +} diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/mod.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/mod.rs index a93530e519..7e1f8b942f 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/mod.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/mod.rs @@ -1,3 +1,4 @@ +pub use chat_deps::*; pub use collab_deps::*; pub use database_deps::*; pub use document_deps::*; @@ -7,8 +8,12 @@ pub use user_deps::*; mod collab_deps; mod document_deps; -mod folder_deps; +mod chat_deps; +mod cloud_service_impl; mod database_deps; +pub mod file_storage_deps; +mod folder_deps; +pub(crate) mod reminder_deps; mod search_deps; mod user_deps; diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/reminder_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/reminder_deps.rs new file mode 100644 index 0000000000..29022b6fdc --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/reminder_deps.rs @@ -0,0 +1,57 @@ +use collab_entity::reminder::Reminder; +use std::convert::TryFrom; +use std::sync::Weak; + +use flowy_database2::DatabaseManager; +use flowy_document::manager::DocumentManager; +use flowy_document::reminder::{DocumentReminder, DocumentReminderAction}; +use flowy_folder_pub::cloud::Error; +use flowy_user::services::collab_interact::UserReminder; +use lib_infra::async_trait::async_trait; + +pub struct CollabInteractImpl { + #[allow(dead_code)] + pub(crate) database_manager: Weak, + #[allow(dead_code)] + pub(crate) document_manager: Weak, +} + +#[async_trait] +impl UserReminder for CollabInteractImpl { + async fn add_reminder(&self, reminder: Reminder) -> Result<(), Error> { + if let Some(document_manager) = self.document_manager.upgrade() { + match DocumentReminder::try_from(reminder) { + Ok(reminder) => { + document_manager + .handle_reminder_action(DocumentReminderAction::Add { reminder }) + .await; + }, + Err(e) => tracing::error!("Failed to add reminder: {:?}", e), + } + } + Ok(()) + } + + async fn remove_reminder(&self, reminder_id: &str) -> Result<(), Error> { + let reminder_id = reminder_id.to_string(); + if let Some(document_manager) = self.document_manager.upgrade() { + let action = DocumentReminderAction::Remove { reminder_id }; + document_manager.handle_reminder_action(action).await; + } + Ok(()) + } + + async fn update_reminder(&self, reminder: Reminder) -> Result<(), Error> { + if let Some(document_manager) = self.document_manager.upgrade() { + match DocumentReminder::try_from(reminder) { + Ok(reminder) => { + document_manager + .handle_reminder_action(DocumentReminderAction::Update { reminder }) + .await; + }, + Err(e) => tracing::error!("Failed to update reminder: {:?}", e), + } + } + Ok(()) + } +} diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/search_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/search_deps.rs index 23e6af0b51..b31853a803 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/search_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/search_deps.rs @@ -1,12 +1,20 @@ +use flowy_folder::manager::FolderManager; +use flowy_search::document::handler::DocumentSearchHandler; use flowy_search::folder::handler::FolderSearchHandler; use flowy_search::folder::indexer::FolderIndexManagerImpl; use flowy_search::services::manager::SearchManager; +use flowy_search_pub::cloud::SearchCloudService; use std::sync::Arc; pub struct SearchDepsResolver(); impl SearchDepsResolver { - pub async fn resolve(folder_indexer: Arc) -> Arc { + pub async fn resolve( + folder_indexer: Arc, + cloud_service: Arc, + folder_manager: Arc, + ) -> Arc { let folder_handler = Arc::new(FolderSearchHandler::new(folder_indexer)); - Arc::new(SearchManager::new(vec![folder_handler])) + let document_handler = Arc::new(DocumentSearchHandler::new(cloud_service, folder_manager)); + Arc::new(SearchManager::new(vec![folder_handler, document_handler])) } } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs index b8d18af390..73c2844a23 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs @@ -1,16 +1,19 @@ -use crate::integrate::server::ServerProvider; +use crate::server_layer::ServerProvider; +use collab_folder::hierarchy_builder::ParentChildViews; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use flowy_database2::DatabaseManager; use flowy_error::FlowyResult; use flowy_folder::manager::FolderManager; -use flowy_folder_pub::folder_builder::ParentChildViews; -use flowy_sqlite::kv::StorePreferences; +use flowy_folder_pub::entities::ImportFrom; +use flowy_sqlite::kv::KVStorePreferences; use flowy_user::services::authenticate_user::AuthenticateUser; use flowy_user::user_manager::UserManager; use flowy_user_pub::workspace_service::UserWorkspaceService; use lib_infra::async_trait::async_trait; use std::collections::HashMap; use std::sync::Arc; +use tracing::info; +use uuid::Uuid; pub struct UserDepsResolver(); @@ -19,7 +22,7 @@ impl UserDepsResolver { authenticate_user: Arc, collab_builder: Arc, server_provider: Arc, - store_preference: Arc, + store_preference: Arc, database_manager: Arc, folder_manager: Arc, ) -> Arc { @@ -44,12 +47,31 @@ pub struct UserWorkspaceServiceImpl { #[async_trait] impl UserWorkspaceService for UserWorkspaceServiceImpl { - async fn did_import_views(&self, views: Vec) -> FlowyResult<()> { - self.folder_manager.insert_parent_child_views(views).await?; + async fn import_views( + &self, + source: &ImportFrom, + views: Vec, + orphan_views: Vec, + parent_view_id: Option, + ) -> FlowyResult<()> { + match source { + ImportFrom::AnonUser => { + self + .folder_manager + .insert_views_as_spaces(views, orphan_views) + .await?; + }, + ImportFrom::AppFlowyDataFolder => { + self + .folder_manager + .insert_views_with_parent(views, orphan_views, parent_view_id) + .await?; + }, + } Ok(()) } - async fn did_import_database_views( + async fn import_database_views( &self, ids_by_database_id: HashMap>, ) -> FlowyResult<()> { @@ -59,4 +81,18 @@ impl UserWorkspaceService for UserWorkspaceServiceImpl { .await?; Ok(()) } + + async fn did_delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { + // The remove_indices_for_workspace should not block the deletion of the workspace + // Log the error and continue + if let Err(err) = self + .folder_manager + .remove_indices_for_workspace(workspace_id) + .await + { + info!("Error removing indices for workspace: {}", err); + } + + Ok(()) + } } diff --git a/frontend/rust-lib/flowy-core/src/integrate/collab_interact.rs b/frontend/rust-lib/flowy-core/src/integrate/collab_interact.rs deleted file mode 100644 index 171fc20010..0000000000 --- a/frontend/rust-lib/flowy-core/src/integrate/collab_interact.rs +++ /dev/null @@ -1,65 +0,0 @@ -use collab_entity::reminder::Reminder; -use std::convert::TryFrom; -use std::sync::Weak; - -use flowy_database2::DatabaseManager; -use flowy_document::manager::DocumentManager; -use flowy_document::reminder::{DocumentReminder, DocumentReminderAction}; -use flowy_folder_pub::cloud::Error; -use flowy_user::services::collab_interact::CollabInteract; -use lib_infra::future::FutureResult; - -pub struct CollabInteractImpl { - #[allow(dead_code)] - pub(crate) database_manager: Weak, - #[allow(dead_code)] - pub(crate) document_manager: Weak, -} - -impl CollabInteract for CollabInteractImpl { - fn add_reminder(&self, reminder: Reminder) -> FutureResult<(), Error> { - let cloned_document_manager = self.document_manager.clone(); - FutureResult::new(async move { - if let Some(document_manager) = cloned_document_manager.upgrade() { - match DocumentReminder::try_from(reminder) { - Ok(reminder) => { - document_manager - .handle_reminder_action(DocumentReminderAction::Add { reminder }) - .await; - }, - Err(e) => tracing::error!("Failed to add reminder: {:?}", e), - } - } - Ok(()) - }) - } - - fn remove_reminder(&self, reminder_id: &str) -> FutureResult<(), Error> { - let reminder_id = reminder_id.to_string(); - let cloned_document_manager = self.document_manager.clone(); - FutureResult::new(async move { - if let Some(document_manager) = cloned_document_manager.upgrade() { - let action = DocumentReminderAction::Remove { reminder_id }; - document_manager.handle_reminder_action(action).await; - } - Ok(()) - }) - } - - fn update_reminder(&self, reminder: Reminder) -> FutureResult<(), Error> { - let cloned_document_manager = self.document_manager.clone(); - FutureResult::new(async move { - if let Some(document_manager) = cloned_document_manager.upgrade() { - match DocumentReminder::try_from(reminder) { - Ok(reminder) => { - document_manager - .handle_reminder_action(DocumentReminderAction::Update { reminder }) - .await; - }, - Err(e) => tracing::error!("Failed to update reminder: {:?}", e), - } - } - Ok(()) - }) - } -} diff --git a/frontend/rust-lib/flowy-core/src/integrate/log.rs b/frontend/rust-lib/flowy-core/src/integrate/log.rs deleted file mode 100644 index 66c37837d1..0000000000 --- a/frontend/rust-lib/flowy-core/src/integrate/log.rs +++ /dev/null @@ -1,82 +0,0 @@ -use lib_infra::util::Platform; -use lib_log::stream_log::StreamLogSender; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -use crate::AppFlowyCoreConfig; - -static INIT_LOG: AtomicBool = AtomicBool::new(false); -pub(crate) fn init_log( - config: &AppFlowyCoreConfig, - platform: &Platform, - stream_log_sender: Option>, -) { - #[cfg(debug_assertions)] - if get_bool_from_env_var("DISABLE_CI_TEST_LOG") { - return; - } - - if !INIT_LOG.load(Ordering::SeqCst) { - INIT_LOG.store(true, Ordering::SeqCst); - - let _ = lib_log::Builder::new("log", &config.storage_path, platform, stream_log_sender) - .env_filter(&config.log_filter) - .build(); - } -} - -pub fn create_log_filter(level: String, with_crates: Vec, platform: Platform) -> String { - let mut level = std::env::var("RUST_LOG").unwrap_or(level); - - #[cfg(debug_assertions)] - if matches!(platform, Platform::IOS) { - level = "trace".to_string(); - } - - let mut filters = with_crates - .into_iter() - .map(|crate_name| format!("{}={}", crate_name, level)) - .collect::>(); - filters.push(format!("flowy_core={}", level)); - filters.push(format!("flowy_folder={}", level)); - filters.push(format!("collab_sync={}", level)); - filters.push(format!("collab_folder={}", level)); - filters.push(format!("collab_database={}", level)); - filters.push(format!("collab_plugins={}", level)); - filters.push(format!("collab_integrate={}", level)); - filters.push(format!("collab={}", level)); - filters.push(format!("flowy_user={}", level)); - filters.push(format!("flowy_document={}", level)); - filters.push(format!("flowy_database2={}", level)); - filters.push(format!("flowy_server={}", level)); - filters.push(format!("flowy_notification={}", "info")); - filters.push(format!("lib_infra={}", level)); - filters.push(format!("flowy_search={}", level)); - // Enable the frontend logs. DO NOT DISABLE. - // These logs are essential for debugging and verifying frontend behavior. - filters.push(format!("dart_ffi={}", level)); - - // Most of the time, we don't need to see the logs from the following crates - // filters.push(format!("flowy_sqlite={}", "info")); - // filters.push(format!("lib_dispatch={}", level)); - - filters.push(format!("client_api={}", level)); - #[cfg(feature = "profiling")] - filters.push(format!("tokio={}", level)); - #[cfg(feature = "profiling")] - filters.push(format!("runtime={}", level)); - - filters.join(",") -} - -#[cfg(debug_assertions)] -fn get_bool_from_env_var(env_var_name: &str) -> bool { - match std::env::var(env_var_name) { - Ok(value) => match value.to_lowercase().as_str() { - "true" | "1" => true, - "false" | "0" => false, - _ => false, - }, - Err(_) => false, - } -} diff --git a/frontend/rust-lib/flowy-core/src/integrate/mod.rs b/frontend/rust-lib/flowy-core/src/integrate/mod.rs deleted file mode 100644 index 129a22a99f..0000000000 --- a/frontend/rust-lib/flowy-core/src/integrate/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub(crate) mod collab_interact; -pub mod log; -pub(crate) mod server; -mod trait_impls; -pub(crate) mod user; diff --git a/frontend/rust-lib/flowy-core/src/integrate/server.rs b/frontend/rust-lib/flowy-core/src/integrate/server.rs deleted file mode 100644 index b0c0acbdcf..0000000000 --- a/frontend/rust-lib/flowy-core/src/integrate/server.rs +++ /dev/null @@ -1,217 +0,0 @@ -use std::collections::HashMap; -use std::fmt::{Display, Formatter}; -use std::sync::{Arc, Weak}; - -use parking_lot::RwLock; -use serde_repr::*; - -use flowy_error::{FlowyError, FlowyResult}; -use flowy_server::af_cloud::define::ServerUser; -use flowy_server::af_cloud::AppFlowyCloudServer; -use flowy_server::local_server::{LocalServer, LocalServerDB}; -use flowy_server::supabase::SupabaseServer; -use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl}; -use flowy_server_pub::af_cloud_config::AFCloudConfiguration; -use flowy_server_pub::supabase_config::SupabaseConfiguration; -use flowy_server_pub::AuthenticatorType; -use flowy_sqlite::kv::StorePreferences; -use flowy_user_pub::entities::*; - -use crate::AppFlowyCoreConfig; - -#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize_repr, Deserialize_repr)] -#[repr(u8)] -pub enum Server { - /// Local server provider. - /// Offline mode, no user authentication and the data is stored locally. - Local = 0, - /// AppFlowy Cloud server provider. - /// The [AppFlowy-Server](https://github.com/AppFlowy-IO/AppFlowy-Cloud) is still a work in - /// progress. - AppFlowyCloud = 1, - /// Supabase server provider. - /// It uses supabase postgresql database to store data and user authentication. - Supabase = 2, -} - -impl Server { - pub fn is_local(&self) -> bool { - matches!(self, Server::Local) - } -} - -impl Display for Server { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Server::Local => write!(f, "Local"), - Server::AppFlowyCloud => write!(f, "AppFlowyCloud"), - Server::Supabase => write!(f, "Supabase"), - } - } -} - -/// The [ServerProvider] provides list of [AppFlowyServer] base on the [Authenticator]. Using -/// the auth type, the [ServerProvider] will create a new [AppFlowyServer] if it doesn't -/// exist. -/// Each server implements the [AppFlowyServer] trait, which provides the [UserCloudService], etc. -pub struct ServerProvider { - config: AppFlowyCoreConfig, - providers: RwLock>>, - pub(crate) encryption: RwLock>, - #[allow(dead_code)] - pub(crate) store_preferences: Weak, - pub(crate) user_enable_sync: RwLock, - - /// The authenticator type of the user. - authenticator: RwLock, - user: Arc, - pub(crate) uid: Arc>>, -} - -impl ServerProvider { - pub fn new( - config: AppFlowyCoreConfig, - server: Server, - store_preferences: Weak, - server_user: impl ServerUser + 'static, - ) -> Self { - let user = Arc::new(server_user); - let encryption = EncryptionImpl::new(None); - Self { - config, - providers: RwLock::new(HashMap::new()), - user_enable_sync: RwLock::new(true), - authenticator: RwLock::new(Authenticator::from(server)), - encryption: RwLock::new(Arc::new(encryption)), - store_preferences, - uid: Default::default(), - user, - } - } - - pub fn get_server_type(&self) -> Server { - match &*self.authenticator.read() { - Authenticator::Local => Server::Local, - Authenticator::AppFlowyCloud => Server::AppFlowyCloud, - Authenticator::Supabase => Server::Supabase, - } - } - - pub fn set_authenticator(&self, authenticator: Authenticator) { - let old_server_type = self.get_server_type(); - *self.authenticator.write() = authenticator; - let new_server_type = self.get_server_type(); - - if old_server_type != new_server_type { - self.providers.write().remove(&old_server_type); - } - } - - pub fn get_authenticator(&self) -> Authenticator { - self.authenticator.read().clone() - } - - /// Returns a [AppFlowyServer] trait implementation base on the provider_type. - pub fn get_server(&self) -> FlowyResult> { - let server_type = self.get_server_type(); - - if let Some(provider) = self.providers.read().get(&server_type) { - return Ok(provider.clone()); - } - - let server = match server_type { - Server::Local => { - let local_db = Arc::new(LocalServerDBImpl { - storage_path: self.config.storage_path.clone(), - }); - let server = Arc::new(LocalServer::new(local_db)); - Ok::, FlowyError>(server) - }, - Server::AppFlowyCloud => { - let config = AFCloudConfiguration::from_env()?; - let server = Arc::new(AppFlowyCloudServer::new( - config, - *self.user_enable_sync.read(), - self.config.device_id.clone(), - &self.config.app_version, - self.user.clone(), - )); - - Ok::, FlowyError>(server) - }, - Server::Supabase => { - let config = SupabaseConfiguration::from_env()?; - let uid = self.uid.clone(); - tracing::trace!("🔑Supabase config: {:?}", config); - let encryption = Arc::downgrade(&*self.encryption.read()); - Ok::, FlowyError>(Arc::new(SupabaseServer::new( - uid, - config, - *self.user_enable_sync.read(), - self.config.device_id.clone(), - encryption, - ))) - }, - }?; - - self - .providers - .write() - .insert(server_type.clone(), server.clone()); - Ok(server) - } -} - -impl From for Server { - fn from(auth_provider: Authenticator) -> Self { - match auth_provider { - Authenticator::Local => Server::Local, - Authenticator::AppFlowyCloud => Server::AppFlowyCloud, - Authenticator::Supabase => Server::Supabase, - } - } -} - -impl From for Authenticator { - fn from(ty: Server) -> Self { - match ty { - Server::Local => Authenticator::Local, - Server::AppFlowyCloud => Authenticator::AppFlowyCloud, - Server::Supabase => Authenticator::Supabase, - } - } -} -impl From<&Authenticator> for Server { - fn from(auth_provider: &Authenticator) -> Self { - Self::from(auth_provider.clone()) - } -} - -pub fn current_server_type() -> Server { - match AuthenticatorType::from_env() { - AuthenticatorType::Local => Server::Local, - AuthenticatorType::Supabase => Server::Supabase, - AuthenticatorType::AppFlowyCloud => Server::AppFlowyCloud, - } -} - -struct LocalServerDBImpl { - #[allow(dead_code)] - storage_path: String, -} - -impl LocalServerDB for LocalServerDBImpl { - fn get_user_profile(&self, _uid: i64) -> Result { - Err( - FlowyError::local_version_not_support() - .with_context("LocalServer doesn't support get_user_profile"), - ) - } - - fn get_user_workspace(&self, _uid: i64) -> Result, FlowyError> { - Err( - FlowyError::local_version_not_support() - .with_context("LocalServer doesn't support get_user_workspace"), - ) - } -} diff --git a/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs b/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs deleted file mode 100644 index a4c3638d41..0000000000 --- a/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs +++ /dev/null @@ -1,429 +0,0 @@ -use flowy_storage::{ObjectIdentity, ObjectStorageService}; -use std::sync::Arc; - -use anyhow::Error; -use client_api::collab_sync::{SinkConfig, SyncObject, SyncPlugin}; - -use collab::core::origin::{CollabClient, CollabOrigin}; -use collab::preclude::CollabPlugin; -use collab_entity::CollabType; -use collab_plugins::cloud_storage::postgres::SupabaseDBPlugin; -use tokio_stream::wrappers::WatchStream; -use tracing::debug; - -use collab_integrate::collab_builder::{ - CollabCloudPluginProvider, CollabPluginProviderContext, CollabPluginProviderType, -}; -use flowy_database_pub::cloud::{ - CollabDocStateByOid, DatabaseCloudService, DatabaseSnapshot, SummaryRowContent, -}; -use flowy_document::deps::DocumentData; -use flowy_document_pub::cloud::{DocumentCloudService, DocumentSnapshot}; -use flowy_error::FlowyError; -use flowy_folder_pub::cloud::{ - FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, Workspace, WorkspaceRecord, -}; -use flowy_server_pub::af_cloud_config::AFCloudConfiguration; -use flowy_server_pub::supabase_config::SupabaseConfiguration; -use flowy_storage::ObjectValue; -use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; -use flowy_user_pub::entities::{Authenticator, UserTokenState}; -use lib_infra::future::FutureResult; - -use crate::integrate::server::{Server, ServerProvider}; - -impl ObjectStorageService for ServerProvider { - fn get_object_url(&self, object_id: ObjectIdentity) -> FutureResult { - let server = self.get_server(); - FutureResult::new(async move { - let storage = server?.file_storage().ok_or(FlowyError::internal())?; - storage.get_object_url(object_id).await - }) - } - - fn put_object(&self, url: String, val: ObjectValue) -> FutureResult<(), FlowyError> { - let server = self.get_server(); - FutureResult::new(async move { - let storage = server?.file_storage().ok_or(FlowyError::internal())?; - storage.put_object(url, val).await - }) - } - - fn delete_object(&self, url: String) -> FutureResult<(), FlowyError> { - let server = self.get_server(); - FutureResult::new(async move { - let storage = server?.file_storage().ok_or(FlowyError::internal())?; - storage.delete_object(url).await - }) - } - - fn get_object(&self, url: String) -> FutureResult { - let server = self.get_server(); - FutureResult::new(async move { - let storage = server?.file_storage().ok_or(FlowyError::internal())?; - storage.get_object(url).await - }) - } -} - -impl UserCloudServiceProvider for ServerProvider { - fn set_token(&self, token: &str) -> Result<(), FlowyError> { - let server = self.get_server()?; - server.set_token(token)?; - Ok(()) - } - - fn subscribe_token_state(&self) -> Option> { - let server = self.get_server().ok()?; - server.subscribe_token_state() - } - - fn set_enable_sync(&self, uid: i64, enable_sync: bool) { - if let Ok(server) = self.get_server() { - server.set_enable_sync(uid, enable_sync); - *self.user_enable_sync.write() = enable_sync; - *self.uid.write() = Some(uid); - } - } - - /// When user login, the provider type is set by the [Authenticator] and save to disk for next use. - /// - /// Each [Authenticator] has a corresponding [Server]. The [Server] is used - /// to create a new [AppFlowyServer] if it doesn't exist. Once the [Server] is set, - /// it will be used when user open the app again. - /// - fn set_user_authenticator(&self, authenticator: &Authenticator) { - self.set_authenticator(authenticator.clone()); - } - - fn get_user_authenticator(&self) -> Authenticator { - self.get_authenticator() - } - - fn set_network_reachable(&self, reachable: bool) { - if let Ok(server) = self.get_server() { - server.set_network_reachable(reachable); - } - } - - fn set_encrypt_secret(&self, secret: String) { - tracing::info!("🔑Set encrypt secret"); - self.encryption.write().set_secret(secret); - } - - /// Returns the [UserCloudService] base on the current [Server]. - /// Creates a new [AppFlowyServer] if it doesn't exist. - fn get_user_service(&self) -> Result, FlowyError> { - let user_service = self.get_server()?.user_service(); - Ok(user_service) - } - - fn service_url(&self) -> String { - match self.get_server_type() { - Server::Local => "".to_string(), - Server::AppFlowyCloud => AFCloudConfiguration::from_env() - .map(|config| config.base_url) - .unwrap_or_default(), - Server::Supabase => SupabaseConfiguration::from_env() - .map(|config| config.url) - .unwrap_or_default(), - } - } -} - -impl FolderCloudService for ServerProvider { - fn create_workspace(&self, uid: i64, name: &str) -> FutureResult { - let server = self.get_server(); - let name = name.to_string(); - FutureResult::new(async move { server?.folder_service().create_workspace(uid, &name).await }) - } - - fn open_workspace(&self, workspace_id: &str) -> FutureResult<(), Error> { - let workspace_id = workspace_id.to_string(); - let server = self.get_server(); - FutureResult::new(async move { server?.folder_service().open_workspace(&workspace_id).await }) - } - - fn get_all_workspace(&self) -> FutureResult, Error> { - let server = self.get_server(); - FutureResult::new(async move { server?.folder_service().get_all_workspace().await }) - } - - fn get_folder_data( - &self, - workspace_id: &str, - uid: &i64, - ) -> FutureResult, Error> { - let uid = *uid; - let server = self.get_server(); - let workspace_id = workspace_id.to_string(); - FutureResult::new(async move { - server? - .folder_service() - .get_folder_data(&workspace_id, &uid) - .await - }) - } - - fn get_folder_snapshots( - &self, - workspace_id: &str, - limit: usize, - ) -> FutureResult, Error> { - let workspace_id = workspace_id.to_string(); - let server = self.get_server(); - FutureResult::new(async move { - server? - .folder_service() - .get_folder_snapshots(&workspace_id, limit) - .await - }) - } - - fn get_folder_doc_state( - &self, - workspace_id: &str, - uid: i64, - collab_type: CollabType, - object_id: &str, - ) -> FutureResult, Error> { - let object_id = object_id.to_string(); - let workspace_id = workspace_id.to_string(); - let server = self.get_server(); - FutureResult::new(async move { - server? - .folder_service() - .get_folder_doc_state(&workspace_id, uid, collab_type, &object_id) - .await - }) - } - - fn batch_create_folder_collab_objects( - &self, - workspace_id: &str, - objects: Vec, - ) -> FutureResult<(), Error> { - let workspace_id = workspace_id.to_string(); - let server = self.get_server(); - FutureResult::new(async move { - server? - .folder_service() - .batch_create_folder_collab_objects(&workspace_id, objects) - .await - }) - } - - fn service_name(&self) -> String { - self - .get_server() - .map(|provider| provider.folder_service().service_name()) - .unwrap_or_default() - } -} - -impl DatabaseCloudService for ServerProvider { - fn get_database_object_doc_state( - &self, - object_id: &str, - collab_type: CollabType, - workspace_id: &str, - ) -> FutureResult>, Error> { - let workspace_id = workspace_id.to_string(); - let server = self.get_server(); - let database_id = object_id.to_string(); - FutureResult::new(async move { - server? - .database_service() - .get_database_object_doc_state(&database_id, collab_type, &workspace_id) - .await - }) - } - - fn batch_get_database_object_doc_state( - &self, - object_ids: Vec, - object_ty: CollabType, - workspace_id: &str, - ) -> FutureResult { - let workspace_id = workspace_id.to_string(); - let server = self.get_server(); - FutureResult::new(async move { - server? - .database_service() - .batch_get_database_object_doc_state(object_ids, object_ty, &workspace_id) - .await - }) - } - - fn get_database_collab_object_snapshots( - &self, - object_id: &str, - limit: usize, - ) -> FutureResult, Error> { - let server = self.get_server(); - let database_id = object_id.to_string(); - FutureResult::new(async move { - server? - .database_service() - .get_database_collab_object_snapshots(&database_id, limit) - .await - }) - } - - fn summary_database_row( - &self, - workspace_id: &str, - object_id: &str, - summary_row: SummaryRowContent, - ) -> FutureResult { - let workspace_id = workspace_id.to_string(); - let server = self.get_server(); - let object_id = object_id.to_string(); - FutureResult::new(async move { - server? - .database_service() - .summary_database_row(&workspace_id, &object_id, summary_row) - .await - }) - } -} - -impl DocumentCloudService for ServerProvider { - fn get_document_doc_state( - &self, - document_id: &str, - workspace_id: &str, - ) -> FutureResult, FlowyError> { - let workspace_id = workspace_id.to_string(); - let document_id = document_id.to_string(); - let server = self.get_server(); - FutureResult::new(async move { - server? - .document_service() - .get_document_doc_state(&document_id, &workspace_id) - .await - }) - } - - fn get_document_snapshots( - &self, - document_id: &str, - limit: usize, - workspace_id: &str, - ) -> FutureResult, Error> { - let workspace_id = workspace_id.to_string(); - let server = self.get_server(); - let document_id = document_id.to_string(); - FutureResult::new(async move { - server? - .document_service() - .get_document_snapshots(&document_id, limit, &workspace_id) - .await - }) - } - - fn get_document_data( - &self, - document_id: &str, - workspace_id: &str, - ) -> FutureResult, Error> { - let workspace_id = workspace_id.to_string(); - let server = self.get_server(); - let document_id = document_id.to_string(); - FutureResult::new(async move { - server? - .document_service() - .get_document_data(&document_id, &workspace_id) - .await - }) - } -} - -impl CollabCloudPluginProvider for ServerProvider { - fn provider_type(&self) -> CollabPluginProviderType { - self.get_server_type().into() - } - - fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { - // If the user is local, we don't need to create a sync plugin. - if self.get_server_type().is_local() { - debug!( - "User authenticator is local, skip create sync plugin for: {}", - context - ); - return vec![]; - } - - match context { - CollabPluginProviderContext::Local => vec![], - CollabPluginProviderContext::AppFlowyCloud { - uid: _, - collab_object, - local_collab, - } => { - if let Ok(server) = self.get_server() { - // to_fut(async move { - let mut plugins: Vec> = vec![]; - // If the user is local, we don't need to create a sync plugin. - - match server.collab_ws_channel(&collab_object.object_id) { - Ok(Some((channel, ws_connect_state, _is_connected))) => { - let origin = CollabOrigin::Client(CollabClient::new( - collab_object.uid, - collab_object.device_id.clone(), - )); - let sync_object = SyncObject::from(collab_object); - let (sink, stream) = (channel.sink(), channel.stream()); - let sink_config = SinkConfig::new().send_timeout(8); - let sync_plugin = SyncPlugin::new( - origin, - sync_object, - local_collab, - sink, - sink_config, - stream, - Some(channel), - ws_connect_state, - ); - plugins.push(Box::new(sync_plugin)); - }, - Ok(None) => { - tracing::error!("🔴Failed to get collab ws channel: channel is none"); - }, - Err(err) => tracing::error!("🔴Failed to get collab ws channel: {:?}", err), - } - plugins - } else { - vec![] - } - }, - CollabPluginProviderContext::Supabase { - uid, - collab_object, - local_collab, - local_collab_db, - } => { - let mut plugins: Vec> = vec![]; - if let Some(remote_collab_storage) = self - .get_server() - .ok() - .and_then(|provider| provider.collab_storage(&collab_object)) - { - plugins.push(Box::new(SupabaseDBPlugin::new( - uid, - collab_object, - local_collab, - 1, - remote_collab_storage, - local_collab_db, - ))); - } - plugins - }, - } - } - - fn is_sync_enabled(&self) -> bool { - *self.user_enable_sync.read() - } -} diff --git a/frontend/rust-lib/flowy-core/src/integrate/user.rs b/frontend/rust-lib/flowy-core/src/integrate/user.rs deleted file mode 100644 index b68e54f2dc..0000000000 --- a/frontend/rust-lib/flowy-core/src/integrate/user.rs +++ /dev/null @@ -1,218 +0,0 @@ -use std::sync::Arc; - -use anyhow::Context; -use collab_entity::CollabType; -use tracing::event; - -use collab_integrate::collab_builder::AppFlowyCollabBuilder; -use flowy_database2::DatabaseManager; -use flowy_document::manager::DocumentManager; -use flowy_error::{FlowyError, FlowyResult}; -use flowy_folder::manager::{FolderInitDataSource, FolderManager}; -use flowy_user::event_map::UserStatusCallback; -use flowy_user_pub::cloud::{UserCloudConfig, UserCloudServiceProvider}; -use flowy_user_pub::entities::{Authenticator, UserProfile, UserWorkspace}; -use lib_infra::future::{to_fut, Fut}; - -use crate::integrate::server::{Server, ServerProvider}; -use crate::AppFlowyCoreConfig; - -pub(crate) struct UserStatusCallbackImpl { - pub(crate) collab_builder: Arc, - pub(crate) folder_manager: Arc, - pub(crate) database_manager: Arc, - pub(crate) document_manager: Arc, - pub(crate) server_provider: Arc, - #[allow(dead_code)] - pub(crate) config: AppFlowyCoreConfig, -} - -impl UserStatusCallback for UserStatusCallbackImpl { - fn did_init( - &self, - user_id: i64, - user_authenticator: &Authenticator, - cloud_config: &Option, - user_workspace: &UserWorkspace, - _device_id: &str, - ) -> Fut> { - let user_id = user_id.to_owned(); - let user_workspace = user_workspace.clone(); - let folder_manager = self.folder_manager.clone(); - let database_manager = self.database_manager.clone(); - let document_manager = self.document_manager.clone(); - - self - .server_provider - .set_user_authenticator(user_authenticator); - - if let Some(cloud_config) = cloud_config { - self - .server_provider - .set_enable_sync(user_id, cloud_config.enable_sync); - if cloud_config.enable_encrypt { - self - .server_provider - .set_encrypt_secret(cloud_config.encrypt_secret.clone()); - } - } - - to_fut(async move { - folder_manager - .initialize( - user_id, - &user_workspace.id, - FolderInitDataSource::LocalDisk { - create_if_not_exist: false, - }, - ) - .await?; - database_manager.initialize(user_id).await?; - document_manager.initialize(user_id).await?; - Ok(()) - }) - } - - fn did_sign_in( - &self, - user_id: i64, - user_workspace: &UserWorkspace, - device_id: &str, - ) -> Fut> { - let device_id = device_id.to_owned(); - let user_id = user_id.to_owned(); - let user_workspace = user_workspace.clone(); - let folder_manager = self.folder_manager.clone(); - let database_manager = self.database_manager.clone(); - let document_manager = self.document_manager.clone(); - - to_fut(async move { - event!( - tracing::Level::TRACE, - "Notify did sign in: latest_workspace: {:?}, device_id: {}", - user_workspace, - device_id - ); - - folder_manager.initialize_with_workspace_id(user_id).await?; - database_manager.initialize(user_id).await?; - document_manager.initialize(user_id).await?; - Ok(()) - }) - } - - fn did_sign_up( - &self, - is_new_user: bool, - user_profile: &UserProfile, - user_workspace: &UserWorkspace, - device_id: &str, - ) -> Fut> { - let device_id = device_id.to_owned(); - let user_profile = user_profile.clone(); - let folder_manager = self.folder_manager.clone(); - let database_manager = self.database_manager.clone(); - let user_workspace = user_workspace.clone(); - let document_manager = self.document_manager.clone(); - self - .server_provider - .set_user_authenticator(&user_profile.authenticator); - let server_type = self.server_provider.get_server_type(); - - to_fut(async move { - event!( - tracing::Level::TRACE, - "Notify did sign up: is new: {} user_workspace: {:?}, device_id: {}", - is_new_user, - user_workspace, - device_id - ); - - // In the current implementation, when a user signs up for AppFlowy Cloud, a default workspace - // is automatically created for them. However, for users who sign up through Supabase, the creation - // of the default workspace relies on the client-side operation. This means that the process - // for initializing a default workspace differs depending on the sign-up method used. - let data_source = match folder_manager - .cloud_service - .get_folder_doc_state( - &user_workspace.id, - user_profile.uid, - CollabType::Folder, - &user_workspace.id, - ) - .await - { - Ok(doc_state) => match server_type { - Server::Local => FolderInitDataSource::LocalDisk { - create_if_not_exist: true, - }, - Server::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), - Server::Supabase => { - if is_new_user { - FolderInitDataSource::LocalDisk { - create_if_not_exist: true, - } - } else { - FolderInitDataSource::Cloud(doc_state) - } - }, - }, - Err(err) => match server_type { - Server::Local => FolderInitDataSource::LocalDisk { - create_if_not_exist: true, - }, - Server::AppFlowyCloud | Server::Supabase => { - return Err(FlowyError::from(err)); - }, - }, - }; - - folder_manager - .initialize_with_new_user( - user_profile.uid, - &user_profile.token, - is_new_user, - data_source, - &user_workspace.id, - ) - .await - .context("FolderManager error")?; - - database_manager - .initialize_with_new_user(user_profile.uid) - .await - .context("DatabaseManager error")?; - - document_manager - .initialize_with_new_user(user_profile.uid) - .await - .context("DocumentManager error")?; - Ok(()) - }) - } - - fn did_expired(&self, _token: &str, user_id: i64) -> Fut> { - let folder_manager = self.folder_manager.clone(); - to_fut(async move { - folder_manager.clear(user_id).await; - Ok(()) - }) - } - - fn open_workspace(&self, user_id: i64, _user_workspace: &UserWorkspace) -> Fut> { - let folder_manager = self.folder_manager.clone(); - let database_manager = self.database_manager.clone(); - let document_manager = self.document_manager.clone(); - - to_fut(async move { - folder_manager.initialize_with_workspace_id(user_id).await?; - database_manager.initialize(user_id).await?; - document_manager.initialize(user_id).await?; - Ok(()) - }) - } - - fn did_update_network(&self, reachable: bool) { - self.collab_builder.update_network(reachable); - } -} diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 36addf0fe7..c2800bd73b 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -1,23 +1,25 @@ #![allow(unused_doc_comments)] +use collab_integrate::collab_builder::AppFlowyCollabBuilder; +use collab_plugins::CollabKVDB; +use flowy_ai::ai_manager::AIManager; +use flowy_database2::DatabaseManager; +use flowy_document::manager::DocumentManager; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_folder::manager::FolderManager; use flowy_search::folder::indexer::FolderIndexManagerImpl; use flowy_search::services::manager::SearchManager; -use flowy_storage::ObjectStorageService; -use semver::Version; +use flowy_server::af_cloud::define::LoggedUser; +use std::path::PathBuf; use std::sync::{Arc, Weak}; use std::time::Duration; use sysinfo::System; use tokio::sync::RwLock; use tracing::{debug, error, event, info, instrument}; +use uuid::Uuid; -use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabPluginProviderType}; -use flowy_database2::DatabaseManager; -use flowy_document::manager::DocumentManager; -use flowy_error::{FlowyError, FlowyResult}; -use flowy_folder::manager::FolderManager; -use flowy_server::af_cloud::define::ServerUser; - -use flowy_sqlite::kv::StorePreferences; +use flowy_sqlite::kv::KVStorePreferences; +use flowy_storage::manager::StorageManager; use flowy_user::services::authenticate_user::AuthenticateUser; use flowy_user::services::entities::UserConfig; use flowy_user::user_manager::UserManager; @@ -25,21 +27,26 @@ use flowy_user::user_manager::UserManager; use lib_dispatch::prelude::*; use lib_dispatch::runtime::AFPluginRuntime; use lib_infra::priority_task::{TaskDispatcher, TaskRunner}; -use lib_infra::util::Platform; +use lib_infra::util::OperatingSystem; use lib_log::stream_log::StreamLogSender; use module::make_plugins; use crate::config::AppFlowyCoreConfig; +use crate::deps_resolve::file_storage_deps::FileStorageResolver; use crate::deps_resolve::*; -use crate::integrate::collab_interact::CollabInteractImpl; -use crate::integrate::log::init_log; -use crate::integrate::server::{current_server_type, Server, ServerProvider}; -use crate::integrate::user::UserStatusCallbackImpl; +use crate::log_filter::init_log; +use crate::server_layer::ServerProvider; +use deps_resolve::reminder_deps::CollabInteractImpl; +use flowy_sqlite::DBConnection; +use lib_infra::async_trait::async_trait; +use user_state_callback::UserStatusCallbackImpl; pub mod config; mod deps_resolve; -pub mod integrate; +mod log_filter; pub mod module; +pub(crate) mod server_layer; +pub(crate) mod user_state_callback; /// This name will be used as to identify the current [AppFlowyCore] instance. /// Don't change this. @@ -56,8 +63,10 @@ pub struct AppFlowyCore { pub event_dispatcher: Arc, pub server_provider: Arc, pub task_dispatcher: Arc>, - pub store_preference: Arc, + pub store_preference: Arc, pub search_manager: Arc, + pub ai_manager: Arc, + pub storage_manager: Arc, } impl AppFlowyCore { @@ -66,7 +75,7 @@ impl AppFlowyCore { runtime: Arc, stream_log_sender: Option>, ) -> Self { - let platform = Platform::from(&config.platform); + let platform = OperatingSystem::from(&config.platform); #[allow(clippy::if_same_then_else)] if cfg!(debug_assertions) { @@ -83,11 +92,13 @@ impl AppFlowyCore { init_log(&config, &platform, stream_log_sender); } - info!( - "💡{:?}, platform: {:?}", - System::long_os_version(), - platform - ); + if sysinfo::IS_SUPPORTED_SYSTEM { + info!( + "💡{:?}, platform: {:?}", + System::long_os_version(), + platform + ); + } Self::init(config, runtime).await } @@ -98,21 +109,22 @@ impl AppFlowyCore { #[instrument(skip(config, runtime))] async fn init(config: AppFlowyCoreConfig, runtime: Arc) -> Self { + config.ensure_path(); + // Init the key value database - let store_preference = Arc::new(StorePreferences::new(&config.storage_path).unwrap()); + let store_preference = Arc::new(KVStorePreferences::new(&config.storage_path).unwrap()); info!("🔥{:?}", &config); - let task_scheduler = TaskDispatcher::new(Duration::from_secs(2)); + let task_scheduler = TaskDispatcher::new(Duration::from_secs(10)); let task_dispatcher = Arc::new(RwLock::new(task_scheduler)); runtime.spawn(TaskRunner::run(task_dispatcher.clone())); - let app_version = Version::parse(&config.app_version).unwrap_or_else(|_| Version::new(0, 5, 4)); let user_config = UserConfig::new( &config.name, &config.storage_path, &config.application_path, &config.device_id, - app_version, + config.app_version.clone(), ); let authenticate_user = Arc::new(AuthenticateUser::new( @@ -120,12 +132,10 @@ impl AppFlowyCore { store_preference.clone(), )); - let server_type = current_server_type(); - debug!("🔥runtime:{}, server:{}", runtime, server_type); + debug!("🔥runtime:{}", runtime); let server_provider = Arc::new(ServerProvider::new( config.clone(), - server_type, Arc::downgrade(&store_preference), ServerUserImpl(Arc::downgrade(&authenticate_user)), )); @@ -139,7 +149,14 @@ impl AppFlowyCore { document_manager, collab_builder, search_manager, + ai_manager, + storage_manager, ) = async { + let storage_manager = FileStorageResolver::resolve( + Arc::downgrade(&authenticate_user), + server_provider.clone(), + &user_config.storage_path, + ); /// The shared collab builder is used to build the [Collab] instance. The plugins will be loaded /// on demand based on the [CollabPluginConfig]. let collab_builder = Arc::new(AppFlowyCollabBuilder::new( @@ -150,11 +167,41 @@ impl AppFlowyCore { collab_builder .set_snapshot_persistence(Arc::new(SnapshotDBImpl(Arc::downgrade(&authenticate_user)))); + let folder_indexer = Arc::new(FolderIndexManagerImpl::new(Arc::downgrade( + &authenticate_user, + ))); + + let folder_manager = FolderDepsResolver::resolve( + Arc::downgrade(&authenticate_user), + collab_builder.clone(), + server_provider.clone(), + folder_indexer.clone(), + store_preference.clone(), + ) + .await; + + let folder_query_service = FolderServiceImpl::new( + Arc::downgrade(&folder_manager), + Arc::downgrade(&authenticate_user), + ); + + let ai_manager = ChatDepsResolver::resolve( + Arc::downgrade(&authenticate_user), + server_provider.clone(), + store_preference.clone(), + Arc::downgrade(&storage_manager.storage_service), + server_provider.clone(), + folder_query_service.clone(), + server_provider.local_ai.clone(), + ); + let database_manager = DatabaseDepsResolver::resolve( Arc::downgrade(&authenticate_user), task_dispatcher.clone(), collab_builder.clone(), server_provider.clone(), + server_provider.clone(), + ai_manager.clone(), ) .await; @@ -163,20 +210,9 @@ impl AppFlowyCore { &database_manager, collab_builder.clone(), server_provider.clone(), - Arc::downgrade(&(server_provider.clone() as Arc)), + Arc::downgrade(&storage_manager.storage_service), ); - let folder_indexer = Arc::new(FolderIndexManagerImpl::new(None)); - let folder_manager = FolderDepsResolver::resolve( - Arc::downgrade(&authenticate_user), - &document_manager, - &database_manager, - collab_builder.clone(), - server_provider.clone(), - folder_indexer.clone(), - ) - .await; - let user_manager = UserDepsResolver::resolve( authenticate_user.clone(), collab_builder.clone(), @@ -187,7 +223,20 @@ impl AppFlowyCore { ) .await; - let search_manager = SearchDepsResolver::resolve(folder_indexer).await; + let search_manager = SearchDepsResolver::resolve( + folder_indexer, + server_provider.clone(), + folder_manager.clone(), + ) + .await; + + // Register the folder operation handlers + register_handlers( + &folder_manager, + document_manager.clone(), + database_manager.clone(), + ai_manager.clone(), + ); ( user_manager, @@ -197,17 +246,22 @@ impl AppFlowyCore { document_manager, collab_builder, search_manager, + ai_manager, + storage_manager, ) } .await; let user_status_callback = UserStatusCallbackImpl { + user_manager: user_manager.clone(), collab_builder, folder_manager: folder_manager.clone(), database_manager: database_manager.clone(), document_manager: document_manager.clone(), server_provider: server_provider.clone(), - config: config.clone(), + storage_manager: storage_manager.clone(), + ai_manager: ai_manager.clone(), + runtime: runtime.clone(), }; let collab_interact_impl = CollabInteractImpl { @@ -224,6 +278,7 @@ impl AppFlowyCore { error!("Init user failed: {}", err) } } + #[allow(clippy::arc_with_non_send_sync)] let event_dispatcher = Arc::new(AFPluginDispatcher::new( runtime, make_plugins( @@ -232,6 +287,8 @@ impl AppFlowyCore { Arc::downgrade(&user_manager), Arc::downgrade(&document_manager), Arc::downgrade(&search_manager), + Arc::downgrade(&ai_manager), + Arc::downgrade(&storage_manager), ), )); @@ -246,6 +303,8 @@ impl AppFlowyCore { task_dispatcher, store_preference, search_manager, + ai_manager, + storage_manager, } } @@ -255,16 +314,6 @@ impl AppFlowyCore { } } -impl From for CollabPluginProviderType { - fn from(server_type: Server) -> Self { - match server_type { - Server::Local => CollabPluginProviderType::Local, - Server::AppFlowyCloud => CollabPluginProviderType::AppFlowyCloud, - Server::Supabase => CollabPluginProviderType::Supabase, - } - } -} - struct ServerUserImpl(Weak); impl ServerUserImpl { @@ -276,8 +325,32 @@ impl ServerUserImpl { Ok(user) } } -impl ServerUser for ServerUserImpl { - fn workspace_id(&self) -> FlowyResult { + +#[async_trait] +impl LoggedUser for ServerUserImpl { + fn workspace_id(&self) -> FlowyResult { self.upgrade_user()?.workspace_id() } + + fn user_id(&self) -> FlowyResult { + self.upgrade_user()?.user_id() + } + + async fn is_local_mode(&self) -> FlowyResult { + self.upgrade_user()?.is_local_mode().await + } + + fn get_sqlite_db(&self, uid: i64) -> Result { + self.upgrade_user()?.get_sqlite_connection(uid) + } + + fn get_collab_db(&self, uid: i64) -> Result, FlowyError> { + self.upgrade_user()?.get_collab_db(uid) + } + + fn application_root_dir(&self) -> Result { + Ok(PathBuf::from( + self.upgrade_user()?.get_application_root_dir(), + )) + } } diff --git a/frontend/rust-lib/flowy-core/src/log_filter.rs b/frontend/rust-lib/flowy-core/src/log_filter.rs new file mode 100644 index 0000000000..6704ad0507 --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/log_filter.rs @@ -0,0 +1,92 @@ +use lib_infra::util::OperatingSystem; +use lib_log::stream_log::StreamLogSender; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::AppFlowyCoreConfig; + +static INIT_LOG: AtomicBool = AtomicBool::new(false); +pub(crate) fn init_log( + config: &AppFlowyCoreConfig, + platform: &OperatingSystem, + stream_log_sender: Option>, +) { + #[cfg(debug_assertions)] + if get_bool_from_env_var("DISABLE_CI_TEST_LOG") { + return; + } + + if !INIT_LOG.load(Ordering::SeqCst) { + INIT_LOG.store(true, Ordering::SeqCst); + + let _ = lib_log::Builder::new("log", &config.storage_path, platform, stream_log_sender) + .env_filter(&config.log_filter) + .build(); + } +} + +pub fn create_log_filter( + level: String, + with_crates: Vec, + platform: OperatingSystem, +) -> String { + let mut level = std::env::var("RUST_LOG").unwrap_or(level); + + #[cfg(debug_assertions)] + if matches!(platform, OperatingSystem::IOS) { + level = "trace".to_string(); + } + + let mut filters = with_crates + .into_iter() + .map(|crate_name| format!("{}={}", crate_name, level)) + .collect::>(); + filters.push(format!("flowy_core={}", level)); + filters.push(format!("flowy_folder={}", level)); + filters.push(format!("collab_sync={}", level)); + filters.push(format!("collab_folder={}", level)); + filters.push(format!("collab_database={}", level)); + filters.push(format!("collab_plugins={}", level)); + filters.push(format!("collab_integrate={}", level)); + filters.push(format!("collab={}", level)); + filters.push(format!("flowy_user={}", level)); + filters.push(format!("flowy_document={}", level)); + filters.push(format!("flowy_database2={}", level)); + filters.push(format!("flowy_server={}", level)); + filters.push(format!("flowy_notification={}", "info")); + filters.push(format!("lib_infra={}", level)); + filters.push(format!("flowy_search={}", level)); + filters.push(format!("flowy_chat={}", level)); + filters.push(format!("af_local_ai={}", level)); + filters.push(format!("af_plugin={}", level)); + filters.push(format!("flowy_ai={}", level)); + filters.push(format!("flowy_storage={}", level)); + // Enable the frontend logs. DO NOT DISABLE. + // These logs are essential for debugging and verifying frontend behavior. + filters.push(format!("dart_ffi={}", level)); + + // Most of the time, we don't need to see the logs from the following crates + // filters.push(format!("flowy_sqlite={}", "info")); + // filters.push(format!("lib_dispatch={}", level)); + + filters.push(format!("client_api={}", level)); + filters.push(format!("infra={}", level)); + #[cfg(feature = "profiling")] + filters.push(format!("tokio={}", level)); + #[cfg(feature = "profiling")] + filters.push(format!("runtime={}", level)); + + filters.join(",") +} + +#[cfg(debug_assertions)] +fn get_bool_from_env_var(env_var_name: &str) -> bool { + match std::env::var(env_var_name) { + Ok(value) => match value.to_lowercase().as_str() { + "true" | "1" => true, + "false" | "0" => false, + _ => false, + }, + Err(_) => false, + } +} diff --git a/frontend/rust-lib/flowy-core/src/module.rs b/frontend/rust-lib/flowy-core/src/module.rs index 8d021955ef..2e657bd9ca 100644 --- a/frontend/rust-lib/flowy-core/src/module.rs +++ b/frontend/rust-lib/flowy-core/src/module.rs @@ -1,9 +1,11 @@ +use flowy_ai::ai_manager::AIManager; use std::sync::Weak; use flowy_database2::DatabaseManager; use flowy_document::manager::DocumentManager as DocumentManager2; use flowy_folder::manager::FolderManager; use flowy_search::services::manager::SearchManager; +use flowy_storage::manager::StorageManager; use flowy_user::user_manager::UserManager; use lib_dispatch::prelude::AFPlugin; @@ -13,25 +15,25 @@ pub fn make_plugins( user_session: Weak, document_manager2: Weak, search_manager: Weak, + ai_manager: Weak, + file_storage_manager: Weak, ) -> Vec { - let store_preferences = user_session - .upgrade() - .map(|session| session.get_store_preferences()) - .unwrap(); let user_plugin = flowy_user::event_map::init(user_session); let folder_plugin = flowy_folder::event_map::init(folder_manager); let database_plugin = flowy_database2::event_map::init(database_manager); let document_plugin2 = flowy_document::event_map::init(document_manager2); - let config_plugin = flowy_config::event_map::init(store_preferences); let date_plugin = flowy_date::event_map::init(); let search_plugin = flowy_search::event_map::init(search_manager); + let ai_plugin = flowy_ai::event_map::init(ai_manager); + let file_storage_plugin = flowy_storage::event_map::init(file_storage_manager); vec![ user_plugin, folder_plugin, database_plugin, document_plugin2, - config_plugin, date_plugin, search_plugin, + ai_plugin, + file_storage_plugin, ] } diff --git a/frontend/rust-lib/flowy-core/src/server_layer.rs b/frontend/rust-lib/flowy-core/src/server_layer.rs new file mode 100644 index 0000000000..b666ab4749 --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/server_layer.rs @@ -0,0 +1,134 @@ +use crate::AppFlowyCoreConfig; +use af_plugin::manager::PluginManager; +use arc_swap::{ArcSwap, ArcSwapOption}; +use dashmap::mapref::one::Ref; +use dashmap::DashMap; +use flowy_ai::local_ai::controller::LocalAIController; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_server::af_cloud::{ + define::{AIUserServiceImpl, LoggedUser}, + AppFlowyCloudServer, +}; +use flowy_server::local_server::LocalServer; +use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl}; +use flowy_server_pub::AuthenticatorType; +use flowy_sqlite::kv::KVStorePreferences; +use flowy_user_pub::entities::*; +use std::ops::Deref; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Weak}; +use tracing::info; + +pub struct ServerProvider { + config: AppFlowyCoreConfig, + providers: DashMap>, + auth_type: ArcSwap, + logged_user: Arc, + pub local_ai: Arc, + pub uid: Arc>, + pub user_enable_sync: Arc, + pub encryption: Arc, +} + +// Our little guard wrapper: +pub struct ServerHandle<'a>(Ref<'a, AuthType, Arc>); + +impl<'a> Deref for ServerHandle<'a> { + type Target = dyn AppFlowyServer; + fn deref(&self) -> &Self::Target { + // `self.0.value()` is an `&Arc` + // so `&**` gives us a `&dyn AppFlowyServer` + &**self.0.value() + } +} + +/// Determine current server type from ENV +pub fn current_server_type() -> AuthType { + match AuthenticatorType::from_env() { + AuthenticatorType::Local => AuthType::Local, + AuthenticatorType::AppFlowyCloud => AuthType::AppFlowyCloud, + } +} + +impl ServerProvider { + pub fn new( + config: AppFlowyCoreConfig, + store_preferences: Weak, + user_service: impl LoggedUser + 'static, + ) -> Self { + let initial_auth = current_server_type(); + let logged_user = Arc::new(user_service) as Arc; + let auth_type = ArcSwap::from(Arc::new(initial_auth)); + let encryption = Arc::new(EncryptionImpl::new(None)) as Arc; + let ai_user = Arc::new(AIUserServiceImpl(Arc::downgrade(&logged_user))); + let plugins = Arc::new(PluginManager::new()); + let local_ai = Arc::new(LocalAIController::new( + plugins, + store_preferences, + ai_user.clone(), + )); + + ServerProvider { + config, + providers: DashMap::new(), + encryption, + user_enable_sync: Arc::new(AtomicBool::new(true)), + auth_type, + logged_user, + uid: Default::default(), + local_ai, + } + } + + pub fn set_auth_type(&self, new_auth_type: AuthType) { + let old_type = self.get_auth_type(); + if old_type != new_auth_type { + info!( + "ServerProvider: auth type from {:?} to {:?}", + old_type, new_auth_type + ); + + self.auth_type.store(Arc::new(new_auth_type)); + if let Some((auth_type, _)) = self.providers.remove(&old_type) { + info!("ServerProvider: remove old auth type: {:?}", auth_type); + } + } + } + + pub fn get_auth_type(&self) -> AuthType { + *self.auth_type.load_full().as_ref() + } + + /// Lazily create or fetch an AppFlowyServer instance + pub fn get_server(&self) -> FlowyResult { + let auth_type = self.get_auth_type(); + if let Some(r) = self.providers.get(&auth_type) { + return Ok(ServerHandle(r)); + } + + let server: Arc = match auth_type { + AuthType::Local => Arc::new(LocalServer::new( + self.logged_user.clone(), + self.local_ai.clone(), + )), + AuthType::AppFlowyCloud => { + let cfg = self + .config + .cloud_config + .clone() + .ok_or_else(|| FlowyError::internal().with_context("Missing cloud config"))?; + Arc::new(AppFlowyCloudServer::new( + cfg, + self.user_enable_sync.load(Ordering::Acquire), + self.config.device_id.clone(), + self.config.app_version.clone(), + Arc::downgrade(&self.logged_user), + )) + }, + }; + + self.providers.insert(auth_type, server); + let guard = self.providers.get(&auth_type).unwrap(); + Ok(ServerHandle(guard)) + } +} diff --git a/frontend/rust-lib/flowy-core/src/user_state_callback.rs b/frontend/rust-lib/flowy-core/src/user_state_callback.rs new file mode 100644 index 0000000000..3be1bf15ed --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/user_state_callback.rs @@ -0,0 +1,300 @@ +use std::sync::Arc; + +use anyhow::Context; +use client_api::entity::billing_dto::SubscriptionPlan; +use tracing::{error, event, info}; + +use crate::server_layer::ServerProvider; +use collab_entity::CollabType; +use collab_integrate::collab_builder::AppFlowyCollabBuilder; +use collab_plugins::local_storage::kv::doc::CollabKVAction; +use collab_plugins::local_storage::kv::KVTransactionDB; +use flowy_ai::ai_manager::AIManager; +use flowy_database2::DatabaseManager; +use flowy_document::manager::DocumentManager; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_folder::manager::{FolderInitDataSource, FolderManager}; +use flowy_storage::manager::StorageManager; +use flowy_user::event_map::UserStatusCallback; +use flowy_user::user_manager::UserManager; +use flowy_user_pub::cloud::{UserCloudConfig, UserCloudServiceProvider}; +use flowy_user_pub::entities::{AuthType, UserProfile, UserWorkspace}; +use lib_dispatch::runtime::AFPluginRuntime; +use lib_infra::async_trait::async_trait; +use uuid::Uuid; + +pub(crate) struct UserStatusCallbackImpl { + pub(crate) user_manager: Arc, + pub(crate) collab_builder: Arc, + pub(crate) folder_manager: Arc, + pub(crate) database_manager: Arc, + pub(crate) document_manager: Arc, + pub(crate) server_provider: Arc, + pub(crate) storage_manager: Arc, + pub(crate) ai_manager: Arc, + // By default, all callback will run on the caller thread. If you don't want to block the caller + // thread, you can use runtime to spawn a new task. + pub(crate) runtime: Arc, +} + +impl UserStatusCallbackImpl { + fn init_ai_component(&self, workspace_id: String) { + let cloned_ai_manager = self.ai_manager.clone(); + self.runtime.spawn(async move { + if let Err(err) = cloned_ai_manager.initialize(&workspace_id).await { + error!("Failed to initialize AIManager: {:?}", err); + } + }); + } + + async fn folder_init_data_source( + &self, + user_id: i64, + workspace_id: &Uuid, + auth_type: &AuthType, + ) -> FlowyResult { + if self.is_object_exist_on_disk(user_id, workspace_id, workspace_id)? { + return Ok(FolderInitDataSource::LocalDisk { + create_if_not_exist: false, + }); + } + let doc_state_result = self + .folder_manager + .cloud_service + .get_folder_doc_state(workspace_id, user_id, CollabType::Folder, workspace_id) + .await; + resolve_data_source(auth_type, doc_state_result) + } + + fn is_object_exist_on_disk( + &self, + user_id: i64, + workspace_id: &Uuid, + object_id: &Uuid, + ) -> FlowyResult { + let db = self + .user_manager + .get_collab_db(user_id)? + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Collab db is not initialized"))?; + let read = db.read_txn(); + let workspace_id = workspace_id.to_string(); + let object_id = object_id.to_string(); + Ok(read.is_exist(user_id, &workspace_id, &object_id)) + } +} + +#[async_trait] +impl UserStatusCallback for UserStatusCallbackImpl { + async fn on_launch_if_authenticated( + &self, + user_id: i64, + cloud_config: &Option, + user_workspace: &UserWorkspace, + _device_id: &str, + auth_type: &AuthType, + ) -> FlowyResult<()> { + let workspace_id = user_workspace.workspace_id()?; + + if let Some(cloud_config) = cloud_config { + self + .server_provider + .set_enable_sync(user_id, cloud_config.enable_sync); + if cloud_config.enable_encrypt { + self + .server_provider + .set_encrypt_secret(cloud_config.encrypt_secret.clone()); + } + } + + self + .folder_manager + .initialize( + user_id, + &workspace_id, + FolderInitDataSource::LocalDisk { + create_if_not_exist: false, + }, + ) + .await?; + self + .database_manager + .initialize(user_id, auth_type == &AuthType::Local) + .await?; + self.document_manager.initialize(user_id).await?; + + let workspace_id = user_workspace.id.clone(); + self.init_ai_component(workspace_id); + Ok(()) + } + + async fn on_sign_in( + &self, + user_id: i64, + user_workspace: &UserWorkspace, + device_id: &str, + auth_type: &AuthType, + ) -> FlowyResult<()> { + event!( + tracing::Level::TRACE, + "Notify did sign in: latest_workspace: {:?}, device_id: {}", + user_workspace, + device_id + ); + let workspace_id = user_workspace.workspace_id()?; + let data_source = self + .folder_init_data_source(user_id, &workspace_id, auth_type) + .await?; + self + .folder_manager + .initialize_after_sign_in(user_id, data_source) + .await?; + self + .database_manager + .initialize_after_sign_in(user_id, auth_type.is_local()) + .await?; + self + .document_manager + .initialize_after_sign_in(user_id) + .await?; + + let workspace_id = user_workspace.id.clone(); + self.init_ai_component(workspace_id); + Ok(()) + } + + async fn on_sign_up( + &self, + is_new_user: bool, + user_profile: &UserProfile, + user_workspace: &UserWorkspace, + device_id: &str, + auth_type: &AuthType, + ) -> FlowyResult<()> { + event!( + tracing::Level::TRACE, + "Notify did sign up: is new: {} user_workspace: {:?}, device_id: {}", + is_new_user, + user_workspace, + device_id + ); + let workspace_id = user_workspace.workspace_id()?; + let data_source = self + .folder_init_data_source(user_profile.uid, &workspace_id, auth_type) + .await?; + + self + .folder_manager + .initialize_after_sign_up( + user_profile.uid, + &user_profile.token, + is_new_user, + data_source, + &workspace_id, + ) + .await + .context("FolderManager error")?; + + self + .database_manager + .initialize_after_sign_up(user_profile.uid, auth_type.is_local()) + .await + .context("DatabaseManager error")?; + + self + .document_manager + .initialize_after_sign_up(user_profile.uid) + .await + .context("DocumentManager error")?; + + let workspace_id = user_workspace.id.clone(); + self.init_ai_component(workspace_id); + Ok(()) + } + + async fn on_token_expired(&self, _token: &str, user_id: i64) -> FlowyResult<()> { + self.folder_manager.clear(user_id).await; + Ok(()) + } + + async fn on_workspace_opened( + &self, + user_id: i64, + workspace_id: &Uuid, + _user_workspace: &UserWorkspace, + auth_type: &AuthType, + ) -> FlowyResult<()> { + let data_source = self + .folder_init_data_source(user_id, workspace_id, auth_type) + .await?; + + self + .folder_manager + .initialize_after_open_workspace(user_id, data_source) + .await?; + self + .database_manager + .initialize_after_open_workspace(user_id, auth_type.is_local()) + .await?; + self + .document_manager + .initialize_after_open_workspace(user_id) + .await?; + self + .ai_manager + .initialize_after_open_workspace(workspace_id) + .await?; + self + .storage_manager + .initialize_after_open_workspace(workspace_id) + .await; + Ok(()) + } + + fn on_network_status_changed(&self, reachable: bool) { + info!("Notify did update network: reachable: {}", reachable); + self.collab_builder.update_network(reachable); + self.storage_manager.update_network_reachable(reachable); + } + + fn on_subscription_plans_updated(&self, plans: Vec) { + let mut storage_plan_changed = false; + for plan in &plans { + match plan { + SubscriptionPlan::Pro | SubscriptionPlan::Team => storage_plan_changed = true, + _ => {}, + } + } + if storage_plan_changed { + self.storage_manager.enable_storage_write_access(); + } + } + + fn on_storage_permission_updated(&self, can_write: bool) { + if can_write { + self.storage_manager.enable_storage_write_access(); + } else { + self.storage_manager.disable_storage_write_access(); + } + } +} + +fn resolve_data_source( + auth_type: &AuthType, + doc_state_result: Result, FlowyError>, +) -> FlowyResult { + match doc_state_result { + Ok(doc_state) => Ok(match auth_type { + AuthType::Local => FolderInitDataSource::LocalDisk { + create_if_not_exist: true, + }, + AuthType::AppFlowyCloud => FolderInitDataSource::Cloud(doc_state), + }), + Err(err) => match auth_type { + AuthType::Local => Ok(FolderInitDataSource::LocalDisk { + create_if_not_exist: true, + }), + AuthType::AppFlowyCloud => Err(err), + }, + } +} diff --git a/frontend/rust-lib/flowy-database-pub/Cargo.toml b/frontend/rust-lib/flowy-database-pub/Cargo.toml index eb6358cb8c..088c7b6465 100644 --- a/frontend/rust-lib/flowy-database-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-database-pub/Cargo.toml @@ -9,4 +9,6 @@ edition = "2021" lib-infra = { workspace = true } collab-entity = { workspace = true } collab = { workspace = true } -anyhow.workspace = true \ No newline at end of file +client-api = { workspace = true } +flowy-error = { workspace = true } +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-database-pub/src/cloud.rs b/frontend/rust-lib/flowy-database-pub/src/cloud.rs index 5df6325362..8666e6c764 100644 --- a/frontend/rust-lib/flowy-database-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-database-pub/src/cloud.rs @@ -1,44 +1,72 @@ -use anyhow::Error; -use collab::core::collab::DataSource; +pub use client_api::entity::ai_dto::{TranslateItem, TranslateRowResponse}; +use collab::entity::EncodedCollab; use collab_entity::CollabType; -use lib_infra::future::FutureResult; +use flowy_error::FlowyError; +use lib_infra::async_trait::async_trait; use std::collections::HashMap; +use uuid::Uuid; -pub type CollabDocStateByOid = HashMap; +pub type EncodeCollabByOid = HashMap; pub type SummaryRowContent = HashMap; +pub type TranslateRowContent = Vec; + +#[async_trait] +pub trait DatabaseAIService: Send + Sync { + async fn summary_database_row( + &self, + _workspace_id: &Uuid, + _object_id: &Uuid, + _summary_row: SummaryRowContent, + ) -> Result { + Ok("".to_string()) + } + + async fn translate_database_row( + &self, + _workspace_id: &Uuid, + _translate_row: TranslateRowContent, + _language: &str, + ) -> Result { + Ok(TranslateRowResponse::default()) + } +} + /// A trait for database cloud service. /// Each kind of server should implement this trait. Check out the [AppFlowyServerProvider] of /// [flowy-server] crate for more information. /// /// returns the doc state of the object with the given object_id. /// None if the object is not found. +/// +#[async_trait] pub trait DatabaseCloudService: Send + Sync { - fn get_database_object_doc_state( + async fn get_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, - ) -> FutureResult>, Error>; + workspace_id: &Uuid, + ) -> Result, FlowyError>; - fn batch_get_database_object_doc_state( + async fn create_database_encode_collab( &self, - object_ids: Vec, + object_id: &Uuid, + collab_type: CollabType, + workspace_id: &Uuid, + encoded_collab: EncodedCollab, + ) -> Result<(), FlowyError>; + + async fn batch_get_database_encode_collab( + &self, + object_ids: Vec, object_ty: CollabType, - workspace_id: &str, - ) -> FutureResult; + workspace_id: &Uuid, + ) -> Result; - fn get_database_collab_object_snapshots( + async fn get_database_collab_object_snapshots( &self, - object_id: &str, + object_id: &Uuid, limit: usize, - ) -> FutureResult, Error>; - - fn summary_database_row( - &self, - workspace_id: &str, - object_id: &str, - summary_row: SummaryRowContent, - ) -> FutureResult; + ) -> Result, FlowyError>; } pub struct DatabaseSnapshot { diff --git a/frontend/rust-lib/flowy-database2/Cargo.toml b/frontend/rust-lib/flowy-database2/Cargo.toml index 97a11f3064..ec0eb94210 100644 --- a/frontend/rust-lib/flowy-database2/Cargo.toml +++ b/frontend/rust-lib/flowy-database2/Cargo.toml @@ -15,12 +15,12 @@ flowy-database-pub = { workspace = true } flowy-derive.workspace = true flowy-notification = { workspace = true } -parking_lot.workspace = true protobuf.workspace = true -flowy-error = { workspace = true, features = [ - "impl_from_dispatch_error", - "impl_from_collab_database", +flowy-error = { path = "../flowy-error", features = [ + "impl_from_dispatch_error", + "impl_from_collab_database", ] } + lib-dispatch = { workspace = true } tokio = { workspace = true, features = ["sync"] } bytes.workspace = true @@ -28,6 +28,7 @@ tracing.workspace = true serde.workspace = true serde_json.workspace = true serde_repr.workspace = true +arc-swap.workspace = true lib-infra = { workspace = true } chrono = { workspace = true, default-features = false, features = ["clock"] } rust_decimal = "1.28.1" @@ -37,20 +38,24 @@ indexmap = { version = "2.1.0", features = ["serde"] } url = { version = "2" } fancy-regex = "0.11.0" futures.workspace = true -dashmap = "5" +dashmap.workspace = true anyhow.workspace = true async-stream = "0.3.4" rayon = "1.9.0" nanoid = "0.4.0" async-trait.workspace = true chrono-tz = "0.8.2" -csv = "1.1.6" +csv = "1.3.0" strum = "0.25" strum_macros = "0.25" -validator = { version = "0.16.0", features = ["derive"] } +validator = { workspace = true, features = ["derive"] } +tokio-util.workspace = true +moka = { version = "0.12.8", features = ["future"] } +uuid.workspace = true [dev-dependencies] event-integration-test = { path = "../event-integration-test", default-features = false } +flowy-database2 = { path = ".", features = ["verbose_log"] } [build-dependencies] flowy-codegen.workspace = true @@ -58,4 +63,4 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] +verbose_log = ["collab-database/verbose_log"] \ No newline at end of file diff --git a/frontend/rust-lib/flowy-database2/build.rs b/frontend/rust-lib/flowy-database2/build.rs index aeaaee42f3..e10aed7956 100644 --- a/frontend/rust-lib/flowy-database2/build.rs +++ b/frontend/rust-lib/flowy-database2/build.rs @@ -5,19 +5,19 @@ fn main() { flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - #[cfg(feature = "ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } + // #[cfg(feature = "ts")] + // { + // flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); + // flowy_codegen::protobuf_file::ts_gen( + // env!("CARGO_PKG_NAME"), + // env!("CARGO_PKG_NAME"), + // flowy_codegen::Project::Tauri, + // ); + // flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); + // flowy_codegen::protobuf_file::ts_gen( + // env!("CARGO_PKG_NAME"), + // env!("CARGO_PKG_NAME"), + // flowy_codegen::Project::TauriApp, + // ); + // } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_changeset.rs b/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_changeset.rs index a760c3cee5..c7fbb3368f 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_changeset.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_changeset.rs @@ -8,14 +8,14 @@ use validator::Validate; #[derive(Default, ProtoBuf, Validate)] pub struct UpdateCalculationChangesetPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub view_id: String, #[pb(index = 2, one_of)] pub calculation_id: Option, #[pb(index = 3)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub field_id: String, #[pb(index = 4)] @@ -25,15 +25,15 @@ pub struct UpdateCalculationChangesetPB { #[derive(Default, ProtoBuf, Validate)] pub struct RemoveCalculationChangesetPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub view_id: String, #[pb(index = 2)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub field_id: String, #[pb(index = 3)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub calculation_id: String, } diff --git a/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs index 8137b905d9..36451e9032 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs @@ -23,6 +23,19 @@ pub struct CalculationPB { pub value: String, } +impl std::convert::From<&CalculationPB> for Calculation { + fn from(calculation: &CalculationPB) -> Self { + let calculation_type = calculation.calculation_type.into(); + + Self { + id: calculation.id.clone(), + field_id: calculation.field_id.clone(), + calculation_type, + value: calculation.value.clone(), + } + } +} + impl std::convert::From<&Calculation> for CalculationPB { fn from(calculation: &Calculation) -> Self { let calculation_type = calculation.calculation_type.into(); diff --git a/frontend/rust-lib/flowy-database2/src/entities/calendar_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/calendar_entities.rs index e757ce4407..6ab424a230 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/calendar_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/calendar_entities.rs @@ -108,11 +108,8 @@ pub struct CalendarEventPB { #[pb(index = 3)] pub title: String, - #[pb(index = 4)] - pub timestamp: i64, - - #[pb(index = 5)] - pub is_scheduled: bool, + #[pb(index = 4, one_of)] + pub timestamp: Option, } #[derive(Debug, Clone, Default, ProtoBuf)] diff --git a/frontend/rust-lib/flowy-database2/src/entities/cell_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/cell_entities.rs index 94ecc9866b..9af549c698 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/cell_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/cell_entities.rs @@ -2,6 +2,8 @@ use collab_database::rows::RowId; use flowy_derive::ProtoBuf; use flowy_error::ErrorCode; +use lib_infra::validator_fn::required_not_empty_str; +use validator::Validate; use crate::entities::parser::NotEmptyStr; use crate::entities::FieldType; @@ -40,15 +42,18 @@ impl TryInto for CreateSelectOptionPayloadPB { } } -#[derive(Debug, Clone, Default, ProtoBuf)] +#[derive(Debug, Clone, Default, ProtoBuf, Validate)] pub struct CellIdPB { #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] pub view_id: String, #[pb(index = 2)] + #[validate(custom(function = "required_not_empty_str"))] pub field_id: String, #[pb(index = 3)] + #[validate(custom(function = "required_not_empty_str"))] pub row_id: String, } diff --git a/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs index 688e878caa..2562bd84f7 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs @@ -26,9 +26,6 @@ pub struct DatabasePB { #[pb(index = 4)] pub layout_type: DatabaseLayoutPB, - - #[pb(index = 5)] - pub is_linked: bool, } #[derive(ProtoBuf, Default)] @@ -77,7 +74,7 @@ pub struct RepeatedDatabaseIdPB { #[derive(Clone, ProtoBuf, Default, Debug, Validate)] pub struct DatabaseViewIdPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub value: String, } @@ -155,6 +152,7 @@ impl TryInto for MoveRowPayloadPB { }) } } + #[derive(Debug, Clone, Default, ProtoBuf)] pub struct MoveGroupRowPayloadPB { #[pb(index = 1)] @@ -207,7 +205,7 @@ pub struct DatabaseMetaPB { pub database_id: String, #[pb(index = 2)] - pub inline_view_id: String, + pub view_id: String, } #[derive(Debug, Default, ProtoBuf)] @@ -323,3 +321,31 @@ pub struct DatabaseSnapshotPB { #[pb(index = 4)] pub data: Vec, } + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RemoveCoverPayloadPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub row_id: String, +} + +pub struct RemoveCoverParams { + pub view_id: String, + pub row_id: RowId, +} + +impl TryInto for RemoveCoverPayloadPB { + type Error = ErrorCode; + + fn try_into(self) -> Result { + let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::DatabaseViewIdIsEmpty)?; + let row_id = NotEmptyStr::parse(self.row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?; + + Ok(RemoveCoverParams { + view_id: view_id.0, + row_id: RowId::from(row_id.0), + }) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs index ad8635d80d..8731b63b66 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs @@ -10,6 +10,7 @@ use strum_macros::{EnumCount as EnumCountMacro, EnumIter}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; +use lib_infra::validator_fn::required_not_empty_str; use validator::Validate; use crate::entities::parser::NotEmptyStr; @@ -27,12 +28,15 @@ pub struct FieldPB { pub name: String, #[pb(index = 3)] + pub icon: String, + + #[pb(index = 4)] pub field_type: FieldType, - #[pb(index = 6)] + #[pb(index = 5)] pub is_primary: bool, - #[pb(index = 7)] + #[pb(index = 6)] pub type_option_data: Vec, } @@ -45,6 +49,7 @@ impl FieldPB { Self { id: field.id, name: field.name, + icon: field.icon, field_type, is_primary: field.is_primary, type_option_data: type_option_to_pb(type_option, &field_type).to_vec(), @@ -152,12 +157,15 @@ pub struct CreateFieldPayloadPB { #[pb(index = 3, one_of)] pub field_name: Option, + #[pb(index = 4, one_of)] + pub field_icon: Option, + /// If the type_option_data is not empty, it will be used to create the field. /// Otherwise, the default value will be used. - #[pb(index = 4, one_of)] + #[pb(index = 5, one_of)] pub type_option_data: Option>, - #[pb(index = 5)] + #[pb(index = 6)] pub field_position: OrderObjectPositionPB, } @@ -207,12 +215,16 @@ pub struct UpdateFieldTypePayloadPB { #[pb(index = 3)] pub field_type: FieldType, + + #[pb(index = 4, one_of)] + pub field_name: Option, } pub struct EditFieldParams { pub view_id: String, pub field_id: String, pub field_type: FieldType, + pub field_name: Option, } impl TryInto for UpdateFieldTypePayloadPB { @@ -225,6 +237,7 @@ impl TryInto for UpdateFieldTypePayloadPB { view_id: view_id.0, field_id: field_id.0, field_type: self.field_type, + field_name: self.field_name, }) } } @@ -364,53 +377,29 @@ impl TryInto for GetFieldPayloadPB { /// Pass in None if you don't want to modify a property /// Pass in Some(Value) if you want to modify a property /// -#[derive(Debug, Clone, Default, ProtoBuf)] +#[derive(Debug, Clone, Default, ProtoBuf, Validate)] pub struct FieldChangesetPB { #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] pub field_id: String, #[pb(index = 2)] + #[validate(custom(function = "required_not_empty_str"))] pub view_id: String, #[pb(index = 3, one_of)] pub name: Option, #[pb(index = 4, one_of)] - pub desc: Option, + pub icon: Option, #[pb(index = 5, one_of)] - pub frozen: Option, -} - -impl TryInto for FieldChangesetPB { - type Error = ErrorCode; - - fn try_into(self) -> Result { - let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::DatabaseIdIsEmpty)?; - let field_id = NotEmptyStr::parse(self.field_id).map_err(|_| ErrorCode::FieldIdIsEmpty)?; - - Ok(FieldChangesetParams { - field_id: field_id.0, - view_id: view_id.0, - name: self.name, - desc: self.desc, - frozen: self.frozen, - }) - } -} - -#[derive(Debug, Clone, Default)] -pub struct FieldChangesetParams { - pub field_id: String, - - pub view_id: String, - - pub name: Option, - pub desc: Option, + #[pb(index = 6, one_of)] pub frozen: Option, } + /// Certain field types have user-defined options such as color, date format, number format, /// or a list of values for a multi-select list. These options are defined within a specialization /// of the FieldTypeOption class. @@ -449,6 +438,9 @@ pub enum FieldType { CreatedTime = 9, Relation = 10, Summary = 11, + Translate = 12, + Time = 13, + Media = 14, } impl Display for FieldType { @@ -489,10 +481,17 @@ impl FieldType { FieldType::CreatedTime => "Created time", FieldType::Relation => "Relation", FieldType::Summary => "Summarize", + FieldType::Translate => "Translate", + FieldType::Time => "Time", + FieldType::Media => "Media", }; s.to_string() } + pub fn is_ai_field(&self) -> bool { + matches!(self, FieldType::Summary | FieldType::Translate) + } + pub fn is_number(&self) -> bool { matches!(self, FieldType::Number) } @@ -541,6 +540,14 @@ impl FieldType { matches!(self, FieldType::Relation) } + pub fn is_time(&self) -> bool { + matches!(self, FieldType::Time) + } + + pub fn is_media(&self) -> bool { + matches!(self, FieldType::Media) + } + pub fn can_be_group(&self) -> bool { self.is_select_option() || self.is_checkbox() || self.is_url() } @@ -599,11 +606,11 @@ impl TryInto for DuplicateFieldPayloadPB { #[derive(Debug, Clone, Default, ProtoBuf, Validate)] pub struct ClearFieldPayloadPB { #[pb(index = 1)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub field_id: String, #[pb(index = 2)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub view_id: String, } diff --git a/frontend/rust-lib/flowy-database2/src/entities/field_settings_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/field_settings_entities.rs index 1d59b0b87e..8171ed11a8 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/field_settings_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/field_settings_entities.rs @@ -101,11 +101,11 @@ impl std::convert::From> for RepeatedFieldSettingsPB { #[derive(Debug, Default, Clone, ProtoBuf, Validate)] pub struct FieldSettingsChangesetPB { - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] #[pb(index = 1)] pub view_id: String, - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] #[pb(index = 2)] pub field_id: String, diff --git a/frontend/rust-lib/flowy-database2/src/entities/file_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/file_entities.rs new file mode 100644 index 0000000000..6c2b0bd5ec --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/file_entities.rs @@ -0,0 +1,53 @@ +use collab_database::entity::FileUploadType; +use collab_database::fields::media_type_option::MediaUploadType; +use flowy_derive::ProtoBuf_Enum; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Clone, ProtoBuf_Enum, PartialEq, Eq, Copy, Serialize, Deserialize)] +#[repr(u8)] +pub enum FileUploadTypePB { + #[default] + LocalFile = 0, + NetworkFile = 1, + CloudFile = 2, +} + +impl From for MediaUploadType { + fn from(file_upload_type: FileUploadTypePB) -> Self { + match file_upload_type { + FileUploadTypePB::LocalFile => MediaUploadType::Local, + FileUploadTypePB::NetworkFile => MediaUploadType::Network, + FileUploadTypePB::CloudFile => MediaUploadType::Cloud, + } + } +} + +impl From for FileUploadTypePB { + fn from(file_upload_type: MediaUploadType) -> Self { + match file_upload_type { + MediaUploadType::Local => FileUploadTypePB::LocalFile, + MediaUploadType::Network => FileUploadTypePB::NetworkFile, + MediaUploadType::Cloud => FileUploadTypePB::CloudFile, + } + } +} + +impl From for FileUploadTypePB { + fn from(data: FileUploadType) -> Self { + match data { + FileUploadType::LocalFile => FileUploadTypePB::LocalFile, + FileUploadType::NetworkFile => FileUploadTypePB::NetworkFile, + FileUploadType::CloudFile => FileUploadTypePB::CloudFile, + } + } +} + +impl From for FileUploadType { + fn from(data: FileUploadTypePB) -> Self { + match data { + FileUploadTypePB::LocalFile => FileUploadType::LocalFile, + FileUploadTypePB::NetworkFile => FileUploadType::NetworkFile, + FileUploadTypePB::CloudFile => FileUploadType::CloudFile, + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/date_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/date_filter.rs index 01c3c9687c..4b4683eb35 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/date_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/date_filter.rs @@ -29,8 +29,8 @@ pub struct DateFilterContent { pub timestamp: Option, } -impl ToString for DateFilterContent { - fn to_string(&self) -> String { +impl DateFilterContent { + pub fn to_json_string(&self) -> String { serde_json::to_string(self).unwrap() } } @@ -47,14 +47,38 @@ impl FromStr for DateFilterContent { #[repr(u8)] pub enum DateFilterConditionPB { #[default] - DateIs = 0, - DateBefore = 1, - DateAfter = 2, - DateOnOrBefore = 3, - DateOnOrAfter = 4, - DateWithIn = 5, - DateIsEmpty = 6, - DateIsNotEmpty = 7, + DateStartsOn = 0, + DateStartsBefore = 1, + DateStartsAfter = 2, + DateStartsOnOrBefore = 3, + DateStartsOnOrAfter = 4, + DateStartsBetween = 5, + DateStartIsEmpty = 6, + DateStartIsNotEmpty = 7, + DateEndsOn = 8, + DateEndsBefore = 9, + DateEndsAfter = 10, + DateEndsOnOrBefore = 11, + DateEndsOnOrAfter = 12, + DateEndsBetween = 13, + DateEndIsEmpty = 14, + DateEndIsNotEmpty = 15, +} + +impl DateFilterConditionPB { + pub fn is_filter_on_start_timestamp(&self) -> bool { + matches!( + self, + Self::DateStartsOn + | Self::DateStartsBefore + | Self::DateStartsAfter + | Self::DateStartsOnOrBefore + | Self::DateStartsOnOrAfter + | Self::DateStartsBetween + | Self::DateStartIsEmpty + | Self::DateStartIsNotEmpty, + ) + } } impl std::convert::From for u32 { @@ -68,13 +92,22 @@ impl std::convert::TryFrom for DateFilterConditionPB { fn try_from(value: u8) -> Result { match value { - 0 => Ok(DateFilterConditionPB::DateIs), - 1 => Ok(DateFilterConditionPB::DateBefore), - 2 => Ok(DateFilterConditionPB::DateAfter), - 3 => Ok(DateFilterConditionPB::DateOnOrBefore), - 4 => Ok(DateFilterConditionPB::DateOnOrAfter), - 5 => Ok(DateFilterConditionPB::DateWithIn), - 6 => Ok(DateFilterConditionPB::DateIsEmpty), + 0 => Ok(Self::DateStartsOn), + 1 => Ok(Self::DateStartsBefore), + 2 => Ok(Self::DateStartsAfter), + 3 => Ok(Self::DateStartsOnOrBefore), + 4 => Ok(Self::DateStartsOnOrAfter), + 5 => Ok(Self::DateStartsBetween), + 6 => Ok(Self::DateStartIsEmpty), + 7 => Ok(Self::DateStartIsNotEmpty), + 8 => Ok(Self::DateEndsOn), + 9 => Ok(Self::DateEndsBefore), + 10 => Ok(Self::DateEndsAfter), + 11 => Ok(Self::DateEndsOnOrBefore), + 12 => Ok(Self::DateEndsOnOrAfter), + 13 => Ok(Self::DateEndsBetween), + 14 => Ok(Self::DateEndIsEmpty), + 15 => Ok(Self::DateEndIsNotEmpty), _ => Err(ErrorCode::InvalidParams), } } @@ -83,7 +116,7 @@ impl std::convert::TryFrom for DateFilterConditionPB { impl ParseFilterData for DateFilterPB { fn parse(condition: u8, content: String) -> Self { let condition = - DateFilterConditionPB::try_from(condition).unwrap_or(DateFilterConditionPB::DateIs); + DateFilterConditionPB::try_from(condition).unwrap_or(DateFilterConditionPB::DateStartsOn); let mut date_filter = Self { condition, ..Default::default() @@ -98,3 +131,13 @@ impl ParseFilterData for DateFilterPB { date_filter } } + +impl DateFilterPB { + pub fn remove_end_date_conditions(self) -> Self { + if self.condition.is_filter_on_start_timestamp() { + self + } else { + Self::default() + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/media_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/media_filter.rs new file mode 100644 index 0000000000..9cc9adc481 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/media_filter.rs @@ -0,0 +1,49 @@ +use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; +use flowy_error::ErrorCode; + +use crate::services::filter::ParseFilterData; + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct MediaFilterPB { + #[pb(index = 1)] + pub condition: MediaFilterConditionPB, + + #[pb(index = 2)] + pub content: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, ProtoBuf_Enum)] +#[repr(u8)] +pub enum MediaFilterConditionPB { + #[default] + MediaIsEmpty = 0, + MediaIsNotEmpty = 1, +} + +impl std::convert::From for u32 { + fn from(value: MediaFilterConditionPB) -> Self { + value as u32 + } +} + +impl std::convert::TryFrom for MediaFilterConditionPB { + type Error = ErrorCode; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(MediaFilterConditionPB::MediaIsEmpty), + 1 => Ok(MediaFilterConditionPB::MediaIsNotEmpty), + _ => Err(ErrorCode::InvalidParams), + } + } +} + +impl ParseFilterData for MediaFilterPB { + fn parse(condition: u8, content: String) -> Self { + Self { + condition: MediaFilterConditionPB::try_from(condition) + .unwrap_or(MediaFilterConditionPB::MediaIsEmpty), + content, + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs index 7840bd4ff6..0adb930020 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs @@ -2,18 +2,22 @@ mod checkbox_filter; mod checklist_filter; mod date_filter; mod filter_changeset; +mod media_filter; mod number_filter; mod relation_filter; mod select_option_filter; mod text_filter; +mod time_filter; mod util; pub use checkbox_filter::*; pub use checklist_filter::*; pub use date_filter::*; pub use filter_changeset::*; +pub use media_filter::*; pub use number_filter::*; pub use relation_filter::*; pub use select_option_filter::*; pub use text_filter::*; +pub use time_filter::*; pub use util::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/relation_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/relation_filter.rs index 1a186eb038..7c5d3d358d 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/relation_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/relation_filter.rs @@ -16,7 +16,7 @@ impl ParseFilterData for RelationFilterPB { } impl PreFillCellsWithFilter for RelationFilterPB { - fn get_compliant_cell(&self, _field: &Field) -> (Option, bool) { - (None, false) + fn get_compliant_cell(&self, _field: &Field) -> Option { + None } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs index 1643116ccb..37fd35aaa9 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs @@ -1,9 +1,9 @@ -use std::str::FromStr; - +use collab_database::fields::select_type_option::SelectOptionIds; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; +use std::str::FromStr; -use crate::services::{field::SelectOptionIds, filter::ParseFilterData}; +use crate::services::filter::ParseFilterData; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct SelectOptionFilterPB { @@ -58,3 +58,28 @@ impl ParseFilterData for SelectOptionFilterPB { } } } + +impl SelectOptionFilterPB { + pub fn to_single_select_filter(mut self) -> Self { + match self.condition { + SelectOptionFilterConditionPB::OptionContains + | SelectOptionFilterConditionPB::OptionDoesNotContain => { + self.condition = SelectOptionFilterConditionPB::OptionIs; + }, + _ => {}, + } + + self + } + + pub fn to_multi_select_filter(mut self) -> Self { + match self.condition { + SelectOptionFilterConditionPB::OptionIs | SelectOptionFilterConditionPB::OptionIsNot => { + self.condition = SelectOptionFilterConditionPB::OptionContains; + }, + _ => {}, + } + + self + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/time_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/time_filter.rs new file mode 100644 index 0000000000..bf1f734450 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/time_filter.rs @@ -0,0 +1,23 @@ +use flowy_derive::ProtoBuf; + +use crate::entities::NumberFilterConditionPB; +use crate::services::filter::ParseFilterData; + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct TimeFilterPB { + #[pb(index = 1)] + pub condition: NumberFilterConditionPB, + + #[pb(index = 2)] + pub content: String, +} + +impl ParseFilterData for TimeFilterPB { + fn parse(condition: u8, content: String) -> Self { + TimeFilterPB { + condition: NumberFilterConditionPB::try_from(condition) + .unwrap_or(NumberFilterConditionPB::Equal), + content, + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs index 7bcd2292bf..64331c9542 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs @@ -10,10 +10,12 @@ use validator::Validate; use crate::entities::{ CheckboxFilterPB, ChecklistFilterPB, DateFilterPB, FieldType, NumberFilterPB, RelationFilterPB, - SelectOptionFilterPB, TextFilterPB, + SelectOptionFilterPB, TextFilterPB, TimeFilterPB, }; use crate::services::filter::{Filter, FilterChangeset, FilterInner}; +use super::MediaFilterPB; + #[derive(Debug, Default, Clone, ProtoBuf_Enum, Eq, PartialEq, Copy)] #[repr(u8)] pub enum FilterType { @@ -109,6 +111,18 @@ impl From<&Filter> for FilterPB { .cloned::() .unwrap() .try_into(), + FieldType::Time => condition_and_content + .cloned::() + .unwrap() + .try_into(), + FieldType::Translate => condition_and_content + .cloned::() + .unwrap() + .try_into(), + FieldType::Media => condition_and_content + .cloned::() + .unwrap() + .try_into(), }; Self { @@ -156,6 +170,15 @@ impl TryFrom for FilterInner { FieldType::Summary => { BoxAny::new(TextFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) }, + FieldType::Time => { + BoxAny::new(TimeFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) + }, + FieldType::Translate => { + BoxAny::new(TextFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) + }, + FieldType::Media => { + BoxAny::new(MediaFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) + }, }; Ok(Self::Data { @@ -190,7 +213,7 @@ impl From> for RepeatedFilterPB { pub struct InsertFilterPB { /// If None, the filter will be the root of a new filter tree #[pb(index = 1, one_of)] - #[validate(custom = "crate::entities::utils::validate_filter_id")] + #[validate(custom(function = "crate::entities::utils::validate_filter_id"))] pub parent_filter_id: Option, #[pb(index = 2)] @@ -200,7 +223,7 @@ pub struct InsertFilterPB { #[derive(ProtoBuf, Debug, Default, Clone, Validate)] pub struct UpdateFilterTypePB { #[pb(index = 1)] - #[validate(custom = "crate::entities::utils::validate_filter_id")] + #[validate(custom(function = "crate::entities::utils::validate_filter_id"))] pub filter_id: String, #[pb(index = 2)] @@ -210,7 +233,7 @@ pub struct UpdateFilterTypePB { #[derive(ProtoBuf, Debug, Default, Clone, Validate)] pub struct UpdateFilterDataPB { #[pb(index = 1)] - #[validate(custom = "crate::entities::utils::validate_filter_id")] + #[validate(custom(function = "crate::entities::utils::validate_filter_id"))] pub filter_id: String, #[pb(index = 2)] @@ -220,12 +243,8 @@ pub struct UpdateFilterDataPB { #[derive(ProtoBuf, Debug, Default, Clone, Validate)] pub struct DeleteFilterPB { #[pb(index = 1)] - #[validate(custom = "crate::entities::utils::validate_filter_id")] + #[validate(custom(function = "crate::entities::utils::validate_filter_id"))] pub filter_id: String, - - #[pb(index = 2)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] - pub field_id: String, } impl TryFrom for FilterChangeset { @@ -274,7 +293,6 @@ impl From for FilterChangeset { fn from(value: DeleteFilterPB) -> Self { Self::Delete { filter_id: value.filter_id, - field_id: value.field_id, } } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/group_entities/configuration.rs b/frontend/rust-lib/flowy-database2/src/entities/group_entities/configuration.rs index a27e67ff1a..bc6c528809 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/group_entities/configuration.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/group_entities/configuration.rs @@ -1,5 +1,10 @@ -use crate::services::group::Group; +use crate::{ + entities::FieldType, + services::group::{DateCondition, DateGroupConfiguration, Group}, +}; +use bytes::Bytes; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; +use flowy_error::FlowyResult; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct URLGroupConfigurationPB { @@ -46,16 +51,33 @@ pub struct NumberGroupConfigurationPB { #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct DateGroupConfigurationPB { #[pb(index = 1)] - pub condition: DateCondition, + pub condition: DateConditionPB, #[pb(index = 2)] hide_empty: bool, } -#[derive(Debug, Clone, PartialEq, Eq, ProtoBuf_Enum)] +impl From for DateGroupConfiguration { + fn from(data: DateGroupConfigurationPB) -> Self { + Self { + condition: data.condition.into(), + hide_empty: data.hide_empty, + } + } +} + +impl From for DateGroupConfigurationPB { + fn from(data: DateGroupConfiguration) -> Self { + Self { + condition: data.condition.into(), + hide_empty: data.hide_empty, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, ProtoBuf_Enum, Default)] #[repr(u8)] -#[derive(Default)] -pub enum DateCondition { +pub enum DateConditionPB { #[default] Relative = 0, Day = 1, @@ -64,8 +86,56 @@ pub enum DateCondition { Year = 4, } +impl From for DateCondition { + fn from(data: DateConditionPB) -> Self { + match data { + DateConditionPB::Relative => DateCondition::Relative, + DateConditionPB::Day => DateCondition::Day, + DateConditionPB::Week => DateCondition::Week, + DateConditionPB::Month => DateCondition::Month, + DateConditionPB::Year => DateCondition::Year, + } + } +} + +impl From for DateConditionPB { + fn from(data: DateCondition) -> Self { + match data { + DateCondition::Relative => DateConditionPB::Relative, + DateCondition::Day => DateConditionPB::Day, + DateCondition::Week => DateConditionPB::Week, + DateCondition::Month => DateConditionPB::Month, + DateCondition::Year => DateConditionPB::Year, + } + } +} + #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct CheckboxGroupConfigurationPB { #[pb(index = 1)] pub(crate) hide_empty: bool, } + +pub fn group_config_pb_to_json_str>( + bytes: T, + field_type: &FieldType, +) -> FlowyResult { + let bytes = bytes.into(); + match field_type { + FieldType::DateTime => DateGroupConfigurationPB::try_from(bytes) + .map(|pb| DateGroupConfiguration::from(pb).to_json())?, + _ => Ok("".to_string()), + } +} + +pub fn group_config_json_to_pb(setting_content: String, field_type: &FieldType) -> Bytes { + match field_type { + FieldType::DateTime => { + let date_group_config = DateGroupConfiguration::from_json(setting_content.as_ref()).unwrap(); + DateGroupConfigurationPB::from(date_group_config) + .try_into() + .unwrap() + }, + _ => Bytes::new(), + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/group_entities/group.rs b/frontend/rust-lib/flowy-database2/src/entities/group_entities/group.rs index 9f40685702..eef2b740c4 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/group_entities/group.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/group_entities/group.rs @@ -2,12 +2,15 @@ use std::convert::TryInto; use flowy_derive::ProtoBuf; use flowy_error::ErrorCode; +use lib_infra::validator_fn::required_not_empty_str; use validator::Validate; use crate::entities::parser::NotEmptyStr; -use crate::entities::RowMetaPB; +use crate::entities::{FieldType, RowMetaPB}; use crate::services::group::{GroupChangeset, GroupData, GroupSetting}; +use super::group_config_json_to_pb; + #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct GroupSettingPB { #[pb(index = 1)] @@ -15,13 +18,18 @@ pub struct GroupSettingPB { #[pb(index = 2)] pub field_id: String, + + #[pb(index = 3)] + pub content: Vec, } impl std::convert::From<&GroupSetting> for GroupSettingPB { fn from(rev: &GroupSetting) -> Self { + let field_type = FieldType::from(rev.field_type); GroupSettingPB { id: rev.id.clone(), field_id: rev.field_id.clone(), + content: group_config_json_to_pb(rev.content.clone(), &field_type).to_vec(), } } } @@ -105,6 +113,9 @@ pub struct GroupByFieldPayloadPB { #[pb(index = 2)] pub view_id: String, + + #[pb(index = 3)] + pub setting_content: Vec, } impl TryInto for GroupByFieldPayloadPB { @@ -118,33 +129,34 @@ impl TryInto for GroupByFieldPayloadPB { .map_err(|_| ErrorCode::ViewIdIsInvalid)? .0; - Ok(GroupByFieldParams { field_id, view_id }) + Ok(GroupByFieldParams { + field_id, + view_id, + setting_content: self.setting_content, + }) } } pub struct GroupByFieldParams { pub field_id: String, pub view_id: String, + pub setting_content: Vec, } #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone, Validate)] pub struct UpdateGroupPB { #[pb(index = 1)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub view_id: String, #[pb(index = 2)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub group_id: String, - #[pb(index = 3)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] - pub field_id: String, - - #[pb(index = 4, one_of)] + #[pb(index = 3, one_of)] pub name: Option, - #[pb(index = 5, one_of)] + #[pb(index = 4, one_of)] pub visible: Option, } @@ -158,14 +170,10 @@ impl TryInto for UpdateGroupPB { let group_id = NotEmptyStr::parse(self.group_id) .map_err(|_| ErrorCode::GroupIdIsEmpty)? .0; - let field_id = NotEmptyStr::parse(self.field_id) - .map_err(|_| ErrorCode::FieldIdIsEmpty)? - .0; Ok(UpdateGroupParams { view_id, group_id, - field_id, name: self.name, visible: self.visible, }) @@ -175,7 +183,6 @@ impl TryInto for UpdateGroupPB { pub struct UpdateGroupParams { pub view_id: String, pub group_id: String, - pub field_id: String, pub name: Option, pub visible: Option, } @@ -184,7 +191,6 @@ impl From for GroupChangeset { fn from(params: UpdateGroupParams) -> Self { Self { group_id: params.group_id, - field_id: params.field_id, name: params.name, visible: params.visible, } diff --git a/frontend/rust-lib/flowy-database2/src/entities/macros.rs b/frontend/rust-lib/flowy-database2/src/entities/macros.rs index 14c0613442..6fc6965bbc 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/macros.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/macros.rs @@ -16,6 +16,9 @@ macro_rules! impl_into_field_type { 9 => FieldType::CreatedTime, 10 => FieldType::Relation, 11 => FieldType::Summary, + 12 => FieldType::Translate, + 13 => FieldType::Time, + 14 => FieldType::Media, _ => { tracing::error!("🔴Can't parse FieldType from value: {}", ty); FieldType::RichText diff --git a/frontend/rust-lib/flowy-database2/src/entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/mod.rs index 6bacbd0539..56a9769b7a 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/mod.rs @@ -5,6 +5,7 @@ mod cell_entities; mod database_entities; mod field_entities; mod field_settings_entities; +pub mod file_entities; pub mod filter_entities; mod group_entities; pub mod parser; @@ -26,6 +27,7 @@ pub use cell_entities::*; pub use database_entities::*; pub use field_entities::*; pub use field_settings_entities::*; +pub use file_entities::*; pub use filter_entities::*; pub use group_entities::*; pub use position_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs index 04d1d70729..53030a142f 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs @@ -1,18 +1,21 @@ use std::collections::HashMap; -use collab_database::rows::{Row, RowDetail, RowId}; +use collab_database::rows::{CoverType, Row, RowCover, RowDetail, RowId}; use collab_database::views::RowOrder; -use flowy_derive::ProtoBuf; +use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; use lib_infra::validator_fn::required_not_empty_str; use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use validator::Validate; use crate::entities::parser::NotEmptyStr; use crate::entities::position_entities::OrderObjectPositionPB; use crate::services::database::{InsertedRow, UpdatedRow}; +use super::FileUploadTypePB; + /// [RowPB] Describes a row. Has the id of the parent Block. Has the metadata of the row. #[derive(Debug, Default, Clone, ProtoBuf, Eq, PartialEq)] pub struct RowPB { @@ -55,42 +58,156 @@ pub struct RowMetaPB { #[pb(index = 1)] pub id: String, - #[pb(index = 2)] - pub document_id: String, + #[pb(index = 2, one_of)] + pub document_id: Option, #[pb(index = 3, one_of)] pub icon: Option, #[pb(index = 4, one_of)] - pub cover: Option, + pub is_document_empty: Option, - #[pb(index = 5)] - pub is_document_empty: bool, + #[pb(index = 5, one_of)] + pub attachment_count: Option, + + #[pb(index = 6, one_of)] + pub cover: Option, } -impl std::convert::From<&RowDetail> for RowMetaPB { - fn from(row_detail: &RowDetail) -> Self { +#[derive(Debug, Default, Clone, ProtoBuf, Serialize, Deserialize)] +pub struct RowCoverPB { + #[pb(index = 1)] + pub data: String, + + #[pb(index = 2)] + pub upload_type: FileUploadTypePB, + + #[pb(index = 3)] + pub cover_type: CoverTypePB, +} + +impl From for RowCover { + fn from(cover: RowCoverPB) -> Self { Self { - id: row_detail.row.id.to_string(), - document_id: row_detail.document_id.clone(), - icon: row_detail.meta.icon_url.clone(), - cover: row_detail.meta.cover_url.clone(), - is_document_empty: row_detail.meta.is_document_empty, + data: cover.data, + upload_type: cover.upload_type.into(), + cover_type: cover.cover_type.into(), } } } -impl std::convert::From for RowMetaPB { + +impl From for RowCoverPB { + fn from(cover: RowCover) -> Self { + Self { + data: cover.data, + upload_type: cover.upload_type.into(), + cover_type: cover.cover_type.into(), + } + } +} + +#[derive(Debug, Default, Clone, ProtoBuf_Enum, PartialEq, Eq, Serialize_repr, Deserialize_repr)] +#[repr(u8)] +pub enum CoverTypePB { + #[default] + ColorCover = 0, + FileCover = 1, + AssetCover = 2, + GradientCover = 3, +} + +impl From for CoverType { + fn from(data: CoverTypePB) -> Self { + match data { + CoverTypePB::ColorCover => CoverType::ColorCover, + CoverTypePB::FileCover => CoverType::FileCover, + CoverTypePB::AssetCover => CoverType::AssetCover, + CoverTypePB::GradientCover => CoverType::GradientCover, + } + } +} + +impl From for CoverTypePB { + fn from(data: CoverType) -> Self { + match data { + CoverType::ColorCover => CoverTypePB::ColorCover, + CoverType::FileCover => CoverTypePB::FileCover, + CoverType::AssetCover => CoverTypePB::AssetCover, + CoverType::GradientCover => CoverTypePB::GradientCover, + } + } +} + +#[derive(Debug, Default, ProtoBuf)] +pub struct RepeatedRowMetaPB { + #[pb(index = 1)] + pub items: Vec, +} + +impl From for RowMetaPB { + fn from(data: RowOrder) -> Self { + Self { + id: data.id.into_inner(), + document_id: None, + icon: None, + is_document_empty: None, + attachment_count: None, + cover: None, + } + } +} + +impl From<&Row> for RowMetaPB { + fn from(data: &Row) -> Self { + Self { + id: data.id.clone().into_inner(), + document_id: None, + icon: None, + cover: None, + is_document_empty: None, + attachment_count: None, + } + } +} + +impl From for RowMetaPB { + fn from(data: Row) -> Self { + Self { + id: data.id.into_inner(), + document_id: None, + icon: None, + is_document_empty: None, + attachment_count: None, + cover: None, + } + } +} + +impl From for RowMetaPB { fn from(row_detail: RowDetail) -> Self { Self { id: row_detail.row.id.to_string(), - document_id: row_detail.document_id, - icon: row_detail.meta.icon_url, - cover: row_detail.meta.cover_url, - is_document_empty: row_detail.meta.is_document_empty, + document_id: Some(row_detail.document_id.clone()), + icon: row_detail.meta.icon_url.clone(), + is_document_empty: Some(row_detail.meta.is_document_empty), + attachment_count: Some(row_detail.meta.attachment_count), + cover: row_detail.meta.cover.map(|cover| cover.into()), + } + } +} + +impl From<&RowDetail> for RowMetaPB { + fn from(row_detail: &RowDetail) -> Self { + Self { + id: row_detail.row.id.to_string(), + document_id: Some(row_detail.document_id.clone()), + icon: row_detail.meta.icon_url.clone(), + is_document_empty: Some(row_detail.meta.is_document_empty), + attachment_count: Some(row_detail.meta.attachment_count), + cover: row_detail.meta.clone().cover.map(|cover| cover.into()), } } } -// #[derive(Debug, Default, Clone, ProtoBuf)] pub struct UpdateRowMetaChangesetPB { @@ -104,19 +221,23 @@ pub struct UpdateRowMetaChangesetPB { pub icon_url: Option, #[pb(index = 4, one_of)] - pub cover_url: Option, + pub cover: Option, #[pb(index = 5, one_of)] pub is_document_empty: Option, + + #[pb(index = 6, one_of)] + pub attachment_count: Option, } -#[derive(Debug)] +#[derive(Debug, Default)] pub struct UpdateRowMetaParams { pub id: String, pub view_id: String, pub icon_url: Option, - pub cover_url: Option, + pub cover: Option, pub is_document_empty: Option, + pub attachment_count: Option, } impl TryInto for UpdateRowMetaChangesetPB { @@ -134,8 +255,9 @@ impl TryInto for UpdateRowMetaChangesetPB { id: row_id, view_id, icon_url: self.icon_url, - cover_url: self.cover_url, + cover: self.cover.map(|cover| cover.into()), is_document_empty: self.is_document_empty, + attachment_count: self.attachment_count, }) } } @@ -213,18 +335,6 @@ pub struct OptionalRowPB { pub row: Option, } -#[derive(Debug, Default, ProtoBuf)] -pub struct RepeatedRowPB { - #[pb(index = 1)] - pub items: Vec, -} - -impl std::convert::From> for RepeatedRowPB { - fn from(items: Vec) -> Self { - Self { items } - } -} - #[derive(Debug, Clone, Default, ProtoBuf)] pub struct InsertedRowPB { #[pb(index = 1)] @@ -235,6 +345,9 @@ pub struct InsertedRowPB { #[pb(index = 3)] pub is_new: bool, + + #[pb(index = 4)] + pub is_hidden_in_view: bool, } impl InsertedRowPB { @@ -243,6 +356,7 @@ impl InsertedRowPB { row_meta, index: None, is_new: false, + is_hidden_in_view: false, } } @@ -258,6 +372,7 @@ impl std::convert::From for InsertedRowPB { row_meta, index: None, is_new: false, + is_hidden_in_view: false, } } } @@ -268,6 +383,7 @@ impl From for InsertedRowPB { row_meta: data.row_detail.into(), index: data.index, is_new: data.is_new, + is_hidden_in_view: false, } } } @@ -298,7 +414,7 @@ impl From for UpdatedRowPB { } #[derive(Debug, Default, Clone, ProtoBuf)] -pub struct RowIdPB { +pub struct DatabaseViewRowIdPB { #[pb(index = 1)] pub view_id: String, @@ -315,7 +431,7 @@ pub struct RowIdParams { pub group_id: Option, } -impl TryInto for RowIdPB { +impl TryInto for DatabaseViewRowIdPB { type Error = ErrorCode; fn try_into(self) -> Result { @@ -338,28 +454,32 @@ impl TryInto for RowIdPB { } } +#[derive(Debug, Default, Clone, ProtoBuf)] +pub struct RepeatedRowIdPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub row_ids: Vec, +} + #[derive(ProtoBuf, Default, Validate)] pub struct CreateRowPayloadPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub view_id: String, #[pb(index = 2)] pub row_position: OrderObjectPositionPB, #[pb(index = 3, one_of)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub group_id: Option, #[pb(index = 4)] pub data: HashMap, } -pub struct CreateRowParams { - pub collab_params: collab_database::rows::CreateRowParams, - pub open_after_create: bool, -} - #[derive(Debug, Default, Clone, ProtoBuf)] pub struct SummaryRowPB { #[pb(index = 1)] @@ -371,3 +491,18 @@ pub struct SummaryRowPB { #[pb(index = 3)] pub field_id: String, } + +#[derive(Debug, Default, Clone, ProtoBuf, Validate)] +pub struct TranslateRowPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub view_id: String, + + #[pb(index = 2)] + #[validate(custom(function = "required_not_empty_str"))] + pub row_id: String, + + #[pb(index = 3)] + #[validate(custom(function = "required_not_empty_str"))] + pub field_id: String, +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/setting_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/setting_entities.rs index 2cb2f3613d..4b8f0eebe9 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/setting_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/setting_entities.rs @@ -71,42 +71,42 @@ impl std::convert::From for DatabaseLayout { #[derive(Default, Validate, ProtoBuf)] pub struct DatabaseSettingChangesetPB { #[pb(index = 1)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + #[validate(custom(function = "lib_infra::validator_fn::required_not_empty_str"))] pub view_id: String, #[pb(index = 2, one_of)] pub layout_type: Option, #[pb(index = 3, one_of)] - #[validate] + #[validate(nested)] pub insert_filter: Option, #[pb(index = 4, one_of)] - #[validate] + #[validate(nested)] pub update_filter_type: Option, #[pb(index = 5, one_of)] - #[validate] + #[validate(nested)] pub update_filter_data: Option, #[pb(index = 6, one_of)] - #[validate] + #[validate(nested)] pub delete_filter: Option, #[pb(index = 7, one_of)] - #[validate] + #[validate(nested)] pub update_group: Option, #[pb(index = 8, one_of)] - #[validate] + #[validate(nested)] pub update_sort: Option, #[pb(index = 9, one_of)] - #[validate] + #[validate(nested)] pub reorder_sort: Option, #[pb(index = 10, one_of)] - #[validate] + #[validate(nested)] pub delete_sort: Option, } diff --git a/frontend/rust-lib/flowy-database2/src/entities/share_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/share_entities.rs index b9fc85387f..981140e041 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/share_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/share_entities.rs @@ -4,6 +4,9 @@ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; pub enum DatabaseExportDataType { #[default] CSV = 0, + + // DatabaseData + RawDatabaseData = 1, } #[derive(Debug, ProtoBuf, Default, Clone)] diff --git a/frontend/rust-lib/flowy-database2/src/entities/sort_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/sort_entities.rs index 307b261d0f..59dc683087 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/sort_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/sort_entities.rs @@ -1,4 +1,5 @@ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; +use lib_infra::validator_fn::required_not_empty_str; use validator::Validate; use crate::services::sort::{Sort, SortCondition}; @@ -96,16 +97,16 @@ impl std::convert::From for SortCondition { #[derive(ProtoBuf, Debug, Default, Clone, Validate)] pub struct UpdateSortPayloadPB { #[pb(index = 1)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub view_id: String, #[pb(index = 2)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub field_id: String, /// Create a new sort if the sort_id is None #[pb(index = 3, one_of)] - #[validate(custom = "super::utils::validate_sort_id")] + #[validate(custom(function = "super::utils::validate_sort_id"))] pub sort_id: Option, #[pb(index = 4)] @@ -115,26 +116,26 @@ pub struct UpdateSortPayloadPB { #[derive(Debug, Default, Clone, Validate, ProtoBuf)] pub struct ReorderSortPayloadPB { #[pb(index = 1)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub view_id: String, #[pb(index = 2)] - #[validate(custom = "super::utils::validate_sort_id")] + #[validate(custom(function = "super::utils::validate_sort_id"))] pub from_sort_id: String, #[pb(index = 3)] - #[validate(custom = "super::utils::validate_sort_id")] + #[validate(custom(function = "super::utils::validate_sort_id"))] pub to_sort_id: String, } #[derive(ProtoBuf, Debug, Default, Clone, Validate)] pub struct DeleteSortPayloadPB { #[pb(index = 1)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub view_id: String, #[pb(index = 2)] - #[validate(custom = "super::utils::validate_sort_id")] + #[validate(custom(function = "super::utils::validate_sort_id"))] pub sort_id: String, } diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/checkbox_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/checkbox_entities.rs index d6d4f84a8a..1b284a9927 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/checkbox_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/checkbox_entities.rs @@ -1,4 +1,6 @@ -use crate::services::field::CheckboxTypeOption; +use crate::services::field::{CHECK, UNCHECK}; +use collab_database::fields::checkbox_type_option::CheckboxTypeOption; +use collab_database::template::util::ToCellString; use flowy_derive::ProtoBuf; #[derive(Default, Debug, Clone, ProtoBuf)] @@ -13,6 +15,16 @@ impl CheckboxCellDataPB { } } +impl ToCellString for CheckboxCellDataPB { + fn to_cell_string(&self) -> String { + if self.is_checked { + CHECK.to_string() + } else { + UNCHECK.to_string() + } + } +} + #[derive(Debug, Clone, Default, ProtoBuf)] pub struct CheckboxTypeOptionPB { /// unused @@ -28,6 +40,6 @@ impl From for CheckboxTypeOptionPB { impl From for CheckboxTypeOption { fn from(_type_option: CheckboxTypeOptionPB) -> Self { - Self() + CheckboxTypeOption } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/checklist_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/checklist_entities.rs index ac27d959ab..b62a0182df 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/checklist_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/checklist_entities.rs @@ -1,11 +1,7 @@ -use collab_database::rows::RowId; - use flowy_derive::ProtoBuf; -use flowy_error::{ErrorCode, FlowyError}; +use validator::Validate; -use crate::entities::parser::NotEmptyStr; -use crate::entities::SelectOptionPB; -use crate::services::field::SelectOption; +use crate::entities::{CellIdPB, SelectOptionPB}; #[derive(Debug, Clone, Default, ProtoBuf)] pub struct ChecklistCellDataPB { @@ -19,61 +15,33 @@ pub struct ChecklistCellDataPB { pub percentage: f64, } -#[derive(Debug, Clone, Default, ProtoBuf)] +#[derive(Debug, Clone, Default, ProtoBuf, Validate)] pub struct ChecklistCellDataChangesetPB { #[pb(index = 1)] - pub view_id: String, + #[validate(nested)] + pub cell_id: CellIdPB, #[pb(index = 2)] - pub field_id: String, + pub insert_task: Vec, #[pb(index = 3)] - pub row_id: String, + pub delete_tasks: Vec, #[pb(index = 4)] - pub insert_options: Vec, + pub update_tasks: Vec, #[pb(index = 5)] - pub selected_option_ids: Vec, + pub completed_tasks: Vec, #[pb(index = 6)] - pub delete_option_ids: Vec, - - #[pb(index = 7)] - pub update_options: Vec, + pub reorder: String, } -#[derive(Debug)] -pub struct ChecklistCellDataChangesetParams { - pub view_id: String, - pub field_id: String, - pub row_id: RowId, - pub insert_options: Vec, - pub selected_option_ids: Vec, - pub delete_option_ids: Vec, - pub update_options: Vec, -} - -impl TryInto for ChecklistCellDataChangesetPB { - type Error = FlowyError; - - fn try_into(self) -> Result { - let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::ViewIdIsInvalid)?; - let field_id = NotEmptyStr::parse(self.field_id).map_err(|_| ErrorCode::FieldIdIsEmpty)?; - let row_id = NotEmptyStr::parse(self.row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?; - - Ok(ChecklistCellDataChangesetParams { - view_id: view_id.0, - field_id: field_id.0, - row_id: RowId::from(row_id.0), - insert_options: self.insert_options, - selected_option_ids: self.selected_option_ids, - delete_option_ids: self.delete_option_ids, - update_options: self - .update_options - .into_iter() - .map(SelectOption::from) - .collect(), - }) - } +#[derive(Debug, Clone, Default, ProtoBuf, Validate)] +pub struct ChecklistCellInsertPB { + #[pb(index = 1)] + pub name: String, + + #[pb(index = 2, one_of)] + pub index: Option, } diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/date_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/date_entities.rs index 5fdb0195ef..4e9730f5ed 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/date_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/date_entities.rs @@ -1,69 +1,65 @@ #![allow(clippy::upper_case_acronyms)] +use collab_database::fields::date_type_option::{ + DateCellData, DateFormat, DateTypeOption, TimeFormat, +}; use strum_macros::EnumIter; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use crate::entities::CellIdPB; -use crate::services::field::{DateFormat, DateTypeOption, TimeFormat}; #[derive(Clone, Debug, Default, ProtoBuf)] pub struct DateCellDataPB { - #[pb(index = 1)] - pub date: String, + #[pb(index = 1, one_of)] + pub timestamp: Option, - #[pb(index = 2)] - pub time: String, + #[pb(index = 2, one_of)] + pub end_timestamp: Option, #[pb(index = 3)] - pub timestamp: i64, - - #[pb(index = 4)] - pub end_date: String, - - #[pb(index = 5)] - pub end_time: String, - - #[pb(index = 6)] - pub end_timestamp: i64, - - #[pb(index = 7)] pub include_time: bool, - #[pb(index = 8)] + #[pb(index = 4)] pub is_range: bool, - #[pb(index = 9)] + #[pb(index = 5)] pub reminder_id: String, } +impl From<&DateCellDataPB> for DateCellData { + fn from(data: &DateCellDataPB) -> Self { + Self { + timestamp: data.timestamp, + end_timestamp: data.end_timestamp, + include_time: data.include_time, + is_range: data.is_range, + reminder_id: data.reminder_id.to_owned(), + } + } +} + #[derive(Clone, Debug, Default, ProtoBuf)] pub struct DateCellChangesetPB { #[pb(index = 1)] pub cell_id: CellIdPB, #[pb(index = 2, one_of)] - pub date: Option, + pub timestamp: Option, #[pb(index = 3, one_of)] - pub time: Option, + pub end_timestamp: Option, #[pb(index = 4, one_of)] - pub end_date: Option, - - #[pb(index = 5, one_of)] - pub end_time: Option, - - #[pb(index = 6, one_of)] pub include_time: Option, - #[pb(index = 7, one_of)] + #[pb(index = 5, one_of)] pub is_range: Option, - #[pb(index = 8, one_of)] + #[pb(index = 6, one_of)] pub clear_flag: Option, - #[pb(index = 9, one_of)] + #[pb(index = 7, one_of)] pub reminder_id: Option, } @@ -108,6 +104,7 @@ pub enum DateFormatPB { #[default] Friendly = 3, DayMonthYear = 4, + FriendlyFull = 5, } impl From for DateFormat { @@ -118,6 +115,7 @@ impl From for DateFormat { DateFormatPB::ISO => DateFormat::ISO, DateFormatPB::Friendly => DateFormat::Friendly, DateFormatPB::DayMonthYear => DateFormat::DayMonthYear, + DateFormatPB::FriendlyFull => DateFormat::FriendlyFull, } } } @@ -130,6 +128,7 @@ impl From for DateFormatPB { DateFormat::ISO => DateFormatPB::ISO, DateFormat::Friendly => DateFormatPB::Friendly, DateFormat::DayMonthYear => DateFormatPB::DayMonthYear, + DateFormat::FriendlyFull => DateFormatPB::FriendlyFull, } } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/media_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/media_entities.rs new file mode 100644 index 0000000000..e337dca57a --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/media_entities.rs @@ -0,0 +1,179 @@ +use collab_database::fields::media_type_option::{ + MediaCellData, MediaFile, MediaFileType, MediaTypeOption, +}; +use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; + +use crate::entities::{CellIdPB, FileUploadTypePB}; + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct MediaCellDataPB { + #[pb(index = 1)] + pub files: Vec, +} + +impl From for MediaCellDataPB { + fn from(data: MediaCellData) -> Self { + Self { + files: data.files.into_iter().map(Into::into).collect(), + } + } +} + +impl From for MediaCellData { + fn from(data: MediaCellDataPB) -> Self { + Self { + files: data.files.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct MediaTypeOptionPB { + #[pb(index = 1)] + pub hide_file_names: bool, +} + +impl From for MediaTypeOptionPB { + fn from(value: MediaTypeOption) -> Self { + Self { + hide_file_names: value.hide_file_names, + } + } +} + +impl From for MediaTypeOption { + fn from(value: MediaTypeOptionPB) -> Self { + Self { + hide_file_names: value.hide_file_names, + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct MediaFilePB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub name: String, + + #[pb(index = 3)] + pub url: String, + + #[pb(index = 4)] + pub upload_type: FileUploadTypePB, + + #[pb(index = 5)] + pub file_type: MediaFileTypePB, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, ProtoBuf_Enum)] +#[repr(u8)] +pub enum MediaFileTypePB { + #[default] + Other = 0, + // Eg. jpg, png, gif, etc. + Image = 1, + // Eg. https://appflowy.io + Link = 2, + // Eg. pdf, doc, etc. + Document = 3, + // Eg. zip, rar, etc. + Archive = 4, + // Eg. mp4, avi, etc. + Video = 5, + // Eg. mp3, wav, etc. + Audio = 6, + // Eg. txt, csv, etc. + Text = 7, +} + +impl From for MediaFileTypePB { + fn from(data: MediaFileType) -> Self { + match data { + MediaFileType::Other => MediaFileTypePB::Other, + MediaFileType::Image => MediaFileTypePB::Image, + MediaFileType::Link => MediaFileTypePB::Link, + MediaFileType::Document => MediaFileTypePB::Document, + MediaFileType::Archive => MediaFileTypePB::Archive, + MediaFileType::Video => MediaFileTypePB::Video, + MediaFileType::Audio => MediaFileTypePB::Audio, + MediaFileType::Text => MediaFileTypePB::Text, + } + } +} + +impl From for MediaFileType { + fn from(data: MediaFileTypePB) -> Self { + match data { + MediaFileTypePB::Other => MediaFileType::Other, + MediaFileTypePB::Image => MediaFileType::Image, + MediaFileTypePB::Link => MediaFileType::Link, + MediaFileTypePB::Document => MediaFileType::Document, + MediaFileTypePB::Archive => MediaFileType::Archive, + MediaFileTypePB::Video => MediaFileType::Video, + MediaFileTypePB::Audio => MediaFileType::Audio, + MediaFileTypePB::Text => MediaFileType::Text, + } + } +} + +impl From for MediaFilePB { + fn from(data: MediaFile) -> Self { + Self { + id: data.id, + name: data.name, + url: data.url, + upload_type: data.upload_type.into(), + file_type: data.file_type.into(), + } + } +} + +impl From for MediaFile { + fn from(data: MediaFilePB) -> Self { + Self { + id: data.id, + name: data.name, + url: data.url, + upload_type: data.upload_type.into(), + file_type: data.file_type.into(), + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct MediaCellChangesetPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub cell_id: CellIdPB, + + #[pb(index = 3)] + pub inserted_files: Vec, + + #[pb(index = 4)] + pub removed_ids: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct MediaCellChangeset { + pub inserted_files: Vec, + pub removed_ids: Vec, +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RenameMediaChangesetPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub cell_id: CellIdPB, + + #[pb(index = 3)] + pub file_id: String, + + #[pb(index = 4)] + pub name: String, +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs index deeb260f0e..633f000e53 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs @@ -1,21 +1,27 @@ mod checkbox_entities; mod checklist_entities; mod date_entities; +mod media_entities; mod number_entities; mod relation_entities; mod select_option_entities; mod summary_entities; mod text_entities; +mod time_entities; mod timestamp_entities; +mod translate_entities; mod url_entities; pub use checkbox_entities::*; pub use checklist_entities::*; pub use date_entities::*; +pub use media_entities::*; pub use number_entities::*; pub use relation_entities::*; pub use select_option_entities::*; pub use summary_entities::*; pub use text_entities::*; +pub use time_entities::*; pub use timestamp_entities::*; +pub use translate_entities::*; pub use url_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/number_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/number_entities.rs index 3b7a301851..bb34178cd7 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/number_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/number_entities.rs @@ -1,4 +1,4 @@ -use crate::services::field::{NumberFormat, NumberTypeOption}; +use collab_database::fields::number_type_option::{NumberFormat, NumberTypeOption}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; // Number diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs index bebcb6189e..17d7434c08 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs @@ -1,7 +1,8 @@ +use collab_database::fields::relation_type_option::RelationTypeOption; +use collab_database::template::relation_parse::RelationCellData; use flowy_derive::ProtoBuf; use crate::entities::CellIdPB; -use crate::services::field::{RelationCellData, RelationTypeOption}; #[derive(Debug, Clone, Default, ProtoBuf)] pub struct RelationCellDataPB { @@ -78,7 +79,7 @@ pub struct RepeatedRelatedRowDataPB { } #[derive(Debug, Default, Clone, ProtoBuf)] -pub struct RepeatedRowIdPB { +pub struct GetRelatedRowDataPB { #[pb(index = 1)] pub database_id: String, diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/select_option_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/select_option_entities.rs index c5e931b017..69b79305a0 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/select_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/select_option_entities.rs @@ -1,8 +1,8 @@ use crate::entities::parser::NotEmptyStr; use crate::entities::{CellIdPB, CellIdParams}; -use crate::services::field::checklist_type_option::ChecklistTypeOption; -use crate::services::field::{ - MultiSelectTypeOption, SelectOption, SelectOptionColor, SingleSelectTypeOption, +use collab_database::fields::checklist_type_option::ChecklistTypeOption; +use collab_database::fields::select_type_option::{ + MultiSelectTypeOption, SelectOption, SelectOptionColor, SelectTypeOption, SingleSelectTypeOption, }; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; @@ -55,9 +55,8 @@ pub struct RepeatedSelectOptionPayload { pub items: Vec, } -#[derive(ProtoBuf_Enum, PartialEq, Eq, Debug, Clone)] +#[derive(ProtoBuf_Enum, PartialEq, Eq, Debug, Clone, Default)] #[repr(u8)] -#[derive(Default)] pub enum SelectOptionColorPB { #[default] Purple = 0, @@ -213,8 +212,8 @@ pub struct SingleSelectTypeOptionPB { pub disable_color: bool, } -impl From for SingleSelectTypeOptionPB { - fn from(data: SingleSelectTypeOption) -> Self { +impl From for SingleSelectTypeOptionPB { + fn from(data: SelectTypeOption) -> Self { Self { options: data .options @@ -228,14 +227,14 @@ impl From for SingleSelectTypeOptionPB { impl From for SingleSelectTypeOption { fn from(data: SingleSelectTypeOptionPB) -> Self { - Self { + SingleSelectTypeOption(SelectTypeOption { options: data .options .into_iter() .map(|option| option.into()) .collect(), disable_color: data.disable_color, - } + }) } } @@ -248,8 +247,8 @@ pub struct MultiSelectTypeOptionPB { pub disable_color: bool, } -impl From for MultiSelectTypeOptionPB { - fn from(data: MultiSelectTypeOption) -> Self { +impl From for MultiSelectTypeOptionPB { + fn from(data: SelectTypeOption) -> Self { Self { options: data .options @@ -263,14 +262,14 @@ impl From for MultiSelectTypeOptionPB { impl From for MultiSelectTypeOption { fn from(data: MultiSelectTypeOptionPB) -> Self { - Self { + MultiSelectTypeOption(SelectTypeOption { options: data .options .into_iter() .map(|option| option.into()) .collect(), disable_color: data.disable_color, - } + }) } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/summary_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/summary_entities.rs index c8f4a9b5c4..cb9cc8eca9 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/summary_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/summary_entities.rs @@ -1,4 +1,4 @@ -use crate::services::field::summary_type_option::summary::SummarizationTypeOption; +use collab_database::fields::summary_type_option::SummarizationTypeOption; use flowy_derive::ProtoBuf; #[derive(Debug, Clone, Default, ProtoBuf)] diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/text_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/text_entities.rs index cce32dc64a..5a61fe5410 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/text_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/text_entities.rs @@ -1,4 +1,4 @@ -use crate::services::field::RichTextTypeOption; +use collab_database::fields::text_type_option::RichTextTypeOption; use flowy_derive::ProtoBuf; #[derive(Debug, Clone, Default, ProtoBuf)] @@ -8,13 +8,15 @@ pub struct RichTextTypeOptionPB { } impl From for RichTextTypeOptionPB { - fn from(data: RichTextTypeOption) -> Self { - Self { data: data.inner } + fn from(_data: RichTextTypeOption) -> Self { + RichTextTypeOptionPB { + data: "".to_string(), + } } } impl From for RichTextTypeOption { - fn from(data: RichTextTypeOptionPB) -> Self { - Self { inner: data.data } + fn from(_data: RichTextTypeOptionPB) -> Self { + RichTextTypeOption } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/time_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/time_entities.rs new file mode 100644 index 0000000000..c699b1ca67 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/time_entities.rs @@ -0,0 +1,28 @@ +use collab_database::fields::date_type_option::TimeTypeOption; +use flowy_derive::ProtoBuf; + +#[derive(Clone, Debug, Default, ProtoBuf)] +pub struct TimeTypeOptionPB { + #[pb(index = 1)] + pub dummy: String, +} + +impl From for TimeTypeOptionPB { + fn from(_data: TimeTypeOption) -> Self { + Self { + dummy: "".to_string(), + } + } +} + +impl From for TimeTypeOption { + fn from(_data: TimeTypeOptionPB) -> Self { + Self + } +} + +#[derive(Clone, Debug, Default, ProtoBuf)] +pub struct TimeCellDataPB { + #[pb(index = 2)] + pub time: i64, +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/timestamp_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/timestamp_entities.rs index b4afcadaf4..1bf20be587 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/timestamp_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/timestamp_entities.rs @@ -1,7 +1,7 @@ +use collab_database::fields::timestamp_type_option::TimestampTypeOption; use flowy_derive::ProtoBuf; use crate::entities::{DateFormatPB, FieldType, TimeFormatPB}; -use crate::services::field::TimestampTypeOption; #[derive(Clone, Debug, Default, ProtoBuf)] pub struct TimestampCellDataPB { @@ -33,7 +33,7 @@ impl From for TimestampTypeOptionPB { date_format: data.date_format.into(), time_format: data.time_format.into(), include_time: data.include_time, - field_type: data.field_type, + field_type: data.field_type.into(), } } } @@ -44,7 +44,8 @@ impl From for TimestampTypeOption { date_format: data.date_format.into(), time_format: data.time_format.into(), include_time: data.include_time, - field_type: data.field_type, + field_type: data.field_type.into(), + timezone: None, } } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/translate_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/translate_entities.rs new file mode 100644 index 0000000000..326abf2c2b --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/translate_entities.rs @@ -0,0 +1,60 @@ +use collab_database::fields::translate_type_option::TranslateTypeOption; +use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct TranslateTypeOptionPB { + #[pb(index = 1)] + pub auto_fill: bool, + + #[pb(index = 2)] + pub language: TranslateLanguagePB, +} + +impl From for TranslateTypeOptionPB { + fn from(value: TranslateTypeOption) -> Self { + TranslateTypeOptionPB { + auto_fill: value.auto_fill, + language: value.language_type.into(), + } + } +} + +impl From for TranslateTypeOption { + fn from(value: TranslateTypeOptionPB) -> Self { + TranslateTypeOption { + auto_fill: value.auto_fill, + language_type: value.language as i64, + } + } +} +#[derive(Clone, Debug, Copy, ProtoBuf_Enum, Default)] +#[repr(i64)] +pub enum TranslateLanguagePB { + TraditionalChinese = 0, + #[default] + English = 1, + French = 2, + German = 3, + Hindi = 4, + Spanish = 5, + Portuguese = 6, + StandardArabic = 7, + SimplifiedChinese = 8, +} + +impl From for TranslateLanguagePB { + fn from(data: i64) -> Self { + match data { + 0 => TranslateLanguagePB::TraditionalChinese, + 1 => TranslateLanguagePB::English, + 2 => TranslateLanguagePB::French, + 3 => TranslateLanguagePB::German, + 4 => TranslateLanguagePB::Hindi, + 5 => TranslateLanguagePB::Spanish, + 6 => TranslateLanguagePB::Portuguese, + 7 => TranslateLanguagePB::StandardArabic, + 8 => TranslateLanguagePB::SimplifiedChinese, + _ => TranslateLanguagePB::English, + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/url_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/url_entities.rs index 1a14299d01..727c4ce759 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/url_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/url_entities.rs @@ -1,4 +1,4 @@ -use crate::services::field::URLTypeOption; +use collab_database::fields::url_type_option::URLTypeOption; use flowy_derive::ProtoBuf; #[derive(Clone, Debug, Default, ProtoBuf)] diff --git a/frontend/rust-lib/flowy-database2/src/entities/view_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/view_entities.rs index 9809516bed..b04fcb3aff 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/view_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/view_entities.rs @@ -1,4 +1,5 @@ use collab_database::rows::RowDetail; +use std::fmt::{Display, Formatter}; use flowy_derive::ProtoBuf; @@ -26,6 +27,54 @@ pub struct RowsChangePB { #[pb(index = 3)] pub updated_rows: Vec, + + #[pb(index = 4)] + pub is_move_row: bool, +} + +impl RowsChangePB { + pub fn new() -> Self { + Default::default() + } +} +impl Display for RowsChangePB { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let mut parts = Vec::new(); + + // Conditionally add inserted rows if not empty + if !self.inserted_rows.is_empty() { + let inserted_rows = self + .inserted_rows + .iter() + .map(|row| format!("{}:{:?}", row.row_meta.id, row.index)) + .collect::>() + .join(", "); + parts.push(format!("Inserted rows: {}", inserted_rows)); + } + + // Conditionally add deleted rows if not empty + if !self.deleted_rows.is_empty() { + let deleted_rows = self.deleted_rows.join(", "); + parts.push(format!("Deleted rows: {}", deleted_rows)); + } + + // Conditionally add updated rows if not empty + if !self.updated_rows.is_empty() { + let updated_rows = self + .updated_rows + .iter() + .map(|row| row.row_id.to_string()) + .collect::>() + .join(", "); + parts.push(format!("Updated rows: {}", updated_rows)); + } + + // Always add is_move_row + parts.push(format!("is_move_row: {}", self.is_move_row)); + + // Join the parts together and write them to the formatter + f.write_str(&parts.join(", ")) + } } impl RowsChangePB { diff --git a/frontend/rust-lib/flowy-database2/src/event_handler.rs b/frontend/rust-lib/flowy-database2/src/event_handler.rs index ebd621eb87..63d6fdf2c3 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -1,18 +1,19 @@ -use std::sync::{Arc, Weak}; - -use collab_database::rows::RowId; +use collab_database::fields::media_type_option::MediaCellData; +use collab_database::rows::{Cell, RowCover, RowId}; use lib_infra::box_any::BoxAny; +use std::sync::{Arc, Weak}; use tokio::sync::oneshot; -use tracing::error; +use tracing::{info, instrument}; use flowy_error::{FlowyError, FlowyResult}; -use lib_dispatch::prelude::{af_spawn, data_result_ok, AFPluginData, AFPluginState, DataResult}; +use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; use crate::entities::*; use crate::manager::DatabaseManager; +use crate::services::field::checklist_filter::ChecklistCellChangeset; +use crate::services::field::date_filter::DateCellChangeset; use crate::services::field::{ - type_option_data_from_pb, ChecklistCellChangeset, DateCellChangeset, RelationCellChangeset, - SelectOptionCellChangeset, + type_option_data_from_pb, RelationCellChangeset, SelectOptionCellChangeset, TypeOptionCellExt, }; use crate::services::group::GroupChangeset; use crate::services::share::csv::CSVFormat; @@ -33,11 +34,43 @@ pub(crate) async fn get_database_data_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let view_id: DatabaseViewIdPB = data.into_inner(); - let database_editor = manager.get_database_with_view_id(view_id.as_ref()).await?; - let data = database_editor.get_database_data(view_id.as_ref()).await?; + let database_id = manager + .get_database_id_with_view_id(view_id.as_ref()) + .await?; + let database_editor = manager.get_or_init_database_editor(&database_id).await?; + let start = std::time::Instant::now(); + let data = database_editor + .open_database_view(view_id.as_ref(), None) + .await?; + info!( + "[Database]: {} layout: {:?}, rows: {}, fields: {}, cost time: {} milliseconds", + database_id, + data.layout_type, + data.rows.len(), + data.fields.len(), + start.elapsed().as_millis() + ); data_result_ok(data) } +#[tracing::instrument(level = "trace", skip_all, err)] +pub(crate) async fn get_all_rows_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let view_id: DatabaseViewIdPB = data.into_inner(); + let database_id = manager + .get_database_id_with_view_id(view_id.as_ref()) + .await?; + let database_editor = manager.get_or_init_database_editor(&database_id).await?; + let row_details = database_editor.get_all_rows(view_id.as_ref()).await?; + let rows = row_details + .into_iter() + .map(|detail| RowMetaPB::from(detail.as_ref())) + .collect::>(); + data_result_ok(RepeatedRowMetaPB { items: rows }) +} #[tracing::instrument(level = "trace", skip_all, err)] pub(crate) async fn open_database_handler( data: AFPluginData, @@ -72,7 +105,9 @@ pub(crate) async fn get_database_setting_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let view_id: DatabaseViewIdPB = data.into_inner(); - let database_editor = manager.get_database_with_view_id(view_id.as_ref()).await?; + let database_editor = manager + .get_database_editor_with_view_id(view_id.as_ref()) + .await?; let data = database_editor .get_database_view_setting(view_id.as_ref()) .await?; @@ -86,7 +121,9 @@ pub(crate) async fn update_database_setting_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params = data.try_into_inner()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; if let Some(payload) = params.insert_filter { database_editor @@ -139,7 +176,9 @@ pub(crate) async fn get_all_filters_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let view_id: DatabaseViewIdPB = data.into_inner(); - let database_editor = manager.get_database_with_view_id(view_id.as_ref()).await?; + let database_editor = manager + .get_database_editor_with_view_id(view_id.as_ref()) + .await?; let filters = database_editor.get_all_filters(view_id.as_ref()).await; data_result_ok(filters) } @@ -151,7 +190,9 @@ pub(crate) async fn get_all_sorts_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let view_id: DatabaseViewIdPB = data.into_inner(); - let database_editor = manager.get_database_with_view_id(view_id.as_ref()).await?; + let database_editor = manager + .get_database_editor_with_view_id(view_id.as_ref()) + .await?; let sorts = database_editor.get_all_sorts(view_id.as_ref()).await; data_result_ok(sorts) } @@ -163,7 +204,9 @@ pub(crate) async fn delete_all_sorts_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let view_id: DatabaseViewIdPB = data.into_inner(); - let database_editor = manager.get_database_with_view_id(view_id.as_ref()).await?; + let database_editor = manager + .get_database_editor_with_view_id(view_id.as_ref()) + .await?; database_editor.delete_all_sorts(view_id.as_ref()).await; Ok(()) } @@ -175,9 +218,12 @@ pub(crate) async fn get_fields_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params: GetFieldParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; let fields = database_editor .get_fields(¶ms.view_id, params.field_ids) + .await .into_iter() .map(FieldPB::new) .collect::>() @@ -192,9 +238,10 @@ pub(crate) async fn get_primary_field_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let view_id = data.into_inner().value; - let database_editor = manager.get_database_with_view_id(&view_id).await?; + let database_editor = manager.get_database_editor_with_view_id(&view_id).await?; let mut fields = database_editor .get_fields(&view_id, None) + .await .into_iter() .filter(|field| field.is_primary) .map(FieldPB::new) @@ -220,8 +267,10 @@ pub(crate) async fn update_field_handler( manager: AFPluginState>, ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; - let params: FieldChangesetParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let params = data.try_into_inner()?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor.update_field(params).await?; Ok(()) } @@ -233,8 +282,10 @@ pub(crate) async fn update_field_type_option_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params: TypeOptionChangesetParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; - if let Some(old_field) = database_editor.get_field(¶ms.field_id) { + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; + if let Some(old_field) = database_editor.get_field(¶ms.field_id).await { let field_type = FieldType::from(old_field.field_type); let type_option_data = type_option_data_from_pb(params.type_option_data, &field_type)?; database_editor @@ -251,7 +302,9 @@ pub(crate) async fn delete_field_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params: FieldIdParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor.delete_field(¶ms.field_id).await?; Ok(()) } @@ -263,7 +316,9 @@ pub(crate) async fn clear_field_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params: FieldIdParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor .clear_field(¶ms.view_id, ¶ms.field_id) .await?; @@ -277,27 +332,18 @@ pub(crate) async fn switch_to_field_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params: EditFieldParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; - let old_field = database_editor.get_field(¶ms.field_id); + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor - .switch_to_field_type(¶ms.field_id, params.field_type) + .switch_to_field_type( + ¶ms.view_id, + ¶ms.field_id, + params.field_type, + params.field_name, + ) .await?; - if let Some(new_type_option) = database_editor - .get_field(¶ms.field_id) - .map(|field| field.get_any_type_option(field.field_type)) - { - match (old_field, new_type_option) { - (Some(old_field), Some(new_type_option)) => { - database_editor - .update_field_type_option(¶ms.field_id, new_type_option, old_field) - .await?; - }, - _ => { - tracing::warn!("Old field and the new type option should not be empty"); - }, - } - } Ok(()) } @@ -308,7 +354,9 @@ pub(crate) async fn duplicate_field_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params: DuplicateFieldPayloadPB = data.into_inner(); - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor .duplicate_field(¶ms.view_id, ¶ms.field_id) .await?; @@ -323,7 +371,9 @@ pub(crate) async fn create_field_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params: CreateFieldParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; let data = database_editor .create_field_with_type_option(params) .await?; @@ -338,33 +388,56 @@ pub(crate) async fn move_field_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params: MoveFieldParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor.move_field(params).await?; Ok(()) } // #[tracing::instrument(level = "debug", skip(data, manager), err)] pub(crate) async fn get_row_handler( - data: AFPluginData, + data: AFPluginData, manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; let params: RowIdParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; let row = database_editor .get_row(¶ms.view_id, ¶ms.row_id) + .await .map(RowPB::from); data_result_ok(OptionalRowPB { row }) } +pub(crate) async fn init_row_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let manager = upgrade_manager(manager)?; + let params: RowIdParams = data.into_inner().try_into()?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; + database_editor.init_database_row(¶ms.row_id).await?; + Ok(()) +} + pub(crate) async fn get_row_meta_handler( - data: AFPluginData, + data: AFPluginData, manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; let params: RowIdParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; - match database_editor.get_row_meta(¶ms.view_id, ¶ms.row_id) { + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; + match database_editor + .get_row_meta(¶ms.view_id, ¶ms.row_id) + .await + { None => Err(FlowyError::record_not_found()), Some(row) => data_result_ok(row), } @@ -376,7 +449,9 @@ pub(crate) async fn update_row_meta_handler( ) -> FlowyResult<()> { let manager = upgrade_manager(manager)?; let params: UpdateRowMetaParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; let row_id = RowId::from(params.id.clone()); database_editor .update_row_meta(&row_id.clone(), params) @@ -385,25 +460,34 @@ pub(crate) async fn update_row_meta_handler( } #[tracing::instrument(level = "debug", skip(data, manager), err)] -pub(crate) async fn delete_row_handler( - data: AFPluginData, +pub(crate) async fn delete_rows_handler( + data: AFPluginData, manager: AFPluginState>, ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; - let params: RowIdParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; - database_editor.delete_row(¶ms.row_id).await; + let params: RepeatedRowIdPB = data.into_inner(); + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; + let row_ids = params + .row_ids + .into_iter() + .map(RowId::from) + .collect::>(); + database_editor.delete_rows(&row_ids).await; Ok(()) } #[tracing::instrument(level = "debug", skip(data, manager), err)] pub(crate) async fn duplicate_row_handler( - data: AFPluginData, + data: AFPluginData, manager: AFPluginState>, ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params: RowIdParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor .duplicate_row(¶ms.view_id, ¶ms.row_id) .await?; @@ -417,13 +501,40 @@ pub(crate) async fn move_row_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params: MoveRowParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor .move_row(¶ms.view_id, params.from_row_id, params.to_row_id) .await?; Ok(()) } +#[tracing::instrument(level = "debug", skip(data, manager), err)] +pub(crate) async fn remove_cover_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let manager = upgrade_manager(manager)?; + let params: RemoveCoverParams = data.into_inner().try_into()?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; + + let update_row_changeset = UpdateRowMetaParams { + id: params.row_id.clone().into(), + view_id: params.view_id.clone(), + cover: Some(RowCover::default()), + ..Default::default() + }; + + database_editor + .update_row_meta(¶ms.row_id, update_row_changeset) + .await; + + Ok(()) +} + #[tracing::instrument(level = "debug", skip(data, manager), err)] pub(crate) async fn create_row_handler( data: AFPluginData, @@ -431,7 +542,9 @@ pub(crate) async fn create_row_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params = data.try_into_inner()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; match database_editor.create_row(params).await? { Some(row) => data_result_ok(RowMetaPB::from(row)), @@ -446,7 +559,9 @@ pub(crate) async fn get_cell_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params: CellIdParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; let cell = database_editor .get_cell_pb(¶ms.field_id, ¶ms.row_id) .await @@ -461,7 +576,9 @@ pub(crate) async fn update_cell_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params: CellChangesetPB = data.into_inner(); - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor .update_cell_with_changeset( ¶ms.view_id, @@ -480,7 +597,9 @@ pub(crate) async fn new_select_option_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params: CreateSelectOptionParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; let result = database_editor .create_select_option(¶ms.field_id, params.option_name) .await; @@ -500,7 +619,9 @@ pub(crate) async fn insert_or_update_select_option_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params = data.into_inner(); - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor .insert_select_options( ¶ms.view_id, @@ -519,7 +640,9 @@ pub(crate) async fn delete_select_option_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params = data.into_inner(); - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor .delete_select_options( ¶ms.view_id, @@ -539,7 +662,7 @@ pub(crate) async fn update_select_option_cell_handler( let manager = upgrade_manager(manager)?; let params: SelectOptionCellChangesetParams = data.into_inner().try_into()?; let database_editor = manager - .get_database_with_view_id(¶ms.cell_identifier.view_id) + .get_database_editor_with_view_id(¶ms.cell_identifier.view_id) .await?; let changeset = SelectOptionCellChangeset { insert_option_ids: params.insert_option_ids, @@ -562,24 +685,19 @@ pub(crate) async fn update_checklist_cell_handler( manager: AFPluginState>, ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; - let params: ChecklistCellDataChangesetParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; - let changeset = ChecklistCellChangeset { - insert_options: params - .insert_options - .into_iter() - .map(|name| (name, false)) - .collect(), - selected_option_ids: params.selected_option_ids, - delete_option_ids: params.delete_option_ids, - update_options: params.update_options, - }; + let params = data.try_into_inner()?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.cell_id.view_id) + .await?; + + let cell_id = params.cell_id.clone(); + database_editor .update_cell_with_changeset( - ¶ms.view_id, - ¶ms.row_id, - ¶ms.field_id, - BoxAny::new(changeset), + &cell_id.view_id, + &(RowId::from(cell_id.row_id)), + &cell_id.field_id, + BoxAny::new(ChecklistCellChangeset::from(params)), ) .await?; Ok(()) @@ -594,17 +712,17 @@ pub(crate) async fn update_date_cell_handler( let data = data.into_inner(); let cell_id: CellIdParams = data.cell_id.try_into()?; let cell_changeset = DateCellChangeset { - date: data.date, - time: data.time, - end_date: data.end_date, - end_time: data.end_time, + timestamp: data.timestamp, + end_timestamp: data.end_timestamp, include_time: data.include_time, is_range: data.is_range, clear_flag: data.clear_flag, reminder_id: data.reminder_id, }; - let database_editor = manager.get_database_with_view_id(&cell_id.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(&cell_id.view_id) + .await?; database_editor .update_cell_with_changeset( &cell_id.view_id, @@ -623,7 +741,9 @@ pub(crate) async fn get_groups_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params: DatabaseViewIdPB = data.into_inner(); - let database_editor = manager.get_database_with_view_id(params.as_ref()).await?; + let database_editor = manager + .get_database_editor_with_view_id(params.as_ref()) + .await?; let groups = database_editor.load_groups(params.as_ref()).await?; data_result_ok(groups) } @@ -635,7 +755,9 @@ pub(crate) async fn get_group_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params: DatabaseGroupIdParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; let group = database_editor .get_group(¶ms.view_id, ¶ms.group_id) .await?; @@ -649,9 +771,11 @@ pub(crate) async fn set_group_by_field_handler( ) -> FlowyResult<()> { let manager = upgrade_manager(manager)?; let params: GroupByFieldParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor - .set_group_by_field(¶ms.view_id, ¶ms.field_id) + .set_group_by_field(¶ms.view_id, ¶ms.field_id, params.setting_content) .await?; Ok(()) } @@ -664,17 +788,11 @@ pub(crate) async fn update_group_handler( let manager = upgrade_manager(manager)?; let params: UpdateGroupParams = data.into_inner().try_into()?; let view_id = params.view_id.clone(); - let database_editor = manager.get_database_with_view_id(&view_id).await?; + let database_editor = manager.get_database_editor_with_view_id(&view_id).await?; let group_changeset = GroupChangeset::from(params); - let (tx, rx) = oneshot::channel(); - af_spawn(async move { - let result = database_editor - .update_group(&view_id, vec![group_changeset]) - .await; - let _ = tx.send(result); - }); - - let _ = rx.await?; + database_editor + .update_group(&view_id, vec![group_changeset]) + .await?; Ok(()) } @@ -685,7 +803,9 @@ pub(crate) async fn move_group_handler( ) -> FlowyResult<()> { let manager = upgrade_manager(manager)?; let params: MoveGroupParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor .move_group(¶ms.view_id, ¶ms.from_group_id, ¶ms.to_group_id) .await?; @@ -699,7 +819,9 @@ pub(crate) async fn move_group_row_handler( ) -> FlowyResult<()> { let manager = upgrade_manager(manager)?; let params: MoveGroupRowParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor .move_group_row( ¶ms.view_id, @@ -719,7 +841,9 @@ pub(crate) async fn create_group_handler( ) -> FlowyResult<()> { let manager = upgrade_manager(manager)?; let params: CreateGroupParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor .create_group(¶ms.view_id, ¶ms.name) .await?; @@ -733,23 +857,33 @@ pub(crate) async fn delete_group_handler( ) -> FlowyResult<()> { let manager = upgrade_manager(manager)?; let params: DeleteGroupParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor.delete_group(params).await?; Ok(()) } #[tracing::instrument(level = "debug", skip(manager), err)] -pub(crate) async fn get_database_meta_handler( +pub(crate) async fn get_default_database_view_id_handler( data: AFPluginData, manager: AFPluginState>, -) -> DataResult { +) -> DataResult { let manager = upgrade_manager(manager)?; let database_id = data.into_inner().value; - let inline_view_id = manager.get_database_inline_view_id(&database_id).await?; + let database_view_id = manager + .get_database_meta(&database_id) + .await? + .and_then(|mut d| d.linked_views.pop()) + .ok_or_else(|| { + FlowyError::internal().with_context(format!( + "Can't find any database view for given database id: {}", + database_id + )) + })?; - let data = DatabaseMetaPB { - database_id, - inline_view_id, + let data = DatabaseViewIdPB { + value: database_view_id, }; data_result_ok(data) } @@ -763,14 +897,11 @@ pub(crate) async fn get_databases_handler( let mut items = Vec::with_capacity(metas.len()); for meta in metas { - match manager.get_database_inline_view_id(&meta.database_id).await { - Ok(view_id) => items.push(DatabaseMetaPB { + if let Some(link_view) = meta.linked_views.first() { + items.push(DatabaseMetaPB { database_id: meta.database_id, - inline_view_id: view_id, - }), - Err(err) => { - error!(?err); - }, + view_id: link_view.clone(), + }) } } @@ -787,18 +918,19 @@ pub(crate) async fn set_layout_setting_handler( let changeset = data.into_inner(); let view_id = changeset.view_id.clone(); let params: LayoutSettingChangeset = changeset.try_into()?; - let database_editor = manager.get_database_with_view_id(&view_id).await?; + let database_editor = manager.get_database_editor_with_view_id(&view_id).await?; database_editor.set_layout_setting(&view_id, params).await?; Ok(()) } - pub(crate) async fn get_layout_setting_handler( data: AFPluginData, manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; let params: DatabaseLayoutMeta = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; let layout_setting_pb = database_editor .get_layout_setting(¶ms.view_id, params.layout) .await @@ -814,7 +946,9 @@ pub(crate) async fn get_calendar_events_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params: CalendarEventRequestParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; let events = database_editor .get_all_calendar_events(¶ms.view_id) .await; @@ -828,7 +962,9 @@ pub(crate) async fn get_no_date_calendar_events_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params: CalendarEventRequestParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; let _events = database_editor .get_all_no_date_calendar_events(¶ms.view_id) .await; @@ -837,12 +973,14 @@ pub(crate) async fn get_no_date_calendar_events_handler( #[tracing::instrument(level = "debug", skip(data, manager), err)] pub(crate) async fn get_calendar_event_handler( - data: AFPluginData, + data: AFPluginData, manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; let params: RowIdParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; let event = database_editor .get_calendar_event(¶ms.view_id, params.row_id) .await; @@ -861,10 +999,12 @@ pub(crate) async fn move_calendar_event_handler( let data = data.into_inner(); let cell_id: CellIdParams = data.cell_path.try_into()?; let cell_changeset = DateCellChangeset { - date: Some(data.timestamp), + timestamp: Some(data.timestamp), ..Default::default() }; - let database_editor = manager.get_database_with_view_id(&cell_id.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(&cell_id.view_id) + .await?; database_editor .update_cell_with_changeset( &cell_id.view_id, @@ -892,7 +1032,7 @@ pub(crate) async fn export_csv_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let view_id = data.into_inner().value; - let database = manager.get_database_with_view_id(&view_id).await?; + let database = manager.get_database_editor_with_view_id(&view_id).await?; let data = database.export_csv(CSVFormat::Original).await?; data_result_ok(DatabaseExportDataPB { export_type: DatabaseExportDataType::CSV, @@ -900,6 +1040,20 @@ pub(crate) async fn export_csv_handler( }) } +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn export_raw_database_data_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let view_id = data.into_inner().value; + let data = manager.get_database_json_string(&view_id).await?; + data_result_ok(DatabaseExportDataPB { + export_type: DatabaseExportDataType::RawDatabaseData, + data, + }) +} + #[tracing::instrument(level = "debug", skip_all, err)] pub(crate) async fn get_snapshots_handler( data: AFPluginData, @@ -918,7 +1072,7 @@ pub(crate) async fn get_field_settings_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let (view_id, field_ids) = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(&view_id).await?; + let database_editor = manager.get_database_editor_with_view_id(&view_id).await?; let field_settings = database_editor .get_field_settings(&view_id, field_ids.clone()) @@ -939,7 +1093,9 @@ pub(crate) async fn get_all_field_settings_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let view_id = data.into_inner(); - let database_editor = manager.get_database_with_view_id(view_id.as_ref()).await?; + let database_editor = manager + .get_database_editor_with_view_id(view_id.as_ref()) + .await?; let field_settings = database_editor .get_all_field_settings(view_id.as_ref()) @@ -960,7 +1116,9 @@ pub(crate) async fn update_field_settings_handler( ) -> FlowyResult<()> { let manager = upgrade_manager(manager)?; let params = data.try_into_inner()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let database_editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; database_editor .update_field_settings_with_changeset(params) .await?; @@ -974,10 +1132,11 @@ pub(crate) async fn get_all_calculations_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let view_id = data.into_inner(); - let database_editor = manager.get_database_with_view_id(view_id.as_ref()).await?; + let database_editor = manager + .get_database_editor_with_view_id(view_id.as_ref()) + .await?; let calculations = database_editor.get_all_calculations(view_id.as_ref()).await; - data_result_ok(calculations) } @@ -988,7 +1147,9 @@ pub(crate) async fn update_calculation_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params: UpdateCalculationChangesetPB = data.into_inner(); - let editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; editor.update_calculation(params).await?; @@ -1002,7 +1163,9 @@ pub(crate) async fn remove_calculation_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params: RemoveCalculationChangesetPB = data.into_inner(); - let editor = manager.get_database_with_view_id(¶ms.view_id).await?; + let editor = manager + .get_database_editor_with_view_id(¶ms.view_id) + .await?; editor.remove_calculation(params).await?; @@ -1036,7 +1199,7 @@ pub(crate) async fn update_relation_cell_handler( removed_row_ids: params.removed_row_ids.into_iter().map(Into::into).collect(), }; - let database_editor = manager.get_database_with_view_id(&view_id).await?; + let database_editor = manager.get_database_editor_with_view_id(&view_id).await?; // // get the related database // let related_database_id = database_editor @@ -1061,30 +1224,39 @@ pub(crate) async fn update_relation_cell_handler( Ok(()) } +#[instrument(level = "debug", skip_all, err)] pub(crate) async fn get_related_row_datas_handler( - data: AFPluginData, + data: AFPluginData, manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; - let params: RepeatedRowIdPB = data.into_inner(); - let database_editor = manager.get_database(¶ms.database_id).await?; + let params: GetRelatedRowDataPB = data.into_inner(); + let database_editor = manager + .get_or_init_database_editor(¶ms.database_id) + .await?; + let row_datas = database_editor - .get_related_rows(Some(¶ms.row_ids)) + .get_related_rows(Some(params.row_ids)) .await?; data_result_ok(RepeatedRelatedRowDataPB { rows: row_datas }) } +#[instrument(level = "debug", skip_all, err)] pub(crate) async fn get_related_database_rows_handler( data: AFPluginData, manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; let database_id = data.into_inner().value; - let database_editor = manager.get_database(&database_id).await?; - let row_datas = database_editor.get_related_rows(None).await?; - data_result_ok(RepeatedRelatedRowDataPB { rows: row_datas }) + info!( + "[Database]: get related database rows from database_id: {}", + database_id + ); + let database_editor = manager.get_or_init_database_editor(&database_id).await?; + let rows = database_editor.get_related_rows(None).await?; + data_result_ok(RepeatedRelatedRowDataPB { rows }) } pub(crate) async fn summarize_row_handler( @@ -1094,8 +1266,150 @@ pub(crate) async fn summarize_row_handler( let manager = upgrade_manager(manager)?; let data = data.into_inner(); let row_id = RowId::from(data.row_id); - manager - .summarize_row(data.view_id, row_id, data.field_id) - .await?; + let (tx, rx) = oneshot::channel(); + tokio::spawn(async move { + let result = manager + .summarize_row(&data.view_id, row_id, data.field_id) + .await; + let _ = tx.send(result); + }); + + rx.await??; + Ok(()) +} + +pub(crate) async fn translate_row_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let manager = upgrade_manager(manager)?; + let data = data.try_into_inner()?; + let row_id = RowId::from(data.row_id); + let (tx, rx) = oneshot::channel(); + tokio::spawn(async move { + let result = manager + .translate_row(&data.view_id, row_id, data.field_id) + .await; + let _ = tx.send(result); + }); + + rx.await??; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn update_media_cell_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> FlowyResult<()> { + let manager = upgrade_manager(manager)?; + let params: MediaCellChangesetPB = data.into_inner(); + let cell_id: CellIdParams = params.cell_id.try_into()?; + let cell_changeset = MediaCellChangeset { + inserted_files: params + .inserted_files + .clone() + .into_iter() + .map(Into::into) + .collect(), + removed_ids: params.removed_ids, + }; + + let database_editor = manager + .get_database_editor_with_view_id(&cell_id.view_id) + .await?; + + database_editor + .update_cell_with_changeset( + &cell_id.view_id, + &cell_id.row_id, + &cell_id.field_id, + BoxAny::new(cell_changeset), + ) + .await?; + + Ok(()) +} + +/// We use a custom handler to rename the media file, as the ordering +/// of the files must be maintained while renaming a file. +/// +/// The changeset in [update_media_cell_handler] contains removals and inserts, +/// and if we were to remove and insert the file, it would mess up the ordering. +/// +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn rename_media_cell_file_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> FlowyResult<()> { + let manager = upgrade_manager(manager)?; + let params: RenameMediaChangesetPB = data.into_inner(); + let cell_id: CellIdParams = params.cell_id.try_into()?; + + let database_editor = manager + .get_database_editor_with_view_id(&cell_id.view_id) + .await?; + + let cell = database_editor + .get_cell(&cell_id.field_id, &cell_id.row_id) + .await; + if cell.is_none() { + return Err(FlowyError::record_not_found()); + } + + let cell = cell.unwrap(); + let field = database_editor + .get_field(&cell_id.field_id) + .await + .ok_or_else(FlowyError::record_not_found)?; + let handler = TypeOptionCellExt::new(&field, None) + .get_type_option_cell_data_handler_with_field_type(FieldType::Media); + if handler.is_none() { + return Err( + FlowyError::internal().with_context("Error renaming media file: field type is not Media"), + ); + } + let handler = handler.unwrap(); + let data = handler + .handle_get_boxed_cell_data(&cell, &field) + .and_then(|cell_data| cell_data.unbox_or_none()) + .unwrap_or_else(MediaCellData::default); + + let file = data + .files + .iter() + .find(|file| file.id == params.file_id) + .ok_or_else(FlowyError::record_not_found)?; + + let new_file = file.rename(params.name); + let new_data = MediaCellData { + files: data + .files + .iter() + .map(|file| { + if file.id == params.file_id { + new_file.clone() + } else { + file.clone() + } + }) + .collect(), + }; + + let result = database_editor + .update_cell( + &cell_id.view_id, + &cell_id.row_id, + &cell_id.field_id, + Cell::from(new_data), + ) + .await; + + if result.is_err() { + return Err( + FlowyError::internal().with_context("Error renaming media file: update cell failed"), + ); + } + Ok(()) } diff --git a/frontend/rust-lib/flowy-database2/src/event_map.rs b/frontend/rust-lib/flowy-database2/src/event_map.rs index 97f390771c..824565e5b8 100644 --- a/frontend/rust-lib/flowy-database2/src/event_map.rs +++ b/frontend/rust-lib/flowy-database2/src/event_map.rs @@ -13,84 +13,91 @@ pub fn init(database_manager: Weak) -> AFPlugin { .name(env!("CARGO_PKG_NAME")) .state(database_manager); plugin - .event(DatabaseEvent::GetDatabase, get_database_data_handler) - .event(DatabaseEvent::GetDatabaseData, get_database_data_handler) - .event(DatabaseEvent::GetDatabaseId, get_database_id_handler) - .event(DatabaseEvent::GetDatabaseSetting, get_database_setting_handler) - .event(DatabaseEvent::UpdateDatabaseSetting, update_database_setting_handler) - .event(DatabaseEvent::GetAllFilters, get_all_filters_handler) - .event(DatabaseEvent::GetAllSorts, get_all_sorts_handler) - .event(DatabaseEvent::DeleteAllSorts, delete_all_sorts_handler) - // Field - .event(DatabaseEvent::GetFields, get_fields_handler) - .event(DatabaseEvent::GetPrimaryField, get_primary_field_handler) - .event(DatabaseEvent::UpdateField, update_field_handler) - .event(DatabaseEvent::UpdateFieldTypeOption, update_field_type_option_handler) - .event(DatabaseEvent::DeleteField, delete_field_handler) - .event(DatabaseEvent::ClearField, clear_field_handler) - .event(DatabaseEvent::UpdateFieldType, switch_to_field_handler) - .event(DatabaseEvent::DuplicateField, duplicate_field_handler) - .event(DatabaseEvent::MoveField, move_field_handler) - .event(DatabaseEvent::CreateField, create_field_handler) - // Row - .event(DatabaseEvent::CreateRow, create_row_handler) - .event(DatabaseEvent::GetRow, get_row_handler) - .event(DatabaseEvent::GetRowMeta, get_row_meta_handler) - .event(DatabaseEvent::UpdateRowMeta, update_row_meta_handler) - .event(DatabaseEvent::DeleteRow, delete_row_handler) - .event(DatabaseEvent::DuplicateRow, duplicate_row_handler) - .event(DatabaseEvent::MoveRow, move_row_handler) - // Cell - .event(DatabaseEvent::GetCell, get_cell_handler) - .event(DatabaseEvent::UpdateCell, update_cell_handler) - // SelectOption - .event(DatabaseEvent::CreateSelectOption, new_select_option_handler) - .event(DatabaseEvent::InsertOrUpdateSelectOption, insert_or_update_select_option_handler) - .event(DatabaseEvent::DeleteSelectOption, delete_select_option_handler) - .event(DatabaseEvent::UpdateSelectOptionCell, update_select_option_cell_handler) - // Checklist - .event(DatabaseEvent::UpdateChecklistCell, update_checklist_cell_handler) - // Date - .event(DatabaseEvent::UpdateDateCell, update_date_cell_handler) - // Group - .event(DatabaseEvent::SetGroupByField, set_group_by_field_handler) - .event(DatabaseEvent::MoveGroup, move_group_handler) - .event(DatabaseEvent::MoveGroupRow, move_group_row_handler) - .event(DatabaseEvent::GetGroups, get_groups_handler) - .event(DatabaseEvent::GetGroup, get_group_handler) - .event(DatabaseEvent::UpdateGroup, update_group_handler) - .event(DatabaseEvent::CreateGroup, create_group_handler) - .event(DatabaseEvent::DeleteGroup, delete_group_handler) - // Database - .event(DatabaseEvent::GetDatabaseMeta, get_database_meta_handler) - .event(DatabaseEvent::GetDatabases, get_databases_handler) - // Calendar - .event(DatabaseEvent::GetAllCalendarEvents, get_calendar_events_handler) - .event(DatabaseEvent::GetNoDateCalendarEvents, get_no_date_calendar_events_handler) - .event(DatabaseEvent::GetCalendarEvent, get_calendar_event_handler) - .event(DatabaseEvent::MoveCalendarEvent, move_calendar_event_handler) - // Layout setting - .event(DatabaseEvent::SetLayoutSetting, set_layout_setting_handler) - .event(DatabaseEvent::GetLayoutSetting, get_layout_setting_handler) - .event(DatabaseEvent::CreateDatabaseView, create_database_view) - // Export - .event(DatabaseEvent::ExportCSV, export_csv_handler) - .event(DatabaseEvent::GetDatabaseSnapshots, get_snapshots_handler) - // Field settings - .event(DatabaseEvent::GetFieldSettings, get_field_settings_handler) - .event(DatabaseEvent::GetAllFieldSettings, get_all_field_settings_handler) - .event(DatabaseEvent::UpdateFieldSettings, update_field_settings_handler) - // Calculations - .event(DatabaseEvent::GetAllCalculations, get_all_calculations_handler) - .event(DatabaseEvent::UpdateCalculation, update_calculation_handler) - .event(DatabaseEvent::RemoveCalculation, remove_calculation_handler) - // Relation - .event(DatabaseEvent::GetRelatedDatabaseIds, get_related_database_ids_handler) - .event(DatabaseEvent::UpdateRelationCell, update_relation_cell_handler) - .event(DatabaseEvent::GetRelatedRowDatas, get_related_row_datas_handler) - .event(DatabaseEvent::GetRelatedDatabaseRows, get_related_database_rows_handler) - // AI - .event(DatabaseEvent::SummarizeRow, summarize_row_handler) + .event(DatabaseEvent::GetDatabase, get_database_data_handler) + .event(DatabaseEvent::GetDatabaseData, get_database_data_handler) + .event(DatabaseEvent::GetDatabaseId, get_database_id_handler) + .event(DatabaseEvent::GetDatabaseSetting, get_database_setting_handler) + .event(DatabaseEvent::UpdateDatabaseSetting, update_database_setting_handler) + .event(DatabaseEvent::GetAllFilters, get_all_filters_handler) + .event(DatabaseEvent::GetAllSorts, get_all_sorts_handler) + .event(DatabaseEvent::DeleteAllSorts, delete_all_sorts_handler) + // Field + .event(DatabaseEvent::GetFields, get_fields_handler) + .event(DatabaseEvent::GetPrimaryField, get_primary_field_handler) + .event(DatabaseEvent::UpdateField, update_field_handler) + .event(DatabaseEvent::UpdateFieldTypeOption, update_field_type_option_handler) + .event(DatabaseEvent::DeleteField, delete_field_handler) + .event(DatabaseEvent::ClearField, clear_field_handler) + .event(DatabaseEvent::UpdateFieldType, switch_to_field_handler) + .event(DatabaseEvent::DuplicateField, duplicate_field_handler) + .event(DatabaseEvent::MoveField, move_field_handler) + .event(DatabaseEvent::CreateField, create_field_handler) + // Row + .event(DatabaseEvent::CreateRow, create_row_handler) + .event(DatabaseEvent::GetRow, get_row_handler) + .event(DatabaseEvent::InitRow, init_row_handler) + .event(DatabaseEvent::GetRowMeta, get_row_meta_handler) + .event(DatabaseEvent::UpdateRowMeta, update_row_meta_handler) + .event(DatabaseEvent::DeleteRows, delete_rows_handler) + .event(DatabaseEvent::DuplicateRow, duplicate_row_handler) + .event(DatabaseEvent::MoveRow, move_row_handler) + .event(DatabaseEvent::RemoveCover, remove_cover_handler) + // Cell + .event(DatabaseEvent::GetCell, get_cell_handler) + .event(DatabaseEvent::UpdateCell, update_cell_handler) + // SelectOption + .event(DatabaseEvent::CreateSelectOption, new_select_option_handler) + .event(DatabaseEvent::InsertOrUpdateSelectOption, insert_or_update_select_option_handler) + .event(DatabaseEvent::DeleteSelectOption, delete_select_option_handler) + .event(DatabaseEvent::UpdateSelectOptionCell, update_select_option_cell_handler) + // Checklist + .event(DatabaseEvent::UpdateChecklistCell, update_checklist_cell_handler) + // Date + .event(DatabaseEvent::UpdateDateCell, update_date_cell_handler) + // Group + .event(DatabaseEvent::SetGroupByField, set_group_by_field_handler) + .event(DatabaseEvent::MoveGroup, move_group_handler) + .event(DatabaseEvent::MoveGroupRow, move_group_row_handler) + .event(DatabaseEvent::GetGroups, get_groups_handler) + .event(DatabaseEvent::GetGroup, get_group_handler) + .event(DatabaseEvent::UpdateGroup, update_group_handler) + .event(DatabaseEvent::CreateGroup, create_group_handler) + .event(DatabaseEvent::DeleteGroup, delete_group_handler) + // Database + .event(DatabaseEvent::GetDefaultDatabaseViewId, get_default_database_view_id_handler) + .event(DatabaseEvent::GetDatabases, get_databases_handler) + // Calendar + .event(DatabaseEvent::GetAllCalendarEvents, get_calendar_events_handler) + .event(DatabaseEvent::GetNoDateCalendarEvents, get_no_date_calendar_events_handler) + .event(DatabaseEvent::GetCalendarEvent, get_calendar_event_handler) + .event(DatabaseEvent::MoveCalendarEvent, move_calendar_event_handler) + // Layout setting + .event(DatabaseEvent::SetLayoutSetting, set_layout_setting_handler) + .event(DatabaseEvent::GetLayoutSetting, get_layout_setting_handler) + .event(DatabaseEvent::CreateDatabaseView, create_database_view) + // Export + .event(DatabaseEvent::ExportCSV, export_csv_handler) + .event(DatabaseEvent::ExportRawDatabaseData, export_raw_database_data_handler) + .event(DatabaseEvent::GetDatabaseSnapshots, get_snapshots_handler) + // Field settings + .event(DatabaseEvent::GetFieldSettings, get_field_settings_handler) + .event(DatabaseEvent::GetAllFieldSettings, get_all_field_settings_handler) + .event(DatabaseEvent::UpdateFieldSettings, update_field_settings_handler) + // Calculations + .event(DatabaseEvent::GetAllCalculations, get_all_calculations_handler) + .event(DatabaseEvent::UpdateCalculation, update_calculation_handler) + .event(DatabaseEvent::RemoveCalculation, remove_calculation_handler) + // Relation + .event(DatabaseEvent::GetRelatedDatabaseIds, get_related_database_ids_handler) + .event(DatabaseEvent::UpdateRelationCell, update_relation_cell_handler) + .event(DatabaseEvent::GetRelatedRowDatas, get_related_row_datas_handler) + .event(DatabaseEvent::GetRelatedDatabaseRows, get_related_database_rows_handler) + // AI + .event(DatabaseEvent::SummarizeRow, summarize_row_handler) + .event(DatabaseEvent::TranslateRow, translate_row_handler) + // Media + .event(DatabaseEvent::UpdateMediaCell, update_media_cell_handler) + .event(DatabaseEvent::RenameMediaFile, rename_media_cell_file_handler) } /// [DatabaseEvent] defines events that are used to interact with the Grid. You could check [this](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/backend/protobuf) @@ -220,24 +227,27 @@ pub enum DatabaseEvent { /// [GetRow] event is used to get the row data,[RowPB]. [OptionalRowPB] is a wrapper that enables /// to return a nullable row data. - #[event(input = "RowIdPB", output = "OptionalRowPB")] + #[event(input = "DatabaseViewRowIdPB", output = "OptionalRowPB")] GetRow = 51, - #[event(input = "RowIdPB")] - DeleteRow = 52, + #[event(input = "RepeatedRowIdPB")] + DeleteRows = 52, - #[event(input = "RowIdPB")] + #[event(input = "DatabaseViewRowIdPB")] DuplicateRow = 53, #[event(input = "MoveRowPayloadPB")] MoveRow = 54, - #[event(input = "RowIdPB", output = "RowMetaPB")] + #[event(input = "DatabaseViewRowIdPB", output = "RowMetaPB")] GetRowMeta = 55, #[event(input = "UpdateRowMetaChangesetPB")] UpdateRowMeta = 56, + #[event(input = "RemoveCoverPayloadPB")] + RemoveCover = 57, + #[event(input = "CellIdPB", output = "CellPB")] GetCell = 70, @@ -295,8 +305,8 @@ pub enum DatabaseEvent { #[event(input = "DeleteGroupPayloadPB")] DeleteGroup = 115, - #[event(input = "DatabaseIdPB", output = "DatabaseMetaPB")] - GetDatabaseMeta = 119, + #[event(input = "DatabaseIdPB", output = "DatabaseViewIdPB")] + GetDefaultDatabaseViewId = 119, /// Returns all the databases #[event(output = "RepeatedDatabaseDescriptionPB")] @@ -317,7 +327,7 @@ pub enum DatabaseEvent { )] GetNoDateCalendarEvents = 124, - #[event(input = "RowIdPB", output = "CalendarEventPB")] + #[event(input = "DatabaseViewRowIdPB", output = "CalendarEventPB")] GetCalendarEvent = 125, #[event(input = "MoveCalendarEventPB")] @@ -364,7 +374,7 @@ pub enum DatabaseEvent { UpdateRelationCell = 171, /// Get the names of the linked rows in a relation cell. - #[event(input = "RepeatedRowIdPB", output = "RepeatedRelatedRowDataPB")] + #[event(input = "GetRelatedRowDataPB", output = "RepeatedRelatedRowDataPB")] GetRelatedRowDatas = 172, /// Get the names of all the rows in a related database. @@ -373,4 +383,22 @@ pub enum DatabaseEvent { #[event(input = "SummaryRowPB")] SummarizeRow = 174, + + #[event(input = "TranslateRowPB")] + TranslateRow = 175, + + #[event(input = "DatabaseViewRowIdPB")] + InitRow = 176, + + #[event(input = "DatabaseViewIdPB", output = "RepeatedRowMetaPB")] + GetAllRows = 177, + + #[event(input = "DatabaseViewIdPB", output = "DatabaseExportDataPB")] + ExportRawDatabaseData = 178, + + #[event(input = "MediaCellChangesetPB")] + UpdateMediaCell = 200, + + #[event(input = "RenameMediaChangesetPB")] + RenameMediaFile = 201, } diff --git a/frontend/rust-lib/flowy-database2/src/manager.rs b/frontend/rust-lib/flowy-database2/src/manager.rs index 21858ad201..666d2f8eaf 100644 --- a/frontend/rust-lib/flowy-database2/src/manager.rs +++ b/frontend/rust-lib/flowy-database2/src/manager.rs @@ -1,79 +1,91 @@ use anyhow::anyhow; -use std::collections::HashMap; -use std::sync::{Arc, Weak}; - -use collab::core::collab::{DataSource, MutexCollab}; -use collab_database::database::DatabaseData; +use arc_swap::ArcSwapOption; +use async_trait::async_trait; +use collab::core::collab::DataSource; +use collab::core::origin::CollabOrigin; +use collab::lock::RwLock; +use collab::preclude::Collab; +use collab_database::database::{Database, DatabaseData}; +use collab_database::entity::{CreateDatabaseParams, CreateViewParams, EncodedDatabase}; use collab_database::error::DatabaseError; +use collab_database::fields::translate_type_option::TranslateTypeOption; use collab_database::rows::RowId; -use collab_database::views::{CreateDatabaseParams, CreateViewParams, DatabaseLayout}; +use collab_database::template::csv::CSVTemplate; +use collab_database::views::DatabaseLayout; use collab_database::workspace_database::{ - CollabDocStateByOid, CollabFuture, DatabaseCollabService, DatabaseMeta, WorkspaceDatabase, + CollabPersistenceImpl, DatabaseCollabPersistenceService, DatabaseCollabService, DatabaseMeta, + EncodeCollabByOid, WorkspaceDatabaseManager, }; -use collab_entity::CollabType; +use collab_entity::{CollabObject, CollabType, EncodedCollab}; use collab_plugins::local_storage::kv::KVTransactionDB; -use tokio::sync::{Mutex, RwLock}; -use tracing::{event, instrument, trace}; +use rayon::prelude::*; +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::{Arc, Weak}; +use std::time::Duration; +use tokio::sync::Mutex; +use tracing::{error, info, instrument, trace}; use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabBuilderConfig}; -use collab_integrate::{CollabKVAction, CollabKVDB, CollabPersistenceConfig}; -use flowy_database_pub::cloud::{DatabaseCloudService, SummaryRowContent}; +use collab_integrate::{CollabKVAction, CollabKVDB}; +use flowy_database_pub::cloud::{ + DatabaseAIService, DatabaseCloudService, SummaryRowContent, TranslateItem, TranslateRowContent, +}; use flowy_error::{internal_error, FlowyError, FlowyResult}; + use lib_infra::box_any::BoxAny; use lib_infra::priority_task::TaskDispatcher; -use crate::entities::{DatabaseLayoutPB, DatabaseSnapshotPB}; +use crate::entities::{DatabaseLayoutPB, DatabaseSnapshotPB, FieldType, RowMetaPB}; use crate::services::cell::stringify_cell; use crate::services::database::DatabaseEditor; use crate::services::database_view::DatabaseLayoutDepsResolver; use crate::services::field_settings::default_field_settings_by_layout_map; use crate::services::share::csv::{CSVFormat, CSVImporter, ImportResult}; +use tokio::sync::RwLock as TokioRwLock; +use uuid::Uuid; pub trait DatabaseUser: Send + Sync { fn user_id(&self) -> Result; fn collab_db(&self, uid: i64) -> Result, FlowyError>; - fn workspace_id(&self) -> Result; - fn workspace_database_object_id(&self) -> Result; + fn workspace_id(&self) -> Result; + fn workspace_database_object_id(&self) -> Result; } +pub(crate) type DatabaseEditorMap = HashMap>; pub struct DatabaseManager { user: Arc, - workspace_database: Arc>>>, - task_scheduler: Arc>, - editors: Mutex>>, + workspace_database_manager: ArcSwapOption>, + task_scheduler: Arc>, + pub(crate) editors: Mutex, + removing_editor: Arc>>>, collab_builder: Arc, cloud_service: Arc, + ai_service: Arc, } impl DatabaseManager { pub fn new( database_user: Arc, - task_scheduler: Arc>, + task_scheduler: Arc>, collab_builder: Arc, cloud_service: Arc, + ai_service: Arc, ) -> Self { Self { user: database_user, - workspace_database: Default::default(), + workspace_database_manager: Default::default(), task_scheduler, editors: Default::default(), + removing_editor: Default::default(), collab_builder, cloud_service, - } - } - - fn is_collab_exist(&self, uid: i64, collab_db: &Weak, object_id: &str) -> bool { - match collab_db.upgrade() { - None => false, - Some(collab_db) => { - let read_txn = collab_db.read_txn(); - read_txn.is_exist(uid, object_id) - }, + ai_service, } } /// When initialize with new workspace, all the resources will be cleared. - pub async fn initialize(&self, uid: i64) -> FlowyResult<()> { + pub async fn initialize(&self, uid: i64, is_local_user: bool) -> FlowyResult<()> { // 1. Clear all existing tasks self.task_scheduler.write().await.clear_task(); // 2. Release all existing editors @@ -81,106 +93,104 @@ impl DatabaseManager { editor.close_all_views().await; } self.editors.lock().await.clear(); + self.removing_editor.lock().await.clear(); // 3. Clear the workspace database - if let Some(old_workspace_database) = self.workspace_database.write().await.take() { - old_workspace_database.close(); + if let Some(old_workspace_database) = self.workspace_database_manager.swap(None) { + info!("Close the old workspace database"); + let wdb = old_workspace_database.read().await; + wdb.close(); } - *self.workspace_database.write().await = None; let collab_db = self.user.collab_db(uid)?; - let collab_builder = UserDatabaseCollabServiceImpl { - user: self.user.clone(), - collab_builder: self.collab_builder.clone(), - cloud_service: self.cloud_service.clone(), - }; - let config = CollabPersistenceConfig::new().snapshot_per_update(100); - - let workspace_id = self.user.workspace_id()?; - let workspace_database_object_id = self.user.workspace_database_object_id()?; - let mut workspace_database_doc_state = DataSource::Disk; - // If the workspace database not exist in disk, try to fetch from remote. - if !self.is_collab_exist(uid, &collab_db, &workspace_database_object_id) { - trace!("workspace database not exist, try to fetch from remote"); - match self - .cloud_service - .get_database_object_doc_state( - &workspace_database_object_id, - CollabType::WorkspaceDatabase, - &workspace_id, - ) - .await - { - Ok(doc_state) => match doc_state { - Some(doc_state) => { - workspace_database_doc_state = DataSource::DocStateV1(doc_state); - }, - None => { - workspace_database_doc_state = DataSource::Disk; - }, - }, - Err(err) => { - return Err(FlowyError::record_not_found().with_context(format!( - "get workspace database :{} failed: {}", - workspace_database_object_id, err, - ))); - }, - } - } - - // Construct the workspace database. - event!( - tracing::Level::INFO, - "open aggregate database views object: {}", - &workspace_database_object_id + let collab_service = WorkspaceDatabaseCollabServiceImpl::new( + is_local_user, + self.user.clone(), + self.collab_builder.clone(), + self.cloud_service.clone(), ); - let collab = collab_builder.build_collab_with_config( - uid, - &workspace_database_object_id, - CollabType::WorkspaceDatabase, - collab_db.clone(), - workspace_database_doc_state, - config.clone(), + + let workspace_database_object_id = self.user.workspace_database_object_id()?; + let workspace_database_collab = collab_service + .build_collab( + workspace_database_object_id.to_string().as_str(), + CollabType::WorkspaceDatabase, + None, + ) + .await?; + let collab_object = collab_service + .build_collab_object(&workspace_database_object_id, CollabType::WorkspaceDatabase)?; + let workspace_database = self.collab_builder.create_workspace_database_manager( + collab_object, + workspace_database_collab, + collab_db, + CollabBuilderConfig::default().sync_enable(true), + collab_service, )?; - let workspace_database = - WorkspaceDatabase::open(uid, collab, collab_db, config, collab_builder); - *self.workspace_database.write().await = Some(Arc::new(workspace_database)); + + self + .workspace_database_manager + .store(Some(workspace_database)); Ok(()) } #[instrument( - name = "database_initialize_with_new_user", + name = "database_initialize_after_sign_up", level = "debug", skip_all, err )] - pub async fn initialize_with_new_user(&self, user_id: i64) -> FlowyResult<()> { - self.initialize(user_id).await?; + pub async fn initialize_after_sign_up( + &self, + user_id: i64, + is_local_user: bool, + ) -> FlowyResult<()> { + self.initialize(user_id, is_local_user).await?; Ok(()) } - pub async fn get_database_inline_view_id(&self, database_id: &str) -> FlowyResult { - let wdb = self.get_database_indexer().await?; - let database_collab = wdb.get_database(database_id).await.ok_or_else(|| { - FlowyError::record_not_found().with_context(format!("The database:{} not found", database_id)) - })?; + pub async fn initialize_after_open_workspace( + &self, + user_id: i64, + is_local_user: bool, + ) -> FlowyResult<()> { + self.initialize(user_id, is_local_user).await?; + Ok(()) + } - let lock_guard = database_collab.lock(); - Ok(lock_guard.get_inline_view_id()) + pub async fn initialize_after_sign_in( + &self, + user_id: i64, + is_local_user: bool, + ) -> FlowyResult<()> { + self.initialize(user_id, is_local_user).await?; + Ok(()) } pub async fn get_all_databases_meta(&self) -> Vec { let mut items = vec![]; - if let Ok(wdb) = self.get_database_indexer().await { + if let Some(lock) = self.workspace_database_manager.load_full() { + let wdb = lock.read().await; items = wdb.get_all_database_meta() } items } + pub async fn get_database_meta(&self, database_id: &str) -> FlowyResult> { + let mut database_meta = None; + if let Some(lock) = self.workspace_database_manager.load_full() { + let wdb = lock.read().await; + database_meta = wdb.get_database_meta(database_id); + } + Ok(database_meta) + } + + #[instrument(level = "trace", skip_all, err)] pub async fn update_database_indexing( &self, view_ids_by_database_id: HashMap>, ) -> FlowyResult<()> { - let wdb = self.get_database_indexer().await?; + let lock = self.workspace_database()?; + let mut wdb = lock.write().await; view_ids_by_database_id .into_iter() .for_each(|(database_id, view_ids)| { @@ -189,37 +199,96 @@ impl DatabaseManager { Ok(()) } - pub async fn get_database_with_view_id(&self, view_id: &str) -> FlowyResult> { - let database_id = self.get_database_id_with_view_id(view_id).await?; - self.get_database(&database_id).await - } - pub async fn get_database_id_with_view_id(&self, view_id: &str) -> FlowyResult { - let wdb = self.get_database_indexer().await?; - wdb.get_database_id_with_view_id(view_id).ok_or_else(|| { + let lock = self.workspace_database()?; + let wdb = lock.read().await; + let database_id = wdb.get_database_id_with_view_id(view_id); + database_id.ok_or_else(|| { FlowyError::record_not_found() .with_context(format!("The database for view id: {} not found", view_id)) }) } - pub async fn get_database(&self, database_id: &str) -> FlowyResult> { + pub async fn encode_database(&self, view_id: &Uuid) -> FlowyResult { + let editor = self + .get_database_editor_with_view_id(view_id.to_string().as_str()) + .await?; + let collabs = editor + .database + .read() + .await + .encode_database_collabs() + .await?; + Ok(collabs) + } + + pub async fn get_database_row_ids_with_view_id(&self, view_id: &str) -> FlowyResult> { + let database = self.get_database_editor_with_view_id(view_id).await?; + Ok(database.get_row_ids().await) + } + + pub async fn get_database_row_metas_with_view_id( + &self, + view_id: &Uuid, + row_ids: Vec, + ) -> FlowyResult> { + let database = self + .get_database_editor_with_view_id(view_id.to_string().as_str()) + .await?; + let view_id = view_id.to_string(); + let mut row_metas: Vec = vec![]; + for row_id in row_ids { + if let Some(row_meta) = database.get_row_meta(&view_id, &row_id).await { + row_metas.push(row_meta); + } + } + Ok(row_metas) + } + + pub async fn get_database_editor_with_view_id( + &self, + view_id: &str, + ) -> FlowyResult> { + let database_id = self.get_database_id_with_view_id(view_id).await?; + self.get_or_init_database_editor(&database_id).await + } + + pub async fn get_or_init_database_editor( + &self, + database_id: &str, + ) -> FlowyResult> { if let Some(editor) = self.editors.lock().await.get(database_id).cloned() { return Ok(editor); } - // TODO(nathan): refactor the get_database that split the database creation and database opening. - self.open_database(database_id).await + let editor = self.open_database(database_id).await?; + Ok(editor) } + #[instrument(level = "trace", skip_all, err)] pub async fn open_database(&self, database_id: &str) -> FlowyResult> { - trace!("open database editor:{}", database_id); - let database = self - .get_database_indexer() - .await? - .get_database(database_id) - .await - .ok_or_else(|| FlowyError::collab_not_sync().with_context("open database error"))?; + let workspace_database = self.workspace_database()?; + if let Some(database_editor) = self.removing_editor.lock().await.remove(database_id) { + self + .editors + .lock() + .await + .insert(database_id.to_string(), database_editor.clone()); + return Ok(database_editor); + } + + trace!("[Database]: init database editor:{}", database_id); + // When the user opens the database from the left-side bar, it may fail because the workspace database + // hasn't finished syncing yet. In such cases, get_or_create_database will return None. + // The workaround is to add a retry mechanism to attempt fetching the database again. + let database = open_database_with_retry(workspace_database, database_id).await?; + let editor = DatabaseEditor::new( + self.user.clone(), + database, + self.task_scheduler.clone(), + self.collab_builder.clone(), + ) + .await?; - let editor = Arc::new(DatabaseEditor::new(database, self.task_scheduler.clone()).await?); self .editors .lock() @@ -228,38 +297,71 @@ impl DatabaseManager { Ok(editor) } - pub async fn open_database_view>(&self, view_id: T) -> FlowyResult<()> { - let view_id = view_id.as_ref(); - let wdb = self.get_database_indexer().await?; - if let Some(database_id) = wdb.get_database_id_with_view_id(view_id) { - if let Some(database) = wdb.open_database(&database_id) { - if let Some(lock_database) = database.try_lock() { - if let Some(lock_collab) = lock_database.get_collab().try_lock() { - trace!("{} database start init sync", view_id); - lock_collab.start_init_sync(); - } - } + /// Open the database view + #[instrument(level = "trace", skip_all, err)] + pub async fn open_database_view(&self, view_id: &Uuid) -> FlowyResult<()> { + let view_id = view_id.to_string(); + let lock = self.workspace_database()?; + let workspace_database = lock.read().await; + let result = workspace_database.get_database_id_with_view_id(&view_id); + drop(workspace_database); + + if let Some(database_id) = result { + let is_not_exist = self.editors.lock().await.get(&database_id).is_none(); + if is_not_exist { + let _ = self.open_database(&database_id).await?; } } Ok(()) } - pub async fn close_database_view>(&self, view_id: T) -> FlowyResult<()> { - let view_id = view_id.as_ref(); - let wdb = self.get_database_indexer().await?; - let database_id = wdb.get_database_id_with_view_id(view_id); + #[instrument(level = "trace", skip_all, err)] + pub async fn close_database_view(&self, view_id: &str) -> FlowyResult<()> { + let lock = self.workspace_database()?; + let workspace_database = lock.read().await; + let database_id = workspace_database.get_database_id_with_view_id(view_id); + drop(workspace_database); + if let Some(database_id) = database_id { let mut editors = self.editors.lock().await; let mut should_remove = false; if let Some(editor) = editors.get(&database_id) { editor.close_view(view_id).await; - should_remove = editor.num_views().await == 0; + // when there is no opening views, mark the database to be removed. + trace!( + "[Database]: {} has {} opening views", + database_id, + editor.num_of_opening_views().await + ); + should_remove = editor.num_of_opening_views().await == 0; } if should_remove { - trace!("remove database editor:{}", database_id); - editors.remove(&database_id); - wdb.close_database(&database_id); + let editor = editors.remove(&database_id); + drop(editors); + + if let Some(editor) = editor { + editor.close_database().await; + self + .removing_editor + .lock() + .await + .insert(database_id.to_string(), editor); + + let weak_workspace_database = Arc::downgrade(&self.workspace_database()?); + let weak_removing_editors = Arc::downgrade(&self.removing_editor); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(120)).await; + if let Some(removing_editors) = weak_removing_editors.upgrade() { + if removing_editors.lock().await.remove(&database_id).is_some() { + if let Some(workspace_database) = weak_workspace_database.upgrade() { + let wdb = workspace_database.write().await; + wdb.close_database(&database_id); + } + } + } + }); + } } } @@ -267,48 +369,92 @@ impl DatabaseManager { } pub async fn delete_database_view(&self, view_id: &str) -> FlowyResult<()> { - let database = self.get_database_with_view_id(view_id).await?; + let database = self.get_database_editor_with_view_id(view_id).await?; let _ = database.delete_database_view(view_id).await?; Ok(()) } - pub async fn duplicate_database(&self, view_id: &str) -> FlowyResult> { - let wdb = self.get_database_indexer().await?; + pub async fn get_database_data(&self, view_id: &str) -> FlowyResult { + let lock = self.workspace_database()?; + let wdb = lock.read().await; let data = wdb.get_database_data(view_id).await?; - let json_bytes = data.to_json_bytes()?; - Ok(json_bytes) + Ok(data) + } + + pub async fn get_database_json_string(&self, view_id: &str) -> FlowyResult { + let lock = self.workspace_database()?; + let wdb = lock.read().await; + let data = wdb.get_database_data(view_id).await?; + let json_string = serde_json::to_string(&data)?; + Ok(json_string) } /// Create a new database with the given data that can be deserialized to [DatabaseData]. #[tracing::instrument(level = "trace", skip_all, err)] - pub async fn create_database_with_database_data( + pub async fn create_database_with_data( &self, - view_id: &str, + new_database_view_id: &str, data: Vec, - ) -> FlowyResult<()> { + ) -> FlowyResult { let database_data = DatabaseData::from_json_bytes(data)?; - - let mut create_database_params = CreateDatabaseParams::from_database_data(database_data); - let old_view_id = create_database_params.inline_view_id.clone(); - create_database_params.inline_view_id = view_id.to_string(); - - if let Some(create_view_params) = create_database_params - .views - .iter_mut() - .find(|view| view.view_id == old_view_id) - { - create_view_params.view_id = view_id.to_string(); + if database_data.views.is_empty() { + return Err(FlowyError::invalid_data().with_context("The database data is empty")); } - let wdb = self.get_database_indexer().await?; - let _ = wdb.create_database(create_database_params)?; - Ok(()) + // choose the first view as the display view. The new database_view_id is the ID in the Folder. + let database_view_id = database_data.views[0].id.clone(); + let create_database_params = CreateDatabaseParams::from_database_data( + database_data, + &database_view_id, + new_database_view_id, + ); + + let lock = self.workspace_database()?; + let mut wdb = lock.write().await; + let database = wdb.create_database(create_database_params).await?; + drop(wdb); + + let encoded_collab = database + .read() + .await + .encode_collab_v1(|collab| CollabType::Database.validate_require_data(collab)) + .map_err(|err| FlowyError::internal().with_context(err))?; + Ok(encoded_collab) } - pub async fn create_database_with_params(&self, params: CreateDatabaseParams) -> FlowyResult<()> { - let wdb = self.get_database_indexer().await?; - let _ = wdb.create_database(params)?; - Ok(()) + /// When duplicating a database view, it will duplicate all the database views and replace the duplicated + /// database_view_id with the new_database_view_id. The new database id is the ID created by Folder. + #[tracing::instrument(level = "trace", skip_all, err)] + pub async fn duplicate_database( + &self, + database_view_id: &str, + new_database_view_id: &str, + ) -> FlowyResult { + let lock = self.workspace_database()?; + let mut wdb = lock.write().await; + let database = wdb + .duplicate_database(database_view_id, new_database_view_id) + .await?; + drop(wdb); + + let encoded_collab = database + .read() + .await + .encode_collab_v1(|collab| CollabType::Database.validate_require_data(collab)) + .map_err(|err| FlowyError::internal().with_context(err))?; + Ok(encoded_collab) + } + + pub async fn import_database( + &self, + params: CreateDatabaseParams, + ) -> FlowyResult>> { + let lock = self.workspace_database()?; + let mut wdb = lock.write().await; + let database = wdb.create_database(params).await?; + drop(wdb); + + Ok(database) } /// A linked view is a view that is linked to existing database. @@ -319,18 +465,25 @@ impl DatabaseManager { layout: DatabaseLayout, database_id: String, database_view_id: String, + database_parent_view_id: String, ) -> FlowyResult<()> { - let wdb = self.get_database_indexer().await?; + let workspace_database = self.workspace_database()?; + let mut wdb = workspace_database.write().await; let mut params = CreateViewParams::new(database_id.clone(), database_view_id, name, layout); - if let Some(database) = wdb.get_database(&database_id).await { - let (field, layout_setting) = DatabaseLayoutDepsResolver::new(database, layout) - .resolve_deps_when_create_database_linked_view(); + if let Ok(database) = wdb.get_or_init_database(&database_id).await { + let (field, layout_setting, field_settings_map) = + DatabaseLayoutDepsResolver::new(database, layout) + .resolve_deps_when_create_database_linked_view(&database_parent_view_id) + .await; if let Some(field) = field { params = params.with_deps_fields(vec![field], vec![default_field_settings_by_layout_map()]); } if let Some(layout_setting) = layout_setting { params = params.with_layout_setting(layout_setting); } + if let Some(field_settings_map) = field_settings_map { + params = params.with_field_settings_map(field_settings_map); + } }; wdb.create_database_linked_view(params).await?; Ok(()) @@ -342,30 +495,43 @@ impl DatabaseManager { content: String, format: CSVFormat, ) -> FlowyResult { - let params = tokio::task::spawn_blocking(move || { - CSVImporter.import_csv_from_string(view_id, content, format) - }) - .await - .map_err(internal_error)??; - let result = ImportResult { - database_id: params.database_id.clone(), - view_id: params.inline_view_id.clone(), + let params = match format { + CSVFormat::Original => { + let mut csv_template = CSVTemplate::try_from_reader(content.as_bytes(), true, None)?; + csv_template.reset_view_id(view_id.clone()); + + let database_template = csv_template.try_into_database_template(None).await?; + database_template.into_params() + }, + + CSVFormat::META => { + let cloned_view_id = view_id.clone(); + tokio::task::spawn_blocking(move || { + CSVImporter.import_csv_from_string(cloned_view_id, content, format) + }) + .await + .map_err(internal_error)?? + }, }; - self.create_database_with_params(params).await?; + + let database_id = params.database_id.clone(); + let database = self.import_database(params).await?; + let encoded_database = database.read().await.encode_database_collabs().await?; + let encoded_collabs = std::iter::once(encoded_database.encoded_database_collab) + .chain(encoded_database.encoded_row_collabs.into_iter()) + .collect::>(); + + let result = ImportResult { + database_id, + view_id, + encoded_collabs, + }; + info!("import csv result: {}", result); Ok(result) } - // will implement soon - pub async fn import_csv_from_file( - &self, - _file_path: String, - _format: CSVFormat, - ) -> FlowyResult<()> { - Ok(()) - } - pub async fn export_csv(&self, view_id: &str, style: CSVFormat) -> FlowyResult { - let database = self.get_database_with_view_id(view_id).await?; + let database = self.get_database_editor_with_view_id(view_id).await?; database.export_csv(style).await } @@ -374,8 +540,10 @@ impl DatabaseManager { view_id: &str, layout: DatabaseLayoutPB, ) -> FlowyResult<()> { - let database = self.get_database_with_view_id(view_id).await?; - database.update_view_layout(view_id, layout.into()).await + let database = self.get_database_editor_with_view_id(view_id).await?; + database + .update_view_layout(view_id.to_string().as_str(), layout.into()) + .await } pub async fn get_database_snapshots( @@ -383,7 +551,7 @@ impl DatabaseManager { view_id: &str, limit: usize, ) -> FlowyResult> { - let database_id = self.get_database_id_with_view_id(view_id).await?; + let database_id = Uuid::from_str(&self.get_database_id_with_view_id(view_id).await?)?; let snapshots = self .cloud_service .get_database_collab_object_snapshots(&database_id, limit) @@ -400,51 +568,133 @@ impl DatabaseManager { Ok(snapshots) } - /// Return the database indexer. - /// Each workspace has itw own Database indexer that manages all the databases and database views - async fn get_database_indexer(&self) -> FlowyResult> { - let database = self.workspace_database.read().await; - match &*database { - None => Err(FlowyError::internal().with_context("Workspace database not initialized")), - Some(user_database) => Ok(user_database.clone()), - } + fn workspace_database(&self) -> FlowyResult>> { + self + .workspace_database_manager + .load_full() + .ok_or_else(|| FlowyError::internal().with_context("Workspace database not initialized")) } #[instrument(level = "debug", skip_all)] pub async fn summarize_row( &self, - view_id: String, + view_id: &str, row_id: RowId, field_id: String, ) -> FlowyResult<()> { - let database = self.get_database_with_view_id(&view_id).await?; - - // + let database = self.get_database_editor_with_view_id(view_id).await?; let mut summary_row_content = SummaryRowContent::new(); - if let Some(row) = database.get_row(&view_id, &row_id) { - let fields = database.get_fields(&view_id, None); + if let Some(row) = database.get_row(view_id, &row_id).await { + let fields = database.get_fields(view_id, None).await; for field in fields { - if let Some(cell) = row.cells.get(&field.id) { - summary_row_content.insert(field.name.clone(), stringify_cell(cell, &field)); + // When summarizing a row, skip the content in the "AI summary" cell; it does not need to + // be summarized. + if field.id != field_id { + if FieldType::from(field.field_type).is_ai_field() { + continue; + } + if let Some(cell) = row.cells.get(&field.id) { + summary_row_content.insert(field.name.clone(), stringify_cell(cell, &field)); + } } } } // Call the cloud service to summarize the row. trace!( - "[AI]: summarize row:{}, content:{:?}", + "[AI]:summarize row:{}, content:{:?}", row_id, summary_row_content ); let response = self - .cloud_service - .summary_database_row(&self.user.workspace_id()?, &row_id, summary_row_content) + .ai_service + .summary_database_row( + &self.user.workspace_id()?, + &Uuid::from_str(&row_id)?, + summary_row_content, + ) .await?; trace!("[AI]:summarize row response: {}", response); // Update the cell with the response from the cloud service. database - .update_cell_with_changeset(&view_id, &row_id, &field_id, BoxAny::new(response)) + .update_cell_with_changeset(view_id, &row_id, &field_id, BoxAny::new(response)) + .await?; + Ok(()) + } + + #[instrument(level = "debug", skip_all)] + pub async fn translate_row( + &self, + view_id: &str, + row_id: RowId, + field_id: String, + ) -> FlowyResult<()> { + let database = self.get_database_editor_with_view_id(view_id).await?; + let view_id = view_id.to_string(); + let mut translate_row_content = TranslateRowContent::new(); + let mut language = "english".to_string(); + + if let Some(row) = database.get_row(&view_id, &row_id).await { + let fields = database.get_fields(&view_id, None).await; + for field in fields { + // When translate a row, skip the content in the "AI Translate" cell; it does not need to + // be translated. + if field.id != field_id { + if FieldType::from(field.field_type).is_ai_field() { + continue; + } + + if let Some(cell) = row.cells.get(&field.id) { + translate_row_content.push(TranslateItem { + title: field.name.clone(), + content: stringify_cell(cell, &field), + }) + } + } else { + language = TranslateTypeOption::language_from_type( + field + .type_options + .get(&FieldType::Translate.to_string()) + .cloned() + .map(TranslateTypeOption::from) + .unwrap_or_default() + .language_type, + ) + .to_string(); + } + } + } + + // Call the cloud service to summarize the row. + trace!( + "[AI]:translate to {}, content:{:?}", + language, + translate_row_content + ); + let response = self + .ai_service + .translate_database_row(&self.user.workspace_id()?, translate_row_content, &language) + .await?; + + // Format the response items into a single string + let content = response + .items + .into_iter() + .map(|value| { + value + .into_values() + .map(|v| v.to_string()) + .collect::>() + .join(", ") + }) + .collect::>() + .join(","); + + trace!("[AI]:translate row response: {}", content); + // Update the cell with the response from the cloud service. + database + .update_cell_with_changeset(&view_id, &row_id, &field_id, BoxAny::new(content)) .await?; Ok(()) } @@ -456,85 +706,501 @@ impl DatabaseManager { } } -struct UserDatabaseCollabServiceImpl { +struct WorkspaceDatabaseCollabServiceImpl { + is_local_user: bool, user: Arc, collab_builder: Arc, + persistence: Arc, cloud_service: Arc, } -impl DatabaseCollabService for UserDatabaseCollabServiceImpl { - fn get_collab_doc_state( - &self, - object_id: &str, - object_ty: CollabType, - ) -> CollabFuture> { - let workspace_id = self.user.workspace_id().unwrap(); - let object_id = object_id.to_string(); - let weak_cloud_service = Arc::downgrade(&self.cloud_service); - Box::pin(async move { - match weak_cloud_service.upgrade() { - None => Err(DatabaseError::Internal(anyhow!("Cloud service is dropped"))), - Some(cloud_service) => { - let doc_state = cloud_service - .get_database_object_doc_state(&object_id, object_ty, &workspace_id) - .await?; - match doc_state { - None => Ok(DataSource::Disk), - Some(doc_state) => Ok(DataSource::DocStateV1(doc_state)), - } - }, - } - }) +impl WorkspaceDatabaseCollabServiceImpl { + fn new( + is_local_user: bool, + user: Arc, + collab_builder: Arc, + cloud_service: Arc, + ) -> Self { + let persistence = DatabasePersistenceImpl { user: user.clone() }; + Self { + is_local_user, + user, + collab_builder, + persistence: Arc::new(persistence), + cloud_service, + } } - fn batch_get_collab_update( + async fn get_encode_collab( &self, - object_ids: Vec, + object_id: &Uuid, object_ty: CollabType, - ) -> CollabFuture> { - let cloned_user = self.user.clone(); - let weak_cloud_service = Arc::downgrade(&self.cloud_service); - Box::pin(async move { - let workspace_id = cloned_user - .workspace_id() - .map_err(|err| DatabaseError::Internal(err.into()))?; - match weak_cloud_service.upgrade() { - None => { - tracing::warn!("Cloud service is dropped"); - Ok(CollabDocStateByOid::default()) - }, - Some(cloud_service) => { - let updates = cloud_service - .batch_get_database_object_doc_state(object_ids, object_ty, &workspace_id) - .await?; - Ok(updates) - }, - } - }) + ) -> Result, DatabaseError> { + let workspace_id = self + .user + .workspace_id() + .map_err(|e| DatabaseError::Internal(e.into()))?; + trace!("[Database]: fetch {}:{} from remote", object_id, object_ty); + let encode_collab = self + .cloud_service + .get_database_encode_collab(object_id, object_ty, &workspace_id) + .await + .map_err(|err| DatabaseError::Internal(err.into()))?; + Ok(encode_collab) } - fn build_collab_with_config( + async fn batch_get_encode_collab( &self, - uid: i64, - object_id: &str, - object_type: CollabType, - collab_db: Weak, - collab_raw_data: DataSource, - _persistence_config: CollabPersistenceConfig, - ) -> Result, DatabaseError> { + object_ids: Vec, + object_ty: CollabType, + ) -> Result { let workspace_id = self .user .workspace_id() .map_err(|err| DatabaseError::Internal(err.into()))?; - let collab = self.collab_builder.build_with_config( - &workspace_id, - uid, - object_id, - object_type.clone(), - collab_db.clone(), - collab_raw_data, - CollabBuilderConfig::default().sync_enable(true), - )?; - Ok(collab) + let updates = self + .cloud_service + .batch_get_database_encode_collab(object_ids, object_ty, &workspace_id) + .await + .map_err(|err| DatabaseError::Internal(err.into()))?; + + Ok( + updates + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) + } + + fn collab_db(&self) -> Result, DatabaseError> { + let uid = self + .user + .user_id() + .map_err(|err| DatabaseError::Internal(err.into()))?; + self + .user + .collab_db(uid) + .map_err(|err| DatabaseError::Internal(err.into())) + } + + fn build_collab_object( + &self, + object_id: &Uuid, + object_type: CollabType, + ) -> Result { + let uid = self + .user + .user_id() + .map_err(|err| DatabaseError::Internal(err.into()))?; + let workspace_id = self + .user + .workspace_id() + .map_err(|err| DatabaseError::Internal(err.into()))?; + let object = self + .collab_builder + .collab_object(&workspace_id, uid, object_id, object_type) + .map_err(|err| DatabaseError::Internal(anyhow!("Failed to build collab object: {}", err)))?; + Ok(object) } } + +#[async_trait] +impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { + ///NOTE: this method doesn't initialize plugins, however it is passed into WorkspaceDatabase, + /// therefore all Database/DatabaseRow creation methods must initialize plugins thmselves. + #[instrument(level = "trace", skip_all)] + async fn build_collab( + &self, + object_id: &str, + collab_type: CollabType, + encoded_collab: Option<(EncodedCollab, bool)>, + ) -> Result { + let object_id = Uuid::parse_str(object_id)?; + let object = self.build_collab_object(&object_id, collab_type)?; + let data_source = if self + .persistence + .is_collab_exist(object_id.to_string().as_str()) + { + trace!( + "build collab: {}:{} from local encode collab", + collab_type, + object_id + ); + CollabPersistenceImpl { + persistence: Some(self.persistence.clone()), + } + .into() + } else { + match encoded_collab { + None => { + info!( + "build collab: fetch {}:{} from remote, is_new:{}", + collab_type, + object_id, + encoded_collab.is_none(), + ); + match self.get_encode_collab(&object_id, collab_type).await { + Ok(Some(encode_collab)) => { + info!( + "build collab: {}:{} with remote encode collab, {} bytes", + collab_type, + object_id, + encode_collab.doc_state.len() + ); + DataSource::from(encode_collab) + }, + Ok(None) => { + if self.is_local_user { + info!( + "build collab: {}:{} with empty encode collab", + collab_type, object_id + ); + CollabPersistenceImpl { + persistence: Some(self.persistence.clone()), + } + .into() + } else { + return Err(DatabaseError::RecordNotFound); + } + }, + Err(err) => { + if !matches!(err, DatabaseError::ActionCancelled) { + error!("build collab: failed to get encode collab: {}", err); + } + return Err(err); + }, + } + }, + Some((encoded_collab, _)) => { + info!( + "build collab: {}:{} with new encode collab, {} bytes", + collab_type, + object_id, + encoded_collab.doc_state.len() + ); + self + .persistence + .save_collab(object_id.to_string().as_str(), encoded_collab.clone())?; + + // TODO(nathan): cover database rows and other database collab type + if matches!(collab_type, CollabType::Database) { + if let Ok(workspace_id) = self.user.workspace_id() { + let cloned_encoded_collab = encoded_collab.clone(); + let cloud_service = self.cloud_service.clone(); + tokio::spawn(async move { + let _ = cloud_service + .create_database_encode_collab( + &object_id, + collab_type, + &workspace_id, + cloned_encoded_collab, + ) + .await; + }); + } + } + encoded_collab.into() + }, + } + }; + + let collab_db = self.collab_db()?; + let collab = self + .collab_builder + .build_collab(&object, &collab_db, data_source) + .await?; + Ok(collab) + } + + async fn get_collabs( + &self, + mut object_ids: Vec, + collab_type: CollabType, + ) -> Result { + if object_ids.is_empty() { + return Ok(EncodeCollabByOid::new()); + } + + let mut encoded_collab_by_id = EncodeCollabByOid::new(); + // 1. Collect local disk collabs into a HashMap + let local_disk_encoded_collab: HashMap = object_ids + .par_iter() + .filter_map(|object_id| { + self + .persistence + .get_encoded_collab(object_id.as_str(), collab_type) + .map(|encoded_collab| (object_id.clone(), encoded_collab)) + }) + .collect(); + trace!( + "[Database]: load {} database row from local disk", + local_disk_encoded_collab.len() + ); + + object_ids.retain(|object_id| !local_disk_encoded_collab.contains_key(object_id)); + for (k, v) in local_disk_encoded_collab { + encoded_collab_by_id.insert(k, v); + } + + if !object_ids.is_empty() { + let object_ids = object_ids + .into_iter() + .flat_map(|v| Uuid::from_str(&v).ok()) + .collect::>(); + // 2. Fetch remaining collabs from remote + let remote_collabs = self + .batch_get_encode_collab(object_ids, collab_type) + .await?; + + trace!( + "[Database]: load {} database row from remote", + remote_collabs.len() + ); + for (k, v) in remote_collabs { + encoded_collab_by_id.insert(k, v); + } + } + + Ok(encoded_collab_by_id) + } + + fn persistence(&self) -> Option> { + Some(self.persistence.clone()) + } +} + +pub struct DatabasePersistenceImpl { + user: Arc, +} + +impl DatabasePersistenceImpl { + fn workspace_id(&self) -> Result { + let workspace_id = self + .user + .workspace_id() + .map_err(|err| DatabaseError::Internal(err.into()))?; + Ok(workspace_id) + } +} + +impl DatabaseCollabPersistenceService for DatabasePersistenceImpl { + fn load_collab(&self, collab: &mut Collab) { + let result = self + .user + .user_id() + .map(|uid| (uid, self.user.collab_db(uid).map(|weak| weak.upgrade()))); + + if let Ok(workspace_id) = self.user.workspace_id() { + if let Ok((uid, Ok(Some(collab_db)))) = result { + let object_id = collab.object_id().to_string(); + let db_read = collab_db.read_txn(); + if !db_read.is_exist(uid, workspace_id.to_string().as_str(), &object_id) { + trace!( + "[Database]: collab:{} not exist in local storage", + object_id + ); + return; + } + + trace!("[Database]: start loading collab:{} from disk", object_id); + let mut txn = collab.transact_mut(); + match db_read.load_doc_with_txn( + uid, + workspace_id.to_string().as_str(), + &object_id, + &mut txn, + ) { + Ok(update_count) => { + trace!( + "[Database]: did load collab:{}, update_count:{}", + object_id, + update_count + ); + }, + Err(err) => { + if !err.is_record_not_found() { + error!("[Database]: load collab:{} failed:{}", object_id, err); + } + }, + } + } + } + } + + fn get_encoded_collab(&self, object_id: &str, collab_type: CollabType) -> Option { + let workspace_id = self.user.workspace_id().ok()?.to_string(); + let uid = self.user.user_id().ok()?; + let db = self.user.collab_db(uid).ok()?.upgrade()?; + let read_txn = db.read_txn(); + if !read_txn.is_exist(uid, workspace_id.as_str(), object_id) { + return None; + } + + let mut collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false); + let mut txn = collab.transact_mut(); + let _ = read_txn.load_doc_with_txn(uid, workspace_id.as_str(), object_id, &mut txn); + drop(txn); + + collab + .encode_collab_v1(|collab| collab_type.validate_require_data(collab)) + .ok() + } + + fn delete_collab(&self, object_id: &str) -> Result<(), DatabaseError> { + let workspace_id = self.workspace_id()?.to_string(); + let uid = self + .user + .user_id() + .map_err(|err| DatabaseError::Internal(err.into()))?; + if let Ok(Some(collab_db)) = self.user.collab_db(uid).map(|weak| weak.upgrade()) { + let write_txn = collab_db.write_txn(); + write_txn + .delete_doc(uid, workspace_id.as_str(), object_id) + .unwrap(); + write_txn + .commit_transaction() + .map_err(|err| DatabaseError::Internal(anyhow!("failed to commit transaction: {}", err)))?; + } + Ok(()) + } + + fn save_collab( + &self, + object_id: &str, + encoded_collab: EncodedCollab, + ) -> Result<(), DatabaseError> { + let workspace_id = self.workspace_id()?.to_string(); + let uid = self + .user + .user_id() + .map_err(|err| DatabaseError::Internal(err.into()))?; + if let Ok(Some(collab_db)) = self.user.collab_db(uid).map(|weak| weak.upgrade()) { + let write_txn = collab_db.write_txn(); + write_txn + .flush_doc( + uid, + workspace_id.as_str(), + object_id, + encoded_collab.state_vector.to_vec(), + encoded_collab.doc_state.to_vec(), + ) + .map_err(|err| DatabaseError::Internal(anyhow!("failed to flush doc: {}", err)))?; + write_txn + .commit_transaction() + .map_err(|err| DatabaseError::Internal(anyhow!("failed to commit transaction: {}", err)))?; + } + Ok(()) + } + + fn is_collab_exist(&self, object_id: &str) -> bool { + match self.user.workspace_id() { + Ok(workspace_id) => { + match self + .user + .user_id() + .map_err(|err| DatabaseError::Internal(err.into())) + { + Ok(uid) => { + if let Ok(Some(collab_db)) = self.user.collab_db(uid).map(|weak| weak.upgrade()) { + let read_txn = collab_db.read_txn(); + return read_txn.is_exist(uid, workspace_id.to_string().as_str(), object_id); + } + false + }, + Err(_) => false, + } + }, + Err(_) => false, + } + } + + fn flush_collabs( + &self, + encoded_collabs: Vec<(String, EncodedCollab)>, + ) -> Result<(), DatabaseError> { + let uid = self + .user + .user_id() + .map_err(|err| DatabaseError::Internal(err.into()))?; + let workspace_id = self + .user + .workspace_id() + .map_err(|err| DatabaseError::Internal(err.into()))? + .to_string(); + + if let Ok(Some(collab_db)) = self.user.collab_db(uid).map(|weak| weak.upgrade()) { + let write_txn = collab_db.write_txn(); + for (object_id, encode_collab) in encoded_collabs { + write_txn + .flush_doc( + uid, + &workspace_id, + &object_id, + encode_collab.state_vector.to_vec(), + encode_collab.doc_state.to_vec(), + ) + .map_err(|err| DatabaseError::Internal(anyhow!("failed to flush doc: {}", err)))?; + } + write_txn + .commit_transaction() + .map_err(|err| DatabaseError::Internal(anyhow!("failed to commit transaction: {}", err)))?; + } + Ok(()) + } +} +async fn open_database_with_retry( + workspace_database_manager: Arc>, + database_id: &str, +) -> Result>, DatabaseError> { + let max_retries = 3; + let retry_interval = Duration::from_secs(4); + for attempt in 1..=max_retries { + trace!( + "[Database]: attempt {} to open database:{}", + attempt, + database_id + ); + + let result = workspace_database_manager + .try_read() + .map_err(|err| DatabaseError::Internal(anyhow!("workspace database lock fail: {}", err)))? + .get_or_init_database(database_id) + .await; + + // Attempt to open the database + match result { + Ok(database) => return Ok(database), + Err(err) => { + if matches!(err, DatabaseError::RecordNotFound) + || matches!(err, DatabaseError::NoRequiredData(_)) + { + error!( + "[Database]: retry {} to open database:{}, error:{}", + attempt, database_id, err + ); + + if attempt < max_retries { + tokio::time::sleep(retry_interval).await; + } else { + error!( + "[Database]: exhausted retries to open database:{}, error:{}", + database_id, err + ); + return Err(err); + } + } else { + error!( + "[Database]: stop retrying to open database:{}, error:{}", + database_id, err + ); + return Err(err); + } + }, + } + } + + Err(DatabaseError::Internal(anyhow!( + "Exhausted retries to open database: {}", + database_id + ))) +} diff --git a/frontend/rust-lib/flowy-database2/src/notification.rs b/frontend/rust-lib/flowy-database2/src/notification.rs index eadaa7e031..93e438452f 100644 --- a/frontend/rust-lib/flowy-database2/src/notification.rs +++ b/frontend/rust-lib/flowy-database2/src/notification.rs @@ -90,6 +90,9 @@ impl std::convert::From for DatabaseNotification { } #[tracing::instrument(level = "trace")] -pub fn send_notification(id: &str, ty: DatabaseNotification) -> NotificationBuilder { +pub fn database_notification_builder(id: &str, ty: DatabaseNotification) -> NotificationBuilder { + #[cfg(feature = "verbose_log")] + tracing::trace!("[Database Notification]: id:{}, ty:{:?}", id, ty); + NotificationBuilder::new(id, ty, DATABASE_OBSERVABLE_SOURCE) } diff --git a/frontend/rust-lib/flowy-database2/src/services/calculations/cache.rs b/frontend/rust-lib/flowy-database2/src/services/calculations/cache.rs index d406c88f04..4b6307b095 100644 --- a/frontend/rust-lib/flowy-database2/src/services/calculations/cache.rs +++ b/frontend/rust-lib/flowy-database2/src/services/calculations/cache.rs @@ -1,6 +1,5 @@ -use parking_lot::RwLock; use std::sync::Arc; use crate::utils::cache::AnyTypeCache; -pub type CalculationsByFieldIdCache = Arc>>; +pub type CalculationsByFieldIdCache = Arc>; diff --git a/frontend/rust-lib/flowy-database2/src/services/calculations/controller.rs b/frontend/rust-lib/flowy-database2/src/services/calculations/controller.rs index 5e199b84ad..4e114261f8 100644 --- a/frontend/rust-lib/flowy-database2/src/services/calculations/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/calculations/controller.rs @@ -1,14 +1,15 @@ +use async_trait::async_trait; use std::str::FromStr; use std::sync::Arc; use collab_database::fields::Field; -use collab_database::rows::{Row, RowCell}; +use collab_database::rows::{Cell, Row}; +use dashmap::DashMap; use flowy_error::FlowyResult; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; - -use lib_infra::future::Fut; use lib_infra::priority_task::{QualityOfService, Task, TaskContent, TaskDispatcher}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock as TokioRwLock; +use tracing::{error, instrument, trace}; use crate::entities::{ CalculationChangesetNotificationPB, CalculationPB, CalculationType, FieldType, @@ -19,13 +20,14 @@ use crate::utils::cache::AnyTypeCache; use super::{Calculation, CalculationChangeset, CalculationsService}; +#[async_trait] pub trait CalculationsDelegate: Send + Sync + 'static { - fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Fut>>; - fn get_field(&self, field_id: &str) -> Option; - fn get_calculation(&self, view_id: &str, field_id: &str) -> Fut>>; - fn get_all_calculations(&self, view_id: &str) -> Fut>>>; - fn update_calculation(&self, view_id: &str, calculation: Calculation); - fn remove_calculation(&self, view_id: &str, calculation_id: &str); + async fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Vec>; + async fn get_field(&self, field_id: &str) -> Option; + async fn get_calculation(&self, view_id: &str, field_id: &str) -> Option>; + async fn get_all_calculations(&self, view_id: &str) -> Vec>; + async fn update_calculation(&self, view_id: &str, calculation: Calculation); + async fn remove_calculation(&self, view_id: &str, calculation_id: &str); } pub struct CalculationsController { @@ -33,7 +35,7 @@ pub struct CalculationsController { handler_id: String, delegate: Box, calculations_by_field_cache: CalculationsByFieldIdCache, - task_scheduler: Arc>, + task_scheduler: Arc>, calculations_service: CalculationsService, notifier: DatabaseViewChangedNotifier, } @@ -45,12 +47,12 @@ impl Drop for CalculationsController { } impl CalculationsController { - pub async fn new( + pub fn new( view_id: &str, handler_id: &str, delegate: T, calculations: Vec>, - task_scheduler: Arc>, + task_scheduler: Arc>, notifier: DatabaseViewChangedNotifier, ) -> Self where @@ -65,25 +67,26 @@ impl CalculationsController { calculations_service: CalculationsService::new(), notifier, }; - this.update_cache(calculations).await; + this.update_cache(calculations); this } pub async fn close(&self) { - if let Ok(mut task_scheduler) = self.task_scheduler.try_write() { - task_scheduler.unregister_handler(&self.handler_id).await; - } else { - tracing::error!("Attempt to get the lock of task_scheduler failed"); - } + self + .task_scheduler + .write() + .await + .unregister_handler(&self.handler_id) + .await; } #[tracing::instrument(name = "schedule_calculation_task", level = "trace", skip(self))] - async fn gen_task(&self, task_type: CalculationEvent, qos: QualityOfService) { + pub(crate) async fn gen_task(&self, task_type: CalculationEvent, qos: QualityOfService) { let task_id = self.task_scheduler.read().await.next_task_id(); let task = Task::new( &self.handler_id, task_id, - TaskContent::Text(task_type.to_string()), + TaskContent::Text(task_type.to_json_string()), qos, ); self.task_scheduler.write().await.add_task(task); @@ -98,8 +101,12 @@ impl CalculationsController { )] pub async fn process(&self, predicate: &str) -> FlowyResult<()> { let event_type = CalculationEvent::from_str(predicate).unwrap(); + trace!( + "[Database Calculate] Processing calculation event: {:?}", + event_type + ); match event_type { - CalculationEvent::RowChanged(row) => self.handle_row_changed(row).await, + CalculationEvent::RowChanged(row) => self.handle_row_changed(&row).await, CalculationEvent::CellUpdated(field_id) => self.handle_cell_changed(field_id).await, CalculationEvent::FieldDeleted(field_id) => self.handle_field_deleted(field_id).await, CalculationEvent::FieldTypeChanged(field_id, new_field_type) => { @@ -116,7 +123,7 @@ impl CalculationsController { self .gen_task( CalculationEvent::FieldDeleted(field_id), - QualityOfService::UserInteractive, + QualityOfService::Background, ) .await } @@ -130,7 +137,8 @@ impl CalculationsController { if let Some(calculation) = calculation { self .delegate - .remove_calculation(&self.view_id, &calculation.id); + .remove_calculation(&self.view_id, &calculation.id) + .await; let notification = CalculationChangesetNotificationPB::from_delete( &self.view_id, @@ -149,7 +157,7 @@ impl CalculationsController { self .gen_task( CalculationEvent::FieldTypeChanged(field_id, new_field_type), - QualityOfService::UserInteractive, + QualityOfService::Background, ) .await } @@ -165,7 +173,8 @@ impl CalculationsController { if !calc_type.is_allowed(new_field_type) { self .delegate - .remove_calculation(&self.view_id, &calculation.id); + .remove_calculation(&self.view_id, &calculation.id) + .await; let notification = CalculationChangesetNotificationPB::from_delete( &self.view_id, @@ -185,7 +194,7 @@ impl CalculationsController { self .gen_task( CalculationEvent::CellUpdated(field_id), - QualityOfService::UserInteractive, + QualityOfService::Background, ) .await } @@ -197,22 +206,37 @@ impl CalculationsController { .await; if let Some(calculation) = calculation { - let update = self.get_updated_calculation(calculation).await; - if let Some(update) = update { - self + if let Some(field) = self.delegate.get_field(&field_id).await { + let cells = self .delegate - .update_calculation(&self.view_id, update.clone()); + .get_cells_for_field(&self.view_id, &calculation.field_id) + .await; - let notification = CalculationChangesetNotificationPB::from_update( - &self.view_id, - vec![CalculationPB::from(&update)], - ); + // Update the calculation + if let Some(update) = self + .update_calculation(calculation.as_ref(), &field, cells) + .await + { + self + .delegate + .update_calculation(&self.view_id, update.clone()) + .await; - let _ = self - .notifier - .send(DatabaseViewChanged::CalculationValueNotification( - notification, - )); + // Send notification + let notification = CalculationChangesetNotificationPB::from_update( + &self.view_id, + vec![CalculationPB::from(&update)], + ); + + if let Err(err) = self + .notifier + .send(DatabaseViewChanged::CalculationValueNotification( + notification, + )) + { + error!("Failed to send calculation notification: {:?}", err); + } + } } } } @@ -221,64 +245,116 @@ impl CalculationsController { self .gen_task( CalculationEvent::RowChanged(row), - QualityOfService::UserInteractive, + QualityOfService::Background, ) .await } - async fn handle_row_changed(&self, row: Row) { - let cells = row.cells.iter(); + async fn handle_row_changed(&self, row: &Row) { + let cells = &row.cells; let mut updates = vec![]; + let mut cells_by_field = DashMap::>>::new(); // In case there are calculations where empty cells are counted // as a contribution to the value. - if cells.len() == 0 { + if cells.is_empty() { let calculations = self.delegate.get_all_calculations(&self.view_id).await; - for calculation in calculations.iter() { - let update = self.get_updated_calculation(calculation.clone()).await; - if let Some(update) = update { - updates.push(CalculationPB::from(&update)); - self.delegate.update_calculation(&self.view_id, update); + for calculation in calculations.into_iter() { + if let Some(field) = self.delegate.get_field(&calculation.field_id).await { + let cells = self + .get_or_fetch_cells(&calculation.field_id, &mut cells_by_field) + .await; + updates.extend( + self + .handle_cells_changed(&field, calculation.as_ref(), cells) + .await, + ); } } } // Iterate each cell in the row for cell in cells { - let field_id = cell.0; + let field_id = &cell.0; let calculation = self.delegate.get_calculation(&self.view_id, field_id).await; if let Some(calculation) = calculation { - let update = self.get_updated_calculation(calculation.clone()).await; + let cells = self + .get_or_fetch_cells(&calculation.field_id, &mut cells_by_field) + .await; - if let Some(update) = update { - updates.push(CalculationPB::from(&update)); - self.delegate.update_calculation(&self.view_id, update); + if let Some(field) = self.delegate.get_field(field_id).await { + let changes = self + .handle_cells_changed(&field, calculation.as_ref(), cells) + .await; + updates.extend(changes); } } } if !updates.is_empty() { let notification = CalculationChangesetNotificationPB::from_update(&self.view_id, updates); - - let _ = self + if let Err(err) = self .notifier .send(DatabaseViewChanged::CalculationValueNotification( notification, - )); + )) + { + error!("Failed to send calculation notification: {:?}", err); + } } } - async fn get_updated_calculation(&self, calculation: Arc) -> Option { - let field_cells = self - .delegate - .get_cells_for_field(&self.view_id, &calculation.field_id) - .await; - let field = self.delegate.get_field(&calculation.field_id)?; + async fn get_or_fetch_cells<'a>( + &'a self, + field_id: &'a str, + cells_by_field: &'a mut DashMap>>, + ) -> Vec> { + let cells = cells_by_field.get(field_id).map(|entry| entry.to_vec()); + match cells { + None => { + let fetch_cells = self + .delegate + .get_cells_for_field(&self.view_id, field_id) + .await; + cells_by_field.insert(field_id.to_string(), fetch_cells.clone()); + fetch_cells + }, + Some(cells) => cells, + } + } - let value = + /// field_cells will be the cells that belong to the field with field_id + pub async fn handle_cells_changed( + &self, + field: &Field, + calculation: &Calculation, + field_cells: Vec>, + ) -> Vec { + let mut updates = vec![]; + let update = self + .update_calculation(calculation, field, field_cells) + .await; + if let Some(update) = update { + updates.push(CalculationPB::from(&update)); self - .calculations_service - .calculate(&field, calculation.calculation_type, field_cells); + .delegate + .update_calculation(&self.view_id, update) + .await; + } + + updates + } + + #[instrument(level = "trace", skip_all)] + async fn update_calculation( + &self, + calculation: &Calculation, + field: &Field, + cells: Vec>, + ) -> Option { + let value = self + .calculations_service + .calculate(field, calculation.calculation_type, cells); if value != calculation.value { return Some(calculation.with_value(value)); @@ -294,16 +370,16 @@ impl CalculationsController { let mut notification: Option = None; if let Some(insert) = &changeset.insert_calculation { - let row_cells: Vec> = self + let cells = self .delegate .get_cells_for_field(&self.view_id, &insert.field_id) .await; - let field = self.delegate.get_field(&insert.field_id)?; + let field = self.delegate.get_field(&insert.field_id).await?; let value = self .calculations_service - .calculate(&field, insert.calculation_type, row_cells); + .calculate(&field, insert.calculation_type, cells); notification = Some(CalculationChangesetNotificationPB::from_insert( &self.view_id, @@ -331,27 +407,26 @@ impl CalculationsController { notification } - async fn update_cache(&self, calculations: Vec>) { + fn update_cache(&self, calculations: Vec>) { for calculation in calculations { let field_id = &calculation.field_id; self .calculations_by_field_cache - .write() .insert(field_id, calculation.clone()); } } } #[derive(Serialize, Deserialize, Clone, Debug)] -enum CalculationEvent { +pub(crate) enum CalculationEvent { RowChanged(Row), CellUpdated(String), FieldTypeChanged(String, FieldType), FieldDeleted(String), } -impl ToString for CalculationEvent { - fn to_string(&self) -> String { +impl CalculationEvent { + fn to_json_string(&self) -> String { serde_json::to_string(self).unwrap() } } diff --git a/frontend/rust-lib/flowy-database2/src/services/calculations/entities.rs b/frontend/rust-lib/flowy-database2/src/services/calculations/entities.rs index f4502020ac..34688b7367 100644 --- a/frontend/rust-lib/flowy-database2/src/services/calculations/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/calculations/entities.rs @@ -1,14 +1,15 @@ -use anyhow::bail; -use collab::core::any_map::AnyMapExtension; +use collab::preclude::encoding::serde::from_any; +use collab::preclude::Any; use collab_database::views::{CalculationMap, CalculationMapBuilder}; +use serde::Deserialize; -use crate::entities::CalculationPB; - -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] pub struct Calculation { pub id: String, pub field_id: String, + #[serde(default, rename = "ty")] pub calculation_type: i64, + #[serde(default, rename = "calculation_value")] pub value: String, } @@ -19,25 +20,12 @@ const CALCULATION_VALUE: &str = "calculation_value"; impl From for CalculationMap { fn from(data: Calculation) -> Self { - CalculationMapBuilder::new() - .insert_str_value(CALCULATION_ID, data.id) - .insert_str_value(FIELD_ID, data.field_id) - .insert_i64_value(CALCULATION_TYPE, data.calculation_type) - .insert_str_value(CALCULATION_VALUE, data.value) - .build() - } -} - -impl std::convert::From<&CalculationPB> for Calculation { - fn from(calculation: &CalculationPB) -> Self { - let calculation_type = calculation.calculation_type.into(); - - Self { - id: calculation.id.clone(), - field_id: calculation.field_id.clone(), - calculation_type, - value: calculation.value.clone(), - } + CalculationMapBuilder::from([ + (CALCULATION_ID.into(), data.id.into()), + (FIELD_ID.into(), data.field_id.into()), + (CALCULATION_TYPE.into(), Any::BigInt(data.calculation_type)), + (CALCULATION_VALUE.into(), data.value.into()), + ]) } } @@ -45,29 +33,7 @@ impl TryFrom for Calculation { type Error = anyhow::Error; fn try_from(calculation: CalculationMap) -> Result { - match ( - calculation.get_str_value(CALCULATION_ID), - calculation.get_str_value(FIELD_ID), - ) { - (Some(id), Some(field_id)) => { - let value = calculation - .get_str_value(CALCULATION_VALUE) - .unwrap_or_default(); - let calculation_type = calculation - .get_i64_value(CALCULATION_TYPE) - .unwrap_or_default(); - - Ok(Calculation { - id, - field_id, - calculation_type, - value, - }) - }, - _ => { - bail!("Invalid calculation data") - }, - } + from_any(&Any::from(calculation)).map_err(|e| e.into()) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs b/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs index bf48136622..43feeb6f19 100644 --- a/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs +++ b/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs @@ -1,74 +1,133 @@ use std::sync::Arc; use collab_database::fields::Field; -use collab_database::rows::RowCell; +use collab_database::rows::Cell; use crate::entities::CalculationType; use crate::services::field::TypeOptionCellExt; +use rayon::prelude::*; -pub struct CalculationsService {} - +pub struct CalculationsService; impl CalculationsService { pub fn new() -> Self { - Self {} + Self } - pub fn calculate( - &self, - field: &Field, - calculation_type: i64, - row_cells: Vec>, - ) -> String { + pub fn calculate(&self, field: &Field, calculation_type: i64, cells: Vec>) -> String { let ty: CalculationType = calculation_type.into(); match ty { - CalculationType::Average => self.calculate_average(field, row_cells), - CalculationType::Max => self.calculate_max(field, row_cells), - CalculationType::Median => self.calculate_median(field, row_cells), - CalculationType::Min => self.calculate_min(field, row_cells), - CalculationType::Sum => self.calculate_sum(field, row_cells), - CalculationType::Count => self.calculate_count(row_cells), - CalculationType::CountEmpty => self.calculate_count_empty(field, row_cells), - CalculationType::CountNonEmpty => self.calculate_count_non_empty(field, row_cells), + CalculationType::Average => self.calculate_average(field, cells), + CalculationType::Max => self.calculate_max(field, cells), + CalculationType::Median => self.calculate_median(field, cells), + CalculationType::Min => self.calculate_min(field, cells), + CalculationType::Sum => self.calculate_sum(field, cells), + CalculationType::Count => self.calculate_count(cells), + CalculationType::CountEmpty => self.calculate_count_empty(field, cells), + CalculationType::CountNonEmpty => self.calculate_count_non_empty(field, cells), } } - fn calculate_average(&self, field: &Field, row_cells: Vec>) -> String { - let mut sum = 0.0; - let mut len = 0.0; + fn calculate_average(&self, field: &Field, cells: Vec>) -> String { if let Some(handler) = TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler() { - for row_cell in row_cells { - if let Some(cell) = &row_cell.cell { - if let Some(value) = handler.handle_numeric_cell(cell) { - sum += value; - len += 1.0; - } - } - } - } + let (sum, len): (f64, usize) = cells + .par_iter() + .filter_map(|cell| handler.handle_numeric_cell(cell)) + .map(|value| (value, 1)) + .reduce( + || (0.0, 0), + |(sum1, len1), (sum2, len2)| (sum1 + sum2, len1 + len2), + ); - if len > 0.0 { - format!("{:.5}", sum / len) + if len > 0 { + format!("{:.2}", sum / len as f64) + } else { + String::new() + } } else { String::new() } } - fn calculate_median(&self, field: &Field, row_cells: Vec>) -> String { - let values = self.reduce_values_f64(field, row_cells, |values| { - values.sort_by(|a, b| a.partial_cmp(b).unwrap()); - values.clone() - }); + fn calculate_median(&self, field: &Field, cells: Vec>) -> String { + let mut values = self.reduce_values_f64(field, cells); + values.par_sort_by(|a, b| a.partial_cmp(b).unwrap()); if !values.is_empty() { - format!("{:.5}", Self::median(&values)) + format!("{:.2}", Self::median(&values)) } else { String::new() } } + fn calculate_min(&self, field: &Field, cells: Vec>) -> String { + let values = self.reduce_values_f64(field, cells); + if let Some(min) = values.par_iter().min_by(|a, b| a.total_cmp(b)) { + format!("{:.2}", min) + } else { + String::new() + } + } + + fn calculate_max(&self, field: &Field, cells: Vec>) -> String { + let values = self.reduce_values_f64(field, cells); + if let Some(max) = values.par_iter().max_by(|a, b| a.total_cmp(b)) { + format!("{:.2}", max) + } else { + String::new() + } + } + + fn calculate_sum(&self, field: &Field, cells: Vec>) -> String { + let values = self.reduce_values_f64(field, cells); + if !values.is_empty() { + format!("{:.2}", values.par_iter().sum::()) + } else { + String::new() + } + } + + fn calculate_count(&self, cells: Vec>) -> String { + format!("{}", cells.len()) + } + + fn calculate_count_empty(&self, field: &Field, cells: Vec>) -> String { + if let Some(handler) = TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler() { + let empty_count = cells + .par_iter() + .filter(|cell| handler.handle_is_empty(cell, field)) + .count(); + empty_count.to_string() + } else { + "".to_string() + } + } + + fn calculate_count_non_empty(&self, field: &Field, cells: Vec>) -> String { + if let Some(handler) = TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler() { + let non_empty_count = cells + .par_iter() + .filter(|cell| !handler.handle_is_empty(cell, field)) + .count(); + non_empty_count.to_string() + } else { + "".to_string() + } + } + + fn reduce_values_f64(&self, field: &Field, row_cells: Vec>) -> Vec { + if let Some(handler) = TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler() { + row_cells + .par_iter() + .filter_map(|cell| handler.handle_numeric_cell(cell)) + .collect::>() + } else { + vec![] + } + } + fn median(array: &[f64]) -> f64 { - if (array.len() % 2) == 0 { + if array.len() % 2 == 0 { let left = array.len() / 2 - 1; let right = array.len() / 2; (array[left] + array[right]) / 2.0 @@ -76,109 +135,4 @@ impl CalculationsService { array[array.len() / 2] } } - - fn calculate_min(&self, field: &Field, row_cells: Vec>) -> String { - let values = self.reduce_values_f64(field, row_cells, |values| { - values.sort_by(|a, b| a.partial_cmp(b).unwrap()); - values.clone() - }); - - if !values.is_empty() { - let min = values.iter().min_by(|a, b| a.total_cmp(b)); - if let Some(min) = min { - return format!("{:.5}", min); - } - } - - String::new() - } - - fn calculate_max(&self, field: &Field, row_cells: Vec>) -> String { - let values = self.reduce_values_f64(field, row_cells, |values| { - values.sort_by(|a, b| a.partial_cmp(b).unwrap()); - values.clone() - }); - - if !values.is_empty() { - let max = values.iter().max_by(|a, b| a.total_cmp(b)); - if let Some(max) = max { - return format!("{:.5}", max); - } - } - - String::new() - } - - fn calculate_sum(&self, field: &Field, row_cells: Vec>) -> String { - let values = self.reduce_values_f64(field, row_cells, |values| values.clone()); - - if !values.is_empty() { - format!("{:.5}", values.iter().sum::()) - } else { - String::new() - } - } - - fn calculate_count(&self, row_cells: Vec>) -> String { - if !row_cells.is_empty() { - format!("{}", row_cells.len()) - } else { - String::new() - } - } - - fn calculate_count_empty(&self, field: &Field, row_cells: Vec>) -> String { - match TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler() { - Some(handler) if !row_cells.is_empty() => row_cells - .iter() - .filter(|row_cell| { - if let Some(cell) = &row_cell.cell { - handler.handle_is_cell_empty(cell, field) - } else { - true - } - }) - .collect::>() - .len() - .to_string(), - _ => "".to_string(), - } - } - - fn calculate_count_non_empty(&self, field: &Field, row_cells: Vec>) -> String { - match TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler() { - Some(handler) if !row_cells.is_empty() => row_cells - .iter() - .filter(|row_cell| { - if let Some(cell) = &row_cell.cell { - !handler.handle_is_cell_empty(cell, field) - } else { - false - } - }) - .collect::>() - .len() - .to_string(), - _ => "".to_string(), - } - } - - fn reduce_values_f64(&self, field: &Field, row_cells: Vec>, f: F) -> T - where - F: FnOnce(&mut Vec) -> T, - { - let mut values = vec![]; - - if let Some(handler) = TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler() { - for row_cell in row_cells { - if let Some(cell) = &row_cell.cell { - if let Some(value) = handler.handle_numeric_cell(cell) { - values.push(value); - } - } - } - } - - f(&mut values) - } } diff --git a/frontend/rust-lib/flowy-database2/src/services/calculations/task.rs b/frontend/rust-lib/flowy-database2/src/services/calculations/task.rs index b8ae249c4b..073e35c143 100644 --- a/frontend/rust-lib/flowy-database2/src/services/calculations/task.rs +++ b/frontend/rust-lib/flowy-database2/src/services/calculations/task.rs @@ -1,9 +1,9 @@ -use lib_infra::future::BoxResultFuture; +use crate::services::calculations::CalculationsController; +use async_trait::async_trait; + use lib_infra::priority_task::{TaskContent, TaskHandler}; use std::sync::Arc; -use crate::services::calculations::CalculationsController; - pub struct CalculationsTaskHandler { handler_id: String, calculations_controller: Arc, @@ -18,6 +18,7 @@ impl CalculationsTaskHandler { } } +#[async_trait] impl TaskHandler for CalculationsTaskHandler { fn handler_id(&self) -> &str { &self.handler_id @@ -27,16 +28,14 @@ impl TaskHandler for CalculationsTaskHandler { "CalculationsTaskHandler" } - fn run(&self, content: TaskContent) -> BoxResultFuture<(), anyhow::Error> { + async fn run(&self, content: TaskContent) -> Result<(), anyhow::Error> { let calculations_controller = self.calculations_controller.clone(); - Box::pin(async move { - if let TaskContent::Text(predicate) = content { - calculations_controller - .process(&predicate) - .await - .map_err(anyhow::Error::from)?; - } - Ok(()) - }) + if let TaskContent::Text(predicate) = content { + calculations_controller + .process(&predicate) + .await + .map_err(anyhow::Error::from)?; + } + Ok(()) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/cell/cell_data_cache.rs b/frontend/rust-lib/flowy-database2/src/services/cell/cell_data_cache.rs index 07864351d4..b7606fedbd 100644 --- a/frontend/rust-lib/flowy-database2/src/services/cell/cell_data_cache.rs +++ b/frontend/rust-lib/flowy-database2/src/services/cell/cell_data_cache.rs @@ -1,6 +1,5 @@ -use parking_lot::RwLock; use std::sync::Arc; use crate::utils::cache::AnyTypeCache; -pub type CellCache = Arc>>; +pub type CellCache = Arc>; diff --git a/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs b/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs index 7212c2fa54..5009c674d0 100644 --- a/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs +++ b/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs @@ -1,14 +1,21 @@ use std::collections::HashMap; use std::str::FromStr; +use collab_database::fields::media_type_option::MediaCellData; +use collab_database::fields::select_type_option::SelectOptionIds; use collab_database::fields::Field; use collab_database::rows::{get_field_type_from_cell, Cell, Cells}; - +use collab_database::template::relation_parse::RelationCellData; use flowy_error::{FlowyError, FlowyResult}; use lib_infra::box_any::BoxAny; +use tracing::trace; use crate::entities::{CheckboxCellDataPB, FieldType}; use crate::services::cell::{CellCache, CellProtobufBlob}; +use crate::services::field::checklist_filter::{ + ChecklistCellChangeset, ChecklistCellInsertChangeset, +}; +use crate::services::field::date_filter::DateCellChangeset; use crate::services::field::*; use crate::services::group::make_no_status_group; @@ -22,7 +29,9 @@ pub trait CellDataDecoder: TypeOption { /// /// * `cell`: the cell to be decoded /// - fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData>; + fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { + Ok(Self::CellData::from(cell)) + } /// Decodes the [Cell] that is of a particular field type into a `CellData` of this `TypeOption`'s field type. /// @@ -46,12 +55,6 @@ pub trait CellDataDecoder: TypeOption { /// separated by a comma. /// fn stringify_cell_data(&self, cell_data: ::CellData) -> String; - - /// Decode the cell into f64 - /// Different field type has different way to decode the cell data into f64 - /// If the field type doesn't support to decode the cell data into f64, it will return None - /// - fn numeric_cell(&self, cell: &Cell) -> Option; } pub trait CellDataChangeset: TypeOption { @@ -170,13 +173,13 @@ pub fn insert_checkbox_cell(is_checked: bool, field: &Field) -> Cell { pub fn insert_date_cell( timestamp: i64, - time: Option, + end_timestamp: Option, include_time: Option, field: &Field, ) -> Cell { let cell_data = DateCellChangeset { - date: Some(timestamp), - time, + timestamp: Some(timestamp), + end_timestamp, include_time, ..Default::default() }; @@ -188,9 +191,12 @@ pub fn insert_select_option_cell(option_ids: Vec, field: &Field) -> Cell apply_cell_changeset(BoxAny::new(changeset), None, field, None).unwrap() } -pub fn insert_checklist_cell(insert_options: Vec<(String, bool)>, field: &Field) -> Cell { +pub fn insert_checklist_cell( + insert_options: Vec, + field: &Field, +) -> Cell { let changeset = ChecklistCellChangeset { - insert_options, + insert_tasks: insert_options, ..Default::default() }; apply_cell_changeset(BoxAny::new(changeset), None, field, None).unwrap() @@ -218,11 +224,12 @@ impl<'a> CellBuilder<'a> { for (field_id, cell_str) in cell_by_field_id { if let Some(field) = field_maps.get(&field_id) { let field_type = FieldType::from(field.field_type); + trace!("Field type: {:?}, cell_str: {}", field_type, cell_str); match field_type { - FieldType::RichText => { + FieldType::RichText | FieldType::Translate | FieldType::Summary => { cells.insert(field_id, insert_text_cell(cell_str, field)); }, - FieldType::Number => { + FieldType::Number | FieldType::Time => { if let Ok(num) = cell_str.parse::() { cells.insert(field_id, insert_number_cell(num, field)); } @@ -257,10 +264,14 @@ impl<'a> CellBuilder<'a> { } }, FieldType::Relation => { - cells.insert(field_id, (&RelationCellData::from(cell_str)).into()); + if let Ok(cell_data) = RelationCellData::from_str(&cell_str) { + cells.insert(field_id, cell_data.into()); + } }, - FieldType::Summary => { - cells.insert(field_id, insert_text_cell(cell_str, field)); + FieldType::Media => { + if let Ok(cell_data) = MediaCellData::from_str(&cell_str) { + cells.insert(field_id, cell_data.into()); + } }, } } @@ -274,7 +285,7 @@ impl<'a> CellBuilder<'a> { } pub fn insert_text_cell(&mut self, field_id: &str, data: String) { - match self.field_maps.get(&field_id.to_owned()) { + match self.field_maps.get(field_id) { None => tracing::warn!("Can't find the text field with id: {}", field_id), Some(field) => { self @@ -285,7 +296,7 @@ impl<'a> CellBuilder<'a> { } pub fn insert_url_cell(&mut self, field_id: &str, data: String) { - match self.field_maps.get(&field_id.to_owned()) { + match self.field_maps.get(field_id) { None => tracing::warn!("Can't find the url field with id: {}", field_id), Some(field) => { self @@ -296,7 +307,7 @@ impl<'a> CellBuilder<'a> { } pub fn insert_number_cell(&mut self, field_id: &str, num: i64) { - match self.field_maps.get(&field_id.to_owned()) { + match self.field_maps.get(field_id) { None => tracing::warn!("Can't find the number field with id: {}", field_id), Some(field) => { self @@ -307,7 +318,7 @@ impl<'a> CellBuilder<'a> { } pub fn insert_checkbox_cell(&mut self, field_id: &str, is_checked: bool) { - match self.field_maps.get(&field_id.to_owned()) { + match self.field_maps.get(field_id) { None => tracing::warn!("Can't find the checkbox field with id: {}", field_id), Some(field) => { self @@ -317,26 +328,20 @@ impl<'a> CellBuilder<'a> { } } - pub fn insert_date_cell( - &mut self, - field_id: &str, - timestamp: i64, - time: Option, - include_time: Option, - ) { - match self.field_maps.get(&field_id.to_owned()) { + pub fn insert_date_cell(&mut self, field_id: &str, timestamp: i64, include_time: Option) { + match self.field_maps.get(field_id) { None => tracing::warn!("Can't find the date field with id: {}", field_id), Some(field) => { self.cells.insert( field_id.to_owned(), - insert_date_cell(timestamp, time, include_time, field), + insert_date_cell(timestamp, None, include_time, field), ); }, } } pub fn insert_select_option_cell(&mut self, field_id: &str, option_ids: Vec) { - match self.field_maps.get(&field_id.to_owned()) { + match self.field_maps.get(field_id) { None => tracing::warn!("Can't find the select option field with id: {}", field_id), Some(field) => { self.cells.insert( @@ -346,13 +351,17 @@ impl<'a> CellBuilder<'a> { }, } } - pub fn insert_checklist_cell(&mut self, field_id: &str, options: Vec<(String, bool)>) { - match self.field_maps.get(&field_id.to_owned()) { + pub fn insert_checklist_cell( + &mut self, + field_id: &str, + new_tasks: Vec, + ) { + match self.field_maps.get(field_id) { None => tracing::warn!("Can't find the field with id: {}", field_id), Some(field) => { self .cells - .insert(field_id.to_owned(), insert_checklist_cell(options, field)); + .insert(field_id.to_owned(), insert_checklist_cell(new_tasks, field)); }, } } diff --git a/frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs b/frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs index ccc877059a..09bd13793a 100644 --- a/frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs +++ b/frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs @@ -1,4 +1,5 @@ use bytes::Bytes; +use std::fmt::Display; use flowy_error::{internal_error, FlowyResult}; @@ -64,15 +65,10 @@ impl CellProtobufBlob { // } } -impl ToString for CellProtobufBlob { - fn to_string(&self) -> String { - match String::from_utf8(self.0.to_vec()) { - Ok(s) => s, - Err(e) => { - tracing::error!("DecodedCellData to string failed: {:?}", e); - "".to_string() - }, - } +impl Display for CellProtobufBlob { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = String::from_utf8(self.0.to_vec()).unwrap_or_default(); + write!(f, "{}", s) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs index 1c62e70b93..227b96df4f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs @@ -1,5 +1,5 @@ use crate::entities::*; -use crate::notification::{send_notification, DatabaseNotification}; +use crate::notification::{database_notification_builder, DatabaseNotification}; use crate::services::calculations::Calculation; use crate::services::cell::{apply_cell_changeset, get_cell_protobuf, CellCache}; use crate::services::database::database_observe::*; @@ -7,60 +7,97 @@ use crate::services::database::util::database_view_setting_pb_from_view; use crate::services::database_view::{ DatabaseViewChanged, DatabaseViewEditor, DatabaseViewOperation, DatabaseViews, EditorByViewId, }; +use crate::services::field::checklist_filter::ChecklistCellChangeset; +use crate::services::field::type_option_transform::transform_type_option; use crate::services::field::{ - default_type_option_data_from_type, select_type_option_from_field, transform_type_option, - type_option_data_from_pb, ChecklistCellChangeset, RelationTypeOption, SelectOptionCellChangeset, - StringCellData, TimestampCellData, TimestampCellDataWrapper, TypeOptionCellDataHandler, - TypeOptionCellExt, + default_type_option_data_from_type, select_type_option_from_field, type_option_data_from_pb, + SelectOptionCellChangeset, StringCellData, TypeOptionCellDataHandler, TypeOptionCellExt, }; use crate::services::field_settings::{default_field_settings_by_layout_map, FieldSettings}; use crate::services::filter::{Filter, FilterChangeset}; -use crate::services::group::{default_group_setting, GroupChangeset, GroupSetting, RowChangeset}; +use crate::services::group::{default_group_setting, GroupChangeset, GroupSetting}; use crate::services::share::csv::{CSVExport, CSVFormat}; use crate::services::sort::Sort; use crate::utils::cache::AnyTypeCache; -use collab_database::database::MutexDatabase; +use crate::DatabaseUser; +use arc_swap::ArcSwapOption; +use async_trait::async_trait; +use collab::core::collab_plugin::CollabPluginType; +use collab::lock::RwLock; +use collab_database::database::Database; +use collab_database::entity::DatabaseView; +use collab_database::fields::media_type_option::MediaCellData; +use collab_database::fields::relation_type_option::RelationTypeOption; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{Cell, Cells, Row, RowCell, RowDetail, RowId}; +use collab_database::rows::{Cell, Cells, DatabaseRow, Row, RowCell, RowDetail, RowId, RowUpdate}; +use collab_database::template::timestamp_parse::TimestampCellData; use collab_database::views::{ - DatabaseLayout, DatabaseView, FilterMap, LayoutSetting, OrderObjectPosition, + DatabaseLayout, FilterMap, LayoutSetting, OrderObjectPosition, RowOrder, }; +use collab_entity::CollabType; +use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabBuilderConfig}; use flowy_error::{internal_error, ErrorCode, FlowyError, FlowyResult}; use flowy_notification::DebounceNotificationSender; +use futures::future::join_all; +use futures::{pin_mut, StreamExt}; use lib_infra::box_any::BoxAny; -use lib_infra::future::{to_fut, Fut, FutureResult}; use lib_infra::priority_task::TaskDispatcher; use lib_infra::util::timestamp; use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::{broadcast, RwLock}; -use tracing::{event, instrument, warn}; +use std::str::FromStr; +use std::sync::{Arc, Weak}; +use std::time::Duration; +use tokio::select; +use tokio::sync::oneshot::Sender; +use tokio::sync::RwLock as TokioRwLock; +use tokio::sync::{broadcast, oneshot}; +use tokio::task::yield_now; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, event, info, instrument, trace, warn}; +use uuid::Uuid; + +type OpenDatabaseResult = oneshot::Sender>; -#[derive(Clone)] pub struct DatabaseEditor { - database: Arc, + database_id: Uuid, + pub(crate) database: Arc>, pub cell_cache: CellCache, - database_views: Arc, + pub(crate) database_views: Arc, #[allow(dead_code)] - /// Used to send notification to the frontend. - notification_sender: Arc, + user: Arc, + collab_builder: Arc, + is_loading_rows: ArcSwapOption>, + opening_ret_txs: Arc>>, + #[allow(dead_code)] + database_cancellation: Arc>>, + un_finalized_rows_cancellation: Arc>, + finalized_rows: Arc>>>, } impl DatabaseEditor { pub async fn new( - database: Arc, - task_scheduler: Arc>, - ) -> FlowyResult { + user: Arc, + database: Arc>, + task_scheduler: Arc>, + collab_builder: Arc, + ) -> FlowyResult> { + let finalized_rows: moka::future::Cache>> = + moka::future::Cache::builder() + .max_capacity(50) + .async_eviction_listener(|key, value, _| { + Box::pin(async move { + database_row_evict_listener(key, value).await; + }) + }) + .build(); let notification_sender = Arc::new(DebounceNotificationSender::new(200)); let cell_cache = AnyTypeCache::::new(); - let database_id = database.lock().get_database_id(); - + let database_id = database.read().await.get_database_id(); + let database_cancellation = Arc::new(RwLock::new(None)); // Receive database sync state and send to frontend via the notification observe_sync_state(&database_id, &database).await; - // observe_view_change(&database_id, &database).await; // observe_field_change(&database_id, &database).await; observe_rows_change(&database_id, &database, ¬ification_sender).await; - // observe_block_event(&database_id, &database).await; // Used to cache the view of the database for fast access. let editor_by_view_id = Arc::new(RwLock::new(EditorByViewId::default())); @@ -69,6 +106,7 @@ impl DatabaseEditor { task_scheduler: task_scheduler.clone(), cell_cache: cell_cache.clone(), editor_by_view_id: editor_by_view_id.clone(), + database_cancellation: database_cancellation.clone(), }); let database_views = Arc::new( @@ -81,19 +119,54 @@ impl DatabaseEditor { .await?, ); - Ok(Self { + let database_id = Uuid::from_str(&database_id)?; + let collab_object = collab_builder.collab_object( + &user.workspace_id()?, + user.user_id()?, + &database_id, + CollabType::Database, + )?; + + let database = collab_builder.finalize( + collab_object, + CollabBuilderConfig::default(), + database.clone(), + )?; + let this = Arc::new(Self { + database_id, + user, database, cell_cache, database_views, - notification_sender, - }) + collab_builder, + is_loading_rows: Default::default(), + opening_ret_txs: Arc::new(Default::default()), + database_cancellation, + un_finalized_rows_cancellation: Arc::new(Default::default()), + finalized_rows: Arc::new(finalized_rows), + }); + observe_block_event(&database_id, &this).await; + observe_view_change(&database_id, &this).await; + Ok(this) } pub async fn close_view(&self, view_id: &str) { - self.database_views.close_view(view_id).await; + self.database_views.remove_view(view_id).await; } - pub async fn num_views(&self) -> usize { + pub async fn get_row_ids(&self) -> Vec { + self + .database + .read() + .await + .get_all_row_orders() + .await + .into_iter() + .map(|entry| entry.id) + .collect() + } + + pub async fn num_of_opening_views(&self) -> usize { self.database_views.num_editors().await } @@ -105,7 +178,11 @@ impl DatabaseEditor { } pub async fn get_layout_type(&self, view_id: &str) -> DatabaseLayout { - let view = self.database_views.get_view_editor(view_id).await.ok(); + let view = self + .database_views + .get_or_init_view_editor(view_id) + .await + .ok(); if let Some(editor) = view { editor.v_get_layout_type().await } else { @@ -118,7 +195,7 @@ impl DatabaseEditor { view_id: &str, layout_type: DatabaseLayout, ) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(view_id).await?; + let view_editor = self.database_views.get_or_init_view_editor(view_id).await?; view_editor.v_update_layout_type(layout_type).await?; Ok(()) @@ -128,38 +205,57 @@ impl DatabaseEditor { &self, view_id: &str, ) -> FlowyResult> { - let view_editor = self.database_views.get_view_editor(view_id).await?; + let view_editor = self.database_views.get_or_init_view_editor(view_id).await?; Ok(view_editor.notifier.subscribe()) } - pub fn get_field(&self, field_id: &str) -> Option { - self.database.lock().fields.get_field(field_id) + pub async fn get_field(&self, field_id: &str) -> Option { + self.database.read().await.get_field(field_id) } - pub async fn set_group_by_field(&self, view_id: &str, field_id: &str) -> FlowyResult<()> { + pub async fn set_group_by_field( + &self, + view_id: &str, + field_id: &str, + data: Vec, + ) -> FlowyResult<()> { + let old_group_settings: Vec; + let mut setting_content = "".to_string(); { - let database = self.database.lock(); - let field = database.fields.get_field(field_id); + let mut database = self.database.write().await; + let field = database.get_field(field_id); + old_group_settings = database.get_all_group_setting(view_id); if let Some(field) = field { - let group_setting = default_group_setting(&field); - database.views.update_database_view(view_id, |view| { + let field_type = FieldType::from(field.field_type); + setting_content = group_config_pb_to_json_str(data, &field_type)?; + let mut group_setting = default_group_setting(&field); + group_setting.content.clone_from(&setting_content); + database.update_database_view(view_id, |view| { view.set_groups(vec![group_setting.into()]); }); } } - let view_editor = self.database_views.get_view_editor(view_id).await?; - view_editor.v_initialize_new_group(field_id).await?; + let old_group_setting = old_group_settings.iter().find(|g| g.field_id == field_id); + let has_same_content = + old_group_setting.is_some() && old_group_setting.unwrap().content == setting_content; + + let view_editor = self.database_views.get_or_init_view_editor(view_id).await?; + if !view_editor.is_grouping_field(field_id).await || !has_same_content { + view_editor.v_initialize_new_group(field_id).await?; + } Ok(()) } pub async fn delete_group(&self, params: DeleteGroupParams) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(¶ms.view_id).await?; + let view_editor = self + .database_views + .get_or_init_view_editor(¶ms.view_id) + .await?; let changes = view_editor.v_delete_group(¶ms.group_id).await?; - if !changes.is_empty() { for view in self.database_views.editors().await { - send_notification(&view.view_id, DatabaseNotification::DidUpdateRow) + database_notification_builder(&view.view_id, DatabaseNotification::DidUpdateRow) .payload(changes.clone()) .send(); } @@ -173,7 +269,7 @@ impl DatabaseEditor { /// will be the reference view ids and the inline view id. Otherwise, the return value will /// be the view id. pub async fn delete_database_view(&self, view_id: &str) -> FlowyResult> { - Ok(self.database.lock().delete_view(view_id)) + Ok(self.database.write().await.delete_view(view_id)) } pub async fn update_group( @@ -181,7 +277,7 @@ impl DatabaseEditor { view_id: &str, changesets: Vec, ) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(view_id).await?; + let view_editor = self.database_views.get_or_init_view_editor(view_id).await?; view_editor.v_update_group(changesets).await?; Ok(()) } @@ -191,31 +287,40 @@ impl DatabaseEditor { view_id: &str, changeset: FilterChangeset, ) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(view_id).await?; + let view_editor = self.database_views.get_or_init_view_editor(view_id).await?; view_editor.v_modify_filters(changeset).await?; Ok(()) } pub async fn create_or_update_sort(&self, params: UpdateSortPayloadPB) -> FlowyResult { - let view_editor = self.database_views.get_view_editor(¶ms.view_id).await?; + let view_editor = self + .database_views + .get_or_init_view_editor(¶ms.view_id) + .await?; let sort = view_editor.v_create_or_update_sort(params).await?; Ok(sort) } pub async fn reorder_sort(&self, params: ReorderSortPayloadPB) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(¶ms.view_id).await?; + let view_editor = self + .database_views + .get_or_init_view_editor(¶ms.view_id) + .await?; view_editor.v_reorder_sort(params).await?; Ok(()) } pub async fn delete_sort(&self, params: DeleteSortPayloadPB) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(¶ms.view_id).await?; + let view_editor = self + .database_views + .get_or_init_view_editor(¶ms.view_id) + .await?; view_editor.v_delete_sort(params).await?; Ok(()) } pub async fn get_all_calculations(&self, view_id: &str) -> RepeatedCalculationsPB { - if let Ok(view_editor) = self.database_views.get_view_editor(view_id).await { + if let Ok(view_editor) = self.database_views.get_or_init_view_editor(view_id).await { view_editor.v_get_all_calculations().await.into() } else { RepeatedCalculationsPB { items: vec![] } @@ -223,19 +328,25 @@ impl DatabaseEditor { } pub async fn update_calculation(&self, update: UpdateCalculationChangesetPB) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(&update.view_id).await?; + let view_editor = self + .database_views + .get_or_init_view_editor(&update.view_id) + .await?; view_editor.v_update_calculations(update).await?; Ok(()) } pub async fn remove_calculation(&self, remove: RemoveCalculationChangesetPB) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(&remove.view_id).await?; + let view_editor = self + .database_views + .get_or_init_view_editor(&remove.view_id) + .await?; view_editor.v_remove_calculation(remove).await?; Ok(()) } pub async fn get_all_filters(&self, view_id: &str) -> RepeatedFilterPB { - if let Ok(view_editor) = self.database_views.get_view_editor(view_id).await { + if let Ok(view_editor) = self.database_views.get_or_init_view_editor(view_id).await { let filters = view_editor.v_get_all_filters().await; RepeatedFilterPB::from(&filters) } else { @@ -244,14 +355,14 @@ impl DatabaseEditor { } pub async fn get_filter(&self, view_id: &str, filter_id: &str) -> Option { - if let Ok(view_editor) = self.database_views.get_view_editor(view_id).await { + if let Ok(view_editor) = self.database_views.get_or_init_view_editor(view_id).await { Some(view_editor.v_get_filter(filter_id).await?) } else { None } } pub async fn get_all_sorts(&self, view_id: &str) -> RepeatedSortPB { - if let Ok(view_editor) = self.database_views.get_view_editor(view_id).await { + if let Ok(view_editor) = self.database_views.get_or_init_view_editor(view_id).await { view_editor.v_get_all_sorts().await.into() } else { RepeatedSortPB { items: vec![] } @@ -259,7 +370,7 @@ impl DatabaseEditor { } pub async fn delete_all_sorts(&self, view_id: &str) { - if let Ok(view_editor) = self.database_views.get_view_editor(view_id).await { + if let Ok(view_editor) = self.database_views.get_or_init_view_editor(view_id).await { let _ = view_editor.v_delete_all_sorts().await; } } @@ -267,11 +378,10 @@ impl DatabaseEditor { /// Returns a list of fields of the view. /// If `field_ids` is not provided, all the fields will be returned in the order of the field that /// defined in the view. Otherwise, the fields will be returned in the order of the `field_ids`. - pub fn get_fields(&self, view_id: &str, field_ids: Option>) -> Vec { - let database = self.database.lock(); + pub async fn get_fields(&self, view_id: &str, field_ids: Option>) -> Vec { + let database = self.database.read().await; let field_ids = field_ids.unwrap_or_else(|| { database - .fields .get_all_field_orders() .into_iter() .map(|field| field.id) @@ -280,23 +390,22 @@ impl DatabaseEditor { database.get_fields_in_view(view_id, Some(field_ids)) } - pub async fn update_field(&self, params: FieldChangesetParams) -> FlowyResult<()> { - self - .database - .lock() - .fields - .update_field(¶ms.field_id, |update| { - update.set_name_if_not_none(params.name); - }); - notify_did_update_database_field(&self.database, ¶ms.field_id)?; + pub async fn update_field(&self, params: FieldChangesetPB) -> FlowyResult<()> { + let mut database = self.database.write().await; + database.update_field(¶ms.field_id, |update| { + update + .set_name_if_not_none(params.name) + .set_icon_if_not_none(params.icon); + }); + notify_did_update_database_field(&database, ¶ms.field_id)?; Ok(()) } pub async fn delete_field(&self, field_id: &str) -> FlowyResult<()> { let is_primary = self .database - .lock() - .fields + .write() + .await .get_field(field_id) .map(|field| field.is_primary) .unwrap_or(false); @@ -309,7 +418,7 @@ impl DatabaseEditor { } let database_id = { - let database = self.database.lock(); + let mut database = self.database.write().await; database.delete_field(field_id); database.get_database_id() }; @@ -327,6 +436,7 @@ impl DatabaseEditor { pub async fn clear_field(&self, view_id: &str, field_id: &str) -> FlowyResult<()> { let field_type: FieldType = self .get_field(field_id) + .await .map(|field| field.field_type.into()) .unwrap_or_default(); @@ -357,64 +467,77 @@ impl DatabaseEditor { old_field: Field, ) -> FlowyResult<()> { let view_editors = self.database_views.editors().await; - update_field_type_option_fn(&self.database, &view_editors, type_option_data, old_field).await?; + { + let mut database = self.database.write().await; + update_field_type_option_fn(&mut database, type_option_data, &old_field).await?; + drop(database); + } + for view_editor in view_editors { + view_editor + .v_did_update_field_type_option(&old_field) + .await?; + } Ok(()) } pub async fn switch_to_field_type( &self, + view_id: &str, field_id: &str, new_field_type: FieldType, + field_name: Option, ) -> FlowyResult<()> { - let field = self.database.lock().fields.get_field(field_id); - match field { - None => {}, - Some(field) => { - if field.is_primary { - return Err(FlowyError::new( - ErrorCode::Internal, - "Can not update primary field's field type", - )); - } + let mut database = self.database.write().await; + if let Some(field) = database.get_field(field_id) { + if field.is_primary { + return Err(FlowyError::new( + ErrorCode::Internal, + "Can not update primary field's field type", + )); + } - let old_field_type = FieldType::from(field.field_type); - let old_type_option_data = field.get_any_type_option(old_field_type); - let new_type_option_data = field - .get_any_type_option(new_field_type) - .unwrap_or_else(|| default_type_option_data_from_type(new_field_type)); + let old_field_type = FieldType::from(field.field_type); + let old_type_option_data = field.get_any_type_option(old_field_type); + let new_type_option_data = field + .get_any_type_option(new_field_type) + .unwrap_or_else(|| default_type_option_data_from_type(new_field_type)); - let transformed_type_option = transform_type_option( - old_field_type, - new_field_type, - old_type_option_data, - new_type_option_data, - ); - self - .database - .lock() - .fields - .update_field(field_id, |update| { - update - .set_field_type(new_field_type.into()) - .set_type_option(new_field_type.into(), Some(transformed_type_option)); - }); + let transformed_type_option = transform_type_option( + view_id, + field_id, + old_field_type, + new_field_type, + old_type_option_data, + new_type_option_data, + &mut database, + ) + .await; - for view in self.database_views.editors().await { - view.v_did_update_field_type(field_id, new_field_type).await; - } - }, + database.update_field(field_id, |update| { + update + .set_field_type(new_field_type.into()) + .set_name_if_not_none(field_name) + .set_type_option(new_field_type.into(), Some(transformed_type_option)); + }); + + drop(database); + + for view in self.database_views.editors().await { + view.v_did_update_field_type(field_id, new_field_type).await; + } + + let database = self.database.read().await; + + notify_did_update_database_field(&database, field_id)?; } - notify_did_update_database_field(&self.database, field_id)?; Ok(()) } pub async fn duplicate_field(&self, view_id: &str, field_id: &str) -> FlowyResult<()> { - let is_primary = self - .database - .lock() - .fields + let mut database = self.database.write().await; + let is_primary = database .get_field(field_id) .map(|field| field.is_primary) .unwrap_or(false); @@ -426,10 +549,10 @@ impl DatabaseEditor { )); } - let value = self - .database - .lock() - .duplicate_field(view_id, field_id, |field| format!("{} (copy)", field.name)); + let value = + database.duplicate_field(view_id, field_id, |field| format!("{} (copy)", field.name)); + drop(database); + if let Some((index, duplicated_field)) = value { let _ = self .notify_did_insert_database_field(duplicated_field.clone(), index) @@ -449,89 +572,129 @@ impl DatabaseEditor { } pub async fn duplicate_row(&self, view_id: &str, row_id: &RowId) -> FlowyResult<()> { - let (row_detail, index) = { - let database = self.database.lock(); + let mut database = self.database.write().await; + let params = database + .duplicate_row(row_id) + .await + .ok_or_else(|| FlowyError::internal().with_context("error while copying row"))?; + let (index, row_order) = database.create_row_in_view(view_id, params).await?; - let params = database - .duplicate_row(row_id) - .ok_or_else(|| FlowyError::internal().with_context("error while copying row"))?; - - let (index, row_order) = database - .create_row_in_view(view_id, params) - .ok_or_else(|| { - FlowyError::internal().with_context("error while inserting duplicated row") - })?; - - tracing::trace!("duplicated row: {:?} at {}", row_order, index); - let row_detail = database.get_row_detail(&row_order.id); - - (row_detail, index) - }; - - if let Some(row_detail) = row_detail { - for view in self.database_views.editors().await { - view.v_did_create_row(&row_detail, index).await; - } + let row_meta = database.get_row_meta(row_id).await; + if let Some(row_meta) = row_meta { + database + .update_row_meta(&row_order.id, |meta_update| { + meta_update + .insert_cover_if_not_none(row_meta.cover) + .insert_icon_if_not_none(row_meta.icon_url) + .update_is_document_empty_if_not_none(Some(row_meta.is_document_empty)) + .update_attachment_count_if_not_none(Some(row_meta.attachment_count)); + }) + .await; } + drop(database); + + trace!( + "duplicate row: {:?} at index:{}, new row:{:?}", + row_id, + index, + row_order + ); + Ok(()) } + #[tracing::instrument(level = "trace", skip_all, err)] pub async fn move_row( &self, view_id: &str, from_row_id: RowId, to_row_id: RowId, ) -> FlowyResult<()> { - let database = self.database.lock(); - - let row_detail = database.get_row_detail(&from_row_id).ok_or_else(|| { - let msg = format!("Cannot find row {}", from_row_id); - FlowyError::internal().with_context(msg) - })?; - - database.views.update_database_view(view_id, |view| { + let mut database = self.database.write().await; + database.update_database_view(view_id, |view| { view.move_row_order(&from_row_id, &to_row_id); }); - let new_index = database.index_of_row(view_id, &from_row_id); - drop(database); + Ok(()) + } - if let Some(index) = new_index { - let delete_row_id = from_row_id.into_inner(); - let insert_row = InsertedRowPB::new(RowMetaPB::from(row_detail)).with_index(index as i32); - let changes = RowsChangePB::from_move(vec![delete_row_id], vec![insert_row]); + #[tracing::instrument(level = "trace", skip_all, err)] + pub async fn move_group_row( + &self, + view_id: &str, + from_group: &str, + to_group: &str, + from_row: RowId, + to_row: Option, + ) -> FlowyResult<()> { + let row = self.get_row(view_id, &from_row).await.ok_or_else(|| { + let msg = format!("Can not find the row:{}", from_row); + FlowyError::internal().with_context(msg) + })?; + trace!( + "Move row:{} from group:{} to group:{}", + from_row, + from_group, + to_group + ); - send_notification(view_id, DatabaseNotification::DidUpdateRow) - .payload(changes) - .send(); + // when moving row between groups, the cells of the row should be updated + // if the updated cells is not empty, we need to update cells for given row + let updated_cells = self + .database_views + .get_or_init_view_editor(view_id) + .await? + .v_move_group_row(&row, to_group, to_row.clone()) + .await; + if !updated_cells.is_empty() { + self + .update_row(row.id, |row| { + row + .set_last_modified(timestamp()) + .set_cells(Cells::from(updated_cells)); + }) + .await?; + } + + let to_row = if to_row.is_some() { + to_row + } else { + self + .database + .read() + .await + .get_row_orders_for_view(view_id) + .last() + .map(|row| row.id.clone()) + }; + + if let Some(to_row_id) = to_row.clone() { + self.move_row(view_id, from_row.clone(), to_row_id).await?; } Ok(()) } pub async fn create_row(&self, params: CreateRowPayloadPB) -> FlowyResult> { - let view_editor = self.database_views.get_view_editor(¶ms.view_id).await?; + let view_editor = self + .database_views + .get_or_init_view_editor(¶ms.view_id) + .await?; - let CreateRowParams { - collab_params, - open_after_create: _, - } = view_editor.v_will_create_row(params).await?; + let params = view_editor.v_will_create_row(params).await?; - let result = self - .database - .lock() - .create_row_in_view(&view_editor.view_id, collab_params); + let mut database = self.database.write().await; + let (index, row_order) = database + .create_row_in_view(&view_editor.view_id, params) + .await?; + let row_detail = database.get_row_detail(&row_order.id).await; + drop(database); - if let Some((index, row_order)) = result { - tracing::trace!("created row: {:?} at {}", row_order, index); - let row_detail = self.database.lock().get_row_detail(&row_order.id); - if let Some(row_detail) = row_detail { - for view in self.database_views.editors().await { - view.v_did_create_row(&row_detail, index).await; - } - return Ok(Some(row_detail)); - } + trace!("[Database]: did create row: {} at {}", row_order.id, index); + if let Some(row_detail) = row_detail { + trace!("created row: {:?} at {}", row_detail, index); + return Ok(Some(row_detail)); } Ok(None) @@ -551,7 +714,7 @@ impl DatabaseEditor { .and_then(|data| type_option_data_from_pb(data, ¶ms.field_type).ok()) .unwrap_or(default_type_option_data_from_type(params.field_type)); - let (index, field) = self.database.lock().create_field_with_mut( + let (index, field) = self.database.write().await.create_field_with_mut( ¶ms.view_id, name, params.field_type.into(), @@ -573,21 +736,16 @@ impl DatabaseEditor { pub async fn move_field(&self, params: MoveFieldParams) -> FlowyResult<()> { let (field, new_index) = { - let database = self.database.lock(); + let mut database = self.database.write().await; - let field = database - .fields - .get_field(¶ms.from_field_id) - .ok_or_else(|| { - let msg = format!("Field with id: {} not found", ¶ms.from_field_id); - FlowyError::internal().with_context(msg) - })?; + let field = database.get_field(¶ms.from_field_id).ok_or_else(|| { + let msg = format!("Field with id: {} not found", ¶ms.from_field_id); + FlowyError::internal().with_context(msg) + })?; - database - .views - .update_database_view(¶ms.view_id, |view_update| { - view_update.move_field_order(¶ms.from_field_id, ¶ms.to_field_id); - }); + database.update_database_view(¶ms.view_id, |view_update| { + view_update.move_field_order(¶ms.from_field_id, ¶ms.to_field_id); + }); let new_index = database.index_of_field(¶ms.view_id, ¶ms.from_field_id); @@ -607,7 +765,7 @@ impl DatabaseEditor { updated_fields: vec![], }; - send_notification(¶ms.view_id, DatabaseNotification::DidUpdateFields) + database_notification_builder(¶ms.view_id, DatabaseNotification::DidUpdateFields) .payload(notified_changeset) .send(); } @@ -615,106 +773,145 @@ impl DatabaseEditor { Ok(()) } - pub async fn get_rows(&self, view_id: &str) -> FlowyResult>> { - let view_editor = self.database_views.get_view_editor(view_id).await?; - Ok(view_editor.v_get_rows().await) + pub async fn get_all_rows(&self, view_id: &str) -> FlowyResult>> { + let view_editor = self.database_views.get_or_init_view_editor(view_id).await?; + Ok(view_editor.v_get_all_rows().await) } - pub fn get_row(&self, view_id: &str, row_id: &RowId) -> Option { - if self.database.lock().views.is_row_exist(view_id, row_id) { - Some(self.database.lock().get_row(row_id)) + pub async fn get_row(&self, view_id: &str, row_id: &RowId) -> Option { + let database = self.database.read().await; + if database.contains_row(view_id, row_id) { + Some(database.get_row(row_id).await) } else { None } } - pub fn get_row_meta(&self, view_id: &str, row_id: &RowId) -> Option { - if self.database.lock().views.is_row_exist(view_id, row_id) { - let row_meta = self.database.lock().get_row_meta(row_id)?; - let row_document_id = self.database.lock().get_row_document_id(row_id)?; + pub async fn init_database_row(&self, row_id: &RowId) -> FlowyResult>> { + if let Some(is_loading) = self.is_loading_rows.load_full() { + let mut rx = is_loading.subscribe(); + trace!("[Database]: wait for loading rows when trying to init database row"); + let _ = tokio::time::timeout(Duration::from_secs(10), rx.recv()).await; + } + + debug!("[Database]: Init database row: {}", row_id); + let database_row = self + .database + .read() + .await + .get_or_init_database_row(row_id) + .await + .ok_or_else(|| { + FlowyError::record_not_found() + .with_context(format!("The row:{} in database not found", row_id)) + })?; + + let is_finalized = self.finalized_rows.get(row_id.as_str()).await.is_some(); + if !is_finalized { + trace!("[Database]: finalize database row: {}", row_id); + let row_id = Uuid::from_str(row_id.as_str())?; + let collab_object = self.collab_builder.collab_object( + &self.user.workspace_id()?, + self.user.user_id()?, + &row_id, + CollabType::DatabaseRow, + )?; + + if let Err(err) = self.collab_builder.finalize( + collab_object, + CollabBuilderConfig::default(), + database_row.clone(), + ) { + error!("Failed to init database row: {}", err); + } + self + .finalized_rows + .insert(row_id.to_string(), Arc::downgrade(&database_row)) + .await; + } + + Ok(database_row) + } + + pub async fn get_row_meta(&self, view_id: &str, row_id: &RowId) -> Option { + let database = self.database.read().await; + if database.contains_row(view_id, row_id) { + let row_meta = database.get_row_meta(row_id).await?; + let row_document_id = database.get_row_document_id(row_id)?; Some(RowMetaPB { id: row_id.clone().into_inner(), - document_id: row_document_id, + document_id: Some(row_document_id), icon: row_meta.icon_url, - cover: row_meta.cover_url, - is_document_empty: row_meta.is_document_empty, + is_document_empty: Some(row_meta.is_document_empty), + attachment_count: Some(row_meta.attachment_count), + cover: row_meta.cover.map(|cover| cover.into()), }) } else { - warn!("the row:{} is exist in view:{}", row_id.as_str(), view_id); + warn!( + "the row:{} is not exist in view:{}", + row_id.as_str(), + view_id + ); None } } - pub fn get_row_detail(&self, view_id: &str, row_id: &RowId) -> Option { - if self.database.lock().views.is_row_exist(view_id, row_id) { - self.database.lock().get_row_detail(row_id) - } else { - warn!("the row:{} is exist in view:{}", row_id.as_str(), view_id); - None - } - } - - pub async fn delete_row(&self, row_id: &RowId) { - let row = self.database.lock().remove_row(row_id); - if let Some(row) = row { - tracing::trace!("Did delete row:{:?}", row); - for view in self.database_views.editors().await { - view.v_did_delete_row(&row).await; - } - } + pub async fn delete_rows(&self, row_ids: &[RowId]) { + let _ = self.database.write().await.remove_rows(row_ids).await; } #[tracing::instrument(level = "trace", skip_all)] pub async fn update_row_meta(&self, row_id: &RowId, changeset: UpdateRowMetaParams) { - self.database.lock().update_row_meta(row_id, |meta_update| { - meta_update - .insert_cover_if_not_none(changeset.cover_url) - .insert_icon_if_not_none(changeset.icon_url) - .update_is_document_empty_if_not_none(changeset.is_document_empty); - }); + let mut database = self.database.write().await; + database + .update_row_meta(row_id, |meta_update| { + meta_update + .insert_cover_if_not_none(changeset.cover) + .insert_icon_if_not_none(changeset.icon_url) + .update_is_document_empty_if_not_none(changeset.is_document_empty) + .update_attachment_count_if_not_none(changeset.attachment_count); + }) + .await; // Use the temporary row meta to get rid of the lock that not implement the `Send` or 'Sync' trait. - let row_detail = self.database.lock().get_row_detail(row_id); + let row_detail = database.get_row_detail(row_id).await; + drop(database); + if let Some(row_detail) = row_detail { for view in self.database_views.editors().await { view.v_did_update_row_meta(row_id, &row_detail).await; } // Notifies the client that the row meta has been updated. - send_notification(row_id.as_str(), DatabaseNotification::DidUpdateRowMeta) - .payload(RowMetaPB::from(&row_detail)) + database_notification_builder(row_id.as_str(), DatabaseNotification::DidUpdateRowMeta) + .payload(RowMetaPB::from(row_detail)) .send(); - - // Update the last modified time of the row - self - .update_last_modified_time(row_detail.clone(), &changeset.view_id) - .await; } } pub async fn get_cell(&self, field_id: &str, row_id: &RowId) -> Option { - let database = self.database.lock(); - let field = database.fields.get_field(field_id)?; + let database = self.database.read().await; + let field = database.get_field(field_id)?; let field_type = FieldType::from(field.field_type); // If the cell data is referenced, return the reference data. Otherwise, return an empty cell. match field_type { FieldType::LastEditedTime | FieldType::CreatedTime => { - let row = database.get_row(row_id); - let wrapped_cell_data = if field_type.is_created_time() { - TimestampCellDataWrapper::from((field_type, TimestampCellData::new(row.created_at))) + let row = database.get_row(row_id).await; + let cell_data = if field_type.is_created_time() { + TimestampCellData::new(row.created_at) } else { - TimestampCellDataWrapper::from((field_type, TimestampCellData::new(row.modified_at))) + TimestampCellData::new(row.modified_at) }; - Some(Cell::from(wrapped_cell_data)) + Some(cell_data.to_cell(field.field_type)) }, - _ => database.get_cell(field_id, row_id).cell, + _ => database.get_cell(field_id, row_id).await.cell, } } pub async fn get_cell_pb(&self, field_id: &str, row_id: &RowId) -> Option { let (field, cell) = { let cell = self.get_cell(field_id, row_id).await?; - let field = self.database.lock().fields.get_field(field_id)?; + let field = self.database.read().await.get_field(field_id)?; (field, cell) }; @@ -729,26 +926,34 @@ impl DatabaseEditor { } pub async fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Vec { - let database = self.database.lock(); - if let Some(field) = database.fields.get_field(field_id) { + let database = self.database.read().await; + if let Some(field) = database.get_field(field_id) { let field_type = FieldType::from(field.field_type); match field_type { - FieldType::LastEditedTime | FieldType::CreatedTime => database - .get_rows_for_view(view_id) - .into_iter() - .map(|row| { - let data = if field_type.is_created_time() { - TimestampCellData::new(row.created_at) - } else { - TimestampCellData::new(row.modified_at) - }; - RowCell { - row_id: row.id, - cell: Some(Cell::from(data)), - } - }) - .collect(), - _ => database.get_cells_for_field(view_id, field_id), + FieldType::LastEditedTime | FieldType::CreatedTime => { + database + .get_rows_for_view(view_id, 10, None) + .await + .filter_map(|result| async { + match result { + Ok(row) => { + let data = if field_type.is_created_time() { + TimestampCellData::new(row.created_at) + } else { + TimestampCellData::new(row.modified_at) + }; + Some(RowCell { + row_id: row.id, + cell: Some(data.to_cell(field.field_type)), + }) + }, + Err(_) => None, + } + }) + .collect() + .await + }, + _ => database.get_cells_for_field(view_id, field_id).await, } } else { vec![] @@ -764,15 +969,15 @@ impl DatabaseEditor { cell_changeset: BoxAny, ) -> FlowyResult<()> { let (field, cell) = { - let database = self.database.lock(); - let field = match database.fields.get_field(field_id) { + let database = self.database.read().await; + let field = match database.get_field(field_id) { Some(field) => Ok(field), None => { let msg = format!("Field with id:{} not found", &field_id); Err(FlowyError::internal().with_context(msg)) }, }?; - (field, database.get_cell(field_id, row_id).cell) + (field, database.get_cell(field_id, row_id).await.cell) }; let new_cell = @@ -780,24 +985,9 @@ impl DatabaseEditor { self.update_cell(view_id, row_id, field_id, new_cell).await } - async fn update_last_modified_time(&self, row_detail: RowDetail, view_id: &str) { - self - .database - .lock() - .update_row(&row_detail.row.id, |row_update| { - row_update.set_last_modified(timestamp()); - }); - - let editor = self.database_views.get_view_editor(view_id).await; - if let Ok(editor) = editor { - editor - .v_did_update_row(&Some(row_detail.clone()), &row_detail, None) - .await; - } - } - /// Update a cell in the database. /// This will notify all views that the cell has been updated. + #[instrument(level = "trace", skip_all)] pub async fn update_cell( &self, view_id: &str, @@ -806,12 +996,17 @@ impl DatabaseEditor { new_cell: Cell, ) -> FlowyResult<()> { // Get the old row before updating the cell. It would be better to get the old cell - let old_row = { self.get_row_detail(view_id, row_id) }; - self.database.lock().update_row(row_id, |row_update| { - row_update.update_cells(|cell_update| { - cell_update.insert(field_id, new_cell); - }); - }); + let old_row = self.get_row(view_id, row_id).await; + trace!("[Database Row]: update cell: {:?}", new_cell); + self + .update_row(row_id.clone(), |row_update| { + row_update + .set_last_modified(timestamp()) + .update_cells(|cell_update| { + cell_update.insert(field_id, new_cell); + }); + }) + .await?; self .did_update_row(view_id, row_id, field_id, old_row) @@ -820,15 +1015,31 @@ impl DatabaseEditor { Ok(()) } + pub async fn update_row(&self, row_id: RowId, modify: F) -> FlowyResult<()> + where + F: FnOnce(RowUpdate), + { + if self.finalized_rows.get(row_id.as_str()).await.is_none() { + info!( + "[Database Row]: row:{} is not finalized when editing, init it", + row_id + ); + self.init_database_row(&row_id).await?; + } + self.database.write().await.update_row(row_id, modify).await; + Ok(()) + } + pub async fn clear_cell(&self, view_id: &str, row_id: RowId, field_id: &str) -> FlowyResult<()> { // Get the old row before updating the cell. It would be better to get the old cell - let old_row = { self.get_row_detail(view_id, &row_id) }; - - self.database.lock().update_row(&row_id, |row_update| { - row_update.update_cells(|cell_update| { - cell_update.clear(field_id); - }); - }); + let old_row = self.get_row(view_id, &row_id).await; + self + .update_row(row_id.clone(), |row_update| { + row_update.update_cells(|cell_update| { + cell_update.clear(field_id); + }); + }) + .await?; self .did_update_row(view_id, &row_id, field_id, old_row) @@ -842,26 +1053,116 @@ impl DatabaseEditor { view_id: &str, row_id: &RowId, field_id: &str, - old_row: Option, + old_row: Option, ) { - let option_row = self.get_row_detail(view_id, row_id); - if let Some(new_row_detail) = option_row { + let option_row = self.get_row(view_id, row_id).await; + let field_type = self + .database + .read() + .await + .get_field(field_id) + .map(|field| field.field_type); + + if let Some(row) = option_row { for view in self.database_views.editors().await { view - .v_did_update_row(&old_row, &new_row_detail, Some(field_id.to_owned())) + .v_did_update_row(&old_row, &row, Some(field_id.to_owned())) + .await; + + let field_id = field_id.to_string(); + tokio::spawn(async move { + view.v_update_calculate(&field_id).await; + }); + } + + if let Some(field_type) = field_type { + if FieldType::from(field_type) == FieldType::Media { + self + .did_update_attachments(view_id, row_id, field_id, old_row.clone()) + .await; + } + } + } + } + + async fn did_update_attachments( + &self, + view_id: &str, + row_id: &RowId, + field_id: &str, + old_row: Option, + ) { + let field = self.get_field(field_id).await; + if let Some(field) = field { + let handler = TypeOptionCellExt::new(&field, None) + .get_type_option_cell_data_handler_with_field_type(FieldType::Media); + if handler.is_none() { + return; + } + let handler = handler.unwrap(); + + let cell = self.get_cell(field_id, row_id).await; + let new_count = match cell { + Some(cell) => { + let data = handler + .handle_get_boxed_cell_data(&cell, &field) + .and_then(|cell_data| cell_data.unbox_or_none()) + .unwrap_or_else(MediaCellData::default); + + data.files.len() as i64 + }, + None => 0, + }; + + let old_cell = old_row.and_then(|row| row.cells.get(field_id).cloned()); + let old_count = match old_cell { + Some(old_cell) => { + let data = handler + .handle_get_boxed_cell_data(&old_cell, &field) + .and_then(|cell_data| cell_data.unbox_or_none()) + .unwrap_or_else(MediaCellData::default); + + data.files.len() as i64 + }, + None => 0, + }; + + if new_count != old_count { + let attachment_count = self + .get_row_meta(view_id, row_id) + .await + .and_then(|meta| meta.attachment_count); + + let new_attachment_count = match attachment_count { + Some(attachment_count) => attachment_count + new_count - old_count, + None => new_count, + }; + + self + .update_row_meta( + row_id, + UpdateRowMetaParams { + id: row_id.clone().into_inner(), + view_id: view_id.to_string(), + cover: None, + icon_url: None, + is_document_empty: None, + attachment_count: Some(new_attachment_count), + }, + ) .await; } } } - pub fn get_auto_updated_fields_changesets( + pub async fn get_auto_updated_fields_changesets( &self, view_id: &str, row_id: RowId, ) -> Vec { // Get all auto updated fields. It will be used to notify the frontend // that the fields have been updated. - let auto_updated_fields = self.get_auto_updated_fields(view_id); + let auto_updated_fields = self.get_auto_updated_fields(view_id).await; // Collect all the updated field's id. Notify the frontend that all of them have been updated. let auto_updated_field_ids = auto_updated_fields @@ -884,7 +1185,7 @@ impl DatabaseEditor { field_id: &str, option_name: String, ) -> Option { - let field = self.database.lock().fields.get_field(field_id)?; + let field = self.database.read().await.get_field(field_id)?; let type_option = select_type_option_from_field(&field).ok()?; let select_option = type_option.create_option(&option_name); Some(SelectOptionPB::from(select_option)) @@ -899,15 +1200,10 @@ impl DatabaseEditor { row_id: RowId, options: Vec, ) -> FlowyResult<()> { - let field = self - .database - .lock() - .fields - .get_field(field_id) - .ok_or_else(|| { - FlowyError::record_not_found() - .with_context(format!("Field with id:{} not found", &field_id)) - })?; + let mut database = self.database.write().await; + let field = database.get_field(field_id).ok_or_else(|| { + FlowyError::record_not_found().with_context(format!("Field with id:{} not found", &field_id)) + })?; debug_assert!(FieldType::from(field.field_type).is_select_option()); let mut type_option = select_type_option_from_field(&field)?; @@ -921,13 +1217,12 @@ impl DatabaseEditor { // Update the field's type option let view_editors = self.database_views.editors().await; - update_field_type_option_fn( - &self.database, - &view_editors, - type_option.to_type_option_data(), - field.clone(), - ) - .await?; + update_field_type_option_fn(&mut database, type_option.to_type_option_data(), &field).await?; + drop(database); + + for view_editor in view_editors { + view_editor.v_did_update_field_type_option(&field).await?; + } // Insert the options into the cell self @@ -943,7 +1238,8 @@ impl DatabaseEditor { row_id: RowId, options: Vec, ) -> FlowyResult<()> { - let field = match self.database.lock().fields.get_field(field_id) { + let mut database = self.database.write().await; + let field = match database.get_field(field_id) { Some(field) => Ok(field), None => { let msg = format!("Field with id:{} not found", &field_id); @@ -961,13 +1257,14 @@ impl DatabaseEditor { } let view_editors = self.database_views.editors().await; - update_field_type_option_fn( - &self.database, - &view_editors, - type_option.to_type_option_data(), - field.clone(), - ) - .await?; + update_field_type_option_fn(&mut database, type_option.to_type_option_data(), &field).await?; + + // Drop the database write lock ASAP + drop(database); + + for view_editor in view_editors { + view_editor.v_did_update_field_type_option(&field).await?; + } self .update_cell_with_changeset(view_id, &row_id, field_id, BoxAny::new(cell_changeset)) @@ -984,8 +1281,8 @@ impl DatabaseEditor { ) -> FlowyResult<()> { let field = self .database - .lock() - .fields + .read() + .await .get_field(field_id) .ok_or_else(|| { FlowyError::record_not_found() @@ -1001,14 +1298,14 @@ impl DatabaseEditor { #[tracing::instrument(level = "trace", skip_all, err)] pub async fn load_groups(&self, view_id: &str) -> FlowyResult { - let view = self.database_views.get_view_editor(view_id).await?; + let view = self.database_views.get_or_init_view_editor(view_id).await?; let groups = view.v_load_groups().await.unwrap_or_default(); Ok(RepeatedGroupPB { items: groups }) } #[tracing::instrument(level = "trace", skip_all, err)] pub async fn get_group(&self, view_id: &str, group_id: &str) -> FlowyResult { - let view = self.database_views.get_view_editor(view_id).await?; + let view = self.database_views.get_or_init_view_editor(view_id).await?; let group = view.v_get_group(group_id).await?; Ok(group) } @@ -1025,69 +1322,19 @@ impl DatabaseEditor { return Ok(()); } - let view = self.database_views.get_view_editor(view_id).await?; + let view = self.database_views.get_or_init_view_editor(view_id).await?; view.v_move_group(from_group, to_group).await?; Ok(()) } - #[tracing::instrument(level = "trace", skip_all, err)] - pub async fn move_group_row( - &self, - view_id: &str, - from_group: &str, - to_group: &str, - from_row: RowId, - to_row: Option, - ) -> FlowyResult<()> { - let row_detail = self.get_row_detail(view_id, &from_row); - match row_detail { - None => { - warn!( - "Move row between group failed, can not find the row:{}", - from_row - ) - }, - Some(row_detail) => { - let view = self.database_views.get_view_editor(view_id).await?; - let mut row_changeset = RowChangeset::new(row_detail.row.id.clone()); - view - .v_move_group_row(&row_detail, &mut row_changeset, to_group, to_row.clone()) - .await; - - let to_row = if to_row.is_some() { - to_row - } else { - let row_details = self.get_rows(view_id).await?; - row_details - .last() - .map(|row_detail| row_detail.row.id.clone()) - }; - if let Some(row_id) = to_row.clone() { - self.move_row(view_id, from_row.clone(), row_id).await?; - } - - if from_group == to_group { - return Ok(()); - } - - tracing::trace!("Row data changed: {:?}", row_changeset); - self.database.lock().update_row(&row_detail.row.id, |row| { - row.set_cells(Cells::from(row_changeset.cell_by_field_id.clone())); - }); - }, - } - - Ok(()) - } - pub async fn group_by_field(&self, view_id: &str, field_id: &str) -> FlowyResult<()> { - let view = self.database_views.get_view_editor(view_id).await?; + let view = self.database_views.get_or_init_view_editor(view_id).await?; view.v_group_by_field(field_id).await?; Ok(()) } pub async fn create_group(&self, view_id: &str, name: &str) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(view_id).await?; + let view_editor = self.database_views.get_or_init_view_editor(view_id).await?; view_editor.v_create_group(name).await?; Ok(()) } @@ -1098,7 +1345,7 @@ impl DatabaseEditor { view_id: &str, layout_setting: LayoutSettingChangeset, ) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(view_id).await?; + let view_editor = self.database_views.get_or_init_view_editor(view_id).await?; view_editor.v_set_layout_settings(layout_setting).await?; Ok(()) } @@ -1108,14 +1355,18 @@ impl DatabaseEditor { view_id: &str, layout_ty: DatabaseLayout, ) -> Option { - let view = self.database_views.get_view_editor(view_id).await.ok()?; + let view = self + .database_views + .get_or_init_view_editor(view_id) + .await + .ok()?; let layout_setting = view.v_get_layout_settings(&layout_ty).await; Some(layout_setting) } #[tracing::instrument(level = "trace", skip_all)] pub async fn get_all_calendar_events(&self, view_id: &str) -> Vec { - match self.database_views.get_view_editor(view_id).await { + match self.database_views.get_or_init_view_editor(view_id).await { Ok(view) => view.v_get_all_calendar_events().await.unwrap_or_default(), Err(_) => { warn!("Can not find the view: {}", view_id); @@ -1129,19 +1380,23 @@ impl DatabaseEditor { &self, view_id: &str, ) -> FlowyResult> { - let _database_view = self.database_views.get_view_editor(view_id).await?; + let _database_view = self.database_views.get_or_init_view_editor(view_id).await?; Ok(vec![]) } #[tracing::instrument(level = "trace", skip_all)] pub async fn get_calendar_event(&self, view_id: &str, row_id: RowId) -> Option { - let view = self.database_views.get_view_editor(view_id).await.ok()?; + let view = self + .database_views + .get_or_init_view_editor(view_id) + .await + .ok()?; view.v_get_calendar_event(row_id).await } #[tracing::instrument(level = "trace", skip_all, err)] async fn notify_did_insert_database_field(&self, field: Field, index: usize) -> FlowyResult<()> { - let database_id = self.database.lock().get_database_id(); + let database_id = self.database.read().await.get_database_id(); let index_field = IndexFieldPB { field: FieldPB::new(field), index: index as i32, @@ -1155,9 +1410,9 @@ impl DatabaseEditor { &self, changeset: DatabaseFieldChangesetPB, ) -> FlowyResult<()> { - let views = self.database.lock().get_all_database_views_meta(); + let views = self.database.read().await.get_all_database_views_meta(); for view in views { - send_notification(&view.id, DatabaseNotification::DidUpdateFields) + database_notification_builder(&view.id, DatabaseNotification::DidUpdateFields) .payload(changeset.clone()) .send(); } @@ -1169,55 +1424,282 @@ impl DatabaseEditor { &self, view_id: &str, ) -> FlowyResult { - let view = - self.database.lock().get_view(view_id).ok_or_else(|| { - FlowyError::record_not_found().with_context("Can't find the database view") + let view = self + .database + .read() + .await + .get_view(view_id) + .ok_or_else(|| { + FlowyError::record_not_found() + .with_context(format!("Can't find the database view:{}", view_id)) })?; Ok(database_view_setting_pb_from_view(view)) } - pub async fn get_database_data(&self, view_id: &str) -> FlowyResult { - let database_view = self.database_views.get_view_editor(view_id).await?; - let view = database_view - .v_get_view() - .await - .ok_or_else(FlowyError::record_not_found)?; - let rows = database_view.v_get_rows().await; - let (database_id, fields, is_linked) = { - let database = self.database.lock(); - let database_id = database.get_database_id(); - let fields = database - .fields - .get_all_field_orders() - .into_iter() - .map(FieldIdPB::from) - .collect(); - let is_linked = database.is_inline_view(view_id); - (database_id, fields, is_linked) - }; + pub async fn close_database(&self) { + info!("[Database]: {} close", self.database_id); + let token = CancellationToken::new(); + let cloned_finalized_rows = self.finalized_rows.clone(); + self + .un_finalized_rows_cancellation + .store(Some(Arc::new(token.clone()))); + tokio::spawn(async move { + // Using select! to concurrently run two asynchronous futures: + // 1. Wait for 30 seconds, then invalidate all the finalized rows. + // 2. If the cancellation token (`token`) is triggered before the 30 seconds expire, + // cancel the invalidation action and do nothing. + select! { + _ = tokio::time::sleep(Duration::from_secs(30)) => { + for (row_id, row) in cloned_finalized_rows.iter() { + remove_row_sync_plugin(row_id.as_str(), row).await; + } + cloned_finalized_rows.invalidate_all(); + }, + _ = token.cancelled() => { + trace!("Invalidate action cancelled"); + } + } + }); - let rows = rows - .into_iter() - .map(|row_detail| RowMetaPB::from(row_detail.as_ref())) - .collect::>(); - Ok(DatabasePB { - id: database_id, - fields, - rows, - layout_type: view.layout.into(), - is_linked, - }) + let cancellation = self.database_cancellation.read().await; + if let Some(cancellation) = &*cancellation { + info!("Cancel database operation"); + cancellation.cancel(); + } + } + + /// Open database view + /// When opening database view, it will load database rows from remote if they are not exist in local disk. + /// After load all rows, it will apply filters and sorts to the rows. + /// + /// If notify_finish is not None, it will send a notification to the sender when the opening process is complete. + pub async fn open_database_view( + &self, + view_id: &str, + notify_finish: Option>, + ) -> FlowyResult { + if let Some(un_finalized_rows_token) = self.un_finalized_rows_cancellation.load_full() { + un_finalized_rows_token.cancel(); + } + + let (tx, rx) = oneshot::channel(); + self.opening_ret_txs.write().await.push(tx); + // Check if the database is currently being opened + if self.is_loading_rows.load_full().is_none() { + self + .is_loading_rows + .store(Some(Arc::new(broadcast::channel(500).0))); + let view_layout = self.database.read().await.get_database_view_layout(view_id); + let new_token = CancellationToken::new(); + if let Some(old_token) = self + .database_cancellation + .write() + .await + .replace(new_token.clone()) + { + old_token.cancel(); + } + + let view_editor = self.database_views.get_or_init_view_editor(view_id).await?; + let row_orders = view_editor.get_all_row_orders().await?; + view_editor.set_row_orders(row_orders.clone()).await; + + // Collect database details in a single block holding the `read` lock + let (database_id, fields) = { + let database = self.database.read().await; + ( + database.get_database_id(), + database + .get_all_field_orders() + .into_iter() + .map(FieldIdPB::from) + .collect::>(), + ) + }; + + // the order_rows are not filtered and sorted yet + let mut order_rows = row_orders + .iter() + .map(|row_order| RowMetaPB::from(row_order.clone())) + .collect::>(); + + trace!( + "[Database]: database: {}, num fields: {}, num rows: {}", + database_id, + fields.len(), + order_rows.len() + ); + + let view_editor = self.database_views.get_or_init_view_editor(view_id).await?; + let blocking_read = notify_finish.is_some() || order_rows.len() < 50; + + let (tx, rx) = oneshot::channel(); + self + .async_load_rows(view_editor, Some(tx), new_token, blocking_read, row_orders) + .await; + if blocking_read { + // the rows returned here are applied with filters and sorts + if let Ok(rows) = rx.await { + order_rows = rows + .into_iter() + .map(|row| RowMetaPB::from(row.as_ref())) + .collect(); + } + } + + if let Some(tx) = notify_finish { + let _ = tx.send(()); + } + + let result = Ok(DatabasePB { + id: database_id, + fields, + rows: order_rows, + layout_type: view_layout.into(), + }); + // Mark that the opening process is complete + if let Some(tx) = self.is_loading_rows.load_full() { + let _ = tx.send(()); + } + self.is_loading_rows.store(None); + // Collect all waiting tasks and send the result + let txs = std::mem::take(&mut *self.opening_ret_txs.write().await); + for tx in txs { + let _ = tx.send(result.clone()); + } + } + + // Wait for the result or timeout after 60 seconds + match tokio::time::timeout(Duration::from_secs(60), rx).await { + Ok(result) => result.map_err(internal_error)?, + Err(_) => Err(FlowyError::internal().with_context("Timeout while opening database view")), + } + } + + async fn async_load_rows( + &self, + view_editor: Arc, + notify_finish: Option>>>, + new_token: CancellationToken, + blocking_read: bool, + original_row_orders: Vec, + ) { + trace!( + "[Database]: start loading rows, blocking: {}", + blocking_read + ); + let cloned_database = Arc::downgrade(&self.database); + let fields = self.get_fields(&view_editor.view_id, None).await; + tokio::spawn(async move { + let apply_filter_and_sort = + |mut loaded_rows: Vec>, view_editor: Arc| async move { + for loaded_row in loaded_rows.iter() { + view_editor + .row_by_row_id + .insert(loaded_row.id.to_string(), loaded_row.clone()); + } + + if view_editor.has_filters().await { + trace!("[Database]: filtering rows:{}", loaded_rows.len()); + if blocking_read { + loaded_rows = view_editor.v_filter_rows(loaded_rows).await; + } else { + view_editor.v_filter_rows_and_notify(&mut loaded_rows).await; + } + } else { + trace!("[Database]: no filter to apply"); + } + + if view_editor.has_sorts().await { + trace!("[Database]: sorting rows:{}", loaded_rows.len()); + if blocking_read { + view_editor.v_sort_rows(&mut loaded_rows).await; + } else { + view_editor.v_sort_rows_and_notify(&mut loaded_rows).await; + } + loaded_rows + } else { + trace!("[Database]: no sort to apply"); + // if there are no sorts, use the original row orders to sort the loaded rows + let mut map = loaded_rows + .into_iter() + .map(|row| (row.id.clone(), row)) + .collect::>>(); + + let mut order_rows = vec![]; + for row_order in original_row_orders { + if let Some(row) = map.remove(&row_order.id) { + order_rows.push(row); + } + } + order_rows + } + }; + + let mut loaded_rows = vec![]; + const CHUNK_SIZE: usize = 10; + let row_orders = view_editor.row_orders.read().await; + let row_orders_chunks = row_orders.chunks(CHUNK_SIZE).collect::>(); + + // Iterate over chunks and load rows concurrently + for chunk_row_orders in row_orders_chunks { + // Check if the database is still available + let database = match cloned_database.upgrade() { + None => break, // If the database is dropped, stop the operation + Some(database) => database, + }; + + let row_ids = chunk_row_orders + .iter() + .map(|row_order| row_order.id.clone()) + .collect(); + + let new_loaded_rows: Vec> = database + .read() + .await + .init_database_rows(row_ids, chunk_row_orders.len(), None) + .filter_map(|result| async { + let database_row = result.ok()?; + let read_guard = database_row.read().await; + read_guard.get_row().map(Arc::new) + }) + .collect() + .await; + loaded_rows.extend(new_loaded_rows); + + // Check for cancellation after each chunk + if new_token.is_cancelled() { + info!("[Database]: stop loading database rows"); + return; + } + yield_now().await; + } + drop(row_orders); + + info!( + "[Database]: Finish loading all rows: {}, blocking: {}", + loaded_rows.len(), + blocking_read + ); + let loaded_rows = apply_filter_and_sort(loaded_rows, view_editor.clone()).await; + // Update calculation values + let calculate_rows = loaded_rows.clone(); + if let Some(notify_finish) = notify_finish { + let _ = notify_finish.send(loaded_rows); + } + tokio::spawn(async move { + let _ = view_editor.v_calculate_rows(fields, calculate_rows).await; + }); + }); } pub async fn export_csv(&self, style: CSVFormat) -> FlowyResult { let database = self.database.clone(); - let csv = tokio::task::spawn_blocking(move || { - let database_guard = database.lock(); - let csv = CSVExport.export_database(&database_guard, style)?; - Ok::(csv) - }) - .await - .map_err(internal_error)??; + let database_guard = database.read().await; + let csv = CSVExport + .export_database(&database_guard, style) + .await + .map_err(internal_error)?; Ok(csv) } @@ -1226,7 +1708,7 @@ impl DatabaseEditor { view_id: &str, field_ids: Vec, ) -> FlowyResult> { - let view = self.database_views.get_view_editor(view_id).await?; + let view = self.database_views.get_or_init_view_editor(view_id).await?; let field_settings = view .v_get_field_settings(&field_ids) @@ -1240,6 +1722,7 @@ impl DatabaseEditor { pub async fn get_all_field_settings(&self, view_id: &str) -> FlowyResult> { let field_ids = self .get_fields(view_id, None) + .await .iter() .map(|field| field.id.clone()) .collect(); @@ -1251,7 +1734,10 @@ impl DatabaseEditor { &self, params: FieldSettingsChangesetPB, ) -> FlowyResult<()> { - let view = self.database_views.get_view_editor(¶ms.view_id).await?; + let view = self + .database_views + .get_or_init_view_editor(¶ms.view_id) + .await?; view.v_update_field_settings(params).await?; Ok(()) @@ -1260,7 +1746,8 @@ impl DatabaseEditor { pub async fn get_related_database_id(&self, field_id: &str) -> FlowyResult { let mut field = self .database - .lock() + .read() + .await .get_fields(Some(vec![field_id.to_string()])); let field = field.pop().ok_or(FlowyError::internal())?; @@ -1271,46 +1758,99 @@ impl DatabaseEditor { Ok(type_option.database_id) } - pub async fn get_related_rows( - &self, - row_ids: Option<&Vec>, - ) -> FlowyResult> { - let primary_field = self.database.lock().fields.get_primary_field().unwrap(); - let handler = TypeOptionCellExt::new(&primary_field, Some(self.cell_cache.clone())) - .get_type_option_cell_data_handler_with_field_type(FieldType::RichText) - .ok_or(FlowyError::internal())?; - - let row_data = { - let database = self.database.lock(); - let mut rows = database.get_database_rows(); - if let Some(row_ids) = row_ids { - rows.retain(|row| row_ids.contains(&row.id)); - } - rows - .iter() - .map(|row| { - let title = database - .get_cell(&primary_field.id, &row.id) - .cell - .and_then(|cell| handler.handle_get_boxed_cell_data(&cell, &primary_field)) - .and_then(|cell_data| cell_data.unbox_or_none()) - .unwrap_or_else(|| StringCellData("".to_string())); - - RelatedRowDataPB { - row_id: row.id.to_string(), - name: title.0, - } - }) - .collect::>() - }; - - Ok(row_data) + pub async fn get_row_index(&self, view_id: &str, row_id: &RowId) -> Option { + self.database.read().await.get_row_index(view_id, row_id) } - fn get_auto_updated_fields(&self, view_id: &str) -> Vec { + pub async fn get_row_order_at_index(&self, view_id: &str, index: u32) -> Option { self .database - .lock() + .read() + .await + .get_row_order_at_index(view_id, index) + .await + } + + pub async fn get_related_rows( + &self, + row_ids: Option>, + ) -> FlowyResult> { + let database = self.database.read().await; + let primary_field = Arc::new( + database + .get_primary_field() + .ok_or_else(|| FlowyError::internal().with_context("Primary field is not exist"))?, + ); + + let handler = Arc::new( + TypeOptionCellExt::new(&primary_field, Some(self.cell_cache.clone())) + .get_type_option_cell_data_handler_with_field_type(FieldType::RichText) + .ok_or(FlowyError::internal())?, + ); + + match row_ids { + None => { + let mut row_data = vec![]; + let rows_stream = database.get_all_rows(10, None).await; + pin_mut!(rows_stream); + while let Some(result) = rows_stream.next().await { + if let Ok(row) = result { + let title = database + .get_cell(&primary_field.id, &row.id) + .await + .cell + .and_then(|cell| handler.handle_get_boxed_cell_data(&cell, &primary_field)) + .and_then(|cell_data| cell_data.unbox_or_none()) + .unwrap_or_else(|| StringCellData("".to_string())); // Default to empty string + + row_data.push(RelatedRowDataPB { + row_id: row.id.to_string(), + name: title.0, + }); + } + } + + Ok(row_data) + }, + Some(row_ids) => { + let mut database_rows = vec![]; + for row_id in row_ids { + let row_id = RowId::from(row_id); + if let Some(database_row) = database.get_or_init_database_row(&row_id).await { + database_rows.push(database_row); + } + } + + let row_data_futures = database_rows.into_iter().map(|database_row| { + let handler = handler.clone(); + let cloned_primary_field = primary_field.clone(); + async move { + let row_id = database_row.read().await.row_id.to_string(); + let title = database_row + .read() + .await + .get_cell(&cloned_primary_field.id) + .and_then(|cell| handler.handle_get_boxed_cell_data(&cell, &cloned_primary_field)) + .and_then(|cell_data| cell_data.unbox_or_none()) + .unwrap_or_else(|| StringCellData("".to_string())); + + RelatedRowDataPB { + row_id, + name: title.0, + } + } + }); + let row_data = join_all(row_data_futures).await; + Ok(row_data) + }, + } + } + + async fn get_auto_updated_fields(&self, view_id: &str) -> Vec { + self + .database + .read() + .await .get_fields_in_view(view_id, None) .into_iter() .filter(|f| FieldType::from(f.field_type).is_auto_update()) @@ -1319,45 +1859,50 @@ impl DatabaseEditor { /// Only expose this method for testing #[cfg(debug_assertions)] - pub fn get_mutex_database(&self) -> &MutexDatabase { + pub fn get_mutex_database(&self) -> &RwLock { &self.database } } struct DatabaseViewOperationImpl { - database: Arc, - task_scheduler: Arc>, + database: Arc>, + task_scheduler: Arc>, cell_cache: CellCache, editor_by_view_id: Arc>, + #[allow(dead_code)] + database_cancellation: Arc>>, } +#[async_trait] impl DatabaseViewOperation for DatabaseViewOperationImpl { - fn get_database(&self) -> Arc { + fn get_database(&self) -> Arc> { self.database.clone() } - fn get_view(&self, view_id: &str) -> Fut> { - let view = self.database.lock().get_view(view_id); - to_fut(async move { view }) + async fn get_view(&self, view_id: &str) -> Option { + self.database.read().await.get_view(view_id) } - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut> { - let fields = self.database.lock().get_fields_in_view(view_id, field_ids); - to_fut(async move { fields }) + async fn get_fields(&self, view_id: &str, field_ids: Option>) -> Vec { + self + .database + .read() + .await + .get_fields_in_view(view_id, field_ids) } - fn get_field(&self, field_id: &str) -> Option { - self.database.lock().fields.get_field(field_id) + async fn get_field(&self, field_id: &str) -> Option { + self.database.read().await.get_field(field_id) } - fn create_field( + async fn create_field( &self, view_id: &str, name: &str, field_type: FieldType, type_option_data: TypeOptionData, - ) -> Fut { - let (_, field) = self.database.lock().create_field_with_mut( + ) -> Field { + let (_, field) = self.database.write().await.create_field_with_mut( view_id, name.to_string(), field_type.into(), @@ -1369,199 +1914,217 @@ impl DatabaseViewOperation for DatabaseViewOperationImpl { }, default_field_settings_by_layout_map(), ); - to_fut(async move { field }) + field } - fn update_field( + async fn update_field( &self, type_option_data: TypeOptionData, old_field: Field, - ) -> FutureResult<(), FlowyError> { - let weak_editor_by_view_id = Arc::downgrade(&self.editor_by_view_id); - let weak_database = Arc::downgrade(&self.database); - FutureResult::new(async move { - if let (Some(database), Some(editor_by_view_id)) = - (weak_database.upgrade(), weak_editor_by_view_id.upgrade()) - { - let view_editors = editor_by_view_id.read().await.values().cloned().collect(); - let _ = - update_field_type_option_fn(&database, &view_editors, type_option_data, old_field).await; - } - Ok(()) - }) - } - - fn get_primary_field(&self) -> Fut>> { - let field = self - .database - .lock() - .fields - .get_primary_field() - .map(Arc::new); - to_fut(async move { field }) - } - - fn index_of_row(&self, view_id: &str, row_id: &RowId) -> Fut> { - let index = self.database.lock().index_of_row(view_id, row_id); - to_fut(async move { index }) - } - - fn get_row(&self, view_id: &str, row_id: &RowId) -> Fut)>> { - let index = self.database.lock().index_of_row(view_id, row_id); - let row_detail = self.database.lock().get_row_detail(row_id); - to_fut(async move { - match (index, row_detail) { - (Some(index), Some(row_detail)) => Some((index, Arc::new(row_detail))), - _ => None, - } - }) - } - - fn get_rows(&self, view_id: &str) -> Fut>> { - let database = self.database.clone(); - let view_id = view_id.to_string(); - to_fut(async move { - let cloned_database = database.clone(); - // offloads the blocking operation to a thread where blocking is acceptable. This prevents - // blocking the main asynchronous runtime - let row_orders = tokio::task::spawn_blocking(move || { - cloned_database.lock().get_row_orders_for_view(&view_id) - }) + ) -> Result<(), FlowyError> { + let view_editors = self + .editor_by_view_id + .read() .await - .unwrap_or_default(); - tokio::task::yield_now().await; + .values() + .cloned() + .collect::>(); - let mut all_rows = vec![]; + // + { + let mut database = self.database.write().await; + let _ = update_field_type_option_fn(&mut database, type_option_data, &old_field).await; + drop(database); + } - // Loading the rows in chunks of 10 rows in order to prevent blocking the main asynchronous runtime - for chunk in row_orders.chunks(10) { - let cloned_database = database.clone(); - let chunk = chunk.to_vec(); - let rows = tokio::task::spawn_blocking(move || { - let orders = cloned_database.lock().get_rows_from_row_orders(&chunk); - let lock_guard = cloned_database.lock(); - orders - .into_iter() - .flat_map(|row| lock_guard.get_row_detail(&row.id)) - .collect::>() - }) - .await - .unwrap_or_default(); + for view_editor in view_editors { + view_editor + .v_did_update_field_type_option(&old_field) + .await?; + } + Ok(()) + } - all_rows.extend(rows); - tokio::task::yield_now().await; + async fn get_primary_field(&self) -> Option> { + self.database.read().await.get_primary_field().map(Arc::new) + } + + async fn index_of_row(&self, view_id: &str, row_id: &RowId) -> Option { + self.database.read().await.index_of_row(view_id, row_id) + } + + async fn get_row_detail(&self, view_id: &str, row_id: &RowId) -> Option<(usize, Arc)> { + let database = self.database.read().await; + let index = database.index_of_row(view_id, row_id); + let row_detail = database.get_row_detail(row_id).await; + match (index, row_detail) { + (Some(index), Some(row_detail)) => Some((index, Arc::new(row_detail))), + _ => None, + } + } + + async fn get_all_rows(&self, view_id: &str, row_orders: Vec) -> Vec> { + let view_id = view_id.to_string(); + trace!("{} has total row orders: {}", view_id, row_orders.len()); + let mut all_rows = vec![]; + let read_guard = self.database.read().await; + let rows_stream = read_guard + .get_rows_from_row_orders(&row_orders, 10, None) + .await; + pin_mut!(rows_stream); + + while let Some(result) = rows_stream.next().await { + match result { + Ok(row) => { + all_rows.push(row); + }, + Err(err) => error!("Error while loading rows: {}", err), } + } - all_rows.into_iter().map(Arc::new).collect() - }) + trace!("total row details: {}", all_rows.len()); + all_rows.into_iter().map(Arc::new).collect() } - fn remove_row(&self, row_id: &RowId) -> Option { - self.database.lock().remove_row(row_id) + async fn get_all_row_orders(&self, view_id: &str) -> Vec { + self.database.read().await.get_row_orders_for_view(view_id) } - fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Fut>> { - let cells = self.database.lock().get_cells_for_field(view_id, field_id); - to_fut(async move { cells.into_iter().map(Arc::new).collect() }) + async fn remove_row(&self, row_id: &RowId) -> Option { + self.database.write().await.remove_row(row_id).await } - fn get_cell_in_row(&self, field_id: &str, row_id: &RowId) -> Fut> { - let cell = self.database.lock().get_cell(field_id, row_id); - to_fut(async move { Arc::new(cell) }) + async fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Vec { + let editor = self.editor_by_view_id.read().await.get(view_id).cloned(); + match editor { + None => vec![], + Some(editor) => editor.v_get_cells_for_field(field_id).await, + } } - fn get_layout_for_view(&self, view_id: &str) -> DatabaseLayout { - self.database.lock().views.get_database_view_layout(view_id) + async fn get_cell_in_row(&self, field_id: &str, row_id: &RowId) -> Arc { + let cell = self.database.read().await.get_cell(field_id, row_id).await; + cell.into() } - fn get_group_setting(&self, view_id: &str) -> Vec { - self.database.lock().get_all_group_setting(view_id) + async fn get_layout_for_view(&self, view_id: &str) -> DatabaseLayout { + self.database.read().await.get_database_view_layout(view_id) } - fn insert_group_setting(&self, view_id: &str, setting: GroupSetting) { - self.database.lock().insert_group_setting(view_id, setting); + async fn get_group_setting(&self, view_id: &str) -> Vec { + self.database.read().await.get_all_group_setting(view_id) } - fn get_sort(&self, view_id: &str, sort_id: &str) -> Option { - self.database.lock().get_sort::(view_id, sort_id) - } - - fn insert_sort(&self, view_id: &str, sort: Sort) { - self.database.lock().insert_sort(view_id, sort); - } - - fn move_sort(&self, view_id: &str, from_sort_id: &str, to_sort_id: &str) { + async fn insert_group_setting(&self, view_id: &str, setting: GroupSetting) { self .database - .lock() + .write() + .await + .insert_group_setting(view_id, setting); + } + + async fn get_sort(&self, view_id: &str, sort_id: &str) -> Option { + self + .database + .read() + .await + .get_sort::(view_id, sort_id) + } + + async fn insert_sort(&self, view_id: &str, sort: Sort) { + self.database.write().await.insert_sort(view_id, sort); + } + + async fn move_sort(&self, view_id: &str, from_sort_id: &str, to_sort_id: &str) { + self + .database + .write() + .await .move_sort(view_id, from_sort_id, to_sort_id); } - fn remove_sort(&self, view_id: &str, sort_id: &str) { - self.database.lock().remove_sort(view_id, sort_id); + async fn remove_sort(&self, view_id: &str, sort_id: &str) { + self.database.write().await.remove_sort(view_id, sort_id); } - fn get_all_sorts(&self, view_id: &str) -> Vec { - self.database.lock().get_all_sorts::(view_id) + async fn get_all_sorts(&self, view_id: &str) -> Vec { + self.database.read().await.get_all_sorts::(view_id) } - fn remove_all_sorts(&self, view_id: &str) { - self.database.lock().remove_all_sorts(view_id); + async fn remove_all_sorts(&self, view_id: &str) { + self.database.write().await.remove_all_sorts(view_id); } - fn get_all_calculations(&self, view_id: &str) -> Vec> { + async fn get_all_calculations(&self, view_id: &str) -> Vec> { self .database - .lock() + .read() + .await .get_all_calculations(view_id) .into_iter() .map(Arc::new) .collect() } - fn get_calculation(&self, view_id: &str, field_id: &str) -> Option { + async fn get_calculation(&self, view_id: &str, field_id: &str) -> Option { self .database - .lock() + .read() + .await .get_calculation::(view_id, field_id) } - fn get_all_filters(&self, view_id: &str) -> Vec { + async fn get_all_filters(&self, view_id: &str) -> Vec { self .database - .lock() + .read() + .await .get_all_filters(view_id) .into_iter() .collect() } - fn delete_filter(&self, view_id: &str, filter_id: &str) { - self.database.lock().remove_filter(view_id, filter_id); - } - - fn insert_filter(&self, view_id: &str, filter: Filter) { - self.database.lock().insert_filter(view_id, &filter); - } - - fn save_filters(&self, view_id: &str, filters: &[Filter]) { + async fn delete_filter(&self, view_id: &str, filter_id: &str) { self .database - .lock() + .write() + .await + .remove_filter(view_id, filter_id); + } + + async fn insert_filter(&self, view_id: &str, filter: Filter) { + self.database.write().await.insert_filter(view_id, &filter); + } + + async fn save_filters(&self, view_id: &str, filters: &[Filter]) { + self + .database + .write() + .await .save_filters::(view_id, filters); } - fn get_filter(&self, view_id: &str, filter_id: &str) -> Option { + async fn get_filter(&self, view_id: &str, filter_id: &str) -> Option { self .database - .lock() + .read() + .await .get_filter::(view_id, filter_id) } - fn get_layout_setting(&self, view_id: &str, layout_ty: &DatabaseLayout) -> Option { - self.database.lock().get_layout_setting(view_id, layout_ty) + async fn get_layout_setting( + &self, + view_id: &str, + layout_ty: &DatabaseLayout, + ) -> Option { + self + .database + .read() + .await + .get_layout_setting(view_id, layout_ty) } - fn insert_layout_setting( + async fn insert_layout_setting( &self, view_id: &str, layout_ty: &DatabaseLayout, @@ -1569,18 +2132,20 @@ impl DatabaseViewOperation for DatabaseViewOperationImpl { ) { self .database - .lock() + .write() + .await .insert_layout_setting(view_id, layout_ty, layout_setting); } - fn update_layout_type(&self, view_id: &str, layout_type: &DatabaseLayout) { + async fn update_layout_type(&self, view_id: &str, layout_type: &DatabaseLayout) { self .database - .lock() + .write() + .await .update_layout_type(view_id, layout_type); } - fn get_task_scheduler(&self) -> Arc> { + fn get_task_scheduler(&self) -> Arc> { self.task_scheduler.clone() } @@ -1591,14 +2156,14 @@ impl DatabaseViewOperation for DatabaseViewOperationImpl { TypeOptionCellExt::new(field, Some(self.cell_cache.clone())).get_type_option_cell_data_handler() } - fn get_field_settings( + async fn get_field_settings( &self, view_id: &str, field_ids: &[String], ) -> HashMap { let (layout_type, field_settings_map) = { - let database = self.database.lock(); - let layout_type = database.views.get_database_view_layout(view_id); + let database = self.database.read().await; + let layout_type = database.get_database_view_layout(view_id); let field_settings_map = database.get_field_settings(view_id, Some(field_ids)); (layout_type, field_settings_map) }; @@ -1629,19 +2194,20 @@ impl DatabaseViewOperation for DatabaseViewOperationImpl { field_settings } - fn update_field_settings(&self, params: FieldSettingsChangesetPB) { - let field_settings_map = self.get_field_settings(¶ms.view_id, &[params.field_id.clone()]); + async fn update_field_settings(&self, params: FieldSettingsChangesetPB) { + let field_settings_map = self + .get_field_settings(¶ms.view_id, &[params.field_id.clone()]) + .await; - let field_settings = field_settings_map - .get(¶ms.field_id) - .cloned() - .unwrap_or_else(|| { - let layout_type = self.get_layout_for_view(¶ms.view_id); + let field_settings = match field_settings_map.get(¶ms.field_id).cloned() { + Some(field_settings) => field_settings, + None => { + let layout_type = self.get_layout_for_view(¶ms.view_id).await; let default_field_settings = default_field_settings_by_layout_map(); let default_field_settings = default_field_settings.get(&layout_type).unwrap(); - FieldSettings::from_any_map(¶ms.field_id, layout_type, default_field_settings) - }); + }, + }; let new_field_settings = FieldSettings { visibility: params @@ -1654,13 +2220,13 @@ impl DatabaseViewOperation for DatabaseViewOperationImpl { ..field_settings }; - self.database.lock().update_field_settings( + self.database.write().await.update_field_settings( ¶ms.view_id, Some(vec![params.field_id]), new_field_settings.clone(), ); - send_notification( + database_notification_builder( ¶ms.view_id, DatabaseNotification::DidUpdateFieldSettings, ) @@ -1668,69 +2234,59 @@ impl DatabaseViewOperation for DatabaseViewOperationImpl { .send() } - fn update_calculation(&self, view_id: &str, calculation: Calculation) { + async fn update_calculation(&self, view_id: &str, calculation: Calculation) { self .database - .lock() + .write() + .await .update_calculation(view_id, calculation) } - fn remove_calculation(&self, view_id: &str, field_id: &str) { - self.database.lock().remove_calculation(view_id, field_id) + async fn remove_calculation(&self, view_id: &str, field_id: &str) { + self + .database + .write() + .await + .remove_calculation(view_id, field_id) } } #[tracing::instrument(level = "trace", skip_all, err)] pub async fn update_field_type_option_fn( - database: &Arc, - view_editors: &Vec>, + database: &mut Database, type_option_data: TypeOptionData, - old_field: Field, + old_field: &Field, ) -> FlowyResult<()> { if type_option_data.is_empty() { warn!("Update type option with empty data"); return Ok(()); } let field_type = FieldType::from(old_field.field_type); - database - .lock() - .fields - .update_field(&old_field.id, |update| { - if old_field.is_primary { - warn!("Cannot update primary field type"); - } else { - update.update_type_options(|type_options_update| { - event!( - tracing::Level::TRACE, - "insert type option to field type: {:?}", - field_type - ); - type_options_update.insert(&field_type.to_string(), type_option_data); - }); - } - }); + database.update_field(&old_field.id, |update| { + if old_field.is_primary { + warn!("Cannot update primary field type"); + } else { + update.update_type_options(|type_options_update| { + event!( + tracing::Level::TRACE, + "insert type option to field type: {:?}, {:?}", + field_type, + type_option_data + ); + type_options_update.insert(&field_type.to_string(), type_option_data); + }); + } + }); let _ = notify_did_update_database_field(database, &old_field.id); - for view_editor in view_editors { - view_editor - .v_did_update_field_type_option(&old_field) - .await?; - } - Ok(()) } #[tracing::instrument(level = "trace", skip_all, err)] -fn notify_did_update_database_field( - database: &Arc, - field_id: &str, -) -> FlowyResult<()> { +fn notify_did_update_database_field(database: &Database, field_id: &str) -> FlowyResult<()> { let (database_id, field, views) = { - let database = database - .try_lock() - .ok_or(FlowyError::internal().with_context("fail to acquire the lock of database"))?; let database_id = database.get_database_id(); - let field = database.fields.get_field(field_id); + let field = database.get_field(field_id); let views = database.get_all_database_views_meta(); (database_id, field, views) }; @@ -1741,14 +2297,31 @@ fn notify_did_update_database_field( DatabaseFieldChangesetPB::update(&database_id, vec![updated_field.clone()]); for view in views { - send_notification(&view.id, DatabaseNotification::DidUpdateFields) + database_notification_builder(&view.id, DatabaseNotification::DidUpdateFields) .payload(notified_changeset.clone()) .send(); } - send_notification(field_id, DatabaseNotification::DidUpdateField) + database_notification_builder(field_id, DatabaseNotification::DidUpdateField) .payload(updated_field) .send(); } Ok(()) } + +async fn database_row_evict_listener(key: Arc, row: Weak>) { + remove_row_sync_plugin(key.as_str(), row).await +} + +async fn remove_row_sync_plugin(row_id: &str, row: Weak>) { + if let Some(row) = row.upgrade() { + let should_remove = row.read().await.has_cloud_plugin(); + if should_remove { + trace!("[Database]: un-finalize row: {}", row_id); + row + .write() + .await + .remove_plugins_for_types(vec![CollabPluginType::CloudStorage]); + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs index 682001948d..1c965995ec 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs @@ -1,28 +1,34 @@ use crate::entities::{DatabaseSyncStatePB, DidFetchRowPB, RowsChangePB}; -use crate::notification::{send_notification, DatabaseNotification, DATABASE_OBSERVABLE_SOURCE}; -use crate::services::database::UpdatedRow; +use crate::notification::{ + database_notification_builder, DatabaseNotification, DATABASE_OBSERVABLE_SOURCE, +}; +use crate::services::database::{DatabaseEditor, UpdatedRow}; +use crate::services::database_view::DatabaseViewEditor; +use collab::lock::RwLock; use collab_database::blocks::BlockEvent; -use collab_database::database::MutexDatabase; +use collab_database::database::Database; use collab_database::fields::FieldChange; use collab_database::rows::{RowChange, RowId}; -use collab_database::views::DatabaseViewChange; +use collab_database::views::{DatabaseViewChange, RowOrder}; +use dashmap::DashMap; use flowy_notification::{DebounceNotificationSender, NotificationBuilder}; use futures::StreamExt; -use lib_dispatch::prelude::af_spawn; -use std::sync::Arc; -use tracing::{trace, warn}; -pub(crate) async fn observe_sync_state(database_id: &str, database: &Arc) { +use std::sync::Arc; +use tracing::{error, trace, warn}; +use uuid::Uuid; + +pub(crate) async fn observe_sync_state(database_id: &str, database: &Arc>) { let weak_database = Arc::downgrade(database); - let mut sync_state = database.lock().subscribe_sync_state(); + let mut sync_state = database.read().await.subscribe_sync_state(); let database_id = database_id.to_string(); - af_spawn(async move { + tokio::spawn(async move { while let Some(sync_state) = sync_state.next().await { if weak_database.upgrade().is_none() { break; } - send_notification( + database_notification_builder( &database_id, DatabaseNotification::DidUpdateDatabaseSyncUpdate, ) @@ -32,117 +38,269 @@ pub(crate) async fn observe_sync_state(database_id: &str, database: &Arc, + database: &Arc>, notification_sender: &Arc, ) { let notification_sender = notification_sender.clone(); let database_id = database_id.to_string(); let weak_database = Arc::downgrade(database); - let mut row_change = database.lock().subscribe_row_change(); - af_spawn(async move { - while let Ok(row_change) = row_change.recv().await { - if let Some(database) = weak_database.upgrade() { + let sub = database.read().await.subscribe_row_change(); + if let Some(mut row_change) = sub { + tokio::spawn(async move { + while let Ok(row_change) = row_change.recv().await { trace!( "[Database Observe]: {} row change:{:?}", database_id, row_change ); - match row_change { - RowChange::DidUpdateCell { - field_id, - row_id, - value: _, - } => { - let cell_id = format!("{}:{}", row_id, field_id); - notify_cell(¬ification_sender, &cell_id); + if let Some(database) = weak_database.upgrade() { + match row_change { + RowChange::DidUpdateCell { + field_id, + row_id, + value: _, + } => { + let cell_id = format!("{}:{}", row_id, field_id); + notify_cell(¬ification_sender, &cell_id); - let views = database.lock().get_all_database_views_meta(); - for view in views { - notify_row(¬ification_sender, &view.id, &field_id, &row_id); - } - }, - _ => { - warn!("unhandled row change: {:?}", row_change); + let views = database.read().await.get_all_database_views_meta(); + for view in views { + notify_row(¬ification_sender, &view.id, &field_id, &row_id); + } + }, + _ => { + warn!("unhandled row change: {:?}", row_change); + }, + } + } else { + trace!( + "[Database Observe]: {} row change: database dropped", + database_id + ); + break; + } + } + }); + } +} +#[allow(dead_code)] +pub(crate) async fn observe_field_change(database_id: &str, database: &Arc>) { + let database_id = database_id.to_string(); + let weak_database = Arc::downgrade(database); + let sub = database.read().await.subscribe_field_change(); + if let Some(mut field_change) = sub { + tokio::spawn(async move { + while let Ok(field_change) = field_change.recv().await { + if weak_database.upgrade().is_none() { + break; + } + + trace!( + "[Database Observe]: {} field change:{:?}", + database_id, + field_change + ); + match field_change { + FieldChange::DidUpdateField { .. } => {}, + FieldChange::DidCreateField { .. } => {}, + FieldChange::DidDeleteField { .. } => {}, + } + } + }); + } +} + +#[allow(dead_code)] +pub(crate) async fn observe_view_change(database_id: &Uuid, database_editor: &Arc) { + let database_id = database_id.to_string(); + let weak_database_editor = Arc::downgrade(database_editor); + let view_change = database_editor + .database + .read() + .await + .subscribe_view_change(); + + if let Some(mut view_change) = view_change { + tokio::spawn(async move { + while let Ok(view_change) = view_change.recv().await { + trace!( + "[Database View Observe]: {} view change:{:?}", + database_id, + view_change + ); + match weak_database_editor.upgrade() { + None => break, + Some(database_editor) => match view_change { + DatabaseViewChange::DidCreateView { .. } => {}, + DatabaseViewChange::DidUpdateView { .. } => {}, + DatabaseViewChange::DidDeleteView { .. } => {}, + DatabaseViewChange::LayoutSettingChanged { .. } => {}, + DatabaseViewChange::DidUpdateRowOrders { + database_view_id, + is_local_change, + insert_row_orders, + delete_row_indexes, + } => { + handle_did_update_row_orders( + database_editor, + &database_view_id, + is_local_change, + insert_row_orders, + delete_row_indexes, + ) + .await; + }, + DatabaseViewChange::DidCreateFilters { .. } => {}, + DatabaseViewChange::DidUpdateFilter { .. } => {}, + DatabaseViewChange::DidCreateGroupSettings { .. } => {}, + DatabaseViewChange::DidUpdateGroupSetting { .. } => {}, + DatabaseViewChange::DidCreateSorts { .. } => {}, + DatabaseViewChange::DidUpdateSort { .. } => {}, + DatabaseViewChange::DidCreateFieldOrder { .. } => {}, + DatabaseViewChange::DidDeleteFieldOrder { .. } => {}, }, } + } + }); + } +} + +async fn handle_did_update_row_orders( + database_editor: Arc, + view_id: &str, + is_local_change: bool, + insert_row_orders: Vec<(RowOrder, u32)>, + delete_row_indexes: Vec, +) { + // DidUpdateRowOrders is triggered whenever a user performs operations such as + // deleting, inserting, or moving a row in the database. + // + // Before DidUpdateRowOrders is called, the changes (insert/move/delete) have already been + // applied to the underlying database. This means the current order of rows reflects these updates. + // + // Example: + // Imagine the current state of rows is: + // Before any changes: [a, b, c] + // + // Operation: Move 'a' before 'c' + // Initial state: [a, b, c] + // + // Move 'a' to before 'c': This operation is divided into two parts: + // Insert row orders: Insert a at position 2 (right before c). + // Delete row indexes: Delete a from its original position (index 0). + // The steps are: + // + // Insert row: After inserting a at position 2, the rows temporarily look like this: + // Insert row orders: [(a, 2)] + // State after insert: [a, b, a, c] + // Delete row: Next, we delete a from its original position at index 0. + // Delete row indexes: [0] + // Final state after delete: [b, a, c] + let row_changes = DashMap::new(); + // 1. handle insert row orders + for (row_order, index) in insert_row_orders { + let row = match database_editor.init_database_row(&row_order.id).await { + Ok(database_row) => database_row.read().await.get_row().map(Arc::new), + Err(err) => { + error!("Failed to init row: {:?}", err); + None + }, + }; + + if let Some(view_editor) = database_editor + .database_views + .get_view_editor(view_id) + .await + { + trace!( + "[RowOrder]: insert row:{} at index:{}, is_local:{}", + row_order.id, + index, + is_local_change + ); + + // insert row order in database view cache + view_editor.insert_row(row.clone(), index, &row_order).await; + + let is_move_row = is_move_row(&view_editor, &row_order, &delete_row_indexes).await; + if let Some((index, row_detail)) = view_editor.v_get_row(&row_order.id).await { + view_editor + .v_did_create_row( + &row_detail, + index as u32, + is_move_row, + is_local_change, + &row_changes, + ) + .await; + } + } + } + + // handle delete row orders + for index in delete_row_indexes { + let index = index as usize; + if let Some(view_editor) = database_editor + .database_views + .get_view_editor(view_id) + .await + { + let mut view_row_orders = view_editor.row_orders.write().await; + if view_row_orders.len() > index { + let lazy_row = view_row_orders.remove(index); + // Update changeset in RowsChangePB + let row_id = lazy_row.id.to_string(); + let mut row_change = row_changes.entry(view_editor.view_id.clone()).or_default(); + row_change.deleted_rows.push(row_id); + + // notify the view + if let Some(row) = view_editor.row_by_row_id.get(lazy_row.id.as_str()) { + trace!( + "[RowOrder]: delete row:{} at index:{}, is_move_row: {}, is_local:{}", + row.id, + index, + row_change.is_move_row, + is_local_change + ); + view_editor + .v_did_delete_row(&row, row_change.is_move_row, is_local_change) + .await; + } else { + error!("[RowOrder]: row not found: {} in cache", lazy_row.id); + } } else { - break; + warn!( + "[RowOrder]: delete row at index:{} out of range:{}", + index, + view_row_orders.len() + ); } } - }); -} -#[allow(dead_code)] -pub(crate) async fn observe_field_change(database_id: &str, database: &Arc) { - let database_id = database_id.to_string(); - let weak_database = Arc::downgrade(database); - let mut field_change = database.lock().subscribe_field_change(); - af_spawn(async move { - while let Ok(field_change) = field_change.recv().await { - if weak_database.upgrade().is_none() { - break; - } + } - trace!( - "[Database Observe]: {} field change:{:?}", - database_id, - field_change - ); - match field_change { - FieldChange::DidUpdateField { .. } => {}, - FieldChange::DidCreateField { .. } => {}, - FieldChange::DidDeleteField { .. } => {}, - } - } - }); + // 3. notify the view + for entry in row_changes.into_iter() { + let (view_id, changes) = entry; + trace!("[RowOrder]: {}", changes); + database_notification_builder(&view_id, DatabaseNotification::DidUpdateRow) + .payload(changes) + .send(); + } } -#[allow(dead_code)] -pub(crate) async fn observe_view_change(database_id: &str, database: &Arc) { +pub(crate) async fn observe_block_event(database_id: &Uuid, database_editor: &Arc) { let database_id = database_id.to_string(); - let weak_database = Arc::downgrade(database); - let mut view_change = database.lock().subscribe_view_change(); - af_spawn(async move { - while let Ok(view_change) = view_change.recv().await { - if weak_database.upgrade().is_none() { - break; - } - - trace!( - "[Database Observe]: {} view change:{:?}", - database_id, - view_change - ); - match view_change { - DatabaseViewChange::DidCreateView { .. } => {}, - DatabaseViewChange::DidUpdateView { .. } => {}, - DatabaseViewChange::DidDeleteView { .. } => {}, - DatabaseViewChange::LayoutSettingChanged { .. } => {}, - DatabaseViewChange::DidInsertRowOrders { .. } => {}, - DatabaseViewChange::DidDeleteRowAtIndex { .. } => {}, - DatabaseViewChange::DidCreateFilters { .. } => {}, - DatabaseViewChange::DidUpdateFilter { .. } => {}, - DatabaseViewChange::DidCreateGroupSettings { .. } => {}, - DatabaseViewChange::DidUpdateGroupSetting { .. } => {}, - DatabaseViewChange::DidCreateSorts { .. } => {}, - DatabaseViewChange::DidUpdateSort { .. } => {}, - DatabaseViewChange::DidCreateFieldOrder { .. } => {}, - DatabaseViewChange::DidDeleteFieldOrder { .. } => {}, - } - } - }); -} - -#[allow(dead_code)] -pub(crate) async fn observe_block_event(database_id: &str, database: &Arc) { - let database_id = database_id.to_string(); - let weak_database = Arc::downgrade(database); - let mut block_event_rx = database.lock().subscribe_block_event(); - af_spawn(async move { + let mut block_event_rx = database_editor + .database + .read() + .await + .subscribe_block_event(); + let database_editor = Arc::downgrade(database_editor); + tokio::spawn(async move { while let Ok(event) = block_event_rx.recv().await { - if weak_database.upgrade().is_none() { + if database_editor.upgrade().is_none() { break; } @@ -157,7 +315,7 @@ pub(crate) async fn observe_block_event(database_id: &str, database: &Arc, view_id: &str, @@ -195,3 +352,26 @@ fn notify_cell(notification_sender: &Arc, cell_id: & .build(); notification_sender.send_subject(subject); } + +async fn is_move_row( + database_view: &Arc, + insert_row_order: &RowOrder, + delete_row_indexes: &[u32], +) -> bool { + let mut is_move_row = false; + for index in delete_row_indexes.iter() { + is_move_row = database_view + .row_orders + .read() + .await + .get(*index as usize) + .map(|deleted_row_order| deleted_row_order == insert_row_order) + .unwrap_or(false); + + if is_move_row { + break; + } + } + + is_move_row +} diff --git a/frontend/rust-lib/flowy-database2/src/services/database/util.rs b/frontend/rust-lib/flowy-database2/src/services/database/util.rs index 4a3810b198..2c6b012747 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/util.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/util.rs @@ -1,5 +1,3 @@ -use collab_database::views::{DatabaseLayout, DatabaseView}; - use crate::entities::{ DatabaseLayoutPB, DatabaseLayoutSettingPB, DatabaseViewSettingPB, FieldSettingsPB, FilterPB, GroupSettingPB, SortPB, @@ -8,6 +6,9 @@ use crate::services::field_settings::FieldSettings; use crate::services::filter::Filter; use crate::services::group::GroupSetting; use crate::services::sort::Sort; +use collab_database::entity::DatabaseView; +use collab_database::views::DatabaseLayout; +use tracing::error; pub(crate) fn database_view_setting_pb_from_view(view: DatabaseView) -> DatabaseViewSettingPB { let layout_type: DatabaseLayoutPB = view.layout.into(); @@ -32,7 +33,10 @@ pub(crate) fn database_view_setting_pb_from_view(view: DatabaseView) -> Database .into_iter() .flat_map(|value| match Filter::try_from(value) { Ok(filter) => Some(FilterPB::from(&filter)), - Err(_) => None, + Err(err) => { + error!("Error converting filter: {:?}", err); + None + }, }) .collect::>(); @@ -41,7 +45,10 @@ pub(crate) fn database_view_setting_pb_from_view(view: DatabaseView) -> Database .into_iter() .flat_map(|value| match GroupSetting::try_from(value) { Ok(setting) => Some(GroupSettingPB::from(&setting)), - Err(_) => None, + Err(err) => { + error!("Error converting group setting: {:?}", err); + None + }, }) .collect::>(); @@ -50,7 +57,10 @@ pub(crate) fn database_view_setting_pb_from_view(view: DatabaseView) -> Database .into_iter() .flat_map(|value| match Sort::try_from(value) { Ok(sort) => Some(SortPB::from(&sort)), - Err(_) => None, + Err(err) => { + error!("Error converting sort: {:?}", err); + None + }, }) .collect::>(); diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/layout_deps.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/layout_deps.rs index 5d5e3b4c3f..9316726663 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/layout_deps.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/layout_deps.rs @@ -1,67 +1,87 @@ -use collab_database::database::{gen_field_id, MutexDatabase}; -use collab_database::fields::Field; -use collab_database::views::{DatabaseLayout, LayoutSetting, OrderObjectPosition}; -use std::sync::Arc; - use crate::entities::FieldType; -use crate::services::field::{DateTypeOption, SingleSelectTypeOption}; use crate::services::field_settings::default_field_settings_by_layout_map; use crate::services::setting::{BoardLayoutSetting, CalendarLayoutSetting}; +use collab::lock::RwLock; +use collab_database::database::{gen_field_id, Database}; +use collab_database::fields::date_type_option::DateTypeOption; +use collab_database::fields::select_type_option::SingleSelectTypeOption; +use collab_database::fields::Field; +use collab_database::views::{ + DatabaseLayout, FieldSettingsByFieldIdMap, LayoutSetting, OrderObjectPosition, +}; +use std::sync::Arc; /// When creating a database, we need to resolve the dependencies of the views. /// Different database views have different dependencies. For example, a board /// view depends on a field that can be used to group rows while a calendar view /// depends on a date field. pub struct DatabaseLayoutDepsResolver { - pub database: Arc, + pub database: Arc>, /// The new database layout. pub database_layout: DatabaseLayout, } impl DatabaseLayoutDepsResolver { - pub fn new(database: Arc, database_layout: DatabaseLayout) -> Self { + pub fn new(database: Arc>, database_layout: DatabaseLayout) -> Self { Self { database, database_layout, } } - pub fn resolve_deps_when_create_database_linked_view( + pub async fn resolve_deps_when_create_database_linked_view( &self, - ) -> (Option, Option) { + view_id: &str, + ) -> ( + Option, + Option, + Option, + ) { match self.database_layout { - DatabaseLayout::Grid => (None, None), + DatabaseLayout::Grid => (None, None, None), DatabaseLayout::Board => { let layout_settings = BoardLayoutSetting::new().into(); - if !self - .database - .lock() + + let database = self.database.read().await; + let field = if !database .get_fields(None) .into_iter() .any(|field| FieldType::from(field.field_type).can_be_group()) { - let select_field = self.create_select_field(); - (Some(select_field), Some(layout_settings)) + Some(self.create_select_field()) } else { - (None, Some(layout_settings)) - } + None + }; + + let field_settings_map = database.get_field_settings(view_id, None); + tracing::info!( + "resolve_deps_when_create_database_linked_view {:?}", + field_settings_map + ); + + ( + field, + Some(layout_settings), + Some(field_settings_map.into()), + ) }, DatabaseLayout::Calendar => { match self .database - .lock() + .read() + .await .get_fields(None) .into_iter() .find(|field| FieldType::from(field.field_type) == FieldType::DateTime) { Some(field) => { let layout_setting = CalendarLayoutSetting::new(field.id).into(); - (None, Some(layout_setting)) + (None, Some(layout_setting), None) }, None => { let date_field = self.create_date_field(); let layout_setting = CalendarLayoutSetting::new(date_field.clone().id).into(); - (Some(date_field), Some(layout_setting)) + (Some(date_field), Some(layout_setting), None) }, } }, @@ -70,13 +90,20 @@ impl DatabaseLayoutDepsResolver { /// If the new layout type is a calendar and there is not date field in the database, it will add /// a new date field to the database and create the corresponding layout setting. - pub fn resolve_deps_when_update_layout_type(&self, view_id: &str) { - let fields = self.database.lock().get_fields(None); + pub async fn resolve_deps_when_update_layout_type(&self, view_id: &str) { + let mut database = self.database.write().await; + let fields = database.get_fields(None); // Insert the layout setting if it's not exist match &self.database_layout { DatabaseLayout::Grid => {}, DatabaseLayout::Board => { - self.create_board_layout_setting_if_need(view_id); + if database + .get_layout_setting::(view_id, &self.database_layout) + .is_none() + { + let layout_setting = BoardLayoutSetting::new(); + database.insert_layout_setting(view_id, &self.database_layout, layout_setting); + } }, DatabaseLayout::Calendar => { let date_field_id = match fields @@ -87,7 +114,7 @@ impl DatabaseLayoutDepsResolver { tracing::trace!("Create a new date field after layout type change"); let field = self.create_date_field(); let field_id = field.id.clone(); - self.database.lock().create_field( + database.create_field( None, field, &OrderObjectPosition::End, @@ -97,41 +124,17 @@ impl DatabaseLayoutDepsResolver { }, Some(date_field) => date_field.id, }; - self.create_calendar_layout_setting_if_need(view_id, &date_field_id); + if database + .get_layout_setting::(view_id, &self.database_layout) + .is_none() + { + let layout_setting = CalendarLayoutSetting::new(date_field_id); + database.insert_layout_setting(view_id, &self.database_layout, layout_setting); + } }, } } - fn create_board_layout_setting_if_need(&self, view_id: &str) { - if self - .database - .lock() - .get_layout_setting::(view_id, &self.database_layout) - .is_none() - { - let layout_setting = BoardLayoutSetting::new(); - self - .database - .lock() - .insert_layout_setting(view_id, &self.database_layout, layout_setting); - } - } - - fn create_calendar_layout_setting_if_need(&self, view_id: &str, field_id: &str) { - if self - .database - .lock() - .get_layout_setting::(view_id, &self.database_layout) - .is_none() - { - let layout_setting = CalendarLayoutSetting::new(field_id.to_string()); - self - .database - .lock() - .insert_layout_setting(view_id, &self.database_layout, layout_setting); - } - } - fn create_date_field(&self) -> Field { let field_type = FieldType::DateTime; let default_date_type_option = DateTypeOption::default(); @@ -148,27 +151,3 @@ impl DatabaseLayoutDepsResolver { .with_type_option_data(field_type, default_select_type_option.into()) } } - -// pub async fn v_get_layout_settings(&self, layout_ty: &DatabaseLayout) -> LayoutSettingParams { -// let mut layout_setting = LayoutSettingParams::default(); -// match layout_ty { -// DatabaseLayout::Grid => {}, -// DatabaseLayout::Board => {}, -// DatabaseLayout::Calendar => { -// if let Some(value) = self.delegate.get_layout_setting(&self.view_id, layout_ty) { -// let calendar_setting = CalendarLayoutSetting::from(value); -// // Check the field exist or not -// if let Some(field) = self.delegate.get_field(&calendar_setting.field_id).await { -// let field_type = FieldType::from(field.field_type); -// -// // Check the type of field is Datetime or not -// if field_type == FieldType::DateTime { -// layout_setting.calendar = Some(calendar_setting); -// } -// } -// } -// }, -// } -// -// layout_setting -// } diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/notifier.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/notifier.rs index 99c3efa45d..a3a5e6f484 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/notifier.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/notifier.rs @@ -1,22 +1,21 @@ #![allow(clippy::while_let_loop)] use crate::entities::{ CalculationChangesetNotificationPB, DatabaseViewSettingPB, FilterChangesetNotificationPB, - GroupChangesPB, GroupRowsNotificationPB, InsertedRowPB, ReorderAllRowsPB, ReorderSingleRowPB, - RowMetaPB, RowsChangePB, RowsVisibilityChangePB, SortChangesetNotificationPB, + GroupChangesPB, GroupRowsNotificationPB, ReorderAllRowsPB, ReorderSingleRowPB, + RowsVisibilityChangePB, SortChangesetNotificationPB, }; -use crate::notification::{send_notification, DatabaseNotification}; +use crate::notification::{database_notification_builder, DatabaseNotification}; use crate::services::filter::FilterResultNotification; -use crate::services::sort::{InsertRowResult, ReorderAllRowsResult, ReorderSingleRowResult}; +use crate::services::sort::{ReorderAllRowsResult, ReorderSingleRowResult}; use async_stream::stream; use futures::stream::StreamExt; use tokio::sync::broadcast; -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum DatabaseViewChanged { FilterNotification(FilterResultNotification), ReorderAllRowsNotification(ReorderAllRowsResult), ReorderSingleRowNotification(ReorderSingleRowResult), - InsertRowNotification(InsertRowResult), CalculationValueNotification(CalculationChangesetNotificationPB), } @@ -51,7 +50,7 @@ impl DatabaseViewChangedReceiverRunner { .collect(), }; - send_notification( + database_notification_builder( &changeset.view_id, DatabaseNotification::DidUpdateViewRowsVisibility, ) @@ -62,9 +61,12 @@ impl DatabaseViewChangedReceiverRunner { let row_orders = ReorderAllRowsPB { row_orders: notification.row_orders, }; - send_notification(¬ification.view_id, DatabaseNotification::DidReorderRows) - .payload(row_orders) - .send() + database_notification_builder( + ¬ification.view_id, + DatabaseNotification::DidReorderRows, + ) + .payload(row_orders) + .send() }, DatabaseViewChanged::ReorderSingleRowNotification(notification) => { let reorder_row = ReorderSingleRowPB { @@ -72,30 +74,21 @@ impl DatabaseViewChangedReceiverRunner { old_index: notification.old_index as i32, new_index: notification.new_index as i32, }; - send_notification( + database_notification_builder( ¬ification.view_id, DatabaseNotification::DidReorderSingleRow, ) .payload(reorder_row) .send() }, - DatabaseViewChanged::InsertRowNotification(result) => { - let inserted_row = InsertedRowPB { - row_meta: RowMetaPB::from(result.row), - index: Some(result.index as i32), - is_new: true, - }; - let changes = RowsChangePB::from_insert(inserted_row); - send_notification(&result.view_id, DatabaseNotification::DidUpdateRow) - .payload(changes) - .send(); + DatabaseViewChanged::CalculationValueNotification(notification) => { + database_notification_builder( + ¬ification.view_id, + DatabaseNotification::DidUpdateCalculation, + ) + .payload(notification) + .send() }, - DatabaseViewChanged::CalculationValueNotification(notification) => send_notification( - ¬ification.view_id, - DatabaseNotification::DidUpdateCalculation, - ) - .payload(notification) - .send(), } }) .await; @@ -103,19 +96,19 @@ impl DatabaseViewChangedReceiverRunner { } pub async fn notify_did_update_group_rows(payload: GroupRowsNotificationPB) { - send_notification(&payload.group_id, DatabaseNotification::DidUpdateGroupRow) + database_notification_builder(&payload.group_id, DatabaseNotification::DidUpdateGroupRow) .payload(payload) .send(); } pub async fn notify_did_update_filter(notification: FilterChangesetNotificationPB) { - send_notification(¬ification.view_id, DatabaseNotification::DidUpdateFilter) + database_notification_builder(¬ification.view_id, DatabaseNotification::DidUpdateFilter) .payload(notification) .send(); } pub async fn notify_did_update_calculation(notification: CalculationChangesetNotificationPB) { - send_notification( + database_notification_builder( ¬ification.view_id, DatabaseNotification::DidUpdateCalculation, ) @@ -125,20 +118,20 @@ pub async fn notify_did_update_calculation(notification: CalculationChangesetNot pub async fn notify_did_update_sort(notification: SortChangesetNotificationPB) { if !notification.is_empty() { - send_notification(¬ification.view_id, DatabaseNotification::DidUpdateSort) + database_notification_builder(¬ification.view_id, DatabaseNotification::DidUpdateSort) .payload(notification) .send(); } } pub(crate) async fn notify_did_update_num_of_groups(view_id: &str, changeset: GroupChangesPB) { - send_notification(view_id, DatabaseNotification::DidUpdateNumOfGroups) + database_notification_builder(view_id, DatabaseNotification::DidUpdateNumOfGroups) .payload(changeset) .send(); } pub(crate) async fn notify_did_update_setting(view_id: &str, setting: DatabaseViewSettingPB) { - send_notification(view_id, DatabaseNotification::DidUpdateSettings) + database_notification_builder(view_id, DatabaseNotification::DidUpdateSettings) .payload(setting) .send(); } diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_calculations.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_calculations.rs index 32ddecc667..301be9fbe9 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/view_calculations.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_calculations.rs @@ -1,8 +1,8 @@ +use async_trait::async_trait; use collab_database::fields::Field; use std::sync::Arc; -use collab_database::rows::RowCell; -use lib_infra::future::{to_fut, Fut}; +use collab_database::rows::Cell; use crate::services::calculations::{ Calculation, CalculationsController, CalculationsDelegate, CalculationsTaskHandler, @@ -17,7 +17,7 @@ pub async fn make_calculations_controller( delegate: Arc, notifier: DatabaseViewChangedNotifier, ) -> Arc { - let calculations = delegate.get_all_calculations(view_id); + let calculations = delegate.get_all_calculations(view_id).await; let task_scheduler = delegate.get_task_scheduler(); let calculations_delegate = DatabaseViewCalculationsDelegateImpl(delegate.clone()); let handler_id = gen_handler_id(); @@ -29,8 +29,7 @@ pub async fn make_calculations_controller( calculations, task_scheduler.clone(), notifier, - ) - .await; + ); let calculations_controller = Arc::new(calculations_controller); task_scheduler @@ -45,30 +44,39 @@ pub async fn make_calculations_controller( struct DatabaseViewCalculationsDelegateImpl(Arc); +#[async_trait] impl CalculationsDelegate for DatabaseViewCalculationsDelegateImpl { - fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Fut>> { - self.0.get_cells_for_field(view_id, field_id) + async fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Vec> { + self + .0 + .get_cells_for_field(view_id, field_id) + .await + .into_iter() + .filter_map(|row_cell| row_cell.cell.map(Arc::new)) + .collect() } - fn get_field(&self, field_id: &str) -> Option { - self.0.get_field(field_id) + async fn get_field(&self, field_id: &str) -> Option { + self.0.get_field(field_id).await } - fn get_calculation(&self, view_id: &str, field_id: &str) -> Fut>> { - let calculation = self.0.get_calculation(view_id, field_id).map(Arc::new); - to_fut(async move { calculation }) + async fn get_calculation(&self, view_id: &str, field_id: &str) -> Option> { + self + .0 + .get_calculation(view_id, field_id) + .await + .map(Arc::new) } - fn update_calculation(&self, view_id: &str, calculation: Calculation) { - self.0.update_calculation(view_id, calculation) + async fn update_calculation(&self, view_id: &str, calculation: Calculation) { + self.0.update_calculation(view_id, calculation).await } - fn remove_calculation(&self, view_id: &str, calculation_id: &str) { - self.0.remove_calculation(view_id, calculation_id) + async fn remove_calculation(&self, view_id: &str, calculation_id: &str) { + self.0.remove_calculation(view_id, calculation_id).await } - fn get_all_calculations(&self, view_id: &str) -> Fut>>> { - let calculations = Arc::new(self.0.get_all_calculations(view_id)); - to_fut(async move { calculations }) + async fn get_all_calculations(&self, view_id: &str) -> Vec> { + self.0.get_all_calculations(view_id).await } } diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_editor.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_editor.rs index 0e2d886655..8b9b7032c7 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/view_editor.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_editor.rs @@ -2,32 +2,21 @@ use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; -use collab_database::database::{gen_database_calculation_id, gen_database_sort_id, gen_row_id}; -use collab_database::fields::Field; -use collab_database::rows::{Cells, Row, RowDetail, RowId}; -use collab_database::views::{DatabaseLayout, DatabaseView}; -use lib_infra::util::timestamp; -use tokio::sync::{broadcast, RwLock}; -use tracing::instrument; - -use flowy_error::{FlowyError, FlowyResult}; -use lib_dispatch::prelude::af_spawn; - +use super::{notify_did_update_calculation, DatabaseViewChanged}; use crate::entities::{ - CalendarEventPB, CreateRowParams, CreateRowPayloadPB, DatabaseLayoutMetaPB, + CalculationChangesetNotificationPB, CalendarEventPB, CreateRowPayloadPB, DatabaseLayoutMetaPB, DatabaseLayoutSettingPB, DeleteSortPayloadPB, FieldSettingsChangesetPB, FieldType, - GroupChangesPB, GroupPB, LayoutSettingChangeset, LayoutSettingParams, + GroupChangesPB, GroupPB, InsertedRowPB, LayoutSettingChangeset, LayoutSettingParams, RemoveCalculationChangesetPB, ReorderSortPayloadPB, RowMetaPB, RowsChangePB, SortChangesetNotificationPB, SortPB, UpdateCalculationChangesetPB, UpdateSortPayloadPB, }; -use crate::notification::{send_notification, DatabaseNotification}; +use crate::notification::{database_notification_builder, DatabaseNotification}; use crate::services::calculations::{Calculation, CalculationChangeset, CalculationsController}; use crate::services::cell::{CellBuilder, CellCache}; use crate::services::database::{database_view_setting_pb_from_view, DatabaseRowEvent, UpdatedRow}; +use crate::services::database_view::view_calculations::make_calculations_controller; use crate::services::database_view::view_filter::make_filter_controller; -use crate::services::database_view::view_group::{ - get_cell_for_row, get_cells_for_field, new_group_controller, -}; +use crate::services::database_view::view_group::{get_cell_for_row, new_group_controller}; use crate::services::database_view::view_operation::DatabaseViewOperation; use crate::services::database_view::view_sort::make_sort_controller; use crate::services::database_view::{ @@ -37,12 +26,22 @@ use crate::services::database_view::{ }; use crate::services::field_settings::FieldSettings; use crate::services::filter::{Filter, FilterChangeset, FilterController}; -use crate::services::group::{GroupChangeset, GroupController, MoveGroupRowContext, RowChangeset}; +use crate::services::group::{ + DidMoveGroupRowResult, GroupChangeset, GroupController, MoveGroupRowContext, UpdatedCells, +}; use crate::services::setting::CalendarLayoutSetting; use crate::services::sort::{Sort, SortChangeset, SortController}; +use collab_database::database::{gen_database_calculation_id, gen_database_sort_id, gen_row_id}; +use collab_database::entity::DatabaseView; +use collab_database::fields::Field; +use collab_database::rows::{Cell, Cells, CreateRowParams, Row, RowCell, RowDetail, RowId}; +use collab_database::views::{DatabaseLayout, RowOrder}; +use dashmap::DashMap; +use flowy_error::{FlowyError, FlowyResult}; -use super::notify_did_update_calculation; -use super::view_calculations::make_calculations_controller; +use lib_infra::util::timestamp; +use tokio::sync::{broadcast, RwLock}; +use tracing::{error, instrument, trace, warn}; pub struct DatabaseViewEditor { database_id: String, @@ -52,6 +51,13 @@ pub struct DatabaseViewEditor { filter_controller: Arc, sort_controller: Arc>, calculations_controller: Arc, + /// Use lazy_rows as cache that represents the row's order for given view + /// It can't get the row id when deleting a row. it only returns the deleted index. + /// So using this cache to get the row id by index + /// + /// Check out this link (https://github.com/y-crdt/y-crdt/issues/341) for more information. + pub(crate) row_orders: RwLock>, + pub(crate) row_by_row_id: DashMap>, pub notifier: DatabaseViewChangedNotifier, } @@ -62,6 +68,14 @@ impl Drop for DatabaseViewEditor { } impl DatabaseViewEditor { + /// Create a new Database View Editor. + /// + /// After creating the editor, you must call [DatabaseViewEditor::initialize] to properly initialize it. + /// This initialization step will load essential data, such as group information. + /// + /// Avoid calling any methods of [DatabaseViewOperation] before the editor is fully initialized, + /// as some actions may rely on the current editor state. Failing to follow this order could result + /// in unexpected behavior, including potential deadlocks. pub async fn new( database_id: String, view_id: String, @@ -69,7 +83,7 @@ impl DatabaseViewEditor { cell_cache: CellCache, ) -> FlowyResult { let (notifier, _) = broadcast::channel(100); - af_spawn(DatabaseViewChangedReceiverRunner(Some(notifier.subscribe())).run()); + tokio::spawn(DatabaseViewChangedReceiverRunner(Some(notifier.subscribe())).run()); // Filter let filter_controller = make_filter_controller( @@ -113,16 +127,61 @@ impl DatabaseViewEditor { filter_controller, sort_controller, calculations_controller, + row_orders: Default::default(), + row_by_row_id: Default::default(), notifier, }) } + /// Initialize the editor after creating it + /// You should call [DatabaseViewEditor::initialize] after creating the editor + pub async fn initialize(&self) -> FlowyResult<()> { + if let Some(group) = self.group_controller.write().await.as_mut() { + group.load_group_data().await?; + } + + Ok(()) + } + + pub async fn insert_row(&self, row: Option>, index: u32, row_order: &RowOrder) { + let mut row_orders = self.row_orders.write().await; + if row_orders.len() >= index as usize { + row_orders.insert(index as usize, row_order.clone()); + } else { + warn!( + "[RowOrder]: insert row at index:{} out of range:{}", + index, + row_orders.len() + ); + } + if let Some(row) = row { + self.row_by_row_id.insert(row.id.to_string(), row); + } + } + + pub async fn set_row_orders(&self, row_orders: Vec) { + *self.row_orders.write().await = row_orders; + } + + pub async fn get_all_row_orders(&self) -> FlowyResult> { + let row_orders = self.delegate.get_all_row_orders(&self.view_id).await; + Ok(row_orders) + } + pub async fn close(&self) { self.sort_controller.write().await.close().await; self.filter_controller.close().await; self.calculations_controller.close().await; } + pub async fn has_filters(&self) -> bool { + self.filter_controller.has_filters().await + } + + pub async fn has_sorts(&self) -> bool { + self.sort_controller.read().await.has_sorts().await + } + pub async fn v_get_view(&self) -> Option { self.delegate.get_view(&self.view_id).await } @@ -132,18 +191,16 @@ impl DatabaseViewEditor { params: CreateRowPayloadPB, ) -> FlowyResult { let timestamp = timestamp(); + trace!("[Database]: will create row at: {:?}", params.row_position); let mut result = CreateRowParams { - collab_params: collab_database::rows::CreateRowParams { - id: gen_row_id(), - database_id: self.database_id.clone(), - cells: Cells::new(), - height: 60, - visibility: true, - row_position: params.row_position.try_into()?, - created_at: timestamp, - modified_at: timestamp, - }, - open_after_create: false, + id: gen_row_id(), + database_id: self.database_id.clone(), + cells: Cells::new(), + height: 60, + visibility: true, + row_position: params.row_position.try_into()?, + created_at: timestamp, + modified_at: timestamp, }; // fill in cells from the frontend @@ -156,6 +213,7 @@ impl DatabaseViewEditor { let field = self .delegate .get_field(controller.get_grouping_field_id()) + .await .ok_or_else(|| FlowyError::internal().with_context("Failed to get grouping field"))?; controller.will_create_row(&mut cells, &field, &group_id); } @@ -165,72 +223,89 @@ impl DatabaseViewEditor { let filter_controller = self.filter_controller.clone(); filter_controller.fill_cells(&mut cells).await; - result.collab_params.cells = cells; - + result.cells = cells; Ok(result) } pub async fn v_did_update_row_meta(&self, row_id: &RowId, row_detail: &RowDetail) { - let update_row = UpdatedRow::new(row_id.as_str()).with_row_meta(row_detail.clone()); - let changeset = RowsChangePB::from_update(update_row.into()); - send_notification(&self.view_id, DatabaseNotification::DidUpdateRow) - .payload(changeset) - .send(); + let rows = vec![Arc::new(row_detail.row.clone())]; + let mut rows = self.v_filter_rows(rows).await; + if rows.pop().is_some() { + let update_row = UpdatedRow::new(row_id.as_str()).with_row_meta(row_detail.clone()); + let changeset = RowsChangePB::from_update(update_row.into()); + database_notification_builder(&self.view_id, DatabaseNotification::DidUpdateRow) + .payload(changeset) + .send(); + } } - pub async fn v_did_create_row(&self, row_detail: &RowDetail, index: usize) { + pub async fn v_did_create_row( + &self, + row_detail: &RowDetail, + index: u32, + is_move_row: bool, + is_local_change: bool, + row_changes: &DashMap, + ) { // Send the group notification if the current view has groups if let Some(controller) = self.group_controller.write().await.as_mut() { - let mut row_details = vec![Arc::new(row_detail.clone())]; - self.v_filter_rows(&mut row_details).await; - - if let Some(row_detail) = row_details.pop() { - let changesets = controller.did_create_row(&row_detail, index); - + let rows = vec![Arc::new(row_detail.row.clone())]; + let mut rows = self.v_filter_rows(rows).await; + if let Some(row) = rows.pop() { + let changesets = controller.did_create_row(&row, index as usize); for changeset in changesets { notify_did_update_group_rows(changeset).await; } } } + let index = self + .sort_controller + .write() + .await + .did_create_row(&row_detail.row) + .await; + + row_changes + .entry(self.view_id.clone()) + .or_insert_with(|| { + let mut change = RowsChangePB::new(); + change.is_move_row = is_move_row; + change + }) + .inserted_rows + .push(InsertedRowPB { + row_meta: RowMetaPB::from(row_detail), + index: index.map(|index| index as i32), + is_new: true, + is_hidden_in_view: is_local_change && index.is_none(), + }); + self - .gen_did_create_row_view_tasks(index, row_detail.clone()) + .gen_did_create_row_view_tasks(row_detail.row.clone()) .await; } #[tracing::instrument(level = "trace", skip_all)] - pub async fn v_did_delete_row(&self, row: &Row) { + pub async fn v_did_delete_row(&self, row: &Row, is_move_row: bool, is_local_change: bool) { let deleted_row = row.clone(); - // Send the group notification if the current view has groups; - let result = self - .mut_group_controller(|group_controller, _| group_controller.did_delete_row(row)) - .await; - - if let Some(result) = result { - tracing::trace!("Delete row in view changeset: {:?}", result); - for changeset in result.row_changesets { - notify_did_update_group_rows(changeset).await; - } - if let Some(deleted_group) = result.deleted_group { - let payload = GroupChangesPB { - view_id: self.view_id.clone(), - deleted_groups: vec![deleted_group.group_id], - ..Default::default() - }; - notify_did_update_num_of_groups(&self.view_id, payload).await; - } + // Only update group rows + // 1. when the row is deleted locally. If the row is moved, we don't need to send the group + // notification. Because it's handled by the move_group_row function + // 2. when the row is deleted remotely + if !is_move_row || !is_local_change { + // Send the group notification if the current view has groups; + let result = self + .mut_group_controller(|group_controller, _| group_controller.did_delete_row(row)) + .await; + handle_mut_group_result(&self.view_id, result).await; } - let changes = RowsChangePB::from_delete(row.id.clone().into_inner()); - - send_notification(&self.view_id, DatabaseNotification::DidUpdateRow) - .payload(changes) - .send(); // Updating calculations for each of the Rows cells is a tedious task // Therefore we spawn a separate task for this let weak_calculations_controller = Arc::downgrade(&self.calculations_controller); - af_spawn(async move { + tokio::spawn(async move { if let Some(calculations_controller) = weak_calculations_controller.upgrade() { calculations_controller .did_receive_row_changed(deleted_row) @@ -242,46 +317,56 @@ impl DatabaseViewEditor { /// Notify the view that the row has been updated. If the view has groups, /// send the group notification with [GroupRowsNotificationPB]. Otherwise, /// send the view notification with [RowsChangePB] - pub async fn v_did_update_row( - &self, - old_row: &Option, - row_detail: &RowDetail, - field_id: Option, - ) { + #[instrument(level = "trace", skip_all)] + pub async fn v_did_update_row(&self, old_row: &Option, row: &Row, field_id: Option) { if let Some(controller) = self.group_controller.write().await.as_mut() { - let field = self.delegate.get_field(controller.get_grouping_field_id()); + let field = self + .delegate + .get_field(controller.get_grouping_field_id()) + .await; if let Some(field) = field { - let mut row_details = vec![Arc::new(row_detail.clone())]; - self.v_filter_rows(&mut row_details).await; + let rows = vec![Arc::new(row.clone())]; + let mut rows = self.v_filter_rows(rows).await; - if let Some(row_detail) = row_details.pop() { - let result = controller.did_update_group_row(old_row, &row_detail, &field); + let mut group_changes = GroupChangesPB { + view_id: self.view_id.clone(), + ..Default::default() + }; - if let Ok(result) = result { - let mut group_changes = GroupChangesPB { - view_id: self.view_id.clone(), - ..Default::default() - }; - if let Some(inserted_group) = result.inserted_group { - tracing::trace!("Create group after editing the row: {:?}", inserted_group); - group_changes.inserted_groups.push(inserted_group); - } - if let Some(delete_group) = result.deleted_group { - tracing::trace!("Delete group after editing the row: {:?}", delete_group); - group_changes.deleted_groups.push(delete_group.group_id); - } + let (inserted_group, deleted_group, row_changesets) = if let Some(row) = rows.pop() { + if let Ok(result) = controller.did_update_group_row(old_row, &row, &field) { + ( + result.inserted_group, + result.deleted_group, + result.row_changesets, + ) + } else { + (None, None, vec![]) + } + } else if let Ok(result) = controller.did_delete_row(row) { + (None, result.deleted_group, result.row_changesets) + } else { + (None, None, vec![]) + }; - if !group_changes.is_empty() { - notify_did_update_num_of_groups(&self.view_id, group_changes).await; - } + if let Some(inserted_group) = inserted_group { + tracing::trace!("Create group after editing the row: {:?}", inserted_group); + group_changes.inserted_groups.push(inserted_group); + } + if let Some(delete_group) = deleted_group { + tracing::trace!("Delete group after editing the row: {:?}", delete_group); + group_changes.deleted_groups.push(delete_group.group_id); + } - for changeset in result.row_changesets { - if !changeset.is_empty() { - tracing::trace!("Group change after editing the row: {:?}", changeset); - notify_did_update_group_rows(changeset).await; - } - } + if !group_changes.is_empty() { + notify_did_update_num_of_groups(&self.view_id, group_changes).await; + } + + for changeset in row_changesets { + if !changeset.is_empty() { + tracing::trace!("Group change after editing the row: {:?}", changeset); + notify_did_update_group_rows(changeset).await; } } } @@ -289,46 +374,78 @@ impl DatabaseViewEditor { // Each row update will trigger a calculations, filter and sort operation. We don't want // to block the main thread, so we spawn a new task to do the work. - if let Some(field_id) = field_id { - self - .gen_did_update_row_view_tasks(row_detail.row.id.clone(), field_id) - .await; - } + self + .gen_did_update_row_view_tasks(row.id.clone(), field_id) + .await; } - pub async fn v_filter_rows(&self, row_details: &mut Vec>) { - self.filter_controller.filter_rows(row_details).await + pub async fn v_filter_rows(&self, rows: Vec>) -> Vec> { + self.filter_controller.filter_rows(rows).await } - pub async fn v_sort_rows(&self, row_details: &mut Vec>) { + pub async fn v_filter_rows_and_notify(&self, rows: &mut Vec>) { + let _ = self.filter_controller.filter_rows_and_notify(rows).await; + } + + pub async fn v_sort_rows(&self, rows: &mut Vec>) { + self.sort_controller.write().await.sort_rows(rows).await + } + + pub async fn v_sort_rows_and_notify(&self, rows: &mut Vec>) { self .sort_controller .write() .await - .sort_rows(row_details) - .await + .sort_rows_and_notify(rows) + .await; } #[instrument(level = "info", skip(self))] - pub async fn v_get_rows(&self) -> Vec> { - let mut rows = self.delegate.get_rows(&self.view_id).await; - self.v_filter_rows(&mut rows).await; + pub async fn v_get_all_rows(&self) -> Vec> { + let row_orders = self.delegate.get_all_row_orders(&self.view_id).await; + let rows = self.delegate.get_all_rows(&self.view_id, row_orders).await; + let mut rows = self.v_filter_rows(rows).await; self.v_sort_rows(&mut rows).await; rows } + pub async fn v_get_cells_for_field(&self, field_id: &str) -> Vec { + let row_orders = self.delegate.get_all_row_orders(&self.view_id).await; + let rows = self.delegate.get_all_rows(&self.view_id, row_orders).await; + let rows = self.v_filter_rows(rows).await; + let rows = rows + .into_iter() + .filter_map(|row| { + row + .cells + .get(field_id) + .map(|cell| RowCell::new(row.id.clone(), Some(cell.clone()))) + }) + .collect::>(); + trace!( + "[Database]: get cells for field: {}, total rows:{}", + field_id, + rows.len() + ); + rows + } + + pub async fn v_get_row(&self, row_id: &RowId) -> Option<(usize, Arc)> { + self.delegate.get_row_detail(&self.view_id, row_id).await + } + pub async fn v_move_group_row( &self, - row_detail: &RowDetail, - row_changeset: &mut RowChangeset, + row: &Row, to_group_id: &str, to_row_id: Option, - ) { + ) -> UpdatedCells { + let mut updated_cells = UpdatedCells::new(); let result = self .mut_group_controller(|group_controller, field| { let move_row_context = MoveGroupRowContext { - row_detail, - row_changeset, + row, + updated_cells: &mut updated_cells, field: &field, to_group_id, to_row_id, @@ -337,21 +454,8 @@ impl DatabaseViewEditor { }) .await; - if let Some(result) = result { - if let Some(delete_group) = result.deleted_group { - tracing::trace!("Delete group after moving the row: {:?}", delete_group); - let changes = GroupChangesPB { - view_id: self.view_id.clone(), - deleted_groups: vec![delete_group.group_id], - ..Default::default() - }; - notify_did_update_num_of_groups(&self.view_id, changes).await; - } - - for changeset in result.row_changesets { - notify_did_update_group_rows(changeset).await; - } - } + handle_mut_group_result(&self.view_id, result).await; + updated_cells } /// Only call once after database view editor initialized @@ -401,23 +505,23 @@ impl DatabaseViewEditor { /// Called when the user changes the grouping field pub async fn v_initialize_new_group(&self, field_id: &str) -> FlowyResult<()> { - let is_grouping_field = self.is_grouping_field(field_id).await; - if !is_grouping_field { - self.v_group_by_field(field_id).await?; - - if let Some(view) = self.delegate.get_view(&self.view_id).await { - let setting = database_view_setting_pb_from_view(view); - notify_did_update_setting(&self.view_id, setting).await; - } + if let Some(view) = self.delegate.get_view(&self.view_id).await { + let setting = database_view_setting_pb_from_view(view); + notify_did_update_setting(&self.view_id, setting).await; } + + self.v_group_by_field(field_id).await?; Ok(()) } pub async fn v_create_group(&self, name: &str) -> FlowyResult<()> { let mut old_field: Option = None; let result = if let Some(controller) = self.group_controller.write().await.as_mut() { - let create_group_results = controller.create_group(name.to_string())?; - old_field = self.delegate.get_field(controller.get_grouping_field_id()); + let create_group_results = controller.create_group(name.to_string()).await?; + old_field = self + .delegate + .get_field(controller.get_grouping_field_id()) + .await; create_group_results } else { (None, None) @@ -450,20 +554,21 @@ impl DatabaseViewEditor { None => return Ok(RowsChangePB::default()), }; - let old_field = self.delegate.get_field(controller.get_grouping_field_id()); - let (row_ids, type_option_data) = controller.delete_group(group_id)?; + let old_field = self + .delegate + .get_field(controller.get_grouping_field_id()) + .await; + let (row_ids, type_option_data) = controller.delete_group(group_id).await?; drop(group_controller); let mut changes = RowsChangePB::default(); - if let Some(field) = old_field { - let deleted_rows = row_ids - .iter() - .filter_map(|row_id| self.delegate.remove_row(row_id)) - .map(|row| row.id.into_inner()); - - changes.deleted_rows.extend(deleted_rows); + for row_id in row_ids { + if let Some(row) = self.delegate.remove_row(&row_id).await { + changes.deleted_rows.push(row.id.into_inner()); + } + } if let Some(type_option) = type_option_data { self.delegate.update_field(type_option, field).await?; @@ -481,19 +586,23 @@ impl DatabaseViewEditor { pub async fn v_update_group(&self, changeset: Vec) -> FlowyResult<()> { let mut type_option_data = None; - let (old_field, updated_groups) = - if let Some(controller) = self.group_controller.write().await.as_mut() { - let old_field = self.delegate.get_field(controller.get_grouping_field_id()); - let (updated_groups, new_type_option) = controller.apply_group_changeset(&changeset)?; + let (old_field, updated_groups) = if let Some(controller) = + self.group_controller.write().await.as_mut() + { + let old_field = self + .delegate + .get_field(controller.get_grouping_field_id()) + .await; + let (updated_groups, new_type_option) = controller.apply_group_changeset(&changeset).await?; - if new_type_option.is_some() { - type_option_data = new_type_option; - } + if new_type_option.is_some() { + type_option_data = new_type_option; + } - (old_field, updated_groups) - } else { - (None, vec![]) - }; + (old_field, updated_groups) + } else { + (None, vec![]) + }; if let Some(old_field) = old_field { if let Some(type_option_data) = type_option_data { @@ -514,7 +623,7 @@ impl DatabaseViewEditor { } pub async fn v_get_all_sorts(&self) -> Vec { - self.delegate.get_all_sorts(&self.view_id) + self.delegate.get_all_sorts(&self.view_id).await } #[tracing::instrument(level = "trace", skip(self), err)] @@ -531,10 +640,8 @@ impl DatabaseViewEditor { condition: params.condition.into(), }; - self.delegate.insert_sort(&self.view_id, sort.clone()); - + self.delegate.insert_sort(&self.view_id, sort.clone()).await; let mut sort_controller = self.sort_controller.write().await; - let notification = if is_exist { sort_controller .apply_changeset(SortChangeset::from_update(sort.clone())) @@ -552,7 +659,8 @@ impl DatabaseViewEditor { pub async fn v_reorder_sort(&self, params: ReorderSortPayloadPB) -> FlowyResult<()> { self .delegate - .move_sort(&self.view_id, ¶ms.from_sort_id, ¶ms.to_sort_id); + .move_sort(&self.view_id, ¶ms.from_sort_id, ¶ms.to_sort_id) + .await; let notification = self .sort_controller @@ -576,17 +684,110 @@ impl DatabaseViewEditor { .apply_changeset(SortChangeset::from_delete(params.sort_id.clone())) .await; - self.delegate.remove_sort(&self.view_id, ¶ms.sort_id); + self + .delegate + .remove_sort(&self.view_id, ¶ms.sort_id) + .await; notify_did_update_sort(notification).await; Ok(()) } + pub async fn v_update_calculate(&self, field_id: &str) -> Option<()> { + let field = self.delegate.get_field(field_id).await?; + let cal = self + .delegate + .get_calculation(&self.view_id, &field.id) + .await?; + + let cells = self + .delegate + .get_cells_for_field(&self.view_id, field_id) + .await + .into_iter() + .flat_map(|row_cell| row_cell.cell.map(Arc::new)) + .collect::>(); + + let changes = self + .calculations_controller + .handle_cells_changed(&field, &cal, cells) + .await; + + if !changes.is_empty() { + let notification = CalculationChangesetNotificationPB::from_update(&self.view_id, changes); + if let Err(_err) = self + .notifier + .send(DatabaseViewChanged::CalculationValueNotification( + notification, + )) + { + error!("Failed to send CalculationValueNotification"); + } + } + + None + } + + pub async fn v_calculate_rows(&self, fields: Vec, rows: Vec>) -> FlowyResult<()> { + let mut updates = vec![]; + // Filter fields to only those with calculations + let fields_with_calculations: Vec<(&Field, Calculation)> = + futures::future::join_all(fields.iter().map(|field| async move { + self + .delegate + .get_calculation(&self.view_id, &field.id) + .await + .map(|cal| (field, cal)) + })) + .await + .into_iter() + .flatten() + .collect(); + + // Pre-compute cells by field ID only for fields that have calculations + let mut cells_by_field_id: HashMap>> = fields_with_calculations + .iter() + .map(|(field, _)| { + let cells = rows + .iter() + .filter_map(|row| row.cells.get(&field.id).cloned().map(Arc::new)) + .collect::>>(); + (field.id.clone(), cells) + }) + .collect(); + + // Perform calculations for the filtered fields + for (field, calculation) in fields_with_calculations { + if let Some(cells) = cells_by_field_id.remove(&field.id) { + let changes = self + .calculations_controller + .handle_cells_changed(field, &calculation, cells) + .await; + updates.extend(changes); + } + } + + // Send notification if updates were made + if !updates.is_empty() { + let notification = CalculationChangesetNotificationPB::from_update(&self.view_id, updates); + if let Err(_err) = self + .notifier + .send(DatabaseViewChanged::CalculationValueNotification( + notification, + )) + { + error!("Failed to send CalculationValueNotification"); + } + } + + Ok(()) + } + pub async fn v_delete_all_sorts(&self) -> FlowyResult<()> { let all_sorts = self.v_get_all_sorts().await; self.sort_controller.write().await.delete_all_sorts().await; - self.delegate.remove_all_sorts(&self.view_id); + self.delegate.remove_all_sorts(&self.view_id).await; let mut notification = SortChangesetNotificationPB::new(self.view_id.clone()); notification.delete_sorts = all_sorts.into_iter().map(SortPB::from).collect(); notify_did_update_sort(notification).await; @@ -594,18 +795,16 @@ impl DatabaseViewEditor { } pub async fn v_get_all_calculations(&self) -> Vec> { - self.delegate.get_all_calculations(&self.view_id) + self.delegate.get_all_calculations(&self.view_id).await } pub async fn v_update_calculations( &self, params: UpdateCalculationChangesetPB, ) -> FlowyResult<()> { - let calculation_id = match params.calculation_id { - None => gen_database_calculation_id(), - Some(calculation_id) => calculation_id, - }; - + let calculation_id = params + .calculation_id + .unwrap_or_else(gen_database_calculation_id); let calculation = Calculation::none( calculation_id, params.field_id, @@ -623,7 +822,8 @@ impl DatabaseViewEditor { let calculation: Calculation = Calculation::from(&insert); self .delegate - .update_calculation(¶ms.view_id, calculation); + .update_calculation(¶ms.view_id, calculation) + .await; } } @@ -639,7 +839,8 @@ impl DatabaseViewEditor { ) -> FlowyResult<()> { self .delegate - .remove_calculation(¶ms.view_id, ¶ms.calculation_id); + .remove_calculation(¶ms.view_id, ¶ms.calculation_id) + .await; let calculation = Calculation::none(params.calculation_id, params.field_id, None); @@ -656,17 +857,16 @@ impl DatabaseViewEditor { } pub async fn v_get_all_filters(&self) -> Vec { - self.delegate.get_all_filters(&self.view_id) + self.delegate.get_all_filters(&self.view_id).await } pub async fn v_get_filter(&self, filter_id: &str) -> Option { - self.delegate.get_filter(&self.view_id, filter_id) + self.delegate.get_filter(&self.view_id, filter_id).await } #[tracing::instrument(level = "trace", skip(self), err)] pub async fn v_modify_filters(&self, changeset: FilterChangeset) -> FlowyResult<()> { let notification = self.filter_controller.apply_changeset(changeset).await; - notify_did_update_filter(notification).await; let group_controller_read_guard = self.group_controller.read().await; @@ -679,6 +879,10 @@ impl DatabaseViewEditor { self.v_group_by_field(&field_id).await?; } + let row_orders = self.delegate.get_all_row_orders(&self.view_id).await; + let rows = self.delegate.get_all_rows(&self.view_id, row_orders).await; + let fields = self.delegate.get_fields(&self.view_id, None).await; + self.v_calculate_rows(fields, rows).await?; Ok(()) } @@ -689,15 +893,23 @@ impl DatabaseViewEditor { match layout_ty { DatabaseLayout::Grid => {}, DatabaseLayout::Board => { - if let Some(value) = self.delegate.get_layout_setting(&self.view_id, layout_ty) { + if let Some(value) = self + .delegate + .get_layout_setting(&self.view_id, layout_ty) + .await + { layout_setting.board = Some(value.into()); } }, DatabaseLayout::Calendar => { - if let Some(value) = self.delegate.get_layout_setting(&self.view_id, layout_ty) { + if let Some(value) = self + .delegate + .get_layout_setting(&self.view_id, layout_ty) + .await + { let calendar_setting = CalendarLayoutSetting::from(value); // Check the field exist or not - if let Some(field) = self.delegate.get_field(&calendar_setting.field_id) { + if let Some(field) = self.delegate.get_field(&calendar_setting.field_id).await { let field_type = FieldType::from(field.field_type); // Check the type of field is Datetime or not @@ -726,27 +938,33 @@ impl DatabaseViewEditor { DatabaseLayout::Board => { let layout_setting = params.board.unwrap(); - self.delegate.insert_layout_setting( - &self.view_id, - ¶ms.layout_type, - layout_setting.clone().into(), - ); + self + .delegate + .insert_layout_setting( + &self.view_id, + ¶ms.layout_type, + layout_setting.clone().into(), + ) + .await; Some(DatabaseLayoutSettingPB::from_board(layout_setting)) }, DatabaseLayout::Calendar => { let layout_setting = params.calendar.unwrap(); - if let Some(field) = self.delegate.get_field(&layout_setting.field_id) { + if let Some(field) = self.delegate.get_field(&layout_setting.field_id).await { if FieldType::from(field.field_type) != FieldType::DateTime { return Err(FlowyError::unexpect_calendar_field_type()); } - self.delegate.insert_layout_setting( - &self.view_id, - ¶ms.layout_type, - layout_setting.clone().into(), - ); + self + .delegate + .insert_layout_setting( + &self.view_id, + ¶ms.layout_type, + layout_setting.clone().into(), + ) + .await; Some(DatabaseLayoutSettingPB::from_calendar(layout_setting)) } else { @@ -757,7 +975,7 @@ impl DatabaseViewEditor { }; if let Some(payload) = layout_setting_pb { - send_notification(&self.view_id, DatabaseNotification::DidUpdateLayoutSettings) + database_notification_builder(&self.view_id, DatabaseNotification::DidUpdateLayoutSettings) .payload(payload) .send(); } @@ -772,10 +990,10 @@ impl DatabaseViewEditor { let notification = self.filter_controller.apply_changeset(changeset).await; notify_did_update_filter(notification).await; - let sorts = self.delegate.get_all_sorts(&self.view_id); + let sorts = self.delegate.get_all_sorts(&self.view_id).await; if let Some(sort) = sorts.iter().find(|sort| sort.field_id == deleted_field_id) { - self.delegate.remove_sort(&self.view_id, &sort.id); + self.delegate.remove_sort(&self.view_id, &sort.id).await; let notification = self .sort_controller .write() @@ -804,6 +1022,16 @@ impl DatabaseViewEditor { .calculations_controller .did_receive_field_type_changed(field_id.to_owned(), new_field_type) .await; + if self.filter_controller.has_filters().await { + let changeset = FilterChangeset::DeleteAllWithFieldId { + field_id: field_id.to_string(), + }; + let notification = self.filter_controller.apply_changeset(changeset).await; + notify_did_update_filter(notification).await; + } + if self.is_grouping_field(field_id).await { + let _ = self.v_group_by_field(field_id).await; + } } /// Notifies the view's field type-option data is changed @@ -813,7 +1041,7 @@ impl DatabaseViewEditor { pub async fn v_did_update_field_type_option(&self, old_field: &Field) -> FlowyResult<()> { let field_id = &old_field.id; - if let Some(field) = self.delegate.get_field(field_id) { + if let Some(field) = self.delegate.get_field(field_id).await { self .sort_controller .read() @@ -821,31 +1049,28 @@ impl DatabaseViewEditor { .did_update_field_type_option(&field) .await; - if old_field.field_type != field.field_type { - let changeset = FilterChangeset::DeleteAllWithFieldId { - field_id: field.id.clone(), - }; - let notification = self.filter_controller.apply_changeset(changeset).await; - notify_did_update_filter(notification).await; + // If the id of the grouping field is equal to the updated field's id + // and something critical changed, then we need to update the group setting + if self.is_grouping_field(field_id).await + && matches!( + FieldType::from(field.field_type), + FieldType::SingleSelect | FieldType::MultiSelect + ) + { + self.v_group_by_field(field_id).await?; } } - // If the id of the grouping field is equal to the updated field's id, then we need to - // update the group setting - if self.is_grouping_field(field_id).await { - self.v_group_by_field(field_id).await?; - } - Ok(()) } /// Called when a grouping field is updated. #[tracing::instrument(level = "debug", skip_all, err)] pub async fn v_group_by_field(&self, field_id: &str) -> FlowyResult<()> { - if let Some(field) = self.delegate.get_field(field_id) { + if let Some(field) = self.delegate.get_field(field_id).await { tracing::trace!("create new group controller"); - let new_group_controller = new_group_controller( + let mut new_group_controller = new_group_controller( self.view_id.clone(), self.delegate.clone(), self.filter_controller.clone(), @@ -853,7 +1078,9 @@ impl DatabaseViewEditor { ) .await?; - if let Some(controller) = &new_group_controller { + if let Some(controller) = &mut new_group_controller { + (*controller).load_group_data().await?; + let new_groups = controller .get_all_groups() .into_iter() @@ -865,16 +1092,14 @@ impl DatabaseViewEditor { initial_groups: new_groups, ..Default::default() }; - tracing::trace!("notify did group by field1"); debug_assert!(!changeset.is_empty()); if !changeset.is_empty() { - send_notification(&changeset.view_id, DatabaseNotification::DidGroupByField) + database_notification_builder(&changeset.view_id, DatabaseNotification::DidGroupByField) .payload(changeset) .send(); } } - tracing::trace!("notify did group by field2"); *self.group_controller.write().await = new_group_controller; @@ -893,7 +1118,7 @@ impl DatabaseViewEditor { let text_cell = get_cell_for_row(self.delegate.clone(), &primary_field.id, &row_id).await?; // Date - let date_field = self.delegate.get_field(&calendar_setting.field_id)?; + let date_field = self.delegate.get_field(&calendar_setting.field_id).await?; let date_cell = get_cell_for_row(self.delegate.clone(), &date_field.id, &row_id).await?; let title = text_cell @@ -904,16 +1129,15 @@ impl DatabaseViewEditor { let timestamp = date_cell .into_date_field_cell_data() .unwrap_or_default() - .timestamp - .unwrap_or_default(); + .timestamp; + + let (_, row_detail) = self.delegate.get_row_detail(&self.view_id, &row_id).await?; - let (_, row_detail) = self.delegate.get_row(&self.view_id, &row_id).await?; Some(CalendarEventPB { - row_meta: RowMetaPB::from(row_detail.as_ref()), + row_meta: RowMetaPB::from(row_detail.as_ref().clone()), date_field_id: date_field.id.clone(), title, timestamp, - is_scheduled: timestamp != 0, }) } @@ -931,89 +1155,83 @@ impl DatabaseViewEditor { Some(calendar_setting) => calendar_setting, }; - // Text let primary_field = self.delegate.get_primary_field().await?; - let text_cells = - get_cells_for_field(self.delegate.clone(), &self.view_id, &primary_field.id).await; - - // Date - let timestamp_by_row_id = get_cells_for_field( - self.delegate.clone(), - &self.view_id, - &calendar_setting.field_id, - ) - .await - .into_iter() - .map(|date_cell| { - let row_id = date_cell.row_id.clone(); - - // timestamp - let timestamp = date_cell - .into_date_field_cell_data() - .map(|date_cell_data| date_cell_data.timestamp.unwrap_or_default()) - .unwrap_or_default(); - - (row_id, timestamp) - }) - .collect::>(); let mut events: Vec = vec![]; - for text_cell in text_cells { - let row_id = text_cell.row_id.clone(); - let timestamp = timestamp_by_row_id - .get(&row_id) - .cloned() + + let rows = self.v_get_all_rows().await; + + for row in rows { + let primary_cell = get_cell_for_row(self.delegate.clone(), &primary_field.id, &row.id).await; + let timestamp_cell = + get_cell_for_row(self.delegate.clone(), &calendar_setting.field_id, &row.id).await; + + let timestamp = timestamp_cell + .and_then(|cell| cell.into_date_field_cell_data()) + .and_then(|cell_data| cell_data.timestamp); + + let title = primary_cell + .and_then(|cell| cell.into_text_field_cell_data()) + .map(|cell_data| cell_data.into()) .unwrap_or_default(); - let title = text_cell - .into_text_field_cell_data() - .unwrap_or_default() - .into(); - - let (_, row_detail) = self.delegate.get_row(&self.view_id, &row_id).await?; + let (_, row_detail) = self.delegate.get_row_detail(&self.view_id, &row.id).await?; let event = CalendarEventPB { - row_meta: RowMetaPB::from(row_detail.as_ref()), + row_meta: RowMetaPB::from(row_detail.as_ref().clone()), date_field_id: calendar_setting.field_id.clone(), title, timestamp, - is_scheduled: timestamp != 0, }; + events.push(event); } + Some(events) } pub async fn v_get_layout_type(&self) -> DatabaseLayout { - self.delegate.get_layout_for_view(&self.view_id) + self.delegate.get_layout_for_view(&self.view_id).await } #[tracing::instrument(level = "trace", skip_all)] pub async fn v_update_layout_type(&self, new_layout_type: DatabaseLayout) -> FlowyResult<()> { self .delegate - .update_layout_type(&self.view_id, &new_layout_type); + .update_layout_type(&self.view_id, &new_layout_type) + .await; // using the {} brackets to denote the lifetime of the resolver. Because the DatabaseLayoutDepsResolver // is not sync and send, so we can't pass it to the async block. { let resolver = DatabaseLayoutDepsResolver::new(self.delegate.get_database(), new_layout_type); - resolver.resolve_deps_when_update_layout_type(&self.view_id); + resolver + .resolve_deps_when_update_layout_type(&self.view_id) + .await; } // initialize the group controller if the current layout support grouping - *self.group_controller.write().await = new_group_controller( + let new_group_controller = match new_group_controller( self.view_id.clone(), self.delegate.clone(), self.filter_controller.clone(), None, ) - .await?; + .await? + { + Some(mut controller) => { + controller.load_group_data().await?; + Some(controller) + }, + None => None, + }; + + *self.group_controller.write().await = new_group_controller; let payload = DatabaseLayoutMetaPB { view_id: self.view_id.clone(), layout: new_layout_type.into(), }; - send_notification(&self.view_id, DatabaseNotification::DidUpdateDatabaseLayout) + database_notification_builder(&self.view_id, DatabaseNotification::DidUpdateDatabaseLayout) .payload(payload) .send(); @@ -1031,18 +1249,20 @@ impl DatabaseViewEditor { } => RowsChangePB::from_move(vec![deleted_row_id.into_inner()], vec![inserted_row.into()]), }; - send_notification(&self.view_id, DatabaseNotification::DidUpdateRow) + database_notification_builder(&self.view_id, DatabaseNotification::DidUpdateRow) .payload(changeset) .send(); } pub async fn v_get_field_settings(&self, field_ids: &[String]) -> HashMap { - self.delegate.get_field_settings(&self.view_id, field_ids) + self + .delegate + .get_field_settings(&self.view_id, field_ids) + .await } pub async fn v_update_field_settings(&self, params: FieldSettingsChangesetPB) -> FlowyResult<()> { - self.delegate.update_field_settings(params); - + self.delegate.update_field_settings(params).await; Ok(()) } @@ -1056,7 +1276,7 @@ impl DatabaseViewEditor { .await .as_ref() .map(|controller| controller.get_grouping_field_id().to_owned())?; - let field = self.delegate.get_field(&group_field_id)?; + let field = self.delegate.get_field(&group_field_id).await?; let mut write_guard = self.group_controller.write().await; if let Some(group_controller) = &mut *write_guard { f(group_controller, field).ok() @@ -1065,11 +1285,11 @@ impl DatabaseViewEditor { } } - async fn gen_did_update_row_view_tasks(&self, row_id: RowId, field_id: String) { + async fn gen_did_update_row_view_tasks(&self, row_id: RowId, field_id: Option) { let weak_filter_controller = Arc::downgrade(&self.filter_controller); let weak_sort_controller = Arc::downgrade(&self.sort_controller); let weak_calculations_controller = Arc::downgrade(&self.calculations_controller); - af_spawn(async move { + tokio::spawn(async move { if let Some(filter_controller) = weak_filter_controller.upgrade() { filter_controller .did_receive_row_changed(row_id.clone()) @@ -1082,31 +1302,43 @@ impl DatabaseViewEditor { .did_receive_row_changed(row_id.clone()) .await; } + if let Some(calculations_controller) = weak_calculations_controller.upgrade() { - calculations_controller - .did_receive_cell_changed(field_id) - .await; + if let Some(field_id) = field_id { + calculations_controller + .did_receive_cell_changed(field_id) + .await; + } } }); } - async fn gen_did_create_row_view_tasks(&self, preliminary_index: usize, row_detail: RowDetail) { - let weak_sort_controller = Arc::downgrade(&self.sort_controller); + async fn gen_did_create_row_view_tasks(&self, row: Row) { let weak_calculations_controller = Arc::downgrade(&self.calculations_controller); - af_spawn(async move { - if let Some(sort_controller) = weak_sort_controller.upgrade() { - sort_controller - .read() - .await - .did_create_row(preliminary_index, &row_detail) - .await; - } - + tokio::spawn(async move { if let Some(calculations_controller) = weak_calculations_controller.upgrade() { calculations_controller - .did_receive_row_changed(row_detail.row.clone()) + .did_receive_row_changed(row.clone()) .await; } }); } } + +async fn handle_mut_group_result(view_id: &str, result: Option) { + if let Some(result) = result { + if let Some(deleted_group) = result.deleted_group { + trace!("Delete group after moving the row: {:?}", deleted_group); + let payload = GroupChangesPB { + view_id: view_id.to_string(), + deleted_groups: vec![deleted_group.group_id], + ..Default::default() + }; + notify_did_update_num_of_groups(view_id, payload).await; + } + for changeset in result.row_changesets { + trace!("[RowOrder]: group row changeset: {:?}", changeset); + notify_did_update_group_rows(changeset).await; + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_filter.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_filter.rs index f710144e60..91341ae3b3 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/view_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_filter.rs @@ -1,15 +1,13 @@ +use async_trait::async_trait; use std::sync::Arc; -use collab_database::fields::Field; -use collab_database::rows::{RowDetail, RowId}; - -use lib_infra::future::Fut; - use crate::services::cell::CellCache; use crate::services::database_view::{ gen_handler_id, DatabaseViewChangedNotifier, DatabaseViewOperation, }; use crate::services::filter::{Filter, FilterController, FilterDelegate, FilterTaskHandler}; +use collab_database::fields::Field; +use collab_database::rows::{Row, RowDetail, RowId}; pub async fn make_filter_controller( view_id: &str, @@ -43,28 +41,30 @@ pub async fn make_filter_controller( struct DatabaseViewFilterDelegateImpl(Arc); +#[async_trait] impl FilterDelegate for DatabaseViewFilterDelegateImpl { - fn get_field(&self, field_id: &str) -> Option { - self.0.get_field(field_id) + async fn get_field(&self, field_id: &str) -> Option { + self.0.get_field(field_id).await } - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut> { - self.0.get_fields(view_id, field_ids) + async fn get_fields(&self, view_id: &str, field_ids: Option>) -> Vec { + self.0.get_fields(view_id, field_ids).await } - fn get_rows(&self, view_id: &str) -> Fut>> { - self.0.get_rows(view_id) + async fn get_rows(&self, view_id: &str) -> Vec> { + let row_orders = self.0.get_all_row_orders(view_id).await; + self.0.get_all_rows(view_id, row_orders).await } - fn get_row(&self, view_id: &str, rows_id: &RowId) -> Fut)>> { - self.0.get_row(view_id, rows_id) + async fn get_row(&self, view_id: &str, rows_id: &RowId) -> Option<(usize, Arc)> { + self.0.get_row_detail(view_id, rows_id).await } - fn get_all_filters(&self, view_id: &str) -> Vec { - self.0.get_all_filters(view_id) + async fn get_all_filters(&self, view_id: &str) -> Vec { + self.0.get_all_filters(view_id).await } - fn save_filters(&self, view_id: &str, filters: &[Filter]) { - self.0.save_filters(view_id, filters) + async fn save_filters(&self, view_id: &str, filters: &[Filter]) { + self.0.save_filters(view_id, filters).await } } diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_group.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_group.rs index 504511608a..63d4ca99a0 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/view_group.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_group.rs @@ -1,10 +1,10 @@ +use async_trait::async_trait; use std::sync::Arc; use collab_database::fields::Field; -use collab_database::rows::{RowDetail, RowId}; +use collab_database::rows::{Row, RowId}; use flowy_error::FlowyResult; -use lib_infra::future::{to_fut, Fut}; use crate::entities::FieldType; use crate::services::database_view::DatabaseViewOperation; @@ -21,20 +21,19 @@ pub async fn new_group_controller( filter_controller: Arc, grouping_field: Option, ) -> FlowyResult>> { - if !delegate.get_layout_for_view(&view_id).is_board() { + if !delegate.get_layout_for_view(&view_id).await.is_board() { return Ok(None); } let controller_delegate = GroupControllerDelegateImpl { delegate: delegate.clone(), - filter_controller: filter_controller.clone(), + filter_controller, }; let grouping_field = match grouping_field { Some(field) => Some(field), None => { let group_setting = controller_delegate.get_group_setting(&view_id).await; - let fields = delegate.get_fields(&view_id, None).await; group_setting @@ -61,45 +60,46 @@ pub(crate) struct GroupControllerDelegateImpl { filter_controller: Arc, } +#[async_trait] impl GroupContextDelegate for GroupControllerDelegateImpl { - fn get_group_setting(&self, view_id: &str) -> Fut>> { - let mut settings = self.delegate.get_group_setting(view_id); - to_fut(async move { - if settings.is_empty() { - None - } else { - Some(Arc::new(settings.remove(0))) - } - }) + async fn get_group_setting(&self, view_id: &str) -> Option> { + let mut settings = self.delegate.get_group_setting(view_id).await; + if settings.is_empty() { + None + } else { + Some(Arc::new(settings.remove(0))) + } } - fn get_configuration_cells(&self, view_id: &str, field_id: &str) -> Fut> { - let field_id = field_id.to_owned(); - let view_id = view_id.to_owned(); + async fn get_configuration_cells(&self, view_id: &str, field_id: &str) -> Vec { let delegate = self.delegate.clone(); - to_fut(async move { get_cells_for_field(delegate, &view_id, &field_id).await }) + get_cells_for_field(delegate, view_id, field_id).await } - fn save_configuration(&self, view_id: &str, group_setting: GroupSetting) -> Fut> { - self.delegate.insert_group_setting(view_id, group_setting); - to_fut(async move { Ok(()) }) + async fn save_configuration( + &self, + view_id: &str, + group_setting: GroupSetting, + ) -> FlowyResult<()> { + self + .delegate + .insert_group_setting(view_id, group_setting) + .await; + Ok(()) } } +#[async_trait] impl GroupControllerDelegate for GroupControllerDelegateImpl { - fn get_field(&self, field_id: &str) -> Option { - self.delegate.get_field(field_id) + async fn get_field(&self, field_id: &str) -> Option { + self.delegate.get_field(field_id).await } - fn get_all_rows(&self, view_id: &str) -> Fut>> { - let view_id = view_id.to_string(); - let delegate = self.delegate.clone(); - let filter_controller = self.filter_controller.clone(); - to_fut(async move { - let mut row_details = delegate.get_rows(&view_id).await; - filter_controller.filter_rows(&mut row_details).await; - row_details - }) + async fn get_all_rows(&self, view_id: &str) -> Vec> { + let row_orders = self.delegate.get_all_row_orders(view_id).await; + let rows = self.delegate.get_all_rows(view_id, row_orders).await; + + self.filter_controller.filter_rows(rows).await } } @@ -108,7 +108,7 @@ pub(crate) async fn get_cell_for_row( field_id: &str, row_id: &RowId, ) -> Option { - let field = delegate.get_field(field_id)?; + let field = delegate.get_field(field_id).await?; let row_cell = delegate.get_cell_in_row(field_id, row_id).await; let field_type = FieldType::from(field.field_type); let handler = delegate.get_type_option_cell_handler(&field)?; @@ -131,7 +131,7 @@ pub(crate) async fn get_cells_for_field( view_id: &str, field_id: &str, ) -> Vec { - if let Some(field) = delegate.get_field(field_id) { + if let Some(field) = delegate.get_field(field_id).await { let field_type = FieldType::from(field.field_type); if let Some(handler) = delegate.get_type_option_cell_handler(&field) { let cells = delegate.get_cells_for_field(view_id, field_id).await; diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_operation.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_operation.rs index 3a912646cd..30816587d6 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/view_operation.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_operation.rs @@ -1,14 +1,15 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use collab_database::database::MutexDatabase; +use async_trait::async_trait; +use collab::lock::RwLock; +use collab_database::database::Database; +use collab_database::entity::DatabaseView; use collab_database::fields::{Field, TypeOptionData}; use collab_database::rows::{Row, RowCell, RowDetail, RowId}; -use collab_database::views::{DatabaseLayout, DatabaseView, LayoutSetting}; -use tokio::sync::RwLock; +use collab_database::views::{DatabaseLayout, LayoutSetting, RowOrder}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock as TokioRwLock; use flowy_error::FlowyError; -use lib_infra::future::{Fut, FutureResult}; use lib_infra::priority_task::TaskDispatcher; use crate::entities::{FieldSettingsChangesetPB, FieldType}; @@ -20,111 +21,117 @@ use crate::services::group::GroupSetting; use crate::services::sort::Sort; /// Defines the operation that can be performed on a database view +#[async_trait] pub trait DatabaseViewOperation: Send + Sync + 'static { /// Get the database that the view belongs to - fn get_database(&self) -> Arc; + fn get_database(&self) -> Arc>; /// Get the view of the database with the view_id - fn get_view(&self, view_id: &str) -> Fut>; + async fn get_view(&self, view_id: &str) -> Option; /// If the field_ids is None, then it will return all the field revisions - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>; + async fn get_fields(&self, view_id: &str, field_ids: Option>) -> Vec; /// Returns the field with the field_id - fn get_field(&self, field_id: &str) -> Option; + async fn get_field(&self, field_id: &str) -> Option; - fn create_field( + async fn create_field( &self, view_id: &str, name: &str, field_type: FieldType, type_option_data: TypeOptionData, - ) -> Fut; + ) -> Field; - fn update_field( + async fn update_field( &self, type_option_data: TypeOptionData, old_field: Field, - ) -> FutureResult<(), FlowyError>; + ) -> Result<(), FlowyError>; - fn get_primary_field(&self) -> Fut>>; + async fn get_primary_field(&self) -> Option>; /// Returns the index of the row with row_id - fn index_of_row(&self, view_id: &str, row_id: &RowId) -> Fut>; + async fn index_of_row(&self, view_id: &str, row_id: &RowId) -> Option; /// Returns the `index` and `RowRevision` with row_id - fn get_row(&self, view_id: &str, row_id: &RowId) -> Fut)>>; + async fn get_row_detail(&self, view_id: &str, row_id: &RowId) -> Option<(usize, Arc)>; /// Returns all the rows in the view - fn get_rows(&self, view_id: &str) -> Fut>>; + async fn get_all_rows(&self, view_id: &str, row_orders: Vec) -> Vec>; + async fn get_all_row_orders(&self, view_id: &str) -> Vec; - fn remove_row(&self, row_id: &RowId) -> Option; + async fn remove_row(&self, row_id: &RowId) -> Option; - fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Fut>>; + async fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Vec; - fn get_cell_in_row(&self, field_id: &str, row_id: &RowId) -> Fut>; + async fn get_cell_in_row(&self, field_id: &str, row_id: &RowId) -> Arc; /// Return the database layout type for the view with given view_id /// The default layout type is [DatabaseLayout::Grid] - fn get_layout_for_view(&self, view_id: &str) -> DatabaseLayout; + async fn get_layout_for_view(&self, view_id: &str) -> DatabaseLayout; - fn get_group_setting(&self, view_id: &str) -> Vec; + async fn get_group_setting(&self, view_id: &str) -> Vec; - fn insert_group_setting(&self, view_id: &str, setting: GroupSetting); + async fn insert_group_setting(&self, view_id: &str, setting: GroupSetting); - fn get_sort(&self, view_id: &str, sort_id: &str) -> Option; + async fn get_sort(&self, view_id: &str, sort_id: &str) -> Option; - fn insert_sort(&self, view_id: &str, sort: Sort); + async fn insert_sort(&self, view_id: &str, sort: Sort); - fn move_sort(&self, view_id: &str, from_sort_id: &str, to_sort_id: &str); + async fn move_sort(&self, view_id: &str, from_sort_id: &str, to_sort_id: &str); - fn remove_sort(&self, view_id: &str, sort_id: &str); + async fn remove_sort(&self, view_id: &str, sort_id: &str); - fn get_all_sorts(&self, view_id: &str) -> Vec; + async fn get_all_sorts(&self, view_id: &str) -> Vec; - fn remove_all_sorts(&self, view_id: &str); + async fn remove_all_sorts(&self, view_id: &str); - fn get_all_calculations(&self, view_id: &str) -> Vec>; + async fn get_all_calculations(&self, view_id: &str) -> Vec>; - fn get_calculation(&self, view_id: &str, field_id: &str) -> Option; + async fn get_calculation(&self, view_id: &str, field_id: &str) -> Option; - fn update_calculation(&self, view_id: &str, calculation: Calculation); + async fn update_calculation(&self, view_id: &str, calculation: Calculation); - fn remove_calculation(&self, view_id: &str, calculation_id: &str); + async fn remove_calculation(&self, view_id: &str, calculation_id: &str); - fn get_all_filters(&self, view_id: &str) -> Vec; + async fn get_all_filters(&self, view_id: &str) -> Vec; - fn get_filter(&self, view_id: &str, filter_id: &str) -> Option; + async fn get_filter(&self, view_id: &str, filter_id: &str) -> Option; - fn delete_filter(&self, view_id: &str, filter_id: &str); + async fn delete_filter(&self, view_id: &str, filter_id: &str); - fn insert_filter(&self, view_id: &str, filter: Filter); + async fn insert_filter(&self, view_id: &str, filter: Filter); - fn save_filters(&self, view_id: &str, filters: &[Filter]); + async fn save_filters(&self, view_id: &str, filters: &[Filter]); - fn get_layout_setting(&self, view_id: &str, layout_ty: &DatabaseLayout) -> Option; + async fn get_layout_setting( + &self, + view_id: &str, + layout_ty: &DatabaseLayout, + ) -> Option; - fn insert_layout_setting( + async fn insert_layout_setting( &self, view_id: &str, layout_ty: &DatabaseLayout, layout_setting: LayoutSetting, ); - fn update_layout_type(&self, view_id: &str, layout_type: &DatabaseLayout); + async fn update_layout_type(&self, view_id: &str, layout_type: &DatabaseLayout); /// Returns a `TaskDispatcher` used to poll a `Task` - fn get_task_scheduler(&self) -> Arc>; + fn get_task_scheduler(&self) -> Arc>; fn get_type_option_cell_handler( &self, field: &Field, ) -> Option>; - fn get_field_settings( + async fn get_field_settings( &self, view_id: &str, field_ids: &[String], ) -> HashMap; - fn update_field_settings(&self, params: FieldSettingsChangesetPB); + async fn update_field_settings(&self, params: FieldSettingsChangesetPB); } diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_sort.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_sort.rs index 0397526b66..53b70a42ca 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/view_sort.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_sort.rs @@ -1,11 +1,10 @@ +use async_trait::async_trait; use std::sync::Arc; use collab_database::fields::Field; -use collab_database::rows::RowDetail; +use collab_database::rows::Row; use tokio::sync::RwLock; -use lib_infra::future::{to_fut, Fut}; - use crate::services::cell::CellCache; use crate::services::database_view::{ gen_handler_id, DatabaseViewChangedNotifier, DatabaseViewOperation, @@ -23,6 +22,7 @@ pub(crate) async fn make_sort_controller( let handler_id = gen_handler_id(); let sorts = delegate .get_all_sorts(view_id) + .await .into_iter() .map(Arc::new) .collect(); @@ -53,38 +53,31 @@ struct DatabaseViewSortDelegateImpl { filter_controller: Arc, } +#[async_trait] impl SortDelegate for DatabaseViewSortDelegateImpl { - fn get_sort(&self, view_id: &str, sort_id: &str) -> Fut>> { - let sort = self.delegate.get_sort(view_id, sort_id).map(Arc::new); - to_fut(async move { sort }) + async fn get_sort(&self, view_id: &str, sort_id: &str) -> Option> { + self.delegate.get_sort(view_id, sort_id).await.map(Arc::new) } - fn get_rows(&self, view_id: &str) -> Fut>> { + async fn get_rows(&self, view_id: &str) -> Vec> { let view_id = view_id.to_string(); - let delegate = self.delegate.clone(); - let filter_controller = self.filter_controller.clone(); - to_fut(async move { - let mut row_details = delegate.get_rows(&view_id).await; - filter_controller.filter_rows(&mut row_details).await; - row_details - }) + let row_orders = self.delegate.get_all_row_orders(&view_id).await; + let rows = self.delegate.get_all_rows(&view_id, row_orders).await; + + self.filter_controller.filter_rows(rows).await } - fn filter_row(&self, row_detail: &RowDetail) -> Fut { - let filter_controller = self.filter_controller.clone(); - let row_detail = row_detail.clone(); - to_fut(async move { - let mut row_details = vec![Arc::new(row_detail)]; - filter_controller.filter_rows(&mut row_details).await; - !row_details.is_empty() - }) + async fn filter_row(&self, row: &Row) -> bool { + let rows = vec![Arc::new(row.clone())]; + let rows = self.filter_controller.filter_rows(rows).await; + !rows.is_empty() } - fn get_field(&self, field_id: &str) -> Option { - self.delegate.get_field(field_id) + async fn get_field(&self, field_id: &str) -> Option { + self.delegate.get_field(field_id).await } - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut> { - self.delegate.get_fields(view_id, field_ids) + async fn get_fields(&self, view_id: &str, field_ids: Option>) -> Vec { + self.delegate.get_fields(view_id, field_ids).await } } diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/views.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/views.rs index 132b480123..76466cae1c 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/views.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/views.rs @@ -1,15 +1,14 @@ +use collab_database::database::Database; use std::collections::HashMap; use std::sync::Arc; -use collab_database::database::MutexDatabase; -use nanoid::nanoid; -use tokio::sync::{broadcast, RwLock}; - -use flowy_error::{FlowyError, FlowyResult}; - use crate::services::cell::CellCache; use crate::services::database::DatabaseRowEvent; use crate::services::database_view::{DatabaseViewEditor, DatabaseViewOperation}; +use collab::lock::RwLock; +use flowy_error::FlowyResult; +use nanoid::nanoid; +use tokio::sync::broadcast; pub type RowEventSender = broadcast::Sender; pub type RowEventReceiver = broadcast::Receiver; @@ -17,7 +16,7 @@ pub type EditorByViewId = HashMap>; pub struct DatabaseViews { #[allow(dead_code)] - database: Arc, + database: Arc>, cell_cache: CellCache, view_operation: Arc, view_editors: Arc>, @@ -25,7 +24,7 @@ pub struct DatabaseViews { impl DatabaseViews { pub async fn new( - database: Arc, + database: Arc>, cell_cache: CellCache, view_operation: Arc, view_editors: Arc>, @@ -38,7 +37,7 @@ impl DatabaseViews { }) } - pub async fn close_view(&self, view_id: &str) { + pub async fn remove_view(&self, view_id: &str) { let mut lock_guard = self.view_editors.write().await; if let Some(view) = lock_guard.remove(view_id) { view.close().await; @@ -53,19 +52,19 @@ impl DatabaseViews { self.view_editors.read().await.values().cloned().collect() } - pub async fn get_view_editor(&self, view_id: &str) -> FlowyResult> { + pub async fn get_or_init_view_editor( + &self, + view_id: &str, + ) -> FlowyResult> { debug_assert!(!view_id.is_empty()); if let Some(editor) = self.view_editors.read().await.get(view_id) { return Ok(editor.clone()); } - let mut editor_map = self.view_editors.try_write().map_err(|err| { - FlowyError::internal().with_context(format!( - "fail to acquire the lock of editor_by_view_id: {}", - err - )) - })?; - let database_id = self.database.lock().get_database_id(); + let database_id = self.database.read().await.get_database_id(); + // Acquire the write lock to insert the new editor. We need to acquire the lock first to avoid + // initializing the editor multiple times. + let mut editor_map = self.view_editors.write().await; let editor = Arc::new( DatabaseViewEditor::new( database_id, @@ -76,8 +75,15 @@ impl DatabaseViews { .await?, ); editor_map.insert(view_id.to_owned(), editor.clone()); + drop(editor_map); + + editor.initialize().await?; Ok(editor) } + + pub async fn get_view_editor(&self, view_id: &str) -> Option> { + self.view_editors.read().await.get(view_id).cloned() + } } pub fn gen_handler_id() -> String { diff --git a/frontend/rust-lib/flowy-database2/src/services/field/field_builder.rs b/frontend/rust-lib/flowy-database2/src/services/field/field_builder.rs index 18c72313c0..323ff2c815 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/field_builder.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/field_builder.rs @@ -22,7 +22,7 @@ impl FieldBuilder { } pub fn name(mut self, name: &str) -> Self { - self.field.name = name.to_owned(); + name.clone_into(&mut self.field.name); self } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/field_operation.rs b/frontend/rust-lib/flowy-database2/src/services/field/field_operation.rs index e9db74358f..b5f4841393 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/field_operation.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/field_operation.rs @@ -1,24 +1,25 @@ +use collab_database::fields::select_type_option::{MultiSelectTypeOption, SingleSelectTypeOption}; +use flowy_error::{FlowyError, FlowyResult}; use std::sync::Arc; -use flowy_error::FlowyResult; - use crate::entities::FieldType; use crate::services::database::DatabaseEditor; -use crate::services::field::{MultiSelectTypeOption, SingleSelectTypeOption, TypeOption}; +use crate::services::field::TypeOption; pub async fn edit_field_type_option( field_id: &str, editor: Arc, action: impl FnOnce(&mut T), ) -> FlowyResult<()> { - let get_type_option = async { - let field = editor.get_field(field_id)?; - let field_type = FieldType::from(field.field_type); - field.get_type_option::(field_type) - }; + let field = editor + .get_field(field_id) + .await + .ok_or_else(FlowyError::field_record_not_found)?; + let field_type = FieldType::from(field.field_type); + let get_type_option = field.get_type_option::(field_type); - if let Some(mut type_option) = get_type_option.await { - if let Some(old_field) = editor.get_field(field_id) { + if let Some(mut type_option) = get_type_option { + if let Some(old_field) = editor.get_field(field_id).await { action(&mut type_option); let type_option_data = type_option.into(); editor diff --git a/frontend/rust-lib/flowy-database2/src/services/field/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/mod.rs index 72cc377c60..55c052fe33 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/mod.rs @@ -1,5 +1,6 @@ mod field_builder; mod field_operation; +pub(crate) mod type_option_transform; pub mod type_options; pub use field_builder::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_option_transform.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_option_transform.rs new file mode 100644 index 0000000000..e5972ea064 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_option_transform.rs @@ -0,0 +1,140 @@ +use crate::entities::FieldType; +use crate::services::field::TypeOptionTransform; +use async_trait::async_trait; +use collab_database::database::Database; +use collab_database::fields::checkbox_type_option::CheckboxTypeOption; +use collab_database::fields::checklist_type_option::ChecklistTypeOption; +use collab_database::fields::date_type_option::{DateTypeOption, TimeTypeOption}; +use collab_database::fields::media_type_option::MediaTypeOption; +use collab_database::fields::number_type_option::NumberTypeOption; +use collab_database::fields::relation_type_option::RelationTypeOption; +use collab_database::fields::select_type_option::{MultiSelectTypeOption, SingleSelectTypeOption}; +use collab_database::fields::summary_type_option::SummarizationTypeOption; +use collab_database::fields::text_type_option::RichTextTypeOption; +use collab_database::fields::timestamp_type_option::TimestampTypeOption; +use collab_database::fields::translate_type_option::TranslateTypeOption; +use collab_database::fields::url_type_option::URLTypeOption; +use collab_database::fields::TypeOptionData; + +pub async fn transform_type_option( + view_id: &str, + field_id: &str, + old_field_type: FieldType, + new_field_type: FieldType, + old_type_option_data: Option, + new_type_option_data: TypeOptionData, + database: &mut Database, +) -> TypeOptionData { + if let Some(old_type_option_data) = old_type_option_data { + let mut transform_handler = + get_type_option_transform_handler(new_type_option_data, new_field_type); + transform_handler + .transform( + view_id, + field_id, + old_field_type, + old_type_option_data, + new_field_type, + database, + ) + .await; + transform_handler.to_type_option_data() + } else { + new_type_option_data + } +} + +/// A helper trait that used to erase the `Self` of `TypeOption` trait to make it become a Object-safe trait. +#[async_trait] +pub trait TypeOptionTransformHandler: Send + Sync { + async fn transform( + &mut self, + view_id: &str, + field_id: &str, + old_type_option_field_type: FieldType, + old_type_option_data: TypeOptionData, + new_type_option_field_type: FieldType, + database: &mut Database, + ); + + fn to_type_option_data(&self) -> TypeOptionData; +} + +#[async_trait] +impl TypeOptionTransformHandler for T +where + T: TypeOptionTransform + Clone, +{ + async fn transform( + &mut self, + view_id: &str, + field_id: &str, + old_type_option_field_type: FieldType, + old_type_option_data: TypeOptionData, + new_type_option_field_type: FieldType, + database: &mut Database, + ) { + self + .transform_type_option( + view_id, + field_id, + old_type_option_field_type, + old_type_option_data, + new_type_option_field_type, + database, + ) + .await + } + + fn to_type_option_data(&self) -> TypeOptionData { + self.clone().into() + } +} + +fn get_type_option_transform_handler( + type_option_data: TypeOptionData, + field_type: FieldType, +) -> Box { + match field_type { + FieldType::RichText => { + Box::new(RichTextTypeOption::from(type_option_data)) as Box + }, + FieldType::Number => { + Box::new(NumberTypeOption::from(type_option_data)) as Box + }, + FieldType::DateTime => { + Box::new(DateTypeOption::from(type_option_data)) as Box + }, + FieldType::LastEditedTime | FieldType::CreatedTime => { + Box::new(TimestampTypeOption::from(type_option_data)) as Box + }, + FieldType::SingleSelect => Box::new(SingleSelectTypeOption::from(type_option_data)) + as Box, + FieldType::MultiSelect => { + Box::new(MultiSelectTypeOption::from(type_option_data)) as Box + }, + FieldType::Checkbox => { + Box::new(CheckboxTypeOption::from(type_option_data)) as Box + }, + FieldType::URL => { + Box::new(URLTypeOption::from(type_option_data)) as Box + }, + FieldType::Checklist => { + Box::new(ChecklistTypeOption::from(type_option_data)) as Box + }, + FieldType::Relation => { + Box::new(RelationTypeOption::from(type_option_data)) as Box + }, + FieldType::Summary => Box::new(SummarizationTypeOption::from(type_option_data)) + as Box, + FieldType::Time => { + Box::new(TimeTypeOption::from(type_option_data)) as Box + }, + FieldType::Translate => { + Box::new(TranslateTypeOption::from(type_option_data)) as Box + }, + FieldType::Media => { + Box::new(MediaTypeOption::from(type_option_data)) as Box + }, + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_filter.rs index e2aa56de94..79e18c58aa 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_filter.rs @@ -1,8 +1,7 @@ -use collab_database::{fields::Field, rows::Cell}; - use crate::entities::{CheckboxCellDataPB, CheckboxFilterConditionPB, CheckboxFilterPB}; use crate::services::cell::insert_checkbox_cell; use crate::services::filter::PreFillCellsWithFilter; +use collab_database::{fields::Field, rows::Cell}; impl CheckboxFilterPB { pub fn is_visible(&self, cell_data: &CheckboxCellDataPB) -> bool { @@ -14,16 +13,13 @@ impl CheckboxFilterPB { } impl PreFillCellsWithFilter for CheckboxFilterPB { - fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { + fn get_compliant_cell(&self, field: &Field) -> Option { let is_checked = match self.condition { CheckboxFilterConditionPB::IsChecked => Some(true), CheckboxFilterConditionPB::IsUnChecked => None, }; - ( - is_checked.map(|is_checked| insert_checkbox_cell(is_checked, field)), - false, - ) + is_checked.map(|is_checked| insert_checkbox_cell(is_checked, field)) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs index 3003357dea..c0a0eadd86 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs @@ -9,10 +9,12 @@ mod tests { use crate::services::cell::CellDataDecoder; use crate::services::field::type_options::checkbox_type_option::*; use crate::services::field::FieldBuilder; + use collab_database::fields::checkbox_type_option::CheckboxTypeOption; + use collab_database::template::util::ToCellString; #[test] fn checkout_box_description_test() { - let type_option = CheckboxTypeOption::default(); + let type_option = CheckboxTypeOption; let field_type = FieldType::Checkbox; let field_rev = FieldBuilder::from_field_type(field_type).build(); @@ -45,7 +47,7 @@ mod tests { type_option .decode_cell(&CheckboxCellDataPB::from_str(input_str).unwrap().into()) .unwrap() - .to_string(), + .to_cell_string(), expected_str.to_owned() ); } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs index de95ba058c..6afe4c4b57 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs @@ -1,23 +1,20 @@ use std::cmp::Ordering; use std::str::FromStr; -use collab_database::fields::{Field, TypeOptionData, TypeOptionDataBuilder}; +use collab_database::fields::checkbox_type_option::CheckboxTypeOption; +use collab_database::fields::Field; use collab_database::rows::Cell; -use serde::{Deserialize, Serialize}; - +use collab_database::template::util::ToCellString; use flowy_error::FlowyResult; use crate::entities::{CheckboxCellDataPB, CheckboxFilterPB, FieldType}; use crate::services::cell::{CellDataChangeset, CellDataDecoder}; use crate::services::field::{ - TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, TypeOptionCellDataSerde, + CellDataProtobufEncoder, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, TypeOptionTransform, }; use crate::services::sort::SortCondition; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CheckboxTypeOption(); - impl TypeOption for CheckboxTypeOption { type CellData = CheckboxCellDataPB; type CellChangeset = CheckboxCellChangeset; @@ -27,36 +24,16 @@ impl TypeOption for CheckboxTypeOption { impl TypeOptionTransform for CheckboxTypeOption {} -impl From for CheckboxTypeOption { - fn from(_data: TypeOptionData) -> Self { - Self() - } -} - -impl From for TypeOptionData { - fn from(_data: CheckboxTypeOption) -> Self { - TypeOptionDataBuilder::new().build() - } -} - -impl TypeOptionCellDataSerde for CheckboxTypeOption { +impl CellDataProtobufEncoder for CheckboxTypeOption { fn protobuf_encode( &self, cell_data: ::CellData, ) -> ::CellProtobufType { cell_data } - - fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - Ok(CheckboxCellDataPB::from(cell)) - } } impl CellDataDecoder for CheckboxTypeOption { - fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - self.parse_cell(cell) - } - fn decode_cell_with_transform( &self, cell: &Cell, @@ -71,16 +48,7 @@ impl CellDataDecoder for CheckboxTypeOption { } fn stringify_cell_data(&self, cell_data: ::CellData) -> String { - cell_data.to_string() - } - - fn numeric_cell(&self, cell: &Cell) -> Option { - let cell_data = self.parse_cell(cell).ok()?; - if cell_data.is_checked { - Some(1.0) - } else { - Some(0.0) - } + cell_data.to_cell_string() } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs index 35de68136b..55ca8dd77f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs @@ -1,9 +1,9 @@ use std::str::FromStr; use bytes::Bytes; -use collab::core::any_map::AnyMapExtension; +use collab::util::AnyMapExt; use collab_database::rows::{new_cell_builder, Cell}; - +use collab_database::template::util::ToCellString; use flowy_error::{FlowyError, FlowyResult}; use crate::entities::{CheckboxCellDataPB, FieldType}; @@ -21,16 +21,16 @@ impl TypeOptionCellData for CheckboxCellDataPB { impl From<&Cell> for CheckboxCellDataPB { fn from(cell: &Cell) -> Self { - let value = cell.get_str_value(CELL_DATA).unwrap_or_default(); + let value: String = cell.get_as(CELL_DATA).unwrap_or_default(); CheckboxCellDataPB::from_str(&value).unwrap_or_default() } } impl From for Cell { fn from(data: CheckboxCellDataPB) -> Self { - new_cell_builder(FieldType::Checkbox) - .insert_str_value(CELL_DATA, data.to_string()) - .build() + let mut cell = new_cell_builder(FieldType::Checkbox); + cell.insert(CELL_DATA.into(), data.to_cell_string().into()); + cell } } @@ -49,16 +49,6 @@ impl FromStr for CheckboxCellDataPB { } } -impl ToString for CheckboxCellDataPB { - fn to_string(&self) -> String { - if self.is_checked { - CHECK.to_string() - } else { - UNCHECK.to_string() - } - } -} - pub struct CheckboxCellDataParser(); impl CellProtobufBlobParser for CheckboxCellDataParser { type Object = CheckboxCellDataPB; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist.rs deleted file mode 100644 index ceddeadce6..0000000000 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist.rs +++ /dev/null @@ -1,198 +0,0 @@ -use std::cmp::Ordering; - -use collab_database::fields::{TypeOptionData, TypeOptionDataBuilder}; -use collab_database::rows::Cell; -use flowy_error::FlowyResult; - -use crate::entities::{ChecklistCellDataPB, ChecklistFilterPB, SelectOptionPB}; -use crate::services::cell::{CellDataChangeset, CellDataDecoder}; -use crate::services::field::checklist_type_option::{ChecklistCellChangeset, ChecklistCellData}; -use crate::services::field::{ - SelectOption, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, - TypeOptionCellDataFilter, TypeOptionCellDataSerde, TypeOptionTransform, SELECTION_IDS_SEPARATOR, -}; -use crate::services::sort::SortCondition; - -#[derive(Debug, Clone, Default)] -pub struct ChecklistTypeOption; - -impl TypeOption for ChecklistTypeOption { - type CellData = ChecklistCellData; - type CellChangeset = ChecklistCellChangeset; - type CellProtobufType = ChecklistCellDataPB; - type CellFilter = ChecklistFilterPB; -} - -impl From for ChecklistTypeOption { - fn from(_data: TypeOptionData) -> Self { - Self - } -} - -impl From for TypeOptionData { - fn from(_data: ChecklistTypeOption) -> Self { - TypeOptionDataBuilder::new().build() - } -} - -impl TypeOptionCellDataSerde for ChecklistTypeOption { - fn protobuf_encode( - &self, - cell_data: ::CellData, - ) -> ::CellProtobufType { - let percentage = cell_data.percentage_complete(); - let selected_options = cell_data - .options - .iter() - .filter(|option| cell_data.selected_option_ids.contains(&option.id)) - .map(|option| SelectOptionPB::from(option.clone())) - .collect(); - - let options = cell_data - .options - .into_iter() - .map(SelectOptionPB::from) - .collect(); - - ChecklistCellDataPB { - options, - selected_options, - percentage, - } - } - - fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - Ok(ChecklistCellData::from(cell)) - } -} - -impl CellDataChangeset for ChecklistTypeOption { - fn apply_changeset( - &self, - changeset: ::CellChangeset, - cell: Option, - ) -> FlowyResult<(Cell, ::CellData)> { - match cell { - Some(cell) => { - let mut cell_data = self.parse_cell(&cell)?; - update_cell_data_with_changeset(&mut cell_data, changeset); - Ok((Cell::from(cell_data.clone()), cell_data)) - }, - None => { - let cell_data = ChecklistCellData::from_options(changeset.insert_options); - Ok((Cell::from(cell_data.clone()), cell_data)) - }, - } - } -} - -#[inline] -fn update_cell_data_with_changeset( - cell_data: &mut ChecklistCellData, - changeset: ChecklistCellChangeset, -) { - // Delete the options - cell_data - .options - .retain(|option| !changeset.delete_option_ids.contains(&option.id)); - cell_data - .selected_option_ids - .retain(|option_id| !changeset.delete_option_ids.contains(option_id)); - - // Insert new options - changeset - .insert_options - .into_iter() - .for_each(|(option_name, is_selected)| { - let option = SelectOption::new(&option_name); - if is_selected { - cell_data.selected_option_ids.push(option.id.clone()) - } - cell_data.options.push(option); - }); - - // Update options - changeset - .update_options - .into_iter() - .for_each(|updated_option| { - if let Some(option) = cell_data - .options - .iter_mut() - .find(|option| option.id == updated_option.id) - { - option.name = updated_option.name; - } - }); - - // Select the options - changeset - .selected_option_ids - .into_iter() - .for_each(|option_id| { - if let Some(index) = cell_data - .selected_option_ids - .iter() - .position(|id| **id == option_id) - { - cell_data.selected_option_ids.remove(index); - } else { - cell_data.selected_option_ids.push(option_id); - } - }); -} - -impl CellDataDecoder for ChecklistTypeOption { - fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - self.parse_cell(cell) - } - - fn stringify_cell_data(&self, cell_data: ::CellData) -> String { - cell_data - .options - .into_iter() - .map(|option| option.name) - .collect::>() - .join(SELECTION_IDS_SEPARATOR) - } - - fn numeric_cell(&self, _cell: &Cell) -> Option { - // return the percentage complete if needed - None - } -} - -impl TypeOptionCellDataFilter for ChecklistTypeOption { - fn apply_filter( - &self, - filter: &::CellFilter, - cell_data: &::CellData, - ) -> bool { - let selected_options = cell_data.selected_options(); - filter.is_visible(&cell_data.options, &selected_options) - } -} - -impl TypeOptionCellDataCompare for ChecklistTypeOption { - fn apply_cmp( - &self, - cell_data: &::CellData, - other_cell_data: &::CellData, - sort_condition: SortCondition, - ) -> Ordering { - match (cell_data.is_cell_empty(), other_cell_data.is_cell_empty()) { - (true, true) => Ordering::Equal, - (true, false) => Ordering::Greater, - (false, true) => Ordering::Less, - (false, false) => { - let left = cell_data.percentage_complete(); - let right = other_cell_data.percentage_complete(); - // safe to unwrap because the two floats won't be NaN - let order = left.partial_cmp(&right).unwrap(); - sort_condition.evaluate_order(order) - }, - } - } -} - -impl TypeOptionTransform for ChecklistTypeOption {} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_entities.rs deleted file mode 100644 index 12b3e07527..0000000000 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_entities.rs +++ /dev/null @@ -1,101 +0,0 @@ -use crate::entities::FieldType; -use crate::services::field::{SelectOption, TypeOptionCellData, CELL_DATA}; -use collab::core::any_map::AnyMapExtension; -use collab_database::rows::{new_cell_builder, Cell}; -use serde::{Deserialize, Serialize}; -use std::fmt::Debug; - -#[derive(Default, Clone, Debug, Serialize, Deserialize)] -pub struct ChecklistCellData { - pub options: Vec, - pub selected_option_ids: Vec, -} - -impl ToString for ChecklistCellData { - fn to_string(&self) -> String { - serde_json::to_string(self).unwrap_or_default() - } -} - -impl TypeOptionCellData for ChecklistCellData { - fn is_cell_empty(&self) -> bool { - self.options.is_empty() - } -} - -impl ChecklistCellData { - pub fn selected_options(&self) -> Vec { - self - .options - .iter() - .filter(|option| self.selected_option_ids.contains(&option.id)) - .cloned() - .collect() - } - - pub fn percentage_complete(&self) -> f64 { - let selected_options = self.selected_option_ids.len(); - let total_options = self.options.len(); - - if total_options == 0 { - return 0.0; - } - ((selected_options as f64) / (total_options as f64) * 100.0).round() / 100.0 - } - - pub fn from_options(options: Vec<(String, bool)>) -> Self { - let (options, selected_ids): (Vec<_>, Vec<_>) = options - .into_iter() - .map(|(name, is_selected)| { - let option = SelectOption::new(&name); - let selected_id = is_selected.then(|| option.id.clone()); - (option, selected_id) - }) - .unzip(); - let selected_option_ids = selected_ids.into_iter().flatten().collect(); - - Self { - options, - selected_option_ids, - } - } -} - -impl From<&Cell> for ChecklistCellData { - fn from(cell: &Cell) -> Self { - cell - .get_str_value(CELL_DATA) - .map(|data| serde_json::from_str::(&data).unwrap_or_default()) - .unwrap_or_default() - } -} - -impl From for Cell { - fn from(cell_data: ChecklistCellData) -> Self { - let data = serde_json::to_string(&cell_data).unwrap_or_default(); - new_cell_builder(FieldType::Checklist) - .insert_str_value(CELL_DATA, data) - .build() - } -} - -#[derive(Debug, Clone, Default)] -pub struct ChecklistCellChangeset { - /// List of option names that will be inserted - pub insert_options: Vec<(String, bool)>, - pub selected_option_ids: Vec, - pub delete_option_ids: Vec, - pub update_options: Vec, -} - -#[cfg(test)] -mod tests { - #[test] - fn test() { - let a = 1; - let b = 2; - - let c = (a as f32) / (b as f32); - println!("{}", c); - } -} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_filter.rs index 91768a5cf3..5fa9c11242 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_filter.rs @@ -1,9 +1,13 @@ +use crate::entities::ChecklistCellDataChangesetPB; +use crate::entities::{ChecklistFilterConditionPB, ChecklistFilterPB}; +use crate::services::filter::PreFillCellsWithFilter; + +use collab_database::fields::select_type_option::SelectOption; use collab_database::fields::Field; use collab_database::rows::Cell; +use collab_database::template::check_list_parse::ChecklistCellData; -use crate::entities::{ChecklistFilterConditionPB, ChecklistFilterPB}; -use crate::services::field::SelectOption; -use crate::services::filter::PreFillCellsWithFilter; +use std::fmt::Debug; impl ChecklistFilterPB { pub fn is_visible( @@ -43,7 +47,86 @@ impl ChecklistFilterPB { } impl PreFillCellsWithFilter for ChecklistFilterPB { - fn get_compliant_cell(&self, _field: &Field) -> (Option, bool) { - (None, true) + fn get_compliant_cell(&self, _field: &Field) -> Option { + None + } +} + +pub fn checklist_from_options(new_tasks: Vec) -> ChecklistCellData { + let (options, selected_ids): (Vec<_>, Vec<_>) = new_tasks + .into_iter() + .map(|new_task| { + let option = SelectOption::new(&new_task.name); + let selected_id = new_task.is_complete.then(|| option.id.clone()); + (option, selected_id) + }) + .unzip(); + let selected_option_ids = selected_ids.into_iter().flatten().collect(); + + ChecklistCellData { + options, + selected_option_ids, + } +} + +#[derive(Debug, Clone, Default)] +pub struct ChecklistCellChangeset { + pub insert_tasks: Vec, + pub delete_tasks: Vec, + pub update_tasks: Vec, + pub completed_task_ids: Vec, + pub reorder: String, +} + +impl From for ChecklistCellChangeset { + fn from(value: ChecklistCellDataChangesetPB) -> Self { + ChecklistCellChangeset { + insert_tasks: value + .insert_task + .into_iter() + .map(|pb| ChecklistCellInsertChangeset { + name: pb.name, + is_complete: false, + index: pb.index, + }) + .collect(), + delete_tasks: value.delete_tasks, + update_tasks: value + .update_tasks + .into_iter() + .map(SelectOption::from) + .collect(), + completed_task_ids: value.completed_tasks, + reorder: value.reorder, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct ChecklistCellInsertChangeset { + pub name: String, + pub is_complete: bool, + pub index: Option, +} + +impl ChecklistCellInsertChangeset { + pub fn new(name: String, is_complete: bool) -> Self { + Self { + name, + is_complete, + index: None, + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn test() { + let a = 1; + let b = 2; + + let c = (a as f32) / (b as f32); + println!("{}", c); } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_type_option.rs new file mode 100644 index 0000000000..a9043f227c --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_type_option.rs @@ -0,0 +1,186 @@ +use crate::entities::{ChecklistCellDataPB, ChecklistFilterPB, SelectOptionPB}; +use crate::services::cell::{CellDataChangeset, CellDataDecoder}; +use crate::services::field::checklist_filter::{checklist_from_options, ChecklistCellChangeset}; +use crate::services::field::{ + CellDataProtobufEncoder, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, + TypeOptionTransform, +}; +use crate::services::sort::SortCondition; +use collab_database::fields::checklist_type_option::ChecklistTypeOption; +use collab_database::fields::select_type_option::{SelectOption, SELECTION_IDS_SEPARATOR}; +use collab_database::rows::Cell; +use collab_database::template::check_list_parse::ChecklistCellData; +use collab_database::template::util::TypeOptionCellData; +use flowy_error::FlowyResult; +use std::cmp::Ordering; + +impl TypeOption for ChecklistTypeOption { + type CellData = ChecklistCellData; + type CellChangeset = ChecklistCellChangeset; + type CellProtobufType = ChecklistCellDataPB; + type CellFilter = ChecklistFilterPB; +} + +impl CellDataProtobufEncoder for ChecklistTypeOption { + fn protobuf_encode( + &self, + cell_data: ::CellData, + ) -> ::CellProtobufType { + let percentage = cell_data.percentage_complete(); + let selected_options = cell_data + .options + .iter() + .filter(|option| cell_data.selected_option_ids.contains(&option.id)) + .map(|option| SelectOptionPB::from(option.clone())) + .collect(); + + let options = cell_data + .options + .into_iter() + .map(SelectOptionPB::from) + .collect(); + + ChecklistCellDataPB { + options, + selected_options, + percentage, + } + } +} + +impl CellDataChangeset for ChecklistTypeOption { + fn apply_changeset( + &self, + changeset: ::CellChangeset, + cell: Option, + ) -> FlowyResult<(Cell, ::CellData)> { + match cell { + Some(cell) => { + let mut cell_data = self.decode_cell(&cell)?; + update_cell_data_with_changeset(&mut cell_data, changeset); + Ok((Cell::from(cell_data.clone()), cell_data)) + }, + None => { + let cell_data = checklist_from_options(changeset.insert_tasks); + Ok((Cell::from(cell_data.clone()), cell_data)) + }, + } + } +} + +#[inline] +fn update_cell_data_with_changeset( + cell_data: &mut ChecklistCellData, + changeset: ChecklistCellChangeset, +) { + // Delete the options + cell_data + .options + .retain(|option| !changeset.delete_tasks.contains(&option.id)); + cell_data + .selected_option_ids + .retain(|option_id| !changeset.delete_tasks.contains(option_id)); + + // Insert new options + changeset.insert_tasks.into_iter().for_each(|new_task| { + let option = SelectOption::new(&new_task.name); + if new_task.is_complete { + cell_data.selected_option_ids.push(option.id.clone()) + } + match new_task.index { + Some(index) => cell_data.options.insert(index as usize, option), + None => cell_data.options.push(option), + }; + }); + + // Update options + changeset + .update_tasks + .into_iter() + .for_each(|updated_option| { + if let Some(option) = cell_data + .options + .iter_mut() + .find(|option| option.id == updated_option.id) + { + option.name = updated_option.name; + } + }); + + // Select the options + changeset + .completed_task_ids + .into_iter() + .for_each(|option_id| { + if let Some(index) = cell_data + .selected_option_ids + .iter() + .position(|id| **id == option_id) + { + cell_data.selected_option_ids.remove(index); + } else { + cell_data.selected_option_ids.push(option_id); + } + }); + + // Reorder + let mut split = changeset.reorder.split(' ').take(2); + if let (Some(from), Some(to)) = (split.next(), split.next()) { + if let (Some(from_index), Some(to_index)) = ( + cell_data + .options + .iter() + .position(|option| option.id == from), + cell_data.options.iter().position(|option| option.id == to), + ) { + let option = cell_data.options.remove(from_index); + cell_data.options.insert(to_index, option); + } + } +} + +impl CellDataDecoder for ChecklistTypeOption { + fn stringify_cell_data(&self, cell_data: ::CellData) -> String { + cell_data + .options + .into_iter() + .map(|option| option.name) + .collect::>() + .join(SELECTION_IDS_SEPARATOR) + } +} + +impl TypeOptionCellDataFilter for ChecklistTypeOption { + fn apply_filter( + &self, + filter: &::CellFilter, + cell_data: &::CellData, + ) -> bool { + let selected_options = cell_data.selected_options(); + filter.is_visible(&cell_data.options, &selected_options) + } +} + +impl TypeOptionCellDataCompare for ChecklistTypeOption { + fn apply_cmp( + &self, + cell_data: &::CellData, + other_cell_data: &::CellData, + sort_condition: SortCondition, + ) -> Ordering { + match (cell_data.is_cell_empty(), other_cell_data.is_cell_empty()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => { + let left = cell_data.percentage_complete(); + let right = other_cell_data.percentage_complete(); + // safe to unwrap because the two floats won't be NaN + let order = left.partial_cmp(&right).unwrap(); + sort_condition.evaluate_order(order) + }, + } + } +} + +impl TypeOptionTransform for ChecklistTypeOption {} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/mod.rs index be51a38db8..85ff615900 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/mod.rs @@ -1,6 +1,3 @@ -mod checklist; -mod checklist_entities; -mod checklist_filter; - -pub use checklist::*; -pub use checklist_entities::*; +#![allow(clippy::module_inception)] +pub mod checklist_filter; +pub mod checklist_type_option; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_filter.rs index 42a0300e18..3f0808fac2 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_filter.rs @@ -1,38 +1,81 @@ use crate::entities::{DateFilterConditionPB, DateFilterPB}; use crate::services::cell::insert_date_cell; -use crate::services::field::DateCellData; use crate::services::filter::PreFillCellsWithFilter; -use chrono::{Duration, NaiveDate, NaiveDateTime}; +use bytes::Bytes; +use chrono::{Duration, Local, NaiveDate, TimeZone}; +use collab_database::fields::date_type_option::DateCellData; use collab_database::fields::Field; use collab_database::rows::Cell; +use collab_database::template::timestamp_parse::TimestampCellData; +use flowy_error::{internal_error, FlowyResult}; + +use crate::entities::DateCellDataPB; +use crate::services::cell::CellProtobufBlobParser; impl DateFilterPB { /// Returns `None` if the DateFilterPB doesn't have the necessary data for /// the condition. For example, `start` and `end` timestamps for - /// `DateFilterConditionPB::DateWithin`. + /// `DateFilterConditionPB::DateStartsBetween`. pub fn is_visible(&self, cell_data: &DateCellData) -> Option { - let strategy = match self.condition { - DateFilterConditionPB::DateIs => DateFilterStrategy::On(self.timestamp?), - DateFilterConditionPB::DateBefore => DateFilterStrategy::Before(self.timestamp?), - DateFilterConditionPB::DateAfter => DateFilterStrategy::After(self.timestamp?), - DateFilterConditionPB::DateOnOrBefore => DateFilterStrategy::OnOrBefore(self.timestamp?), - DateFilterConditionPB::DateOnOrAfter => DateFilterStrategy::OnOrAfter(self.timestamp?), - DateFilterConditionPB::DateWithIn => DateFilterStrategy::DateWithin { - start: self.start?, - end: self.end?, - }, - DateFilterConditionPB::DateIsEmpty => DateFilterStrategy::Empty, - DateFilterConditionPB::DateIsNotEmpty => DateFilterStrategy::NotEmpty, + let strategy = self.get_strategy()?; + + let timestamp = if self.condition.is_filter_on_start_timestamp() { + cell_data.timestamp + } else { + cell_data.end_timestamp.or(cell_data.timestamp) }; - Some(strategy.filter(cell_data)) + Some(strategy.filter(timestamp)) + } + + pub fn is_timestamp_cell_data_visible(&self, cell_data: &TimestampCellData) -> Option { + let strategy = self.get_strategy()?; + + Some(strategy.filter(cell_data.timestamp)) + } + + fn get_strategy(&self) -> Option { + let strategy = match self.condition { + DateFilterConditionPB::DateStartsOn | DateFilterConditionPB::DateEndsOn => { + DateFilterStrategy::On(self.timestamp?) + }, + DateFilterConditionPB::DateStartsBefore | DateFilterConditionPB::DateEndsBefore => { + DateFilterStrategy::Before(self.timestamp?) + }, + DateFilterConditionPB::DateStartsAfter | DateFilterConditionPB::DateEndsAfter => { + DateFilterStrategy::After(self.timestamp?) + }, + DateFilterConditionPB::DateStartsOnOrBefore | DateFilterConditionPB::DateEndsOnOrBefore => { + DateFilterStrategy::OnOrBefore(self.timestamp?) + }, + DateFilterConditionPB::DateStartsOnOrAfter | DateFilterConditionPB::DateEndsOnOrAfter => { + DateFilterStrategy::OnOrAfter(self.timestamp?) + }, + DateFilterConditionPB::DateStartsBetween | DateFilterConditionPB::DateEndsBetween => { + DateFilterStrategy::DateBetween { + start: self.start?, + end: self.end?, + } + }, + DateFilterConditionPB::DateStartIsEmpty | DateFilterConditionPB::DateEndIsEmpty => { + DateFilterStrategy::Empty + }, + DateFilterConditionPB::DateStartIsNotEmpty | DateFilterConditionPB::DateEndIsNotEmpty => { + DateFilterStrategy::NotEmpty + }, + }; + + Some(strategy) } } #[inline] fn naive_date_from_timestamp(timestamp: i64) -> Option { - NaiveDateTime::from_timestamp_opt(timestamp, 0).map(|date_time: NaiveDateTime| date_time.date()) + Local + .timestamp_opt(timestamp, 0) + .single() + .map(|date_time| date_time.date_naive()) } enum DateFilterStrategy { @@ -41,134 +84,193 @@ enum DateFilterStrategy { After(i64), OnOrBefore(i64), OnOrAfter(i64), - DateWithin { start: i64, end: i64 }, + DateBetween { start: i64, end: i64 }, Empty, NotEmpty, } impl DateFilterStrategy { - fn filter(self, cell_data: &DateCellData) -> bool { + fn filter(self, cell_data: Option) -> bool { match self { - DateFilterStrategy::On(expected_timestamp) => cell_data.timestamp.is_some_and(|timestamp| { + DateFilterStrategy::On(expected_timestamp) => cell_data.is_some_and(|timestamp| { let cell_date = naive_date_from_timestamp(timestamp); let expected_date = naive_date_from_timestamp(expected_timestamp); cell_date == expected_date }), - DateFilterStrategy::Before(expected_timestamp) => { - cell_data.timestamp.is_some_and(|timestamp| { - let cell_date = naive_date_from_timestamp(timestamp); - let expected_date = naive_date_from_timestamp(expected_timestamp); - cell_date < expected_date - }) + DateFilterStrategy::Before(expected_timestamp) => cell_data.is_some_and(|timestamp| { + let cell_date = naive_date_from_timestamp(timestamp); + let expected_date = naive_date_from_timestamp(expected_timestamp); + cell_date < expected_date + }), + DateFilterStrategy::After(expected_timestamp) => cell_data.is_some_and(|timestamp| { + let cell_date = naive_date_from_timestamp(timestamp); + let expected_date = naive_date_from_timestamp(expected_timestamp); + cell_date > expected_date + }), + DateFilterStrategy::OnOrBefore(expected_timestamp) => cell_data.is_some_and(|timestamp| { + let cell_date = naive_date_from_timestamp(timestamp); + let expected_date = naive_date_from_timestamp(expected_timestamp); + cell_date <= expected_date + }), + DateFilterStrategy::OnOrAfter(expected_timestamp) => cell_data.is_some_and(|timestamp| { + let cell_date = naive_date_from_timestamp(timestamp); + let expected_date = naive_date_from_timestamp(expected_timestamp); + cell_date >= expected_date + }), + DateFilterStrategy::DateBetween { start, end } => cell_data.is_some_and(|timestamp| { + let cell_date = naive_date_from_timestamp(timestamp); + let expected_start_date = naive_date_from_timestamp(start); + let expected_end_date = naive_date_from_timestamp(end); + cell_date >= expected_start_date && cell_date <= expected_end_date + }), + DateFilterStrategy::Empty => match cell_data { + None => true, + Some(timestamp) if naive_date_from_timestamp(timestamp).is_none() => true, + _ => false, }, - DateFilterStrategy::After(expected_timestamp) => { - cell_data.timestamp.is_some_and(|timestamp| { - let cell_date = naive_date_from_timestamp(timestamp); - let expected_date = naive_date_from_timestamp(expected_timestamp); - cell_date > expected_date - }) + DateFilterStrategy::NotEmpty => { + matches!(cell_data, Some(timestamp) if naive_date_from_timestamp(timestamp).is_some() ) }, - DateFilterStrategy::OnOrBefore(expected_timestamp) => { - cell_data.timestamp.is_some_and(|timestamp| { - let cell_date = naive_date_from_timestamp(timestamp); - let expected_date = naive_date_from_timestamp(expected_timestamp); - cell_date <= expected_date - }) - }, - DateFilterStrategy::OnOrAfter(expected_timestamp) => { - cell_data.timestamp.is_some_and(|timestamp| { - let cell_date = naive_date_from_timestamp(timestamp); - let expected_date = naive_date_from_timestamp(expected_timestamp); - cell_date >= expected_date - }) - }, - DateFilterStrategy::DateWithin { start, end } => { - cell_data.timestamp.is_some_and(|timestamp| { - let cell_date = naive_date_from_timestamp(timestamp); - let expected_start_date = naive_date_from_timestamp(start); - let expected_end_date = naive_date_from_timestamp(end); - cell_date >= expected_start_date && cell_date <= expected_end_date - }) - }, - DateFilterStrategy::Empty => { - cell_data.timestamp.is_none() && cell_data.end_timestamp.is_none() - }, - DateFilterStrategy::NotEmpty => cell_data.timestamp.is_some(), } } } impl PreFillCellsWithFilter for DateFilterPB { - fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { - let timestamp = match self.condition { - DateFilterConditionPB::DateIs - | DateFilterConditionPB::DateOnOrBefore - | DateFilterConditionPB::DateOnOrAfter => self.timestamp, - DateFilterConditionPB::DateBefore => self + fn get_compliant_cell(&self, field: &Field) -> Option { + let start_timestamp = match self.condition { + DateFilterConditionPB::DateStartsOn + | DateFilterConditionPB::DateStartsOnOrBefore + | DateFilterConditionPB::DateStartsOnOrAfter + | DateFilterConditionPB::DateEndsOn + | DateFilterConditionPB::DateEndsOnOrBefore + | DateFilterConditionPB::DateEndsOnOrAfter => self.timestamp, + DateFilterConditionPB::DateStartsBefore | DateFilterConditionPB::DateEndsBefore => self .timestamp - .and_then(|timestamp| NaiveDateTime::from_timestamp_opt(timestamp, 0)) - .map(|date_time| { + .and_then(|timestamp| { + Local + .timestamp_opt(timestamp, 0) + .single() + .map(|date| date.naive_local()) + }) + .and_then(|date_time| { let answer = date_time - Duration::days(1); - answer.timestamp() + Local + .from_local_datetime(&answer) + .single() + .map(|date_time| date_time.timestamp()) }), - DateFilterConditionPB::DateAfter => self + DateFilterConditionPB::DateStartsAfter | DateFilterConditionPB::DateEndsAfter => self .timestamp - .and_then(|timestamp| NaiveDateTime::from_timestamp_opt(timestamp, 0)) - .map(|date_time| { + .and_then(|timestamp| { + Local + .timestamp_opt(timestamp, 0) + .single() + .map(|date| date.naive_local()) + }) + .and_then(|date_time| { let answer = date_time + Duration::days(1); - answer.timestamp() + Local + .from_local_datetime(&answer) + .single() + .map(|date_time| date_time.timestamp()) }), - DateFilterConditionPB::DateWithIn => self.start, + DateFilterConditionPB::DateStartsBetween | DateFilterConditionPB::DateEndsBetween => { + self.start + }, _ => None, }; - let open_after_create = matches!(self.condition, DateFilterConditionPB::DateIsNotEmpty); + start_timestamp.map(|timestamp| insert_date_cell(timestamp, None, None, field)) + } +} - ( - timestamp.map(|timestamp| insert_date_cell(timestamp, None, None, field)), - open_after_create, - ) +#[derive(Clone, Debug, Default)] +pub struct DateCellChangeset { + pub timestamp: Option, + pub end_timestamp: Option, + pub include_time: Option, + pub is_range: Option, + pub clear_flag: Option, + pub reminder_id: Option, +} + +pub struct DateCellDataParser(); +impl CellProtobufBlobParser for DateCellDataParser { + type Object = DateCellDataPB; + + fn parser(bytes: &Bytes) -> FlowyResult { + DateCellDataPB::try_from(bytes.as_ref()).map_err(internal_error) } } #[cfg(test)] mod tests { use crate::entities::{DateFilterConditionPB, DateFilterPB}; - use crate::services::field::DateCellData; + use collab_database::fields::date_type_option::DateCellData; - fn to_cell_data(timestamp: i32) -> DateCellData { - DateCellData::new(timestamp as i64, false, false, "".to_string()) + fn to_cell_data(timestamp: Option, end_timestamp: Option) -> DateCellData { + DateCellData { + timestamp, + end_timestamp, + ..Default::default() + } } #[test] fn date_filter_is_test() { let filter = DateFilterPB { - condition: DateFilterConditionPB::DateIs, + condition: DateFilterConditionPB::DateStartsOn, timestamp: Some(1668387885), end: None, start: None, }; - for (val, visible) in [(1668387885, true), (1647251762, false)] { - assert_eq!(filter.is_visible(&to_cell_data(val)).unwrap(), visible); + for (start, end, is_visible) in [ + (Some(1668387885), None, true), + (Some(1647251762), None, false), + (None, None, false), + ] { + assert_eq!( + filter.is_visible(&to_cell_data(start, end)).unwrap_or(true), + is_visible + ); + } + + let filter = DateFilterPB { + condition: DateFilterConditionPB::DateStartsOn, + timestamp: None, + end: None, + start: None, + }; + + for (start, end, is_visible) in [ + (Some(1668387885), None, true), + (Some(1647251762), None, true), + (None, None, true), + ] { + assert_eq!( + filter.is_visible(&to_cell_data(start, end)).unwrap_or(true), + is_visible + ); } } #[test] fn date_filter_before_test() { let filter = DateFilterPB { - condition: DateFilterConditionPB::DateBefore, + condition: DateFilterConditionPB::DateStartsBefore, timestamp: Some(1668387885), start: None, end: None, }; - for (val, visible, msg) in [(1668387884, false, "1"), (1647251762, true, "2")] { + for (start, end, is_visible) in [ + (Some(1668387884), None, false), + (Some(1647251762), None, true), + ] { assert_eq!( - filter.is_visible(&to_cell_data(val)).unwrap(), - visible, - "{}", - msg + filter.is_visible(&to_cell_data(start, end)).unwrap_or(true), + is_visible, ); } } @@ -176,67 +278,327 @@ mod tests { #[test] fn date_filter_before_or_on_test() { let filter = DateFilterPB { - condition: DateFilterConditionPB::DateOnOrBefore, + condition: DateFilterConditionPB::DateStartsOnOrBefore, timestamp: Some(1668387885), start: None, end: None, }; - for (val, visible) in [(1668387884, true), (1668387885, true)] { - assert_eq!(filter.is_visible(&to_cell_data(val)).unwrap(), visible); + for (start, end, is_visible) in [ + (Some(1668387884), None, true), + (Some(1668387885), None, true), + ] { + assert_eq!( + filter.is_visible(&to_cell_data(start, end)).unwrap_or(true), + is_visible + ); } } #[test] fn date_filter_after_test() { let filter = DateFilterPB { - condition: DateFilterConditionPB::DateAfter, + condition: DateFilterConditionPB::DateStartsAfter, timestamp: Some(1668387885), start: None, end: None, }; - for (val, visible) in [(1668387888, false), (1668531885, true), (0, false)] { - assert_eq!(filter.is_visible(&to_cell_data(val)).unwrap(), visible); + for (start, end, is_visible) in [ + (Some(1668387888), None, false), + (Some(1668531885), None, true), + (Some(0), None, false), + ] { + assert_eq!( + filter.is_visible(&to_cell_data(start, end)).unwrap_or(true), + is_visible + ); } } #[test] fn date_filter_within_test() { let filter = DateFilterPB { - condition: DateFilterConditionPB::DateWithIn, + condition: DateFilterConditionPB::DateStartsBetween, start: Some(1668272685), // 11/13 end: Some(1668618285), // 11/17 timestamp: None, }; - for (val, visible, _msg) in [ - (1668272685, true, "11/13"), - (1668359085, true, "11/14"), - (1668704685, false, "11/18"), + for (start, end, is_visible, msg) in [ + (Some(1668272685), None, true, "11/13"), + (Some(1668359085), None, true, "11/14"), + (Some(1668704685), None, false, "11/18"), + (None, None, false, "empty"), ] { - assert_eq!(filter.is_visible(&to_cell_data(val)).unwrap(), visible); + assert_eq!( + filter.is_visible(&to_cell_data(start, end)).unwrap_or(true), + is_visible, + "{msg}" + ); + } + + let filter = DateFilterPB { + condition: DateFilterConditionPB::DateStartsBetween, + start: None, + end: Some(1668618285), // 11/17 + timestamp: None, + }; + + for (start, end, is_visible, msg) in [ + (Some(1668272685), None, true, "11/13"), + (Some(1668359085), None, true, "11/14"), + (Some(1668704685), None, true, "11/18"), + (None, None, true, "empty"), + ] { + assert_eq!( + filter.is_visible(&to_cell_data(start, end)).unwrap_or(true), + is_visible, + "{msg}" + ); } } #[test] fn date_filter_is_empty_test() { let filter = DateFilterPB { - condition: DateFilterConditionPB::DateIsEmpty, + condition: DateFilterConditionPB::DateStartIsEmpty, start: None, end: None, timestamp: None, }; - for (val, visible) in [(None, true), (Some(123), false)] { + for (start, end, is_visible) in [(None, None, true), (Some(123), None, false)] { assert_eq!( - filter - .is_visible(&DateCellData { - timestamp: val, - ..Default::default() - }) - .unwrap(), - visible + filter.is_visible(&to_cell_data(start, end)).unwrap_or(true), + is_visible ); } } + + #[test] + fn date_filter_end_test() { + let filter = DateFilterPB { + condition: DateFilterConditionPB::DateEndsOnOrBefore, + timestamp: Some(1668359085), // 11/14 + end: None, + start: None, + }; + + for (start, end, is_visible, msg) in [ + (Some(1668272685), None, true, "11/13"), + (Some(1668359085), None, true, "11/14"), + (Some(1668704685), None, false, "11/18"), + (None, None, false, "empty"), + (Some(1668272685), Some(1668272685), true, "11/13"), + (Some(1668272685), Some(1668359085), true, "11/14"), + (Some(1668272685), Some(1668704685), false, "11/18"), + ] { + assert_eq!( + filter.is_visible(&to_cell_data(start, end)).unwrap_or(true), + is_visible, + "{msg}" + ); + } + + let filter = DateFilterPB { + condition: DateFilterConditionPB::DateEndsOnOrBefore, + timestamp: None, + start: None, + end: None, + }; + + for (start, end, is_visible, msg) in [ + (Some(1668272685), Some(1668272685), true, "11/13"), + (Some(1668272685), Some(1668359085), true, "11/14"), + (Some(1668272685), Some(1668704685), true, "11/18"), + (None, None, true, "empty"), + ] { + assert_eq!( + filter.is_visible(&to_cell_data(start, end)).unwrap_or(true), + is_visible, + "{msg}" + ); + } + } + + // #[test] + // fn timezoned_filter_test() { + // let filter = DateFilterPB { + // condition: DateFilterConditionPB::DateStartsOn, + // timestamp: Some(1728975660), // Oct 15, 2024 00:00 PDT + // end: None, + // start: None, + // }; + // + // for (start, end, is_visible, msg) in [ + // ( + // Some(1728889200), + // None, + // false, + // "10/14/2024 00:00 PDT, 10/14/2024 07:00 GMT", + // ), + // ( + // Some(1728889260), + // None, + // false, + // "10/14/2024 00:01 PDT, 10/14/2024 07:01 GMT", + // ), + // ( + // Some(1728900000), + // None, + // false, + // "10/14/2024 03:00 PDT, 10/14/2024 10:00 GMT", + // ), + // ( + // Some(1728921600), + // None, + // false, + // "10/14/2024 09:00 PDT, 10/14/2024 16:00 GMT", + // ), + // ( + // Some(1728932400), + // None, + // false, + // "10/14/2024 12:00 PDT, 10/14/2024 19:00 GMT", + // ), + // ( + // Some(1728943200), + // None, + // false, + // "10/14/2024 15:00 PDT, 10/14/2024 22:00 GMT", + // ), + // ( + // Some(1728954000), + // None, + // false, + // "10/14/2024 18:00 PDT, 10/15/2024 01:00 GMT", + // ), + // ( + // Some(1728964800), + // None, + // false, + // "10/14/2024 21:00 PDT, 10/15/2024 04:00 GMT", + // ), + // ( + // Some(1728975540), + // None, + // false, + // "10/14/2024 23:59 PDT, 10/15/2024 06:59 GMT", + // ), + // ( + // Some(1728975600), + // None, + // true, + // "10/15/2024 00:00 PDT, 10/15/2024 07:00 GMT", + // ), + // ( + // Some(1728975660), + // None, + // true, + // "10/15/2024 00:01 PDT, 10/15/2024 07:01 GMT", + // ), + // ( + // Some(1728986400), + // None, + // true, + // "10/15/2024 03:00 PDT, 10/15/2024 10:00 GMT", + // ), + // ( + // Some(1729008000), + // None, + // true, + // "10/15/2024 09:00 PDT, 10/15/2024 16:00 GMT", + // ), + // ( + // Some(1729018800), + // None, + // true, + // "10/15/2024 12:00 PDT, 10/15/2024 19:00 GMT", + // ), + // ( + // Some(1729029600), + // None, + // true, + // "10/15/2024 15:00 PDT, 10/15/2024 22:00 GMT", + // ), + // ( + // Some(1729040400), + // None, + // true, + // "10/15/2024 18:00 PDT, 10/16/2024 01:00 GMT", + // ), + // ( + // Some(1729051200), + // None, + // true, + // "10/15/2024 21:00 PDT, 10/16/2024 04:00 GMT", + // ), + // ( + // Some(1729061940), + // None, + // true, + // "10/15/2024 23:59 PDT, 10/16/2024 06:59 GMT", + // ), + // ( + // Some(1729062000), + // None, + // false, + // "10/16/2024 00:00 PDT, 10/16/2024 07:00 GMT", + // ), + // ( + // Some(1729062060), + // None, + // false, + // "10/16/2024 00:01 PDT, 10/16/2024 07:01 GMT", + // ), + // ( + // Some(1729072800), + // None, + // false, + // "10/16/2024 03:00 PDT, 10/16/2024 10:00 GMT", + // ), + // ( + // Some(1729094400), + // None, + // false, + // "10/16/2024 09:00 PDT, 10/16/2024 16:00 GMT", + // ), + // ( + // Some(1729105200), + // None, + // false, + // "10/16/2024 12:00 PDT, 10/16/2024 19:00 GMT", + // ), + // ( + // Some(1729116000), + // None, + // false, + // "10/16/2024 15:00 PDT, 10/16/2024 22:00 GMT", + // ), + // ( + // Some(1729126800), + // None, + // false, + // "10/16/2024 18:00 PDT, 10/17/2024 01:00 GMT", + // ), + // ( + // Some(1729137600), + // None, + // false, + // "10/16/2024 21:00 PDT, 10/17/2024 04:00 GMT", + // ), + // ( + // Some(1729148340), + // None, + // false, + // "10/16/2024 23:59 PDT, 10/17/2024 06:59 GMT", + // ), + // ] { + // assert_eq!( + // filter.is_visible(&to_cell_data(start, end)).unwrap_or(true), + // is_visible, + // "{msg}" + // ); + // } + // } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_tests.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_tests.rs index 8c67f8bc5c..dbaf0be3ea 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_tests.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_tests.rs @@ -1,554 +1,238 @@ #[cfg(test)] mod tests { - use chrono::format::strftime::StrftimeItems; - use chrono::{FixedOffset, NaiveDateTime}; - use collab_database::fields::Field; use collab_database::rows::Cell; - use strum::IntoEnumIterator; - use crate::entities::FieldType; use crate::services::cell::{CellDataChangeset, CellDataDecoder}; - use crate::services::field::{ - DateCellChangeset, DateFormat, DateTypeOption, FieldBuilder, TimeFormat, - }; + use crate::services::field::date_type_option::date_filter::DateCellChangeset; + use collab_database::fields::date_type_option::{DateCellData, DateTypeOption}; #[test] - fn date_type_option_date_format_test() { - let mut type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(FieldType::DateTime).build(); - for date_format in DateFormat::iter() { - type_option.date_format = date_format; - match date_format { - DateFormat::Friendly => { - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1647251762), - time: None, - include_time: None, - ..Default::default() - }, - None, - "Mar 14, 2022", - ); - }, - DateFormat::US => { - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1647251762), - time: None, - include_time: None, - ..Default::default() - }, - None, - "2022/03/14", - ); - }, - DateFormat::ISO => { - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1647251762), - time: None, - include_time: None, - ..Default::default() - }, - None, - "2022-03-14", - ); - }, - DateFormat::Local => { - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1647251762), - time: None, - include_time: None, - ..Default::default() - }, - None, - "03/14/2022", - ); - }, - DateFormat::DayMonthYear => { - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1647251762), - time: None, - include_time: None, - ..Default::default() - }, - None, - "14/03/2022", - ); - }, - } - } - } - - #[test] - fn date_type_option_different_time_format_test() { - let mut type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(FieldType::DateTime).build(); - - for time_format in TimeFormat::iter() { - type_option.time_format = time_format; - match time_format { - TimeFormat::TwentyFourHour => { - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - time: None, - include_time: Some(true), - ..Default::default() - }, - None, - "May 27, 2022 00:00", - ); - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - time: Some("9:00".to_owned()), - include_time: Some(true), - ..Default::default() - }, - None, - "May 27, 2022 09:00", - ); - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - time: Some("23:00".to_owned()), - include_time: Some(true), - ..Default::default() - }, - None, - "May 27, 2022 23:00", - ); - }, - TimeFormat::TwelveHour => { - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - time: None, - include_time: Some(true), - ..Default::default() - }, - None, - "May 27, 2022 12:00 AM", - ); - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - time: Some("9:00 AM".to_owned()), - include_time: Some(true), - ..Default::default() - }, - None, - "May 27, 2022 09:00 AM", - ); - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - time: Some("11:23 pm".to_owned()), - include_time: Some(true), - ..Default::default() - }, - None, - "May 27, 2022 11:23 PM", - ); - }, - } - } - } - - #[test] - #[should_panic] - fn date_type_option_invalid_include_time_str_test() { - let field_type = FieldType::DateTime; - let type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(field_type).build(); + fn apply_changeset_to_empty_cell() { + let type_option = DateTypeOption::default_utc(); assert_date( &type_option, - &field, DateCellChangeset { - date: Some(1653609600), - time: Some("1:".to_owned()), + timestamp: Some(1653782400), include_time: Some(true), ..Default::default() }, None, - "May 27, 2022 01:00", + &DateCellData { + timestamp: Some(1653782400), + include_time: true, + ..Default::default() + }, ); - } - - #[test] - #[should_panic] - fn date_type_option_empty_include_time_str_test() { - let field_type = FieldType::DateTime; - let type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(field_type).build(); assert_date( &type_option, - &field, DateCellChangeset { - date: Some(1653609600), - time: Some("".to_owned()), - include_time: Some(true), - ..Default::default() - }, - None, - "May 27, 2022 01:00", - ); - } - - #[test] - fn date_type_midnight_include_time_str_test() { - let field_type = FieldType::DateTime; - let type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(field_type).build(); - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - time: Some("00:00".to_owned()), - include_time: Some(true), - ..Default::default() - }, - None, - "May 27, 2022 00:00", - ); - } - - /// The default time format is TwentyFourHour, so the include_time_str in - /// twelve_hours_format will cause parser error. - #[test] - #[should_panic] - fn date_type_option_twelve_hours_include_time_str_in_twenty_four_hours_format() { - let type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(FieldType::DateTime).build(); - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - time: Some("1:00 am".to_owned()), - include_time: Some(true), - ..Default::default() - }, - None, - "May 27, 2022 01:00 AM", - ); - } - - /// Attempting to parse include_time_str as TwelveHour when TwentyFourHour - /// format is given should cause parser error. - #[test] - #[should_panic] - fn date_type_option_twenty_four_hours_include_time_str_in_twelve_hours_format() { - let field_type = FieldType::DateTime; - let mut type_option = DateTypeOption::test(); - type_option.time_format = TimeFormat::TwelveHour; - let field = FieldBuilder::from_field_type(field_type).build(); - - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - time: Some("20:00".to_owned()), - include_time: Some(true), - ..Default::default() - }, - None, - "May 27, 2022 08:00 PM", - ); - } - - #[test] - fn utc_to_native_test() { - let native_timestamp = 1647251762; - let native = NaiveDateTime::from_timestamp_opt(native_timestamp, 0).unwrap(); - - let utc = chrono::DateTime::::from_naive_utc_and_offset(native, chrono::Utc); - // utc_timestamp doesn't carry timezone - let utc_timestamp = utc.timestamp(); - assert_eq!(native_timestamp, utc_timestamp); - - let format = "%m/%d/%Y %I:%M %p".to_string(); - let native_time_str = format!("{}", native.format_with_items(StrftimeItems::new(&format))); - let utc_time_str = format!("{}", utc.format_with_items(StrftimeItems::new(&format))); - assert_eq!(native_time_str, utc_time_str); - - // Mon Mar 14 2022 17:56:02 GMT+0800 (China Standard Time) - let gmt_8_offset = FixedOffset::east_opt(8 * 3600).unwrap(); - let china_local = - chrono::DateTime::::from_naive_utc_and_offset(native, gmt_8_offset); - let china_local_time = format!( - "{}", - china_local.format_with_items(StrftimeItems::new(&format)) - ); - - assert_eq!(china_local_time, "03/14/2022 05:56 PM"); - } - - /// The time component shouldn't remain the same since the timestamp is - /// completely overwritten. To achieve the desired result, also pass in the - /// time string along with the new timestamp. - #[test] - #[should_panic] - fn update_date_keep_time() { - let type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(FieldType::DateTime).build(); - - let old_cell_data = initialize_date_cell( - &type_option, - DateCellChangeset { - date: Some(1700006400), - time: Some("08:00".to_owned()), - include_time: Some(true), - ..Default::default() - }, - ); - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1701302400), - time: None, - include_time: None, - ..Default::default() - }, - Some(old_cell_data), - "Nov 30, 2023 08:00", - ); - } - - #[test] - fn update_time_keep_date() { - let type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(FieldType::DateTime).build(); - - let old_cell_data = initialize_date_cell( - &type_option, - DateCellChangeset { - date: Some(1700006400), - time: Some("08:00".to_owned()), - include_time: Some(true), - ..Default::default() - }, - ); - assert_date( - &type_option, - &field, - DateCellChangeset { - date: None, - time: Some("14:00".to_owned()), - include_time: None, - ..Default::default() - }, - Some(old_cell_data), - "Nov 15, 2023 14:00", - ); - } - - #[test] - fn clear_date() { - let type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(FieldType::DateTime).build(); - - let old_cell_data = initialize_date_cell( - &type_option, - DateCellChangeset { - date: Some(1700006400), - time: Some("08:00".to_owned()), - include_time: Some(true), - ..Default::default() - }, - ); - assert_date( - &type_option, - &field, - DateCellChangeset { - date: None, - time: None, - include_time: Some(true), - clear_flag: Some(true), - ..Default::default() - }, - Some(old_cell_data), - "", - ); - } - - #[test] - fn end_date_time_test() { - let type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(FieldType::DateTime).build(); - - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - end_date: Some(1653782400), - include_time: Some(false), + timestamp: Some(1625130000), + end_timestamp: Some(1653782400), is_range: Some(true), ..Default::default() }, None, - "May 27, 2022 → May 29, 2022", - ); - - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - time: Some("20:00".to_owned()), - end_date: Some(1653782400), - end_time: Some("08:00".to_owned()), - include_time: Some(true), - is_range: Some(true), + &DateCellData { + timestamp: Some(1625130000), + end_timestamp: Some(1653782400), + is_range: true, ..Default::default() }, - None, - "May 27, 2022 20:00 → May 29, 2022 08:00", - ); - - assert_date( - &type_option, - &field, - DateCellChangeset { - date: Some(1653609600), - time: Some("20:00".to_owned()), - end_date: Some(1653782400), - include_time: Some(true), - is_range: Some(true), - ..Default::default() - }, - None, - "May 27, 2022 20:00 → May 29, 2022 00:00", ); } #[test] - fn turn_on_date_range() { - let type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(FieldType::DateTime).build(); + fn apply_changeset_to_exsiting_cell() { + let type_option = DateTypeOption::default_utc(); - let old_cell_data = initialize_date_cell( + let date_cell = initialize_date_cell( &type_option, DateCellChangeset { - date: Some(1653609600), - time: Some("08:00".to_owned()), - include_time: Some(true), + timestamp: Some(1653782400), ..Default::default() }, ); assert_date( &type_option, - &field, DateCellChangeset { + timestamp: Some(1625130000), + ..Default::default() + }, + Some(date_cell.clone()), + &DateCellData { + timestamp: Some(1625130000), + ..Default::default() + }, + ); + + let date_cell = initialize_date_cell( + &type_option, + DateCellChangeset { + timestamp: Some(1653782400), + ..Default::default() + }, + ); + assert_date( + &type_option, + DateCellChangeset { + timestamp: Some(1653782400), + include_time: Some(true), + ..Default::default() + }, + Some(date_cell.clone()), + &DateCellData { + timestamp: Some(1653782400), + include_time: true, + ..Default::default() + }, + ); + + let date_cell = initialize_date_cell( + &type_option, + DateCellChangeset { + timestamp: Some(1653782400), + end_timestamp: Some(1653782400), is_range: Some(true), ..Default::default() }, - Some(old_cell_data), - "May 27, 2022 08:00 → May 27, 2022 08:00", + ); + assert_date( + &type_option, + DateCellChangeset { + timestamp: Some(1625130000), + end_timestamp: Some(1625130000), + ..Default::default() + }, + Some(date_cell.clone()), + &DateCellData { + timestamp: Some(1625130000), + end_timestamp: Some(1625130000), + is_range: true, + ..Default::default() + }, + ); + + let date_cell = initialize_date_cell( + &type_option, + DateCellChangeset { + timestamp: Some(1653782400), + end_timestamp: Some(1653782400), + is_range: Some(true), + ..Default::default() + }, + ); + assert_date( + &type_option, + DateCellChangeset { + is_range: Some(false), + ..Default::default() + }, + Some(date_cell.clone()), + &DateCellData { + timestamp: Some(1653782400), + is_range: false, + ..Default::default() + }, ); } #[test] - fn add_an_end_time() { - let type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(FieldType::DateTime).build(); - - let old_cell_data = initialize_date_cell( - &type_option, - DateCellChangeset { - date: Some(1653609600), - time: Some("08:00".to_owned()), - include_time: Some(true), - ..Default::default() - }, - ); - assert_date( - &type_option, - &field, - DateCellChangeset { - date: None, - time: None, - end_date: Some(1700006400), - end_time: Some("16:00".to_owned()), - include_time: Some(true), - is_range: Some(true), - ..Default::default() - }, - Some(old_cell_data), - "May 27, 2022 08:00 → Nov 15, 2023 16:00", - ); - } - - #[test] - #[should_panic] - fn end_date_with_no_start_date() { - let type_option = DateTypeOption::test(); - let field = FieldBuilder::from_field_type(FieldType::DateTime).build(); + fn apply_invalid_changeset_to_empty_cell() { + let type_option = DateTypeOption::default_utc(); assert_date( &type_option, - &field, DateCellChangeset { - date: None, - end_date: Some(1653782400), - include_time: Some(false), + timestamp: Some(1653782400), + end_timestamp: Some(1653782400), + is_range: Some(false), + ..Default::default() + }, + None, + &DateCellData::default(), + ); + + assert_date( + &type_option, + DateCellChangeset { + timestamp: None, + end_timestamp: Some(1653782400), is_range: Some(true), ..Default::default() }, None, - "→ May 29, 2022", + &DateCellData::default(), + ); + } + + #[test] + fn apply_invalid_changeset_to_existing_cell() { + let type_option = DateTypeOption::default_utc(); + + // is_range is false but a date range is passed in + let date_cell = initialize_date_cell( + &type_option, + DateCellChangeset { + timestamp: Some(1653782400), + is_range: Some(false), + ..Default::default() + }, + ); + assert_date( + &type_option, + DateCellChangeset { + timestamp: Some(1653782400), + end_timestamp: Some(1653782400), + ..Default::default() + }, + Some(date_cell.clone()), + &decode_cell_data(&date_cell, &type_option), + ); + + // is_range is true but either the start or end is missing + let date_cell = initialize_date_cell( + &type_option, + DateCellChangeset { + timestamp: Some(1653782400), + end_timestamp: Some(1653782400), + is_range: Some(false), + ..Default::default() + }, + ); + assert_date( + &type_option, + DateCellChangeset { + timestamp: None, + end_timestamp: Some(1653782400), + ..Default::default() + }, + Some(date_cell.clone()), + &decode_cell_data(&date_cell, &type_option), ); } fn assert_date( type_option: &DateTypeOption, - field: &Field, changeset: DateCellChangeset, old_cell_data: Option, - expected_str: &str, + expected: &DateCellData, ) { let (cell, _) = type_option .apply_changeset(changeset, old_cell_data) .unwrap(); - assert_eq!(decode_cell_data(&cell, type_option, field), expected_str,); + let actual = decode_cell_data(&cell, type_option); + + assert_eq!(expected.timestamp, actual.timestamp); + assert_eq!(expected.end_timestamp, actual.end_timestamp); + assert_eq!(expected.include_time, actual.include_time); + assert_eq!(expected.is_range, actual.is_range); } - fn decode_cell_data(cell: &Cell, type_option: &DateTypeOption, _field: &Field) -> String { - let decoded_data = type_option.decode_cell(cell).unwrap(); - type_option.stringify_cell_data(decoded_data) + fn decode_cell_data(cell: &Cell, type_option: &DateTypeOption) -> DateCellData { + type_option.decode_cell(cell).unwrap() } fn initialize_date_cell(type_option: &DateTypeOption, changeset: DateCellChangeset) -> Cell { diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs index 6214dc3f24..d9739fa792 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs @@ -1,31 +1,24 @@ use std::cmp::Ordering; -use std::str::FromStr; -use chrono::{DateTime, FixedOffset, Local, NaiveDateTime, NaiveTime, Offset, TimeZone}; -use chrono_tz::Tz; -use collab::core::any_map::AnyMapExtension; -use collab_database::fields::{TypeOptionData, TypeOptionDataBuilder}; +use async_trait::async_trait; +use collab::util::AnyMapExt; +use collab_database::database::Database; +use collab_database::fields::date_type_option::{DateCellData, DateTypeOption}; +use collab_database::fields::{Field, TypeOptionData}; use collab_database::rows::Cell; -use serde::{Deserialize, Serialize}; +use collab_database::template::date_parse::cast_string_to_timestamp; +use flowy_error::FlowyResult; +use tracing::info; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; - -use crate::entities::{DateCellDataPB, DateFilterPB}; +use crate::entities::{DateCellDataPB, DateFilterPB, FieldType}; use crate::services::cell::{CellDataChangeset, CellDataDecoder}; +use crate::services::field::date_type_option::date_filter::DateCellChangeset; use crate::services::field::{ - default_order, DateCellChangeset, DateCellData, DateFormat, TimeFormat, TypeOption, - TypeOptionCellDataCompare, TypeOptionCellDataFilter, TypeOptionCellDataSerde, - TypeOptionTransform, + default_order, CellDataProtobufEncoder, TypeOption, TypeOptionCellDataCompare, + TypeOptionCellDataFilter, TypeOptionTransform, CELL_DATA, }; use crate::services::sort::SortCondition; -#[derive(Clone, Debug, Serialize, Deserialize, Default)] -pub struct DateTypeOption { - pub date_format: DateFormat, - pub time_format: TimeFormat, - pub timezone_id: String, -} - impl TypeOption for DateTypeOption { type CellData = DateCellData; type CellChangeset = DateCellChangeset; @@ -33,36 +26,7 @@ impl TypeOption for DateTypeOption { type CellFilter = DateFilterPB; } -impl From for DateTypeOption { - fn from(data: TypeOptionData) -> Self { - let date_format = data - .get_i64_value("date_format") - .map(DateFormat::from) - .unwrap_or_default(); - let time_format = data - .get_i64_value("time_format") - .map(TimeFormat::from) - .unwrap_or_default(); - let timezone_id = data.get_str_value("timezone_id").unwrap_or_default(); - Self { - date_format, - time_format, - timezone_id, - } - } -} - -impl From for TypeOptionData { - fn from(data: DateTypeOption) -> Self { - TypeOptionDataBuilder::new() - .insert_i64_value("date_format", data.date_format.value()) - .insert_i64_value("time_format", data.time_format.value()) - .insert_str_value("timezone_id", data.timezone_id) - .build() - } -} - -impl TypeOptionCellDataSerde for DateTypeOption { +impl CellDataProtobufEncoder for DateTypeOption { fn protobuf_encode( &self, cell_data: ::CellData, @@ -71,138 +35,72 @@ impl TypeOptionCellDataSerde for DateTypeOption { let is_range = cell_data.is_range; let timestamp = cell_data.timestamp; - let (date, time) = self.formatted_date_time_from_timestamp(×tamp); - - let end_timestamp = cell_data.end_timestamp; - let (end_date, end_time) = self.formatted_date_time_from_timestamp(&end_timestamp); + let end_timestamp = if is_range { + cell_data.end_timestamp.or(timestamp) + } else { + None + }; let reminder_id = cell_data.reminder_id; DateCellDataPB { - date, - time, - timestamp: timestamp.unwrap_or_default(), - end_date, - end_time, - end_timestamp: end_timestamp.unwrap_or_default(), + timestamp, + end_timestamp, include_time, is_range, reminder_id, } } - - fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - Ok(DateCellData::from(cell)) - } } -impl DateTypeOption { - pub fn new() -> Self { - Self::default() - } +#[async_trait] +impl TypeOptionTransform for DateTypeOption { + async fn transform_type_option( + &mut self, + view_id: &str, + field_id: &str, + old_type_option_field_type: FieldType, + _old_type_option_data: TypeOptionData, + _new_type_option_field_type: FieldType, + database: &mut Database, + ) { + match old_type_option_field_type { + FieldType::RichText => { + let rows = database + .get_cells_for_field(view_id, field_id) + .await + .into_iter() + .filter_map(|row| row.cell.map(|cell| (row.row_id, cell))) + .collect::>(); - pub fn test() -> Self { - Self { - timezone_id: "Etc/UTC".to_owned(), - ..Self::default() - } - } - - fn formatted_date_time_from_timestamp(&self, timestamp: &Option) -> (String, String) { - if let Some(timestamp) = timestamp { - let naive = chrono::NaiveDateTime::from_timestamp_opt(*timestamp, 0).unwrap(); - let offset = self.get_timezone_offset(naive); - let date_time = DateTime::::from_naive_utc_and_offset(naive, offset); - - let fmt = self.date_format.format_str(); - let date = format!("{}", date_time.format(fmt)); - let fmt = self.time_format.format_str(); - let time = format!("{}", date_time.format(fmt)); - (date, time) - } else { - ("".to_owned(), "".to_owned()) - } - } - - fn naive_time_from_time_string( - &self, - include_time: bool, - time_str: Option, - ) -> FlowyResult> { - match (include_time, time_str) { - (true, Some(time_str)) => { - let result = NaiveTime::parse_from_str(&time_str, self.time_format.format_str()); - match result { - Ok(time) => Ok(Some(time)), - Err(_e) => { - let msg = format!("Parse {} failed", time_str); - Err(FlowyError::new(ErrorCode::InvalidDateTimeFormat, msg)) - }, + info!( + "Transforming RichText to DateTypeOption, updating {} row's cell content", + rows.len() + ); + for (row_id, cell_data) in rows { + if let Some(cell_data) = cell_data + .get_as::(CELL_DATA) + .and_then(|s| cast_string_to_timestamp(&s)) + .map(DateCellData::from_timestamp) + { + database + .update_row(row_id, |row| { + row.update_cells(|cell| { + cell.insert(field_id, Cell::from(&cell_data)); + }); + }) + .await; + } } }, - _ => Ok(None), - } - } - - /// combine the changeset_timestamp and parsed_time if provided. if - /// changeset_timestamp is None, fallback to previous_timestamp - fn timestamp_from_parsed_time_previous_and_new_timestamp( - &self, - parsed_time: Option, - previous_timestamp: Option, - changeset_timestamp: Option, - ) -> Option { - if let Some(time) = parsed_time { - // a valid time is provided, so we replace the time component of old timestamp - // (or new timestamp if provided) with it. - let utc_date = changeset_timestamp - .or(previous_timestamp) - .map(|timestamp| NaiveDateTime::from_timestamp_opt(timestamp, 0).unwrap()) - .unwrap(); - let offset = self.get_timezone_offset(utc_date); - - let local_date = changeset_timestamp.or(previous_timestamp).map(|timestamp| { - offset - .from_utc_datetime(&NaiveDateTime::from_timestamp_opt(timestamp, 0).unwrap()) - .date_naive() - }); - - match local_date { - Some(date) => { - let local_datetime = offset - .from_local_datetime(&NaiveDateTime::new(date, time)) - .unwrap(); - - Some(local_datetime.timestamp()) - }, - None => None, - } - } else { - changeset_timestamp.or(previous_timestamp) - } - } - - /// returns offset of Tz timezone if provided or of the local timezone otherwise - fn get_timezone_offset(&self, date_time: NaiveDateTime) -> FixedOffset { - let current_timezone_offset = Local::now().offset().fix(); - if self.timezone_id.is_empty() { - current_timezone_offset - } else { - match Tz::from_str(&self.timezone_id) { - Ok(timezone) => timezone.offset_from_utc_datetime(&date_time).fix(), - Err(_) => current_timezone_offset, - } + _ => { + // do nothing + }, } } } -impl TypeOptionTransform for DateTypeOption {} - impl CellDataDecoder for DateTypeOption { - fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - self.parse_cell(cell) - } - fn stringify_cell_data(&self, cell_data: ::CellData) -> String { let include_time = cell_data.include_time; let timestamp = cell_data.timestamp; @@ -231,8 +129,15 @@ impl CellDataDecoder for DateTypeOption { } } - fn numeric_cell(&self, _cell: &Cell) -> Option { - None + fn decode_cell_with_transform( + &self, + cell: &Cell, + _from_field_type: FieldType, + _field: &Field, + ) -> Option<::CellData> { + let s = cell.get_as::(CELL_DATA)?; + let timestamp = cast_string_to_timestamp(&s)?; + Some(DateCellData::from_timestamp(timestamp)) } } @@ -242,71 +147,46 @@ impl CellDataChangeset for DateTypeOption { changeset: ::CellChangeset, cell: Option, ) -> FlowyResult<(Cell, ::CellData)> { - // old date cell data - let (previous_timestamp, previous_end_timestamp, include_time, is_range, reminder_id) = - match cell { - Some(cell) => { - let cell_data = DateCellData::from(&cell); - ( - cell_data.timestamp, - cell_data.end_timestamp, - cell_data.include_time, - cell_data.is_range, - cell_data.reminder_id, - ) - }, - None => (None, None, false, false, String::new()), - }; - - if changeset.clear_flag == Some(true) { - let cell_data = DateCellData { - timestamp: None, - end_timestamp: None, - include_time, - is_range, - reminder_id: String::new(), - }; - + if let Some(true) = changeset.clear_flag { + let cell_data = DateCellData::default(); return Ok((Cell::from(&cell_data), cell_data)); } - // update include_time and is_range if necessary + // old date cell data + let cell_data = match cell { + Some(cell) => DateCellData::from(&cell), + None => DateCellData::default(), + }; + + let is_range = changeset.is_range.unwrap_or(cell_data.is_range); + + let has_timestamp = changeset.timestamp.is_some(); + let has_end_timestamp = changeset.end_timestamp.is_some(); + let unexpected_end_changeset = !is_range && has_end_timestamp; + let missing_timestamp = is_range && has_timestamp != has_end_timestamp; + + if unexpected_end_changeset || missing_timestamp { + return Ok((Cell::from(&cell_data), cell_data)); + } + + let DateCellData { + timestamp, + end_timestamp, + include_time, + is_range: _, + reminder_id, + } = cell_data; + + // update include_time and reminder_id if necessary let include_time = changeset.include_time.unwrap_or(include_time); - let is_range = changeset.is_range.unwrap_or(is_range); let reminder_id = changeset.reminder_id.unwrap_or(reminder_id); - // Calculate the timestamp in the time zone specified in type option. If - // a new timestamp is included in the changeset without an accompanying - // time string, the old timestamp will simply be overwritten. Meaning, in - // order to change the day without changing the time, the old time string - // should be passed in as well. - - // parse the time string, which is in the local timezone - let parsed_start_time = self.naive_time_from_time_string(include_time, changeset.time)?; - - let timestamp = self.timestamp_from_parsed_time_previous_and_new_timestamp( - parsed_start_time, - previous_timestamp, - changeset.date, - ); - - let end_timestamp = - if is_range && changeset.end_date.is_none() && previous_end_timestamp.is_none() { - // just toggled is_range so no passed in or existing end time data - timestamp - } else if is_range { - // parse the changeset's end time data or fallback to previous version - let parsed_end_time = self.naive_time_from_time_string(include_time, changeset.end_time)?; - - self.timestamp_from_parsed_time_previous_and_new_timestamp( - parsed_end_time, - previous_end_timestamp, - changeset.end_date, - ) - } else { - // clear the end time data - None - }; + let timestamp = changeset.timestamp.or(timestamp); + let end_timestamp = if is_range && timestamp.is_some() { + changeset.end_timestamp.or(end_timestamp).or(timestamp) + } else { + None + }; let cell_data = DateCellData { timestamp, diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs deleted file mode 100644 index c2b0259aff..0000000000 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs +++ /dev/null @@ -1,288 +0,0 @@ -#![allow(clippy::upper_case_acronyms)] - -use bytes::Bytes; -use collab::core::any_map::AnyMapExtension; -use collab_database::rows::{new_cell_builder, Cell}; -use serde::de::Visitor; -use serde::{Deserialize, Serialize}; -use std::fmt; -use strum_macros::EnumIter; - -use flowy_error::{internal_error, FlowyResult}; - -use crate::entities::{DateCellDataPB, FieldType}; -use crate::services::cell::CellProtobufBlobParser; -use crate::services::field::{TypeOptionCellData, CELL_DATA}; - -#[derive(Clone, Debug, Default)] -pub struct DateCellChangeset { - pub date: Option, - pub time: Option, - pub end_date: Option, - pub end_time: Option, - pub include_time: Option, - pub is_range: Option, - pub clear_flag: Option, - pub reminder_id: Option, -} - -#[derive(Default, Clone, Debug, Serialize)] -pub struct DateCellData { - pub timestamp: Option, - pub end_timestamp: Option, - #[serde(default)] - pub include_time: bool, - #[serde(default)] - pub is_range: bool, - pub reminder_id: String, -} - -impl DateCellData { - pub fn new(timestamp: i64, include_time: bool, is_range: bool, reminder_id: String) -> Self { - Self { - timestamp: Some(timestamp), - end_timestamp: None, - include_time, - is_range, - reminder_id, - } - } -} - -impl TypeOptionCellData for DateCellData { - fn is_cell_empty(&self) -> bool { - self.timestamp.is_none() - } -} - -impl From<&Cell> for DateCellData { - fn from(cell: &Cell) -> Self { - let timestamp = cell - .get_str_value(CELL_DATA) - .and_then(|data| data.parse::().ok()); - let end_timestamp = cell - .get_str_value("end_timestamp") - .and_then(|data| data.parse::().ok()); - let include_time = cell.get_bool_value("include_time").unwrap_or_default(); - let is_range = cell.get_bool_value("is_range").unwrap_or_default(); - let reminder_id = cell.get_str_value("reminder_id").unwrap_or_default(); - - Self { - timestamp, - end_timestamp, - include_time, - is_range, - reminder_id, - } - } -} - -impl From<&DateCellDataPB> for DateCellData { - fn from(data: &DateCellDataPB) -> Self { - Self { - timestamp: Some(data.timestamp), - end_timestamp: Some(data.end_timestamp), - include_time: data.include_time, - is_range: data.is_range, - reminder_id: data.reminder_id.to_owned(), - } - } -} - -impl From<&DateCellData> for Cell { - fn from(cell_data: &DateCellData) -> Self { - let timestamp_string = match cell_data.timestamp { - Some(timestamp) => timestamp.to_string(), - None => "".to_owned(), - }; - let end_timestamp_string = match cell_data.end_timestamp { - Some(timestamp) => timestamp.to_string(), - None => "".to_owned(), - }; - // Most of the case, don't use these keys in other places. Otherwise, we should define - // constants for them. - new_cell_builder(FieldType::DateTime) - .insert_str_value(CELL_DATA, timestamp_string) - .insert_str_value("end_timestamp", end_timestamp_string) - .insert_bool_value("include_time", cell_data.include_time) - .insert_bool_value("is_range", cell_data.is_range) - .insert_str_value("reminder_id", cell_data.reminder_id.to_owned()) - .build() - } -} - -impl<'de> serde::Deserialize<'de> for DateCellData { - fn deserialize(deserializer: D) -> core::result::Result - where - D: serde::Deserializer<'de>, - { - struct DateCellVisitor(); - - impl<'de> Visitor<'de> for DateCellVisitor { - type Value = DateCellData; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str( - "DateCellData with type: str containing either an integer timestamp or the JSON representation", - ) - } - - fn visit_i64(self, value: i64) -> Result - where - E: serde::de::Error, - { - Ok(DateCellData { - timestamp: Some(value), - end_timestamp: None, - include_time: false, - is_range: false, - reminder_id: String::new(), - }) - } - - fn visit_u64(self, value: u64) -> Result - where - E: serde::de::Error, - { - self.visit_i64(value as i64) - } - - fn visit_map(self, mut map: M) -> Result - where - M: serde::de::MapAccess<'de>, - { - let mut timestamp: Option = None; - let mut end_timestamp: Option = None; - let mut include_time: Option = None; - let mut is_range: Option = None; - let mut reminder_id: Option = None; - - while let Some(key) = map.next_key()? { - match key { - "timestamp" => { - timestamp = map.next_value()?; - }, - "end_timestamp" => { - end_timestamp = map.next_value()?; - }, - "include_time" => { - include_time = map.next_value()?; - }, - "is_range" => { - is_range = map.next_value()?; - }, - "reminder_id" => { - reminder_id = map.next_value()?; - }, - _ => {}, - } - } - - let include_time = include_time.unwrap_or_default(); - let is_range = is_range.unwrap_or_default(); - let reminder_id = reminder_id.unwrap_or_default(); - - Ok(DateCellData { - timestamp, - end_timestamp, - include_time, - is_range, - reminder_id, - }) - } - } - - deserializer.deserialize_any(DateCellVisitor()) - } -} - -impl ToString for DateCellData { - fn to_string(&self) -> String { - serde_json::to_string(self).unwrap() - } -} - -#[derive(Clone, Debug, Copy, EnumIter, Serialize, Deserialize, Default)] -pub enum DateFormat { - Local = 0, - US = 1, - ISO = 2, - #[default] - Friendly = 3, - DayMonthYear = 4, -} - -impl std::convert::From for DateFormat { - fn from(value: i64) -> Self { - match value { - 0 => DateFormat::Local, - 1 => DateFormat::US, - 2 => DateFormat::ISO, - 3 => DateFormat::Friendly, - 4 => DateFormat::DayMonthYear, - _ => { - tracing::error!("Unsupported date format, fallback to friendly"); - DateFormat::Friendly - }, - } - } -} - -impl DateFormat { - pub fn value(&self) -> i64 { - *self as i64 - } - // https://docs.rs/chrono/0.4.19/chrono/format/strftime/index.html - pub fn format_str(&self) -> &'static str { - match self { - DateFormat::Local => "%m/%d/%Y", - DateFormat::US => "%Y/%m/%d", - DateFormat::ISO => "%Y-%m-%d", - DateFormat::Friendly => "%b %d, %Y", - DateFormat::DayMonthYear => "%d/%m/%Y", - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq, EnumIter, Debug, Hash, Serialize, Deserialize, Default)] -pub enum TimeFormat { - TwelveHour = 0, - #[default] - TwentyFourHour = 1, -} - -impl std::convert::From for TimeFormat { - fn from(value: i64) -> Self { - match value { - 0 => TimeFormat::TwelveHour, - 1 => TimeFormat::TwentyFourHour, - _ => { - tracing::error!("Unsupported time format, fallback to TwentyFourHour"); - TimeFormat::TwentyFourHour - }, - } - } -} - -impl TimeFormat { - pub fn value(&self) -> i64 { - *self as i64 - } - - // https://docs.rs/chrono/0.4.19/chrono/format/strftime/index.html - pub fn format_str(&self) -> &'static str { - match self { - TimeFormat::TwelveHour => "%I:%M %p", - TimeFormat::TwentyFourHour => "%R", - } - } -} - -pub struct DateCellDataParser(); -impl CellProtobufBlobParser for DateCellDataParser { - type Object = DateCellDataPB; - - fn parser(bytes: &Bytes) -> FlowyResult { - DateCellDataPB::try_from(bytes.as_ref()).map_err(internal_error) - } -} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/mod.rs index ff0c344957..3d50a36a82 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/mod.rs @@ -1,8 +1,4 @@ #![allow(clippy::module_inception)] -mod date_filter; +pub mod date_filter; mod date_tests; -mod date_type_option; -mod date_type_option_entities; - -pub use date_type_option::*; -pub use date_type_option_entities::*; +pub mod date_type_option; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_file.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_file.rs new file mode 100644 index 0000000000..b4a24febb6 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_file.rs @@ -0,0 +1,100 @@ +use std::fmt::{Display, Formatter}; + +use collab_database::entity::FileUploadType; +use serde::{Deserialize, Serialize}; + +use crate::entities::FileUploadTypePB; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum MediaUploadType { + #[default] + LocalMedia = 0, + NetworkMedia = 1, + CloudMedia = 2, +} + +impl From for FileUploadTypePB { + fn from(media_upload_type: MediaUploadType) -> Self { + match media_upload_type { + MediaUploadType::LocalMedia => FileUploadTypePB::LocalFile, + MediaUploadType::NetworkMedia => FileUploadTypePB::NetworkFile, + MediaUploadType::CloudMedia => FileUploadTypePB::CloudFile, + } + } +} + +impl From for MediaUploadType { + fn from(file_upload_type: FileUploadTypePB) -> Self { + match file_upload_type { + FileUploadTypePB::LocalFile => MediaUploadType::LocalMedia, + FileUploadTypePB::NetworkFile => MediaUploadType::NetworkMedia, + FileUploadTypePB::CloudFile => MediaUploadType::CloudMedia, + } + } +} + +impl From for FileUploadType { + fn from(media_upload_type: MediaUploadType) -> Self { + match media_upload_type { + MediaUploadType::LocalMedia => FileUploadType::LocalFile, + MediaUploadType::NetworkMedia => FileUploadType::NetworkFile, + MediaUploadType::CloudMedia => FileUploadType::CloudFile, + } + } +} + +impl From for MediaUploadType { + fn from(file_upload_type: FileUploadType) -> Self { + match file_upload_type { + FileUploadType::LocalFile => MediaUploadType::LocalMedia, + FileUploadType::NetworkFile => MediaUploadType::NetworkMedia, + FileUploadType::CloudFile => MediaUploadType::CloudMedia, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MediaFile { + pub id: String, + pub name: String, + pub url: String, + pub upload_type: MediaUploadType, + pub file_type: MediaFileType, +} + +impl MediaFile { + pub fn rename(&self, new_name: String) -> Self { + Self { + id: self.id.clone(), + name: new_name, + url: self.url.clone(), + upload_type: self.upload_type.clone(), + file_type: self.file_type.clone(), + } + } +} + +impl Display for MediaFile { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "MediaFile(id: {}, name: {}, url: {}, upload_type: {:?}, file_type: {:?})", + self.id, self.name, self.url, self.upload_type, self.file_type + ) + } +} + +#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Default, Clone)] +#[repr(u8)] +pub enum MediaFileType { + #[default] + Other = 0, + Image = 1, + Link = 2, + Document = 3, + Archive = 4, + Video = 5, + Audio = 6, + Text = 7, +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_filter.rs new file mode 100644 index 0000000000..f3a02b137c --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_filter.rs @@ -0,0 +1,22 @@ +use collab_database::{fields::Field, rows::Cell}; + +use crate::{ + entities::{MediaFilterConditionPB, MediaFilterPB}, + services::filter::PreFillCellsWithFilter, +}; + +impl MediaFilterPB { + pub fn is_visible>(&self, cell_data: T) -> bool { + let cell_data = cell_data.as_ref().to_lowercase(); + match self.condition { + MediaFilterConditionPB::MediaIsEmpty => cell_data.is_empty(), + MediaFilterConditionPB::MediaIsNotEmpty => !cell_data.is_empty(), + } + } +} + +impl PreFillCellsWithFilter for MediaFilterPB { + fn get_compliant_cell(&self, _field: &Field) -> Option { + None + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_type_option.rs new file mode 100644 index 0000000000..6fca683358 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_type_option.rs @@ -0,0 +1,124 @@ +use crate::{ + entities::{FieldType, MediaCellChangeset, MediaCellDataPB, MediaFilterPB}, + services::{ + cell::{CellDataChangeset, CellDataDecoder}, + field::{ + default_order, CellDataProtobufEncoder, TypeOption, TypeOptionCellData, + TypeOptionCellDataCompare, TypeOptionCellDataFilter, TypeOptionTransform, + }, + sort::SortCondition, + }, +}; +use collab_database::fields::media_type_option::{MediaCellData, MediaTypeOption}; +use collab_database::template::util::ToCellString; +use collab_database::{fields::Field, rows::Cell}; +use flowy_error::FlowyResult; +use std::cmp::Ordering; + +impl TypeOption for MediaTypeOption { + type CellData = MediaCellData; + type CellChangeset = MediaCellChangeset; + type CellProtobufType = MediaCellDataPB; + type CellFilter = MediaFilterPB; +} + +impl TypeOptionTransform for MediaTypeOption {} + +impl CellDataProtobufEncoder for MediaTypeOption { + fn protobuf_encode( + &self, + cell_data: ::CellData, + ) -> ::CellProtobufType { + cell_data.into() + } +} + +impl CellDataDecoder for MediaTypeOption { + fn decode_cell_with_transform( + &self, + _cell: &Cell, + from_field_type: FieldType, + _field: &Field, + ) -> Option<::CellData> { + match from_field_type { + FieldType::RichText + | FieldType::Number + | FieldType::DateTime + | FieldType::SingleSelect + | FieldType::MultiSelect + | FieldType::Checkbox + | FieldType::URL + | FieldType::Summary + | FieldType::Translate + | FieldType::Time + | FieldType::Checklist + | FieldType::LastEditedTime + | FieldType::CreatedTime + | FieldType::Relation + | FieldType::Media => None, + } + } + + fn stringify_cell_data(&self, cell_data: ::CellData) -> String { + cell_data.to_cell_string() + } +} + +impl CellDataChangeset for MediaTypeOption { + fn apply_changeset( + &self, + changeset: ::CellChangeset, + cell: Option, + ) -> FlowyResult<(Cell, ::CellData)> { + if cell.is_none() { + let cell_data = MediaCellData { + files: changeset.inserted_files, + }; + return Ok((cell_data.clone().into(), cell_data)); + } + + let cell_data: MediaCellData = MediaCellData::from(&cell.unwrap()); + let mut files = cell_data.files.clone(); + for removed_id in changeset.removed_ids.iter() { + if let Some(index) = files.iter().position(|file| file.id == removed_id.clone()) { + files.remove(index); + } + } + + for inserted in changeset.inserted_files.iter() { + if !files.iter().any(|file| file.id == inserted.id) { + files.push(inserted.clone()) + } + } + + let cell_data = MediaCellData { files }; + + Ok((Cell::from(cell_data.clone()), cell_data)) + } +} + +impl TypeOptionCellDataFilter for MediaTypeOption { + fn apply_filter( + &self, + _filter: &::CellFilter, + _cell_data: &::CellData, + ) -> bool { + true + } +} + +impl TypeOptionCellDataCompare for MediaTypeOption { + fn apply_cmp( + &self, + cell_data: &::CellData, + other_cell_data: &::CellData, + _sort_condition: SortCondition, + ) -> Ordering { + match (cell_data.files.is_empty(), other_cell_data.is_cell_empty()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => default_order(), + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/mod.rs new file mode 100644 index 0000000000..b9bd6e471a --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/mod.rs @@ -0,0 +1,4 @@ +#![allow(clippy::module_inception)] +// mod media_file; +mod media_filter; +mod media_type_option; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs index 44d7329567..0530eb746e 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs @@ -1,12 +1,15 @@ pub mod checkbox_type_option; pub mod checklist_type_option; pub mod date_type_option; +pub mod media_type_option; pub mod number_type_option; pub mod relation_type_option; pub mod selection_type_option; pub mod summary_type_option; pub mod text_type_option; +pub mod time_type_option; pub mod timestamp_type_option; +pub mod translate_type_option; mod type_option; mod type_option_cell; mod url_type_option; @@ -15,11 +18,13 @@ mod util; pub use checkbox_type_option::*; pub use checklist_type_option::*; pub use date_type_option::*; + pub use number_type_option::*; pub use relation_type_option::*; pub use selection_type_option::*; pub use text_type_option::*; -pub use timestamp_type_option::*; +pub use time_type_option::*; + pub use type_option::*; pub use type_option_cell::*; pub use url_type_option::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/format.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/format.rs deleted file mode 100644 index 5a4727984b..0000000000 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/format.rs +++ /dev/null @@ -1,498 +0,0 @@ -#![allow(clippy::upper_case_acronyms)] - -use lazy_static::lazy_static; -use rusty_money::define_currency_set; -use serde::{Deserialize, Serialize}; -use strum::IntoEnumIterator; -use strum_macros::EnumIter; - -lazy_static! { - pub static ref CURRENCY_SYMBOL: Vec = NumberFormat::iter() - .map(|format| format.symbol()) - .collect::>(); -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter, Serialize, Deserialize, Default)] -pub enum NumberFormat { - #[default] - 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, -} - -impl NumberFormat { - pub fn value(&self) -> i64 { - *self as i64 - } -} - -impl From for NumberFormat { - fn from(value: i64) -> Self { - match value { - 0 => NumberFormat::Num, - 1 => NumberFormat::USD, - 2 => NumberFormat::CanadianDollar, - 4 => NumberFormat::EUR, - 5 => NumberFormat::Pound, - 6 => NumberFormat::Yen, - 7 => NumberFormat::Ruble, - 8 => NumberFormat::Rupee, - 9 => NumberFormat::Won, - 10 => NumberFormat::Yuan, - 11 => NumberFormat::Real, - 12 => NumberFormat::Lira, - 13 => NumberFormat::Rupiah, - 14 => NumberFormat::Franc, - 15 => NumberFormat::HongKongDollar, - 16 => NumberFormat::NewZealandDollar, - 17 => NumberFormat::Krona, - 18 => NumberFormat::NorwegianKrone, - 19 => NumberFormat::MexicanPeso, - 20 => NumberFormat::Rand, - 21 => NumberFormat::NewTaiwanDollar, - 22 => NumberFormat::DanishKrone, - 23 => NumberFormat::Baht, - 24 => NumberFormat::Forint, - 25 => NumberFormat::Koruna, - 26 => NumberFormat::Shekel, - 27 => NumberFormat::ChileanPeso, - 28 => NumberFormat::PhilippinePeso, - 29 => NumberFormat::Dirham, - 30 => NumberFormat::ColombianPeso, - 31 => NumberFormat::Riyal, - 32 => NumberFormat::Ringgit, - 33 => NumberFormat::Leu, - 34 => NumberFormat::ArgentinePeso, - 35 => NumberFormat::UruguayanPeso, - 36 => NumberFormat::Percent, - _ => NumberFormat::Num, - } - } -} - -define_currency_set!( - number_currency { - NUMBER : { - code: "", - exponent: 2, - locale: EnEu, - minor_units: 1, - name: "number", - symbol: "RUB", - symbol_first: false, - }, - PERCENT : { - code: "", - exponent: 2, - locale: EnIn, - minor_units: 1, - name: "percent", - symbol: "%", - symbol_first: false, - }, - USD : { - code: "USD", - exponent: 2, - locale: EnUs, - minor_units: 1, - name: "United States Dollar", - symbol: "$", - symbol_first: true, - }, - CANADIAN_DOLLAR : { - code: "USD", - exponent: 2, - locale: EnUs, - minor_units: 1, - name: "Canadian Dollar", - symbol: "CA$", - symbol_first: true, - }, - NEW_TAIWAN_DOLLAR : { - code: "USD", - exponent: 2, - locale: EnUs, - minor_units: 1, - name: "NewTaiwan Dollar", - symbol: "NT$", - symbol_first: true, - }, - HONG_KONG_DOLLAR : { - code: "USD", - exponent: 2, - locale: EnUs, - minor_units: 1, - name: "HongKong Dollar", - symbol: "HZ$", - symbol_first: true, - }, - NEW_ZEALAND_DOLLAR : { - code: "USD", - exponent: 2, - locale: EnUs, - minor_units: 1, - name: "NewZealand Dollar", - symbol: "NZ$", - symbol_first: true, - }, - EUR : { - code: "EUR", - exponent: 2, - locale: EnEu, - minor_units: 1, - name: "Euro", - symbol: "€", - symbol_first: true, - }, - GIP : { - code: "GIP", - exponent: 2, - locale: EnUs, - minor_units: 1, - name: "Gibraltar Pound", - symbol: "£", - symbol_first: true, - }, - CNY : { - code: "CNY", - exponent: 2, - locale: EnUs, - minor_units: 1, - name: "Chinese Renminbi Yuan", - symbol: "¥", - symbol_first: true, - }, - YUAN : { - code: "CNY", - exponent: 2, - locale: EnUs, - minor_units: 1, - name: "Chinese Renminbi Yuan", - symbol: "CN¥", - symbol_first: true, - }, - RUB : { - code: "RUB", - exponent: 2, - locale: EnEu, - minor_units: 1, - name: "Russian Ruble", - symbol: "RUB", - symbol_first: false, - }, - INR : { - code: "INR", - exponent: 2, - locale: EnIn, - minor_units: 50, - name: "Indian Rupee", - symbol: "₹", - symbol_first: true, - }, - KRW : { - code: "KRW", - exponent: 0, - locale: EnUs, - minor_units: 1, - name: "South Korean Won", - symbol: "₩", - symbol_first: true, - }, - BRL : { - code: "BRL", - exponent: 2, - locale: EnUs, - minor_units: 5, - name: "Brazilian real", - symbol: "R$", - symbol_first: true, - }, - TRY : { - code: "TRY", - exponent: 2, - locale: EnEu, - minor_units: 1, - name: "Turkish Lira", - // symbol: "₺", - symbol: "TRY", - symbol_first: true, - }, - IDR : { - code: "IDR", - exponent: 2, - locale: EnUs, - minor_units: 5000, - name: "Indonesian Rupiah", - // symbol: "Rp", - symbol: "IDR", - symbol_first: true, - }, - CHF : { - code: "CHF", - exponent: 2, - locale: EnUs, - minor_units: 5, - name: "Swiss Franc", - // symbol: "Fr", - symbol: "CHF", - symbol_first: true, - }, - SEK : { - code: "SEK", - exponent: 2, - locale: EnBy, - minor_units: 100, - name: "Swedish Krona", - // symbol: "kr", - symbol: "SEK", - symbol_first: false, - }, - NOK : { - code: "NOK", - exponent: 2, - locale: EnUs, - minor_units: 100, - name: "Norwegian Krone", - // symbol: "kr", - symbol: "NOK", - symbol_first: false, - }, - MEXICAN_PESO : { - code: "USD", - exponent: 2, - locale: EnUs, - minor_units: 1, - name: "Mexican Peso", - symbol: "MX$", - symbol_first: true, - }, - ZAR : { - code: "ZAR", - exponent: 2, - locale: EnUs, - minor_units: 10, - name: "South African Rand", - // symbol: "R", - symbol: "ZAR", - symbol_first: true, - }, - DKK : { - code: "DKK", - exponent: 2, - locale: EnEu, - minor_units: 50, - name: "Danish Krone", - // symbol: "kr.", - symbol: "DKK", - symbol_first: false, - }, - THB : { - code: "THB", - exponent: 2, - locale: EnUs, - minor_units: 1, - name: "Thai Baht", - // symbol: "฿", - symbol: "THB", - symbol_first: true, - }, - HUF : { - code: "HUF", - exponent: 0, - locale: EnBy, - minor_units: 5, - name: "Hungarian Forint", - // symbol: "Ft", - symbol: "HUF", - symbol_first: false, - }, - KORUNA : { - code: "CZK", - exponent: 2, - locale: EnBy, - minor_units: 100, - name: "Czech Koruna", - // symbol: "Kč", - symbol: "CZK", - symbol_first: false, - }, - SHEKEL : { - code: "CZK", - exponent: 2, - locale: EnBy, - minor_units: 100, - name: "Czech Koruna", - symbol: "Kč", - symbol_first: false, - }, - CLP : { - code: "CLP", - exponent: 0, - locale: EnEu, - minor_units: 1, - name: "Chilean Peso", - // symbol: "$", - symbol: "CLP", - symbol_first: true, - }, - PHP : { - code: "PHP", - exponent: 2, - locale: EnUs, - minor_units: 1, - name: "Philippine Peso", - symbol: "₱", - symbol_first: true, - }, - AED : { - code: "AED", - exponent: 2, - locale: EnUs, - minor_units: 25, - name: "United Arab Emirates Dirham", - // symbol: "د.إ", - symbol: "AED", - symbol_first: false, - }, - COP : { - code: "COP", - exponent: 2, - locale: EnEu, - minor_units: 20, - name: "Colombian Peso", - // symbol: "$", - symbol: "COP", - symbol_first: true, - }, - SAR : { - code: "SAR", - exponent: 2, - locale: EnUs, - minor_units: 5, - name: "Saudi Riyal", - // symbol: "ر.س", - symbol: "SAR", - symbol_first: true, - }, - MYR : { - code: "MYR", - exponent: 2, - locale: EnUs, - minor_units: 5, - name: "Malaysian Ringgit", - // symbol: "RM", - symbol: "MYR", - symbol_first: true, - }, - RON : { - code: "RON", - exponent: 2, - locale: EnEu, - minor_units: 1, - name: "Romanian Leu", - // symbol: "ر.ق", - symbol: "RON", - symbol_first: false, - }, - ARS : { - code: "ARS", - exponent: 2, - locale: EnEu, - minor_units: 1, - name: "Argentine Peso", - // symbol: "$", - symbol: "ARS", - symbol_first: true, - }, - UYU : { - code: "UYU", - exponent: 2, - locale: EnEu, - minor_units: 100, - name: "Uruguayan Peso", - // symbol: "$U", - symbol: "UYU", - symbol_first: true, - } - } -); - -impl NumberFormat { - pub fn currency(&self) -> &'static number_currency::Currency { - match self { - NumberFormat::Num => number_currency::NUMBER, - NumberFormat::USD => number_currency::USD, - NumberFormat::CanadianDollar => number_currency::CANADIAN_DOLLAR, - NumberFormat::EUR => number_currency::EUR, - NumberFormat::Pound => number_currency::GIP, - NumberFormat::Yen => number_currency::CNY, - NumberFormat::Ruble => number_currency::RUB, - NumberFormat::Rupee => number_currency::INR, - NumberFormat::Won => number_currency::KRW, - NumberFormat::Yuan => number_currency::YUAN, - NumberFormat::Real => number_currency::BRL, - NumberFormat::Lira => number_currency::TRY, - NumberFormat::Rupiah => number_currency::IDR, - NumberFormat::Franc => number_currency::CHF, - NumberFormat::HongKongDollar => number_currency::HONG_KONG_DOLLAR, - NumberFormat::NewZealandDollar => number_currency::NEW_ZEALAND_DOLLAR, - NumberFormat::Krona => number_currency::SEK, - NumberFormat::NorwegianKrone => number_currency::NOK, - NumberFormat::MexicanPeso => number_currency::MEXICAN_PESO, - NumberFormat::Rand => number_currency::ZAR, - NumberFormat::NewTaiwanDollar => number_currency::NEW_TAIWAN_DOLLAR, - NumberFormat::DanishKrone => number_currency::DKK, - NumberFormat::Baht => number_currency::THB, - NumberFormat::Forint => number_currency::HUF, - NumberFormat::Koruna => number_currency::KORUNA, - NumberFormat::Shekel => number_currency::SHEKEL, - NumberFormat::ChileanPeso => number_currency::CLP, - NumberFormat::PhilippinePeso => number_currency::PHP, - NumberFormat::Dirham => number_currency::AED, - NumberFormat::ColombianPeso => number_currency::COP, - NumberFormat::Riyal => number_currency::SAR, - NumberFormat::Ringgit => number_currency::MYR, - NumberFormat::Leu => number_currency::RON, - NumberFormat::ArgentinePeso => number_currency::ARS, - NumberFormat::UruguayanPeso => number_currency::UYU, - NumberFormat::Percent => number_currency::PERCENT, - } - } - - pub fn symbol(&self) -> String { - self.currency().symbol.to_string() - } -} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/mod.rs index 8136fb57c5..714b6baca8 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/mod.rs @@ -1,10 +1,8 @@ #![allow(clippy::module_inception)] -mod format; mod number_filter; -mod number_tests; mod number_type_option; mod number_type_option_entities; -pub use format::*; +// pub use format::*; pub use number_type_option::*; pub use number_type_option_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_filter.rs index ba95dd8843..01b6f6dd90 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_filter.rs @@ -1,12 +1,12 @@ use std::str::FromStr; +use collab_database::fields::number_type_option::NumberCellFormat; use collab_database::fields::Field; use collab_database::rows::Cell; use rust_decimal::Decimal; use crate::entities::{NumberFilterConditionPB, NumberFilterPB}; use crate::services::cell::insert_text_cell; -use crate::services::field::NumberCellFormat; use crate::services::filter::PreFillCellsWithFilter; impl NumberFilterPB { @@ -35,7 +35,7 @@ impl NumberFilterPB { } impl PreFillCellsWithFilter for NumberFilterPB { - fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { + fn get_compliant_cell(&self, field: &Field) -> Option { let expected_decimal = || Decimal::from_str(&self.content).ok(); let text = match self.condition { @@ -61,10 +61,8 @@ impl PreFillCellsWithFilter for NumberFilterPB { _ => None, }; - let open_after_create = matches!(self.condition, NumberFilterConditionPB::NumberIsNotEmpty); - // use `insert_text_cell` because self.content might not be a parsable i64. - (text.map(|s| insert_text_cell(s, field)), open_after_create) + text.map(|s| insert_text_cell(s, field)) } } enum NumberFilterStrategy { @@ -108,7 +106,7 @@ impl NumberFilterStrategy { #[cfg(test)] mod tests { use crate::entities::{NumberFilterConditionPB, NumberFilterPB}; - use crate::services::field::{NumberCellFormat, NumberFormat}; + use collab_database::fields::number_type_option::{NumberCellFormat, NumberFormat}; #[test] fn number_filter_equal_test() { diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_tests.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_tests.rs deleted file mode 100644 index a998cdf87e..0000000000 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_tests.rs +++ /dev/null @@ -1,80 +0,0 @@ -#[cfg(test)] -mod tests { - - use crate::services::cell::CellDataDecoder; - use crate::services::field::NumberCellData; - use crate::services::field::{NumberFormat, NumberTypeOption}; - - /// Testing when the input is not a number. - #[test] - fn number_type_option_input_test() { - let type_option = NumberTypeOption::default(); - - // Input is empty String - assert_number(&type_option, "", ""); - assert_number(&type_option, "abc", ""); - assert_number(&type_option, "-123", "-123"); - assert_number(&type_option, "abc-123", "-123"); - assert_number(&type_option, "+123", "123"); - assert_number(&type_option, "0.2", "0.2"); - assert_number(&type_option, "-0.2", "-0.2"); - assert_number(&type_option, "-$0.2", "0.2"); - assert_number(&type_option, ".2", "0.2"); - } - - #[test] - fn dollar_type_option_test() { - let mut type_option = NumberTypeOption::new(); - type_option.format = NumberFormat::USD; - - assert_number(&type_option, "", ""); - assert_number(&type_option, "abc", ""); - assert_number(&type_option, "-123", "-$123"); - assert_number(&type_option, "+123", "$123"); - assert_number(&type_option, "0.2", "$0.2"); - assert_number(&type_option, "-0.2", "-$0.2"); - assert_number(&type_option, "-$0.2", "-$0.2"); - assert_number(&type_option, "-€0.2", "-$0.2"); - assert_number(&type_option, ".2", "$0.2"); - } - - #[test] - fn dollar_type_option_test2() { - let mut type_option = NumberTypeOption::new(); - type_option.format = NumberFormat::USD; - - assert_number(&type_option, "99999999999", "$99,999,999,999"); - assert_number(&type_option, "$99,999,999,999", "$99,999,999,999"); - } - #[test] - fn other_symbol_to_dollar_type_option_test() { - let mut type_option = NumberTypeOption::new(); - type_option.format = NumberFormat::USD; - - assert_number(&type_option, "€0.2", "$0.2"); - assert_number(&type_option, "-€0.2", "-$0.2"); - assert_number(&type_option, "-CN¥0.2", "-$0.2"); - assert_number(&type_option, "CN¥0.2", "$0.2"); - assert_number(&type_option, "0.2", "$0.2"); - } - - #[test] - fn euro_type_option_test() { - let mut type_option = NumberTypeOption::new(); - type_option.format = NumberFormat::EUR; - - assert_number(&type_option, "0.2", "€0,2"); - assert_number(&type_option, "1000", "€1.000"); - assert_number(&type_option, "1234.56", "€1.234,56"); - } - - fn assert_number(type_option: &NumberTypeOption, input_str: &str, expected_str: &str) { - assert_eq!( - type_option - .decode_cell(&NumberCellData(input_str.to_owned()).into(),) - .unwrap() - .to_string(), - expected_str.to_owned() - ); - } -} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs index 0fc7cd5920..1b0cfa85ae 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs @@ -1,71 +1,29 @@ -use std::cmp::Ordering; -use std::default::Default; -use std::str::FromStr; +use async_trait::async_trait; -use collab::core::any_map::AnyMapExtension; -use collab_database::fields::{TypeOptionData, TypeOptionDataBuilder}; -use collab_database::rows::{new_cell_builder, Cell}; +use collab_database::database::Database; +use collab_database::fields::number_type_option::{ + NumberCellFormat, NumberFormat, NumberTypeOption, +}; +use collab_database::fields::{Field, TypeOptionData}; +use collab_database::rows::Cell; use fancy_regex::Regex; -use lazy_static::lazy_static; -use rust_decimal::Decimal; -use serde::{Deserialize, Serialize}; - use flowy_error::FlowyResult; +use lazy_static::lazy_static; + +use collab_database::template::number_parse::NumberCellData; +use std::cmp::Ordering; + +use tracing::info; use crate::entities::{FieldType, NumberFilterPB}; use crate::services::cell::{CellDataChangeset, CellDataDecoder}; -use crate::services::field::type_options::number_type_option::format::*; use crate::services::field::type_options::util::ProtobufStr; use crate::services::field::{ - NumberCellFormat, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, - TypeOptionCellDataFilter, TypeOptionCellDataSerde, TypeOptionTransform, CELL_DATA, + CellDataProtobufEncoder, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, + TypeOptionCellDataFilter, TypeOptionTransform, }; use crate::services::sort::SortCondition; -// Number -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct NumberTypeOption { - pub format: NumberFormat, - pub scale: u32, - pub symbol: String, - pub name: String, -} - -#[derive(Clone, Debug, Default)] -pub struct NumberCellData(pub String); - -impl TypeOptionCellData for NumberCellData { - fn is_cell_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl From<&Cell> for NumberCellData { - fn from(cell: &Cell) -> Self { - Self(cell.get_str_value(CELL_DATA).unwrap_or_default()) - } -} - -impl From for Cell { - fn from(data: NumberCellData) -> Self { - new_cell_builder(FieldType::Number) - .insert_str_value(CELL_DATA, data.0) - .build() - } -} - -impl std::convert::From for NumberCellData { - fn from(s: String) -> Self { - Self(s) - } -} - -impl ToString for NumberCellData { - fn to_string(&self) -> String { - self.0.clone() - } -} - impl TypeOption for NumberTypeOption { type CellData = NumberCellData; type CellChangeset = NumberCellChangeset; @@ -73,125 +31,86 @@ impl TypeOption for NumberTypeOption { type CellFilter = NumberFilterPB; } -impl From for NumberTypeOption { - fn from(data: TypeOptionData) -> Self { - let format = data - .get_i64_value("format") - .map(NumberFormat::from) - .unwrap_or_default(); - let scale = data.get_i64_value("scale").unwrap_or_default() as u32; - let symbol = data.get_str_value("symbol").unwrap_or_default(); - let name = data.get_str_value("name").unwrap_or_default(); - Self { - format, - scale, - symbol, - name, - } - } -} - -impl From for TypeOptionData { - fn from(data: NumberTypeOption) -> Self { - TypeOptionDataBuilder::new() - .insert_i64_value("format", data.format.value()) - .insert_i64_value("scale", data.scale as i64) - .insert_str_value("name", data.name) - .insert_str_value("symbol", data.symbol) - .build() - } -} - -impl TypeOptionCellDataSerde for NumberTypeOption { +impl CellDataProtobufEncoder for NumberTypeOption { fn protobuf_encode( &self, cell_data: ::CellData, ) -> ::CellProtobufType { ProtobufStr::from(cell_data.0) } - - fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - Ok(NumberCellData::from(cell)) - } } -impl NumberTypeOption { - pub fn new() -> Self { - Self::default() - } +#[async_trait] +impl TypeOptionTransform for NumberTypeOption { + async fn transform_type_option( + &mut self, + view_id: &str, + field_id: &str, + old_type_option_field_type: FieldType, + _old_type_option_data: TypeOptionData, + _new_type_option_field_type: FieldType, + database: &mut Database, + ) { + match old_type_option_field_type { + FieldType::RichText => { + let rows = database + .get_cells_for_field(view_id, field_id) + .await + .into_iter() + .filter_map(|row| row.cell.map(|cell| (row.row_id, cell))) + .collect::>(); - fn format_cell_data(&self, num_cell_data: &NumberCellData) -> FlowyResult { - match self.format { - NumberFormat::Num => { - if SCIENTIFIC_NOTATION_REGEX - .is_match(&num_cell_data.0) - .unwrap() - { - match Decimal::from_scientific(&num_cell_data.0.to_lowercase()) { - Ok(value, ..) => Ok(NumberCellFormat::from_decimal(value)), - Err(_) => Ok(NumberCellFormat::new()), - } - } else { - // Test the input string is start with dot and only contains number. - // If it is, add a 0 before the dot. For example, ".123" -> "0.123" - let num_str = match START_WITH_DOT_NUM_REGEX.captures(&num_cell_data.0) { - Ok(Some(captures)) => match captures.get(0).map(|m| m.as_str().to_string()) { - Some(s) => { - format!("0{}", s) - }, - None => "".to_string(), - }, - // Extract the number from the string. - // For example, "123abc" -> "123". check out the number_type_option_input_test test for - // more examples. - _ => match EXTRACT_NUM_REGEX.captures(&num_cell_data.0) { - Ok(Some(captures)) => captures - .get(0) - .map(|m| m.as_str().to_string()) - .unwrap_or_default(), - _ => "".to_string(), - }, - }; - - match Decimal::from_str(&num_str) { - Ok(decimal, ..) => Ok(NumberCellFormat::from_decimal(decimal)), - Err(_) => Ok(NumberCellFormat::new()), + info!( + "Transforming RichText to NumberTypeOption, updating {} row's cell content", + rows.len() + ); + for (row_id, cell_data) in rows { + if let Ok(num_cell) = self + .decode_cell(&cell_data) + .and_then(|num_cell_data| self.format_cell_data(num_cell_data).map_err(Into::into)) + { + database + .update_row(row_id, |row| { + row.update_cells(|cell| { + cell.insert(field_id, NumberCellData::from(num_cell.to_string())); + }); + }) + .await; } } }, _ => { - // If the format is not number, use the format string to format the number. - NumberCellFormat::from_format_str(&num_cell_data.0, &self.format) + // do nothing }, } } - - pub fn set_format(&mut self, format: NumberFormat) { - self.format = format; - self.symbol = format.symbol(); - } } -impl TypeOptionTransform for NumberTypeOption {} - impl CellDataDecoder for NumberTypeOption { fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - let num_cell_data = self.parse_cell(cell)?; + let num_cell_data = Self::CellData::from(cell); Ok(NumberCellData::from( - self.format_cell_data(&num_cell_data)?.to_string(), + self.format_cell_data(num_cell_data)?.to_string(), )) } fn stringify_cell_data(&self, cell_data: ::CellData) -> String { - match self.format_cell_data(&cell_data) { + match self.format_cell_data(cell_data) { Ok(cell_data) => cell_data.to_string(), Err(_) => "".to_string(), } } - fn numeric_cell(&self, cell: &Cell) -> Option { - let num_cell_data = self.parse_cell(cell).ok()?; - num_cell_data.0.parse::().ok() + fn decode_cell_with_transform( + &self, + cell: &Cell, + _from_field_type: FieldType, + _field: &Field, + ) -> Option<::CellData> { + let num_cell = Self::CellData::from(cell); + Some(Self::CellData::from( + self.format_cell_data(num_cell).ok()?.to_string(), + )) } } @@ -207,7 +126,12 @@ impl CellDataChangeset for NumberTypeOption { let number_cell_data = NumberCellData(num_str); let formatter = self.format_cell_data(&number_cell_data)?; - tracing::trace!("number: {:?}", number_cell_data); + tracing::trace!( + "NumberTypeOption: {:?}, {}, {}", + number_cell_data, + formatter.to_string(), + formatter.to_unformatted_string() + ); match self.format { NumberFormat::Num => Ok(( NumberCellData(formatter.to_string()).into(), @@ -270,19 +194,6 @@ impl TypeOptionCellDataCompare for NumberTypeOption { } } -impl std::default::Default for NumberTypeOption { - fn default() -> Self { - let format = NumberFormat::default(); - let symbol = format.symbol(); - NumberTypeOption { - format, - scale: 0, - symbol, - name: "Number".to_string(), - } - } -} - lazy_static! { static ref SCIENTIFIC_NOTATION_REGEX: Regex = Regex::new(r"([+-]?\d*\.?\d+)e([+-]?\d+)").unwrap(); pub(crate) static ref EXTRACT_NUM_REGEX: Regex = Regex::new(r"-?\d+(\.\d+)?").unwrap(); diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs index 5085bc3db3..6f9cb36c49 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs @@ -1,119 +1,14 @@ use crate::services::cell::{CellBytesCustomParser, CellProtobufBlobParser}; -use crate::services::field::number_currency::Currency; -use crate::services::field::{NumberFormat, EXTRACT_NUM_REGEX, START_WITH_DOT_NUM_REGEX}; use bytes::Bytes; +use collab_database::fields::number_type_option::{NumberCellFormat, NumberFormat}; use flowy_error::FlowyResult; -use rust_decimal::Decimal; -use rusty_money::Money; -use std::str::FromStr; - -#[derive(Debug, Default)] -pub struct NumberCellFormat { - decimal: Option, - money: Option, -} - -impl NumberCellFormat { - pub fn new() -> Self { - Self { - decimal: Default::default(), - money: None, - } - } - - /// The num_str might contain currency symbol, e.g. $1,000.00 - pub fn from_format_str(num_str: &str, format: &NumberFormat) -> FlowyResult { - if num_str.is_empty() { - return Ok(Self::default()); - } - // If the first char is not '-', then it is a sign. - let sign_positive = match num_str.find('-') { - None => true, - Some(offset) => offset != 0, - }; - - let num_str = auto_fill_zero_at_start_if_need(num_str); - let num_str = extract_number(&num_str); - match Decimal::from_str(&num_str) { - Ok(mut decimal) => { - decimal.set_sign_positive(sign_positive); - let money = Money::from_decimal(decimal, format.currency()); - Ok(Self::from_money(money)) - }, - Err(_) => match Money::from_str(&num_str, format.currency()) { - Ok(money) => Ok(Self::from_money(money)), - Err(_) => Ok(Self::default()), - }, - } - } - - pub fn from_decimal(decimal: Decimal) -> Self { - Self { - decimal: Some(decimal), - money: None, - } - } - - pub fn from_money(money: Money) -> Self { - Self { - decimal: Some(*money.amount()), - money: Some(money.to_string()), - } - } - - pub fn decimal(&self) -> &Option { - &self.decimal - } - - pub fn is_empty(&self) -> bool { - self.decimal.is_none() - } - - pub fn to_unformatted_string(&self) -> String { - match self.decimal { - None => String::default(), - Some(decimal) => decimal.to_string(), - } - } -} - -fn auto_fill_zero_at_start_if_need(num_str: &str) -> String { - match START_WITH_DOT_NUM_REGEX.captures(num_str) { - Ok(Some(captures)) => match captures.get(0).map(|m| m.as_str().to_string()) { - Some(s) => format!("0{}", s), - None => num_str.to_string(), - }, - _ => num_str.to_string(), - } -} - -fn extract_number(num_str: &str) -> String { - let mut matches = EXTRACT_NUM_REGEX.find_iter(num_str); - let mut values = vec![]; - while let Some(Ok(m)) = matches.next() { - values.push(m.as_str().to_string()); - } - values.join("") -} - -impl ToString for NumberCellFormat { - fn to_string(&self) -> String { - match &self.money { - None => match self.decimal { - None => String::default(), - Some(decimal) => decimal.to_string(), - }, - Some(money) => money.to_string(), - } - } -} pub struct NumberCellDataParser(); impl CellProtobufBlobParser for NumberCellDataParser { type Object = NumberCellFormat; fn parser(bytes: &Bytes) -> FlowyResult { match String::from_utf8(bytes.to_vec()) { - Ok(s) => NumberCellFormat::from_format_str(&s, &NumberFormat::Num), + Ok(s) => NumberCellFormat::from_format_str(&s, &NumberFormat::Num).map_err(Into::into), Err(_) => Ok(NumberCellFormat::default()), } } @@ -124,7 +19,7 @@ impl CellBytesCustomParser for NumberCellCustomDataParser { type Object = NumberCellFormat; fn parse(&self, bytes: &Bytes) -> FlowyResult { match String::from_utf8(bytes.to_vec()) { - Ok(s) => NumberCellFormat::from_format_str(&s, &self.0), + Ok(s) => NumberCellFormat::from_format_str(&s, &self.0).map_err(Into::into), Err(_) => Ok(NumberCellFormat::default()), } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/mod.rs index 4ae30a6589..92a224c235 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/mod.rs @@ -1,5 +1,4 @@ mod relation; mod relation_entities; -pub use relation::*; pub use relation_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs index ac2548b89d..1ed1d6b1be 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs @@ -1,40 +1,21 @@ use std::cmp::Ordering; -use collab::core::any_map::AnyMapExtension; -use collab_database::fields::{TypeOptionData, TypeOptionDataBuilder}; +use collab_database::fields::relation_type_option::RelationTypeOption; + use collab_database::rows::Cell; +use collab_database::template::relation_parse::RelationCellData; +use collab_database::template::util::ToCellString; use flowy_error::FlowyResult; -use serde::{Deserialize, Serialize}; use crate::entities::{RelationCellDataPB, RelationFilterPB}; use crate::services::cell::{CellDataChangeset, CellDataDecoder}; use crate::services::field::{ - default_order, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, - TypeOptionCellDataSerde, TypeOptionTransform, + default_order, CellDataProtobufEncoder, TypeOption, TypeOptionCellDataCompare, + TypeOptionCellDataFilter, TypeOptionTransform, }; use crate::services::sort::SortCondition; -use super::{RelationCellChangeset, RelationCellData}; - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RelationTypeOption { - pub database_id: String, -} - -impl From for RelationTypeOption { - fn from(value: TypeOptionData) -> Self { - let database_id = value.get_str_value("database_id").unwrap_or_default(); - Self { database_id } - } -} - -impl From for TypeOptionData { - fn from(value: RelationTypeOption) -> Self { - TypeOptionDataBuilder::new() - .insert_str_value("database_id", value.database_id) - .build() - } -} +use super::RelationCellChangeset; impl TypeOption for RelationTypeOption { type CellData = RelationCellData; @@ -53,11 +34,10 @@ impl CellDataChangeset for RelationTypeOption { let cell_data = RelationCellData { row_ids: changeset.inserted_row_ids, }; - - return Ok(((&cell_data).into(), cell_data)); + return Ok(((cell_data.clone()).into(), cell_data)); } - let cell_data: RelationCellData = cell.unwrap().as_ref().into(); + let cell_data: RelationCellData = cell.as_ref().unwrap().into(); let mut row_ids = cell_data.row_ids.clone(); for inserted in changeset.inserted_row_ids.iter() { if !row_ids.iter().any(|row_id| row_id == inserted) { @@ -72,21 +52,13 @@ impl CellDataChangeset for RelationTypeOption { let cell_data = RelationCellData { row_ids }; - Ok(((&cell_data).into(), cell_data)) + Ok(((cell_data.clone()).into(), cell_data)) } } impl CellDataDecoder for RelationTypeOption { - fn decode_cell(&self, cell: &Cell) -> FlowyResult { - Ok(cell.into()) - } - fn stringify_cell_data(&self, cell_data: RelationCellData) -> String { - cell_data.to_string() - } - - fn numeric_cell(&self, _cell: &Cell) -> Option { - None + cell_data.to_cell_string() } } @@ -109,12 +81,8 @@ impl TypeOptionCellDataFilter for RelationTypeOption { impl TypeOptionTransform for RelationTypeOption {} -impl TypeOptionCellDataSerde for RelationTypeOption { +impl CellDataProtobufEncoder for RelationTypeOption { fn protobuf_encode(&self, cell_data: RelationCellData) -> RelationCellDataPB { cell_data.into() } - - fn parse_cell(&self, cell: &Cell) -> FlowyResult { - Ok(cell.into()) - } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation_entities.rs index 97b18590af..6c61bca8fe 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation_entities.rs @@ -1,82 +1,4 @@ -use std::sync::Arc; - -use collab::preclude::Any; -use collab_database::rows::{new_cell_builder, Cell, RowId}; - -use crate::entities::FieldType; -use crate::services::field::{TypeOptionCellData, CELL_DATA}; - -#[derive(Debug, Clone, Default)] -pub struct RelationCellData { - pub row_ids: Vec, -} - -impl From<&Cell> for RelationCellData { - fn from(value: &Cell) -> Self { - let row_ids = match value.get(CELL_DATA) { - Some(Any::Array(array)) => array - .iter() - .flat_map(|item| { - if let Any::String(string) = item { - Some(RowId::from(string.clone().to_string())) - } else { - None - } - }) - .collect(), - _ => vec![], - }; - Self { row_ids } - } -} - -impl From<&RelationCellData> for Cell { - fn from(value: &RelationCellData) -> Self { - let data = Any::Array(Arc::from( - value - .row_ids - .clone() - .into_iter() - .map(|id| Any::String(Arc::from(id.to_string()))) - .collect::>(), - )); - new_cell_builder(FieldType::Relation) - .insert_any(CELL_DATA, data) - .build() - } -} - -impl From for RelationCellData { - fn from(s: String) -> Self { - if s.is_empty() { - return RelationCellData { row_ids: vec![] }; - } - - let ids = s - .split(", ") - .map(|id| id.to_string().into()) - .collect::>(); - - RelationCellData { row_ids: ids } - } -} - -impl TypeOptionCellData for RelationCellData { - fn is_cell_empty(&self) -> bool { - self.row_ids.is_empty() - } -} - -impl ToString for RelationCellData { - fn to_string(&self) -> String { - self - .row_ids - .iter() - .map(|id| id.to_string()) - .collect::>() - .join(", ") - } -} +use collab_database::rows::RowId; #[derive(Debug, Clone, Default)] pub struct RelationCellChangeset { diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/mod.rs index dbcac0b8c2..8a4d4e40c9 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/mod.rs @@ -1,13 +1,7 @@ mod multi_select_type_option; mod select_filter; -mod select_ids; -mod select_option; mod select_type_option; mod single_select_type_option; mod type_option_transform; -pub use multi_select_type_option::*; -pub use select_ids::*; -pub use select_option::*; pub use select_type_option::*; -pub use single_select_type_option::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs index 8ebd0d1db4..5f1cbeb5d8 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs @@ -1,27 +1,20 @@ -use std::cmp::Ordering; - -use collab::core::any_map::AnyMapExtension; -use collab_database::fields::{TypeOptionData, TypeOptionDataBuilder}; -use collab_database::rows::Cell; -use serde::{Deserialize, Serialize}; - -use flowy_error::FlowyResult; - use crate::entities::{FieldType, SelectOptionCellDataPB, SelectOptionFilterPB}; use crate::services::cell::CellDataChangeset; use crate::services::field::{ - default_order, SelectOption, SelectOptionCellChangeset, SelectOptionIds, - SelectTypeOptionSharedAction, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, - TypeOptionCellDataSerde, + default_order, CellDataProtobufEncoder, SelectOptionCellChangeset, SelectTypeOptionSharedAction, + TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, }; use crate::services::sort::SortCondition; -// Multiple select -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct MultiSelectTypeOption { - pub options: Vec, - pub disable_color: bool, -} +use collab_database::fields::select_type_option::{ + MultiSelectTypeOption, SelectOption, SelectOptionIds, +}; +use collab_database::fields::TypeOptionData; +use collab_database::rows::Cell; +use flowy_error::FlowyResult; + +use collab_database::template::util::ToCellString; +use std::cmp::Ordering; impl TypeOption for MultiSelectTypeOption { type CellData = SelectOptionIds; @@ -30,35 +23,13 @@ impl TypeOption for MultiSelectTypeOption { type CellFilter = SelectOptionFilterPB; } -impl From for MultiSelectTypeOption { - fn from(data: TypeOptionData) -> Self { - data - .get_str_value("content") - .map(|s| serde_json::from_str::(&s).unwrap_or_default()) - .unwrap_or_default() - } -} - -impl From for TypeOptionData { - fn from(data: MultiSelectTypeOption) -> Self { - let content = serde_json::to_string(&data).unwrap_or_default(); - TypeOptionDataBuilder::new() - .insert_str_value("content", content) - .build() - } -} - -impl TypeOptionCellDataSerde for MultiSelectTypeOption { +impl CellDataProtobufEncoder for MultiSelectTypeOption { fn protobuf_encode( &self, cell_data: ::CellData, ) -> ::CellProtobufType { self.get_selected_options(cell_data).into() } - - fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - Ok(SelectOptionIds::from(cell)) - } } impl SelectTypeOptionSharedAction for MultiSelectTypeOption { @@ -110,12 +81,12 @@ impl CellDataChangeset for MultiSelectTypeOption { select_ids.retain(|id| id != &delete_option_id); } - tracing::trace!("Multi-select cell data: {}", select_ids.to_string()); + tracing::trace!("Multi-select cell data: {}", select_ids.to_cell_string()); select_ids }, }; Ok(( - select_option_ids.to_cell_data(FieldType::MultiSelect), + select_option_ids.to_cell(FieldType::MultiSelect), select_option_ids, )) } @@ -178,48 +149,21 @@ impl TypeOptionCellDataCompare for MultiSelectTypeOption { #[cfg(test)] mod tests { - use crate::entities::FieldType; use crate::services::cell::CellDataChangeset; use crate::services::field::type_options::selection_type_option::*; - use crate::services::field::MultiSelectTypeOption; - use crate::services::field::{CheckboxTypeOption, TypeOptionTransform}; - - #[test] - fn multi_select_transform_with_checkbox_type_option_test() { - let checkbox_type_option = CheckboxTypeOption(); - let mut multi_select = MultiSelectTypeOption::default(); - multi_select.transform_type_option(FieldType::Checkbox, checkbox_type_option.clone().into()); - debug_assert_eq!(multi_select.options.len(), 2); - - // Already contain the yes/no option. It doesn't need to insert new options - multi_select.transform_type_option(FieldType::Checkbox, checkbox_type_option.into()); - debug_assert_eq!(multi_select.options.len(), 2); - } - - #[test] - fn multi_select_transform_with_single_select_type_option_test() { - let google = SelectOption::new("Google"); - let facebook = SelectOption::new("Facebook"); - let single_select = SingleSelectTypeOption { - options: vec![google, facebook], - disable_color: false, - }; - let mut multi_select = MultiSelectTypeOption { - options: vec![], - disable_color: false, - }; - multi_select.transform_type_option(FieldType::MultiSelect, single_select.into()); - debug_assert_eq!(multi_select.options.len(), 2); - } + use collab_database::fields::select_type_option::{ + MultiSelectTypeOption, SelectOption, SelectOptionIds, SelectTypeOption, + }; + use collab_database::template::util::ToCellString; #[test] fn multi_select_insert_multi_option_test() { let google = SelectOption::new("Google"); let facebook = SelectOption::new("Facebook"); - let multi_select = MultiSelectTypeOption { + let multi_select = MultiSelectTypeOption(SelectTypeOption { options: vec![google.clone(), facebook.clone()], disable_color: false, - }; + }); let option_ids = vec![google.id, facebook.id]; let changeset = SelectOptionCellChangeset::from_insert_options(option_ids.clone()); @@ -233,10 +177,10 @@ mod tests { fn multi_select_unselect_multi_option_test() { let google = SelectOption::new("Google"); let facebook = SelectOption::new("Facebook"); - let multi_select = MultiSelectTypeOption { + let multi_select = MultiSelectTypeOption(SelectTypeOption { options: vec![google.clone(), facebook.clone()], disable_color: false, - }; + }); let option_ids = vec![google.id, facebook.id]; // insert @@ -253,23 +197,23 @@ mod tests { #[test] fn multi_select_insert_single_option_test() { let google = SelectOption::new("Google"); - let multi_select = MultiSelectTypeOption { + let multi_select = MultiSelectTypeOption(SelectTypeOption { options: vec![google.clone()], disable_color: false, - }; + }); let changeset = SelectOptionCellChangeset::from_insert_option_id(&google.id); let select_option_ids = multi_select.apply_changeset(changeset, None).unwrap().1; - assert_eq!(select_option_ids.to_string(), google.id); + assert_eq!(select_option_ids.to_cell_string(), google.id); } #[test] fn multi_select_insert_non_exist_option_test() { let google = SelectOption::new("Google"); - let multi_select = MultiSelectTypeOption { + let multi_select = MultiSelectTypeOption(SelectTypeOption { options: vec![], disable_color: false, - }; + }); let changeset = SelectOptionCellChangeset::from_insert_option_id(&google.id); let (_, select_option_ids) = multi_select.apply_changeset(changeset, None).unwrap(); @@ -279,10 +223,10 @@ mod tests { #[test] fn multi_select_insert_invalid_option_id_test() { let google = SelectOption::new("Google"); - let multi_select = MultiSelectTypeOption { + let multi_select = MultiSelectTypeOption(SelectTypeOption { options: vec![google], disable_color: false, - }; + }); // empty option id string let changeset = SelectOptionCellChangeset::from_insert_option_id(""); diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_filter.rs index a0e1ce096b..af1ff97368 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_filter.rs @@ -1,9 +1,10 @@ +use collab_database::fields::select_type_option::SelectOption; use collab_database::fields::Field; use collab_database::rows::Cell; use crate::entities::{SelectOptionFilterConditionPB, SelectOptionFilterPB}; use crate::services::cell::insert_select_option_cell; -use crate::services::field::{select_type_option_from_field, SelectOption}; +use crate::services::field::select_type_option_from_field; use crate::services::filter::PreFillCellsWithFilter; impl SelectOptionFilterPB { @@ -54,16 +55,14 @@ impl SelectOptionFilterStrategy { return false; } - selected_option_ids.len() == option_ids.len() - && selected_option_ids.iter().all(|id| option_ids.contains(id)) + selected_option_ids.iter().all(|id| option_ids.contains(id)) }, SelectOptionFilterStrategy::IsNot(option_ids) => { if selected_option_ids.is_empty() { return true; } - selected_option_ids.len() != option_ids.len() - || !selected_option_ids.iter().all(|id| option_ids.contains(id)) + !selected_option_ids.iter().all(|id| option_ids.contains(id)) }, SelectOptionFilterStrategy::Contains(option_ids) => { if selected_option_ids.is_empty() { @@ -96,19 +95,10 @@ impl SelectOptionFilterStrategy { } impl PreFillCellsWithFilter for SelectOptionFilterPB { - fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { - let get_non_empty_expected_options = || { - if !self.option_ids.is_empty() { - Some(self.option_ids.clone()) - } else { - None - } - }; - + fn get_compliant_cell(&self, field: &Field) -> Option { let option_ids = match self.condition { - SelectOptionFilterConditionPB::OptionIs => get_non_empty_expected_options(), - SelectOptionFilterConditionPB::OptionContains => { - get_non_empty_expected_options().map(|mut options| vec![options.swap_remove(0)]) + SelectOptionFilterConditionPB::OptionIs | SelectOptionFilterConditionPB::OptionContains => { + self.option_ids.first().map(|id| vec![id.clone()]) }, SelectOptionFilterConditionPB::OptionIsNotEmpty => select_type_option_from_field(field) .ok() @@ -123,16 +113,14 @@ impl PreFillCellsWithFilter for SelectOptionFilterPB { _ => None, }; - ( - option_ids.map(|ids| insert_select_option_cell(ids, field)), - false, - ) + option_ids.map(|ids| insert_select_option_cell(ids, field)) } } + #[cfg(test)] mod tests { use crate::entities::{SelectOptionFilterConditionPB, SelectOptionFilterPB}; - use crate::services::field::SelectOption; + use collab_database::fields::select_type_option::SelectOption; #[test] fn select_option_filter_is_empty_test() { @@ -152,11 +140,12 @@ mod tests { let option_2 = SelectOption::new("B"); let filter = SelectOptionFilterPB { condition: SelectOptionFilterConditionPB::OptionIsNotEmpty, - option_ids: vec![option_1.id.clone(), option_2.id.clone()], + option_ids: vec![], }; assert_eq!(filter.is_visible(&[]), Some(false)); assert_eq!(filter.is_visible(&[option_1.clone()]), Some(true)); + assert_eq!(filter.is_visible(&[option_1, option_2]), Some(true)); } #[test] @@ -187,8 +176,6 @@ mod tests { (vec![], Some(false)), (vec![option_1.clone()], Some(true)), (vec![option_2.clone()], Some(false)), - (vec![option_3.clone()], Some(false)), - (vec![option_1.clone(), option_2.clone()], Some(false)), ] { assert_eq!(filter.is_visible(&options), is_visible); } @@ -200,12 +187,9 @@ mod tests { }; for (options, is_visible) in [ (vec![], Some(false)), - (vec![option_1.clone()], Some(false)), - (vec![option_1.clone(), option_2.clone()], Some(true)), - ( - vec![option_1.clone(), option_2.clone(), option_3.clone()], - Some(false), - ), + (vec![option_1.clone()], Some(true)), + (vec![option_2.clone()], Some(true)), + (vec![option_3.clone()], Some(false)), ] { assert_eq!(filter.is_visible(&options), is_visible); } @@ -225,7 +209,7 @@ mod tests { for (options, is_visible) in [ (vec![], None), (vec![option_1.clone()], None), - (vec![option_1.clone(), option_2.clone()], None), + (vec![option_2.clone()], None), ] { assert_eq!(filter.is_visible(&options), is_visible); } @@ -240,7 +224,6 @@ mod tests { (vec![option_1.clone()], Some(false)), (vec![option_2.clone()], Some(true)), (vec![option_3.clone()], Some(true)), - (vec![option_1.clone(), option_2.clone()], Some(true)), ] { assert_eq!(filter.is_visible(&options), is_visible); } @@ -252,12 +235,9 @@ mod tests { }; for (options, is_visible) in [ (vec![], Some(true)), - (vec![option_1.clone()], Some(true)), - (vec![option_1.clone(), option_2.clone()], Some(false)), - ( - vec![option_1.clone(), option_2.clone(), option_3.clone()], - Some(true), - ), + (vec![option_1.clone()], Some(false)), + (vec![option_2.clone()], Some(false)), + (vec![option_3.clone()], Some(true)), ] { assert_eq!(filter.is_visible(&options), is_visible); } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_ids.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_ids.rs deleted file mode 100644 index c47738b788..0000000000 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_ids.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::str::FromStr; - -use collab::core::any_map::AnyMapExtension; -use collab_database::rows::{new_cell_builder, Cell}; - -use flowy_error::FlowyError; - -use crate::entities::FieldType; -use crate::services::field::{TypeOptionCellData, CELL_DATA}; - -pub const SELECTION_IDS_SEPARATOR: &str = ","; - -/// List of select option ids -/// -/// Calls [to_string] will return a string consists list of ids, -/// placing a commas separator between each -/// -#[derive(Default, Clone, Debug)] -pub struct SelectOptionIds(Vec); - -impl SelectOptionIds { - pub fn new() -> Self { - Self::default() - } - pub fn into_inner(self) -> Vec { - self.0 - } - pub fn to_cell_data(&self, field_type: FieldType) -> Cell { - new_cell_builder(field_type) - .insert_str_value(CELL_DATA, self.to_string()) - .build() - } -} - -impl TypeOptionCellData for SelectOptionIds { - fn is_cell_empty(&self) -> bool { - self.is_empty() - } -} - -impl From<&Cell> for SelectOptionIds { - fn from(cell: &Cell) -> Self { - let value = cell.get_str_value(CELL_DATA).unwrap_or_default(); - Self::from_str(&value).unwrap_or_default() - } -} - -impl FromStr for SelectOptionIds { - type Err = FlowyError; - - fn from_str(s: &str) -> Result { - if s.is_empty() { - return Ok(Self(vec![])); - } - let ids = s - .split(SELECTION_IDS_SEPARATOR) - .map(|id| id.to_string()) - .collect::>(); - Ok(Self(ids)) - } -} - -impl std::convert::From> for SelectOptionIds { - fn from(ids: Vec) -> Self { - let ids = ids - .into_iter() - .filter(|id| !id.is_empty()) - .collect::>(); - Self(ids) - } -} - -impl ToString for SelectOptionIds { - /// Returns a string that consists list of ids, placing a commas - /// separator between each - fn to_string(&self) -> String { - self.0.join(SELECTION_IDS_SEPARATOR) - } -} - -impl std::convert::From> for SelectOptionIds { - fn from(s: Option) -> Self { - match s { - None => Self(vec![]), - Some(s) => Self::from_str(&s).unwrap_or_default(), - } - } -} - -impl std::ops::Deref for SelectOptionIds { - type Target = Vec; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl std::ops::DerefMut for SelectOptionIds { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_option.rs deleted file mode 100644 index f7755f55b5..0000000000 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_option.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::entities::SelectOptionCellDataPB; -use crate::services::field::SelectOptionIds; -use collab_database::database::gen_option_id; -use serde::{Deserialize, Serialize}; - -/// [SelectOption] represents an option for a single select, and multiple select. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct SelectOption { - pub id: String, - pub name: String, - pub color: SelectOptionColor, -} - -impl SelectOption { - pub fn new(name: &str) -> Self { - SelectOption { - id: gen_option_id(), - name: name.to_owned(), - color: SelectOptionColor::default(), - } - } - - pub fn with_color(name: &str, color: SelectOptionColor) -> Self { - SelectOption { - id: gen_option_id(), - name: name.to_owned(), - color, - } - } -} - -#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] -#[repr(u8)] -#[derive(Default)] -pub enum SelectOptionColor { - #[default] - Purple = 0, - Pink = 1, - LightPink = 2, - Orange = 3, - Yellow = 4, - Lime = 5, - Green = 6, - Aqua = 7, - Blue = 8, -} - -#[derive(Debug)] -pub struct SelectOptionCellData { - pub select_options: Vec, -} - -impl From for SelectOptionCellDataPB { - fn from(data: SelectOptionCellData) -> Self { - SelectOptionCellDataPB { - select_options: data - .select_options - .into_iter() - .map(|option| option.into()) - .collect(), - } - } -} - -pub fn make_selected_options(ids: SelectOptionIds, options: &[SelectOption]) -> Vec { - ids - .iter() - .flat_map(|option_id| { - options - .iter() - .find(|option| &option.id == option_id) - .cloned() - }) - .collect() -} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs index 4e558b5c83..dde93d4f50 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs @@ -1,19 +1,21 @@ -use std::str::FromStr; - -use bytes::Bytes; -use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::Cell; - -use flowy_error::{internal_error, ErrorCode, FlowyResult}; - use crate::entities::{CheckboxCellDataPB, FieldType, SelectOptionCellDataPB}; use crate::services::cell::{CellDataDecoder, CellProtobufBlobParser}; use crate::services::field::selection_type_option::type_option_transform::SelectOptionTypeOptionTransformHelper; use crate::services::field::{ - make_selected_options, MultiSelectTypeOption, SelectOption, SelectOptionCellData, - SelectOptionColor, SelectOptionIds, SingleSelectTypeOption, TypeOption, TypeOptionCellDataSerde, - TypeOptionTransform, SELECTION_IDS_SEPARATOR, + CellDataProtobufEncoder, StringCellData, TypeOption, TypeOptionTransform, }; +use async_trait::async_trait; +use bytes::Bytes; +use collab_database::database::Database; +use collab_database::fields::select_type_option::{ + MultiSelectTypeOption, SelectOption, SelectOptionColor, SelectOptionIds, SingleSelectTypeOption, + SELECTION_IDS_SEPARATOR, +}; +use collab_database::fields::{Field, TypeOptionData}; +use collab_database::rows::Cell; +use collab_database::template::util::ToCellString; +use flowy_error::{internal_error, ErrorCode, FlowyResult}; +use std::str::FromStr; /// Defines the shared actions used by SingleSelect or Multi-Select. pub trait SelectTypeOptionSharedAction: Send + Sync { @@ -68,32 +70,38 @@ pub trait SelectTypeOptionSharedAction: Send + Sync { fn mut_options(&mut self) -> &mut Vec; } +#[async_trait] impl TypeOptionTransform for T where T: SelectTypeOptionSharedAction + TypeOption + CellDataDecoder, { - fn transform_type_option( + async fn transform_type_option( &mut self, - _old_type_option_field_type: FieldType, - _old_type_option_data: TypeOptionData, + view_id: &str, + field_id: &str, + old_type_option_field_type: FieldType, + old_type_option_data: TypeOptionData, + new_type_option_field_type: FieldType, + database: &mut Database, ) { SelectOptionTypeOptionTransformHelper::transform_type_option( self, - &_old_type_option_field_type, - _old_type_option_data, - ); + view_id, + field_id, + &old_type_option_field_type, + old_type_option_data, + new_type_option_field_type, + database, + ) + .await; } } impl CellDataDecoder for T where T: - SelectTypeOptionSharedAction + TypeOption + TypeOptionCellDataSerde, + SelectTypeOptionSharedAction + TypeOption + CellDataProtobufEncoder, { - fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - self.parse_cell(cell) - } - fn decode_cell_with_transform( &self, cell: &Cell, @@ -101,8 +109,17 @@ where _field: &Field, ) -> Option<::CellData> { match from_field_type { + FieldType::RichText => { + let text_cell = StringCellData::from(cell).into_inner(); + let mut transformed_ids = Vec::new(); + let options = self.options(); + if let Some(option) = options.iter().find(|option| option.name == text_cell) { + transformed_ids.push(option.id.clone()); + } + Some(SelectOptionIds::from(transformed_ids)) + }, FieldType::Checkbox => { - let cell_content = CheckboxCellDataPB::from(cell).to_string(); + let cell_content = CheckboxCellDataPB::from(cell).to_cell_string(); let mut transformed_ids = Vec::new(); let options = self.options(); if let Some(option) = options.iter().find(|option| option.name == cell_content) { @@ -110,7 +127,6 @@ where } Some(SelectOptionIds::from(transformed_ids)) }, - FieldType::RichText => Some(SelectOptionIds::from(cell)), FieldType::SingleSelect | FieldType::MultiSelect => Some(SelectOptionIds::from(cell)), _ => None, } @@ -125,10 +141,6 @@ where .collect::>() .join(SELECTION_IDS_SEPARATOR) } - - fn numeric_cell(&self, _cell: &Cell) -> Option { - None - } } pub fn select_type_option_from_field( @@ -187,7 +199,7 @@ impl CellProtobufBlobParser for SelectOptionIdsParser { type Object = SelectOptionIds; fn parser(bytes: &Bytes) -> FlowyResult { match String::from_utf8(bytes.to_vec()) { - Ok(s) => SelectOptionIds::from_str(&s), + Ok(s) => SelectOptionIds::from_str(&s).map_err(Into::into), Err(_) => Ok(SelectOptionIds::default()), } } @@ -237,3 +249,32 @@ impl SelectOptionCellChangeset { } } } + +#[derive(Debug)] +pub struct SelectOptionCellData { + pub select_options: Vec, +} + +impl From for SelectOptionCellDataPB { + fn from(data: SelectOptionCellData) -> Self { + SelectOptionCellDataPB { + select_options: data + .select_options + .into_iter() + .map(|option| option.into()) + .collect(), + } + } +} + +pub fn make_selected_options(ids: SelectOptionIds, options: &[SelectOption]) -> Vec { + ids + .iter() + .flat_map(|option_id| { + options + .iter() + .find(|option| &option.id == option_id) + .cloned() + }) + .collect() +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs index fa0745133b..b91cb5be3b 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs @@ -1,26 +1,22 @@ use crate::entities::{FieldType, SelectOptionCellDataPB, SelectOptionFilterPB}; use crate::services::cell::CellDataChangeset; use crate::services::field::{ - default_order, SelectOption, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, - TypeOptionCellDataSerde, -}; -use crate::services::field::{ - SelectOptionCellChangeset, SelectOptionIds, SelectTypeOptionSharedAction, + default_order, CellDataProtobufEncoder, TypeOption, TypeOptionCellDataCompare, + TypeOptionCellDataFilter, }; +use crate::services::field::{SelectOptionCellChangeset, SelectTypeOptionSharedAction}; use crate::services::sort::SortCondition; -use collab::core::any_map::AnyMapExtension; -use collab_database::fields::{TypeOptionData, TypeOptionDataBuilder}; + +use collab_database::fields::select_type_option::{ + SelectOption, SelectOptionIds, SingleSelectTypeOption, +}; +use collab_database::fields::TypeOptionData; use collab_database::rows::Cell; use flowy_error::FlowyResult; -use serde::{Deserialize, Serialize}; + use std::cmp::Ordering; // Single select -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct SingleSelectTypeOption { - pub options: Vec, - pub disable_color: bool, -} impl TypeOption for SingleSelectTypeOption { type CellData = SelectOptionIds; @@ -29,35 +25,13 @@ impl TypeOption for SingleSelectTypeOption { type CellFilter = SelectOptionFilterPB; } -impl From for SingleSelectTypeOption { - fn from(data: TypeOptionData) -> Self { - data - .get_str_value("content") - .map(|s| serde_json::from_str::(&s).unwrap_or_default()) - .unwrap_or_default() - } -} - -impl From for TypeOptionData { - fn from(data: SingleSelectTypeOption) -> Self { - let content = serde_json::to_string(&data).unwrap_or_default(); - TypeOptionDataBuilder::new() - .insert_str_value("content", content) - .build() - } -} - -impl TypeOptionCellDataSerde for SingleSelectTypeOption { +impl CellDataProtobufEncoder for SingleSelectTypeOption { fn protobuf_encode( &self, cell_data: ::CellData, ) -> ::CellProtobufType { self.get_selected_options(cell_data).into() } - - fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - Ok(SelectOptionIds::from(cell)) - } } impl SelectTypeOptionSharedAction for SingleSelectTypeOption { @@ -106,7 +80,7 @@ impl CellDataChangeset for SingleSelectTypeOption { SelectOptionIds::from(insert_option_ids) }; Ok(( - select_option_ids.to_cell_data(FieldType::SingleSelect), + select_option_ids.to_cell(FieldType::SingleSelect), select_option_ids, )) } @@ -151,49 +125,20 @@ impl TypeOptionCellDataCompare for SingleSelectTypeOption { #[cfg(test)] mod tests { - use crate::entities::FieldType; use crate::services::cell::CellDataChangeset; use crate::services::field::type_options::*; - - #[test] - fn single_select_transform_with_checkbox_type_option_test() { - let checkbox = CheckboxTypeOption::default(); - - let mut single_select = SingleSelectTypeOption::default(); - single_select.transform_type_option(FieldType::Checkbox, checkbox.clone().into()); - debug_assert_eq!(single_select.options.len(), 2); - - // Already contain the yes/no option. It doesn't need to insert new options - single_select.transform_type_option(FieldType::Checkbox, checkbox.into()); - debug_assert_eq!(single_select.options.len(), 2); - } - - #[test] - fn single_select_transform_with_multi_select_type_option_test() { - let google = SelectOption::new("Google"); - let facebook = SelectOption::new("Facebook"); - let multi_select = MultiSelectTypeOption { - options: vec![google, facebook], - disable_color: false, - }; - - let mut single_select = SingleSelectTypeOption::default(); - single_select.transform_type_option(FieldType::MultiSelect, multi_select.clone().into()); - debug_assert_eq!(single_select.options.len(), 2); - - // Already contain the yes/no option. It doesn't need to insert new options - single_select.transform_type_option(FieldType::MultiSelect, multi_select.into()); - debug_assert_eq!(single_select.options.len(), 2); - } + use collab_database::fields::select_type_option::{ + SelectOption, SelectTypeOption, SingleSelectTypeOption, + }; #[test] fn single_select_insert_multi_option_test() { let google = SelectOption::new("Google"); let facebook = SelectOption::new("Facebook"); - let single_select = SingleSelectTypeOption { + let single_select = SingleSelectTypeOption(SelectTypeOption { options: vec![google.clone(), facebook.clone()], disable_color: false, - }; + }); let option_ids = vec![google.id.clone(), facebook.id]; let changeset = SelectOptionCellChangeset::from_insert_options(option_ids); @@ -205,10 +150,10 @@ mod tests { fn single_select_unselect_multi_option_test() { let google = SelectOption::new("Google"); let facebook = SelectOption::new("Facebook"); - let single_select = SingleSelectTypeOption { + let single_select = SingleSelectTypeOption(SelectTypeOption { options: vec![google.clone(), facebook.clone()], disable_color: false, - }; + }); let option_ids = vec![google.id.clone(), facebook.id]; // insert @@ -219,6 +164,6 @@ mod tests { // delete let changeset = SelectOptionCellChangeset::from_delete_options(option_ids); let select_option_ids = single_select.apply_changeset(changeset, None).unwrap().1; - assert!(select_option_ids.is_cell_empty()); + assert!(select_option_ids.is_empty()); } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/type_option_transform.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/type_option_transform.rs index 427069c182..b085bab09d 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/type_option_transform.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/type_option_transform.rs @@ -1,9 +1,14 @@ use crate::entities::FieldType; -use crate::services::field::{ - MultiSelectTypeOption, SelectOption, SelectOptionColor, SelectOptionIds, - SelectTypeOptionSharedAction, SingleSelectTypeOption, TypeOption, CHECK, UNCHECK, +use crate::services::cell::CellDataDecoder; +use crate::services::field::{SelectTypeOptionSharedAction, TypeOption, CHECK, UNCHECK}; +use collab_database::database::Database; +use collab_database::fields::select_type_option::{ + SelectOption, SelectOptionColor, SelectOptionIds, SelectTypeOption, SELECTION_IDS_SEPARATOR, }; +use collab_database::fields::text_type_option::RichTextTypeOption; use collab_database::fields::TypeOptionData; +use collab_database::template::option_parse::build_options_from_cells; +use tracing::info; /// Handles how to transform the cell data when switching between different field types pub(crate) struct SelectOptionTypeOptionTransformHelper(); @@ -14,14 +19,70 @@ impl SelectOptionTypeOptionTransformHelper { /// /// * `old_field_type`: the FieldType of the passed-in TypeOptionData /// - pub fn transform_type_option( + pub async fn transform_type_option( shared: &mut T, + view_id: &str, + field_id: &str, old_field_type: &FieldType, old_type_option_data: TypeOptionData, + new_field_type: FieldType, + database: &mut Database, ) where T: SelectTypeOptionSharedAction + TypeOption, { match old_field_type { + FieldType::RichText => { + if !shared.options().is_empty() { + return; + } + let text_type_option = RichTextTypeOption::from(old_type_option_data); + let rows = database + .get_cells_for_field(view_id, field_id) + .await + .into_iter() + .filter_map(|row| row.cell.map(|cell| (row.row_id, cell))) + .map(|(row_id, cell)| { + let text = text_type_option + .decode_cell(&cell) + .unwrap_or_default() + .into_inner(); + (row_id, text) + }) + .collect::>(); + + let options = + build_options_from_cells(&rows.iter().map(|row| row.1.clone()).collect::>()); + info!( + "Transforming RichText to SelectOption, updating {} row's cell content", + rows.len() + ); + for (row_id, text_cell) in rows { + let mut transformed_ids = Vec::new(); + let names = text_cell + .split(SELECTION_IDS_SEPARATOR) + .map(|name| name.to_string()) + .collect::>(); + + names.into_iter().for_each(|name| { + if let Some(option) = options.iter().find(|option| option.name == name) { + transformed_ids.push(option.id.clone()); + } + }); + + database + .update_row(row_id, |row| { + row.update_cells(|cell| { + cell.insert( + field_id, + SelectOptionIds::from(transformed_ids).to_cell(new_field_type), + ); + }); + }) + .await; + } + + shared.mut_options().extend(options); + }, FieldType::Checkbox => { // add Yes and No options if it does not exist. if !shared.options().iter().any(|option| option.name == CHECK) { @@ -35,7 +96,7 @@ impl SelectOptionTypeOptionTransformHelper { } }, FieldType::MultiSelect => { - let options = MultiSelectTypeOption::from(old_type_option_data).options; + let options = SelectTypeOption::from(old_type_option_data).options; options.iter().for_each(|new_option| { if !shared .options() @@ -47,7 +108,7 @@ impl SelectOptionTypeOptionTransformHelper { }) }, FieldType::SingleSelect => { - let options = SingleSelectTypeOption::from(old_type_option_data).options; + let options = SelectTypeOption::from(old_type_option_data).options; options.iter().for_each(|new_option| { if !shared .options() diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/mod.rs index e927cc4feb..1c631a9922 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/mod.rs @@ -1,2 +1 @@ pub mod summary; -pub mod summary_entities; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/summary.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/summary.rs index 920f76de8e..87aed94576 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/summary.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/summary.rs @@ -1,38 +1,17 @@ use crate::entities::TextFilterPB; use crate::services::cell::{CellDataChangeset, CellDataDecoder}; -use crate::services::field::summary_type_option::summary_entities::SummaryCellData; use crate::services::field::type_options::util::ProtobufStr; use crate::services::field::{ - TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, TypeOptionCellDataFilter, - TypeOptionCellDataSerde, TypeOptionTransform, + CellDataProtobufEncoder, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, + TypeOptionCellDataFilter, TypeOptionTransform, }; use crate::services::sort::SortCondition; -use collab::core::any_map::AnyMapExtension; -use collab_database::fields::{TypeOptionData, TypeOptionDataBuilder}; +use collab_database::fields::summary_type_option::SummarizationTypeOption; use collab_database::rows::Cell; +use collab_database::template::summary_parse::SummaryCellData; use flowy_error::FlowyResult; use std::cmp::Ordering; -#[derive(Default, Debug, Clone)] -pub struct SummarizationTypeOption { - pub auto_fill: bool, -} - -impl From for SummarizationTypeOption { - fn from(value: TypeOptionData) -> Self { - let auto_fill = value.get_bool_value("auto_fill").unwrap_or_default(); - Self { auto_fill } - } -} - -impl From for TypeOptionData { - fn from(value: SummarizationTypeOption) -> Self { - TypeOptionDataBuilder::new() - .insert_bool_value("auto_fill", value.auto_fill) - .build() - } -} - impl TypeOption for SummarizationTypeOption { type CellData = SummaryCellData; type CellChangeset = String; @@ -81,29 +60,17 @@ impl TypeOptionCellDataCompare for SummarizationTypeOption { } impl CellDataDecoder for SummarizationTypeOption { - fn decode_cell(&self, cell: &Cell) -> FlowyResult { - Ok(SummaryCellData::from(cell)) - } - fn stringify_cell_data(&self, cell_data: SummaryCellData) -> String { cell_data.to_string() } - - fn numeric_cell(&self, _cell: &Cell) -> Option { - None - } } impl TypeOptionTransform for SummarizationTypeOption {} -impl TypeOptionCellDataSerde for SummarizationTypeOption { +impl CellDataProtobufEncoder for SummarizationTypeOption { fn protobuf_encode( &self, cell_data: ::CellData, ) -> ::CellProtobufType { ProtobufStr::from(cell_data.0) } - - fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - Ok(SummaryCellData::from(cell)) - } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/summary_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/summary_entities.rs deleted file mode 100644 index 8d45578e38..0000000000 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/summary_entities.rs +++ /dev/null @@ -1,46 +0,0 @@ -use crate::entities::FieldType; -use crate::services::field::{TypeOptionCellData, CELL_DATA}; -use collab::core::any_map::AnyMapExtension; -use collab_database::rows::{new_cell_builder, Cell}; - -#[derive(Default, Debug, Clone)] -pub struct SummaryCellData(pub String); -impl std::ops::Deref for SummaryCellData { - type Target = String; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl TypeOptionCellData for SummaryCellData { - fn is_cell_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl From<&Cell> for SummaryCellData { - fn from(cell: &Cell) -> Self { - Self(cell.get_str_value(CELL_DATA).unwrap_or_default()) - } -} - -impl From for Cell { - fn from(data: SummaryCellData) -> Self { - new_cell_builder(FieldType::Summary) - .insert_str_value(CELL_DATA, data.0) - .build() - } -} - -impl ToString for SummaryCellData { - fn to_string(&self) -> String { - self.0.clone() - } -} - -impl AsRef for SummaryCellData { - fn as_ref(&self) -> &str { - &self.0 - } -} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_filter.rs index 8f090f5802..8ba903f217 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_filter.rs @@ -8,7 +8,18 @@ impl TextFilterPB { pub fn is_visible>(&self, cell_data: T) -> bool { let cell_data = cell_data.as_ref().to_lowercase(); let content = &self.content.to_lowercase(); + match self.condition { + TextFilterConditionPB::TextIs + | TextFilterConditionPB::TextIsNot + | TextFilterConditionPB::TextContains + | TextFilterConditionPB::TextDoesNotContain + | TextFilterConditionPB::TextStartsWith + | TextFilterConditionPB::TextEndsWith + if content.is_empty() => + { + true + }, TextFilterConditionPB::TextIs => &cell_data == content, TextFilterConditionPB::TextIsNot => &cell_data != content, TextFilterConditionPB::TextContains => cell_data.contains(content), @@ -22,7 +33,7 @@ impl TextFilterPB { } impl PreFillCellsWithFilter for TextFilterPB { - fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { + fn get_compliant_cell(&self, field: &Field) -> Option { let text = match self.condition { TextFilterConditionPB::TextIs | TextFilterConditionPB::TextContains @@ -35,9 +46,7 @@ impl PreFillCellsWithFilter for TextFilterPB { _ => None, }; - let open_after_create = matches!(self.condition, TextFilterConditionPB::TextIsNotEmpty); - - (text.map(|s| insert_text_cell(s, field)), open_after_create) + text.map(|s| insert_text_cell(s, field)) } } @@ -53,10 +62,22 @@ mod tests { content: "appflowy".to_owned(), }; - assert!(text_filter.is_visible("AppFlowy")); + assert_eq!(text_filter.is_visible("AppFlowy"), true); assert_eq!(text_filter.is_visible("appflowy"), true); assert_eq!(text_filter.is_visible("Appflowy"), true); assert_eq!(text_filter.is_visible("AppFlowy.io"), false); + assert_eq!(text_filter.is_visible(""), false); + + let text_filter = TextFilterPB { + condition: TextFilterConditionPB::TextIs, + content: "".to_owned(), + }; + + assert_eq!(text_filter.is_visible("AppFlowy"), true); + assert_eq!(text_filter.is_visible("appflowy"), true); + assert_eq!(text_filter.is_visible("Appflowy"), true); + assert_eq!(text_filter.is_visible("AppFlowy.io"), true); + assert_eq!(text_filter.is_visible(""), true); } #[test] fn text_filter_start_with_test() { @@ -68,6 +89,17 @@ mod tests { assert_eq!(text_filter.is_visible("AppFlowy.io"), true); assert_eq!(text_filter.is_visible(""), false); assert_eq!(text_filter.is_visible("https"), false); + assert_eq!(text_filter.is_visible(""), false); + + let text_filter = TextFilterPB { + condition: TextFilterConditionPB::TextStartsWith, + content: "".to_owned(), + }; + + assert_eq!(text_filter.is_visible("AppFlowy.io"), true); + assert_eq!(text_filter.is_visible(""), true); + assert_eq!(text_filter.is_visible("https"), true); + assert_eq!(text_filter.is_visible(""), true); } #[test] @@ -80,6 +112,17 @@ mod tests { assert_eq!(text_filter.is_visible("https://github.com/appflowy"), true); assert_eq!(text_filter.is_visible("App"), false); assert_eq!(text_filter.is_visible("appflowy.io"), false); + assert_eq!(text_filter.is_visible(""), false); + + let text_filter = TextFilterPB { + condition: TextFilterConditionPB::TextEndsWith, + content: "".to_owned(), + }; + + assert_eq!(text_filter.is_visible("https://github.com/appflowy"), true); + assert_eq!(text_filter.is_visible("App"), true); + assert_eq!(text_filter.is_visible("appflowy.io"), true); + assert_eq!(text_filter.is_visible(""), true); } #[test] fn text_filter_empty_test() { @@ -90,6 +133,14 @@ mod tests { assert_eq!(text_filter.is_visible(""), true); assert_eq!(text_filter.is_visible("App"), false); + + let text_filter = TextFilterPB { + condition: TextFilterConditionPB::TextIsEmpty, + content: "".to_owned(), + }; + + assert_eq!(text_filter.is_visible(""), true); + assert_eq!(text_filter.is_visible("App"), false); } #[test] fn text_filter_contain_test() { @@ -103,5 +154,16 @@ mod tests { assert_eq!(text_filter.is_visible("App"), false); assert_eq!(text_filter.is_visible(""), false); assert_eq!(text_filter.is_visible("github"), false); + + let text_filter = TextFilterPB { + condition: TextFilterConditionPB::TextContains, + content: "".to_owned(), + }; + + assert_eq!(text_filter.is_visible("https://github.com/appflowy"), true); + assert_eq!(text_filter.is_visible("AppFlowy"), true); + assert_eq!(text_filter.is_visible("App"), true); + assert_eq!(text_filter.is_visible(""), true); + assert_eq!(text_filter.is_visible("github"), true); } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_tests.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_tests.rs index 5728642b9e..e5101a2775 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_tests.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_tests.rs @@ -3,14 +3,16 @@ mod tests { use crate::entities::FieldType; use crate::services::cell::{insert_select_option_cell, stringify_cell}; use crate::services::field::FieldBuilder; - use crate::services::field::*; + + use collab_database::fields::date_type_option::{DateCellData, DateTypeOption}; + use collab_database::fields::select_type_option::{SelectOption, SelectTypeOption}; // Test parser the cell data which field's type is FieldType::Date to cell data // which field's type is FieldType::Text #[test] fn date_type_to_text_type() { let field_type = FieldType::DateTime; - let field = FieldBuilder::new(field_type, DateTypeOption::test()).build(); + let field = FieldBuilder::new(field_type, DateTypeOption::default_utc()).build(); let data = DateCellData { timestamp: Some(1647251762), @@ -67,7 +69,7 @@ mod tests { let done_option = SelectOption::new("Done"); let option_id = done_option.id.clone(); - let single_select = SingleSelectTypeOption { + let single_select = SelectTypeOption { options: vec![done_option.clone()], disable_color: false, }; @@ -84,7 +86,7 @@ mod tests { let france = SelectOption::new("france"); let argentina = SelectOption::new("argentina"); - let multi_select = MultiSelectTypeOption { + let multi_select = SelectTypeOption { options: vec![france.clone(), argentina.clone()], disable_color: false, }; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs index e8c3e8b9d8..6872d97d95 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs @@ -1,29 +1,21 @@ +use collab::util::AnyMapExt; use std::cmp::Ordering; -use collab::core::any_map::AnyMapExtension; -use collab_database::fields::{Field, TypeOptionData, TypeOptionDataBuilder}; +use collab_database::fields::text_type_option::RichTextTypeOption; +use collab_database::fields::Field; use collab_database::rows::{new_cell_builder, Cell}; -use serde::{Deserialize, Serialize}; - +use collab_database::template::util::ToCellString; use flowy_error::{FlowyError, FlowyResult}; use crate::entities::{FieldType, TextFilterPB}; use crate::services::cell::{stringify_cell, CellDataChangeset, CellDataDecoder}; use crate::services::field::type_options::util::ProtobufStr; use crate::services::field::{ - TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, TypeOptionCellDataFilter, - TypeOptionCellDataSerde, TypeOptionTransform, CELL_DATA, + CellDataProtobufEncoder, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, + TypeOptionCellDataFilter, TypeOptionTransform, CELL_DATA, }; use crate::services::sort::SortCondition; -/// For the moment, the `RichTextTypeOptionPB` is empty. The `data` property is not -/// used yet. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RichTextTypeOption { - #[serde(default)] - pub inner: String, -} - impl TypeOption for RichTextTypeOption { type CellData = StringCellData; type CellChangeset = String; @@ -31,41 +23,18 @@ impl TypeOption for RichTextTypeOption { type CellFilter = TextFilterPB; } -impl From for RichTextTypeOption { - fn from(data: TypeOptionData) -> Self { - let s = data.get_str_value(CELL_DATA).unwrap_or_default(); - Self { inner: s } - } -} - -impl From for TypeOptionData { - fn from(data: RichTextTypeOption) -> Self { - TypeOptionDataBuilder::new() - .insert_str_value(CELL_DATA, data.inner) - .build() - } -} - impl TypeOptionTransform for RichTextTypeOption {} -impl TypeOptionCellDataSerde for RichTextTypeOption { +impl CellDataProtobufEncoder for RichTextTypeOption { fn protobuf_encode( &self, cell_data: ::CellData, ) -> ::CellProtobufType { ProtobufStr::from(cell_data.0) } - - fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - Ok(StringCellData::from(cell)) - } } impl CellDataDecoder for RichTextTypeOption { - fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - Ok(StringCellData::from(cell)) - } - fn decode_cell_with_transform( &self, cell: &Cell, @@ -79,22 +48,21 @@ impl CellDataDecoder for RichTextTypeOption { | FieldType::SingleSelect | FieldType::MultiSelect | FieldType::Checkbox - | FieldType::URL => Some(StringCellData::from(stringify_cell(cell, field))), + | FieldType::URL + | FieldType::Summary + | FieldType::Translate + | FieldType::Media + | FieldType::Time => Some(StringCellData::from(stringify_cell(cell, field))), FieldType::Checklist | FieldType::LastEditedTime | FieldType::CreatedTime | FieldType::Relation => None, - FieldType::Summary => Some(StringCellData::from(stringify_cell(cell, field))), } } fn stringify_cell_data(&self, cell_data: ::CellData) -> String { cell_data.to_string() } - - fn numeric_cell(&self, cell: &Cell) -> Option { - StringCellData::from(cell).0.parse::().ok() - } } impl CellDataChangeset for RichTextTypeOption { @@ -146,6 +114,11 @@ impl TypeOptionCellDataCompare for RichTextTypeOption { #[derive(Default, Debug, Clone)] pub struct StringCellData(pub String); +impl StringCellData { + pub fn into_inner(self) -> String { + self.0 + } +} impl std::ops::Deref for StringCellData { type Target = String; @@ -162,15 +135,15 @@ impl TypeOptionCellData for StringCellData { impl From<&Cell> for StringCellData { fn from(cell: &Cell) -> Self { - Self(cell.get_str_value(CELL_DATA).unwrap_or_default()) + Self(cell.get_as(CELL_DATA).unwrap_or_default()) } } impl From for Cell { fn from(data: StringCellData) -> Self { - new_cell_builder(FieldType::RichText) - .insert_str_value(CELL_DATA, data.0) - .build() + let mut cell = new_cell_builder(FieldType::RichText); + cell.insert(CELL_DATA.into(), data.0.into()); + cell } } @@ -186,8 +159,8 @@ impl std::convert::From for StringCellData { } } -impl ToString for StringCellData { - fn to_string(&self) -> String { +impl ToCellString for StringCellData { + fn to_cell_string(&self) -> String { self.0.clone() } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/mod.rs new file mode 100644 index 0000000000..7f700fc4c7 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/mod.rs @@ -0,0 +1,4 @@ +mod time; +mod time_filter; + +pub use time::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time.rs new file mode 100644 index 0000000000..218ac8daf4 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time.rs @@ -0,0 +1,83 @@ +use crate::entities::{TimeCellDataPB, TimeFilterPB}; +use crate::services::cell::{CellDataChangeset, CellDataDecoder}; +use crate::services::field::{ + CellDataProtobufEncoder, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, + TypeOptionTransform, +}; +use crate::services::sort::SortCondition; +use collab_database::fields::date_type_option::TimeTypeOption; + +use collab_database::rows::Cell; +use flowy_error::FlowyResult; + +use collab_database::template::time_parse::TimeCellData; +use std::cmp::Ordering; + +impl TypeOption for TimeTypeOption { + type CellData = TimeCellData; + type CellChangeset = TimeCellChangeset; + type CellProtobufType = TimeCellDataPB; + type CellFilter = TimeFilterPB; +} + +impl CellDataProtobufEncoder for TimeTypeOption { + fn protobuf_encode( + &self, + cell_data: ::CellData, + ) -> ::CellProtobufType { + if let Some(time) = cell_data.0 { + return TimeCellDataPB { time }; + } + TimeCellDataPB { + time: i64::default(), + } + } +} + +impl TypeOptionTransform for TimeTypeOption {} + +impl CellDataDecoder for TimeTypeOption { + fn stringify_cell_data(&self, cell_data: ::CellData) -> String { + if let Some(time) = cell_data.0 { + return time.to_string(); + } + "".to_string() + } +} + +pub type TimeCellChangeset = String; + +impl CellDataChangeset for TimeTypeOption { + fn apply_changeset( + &self, + changeset: ::CellChangeset, + _cell: Option, + ) -> FlowyResult<(Cell, ::CellData)> { + let str = changeset.trim().to_string(); + let cell_data = TimeCellData(str.parse::().ok()); + + Ok((Cell::from(&cell_data), cell_data)) + } +} + +impl TypeOptionCellDataFilter for TimeTypeOption { + fn apply_filter( + &self, + filter: &::CellFilter, + cell_data: &::CellData, + ) -> bool { + filter.is_visible(cell_data.0) + } +} + +impl TypeOptionCellDataCompare for TimeTypeOption { + fn apply_cmp( + &self, + cell_data: &::CellData, + other_cell_data: &::CellData, + sort_condition: SortCondition, + ) -> Ordering { + let order = cell_data.0.cmp(&other_cell_data.0); + sort_condition.evaluate_order(order) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_filter.rs new file mode 100644 index 0000000000..13455a4ad0 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_filter.rs @@ -0,0 +1,70 @@ +use collab_database::fields::Field; +use collab_database::rows::Cell; + +use crate::entities::{NumberFilterConditionPB, TimeFilterPB}; +use crate::services::cell::insert_text_cell; +use crate::services::filter::PreFillCellsWithFilter; + +impl TimeFilterPB { + pub fn is_visible(&self, cell_time: Option) -> bool { + if self.content.is_empty() { + match self.condition { + NumberFilterConditionPB::NumberIsEmpty => { + return cell_time.is_none(); + }, + NumberFilterConditionPB::NumberIsNotEmpty => { + return cell_time.is_some(); + }, + _ => {}, + } + } + + if cell_time.is_none() { + return false; + } + + let time = cell_time.unwrap(); + let content_time = self.content.parse::().unwrap_or_default(); + match self.condition { + NumberFilterConditionPB::Equal => time == content_time, + NumberFilterConditionPB::NotEqual => time != content_time, + NumberFilterConditionPB::GreaterThan => time > content_time, + NumberFilterConditionPB::LessThan => time < content_time, + NumberFilterConditionPB::GreaterThanOrEqualTo => time >= content_time, + NumberFilterConditionPB::LessThanOrEqualTo => time <= content_time, + _ => true, + } + } +} + +impl PreFillCellsWithFilter for TimeFilterPB { + fn get_compliant_cell(&self, field: &Field) -> Option { + let expected_decimal = || self.content.parse::().ok(); + + let text = match self.condition { + NumberFilterConditionPB::Equal + | NumberFilterConditionPB::GreaterThanOrEqualTo + | NumberFilterConditionPB::LessThanOrEqualTo + if !self.content.is_empty() => + { + Some(self.content.clone()) + }, + NumberFilterConditionPB::GreaterThan if !self.content.is_empty() => { + expected_decimal().map(|value| { + let answer = value + 1; + answer.to_string() + }) + }, + NumberFilterConditionPB::LessThan if !self.content.is_empty() => { + expected_decimal().map(|value| { + let answer = value - 1; + answer.to_string() + }) + }, + _ => None, + }; + + // use `insert_text_cell` because self.content might not be a parsable i64. + text.map(|s| insert_text_cell(s, field)) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/mod.rs index 3041a7947b..579d1b9ea6 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/mod.rs @@ -1,6 +1,2 @@ #![allow(clippy::module_inception)] mod timestamp_type_option; -mod timestamp_type_option_entities; - -pub use timestamp_type_option::*; -pub use timestamp_type_option_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs index 17b9f54dd3..528f4df01b 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs @@ -1,38 +1,15 @@ -use std::cmp::Ordering; - -use chrono::{DateTime, Local, Offset}; -use collab::core::any_map::AnyMapExtension; -use collab_database::fields::{TypeOptionData, TypeOptionDataBuilder}; -use collab_database::rows::Cell; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use serde::{Deserialize, Serialize}; - -use crate::entities::{DateFilterPB, FieldType, TimestampCellDataPB}; +use crate::entities::{DateFilterPB, TimestampCellDataPB}; use crate::services::cell::{CellDataChangeset, CellDataDecoder}; use crate::services::field::{ - default_order, DateFormat, TimeFormat, TimestampCellData, TypeOption, TypeOptionCellDataCompare, - TypeOptionCellDataFilter, TypeOptionCellDataSerde, TypeOptionTransform, + default_order, CellDataProtobufEncoder, TypeOption, TypeOptionCellDataCompare, + TypeOptionCellDataFilter, TypeOptionTransform, }; use crate::services::sort::SortCondition; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct TimestampTypeOption { - pub date_format: DateFormat, - pub time_format: TimeFormat, - pub include_time: bool, - pub field_type: FieldType, -} - -impl Default for TimestampTypeOption { - fn default() -> Self { - Self { - date_format: Default::default(), - time_format: Default::default(), - include_time: true, - field_type: FieldType::LastEditedTime, - } - } -} +use collab_database::fields::timestamp_type_option::TimestampTypeOption; +use collab_database::rows::Cell; +use collab_database::template::timestamp_parse::TimestampCellData; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use std::cmp::Ordering; impl TypeOption for TimestampTypeOption { type CellData = TimestampCellData; @@ -41,42 +18,7 @@ impl TypeOption for TimestampTypeOption { type CellFilter = DateFilterPB; } -impl From for TimestampTypeOption { - fn from(data: TypeOptionData) -> Self { - let date_format = data - .get_i64_value("date_format") - .map(DateFormat::from) - .unwrap_or_default(); - let time_format = data - .get_i64_value("time_format") - .map(TimeFormat::from) - .unwrap_or_default(); - let include_time = data.get_bool_value("include_time").unwrap_or_default(); - let field_type = data - .get_i64_value("field_type") - .map(FieldType::from) - .unwrap_or(FieldType::LastEditedTime); - Self { - date_format, - time_format, - include_time, - field_type, - } - } -} - -impl From for TypeOptionData { - fn from(option: TimestampTypeOption) -> Self { - TypeOptionDataBuilder::new() - .insert_i64_value("date_format", option.date_format.value()) - .insert_i64_value("time_format", option.time_format.value()) - .insert_bool_value("include_time", option.include_time) - .insert_i64_value("field_type", option.field_type.value()) - .build() - } -} - -impl TypeOptionCellDataSerde for TimestampTypeOption { +impl CellDataProtobufEncoder for TimestampTypeOption { fn protobuf_encode( &self, cell_data: ::CellData, @@ -89,45 +31,11 @@ impl TypeOptionCellDataSerde for TimestampTypeOption { timestamp, } } - - fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - Ok(TimestampCellData::from(cell)) - } -} - -impl TimestampTypeOption { - pub fn new(field_type: FieldType) -> Self { - Self { - field_type, - include_time: true, - ..Default::default() - } - } - - fn formatted_date_time_from_timestamp(&self, timestamp: &Option) -> (String, String) { - if let Some(timestamp) = timestamp { - let naive = chrono::NaiveDateTime::from_timestamp_opt(*timestamp, 0).unwrap(); - let offset = Local::now().offset().fix(); - let date_time = DateTime::::from_naive_utc_and_offset(naive, offset); - - let fmt = self.date_format.format_str(); - let date = format!("{}", date_time.format(fmt)); - let fmt = self.time_format.format_str(); - let time = format!("{}", date_time.format(fmt)); - (date, time) - } else { - ("".to_owned(), "".to_owned()) - } - } } impl TypeOptionTransform for TimestampTypeOption {} impl CellDataDecoder for TimestampTypeOption { - fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - self.parse_cell(cell) - } - fn stringify_cell_data(&self, cell_data: ::CellData) -> String { let timestamp = cell_data.timestamp; let (date_string, time_string) = self.formatted_date_time_from_timestamp(×tamp); @@ -137,10 +45,6 @@ impl CellDataDecoder for TimestampTypeOption { date_string } } - - fn numeric_cell(&self, _cell: &Cell) -> Option { - None - } } impl CellDataChangeset for TimestampTypeOption { @@ -159,10 +63,12 @@ impl CellDataChangeset for TimestampTypeOption { impl TypeOptionCellDataFilter for TimestampTypeOption { fn apply_filter( &self, - _filter: &::CellFilter, - _cell_data: &::CellData, + filter: &::CellFilter, + cell_data: &::CellData, ) -> bool { - true + filter + .is_timestamp_cell_data_visible(cell_data) + .unwrap_or(true) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option_entities.rs deleted file mode 100644 index 307b7637b8..0000000000 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option_entities.rs +++ /dev/null @@ -1,69 +0,0 @@ -use collab::core::any_map::AnyMapExtension; -use collab_database::rows::{new_cell_builder, Cell}; -use serde::Serialize; - -use crate::{ - entities::FieldType, - services::field::{TypeOptionCellData, CELL_DATA}, -}; - -#[derive(Clone, Debug, Default, Serialize)] -pub struct TimestampCellData { - pub timestamp: Option, -} - -impl TimestampCellData { - pub fn new(timestamp: i64) -> Self { - Self { - timestamp: Some(timestamp), - } - } -} - -impl From<&Cell> for TimestampCellData { - fn from(cell: &Cell) -> Self { - let timestamp = cell - .get_str_value(CELL_DATA) - .and_then(|data| data.parse::().ok()); - Self { timestamp } - } -} - -/// Wrapper for DateCellData that also contains the field type. -/// Handy struct to use when you need to convert a DateCellData to a Cell. -pub struct TimestampCellDataWrapper { - data: TimestampCellData, - field_type: FieldType, -} - -impl From<(FieldType, TimestampCellData)> for TimestampCellDataWrapper { - fn from((field_type, data): (FieldType, TimestampCellData)) -> Self { - Self { data, field_type } - } -} - -impl From for Cell { - fn from(wrapper: TimestampCellDataWrapper) -> Self { - let (field_type, data) = (wrapper.field_type, wrapper.data); - let timestamp_string = data.timestamp.unwrap_or_default(); - - new_cell_builder(field_type) - .insert_str_value(CELL_DATA, timestamp_string) - .build() - } -} - -impl From for Cell { - fn from(data: TimestampCellData) -> Self { - let data: TimestampCellDataWrapper = (FieldType::LastEditedTime, data).into(); - Cell::from(data) - } -} - -impl TypeOptionCellData for TimestampCellData {} - -impl ToString for TimestampCellData { - fn to_string(&self) -> String { - serde_json::to_string(self).unwrap() - } -} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/translate_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/translate_type_option/mod.rs new file mode 100644 index 0000000000..d6edf7c9e9 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/translate_type_option/mod.rs @@ -0,0 +1 @@ +pub mod translate; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/translate_type_option/translate.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/translate_type_option/translate.rs new file mode 100644 index 0000000000..ecf9a4de26 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/translate_type_option/translate.rs @@ -0,0 +1,76 @@ +use crate::entities::TextFilterPB; +use crate::services::cell::{CellDataChangeset, CellDataDecoder}; +use crate::services::field::type_options::util::ProtobufStr; +use crate::services::field::{ + CellDataProtobufEncoder, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, + TypeOptionTransform, +}; +use crate::services::sort::SortCondition; +use collab_database::fields::translate_type_option::TranslateTypeOption; +use collab_database::rows::Cell; +use collab_database::template::translate_parse::TranslateCellData; +use flowy_error::FlowyResult; +use std::cmp::Ordering; + +impl TypeOption for TranslateTypeOption { + type CellData = TranslateCellData; + type CellChangeset = String; + type CellProtobufType = ProtobufStr; + type CellFilter = TextFilterPB; +} + +impl CellDataChangeset for TranslateTypeOption { + fn apply_changeset( + &self, + changeset: String, + _cell: Option, + ) -> FlowyResult<(Cell, TranslateCellData)> { + let cell_data = TranslateCellData(changeset); + Ok((cell_data.clone().into(), cell_data)) + } +} + +impl TypeOptionCellDataFilter for TranslateTypeOption { + fn apply_filter( + &self, + filter: &::CellFilter, + cell_data: &::CellData, + ) -> bool { + filter.is_visible(cell_data) + } +} + +impl TypeOptionCellDataCompare for TranslateTypeOption { + fn apply_cmp( + &self, + cell_data: &::CellData, + other_cell_data: &::CellData, + sort_condition: SortCondition, + ) -> Ordering { + match (cell_data.is_empty(), other_cell_data.is_empty()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => { + let order = cell_data.0.cmp(&other_cell_data.0); + sort_condition.evaluate_order(order) + }, + } + } +} + +impl CellDataDecoder for TranslateTypeOption { + fn stringify_cell_data(&self, cell_data: TranslateCellData) -> String { + cell_data.to_string() + } +} +impl TypeOptionTransform for TranslateTypeOption {} + +impl CellDataProtobufEncoder for TranslateTypeOption { + fn protobuf_encode( + &self, + cell_data: ::CellData, + ) -> ::CellProtobufType { + ProtobufStr::from(cell_data.0) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs index e86c4fd7e8..86a187f3b6 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs @@ -1,29 +1,36 @@ +use crate::entities::{ + CheckboxTypeOptionPB, ChecklistTypeOptionPB, DateTypeOptionPB, FieldType, MediaTypeOptionPB, + MultiSelectTypeOptionPB, NumberTypeOptionPB, RelationTypeOptionPB, RichTextTypeOptionPB, + SingleSelectTypeOptionPB, SummarizationTypeOptionPB, TimeTypeOptionPB, TimestampTypeOptionPB, + TranslateTypeOptionPB, URLTypeOptionPB, +}; +use crate::services::cell::CellDataDecoder; +use crate::services::filter::{ParseFilterData, PreFillCellsWithFilter}; +use crate::services::sort::SortCondition; +use async_trait::async_trait; +use bytes::Bytes; +use collab_database::database::Database; +use collab_database::fields::checkbox_type_option::CheckboxTypeOption; +use collab_database::fields::checklist_type_option::ChecklistTypeOption; +use collab_database::fields::date_type_option::{DateTypeOption, TimeTypeOption}; +use collab_database::fields::media_type_option::MediaTypeOption; +use collab_database::fields::number_type_option::NumberTypeOption; +use collab_database::fields::relation_type_option::RelationTypeOption; +use collab_database::fields::select_type_option::{MultiSelectTypeOption, SingleSelectTypeOption}; +use collab_database::fields::summary_type_option::SummarizationTypeOption; +use collab_database::fields::text_type_option::RichTextTypeOption; +use collab_database::fields::timestamp_type_option::TimestampTypeOption; +use collab_database::fields::translate_type_option::TranslateTypeOption; +use collab_database::fields::url_type_option::URLTypeOption; +use collab_database::fields::{TypeOptionCellReader, TypeOptionData}; +use collab_database::rows::Cell; +use collab_database::template::util::ToCellString; +pub use collab_database::template::util::TypeOptionCellData; +use protobuf::ProtobufError; use std::cmp::Ordering; use std::fmt::Debug; -use bytes::Bytes; -use collab_database::fields::TypeOptionData; -use collab_database::rows::Cell; -use protobuf::ProtobufError; - -use flowy_error::FlowyResult; - -use crate::entities::{ - CheckboxTypeOptionPB, ChecklistTypeOptionPB, DateTypeOptionPB, FieldType, - MultiSelectTypeOptionPB, NumberTypeOptionPB, RelationTypeOptionPB, RichTextTypeOptionPB, - SingleSelectTypeOptionPB, SummarizationTypeOptionPB, TimestampTypeOptionPB, URLTypeOptionPB, -}; -use crate::services::cell::CellDataDecoder; -use crate::services::field::checklist_type_option::ChecklistTypeOption; -use crate::services::field::summary_type_option::summary::SummarizationTypeOption; -use crate::services::field::{ - CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RelationTypeOption, - RichTextTypeOption, SingleSelectTypeOption, TimestampTypeOption, URLTypeOption, -}; -use crate::services::filter::{ParseFilterData, PreFillCellsWithFilter}; -use crate::services::sort::SortCondition; - -pub trait TypeOption: From + Into { +pub trait TypeOption: From + Into + TypeOptionCellReader { /// `CellData` represents the decoded model for the current type option. Each of them must /// implement the From<&Cell> trait. If the `Cell` cannot be decoded into this type, the default /// value will be returned. @@ -36,7 +43,7 @@ pub trait TypeOption: From + Into { /// type CellData: for<'a> From<&'a Cell> + TypeOptionCellData - + ToString + + ToCellString + Default + Send + Sync @@ -65,7 +72,7 @@ pub trait TypeOption: From + Into { /// /// This trait ensures that a type which implements both `TypeOption` and `TypeOptionCellDataSerde` can /// be converted to and from a corresponding `Protobuf struct`, and can be parsed from an opaque [Cell] structure. -pub trait TypeOptionCellDataSerde: TypeOption { +pub trait CellDataProtobufEncoder: TypeOption { /// Encode the cell data into corresponding `Protobuf struct`. /// For example: /// FieldType::URL => URLCellDataPB @@ -74,23 +81,10 @@ pub trait TypeOptionCellDataSerde: TypeOption { &self, cell_data: ::CellData, ) -> ::CellProtobufType; - - /// Parse the opaque [Cell] to corresponding data struct. - /// The [Cell] is a map that stores list of key/value data. Each [TypeOption::CellData] - /// should implement the From<&Cell> trait to parse the [Cell] to corresponding data struct. - fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData>; } -/// This trait that provides methods to extend the [TypeOption::CellData] functionalities. -pub trait TypeOptionCellData { - /// Checks if the cell content is considered empty based on certain criteria. e.g. empty text, - /// no date selected, no selected options - fn is_cell_empty(&self) -> bool { - false - } -} - -pub trait TypeOptionTransform: TypeOption { +#[async_trait] +pub trait TypeOptionTransform: TypeOption + Send + Sync { /// Transform the TypeOption from one field type to another /// For example, when switching from `Checkbox` type option to `Single-Select` /// type option, adding the `Yes` option if the `Single-select` type-option doesn't contain it. @@ -102,10 +96,14 @@ pub trait TypeOptionTransform: TypeOption { /// * `old_type_option_field_type`: the FieldType of the passed-in TypeOption /// * `old_type_option_data`: the data that can be parsed into corresponding `TypeOption`. /// - fn transform_type_option( + async fn transform_type_option( &mut self, + _view_id: &str, + _field_id: &str, _old_type_option_field_type: FieldType, _old_type_option_data: TypeOptionData, + _new_type_option_field_type: FieldType, + _database: &mut Database, ) { } } @@ -185,6 +183,13 @@ pub fn type_option_data_from_pb>( FieldType::Summary => { SummarizationTypeOptionPB::try_from(bytes).map(|pb| SummarizationTypeOption::from(pb).into()) }, + FieldType::Time => TimeTypeOptionPB::try_from(bytes).map(|pb| TimeTypeOption::from(pb).into()), + FieldType::Translate => { + TranslateTypeOptionPB::try_from(bytes).map(|pb| TranslateTypeOption::from(pb).into()) + }, + FieldType::Media => { + MediaTypeOptionPB::try_from(bytes).map(|pb| MediaTypeOption::from(pb).into()) + }, } } @@ -214,13 +219,13 @@ pub fn type_option_to_pb(type_option: TypeOptionData, field_type: &FieldType) -> }, FieldType::SingleSelect => { let single_select_type_option: SingleSelectTypeOption = type_option.into(); - SingleSelectTypeOptionPB::from(single_select_type_option) + SingleSelectTypeOptionPB::from(single_select_type_option.0) .try_into() .unwrap() }, FieldType::MultiSelect => { let multi_select_type_option: MultiSelectTypeOption = type_option.into(); - MultiSelectTypeOptionPB::from(multi_select_type_option) + MultiSelectTypeOptionPB::from(multi_select_type_option.0) .try_into() .unwrap() }, @@ -252,25 +257,44 @@ pub fn type_option_to_pb(type_option: TypeOptionData, field_type: &FieldType) -> .try_into() .unwrap() }, + FieldType::Time => { + let time_type_option: TimeTypeOption = type_option.into(); + TimeTypeOptionPB::from(time_type_option).try_into().unwrap() + }, + FieldType::Translate => { + let translate_type_option: TranslateTypeOption = type_option.into(); + TranslateTypeOptionPB::from(translate_type_option) + .try_into() + .unwrap() + }, + FieldType::Media => { + let media_type_option: MediaTypeOption = type_option.into(); + MediaTypeOptionPB::from(media_type_option) + .try_into() + .unwrap() + }, } } pub fn default_type_option_data_from_type(field_type: FieldType) -> TypeOptionData { match field_type { - FieldType::RichText => RichTextTypeOption::default().into(), + FieldType::RichText => RichTextTypeOption.into(), FieldType::Number => NumberTypeOption::default().into(), FieldType::DateTime => DateTypeOption::default().into(), FieldType::LastEditedTime | FieldType::CreatedTime => TimestampTypeOption { - field_type, + field_type: field_type.into(), ..Default::default() } .into(), FieldType::SingleSelect => SingleSelectTypeOption::default().into(), FieldType::MultiSelect => MultiSelectTypeOption::default().into(), - FieldType::Checkbox => CheckboxTypeOption::default().into(), + FieldType::Checkbox => CheckboxTypeOption.into(), FieldType::URL => URLTypeOption::default().into(), FieldType::Checklist => ChecklistTypeOption.into(), FieldType::Relation => RelationTypeOption::default().into(), FieldType::Summary => SummarizationTypeOption::default().into(), + FieldType::Translate => TranslateTypeOption::default().into(), + FieldType::Time => TimeTypeOption.into(), + FieldType::Media => MediaTypeOption::default().into(), } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs index 7e145bffb7..27b2f05f9a 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs @@ -1,23 +1,31 @@ -use std::cmp::Ordering; -use std::collections::hash_map::DefaultHasher; -use std::hash::{Hash, Hasher}; - -use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{get_field_type_from_cell, Cell, RowId}; - -use flowy_error::FlowyResult; -use lib_infra::box_any::BoxAny; - use crate::entities::FieldType; use crate::services::cell::{CellCache, CellDataChangeset, CellDataDecoder, CellProtobufBlob}; -use crate::services::field::summary_type_option::summary::SummarizationTypeOption; use crate::services::field::{ - CheckboxTypeOption, ChecklistTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, - RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimestampTypeOption, TypeOption, - TypeOptionCellData, TypeOptionCellDataCompare, TypeOptionCellDataFilter, TypeOptionCellDataSerde, - TypeOptionTransform, URLTypeOption, + CellDataProtobufEncoder, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, + TypeOptionCellDataFilter, TypeOptionTransform, }; use crate::services::sort::SortCondition; +use collab::preclude::Any; +use collab_database::fields::checkbox_type_option::CheckboxTypeOption; +use collab_database::fields::checklist_type_option::ChecklistTypeOption; +use collab_database::fields::date_type_option::{DateTypeOption, TimeTypeOption}; +use collab_database::fields::media_type_option::MediaTypeOption; +use collab_database::fields::number_type_option::NumberTypeOption; +use collab_database::fields::relation_type_option::RelationTypeOption; +use collab_database::fields::select_type_option::{MultiSelectTypeOption, SingleSelectTypeOption}; +use collab_database::fields::summary_type_option::SummarizationTypeOption; +use collab_database::fields::text_type_option::RichTextTypeOption; +use collab_database::fields::timestamp_type_option::TimestampTypeOption; +use collab_database::fields::translate_type_option::TranslateTypeOption; +use collab_database::fields::url_type_option::URLTypeOption; +use collab_database::fields::Field; +use collab_database::rows::{get_field_type_from_cell, Cell, RowId}; +use flowy_error::FlowyResult; +use lib_infra::box_any::BoxAny; +use std::cmp::Ordering; +use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; pub const CELL_DATA: &str = "data"; @@ -87,23 +95,49 @@ pub trait TypeOptionCellDataHandler: Send + Sync + 'static { fn handle_numeric_cell(&self, cell: &Cell) -> Option; - fn handle_is_cell_empty(&self, cell: &Cell, field: &Field) -> bool; + fn handle_is_empty(&self, cell: &Cell, field: &Field) -> bool; } +#[derive(Debug)] struct CellDataCacheKey(u64); impl CellDataCacheKey { pub fn new(field_rev: &Field, decoded_field_type: FieldType, cell: &Cell) -> Self { let mut hasher = DefaultHasher::new(); if let Some(type_option_data) = field_rev.get_any_type_option(decoded_field_type) { - type_option_data.hash(&mut hasher); + map_hash(&type_option_data, &mut hasher); } hasher.write(field_rev.id.as_bytes()); hasher.write_u8(decoded_field_type as u8); - cell.hash(&mut hasher); + map_hash(cell, &mut hasher); Self(hasher.finish()) } } +fn any_hash(any: &Any, hasher: &mut H) { + //FIXME: this is very bad idea for hash calculation + match any { + Any::Null | Any::Undefined => hasher.write_u8(0), + Any::Bool(v) => v.hash(hasher), + Any::Number(v) => v.to_be_bytes().hash(hasher), + Any::BigInt(v) => v.hash(hasher), + Any::String(v) => v.hash(hasher), + Any::Buffer(v) => v.hash(hasher), + Any::Array(v) => { + for v in v.iter() { + any_hash(v, hasher); + } + }, + Any::Map(v) => map_hash(v, hasher), + } +} + +fn map_hash(map: &HashMap, hasher: &mut H) { + for (k, v) in map.iter() { + k.hash(hasher); + any_hash(v, hasher); + } +} + impl AsRef for CellDataCacheKey { fn as_ref(&self) -> &u64 { &self.0 @@ -121,7 +155,7 @@ where T: TypeOption + CellDataDecoder + CellDataChangeset - + TypeOptionCellDataSerde + + CellDataProtobufEncoder + TypeOptionTransform + TypeOptionCellDataFilter + TypeOptionCellDataCompare @@ -157,23 +191,22 @@ where fn get_cell_data_from_cache(&self, cell: &Cell, field: &Field) -> Option { let key = self.get_cell_data_cache_key(cell, field); - - let cell_data_cache = self.cell_data_cache.as_ref()?.read(); - - cell_data_cache.get(key.as_ref()).cloned() + let cell_data_cache = self.cell_data_cache.as_ref()?; + let cell = cell_data_cache.get::(key.as_ref())?; + Some(cell.value().clone()) } fn set_cell_data_in_cache(&self, cell: &Cell, cell_data: T::CellData, field: &Field) { if let Some(cell_data_cache) = self.cell_data_cache.as_ref() { let field_type = FieldType::from(field.field_type); let key = CellDataCacheKey::new(field, field_type, cell); - tracing::trace!( - "Cell cache update: field_type:{}, cell: {:?}, cell_data: {:?}", - field_type, - cell, - cell_data - ); - cell_data_cache.write().insert(key.as_ref(), cell_data); + // tracing::trace!( + // "Cell cache update: field_type:{}, cell: {:?}, cell_data: {:?}", + // field_type, + // cell, + // cell_data + // ); + cell_data_cache.insert(key.as_ref(), cell_data); } } @@ -218,7 +251,7 @@ where T: TypeOption + CellDataDecoder + CellDataChangeset - + TypeOptionCellDataSerde + + CellDataProtobufEncoder + TypeOptionTransform + TypeOptionCellDataFilter + TypeOptionCellDataCompare @@ -237,7 +270,6 @@ where field_rev: &Field, ) -> FlowyResult { let cell_data = self.get_cell_data(cell, field_rev).unwrap_or_default(); - CellProtobufBlob::from(self.protobuf_encode(cell_data)) } @@ -312,7 +344,7 @@ where self.numeric_cell(cell) } - fn handle_is_cell_empty(&self, cell: &Cell, field: &Field) -> bool { + fn handle_is_empty(&self, cell: &Cell, field: &Field) -> bool { let cell_data = self.get_cell_data(cell, field).unwrap_or_default(); cell_data.is_cell_empty() @@ -449,6 +481,36 @@ impl<'a> TypeOptionCellExt<'a> { self.cell_data_cache.clone(), ) }), + FieldType::Time => self + .field + .get_type_option::(field_type) + .map(|type_option| { + TypeOptionCellDataHandlerImpl::new_with_boxed( + type_option, + field_type, + self.cell_data_cache.clone(), + ) + }), + FieldType::Translate => self + .field + .get_type_option::(field_type) + .map(|type_option| { + TypeOptionCellDataHandlerImpl::new_with_boxed( + type_option, + field_type, + self.cell_data_cache.clone(), + ) + }), + FieldType::Media => self + .field + .get_type_option::(field_type) + .map(|type_option| { + TypeOptionCellDataHandlerImpl::new_with_boxed( + type_option, + field_type, + self.cell_data_cache.clone(), + ) + }), } } @@ -458,103 +520,29 @@ impl<'a> TypeOptionCellExt<'a> { } } +/// when return true, the to_field_type must implement [CellDataDecoder]'s decode_cell_with_transform pub fn is_type_option_cell_transformable( from_field_type: FieldType, to_field_type: FieldType, ) -> bool { matches!( (from_field_type, to_field_type), + // Checkbox (FieldType::Checkbox, FieldType::SingleSelect) | (FieldType::Checkbox, FieldType::MultiSelect) + // SingleSelect or MultiSelect | (FieldType::SingleSelect, FieldType::MultiSelect) | (FieldType::MultiSelect, FieldType::SingleSelect) + // Text + | (FieldType::RichText, FieldType::SingleSelect) + | (FieldType::RichText, FieldType::MultiSelect) + | (FieldType::RichText, FieldType::URL) + | (FieldType::RichText, FieldType::Number) + | (FieldType::RichText, FieldType::DateTime) | (_, FieldType::RichText) ) } -pub fn transform_type_option( - old_field_type: FieldType, - new_field_type: FieldType, - old_type_option_data: Option, - new_type_option_data: TypeOptionData, -) -> TypeOptionData { - if let Some(old_type_option_data) = old_type_option_data { - let mut transform_handler = - get_type_option_transform_handler(new_type_option_data, new_field_type); - transform_handler.transform(old_field_type, old_type_option_data); - transform_handler.to_type_option_data() - } else { - new_type_option_data - } -} - -/// A helper trait that used to erase the `Self` of `TypeOption` trait to make it become a Object-safe trait. -pub trait TypeOptionTransformHandler { - fn transform( - &mut self, - old_type_option_field_type: FieldType, - old_type_option_data: TypeOptionData, - ); - - fn to_type_option_data(&self) -> TypeOptionData; -} - -impl TypeOptionTransformHandler for T -where - T: TypeOptionTransform + Clone, -{ - fn transform( - &mut self, - old_type_option_field_type: FieldType, - old_type_option_data: TypeOptionData, - ) { - self.transform_type_option(old_type_option_field_type, old_type_option_data) - } - - fn to_type_option_data(&self) -> TypeOptionData { - self.clone().into() - } -} - -fn get_type_option_transform_handler( - type_option_data: TypeOptionData, - field_type: FieldType, -) -> Box { - match field_type { - FieldType::RichText => { - Box::new(RichTextTypeOption::from(type_option_data)) as Box - }, - FieldType::Number => { - Box::new(NumberTypeOption::from(type_option_data)) as Box - }, - FieldType::DateTime => { - Box::new(DateTypeOption::from(type_option_data)) as Box - }, - FieldType::LastEditedTime | FieldType::CreatedTime => { - Box::new(TimestampTypeOption::from(type_option_data)) as Box - }, - FieldType::SingleSelect => Box::new(SingleSelectTypeOption::from(type_option_data)) - as Box, - FieldType::MultiSelect => { - Box::new(MultiSelectTypeOption::from(type_option_data)) as Box - }, - FieldType::Checkbox => { - Box::new(CheckboxTypeOption::from(type_option_data)) as Box - }, - FieldType::URL => { - Box::new(URLTypeOption::from(type_option_data)) as Box - }, - FieldType::Checklist => { - Box::new(ChecklistTypeOption::from(type_option_data)) as Box - }, - FieldType::Relation => { - Box::new(RelationTypeOption::from(type_option_data)) as Box - }, - FieldType::Summary => Box::new(SummarizationTypeOption::from(type_option_data)) - as Box, - } -} - pub type BoxCellData = BoxAny; pub struct RowSingleCellData { diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_tests.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_tests.rs index 7f32aa4c18..9c54ed2f0d 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_tests.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_tests.rs @@ -5,7 +5,7 @@ mod tests { use crate::entities::FieldType; use crate::services::cell::CellDataChangeset; use crate::services::field::FieldBuilder; - use crate::services::field::URLTypeOption; + use collab_database::fields::url_type_option::URLTypeOption; #[test] fn url_test() { diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs index 3a95c6bae0..907837c0fc 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs @@ -1,24 +1,19 @@ -use std::cmp::Ordering; - -use collab::core::any_map::AnyMapExtension; -use collab_database::fields::{TypeOptionData, TypeOptionDataBuilder}; -use collab_database::rows::Cell; -use flowy_error::FlowyResult; -use serde::{Deserialize, Serialize}; - -use crate::entities::{TextFilterPB, URLCellDataPB}; +use crate::entities::{FieldType, TextFilterPB, URLCellDataPB}; use crate::services::cell::{CellDataChangeset, CellDataDecoder}; use crate::services::field::{ - TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, TypeOptionCellDataSerde, - TypeOptionTransform, URLCellData, + CellDataProtobufEncoder, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, + TypeOptionTransform, }; use crate::services::sort::SortCondition; +use async_trait::async_trait; +use collab_database::database::Database; +use collab_database::fields::url_type_option::{URLCellData, URLTypeOption}; +use collab_database::fields::{Field, TypeOptionData}; +use collab_database::rows::Cell; +use flowy_error::FlowyResult; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct URLTypeOption { - pub url: String, - pub content: String, -} +use std::cmp::Ordering; +use tracing::trace; impl TypeOption for URLTypeOption { type CellData = URLCellData; @@ -27,50 +22,72 @@ impl TypeOption for URLTypeOption { type CellFilter = TextFilterPB; } -impl From for URLTypeOption { - fn from(data: TypeOptionData) -> Self { - let url = data.get_str_value("url").unwrap_or_default(); - let content = data.get_str_value("content").unwrap_or_default(); - Self { url, content } +#[async_trait] +impl TypeOptionTransform for URLTypeOption { + async fn transform_type_option( + &mut self, + view_id: &str, + field_id: &str, + old_type_option_field_type: FieldType, + _old_type_option_data: TypeOptionData, + _new_type_option_field_type: FieldType, + database: &mut Database, + ) { + match old_type_option_field_type { + FieldType::RichText => { + let rows = database + .get_cells_for_field(view_id, field_id) + .await + .into_iter() + .filter_map(|row| row.cell.map(|cell| (row.row_id, cell))) + .collect::>(); + + trace!( + "Transforming RichText to URLTypeOption, updating {} row's cell content", + rows.len() + ); + for (row_id, cell_data) in rows { + database + .update_row(row_id, |row| { + row.update_cells(|cell| { + cell.insert(field_id, Self::CellData::from(&cell_data)); + }); + }) + .await; + } + }, + _ => { + // Do nothing + }, + } } } -impl From for TypeOptionData { - fn from(data: URLTypeOption) -> Self { - TypeOptionDataBuilder::new() - .insert_str_value("url", data.url) - .insert_str_value("content", data.content) - .build() - } -} - -impl TypeOptionTransform for URLTypeOption {} - -impl TypeOptionCellDataSerde for URLTypeOption { +impl CellDataProtobufEncoder for URLTypeOption { fn protobuf_encode( &self, cell_data: ::CellData, ) -> ::CellProtobufType { cell_data.into() } - - fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - Ok(URLCellData::from(cell)) - } } impl CellDataDecoder for URLTypeOption { - fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { - self.parse_cell(cell) + fn decode_cell_with_transform( + &self, + cell: &Cell, + from_field_type: FieldType, + _field: &Field, + ) -> Option<::CellData> { + match from_field_type { + FieldType::RichText => Some(Self::CellData::from(cell)), + _ => None, + } } fn stringify_cell_data(&self, cell_data: ::CellData) -> String { cell_data.data } - - fn numeric_cell(&self, _cell: &Cell) -> Option { - None - } } pub type URLCellChangeset = String; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs index 2b286e0604..b9d1a07823 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs @@ -1,51 +1,11 @@ use bytes::Bytes; -use collab::core::any_map::AnyMapExtension; -use collab_database::rows::{new_cell_builder, Cell}; -use serde::{Deserialize, Serialize}; + +use collab_database::fields::url_type_option::URLCellData; use flowy_error::{internal_error, FlowyResult}; -use crate::entities::{FieldType, URLCellDataPB}; +use crate::entities::URLCellDataPB; use crate::services::cell::CellProtobufBlobParser; -use crate::services::field::{TypeOptionCellData, CELL_DATA}; - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct URLCellData { - pub data: String, -} - -impl URLCellData { - pub fn new(s: &str) -> Self { - Self { - data: s.to_string(), - } - } - - pub fn to_json(&self) -> FlowyResult { - serde_json::to_string(self).map_err(internal_error) - } -} - -impl TypeOptionCellData for URLCellData { - fn is_cell_empty(&self) -> bool { - self.data.is_empty() - } -} - -impl From<&Cell> for URLCellData { - fn from(cell: &Cell) -> Self { - let data = cell.get_str_value(CELL_DATA).unwrap_or_default(); - Self { data } - } -} - -impl From for Cell { - fn from(data: URLCellData) -> Self { - new_cell_builder(FieldType::URL) - .insert_str_value(CELL_DATA, data.data) - .build() - } -} impl From for URLCellDataPB { fn from(data: URLCellData) -> Self { @@ -59,12 +19,6 @@ impl From for URLCellData { } } -impl AsRef for URLCellData { - fn as_ref(&self) -> &str { - &self.data - } -} - pub struct URLCellDataParser(); impl CellProtobufBlobParser for URLCellDataParser { type Object = URLCellDataPB; @@ -73,9 +27,3 @@ impl CellProtobufBlobParser for URLCellDataParser { URLCellDataPB::try_from(bytes.as_ref()).map_err(internal_error) } } - -impl ToString for URLCellData { - fn to_string(&self) -> String { - self.to_json().unwrap() - } -} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/util.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/util.rs index 6bf03b127a..ec06f84e61 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/util.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/util.rs @@ -1,5 +1,6 @@ use bytes::Bytes; use protobuf::ProtobufError; +use std::fmt::Display; #[derive(Default, Debug, Clone)] pub struct ProtobufStr(pub String); @@ -23,9 +24,9 @@ impl std::convert::From for ProtobufStr { } } -impl ToString for ProtobufStr { - fn to_string(&self) -> String { - self.0.clone() +impl Display for ProtobufStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.clone()) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field_settings/entities.rs b/frontend/rust-lib/flowy-database2/src/services/field_settings/entities.rs index 9f9e82311f..65d58441bc 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field_settings/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field_settings/entities.rs @@ -1,4 +1,5 @@ -use collab::core::any_map::AnyMapExtension; +use collab::preclude::Any; +use collab::util::AnyMapExt; use collab_database::views::{DatabaseLayout, FieldSettingsMap, FieldSettingsMapBuilder}; use crate::entities::FieldVisibility; @@ -25,16 +26,11 @@ impl FieldSettings { field_settings: &FieldSettingsMap, ) -> Self { let visibility = field_settings - .get_i64_value(VISIBILITY) + .get_as::(VISIBILITY) .map(Into::into) .unwrap_or_else(|| default_field_visibility(layout_type)); - let width = field_settings - .get_i64_value(WIDTH) - .map(|value| value as i32) - .unwrap_or(DEFAULT_WIDTH); - let wrap_cell_content = field_settings - .get_bool_value(WRAP_CELL_CONTENT) - .unwrap_or(true); + let width = field_settings.get_as::(WIDTH).unwrap_or(DEFAULT_WIDTH); + let wrap_cell_content: bool = field_settings.get_as(WRAP_CELL_CONTENT).unwrap_or(true); Self { field_id: field_id.to_string(), @@ -47,10 +43,16 @@ impl FieldSettings { impl From for FieldSettingsMap { fn from(field_settings: FieldSettings) -> Self { - FieldSettingsMapBuilder::new() - .insert_i64_value(VISIBILITY, field_settings.visibility.into()) - .insert_i64_value(WIDTH, field_settings.width as i64) - .insert_bool_value(WRAP_CELL_CONTENT, field_settings.wrap_cell_content) - .build() + FieldSettingsMapBuilder::from([ + ( + VISIBILITY.into(), + Any::BigInt(i64::from(field_settings.visibility)), + ), + (WIDTH.into(), Any::BigInt(field_settings.width as i64)), + ( + WRAP_CELL_CONTENT.into(), + Any::Bool(field_settings.wrap_cell_content), + ), + ]) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field_settings/field_settings_builder.rs b/frontend/rust-lib/flowy-database2/src/services/field_settings/field_settings_builder.rs index 7602224acd..95d70184c2 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field_settings/field_settings_builder.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field_settings/field_settings_builder.rs @@ -1,9 +1,9 @@ -use std::collections::HashMap; - +use collab::preclude::Any; use collab_database::fields::Field; use collab_database::views::{ DatabaseLayout, FieldSettingsByFieldIdMap, FieldSettingsMap, FieldSettingsMapBuilder, }; +use std::collections::HashMap; use strum::IntoEnumIterator; use crate::entities::FieldVisibility; @@ -86,9 +86,8 @@ pub fn default_field_settings_by_layout_map() -> HashMap Option; - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>; - fn get_rows(&self, view_id: &str) -> Fut>>; - fn get_row(&self, view_id: &str, rows_id: &RowId) -> Fut)>>; - fn get_all_filters(&self, view_id: &str) -> Vec; - fn save_filters(&self, view_id: &str, filters: &[Filter]); + async fn get_field(&self, field_id: &str) -> Option; + async fn get_fields(&self, view_id: &str, field_ids: Option>) -> Vec; + async fn get_rows(&self, view_id: &str) -> Vec>; + async fn get_row(&self, view_id: &str, rows_id: &RowId) -> Option<(usize, Arc)>; + async fn get_all_filters(&self, view_id: &str) -> Vec; + async fn save_filters(&self, view_id: &str, filters: &[Filter]); } pub trait PreFillCellsWithFilter { - fn get_compliant_cell(&self, field: &Field) -> (Option, bool); + fn get_compliant_cell(&self, field: &Field) -> Option; } pub struct FilterController { @@ -40,7 +45,7 @@ pub struct FilterController { result_by_row_id: DashMap, cell_cache: CellCache, filters: RwLock>, - task_scheduler: Arc>, + task_scheduler: Arc>, notifier: DatabaseViewChangedNotifier, } @@ -55,7 +60,7 @@ impl FilterController { view_id: &str, handler_id: &str, delegate: T, - task_scheduler: Arc>, + task_scheduler: Arc>, cell_cache: CellCache, notifier: DatabaseViewChangedNotifier, ) -> Self @@ -72,15 +77,14 @@ impl FilterController { let mut need_save = false; - let mut filters = delegate.get_all_filters(view_id); + let mut filters = delegate.get_all_filters(view_id).await; + trace!("[Database]: filters: {:?}", filters); let mut filtering_field_ids: HashMap> = HashMap::new(); - for filter in filters.iter() { filter.get_all_filtering_field_ids(&mut filtering_field_ids); } let mut delete_filter_ids = vec![]; - for (field_id, filter_ids) in &filtering_field_ids { if !field_ids.contains(field_id) { need_save = true; @@ -93,7 +97,7 @@ impl FilterController { } if need_save { - delegate.save_filters(view_id, &filters); + delegate.save_filters(view_id, &filters).await; } Self { @@ -108,12 +112,17 @@ impl FilterController { } } + pub async fn has_filters(&self) -> bool { + !self.filters.read().await.is_empty() + } + pub async fn close(&self) { - if let Ok(mut task_scheduler) = self.task_scheduler.try_write() { - task_scheduler.unregister_handler(&self.handler_id).await; - } else { - tracing::error!("Try to get the lock of task_scheduler failed"); - } + self + .task_scheduler + .write() + .await + .unregister_handler(&self.handler_id) + .await; } #[tracing::instrument(name = "schedule_filter_task", level = "trace", skip(self))] @@ -122,38 +131,12 @@ impl FilterController { let task = Task::new( &self.handler_id, task_id, - TaskContent::Text(task_type.to_string()), + TaskContent::Text(task_type.to_json_string()), qos, ); self.task_scheduler.write().await.add_task(task); } - pub async fn filter_rows(&self, rows: &mut Vec>) { - let filters = self.filters.read().await; - - if filters.is_empty() { - return; - } - let field_by_field_id = self.get_field_map().await; - rows.iter().for_each(|row_detail| { - let _ = filter_row( - &row_detail.row, - &self.result_by_row_id, - &field_by_field_id, - &self.cell_cache, - &filters, - ); - }); - - rows.retain(|row_detail| { - self - .result_by_row_id - .get(&row_detail.row.id) - .map(|result| *result) - .unwrap_or(false) - }); - } - pub async fn did_receive_row_changed(&self, row_id: RowId) { if !self.filters.read().await.is_empty() { self @@ -184,8 +167,9 @@ impl FilterController { .iter_mut() .find_map(|filter| filter.find_filter(&parent_filter_id)) { - // TODO(RS): error handling for inserting filters - let _result = parent_filter.insert_filter(new_filter); + if let Err(err) = parent_filter.insert_filter(new_filter) { + error!("error while inserting filter: {}", err); + } } }, None => { @@ -213,13 +197,12 @@ impl FilterController { .find_map(|filter| filter.find_filter(&filter_id)) { // TODO(RS): error handling for updating filter data - let _result = filter.update_filter_data(data); + if let Err(error) = filter.update_filter_data(data) { + error!("error while updating filter data: {}", error); + } } }, - FilterChangeset::Delete { - filter_id, - field_id: _, - } => Self::delete_filter(&mut filters, &filter_id), + FilterChangeset::Delete { filter_id } => Self::delete_filter(&mut filters, &filter_id), FilterChangeset::DeleteAllWithFieldId { field_id } => { let mut filter_ids = vec![]; for filter in filters.iter() { @@ -231,7 +214,7 @@ impl FilterController { }, } - self.delegate.save_filters(&self.view_id, &filters); + self.delegate.save_filters(&self.view_id, &filters).await; self .gen_task(FilterEvent::FilterDidChanged, QualityOfService::Background) @@ -240,11 +223,9 @@ impl FilterController { FilterChangesetNotificationPB::from_filters(&self.view_id, &filters) } - pub async fn fill_cells(&self, cells: &mut Cells) -> bool { + pub async fn fill_cells(&self, cells: &mut Cells) { let filters = self.filters.read().await; - let mut open_after_create = false; - let mut min_required_filters: Vec<&FilterInner> = vec![]; for filter in filters.iter() { filter.get_min_effective_filters(&mut min_required_filters); @@ -265,12 +246,11 @@ impl FilterController { min_required_filters.retain( |inner| matches!(inner, FilterInner::Data { field_id: other_id, .. } if other_id != field_id), ); - open_after_create = true; continue; } if let Some(field) = field_map.get(field_id) { - let (cell, flag) = match field_type { + let cell = match field_type { FieldType::RichText | FieldType::URL => { let filter = condition_and_content.cloned::().unwrap(); filter.get_compliant_cell(field) @@ -303,21 +283,19 @@ impl FilterController { let filter = condition_and_content.cloned::().unwrap(); filter.get_compliant_cell(field) }, - _ => (None, false), + FieldType::Time => { + let filter = condition_and_content.cloned::().unwrap(); + filter.get_compliant_cell(field) + }, + _ => None, }; if let Some(cell) = cell { cells.insert(field_id.clone(), cell); } - - if flag { - open_after_create = flag; - } } } } - - open_after_create } #[tracing::instrument( @@ -330,7 +308,10 @@ impl FilterController { pub async fn process(&self, predicate: &str) -> FlowyResult<()> { let event_type = FilterEvent::from_str(predicate).unwrap(); match event_type { - FilterEvent::FilterDidChanged => self.filter_all_rows_handler().await?, + FilterEvent::FilterDidChanged => { + let mut rows = self.delegate.get_rows(&self.view_id).await; + self.filter_rows_and_notify(&mut rows).await? + }, FilterEvent::RowDidChanged(row_id) => self.filter_single_row_handler(row_id).await?, } Ok(()) @@ -342,22 +323,21 @@ impl FilterController { if let Some((_, row_detail)) = self.delegate.get_row(&self.view_id, &row_id).await { let field_by_field_id = self.get_field_map().await; let mut notification = FilterResultNotification::new(self.view_id.clone()); - if let Some(is_visible) = filter_row( + if filter_row( &row_detail.row, &self.result_by_row_id, &field_by_field_id, &self.cell_cache, &filters, ) { - if is_visible { - if let Some((index, _row)) = self.delegate.get_row(&self.view_id, &row_id).await { - notification.visible_rows.push( - InsertedRowPB::new(RowMetaPB::from(row_detail.as_ref())).with_index(index as i32), - ) - } - } else { - notification.invisible_rows.push(row_id); + if let Some((index, _row)) = self.delegate.get_row(&self.view_id, &row_id).await { + notification.visible_rows.push( + InsertedRowPB::new(RowMetaPB::from(row_detail.as_ref().clone())) + .with_index(index as i32), + ) } + } else { + notification.invisible_rows.push(row_id); } let _ = self @@ -367,42 +347,35 @@ impl FilterController { Ok(()) } - async fn filter_all_rows_handler(&self) -> FlowyResult<()> { + pub async fn filter_rows_and_notify(&self, rows: &mut Vec>) -> FlowyResult<()> { let filters = self.filters.read().await; - let field_by_field_id = self.get_field_map().await; - let mut visible_rows = vec![]; - let mut invisible_rows = vec![]; - - for (index, row_detail) in self - .delegate - .get_rows(&self.view_id) - .await - .into_iter() - .enumerate() - { - if let Some(is_visible) = filter_row( - &row_detail.row, - &self.result_by_row_id, - &field_by_field_id, - &self.cell_cache, - &filters, - ) { - if is_visible { - let row_meta = RowMetaPB::from(row_detail.as_ref()); - visible_rows.push(InsertedRowPB::new(row_meta).with_index(index as i32)) + let (visible_rows, invisible_rows): (Vec<_>, Vec<_>) = + rows.par_iter().enumerate().partition_map(|(index, row)| { + if filter_row( + row, + &self.result_by_row_id, + &field_by_field_id, + &self.cell_cache, + &filters, + ) { + let row_meta = RowMetaPB::from(row.as_ref()); + // Visible rows go into the left partition + rayon::iter::Either::Left(InsertedRowPB::new(row_meta).with_index(index as i32)) } else { - invisible_rows.push(row_detail.row.id.clone()); + // Invisible rows (just IDs) go into the right partition + rayon::iter::Either::Right(row.id.clone()) } - } - } + }); + let len = rows.len(); + rows.retain(|row| !invisible_rows.iter().any(|id| id == &row.id)); + trace!("[Database]: filter out {} invisible rows", len - rows.len()); let notification = FilterResultNotification { view_id: self.view_id.clone(), invisible_rows, visible_rows, }; - tracing::trace!("filter result {:?}", filters); let _ = self .notifier .send(DatabaseViewChanged::FilterNotification(notification)); @@ -410,6 +383,31 @@ impl FilterController { Ok(()) } + pub async fn filter_rows(&self, mut rows: Vec>) -> Vec> { + let filters = self.filters.read().await; + let field_by_field_id = self.get_field_map().await; + rows.par_iter().for_each(|row| { + let _ = filter_row( + row, + &self.result_by_row_id, + &field_by_field_id, + &self.cell_cache, + &filters, + ); + }); + + let len = rows.len(); + rows.retain(|row| { + self + .result_by_row_id + .get(&row.id) + .map(|result| *result) + .unwrap_or(true) + }); + trace!("[Database]: filter out {} invisible rows", len - rows.len()); + rows + } + async fn get_field_map(&self) -> HashMap { self .delegate @@ -454,17 +452,14 @@ fn filter_row( field_by_field_id: &HashMap, cell_data_cache: &CellCache, filters: &Vec, -) -> Option { +) -> bool { // Create a filter result cache if it doesn't exist let mut filter_result = result_by_row_id.entry(row.id.clone()).or_insert(true); - let old_is_visible = *filter_result; - let mut new_is_visible = true; for filter in filters { if let Some(is_visible) = apply_filter(row, field_by_field_id, cell_data_cache, filter) { new_is_visible = new_is_visible && is_visible; - // short-circuit as soon as one filter tree returns false if !new_is_visible { break; @@ -473,12 +468,7 @@ fn filter_row( } *filter_result = new_is_visible; - - if old_is_visible != new_is_visible { - Some(new_is_visible) - } else { - None - } + new_is_visible } /// Recursively applies a `Filter` to a `Row`'s cells. @@ -524,10 +514,22 @@ fn apply_filter( }, }; if *field_type != FieldType::from(field.field_type) { - tracing::error!("field type of filter doesn't match field type of field"); + error!("field type of filter doesn't match field type of field"); return Some(false); } - let cell = row.cells.get(field_id).cloned(); + let timestamp_cell = match field_type { + FieldType::LastEditedTime | FieldType::CreatedTime => { + let timestamp = if field_type.is_created_time() { + row.created_at + } else { + row.modified_at + }; + let cell = TimestampCellData::new(Some(timestamp)).to_cell(field.field_type); + Some(cell) + }, + _ => None, + }; + let cell = timestamp_cell.or_else(|| row.cells.get(field_id).cloned()); if let Some(handler) = TypeOptionCellExt::new(field, Some(cell_data_cache.clone())) .get_type_option_cell_data_handler() { @@ -545,8 +547,8 @@ enum FilterEvent { RowDidChanged(RowId), } -impl ToString for FilterEvent { - fn to_string(&self) -> String { +impl FilterEvent { + fn to_json_string(&self) -> String { serde_json::to_string(self).unwrap() } } diff --git a/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs b/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs index 6a974cc3d5..52e368596c 100644 --- a/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs @@ -1,19 +1,24 @@ use std::collections::HashMap; use std::mem; +use std::ops::Deref; use anyhow::bail; -use collab::core::any_map::AnyMapExtension; +use collab::preclude::Any; +use collab::util::AnyMapExt; use collab_database::database::gen_database_filter_id; +use collab_database::fields::select_type_option::SelectOptionIds; use collab_database::rows::RowId; +use collab_database::template::util::ToCellString; use collab_database::views::{FilterMap, FilterMapBuilder}; use flowy_error::{FlowyError, FlowyResult}; use lib_infra::box_any::BoxAny; +use tracing::error; use crate::entities::{ CheckboxFilterPB, ChecklistFilterPB, DateFilterContent, DateFilterPB, FieldType, FilterType, - InsertedRowPB, NumberFilterPB, RelationFilterPB, SelectOptionFilterPB, TextFilterPB, + InsertedRowPB, MediaFilterPB, NumberFilterPB, RelationFilterPB, SelectOptionFilterPB, + TextFilterPB, TimeFilterPB, }; -use crate::services::field::SelectOptionIds; pub trait ParseFilterData { fn parse(condition: u8, content: String) -> Self; @@ -208,7 +213,7 @@ impl Filter { /// /// 1. a Data filter, then it should be included. /// 2. an AND filter, then all of its effective children should be - /// included. + /// included. /// 3. an OR filter, then only the first child should be included. pub fn get_min_effective_filters<'a>(&'a self, min_effective_filters: &mut Vec<&'a FilterInner>) { match &self.inner { @@ -271,16 +276,27 @@ impl FilterInner { BoxAny::new(TextFilterPB::parse(condition as u8, content)) }, FieldType::Number => BoxAny::new(NumberFilterPB::parse(condition as u8, content)), - FieldType::DateTime | FieldType::CreatedTime | FieldType::LastEditedTime => { - BoxAny::new(DateFilterPB::parse(condition as u8, content)) + FieldType::DateTime => BoxAny::new(DateFilterPB::parse(condition as u8, content)), + FieldType::CreatedTime | FieldType::LastEditedTime => { + let filter = DateFilterPB::parse(condition as u8, content).remove_end_date_conditions(); + BoxAny::new(filter) }, - FieldType::SingleSelect | FieldType::MultiSelect => { - BoxAny::new(SelectOptionFilterPB::parse(condition as u8, content)) + FieldType::SingleSelect => { + let filter = + SelectOptionFilterPB::parse(condition as u8, content).to_single_select_filter(); + BoxAny::new(filter) + }, + FieldType::MultiSelect => { + let filter = SelectOptionFilterPB::parse(condition as u8, content).to_multi_select_filter(); + BoxAny::new(filter) }, FieldType::Checklist => BoxAny::new(ChecklistFilterPB::parse(condition as u8, content)), FieldType::Checkbox => BoxAny::new(CheckboxFilterPB::parse(condition as u8, content)), FieldType::Relation => BoxAny::new(RelationFilterPB::parse(condition as u8, content)), FieldType::Summary => BoxAny::new(TextFilterPB::parse(condition as u8, content)), + FieldType::Translate => BoxAny::new(TextFilterPB::parse(condition as u8, content)), + FieldType::Time => BoxAny::new(TimeFilterPB::parse(condition as u8, content)), + FieldType::Media => BoxAny::new(MediaFilterPB::parse(condition as u8, content)), }; FilterInner::Data { @@ -313,13 +329,20 @@ const FILTER_DATA_INDEX: i64 = 2; impl<'a> From<&'a Filter> for FilterMap { fn from(filter: &'a Filter) -> Self { - let mut builder = FilterMapBuilder::new() - .insert_str_value(FILTER_ID, &filter.id) - .insert_i64_value(FILTER_TYPE, filter.inner.get_int_repr()); + let mut builder = FilterMapBuilder::from([ + (FILTER_ID.into(), filter.id.as_str().into()), + (FILTER_TYPE.into(), Any::BigInt(filter.inner.get_int_repr())), + ]); builder = match &filter.inner { FilterInner::And { children } | FilterInner::Or { children } => { - builder.insert_maps(FILTER_CHILDREN, children.iter().collect::>()) + let mut vec = Vec::with_capacity(children.len()); + for child in children.iter() { + let any: Any = FilterMap::from(child).into(); + vec.push(any); + } + builder.insert(FILTER_CHILDREN.into(), Any::from(vec)); + builder }, FilterInner::Data { field_id, @@ -343,12 +366,12 @@ impl<'a> From<&'a Filter> for FilterMap { end: filter.end, timestamp: filter.timestamp, } - .to_string(); + .to_json_string(); (filter.condition as u8, content) }, FieldType::SingleSelect | FieldType::MultiSelect => { let filter = condition_and_content.cloned::()?; - let content = SelectOptionIds::from(filter.option_ids).to_string(); + let content = SelectOptionIds::from(filter.option_ids).to_cell_string(); (filter.condition as u8, content) }, FieldType::Checkbox => { @@ -367,6 +390,18 @@ impl<'a> From<&'a Filter> for FilterMap { let filter = condition_and_content.cloned::()?; (filter.condition as u8, filter.content) }, + FieldType::Time => { + let filter = condition_and_content.cloned::()?; + (filter.condition as u8, filter.content) + }, + FieldType::Translate => { + let filter = condition_and_content.cloned::()?; + (filter.condition as u8, filter.content) + }, + FieldType::Media => { + let filter = condition_and_content.cloned::()?; + (filter.condition as u8, filter.content) + }, }; Some((condition, content)) }; @@ -376,15 +411,15 @@ impl<'a> From<&'a Filter> for FilterMap { Default::default() }); + builder.insert(FIELD_ID.into(), field_id.as_str().into()); + builder.insert(FIELD_TYPE.into(), Any::BigInt(i64::from(field_type))); + builder.insert(FILTER_CONDITION.into(), Any::BigInt(condition as i64)); + builder.insert(FILTER_CONTENT.into(), content.into()); builder - .insert_str_value(FIELD_ID, field_id) - .insert_i64_value(FIELD_TYPE, field_type.into()) - .insert_i64_value(FILTER_CONDITION, condition as i64) - .insert_str_value(FILTER_CONTENT, content) }, }; - builder.build() + builder } } @@ -392,32 +427,30 @@ impl TryFrom for Filter { type Error = anyhow::Error; fn try_from(filter_map: FilterMap) -> Result { - let filter_id = filter_map - .get_str_value(FILTER_ID) + let filter_id: String = filter_map + .get_as(FILTER_ID) .ok_or_else(|| anyhow::anyhow!("invalid filter data"))?; - let filter_type = filter_map - .get_i64_value(FILTER_TYPE) - .unwrap_or(FILTER_DATA_INDEX); + let filter_type: i64 = filter_map.get_as(FILTER_TYPE).unwrap_or(FILTER_DATA_INDEX); let filter = Filter { id: filter_id, inner: match filter_type { FILTER_AND_INDEX => FilterInner::And { - children: filter_map.try_get_array(FILTER_CHILDREN), + children: get_children(filter_map), }, FILTER_OR_INDEX => FilterInner::Or { - children: filter_map.try_get_array(FILTER_CHILDREN), + children: get_children(filter_map), }, FILTER_DATA_INDEX => { - let field_id = filter_map - .get_str_value(FIELD_ID) + let field_id: String = filter_map + .get_as(FIELD_ID) .ok_or_else(|| anyhow::anyhow!("invalid filter data"))?; let field_type = filter_map - .get_i64_value(FIELD_TYPE) + .get_as::(FIELD_TYPE) .map(FieldType::from) .unwrap_or_default(); - let condition = filter_map.get_i64_value(FILTER_CONDITION).unwrap_or(0); - let content = filter_map.get_str_value(FILTER_CONTENT).unwrap_or_default(); + let condition: i64 = filter_map.get_as(FILTER_CONDITION).unwrap_or_default(); + let content: String = filter_map.get_as(FILTER_CONTENT).unwrap_or_default(); FilterInner::new_data(field_id, field_type, condition, content) }, @@ -429,6 +462,27 @@ impl TryFrom for Filter { } } +fn get_children(filter_map: FilterMap) -> Vec { + //TODO: this method wouldn't be necessary if we could make Filters serializable in backward + // compatible way + let mut result = Vec::new(); + if let Some(Any::Array(children)) = filter_map.get(FILTER_CHILDREN) { + for child in children.iter() { + if let Any::Map(child_map) = child { + match Filter::try_from(child_map.deref().clone()) { + Ok(filter) => { + result.push(filter); + }, + Err(err) => { + error!("Failed to deserialize filter: {:?}", err); + }, + } + } + } + } + result +} + #[derive(Debug)] pub enum FilterChangeset { Insert { @@ -445,7 +499,6 @@ pub enum FilterChangeset { }, Delete { filter_id: String, - field_id: String, }, DeleteAllWithFieldId { field_id: String, diff --git a/frontend/rust-lib/flowy-database2/src/services/filter/task.rs b/frontend/rust-lib/flowy-database2/src/services/filter/task.rs index 03ed453f89..e6c2ae905a 100644 --- a/frontend/rust-lib/flowy-database2/src/services/filter/task.rs +++ b/frontend/rust-lib/flowy-database2/src/services/filter/task.rs @@ -1,5 +1,6 @@ use crate::services::filter::FilterController; -use lib_infra::future::BoxResultFuture; +use async_trait::async_trait; + use lib_infra::priority_task::{TaskContent, TaskHandler}; use std::sync::Arc; @@ -17,6 +18,7 @@ impl FilterTaskHandler { } } +#[async_trait] impl TaskHandler for FilterTaskHandler { fn handler_id(&self) -> &str { &self.handler_id @@ -26,16 +28,14 @@ impl TaskHandler for FilterTaskHandler { "FilterTaskHandler" } - fn run(&self, content: TaskContent) -> BoxResultFuture<(), anyhow::Error> { + async fn run(&self, content: TaskContent) -> Result<(), anyhow::Error> { let filter_controller = self.filter_controller.clone(); - Box::pin(async move { - if let TaskContent::Text(predicate) = content { - filter_controller - .process(&predicate) - .await - .map_err(anyhow::Error::from)?; - } - Ok(()) - }) + if let TaskContent::Text(predicate) = content { + filter_controller + .process(&predicate) + .await + .map_err(anyhow::Error::from)?; + } + Ok(()) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/group/action.rs b/frontend/rust-lib/flowy-database2/src/services/group/action.rs index b540fb5fa3..7b876e0ddb 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/action.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/action.rs @@ -1,16 +1,17 @@ +use async_trait::async_trait; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{Cell, Cells, Row, RowDetail, RowId}; +use collab_database::rows::{Cell, Cells, Row, RowId}; use flowy_error::FlowyResult; -use crate::entities::{GroupChangesPB, GroupPB, GroupRowsNotificationPB, InsertedGroupPB}; +use crate::entities::{GroupPB, GroupRowsNotificationPB, InsertedGroupPB}; use crate::services::field::TypeOption; use crate::services::group::{GroupChangeset, GroupData, MoveGroupRowContext}; /// [GroupCustomize] is implemented by parameterized `BaseGroupController`s to provide different /// behaviors. This allows the BaseGroupController to call these actions indescriminantly using /// polymorphism. -/// +#[async_trait] pub trait GroupCustomize: Send + Sync { type GroupTypeOption: TypeOption; /// Returns the a value of the cell if the cell data is not exist. @@ -32,7 +33,7 @@ pub trait GroupCustomize: Send + Sync { fn create_or_delete_group_when_cell_changed( &mut self, - _row_detail: &RowDetail, + _row: &Row, _old_cell_data: Option<&::CellProtobufType>, _cell_data: &::CellProtobufType, ) -> FlowyResult<(Option, Option)> { @@ -44,7 +45,7 @@ pub trait GroupCustomize: Send + Sync { /// fn add_or_remove_row_when_cell_changed( &mut self, - row_detail: &RowDetail, + row: &Row, cell_data: &::CellProtobufType, ) -> Vec; @@ -59,7 +60,7 @@ pub trait GroupCustomize: Send + Sync { fn move_row(&mut self, context: MoveGroupRowContext) -> Vec; /// Returns None if there is no need to delete the group when corresponding row get removed - fn delete_group_when_move_row( + fn delete_group_after_moving_row( &mut self, _row: &Row, _cell_data: &::CellProtobufType, @@ -67,14 +68,14 @@ pub trait GroupCustomize: Send + Sync { None } - fn create_group( + async fn create_group( &mut self, _name: String, ) -> FlowyResult<(Option, Option)> { Ok((None, None)) } - fn delete_group(&mut self, group_id: &str) -> FlowyResult>; + async fn delete_group(&mut self, group_id: &str) -> FlowyResult>; fn update_type_option_when_update_group( &mut self, @@ -95,8 +96,9 @@ pub trait GroupCustomize: Send + Sync { /// or a `DefaultGroupController` may be the actual object that provides the functionality of /// this trait. For example, a `Single-Select` group controller will be a `BaseGroupController`, /// while a `URL` group controller will be a `DefaultGroupController`. -/// +#[async_trait] pub trait GroupController: Send + Sync { + async fn load_group_data(&mut self) -> FlowyResult<()>; /// Returns the id of field that is being used to group the rows fn get_grouping_field_id(&self) -> &str; @@ -112,14 +114,14 @@ pub trait GroupController: Send + Sync { /// /// * `rows`: rows to be inserted /// * `field`: reference to the field being sorted (currently unused) - fn fill_groups(&mut self, rows: &[&RowDetail], field: &Field) -> FlowyResult<()>; + fn fill_groups(&mut self, rows: &[&Row], field: &Field) -> FlowyResult<()>; /// Create a new group, currently only supports single and multi-select. /// /// Returns a new type option data for the grouping field if it's altered. /// /// * `name`: name of the new group - fn create_group( + async fn create_group( &mut self, name: String, ) -> FlowyResult<(Option, Option)>; @@ -136,11 +138,7 @@ pub trait GroupController: Send + Sync { /// Returns a changeset payload to be sent as a notification. /// /// * `row_detail`: the newly-created row - fn did_create_row( - &mut self, - row_detail: &RowDetail, - index: usize, - ) -> Vec; + fn did_create_row(&mut self, row: &Row, index: usize) -> Vec; /// Called after a row's cell data is changed, this moves the row to the /// correct group. It may also insert a new group and/or remove an old group. @@ -152,8 +150,8 @@ pub trait GroupController: Send + Sync { /// * `field`: fn did_update_group_row( &mut self, - old_row_detail: &Option, - row_detail: &RowDetail, + old_row: &Option, + new_row: &Row, field: &Field, ) -> FlowyResult; @@ -168,18 +166,16 @@ pub trait GroupController: Send + Sync { /// * `context`: information about the row being moved and its destination fn move_group_row(&mut self, context: MoveGroupRowContext) -> FlowyResult; - /// Updates the groups after a field change. (currently never does anything) - /// - /// * `field`: new changeset - fn did_update_group_field(&mut self, field: &Field) -> FlowyResult>; - /// Delete a group from the group configuration. /// /// Return a list of deleted row ids and/or a new `TypeOptionData` if /// successful. /// /// * `group_id`: the id of the group to be deleted - fn delete_group(&mut self, group_id: &str) -> FlowyResult<(Vec, Option)>; + async fn delete_group( + &mut self, + group_id: &str, + ) -> FlowyResult<(Vec, Option)>; /// Updates the name and/or visibility of groups. /// @@ -187,7 +183,7 @@ pub trait GroupController: Send + Sync { /// in the field type option data. /// /// * `changesets`: list of changesets to be made to one or more groups - fn apply_group_changeset( + async fn apply_group_changeset( &mut self, changesets: &[GroupChangeset], ) -> FlowyResult<(Vec, Option)>; diff --git a/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs b/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs index 980fee21b2..95c1048b60 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use std::fmt::Formatter; use std::marker::PhantomData; use std::sync::Arc; @@ -9,8 +10,6 @@ use serde::Serialize; use tracing::event; use flowy_error::{FlowyError, FlowyResult}; -use lib_dispatch::prelude::af_spawn; -use lib_infra::future::Fut; use crate::entities::{GroupChangesPB, GroupPB, InsertedGroupPB}; use crate::services::field::RowSingleCellData; @@ -18,12 +17,14 @@ use crate::services::group::{ default_group_setting, GeneratedGroups, Group, GroupChangeset, GroupData, GroupSetting, }; +#[async_trait] pub trait GroupContextDelegate: Send + Sync + 'static { - fn get_group_setting(&self, view_id: &str) -> Fut>>; + async fn get_group_setting(&self, view_id: &str) -> Option>; - fn get_configuration_cells(&self, view_id: &str, field_id: &str) -> Fut>; + async fn get_configuration_cells(&self, view_id: &str, field_id: &str) -> Vec; - fn save_configuration(&self, view_id: &str, group_setting: GroupSetting) -> Fut>; + async fn save_configuration(&self, view_id: &str, group_setting: GroupSetting) + -> FlowyResult<()>; } impl std::fmt::Display for GroupControllerContext { @@ -350,7 +351,7 @@ where /// # Arguments /// /// * `mut_configuration_fn`: mutate the [GroupSetting] and return whether the [GroupSetting] is - /// changed. If the [GroupSetting] is changed, the [GroupSetting] will be saved to the storage. + /// changed. If the [GroupSetting] is changed, the [GroupSetting] will be saved to the storage. /// fn mut_configuration( &mut self, @@ -362,7 +363,7 @@ where let configuration = (*self.setting).clone(); let delegate = self.delegate.clone(); let view_id = self.view_id.clone(); - af_spawn(async move { + tokio::spawn(async move { match delegate.save_configuration(&view_id, configuration).await { Ok(_) => {}, Err(e) => { diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller.rs index a918e7f7c2..eed12e492a 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller.rs @@ -1,18 +1,16 @@ +use async_trait::async_trait; use std::marker::PhantomData; use std::sync::Arc; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{Cells, Row, RowDetail, RowId}; -use futures::executor::block_on; -use lib_infra::future::Fut; +use collab_database::rows::{Cells, Row, RowId}; +use flowy_error::{FlowyError, FlowyResult}; use serde::de::DeserializeOwned; use serde::Serialize; - -use flowy_error::{FlowyError, FlowyResult}; +use tracing::trace; use crate::entities::{ - FieldType, GroupChangesPB, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, - RowMetaPB, + FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, RowMetaPB, }; use crate::services::cell::{get_cell_protobuf, CellProtobufBlobParser}; use crate::services::field::{default_type_option_data_from_type, TypeOption, TypeOptionCellData}; @@ -23,10 +21,11 @@ use crate::services::group::configuration::GroupControllerContext; use crate::services::group::entities::GroupData; use crate::services::group::{GroupChangeset, GroupsBuilder, MoveGroupRowContext}; +#[async_trait] pub trait GroupControllerDelegate: Send + Sync + 'static { - fn get_field(&self, field_id: &str) -> Option; + async fn get_field(&self, field_id: &str) -> Option; - fn get_all_rows(&self, view_id: &str) -> Fut>>; + async fn get_all_rows(&self, view_id: &str) -> Vec>; } /// [BaseGroupController] is a generic group controller that provides customized implementations @@ -54,37 +53,29 @@ where { pub async fn new( grouping_field: &Field, - mut configuration: GroupControllerContext, + context: GroupControllerContext, delegate: Arc, ) -> FlowyResult { - let field_type = FieldType::from(grouping_field.field_type); - let type_option = grouping_field - .get_type_option::(&field_type) - .unwrap_or_else(|| T::from(default_type_option_data_from_type(field_type))); - - // TODO(nathan): remove block_on - let generated_groups = block_on(G::build(grouping_field, &configuration, &type_option)); - let _ = configuration.init_groups(generated_groups)?; - Ok(Self { grouping_field_id: grouping_field.id.clone(), - context: configuration, + context, group_builder_phantom: PhantomData, cell_parser_phantom: PhantomData, delegate, }) } - pub fn get_grouping_field_type_option(&self) -> Option { + pub async fn get_grouping_field_type_option(&self) -> Option { self .delegate .get_field(&self.grouping_field_id) + .await .and_then(|field| field.get_type_option::(FieldType::from(field.field_type))) } fn update_no_status_group( &mut self, - row_detail: &RowDetail, + row: &Row, other_group_changesets: &[GroupRowsNotificationPB], ) -> Option { let no_status_group = self.context.get_mut_no_status_group()?; @@ -113,8 +104,8 @@ where if !no_status_group_rows.is_empty() { changeset .inserted_rows - .push(InsertedRowPB::new(RowMetaPB::from(row_detail))); - no_status_group.add_row(row_detail.clone()); + .push(InsertedRowPB::new(RowMetaPB::from(row))); + no_status_group.add_row(row.clone()); } // [other_group_delete_rows] contains all the deleted rows except the default group. @@ -137,8 +128,8 @@ where .collect::>(); let mut deleted_row_ids = vec![]; - for row_detail in &no_status_group.rows { - let row_id = row_detail.row.id.to_string(); + for row in &no_status_group.rows { + let row_id = row.id.to_string(); if default_group_deleted_rows .iter() .any(|deleted_row| deleted_row.row_meta.id == row_id) @@ -148,12 +139,13 @@ where } no_status_group .rows - .retain(|row_detail| !deleted_row_ids.contains(&row_detail.row.id)); + .retain(|row| !deleted_row_ids.contains(&row.id)); changeset.deleted_rows.extend(deleted_row_ids); Some(changeset) } } +#[async_trait] impl GroupController for BaseGroupController where P: CellProtobufBlobParser::CellProtobufType>, @@ -162,6 +154,30 @@ where G: GroupsBuilder, GroupTypeOption = T>, Self: GroupCustomize, { + async fn load_group_data(&mut self) -> FlowyResult<()> { + let grouping_field = self + .delegate + .get_field(&self.grouping_field_id) + .await + .ok_or_else(|| FlowyError::internal().with_context("Failed to get grouping field"))?; + + let field_type = FieldType::from(grouping_field.field_type); + let type_option = grouping_field + .get_type_option::(&field_type) + .unwrap_or_else(|| T::from(default_type_option_data_from_type(field_type))); + + let generated_groups = G::build(&grouping_field, &self.context, &type_option).await; + let _ = self.context.init_groups(generated_groups)?; + + let row_details = self.delegate.get_all_rows(&self.context.view_id).await; + let rows = row_details + .iter() + .map(|row| row.as_ref()) + .collect::>(); + self.fill_groups(rows.as_slice(), &grouping_field)?; + Ok(()) + } + fn get_grouping_field_id(&self) -> &str { &self.grouping_field_id } @@ -176,9 +192,9 @@ where } #[tracing::instrument(level = "trace", skip_all, fields(row_count=%rows.len(), group_result))] - fn fill_groups(&mut self, rows: &[&RowDetail], _field: &Field) -> FlowyResult<()> { - for row_detail in rows { - let cell = match row_detail.row.cells.get(&self.grouping_field_id) { + fn fill_groups(&mut self, rows: &[&Row], _field: &Field) -> FlowyResult<()> { + for row in rows { + let cell = match row.cells.get(&self.grouping_field_id) { None => self.placeholder_cell(), Some(cell) => Some(cell.clone()), }; @@ -189,7 +205,7 @@ where for group in self.context.groups() { if self.can_group(&group.id, &cell_data) { grouped_rows.push(GroupedRow { - row_detail: (*row_detail).clone(), + row: (*row).clone(), group_id: group.id.clone(), }); } @@ -198,7 +214,7 @@ where if !grouped_rows.is_empty() { for group_row in grouped_rows { if let Some(group) = self.context.get_mut_group(&group_row.group_id) { - group.add_row(group_row.row_detail); + group.add_row(group_row.row); } } continue; @@ -207,7 +223,7 @@ where match self.context.get_mut_no_status_group() { None => {}, - Some(no_status_group) => no_status_group.add_row((*row_detail).clone()), + Some(no_status_group) => no_status_group.add_row((*row).clone()), } } @@ -215,43 +231,38 @@ where Ok(()) } - fn create_group( + async fn create_group( &mut self, name: String, ) -> FlowyResult<(Option, Option)> { - ::create_group(self, name) + ::create_group(self, name).await } fn move_group(&mut self, from_group_id: &str, to_group_id: &str) -> FlowyResult<()> { self.context.move_group(from_group_id, to_group_id) } - fn did_create_row( - &mut self, - row_detail: &RowDetail, - index: usize, - ) -> Vec { + fn did_create_row(&mut self, row: &Row, index: usize) -> Vec { let mut changesets: Vec = vec![]; - let cell = match row_detail.row.cells.get(&self.grouping_field_id) { + let cell = match row.cells.get(&self.grouping_field_id) { None => self.placeholder_cell(), Some(cell) => Some(cell.clone()), }; if let Some(cell) = cell { let cell_data = ::CellData::from(&cell); - let mut suitable_group_ids = vec![]; - for group in self.get_all_groups() { if self.can_group(&group.id, &cell_data) { suitable_group_ids.push(group.id.clone()); let changeset = GroupRowsNotificationPB::insert( group.id.clone(), vec![InsertedRowPB { - row_meta: (*row_detail).clone().into(), + row_meta: row.into(), index: Some(index as i32), is_new: true, + is_hidden_in_view: false, }], ); changesets.push(changeset); @@ -260,17 +271,18 @@ where if !suitable_group_ids.is_empty() { for group_id in suitable_group_ids.iter() { if let Some(group) = self.context.get_mut_group(group_id) { - group.add_row((*row_detail).clone()); + group.add_row((*row).clone()); } } } else if let Some(no_status_group) = self.context.get_mut_no_status_group() { - no_status_group.add_row((*row_detail).clone()); + no_status_group.add_row((*row).clone()); let changeset = GroupRowsNotificationPB::insert( no_status_group.id.clone(), vec![InsertedRowPB { - row_meta: (*row_detail).clone().into(), + row_meta: row.into(), index: Some(index as i32), is_new: true, + is_hidden_in_view: false, }], ); changesets.push(changeset); @@ -282,8 +294,8 @@ where fn did_update_group_row( &mut self, - old_row_detail: &Option, - row_detail: &RowDetail, + old_row: &Option, + new_row: &Row, field: &Field, ) -> FlowyResult { let mut result = DidUpdateGroupRowResult { @@ -291,20 +303,17 @@ where deleted_group: None, row_changesets: vec![], }; - if let Some(cell_data) = get_cell_data_from_row::

(Some(&row_detail.row), field) { - let old_cell_data = - get_cell_data_from_row::

(old_row_detail.as_ref().map(|detail| &detail.row), field); - if let Ok((insert, delete)) = self.create_or_delete_group_when_cell_changed( - row_detail, - old_cell_data.as_ref(), - &cell_data, - ) { + if let Some(cell_data) = get_cell_data_from_row::

(Some(new_row), field) { + let old_cell_data = get_cell_data_from_row::

(old_row.as_ref(), field); + if let Ok((insert, delete)) = + self.create_or_delete_group_when_cell_changed(new_row, old_cell_data.as_ref(), &cell_data) + { result.inserted_group = insert; result.deleted_group = delete; } - let mut changesets = self.add_or_remove_row_when_cell_changed(row_detail, &cell_data); - if let Some(changeset) = self.update_no_status_group(row_detail, &changesets) { + let mut changesets = self.add_or_remove_row_when_cell_changed(new_row, &cell_data); + if let Some(changeset) = self.update_no_status_group(new_row, &changesets) { if !changeset.is_empty() { changesets.push(changeset); } @@ -316,11 +325,13 @@ where } fn did_delete_row(&mut self, row: &Row) -> FlowyResult { + trace!("[RowOrder]: group did_delete_row: {:?}", row.id); let mut result = DidMoveGroupRowResult { deleted_group: None, row_changesets: vec![], }; - // early return if the row is not in the default group + + // remove row from its group if it is in a group if let Some(cell) = row.cells.get(&self.grouping_field_id) { let cell_data = ::CellData::from(cell); if !cell_data.is_cell_empty() { @@ -329,6 +340,7 @@ where } } + // when the deleted row is not in current group. It should be in the no status group match self.context.get_mut_no_status_group() { None => { tracing::error!("Unexpected None value. It should have the no status group"); @@ -353,7 +365,7 @@ where deleted_group: None, row_changesets: vec![], }; - let cell = match context.row_detail.row.cells.get(&self.grouping_field_id) { + let cell = match context.row.cells.get(&self.grouping_field_id) { Some(cell) => Some(cell.clone()), None => self.placeholder_cell(), }; @@ -361,7 +373,7 @@ where if let Some(cell) = cell { let cell_bytes = get_cell_protobuf(&cell, context.field, None); let cell_data = cell_bytes.parser::

()?; - result.deleted_group = self.delete_group_when_move_row(&context.row_detail.row, &cell_data); + result.deleted_group = self.delete_group_after_moving_row(context.row, &cell_data); result.row_changesets = self.move_row(context); } else { tracing::warn!("Unexpected moving group row, changes should not be empty"); @@ -369,11 +381,10 @@ where Ok(result) } - fn did_update_group_field(&mut self, _field: &Field) -> FlowyResult> { - Ok(None) - } - - fn delete_group(&mut self, group_id: &str) -> FlowyResult<(Vec, Option)> { + async fn delete_group( + &mut self, + group_id: &str, + ) -> FlowyResult<(Vec, Option)> { let group = if group_id != self.get_grouping_field_id() { self.get_group(group_id) } else { @@ -382,19 +393,15 @@ where match group { Some((_index, group_data)) => { - let row_ids = group_data - .rows - .iter() - .map(|row| row.row.id.clone()) - .collect(); - let type_option_data = ::delete_group(self, group_id)?; + let row_ids = group_data.rows.iter().map(|row| row.id.clone()).collect(); + let type_option_data = ::delete_group(self, group_id).await?; Ok((row_ids, type_option_data)) }, None => Ok((vec![], None)), } } - fn apply_group_changeset( + async fn apply_group_changeset( &mut self, changeset: &[GroupChangeset], ) -> FlowyResult<(Vec, Option)> { @@ -404,7 +411,7 @@ where } // update group name - let type_option = self.get_grouping_field_type_option().ok_or_else(|| { + let type_option = self.get_grouping_field_type_option().await.ok_or_else(|| { FlowyError::internal().with_context("Failed to get grouping field type option") })?; @@ -440,7 +447,7 @@ where } struct GroupedRow { - row_detail: RowDetail, + row: Row, group_id: String, } diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/checkbox_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/checkbox_controller.rs index a3057b24a0..b33d402dca 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/checkbox_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/checkbox_controller.rs @@ -1,14 +1,13 @@ use async_trait::async_trait; +use collab_database::fields::checkbox_type_option::CheckboxTypeOption; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail}; +use collab_database::rows::{new_cell_builder, Cell, Cells, Row}; use flowy_error::FlowyResult; use serde::{Deserialize, Serialize}; use crate::entities::{FieldType, GroupPB, GroupRowsNotificationPB, InsertedRowPB, RowMetaPB}; use crate::services::cell::insert_checkbox_cell; -use crate::services::field::{ - CheckboxCellDataParser, CheckboxTypeOption, TypeOption, CHECK, UNCHECK, -}; +use crate::services::field::{CheckboxCellDataParser, TypeOption, CHECK, UNCHECK}; use crate::services::group::action::GroupCustomize; use crate::services::group::configuration::GroupControllerContext; use crate::services::group::controller::BaseGroupController; @@ -25,14 +24,14 @@ pub type CheckboxGroupController = BaseGroupController; pub type CheckboxGroupControllerContext = GroupControllerContext; + +#[async_trait] impl GroupCustomize for CheckboxGroupController { type GroupTypeOption = CheckboxTypeOption; fn placeholder_cell(&self) -> Option { - Some( - new_cell_builder(FieldType::Checkbox) - .insert_str_value("data", UNCHECK) - .build(), - ) + let mut cell = new_cell_builder(FieldType::Checkbox); + cell.insert("data".into(), UNCHECK.into()); + Some(cell) } fn can_group( @@ -49,27 +48,25 @@ impl GroupCustomize for CheckboxGroupController { fn add_or_remove_row_when_cell_changed( &mut self, - row_detail: &RowDetail, + row: &Row, cell_data: &::CellProtobufType, ) -> Vec { let mut changesets = vec![]; self.context.iter_mut_status_groups(|group| { let mut changeset = GroupRowsNotificationPB::new(group.id.clone()); - let is_not_contained = !group.contains_row(&row_detail.row.id); + let is_not_contained = !group.contains_row(&row.id); if group.id == CHECK { if !cell_data.is_checked { // Remove the row if the group.id is CHECK but the cell_data is UNCHECK - changeset - .deleted_rows - .push(row_detail.row.id.clone().into_inner()); - group.remove_row(&row_detail.row.id); + changeset.deleted_rows.push(row.id.clone().into_inner()); + group.remove_row(&row.id); } else { // Add the row to the group if the group didn't contain the row if is_not_contained { changeset .inserted_rows - .push(InsertedRowPB::new(RowMetaPB::from(row_detail))); - group.add_row(row_detail.clone()); + .push(InsertedRowPB::new(RowMetaPB::from(row))); + group.add_row(row.clone()); } } } @@ -77,17 +74,15 @@ impl GroupCustomize for CheckboxGroupController { if group.id == UNCHECK { if cell_data.is_checked { // Remove the row if the group.id is UNCHECK but the cell_data is CHECK - changeset - .deleted_rows - .push(row_detail.row.id.clone().into_inner()); - group.remove_row(&row_detail.row.id); + changeset.deleted_rows.push(row.id.clone().into_inner()); + group.remove_row(&row.id); } else { // Add the row to the group if the group didn't contain the row if is_not_contained { changeset .inserted_rows - .push(InsertedRowPB::new(RowMetaPB::from(row_detail))); - group.add_row(row_detail.clone()); + .push(InsertedRowPB::new(RowMetaPB::from(row))); + group.add_row(row.clone()); } } } @@ -129,7 +124,7 @@ impl GroupCustomize for CheckboxGroupController { group_changeset } - fn delete_group(&mut self, _group_id: &str) -> FlowyResult> { + async fn delete_group(&mut self, _group_id: &str) -> FlowyResult> { Ok(None) } diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs index 8a2827a107..f3d03c6856 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs @@ -1,18 +1,19 @@ use async_trait::async_trait; -use chrono::{DateTime, Datelike, Days, Duration, Local, NaiveDateTime}; +use chrono::{DateTime, Datelike, Days, Duration, Local}; use collab_database::database::timestamp; +use collab_database::fields::date_type_option::{DateCellData, DateTypeOption}; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail}; +use collab_database::rows::{new_cell_builder, Cell, Cells, Row}; +use flowy_error::{internal_error, FlowyResult}; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; -use flowy_error::FlowyResult; - use crate::entities::{ FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, RowMetaPB, }; use crate::services::cell::insert_date_cell; -use crate::services::field::{DateCellData, DateCellDataParser, DateTypeOption, TypeOption}; +use crate::services::field::date_filter::DateCellDataParser; +use crate::services::field::TypeOption; use crate::services::group::action::GroupCustomize; use crate::services::group::configuration::GroupControllerContext; use crate::services::group::controller::BaseGroupController; @@ -27,13 +28,13 @@ pub struct DateGroupConfiguration { } impl DateGroupConfiguration { - fn from_json(s: &str) -> Result { + pub fn from_json(s: &str) -> Result { serde_json::from_str(s) } #[allow(dead_code)] - fn to_json(&self) -> Result { - serde_json::to_string(self) + pub fn to_json(&self) -> FlowyResult { + serde_json::to_string(self).map_err(internal_error) } } @@ -53,15 +54,14 @@ pub type DateGroupController = pub type DateGroupControllerContext = GroupControllerContext; +#[async_trait] impl GroupCustomize for DateGroupController { type GroupTypeOption = DateTypeOption; fn placeholder_cell(&self) -> Option { - Some( - new_cell_builder(FieldType::DateTime) - .insert_str_value("data", "") - .build(), - ) + let mut cell = new_cell_builder(FieldType::DateTime); + cell.insert("data".into(), "".into()); + Some(cell) } fn can_group( @@ -74,7 +74,7 @@ impl GroupCustomize for DateGroupController { fn create_or_delete_group_when_cell_changed( &mut self, - _row_detail: &RowDetail, + _row: &Row, _old_cell_data: Option<&::CellProtobufType>, _cell_data: &::CellProtobufType, ) -> FlowyResult<(Option, Option)> { @@ -87,7 +87,7 @@ impl GroupCustomize for DateGroupController { { let group = make_group_from_date_cell(&_cell_data.into(), &setting_content); let mut new_group = self.context.add_new_group(group)?; - new_group.group.rows.push(RowMetaPB::from(_row_detail)); + new_group.group.rows.push(RowMetaPB::from(_row.clone())); inserted_group = Some(new_group); } @@ -120,7 +120,7 @@ impl GroupCustomize for DateGroupController { fn add_or_remove_row_when_cell_changed( &mut self, - row_detail: &RowDetail, + row: &Row, cell_data: &::CellProtobufType, ) -> Vec { let mut changesets = vec![]; @@ -128,17 +128,15 @@ impl GroupCustomize for DateGroupController { self.context.iter_mut_status_groups(|group| { let mut changeset = GroupRowsNotificationPB::new(group.id.clone()); if group.id == get_date_group_id(&cell_data.into(), &setting_content) { - if !group.contains_row(&row_detail.row.id) { + if !group.contains_row(&row.id) { changeset .inserted_rows - .push(InsertedRowPB::new(RowMetaPB::from(row_detail))); - group.add_row(row_detail.clone()); + .push(InsertedRowPB::new(RowMetaPB::from(row.clone()))); + group.add_row(row.clone()); } - } else if group.contains_row(&row_detail.row.id) { - group.remove_row(&row_detail.row.id); - changeset - .deleted_rows - .push(row_detail.row.id.clone().into_inner()); + } else if group.contains_row(&row.id) { + group.remove_row(&row.id); + changeset.deleted_rows.push(row.id.clone().into_inner()); } if !changeset.is_empty() { @@ -193,7 +191,7 @@ impl GroupCustomize for DateGroupController { group_changeset } - fn delete_group_when_move_row( + fn delete_group_after_moving_row( &mut self, _row: &Row, cell_data: &::CellProtobufType, @@ -214,7 +212,7 @@ impl GroupCustomize for DateGroupController { deleted_group } - fn delete_group(&mut self, group_id: &str) -> FlowyResult> { + async fn delete_group(&mut self, group_id: &str) -> FlowyResult> { self.context.delete_group(group_id)?; Ok(None) } @@ -222,7 +220,7 @@ impl GroupCustomize for DateGroupController { fn will_create_row(&self, cells: &mut Cells, field: &Field, group_id: &str) { match self.context.get_group(group_id) { None => tracing::warn!("Can not find the group: {}", group_id), - Some((_, _)) => { + _ => { let date = DateTime::parse_from_str(group_id, GROUP_ID_DATE_FORMAT).unwrap(); let cell = insert_date_cell(date.timestamp(), None, Some(false), field); cells.insert(field.id.clone(), cell); @@ -330,7 +328,9 @@ fn get_date_group_id(cell_data: &DateCellData, setting_content: &str) -> String fn date_time_from_timestamp(timestamp: Option) -> DateTime { match timestamp { Some(timestamp) => { - let naive = NaiveDateTime::from_timestamp_opt(timestamp, 0).unwrap(); + let naive = DateTime::from_timestamp(timestamp, 0) + .unwrap_or_default() + .naive_utc(); let offset = *Local::now().offset(); DateTime::::from_naive_utc_and_offset(naive, offset) @@ -341,13 +341,11 @@ fn date_time_from_timestamp(timestamp: Option) -> DateTime { #[cfg(test)] mod tests { - use chrono::{offset, Days, Duration, NaiveDateTime}; - - use crate::services::field::date_type_option::DateTypeOption; - use crate::services::field::DateCellData; use crate::services::group::controller_impls::date_controller::{ get_date_group_id, GROUP_ID_DATE_FORMAT, }; + use chrono::{offset, Days, Duration}; + use collab_database::fields::date_type_option::{DateCellData, DateTypeOption}; #[test] fn group_id_name_test() { @@ -357,9 +355,11 @@ mod tests { exp_group_id: String, } - let mar_14_2022 = NaiveDateTime::from_timestamp_opt(1647251762, 0).unwrap(); + let mar_14_2022 = chrono::DateTime::from_timestamp(1647251762, 0) + .unwrap() + .naive_utc(); let mar_14_2022_cd = DateCellData { - timestamp: Some(mar_14_2022.timestamp()), + timestamp: Some(mar_14_2022.and_utc().timestamp()), include_time: false, ..Default::default() }; @@ -410,6 +410,7 @@ mod tests { mar_14_2022 .checked_add_signed(Duration::days(3)) .unwrap() + .and_utc() .timestamp(), ), include_time: false, diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/default_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/default_controller.rs index bcfd48bc09..0ce7dd036c 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/default_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/default_controller.rs @@ -1,13 +1,12 @@ +use async_trait::async_trait; use std::sync::Arc; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{Cells, Row, RowDetail, RowId}; - +use collab_database::rows::{Cells, Row, RowId}; use flowy_error::FlowyResult; +use tracing::trace; -use crate::entities::{ - GroupChangesPB, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, -}; +use crate::entities::{GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB}; use crate::services::group::action::{ DidMoveGroupRowResult, DidUpdateGroupRowResult, GroupController, }; @@ -20,6 +19,7 @@ use crate::services::group::{ /// means all rows will be grouped in the same group. /// pub struct DefaultGroupController { + pub view_id: String, pub field_id: String, pub group: GroupData, pub delegate: Arc, @@ -28,9 +28,10 @@ pub struct DefaultGroupController { const DEFAULT_GROUP_CONTROLLER: &str = "DefaultGroupController"; impl DefaultGroupController { - pub fn new(field: &Field, delegate: Arc) -> Self { + pub fn new(view_id: &str, field: &Field, delegate: Arc) -> Self { let group = GroupData::new(DEFAULT_GROUP_CONTROLLER.to_owned(), field.id.clone(), true); Self { + view_id: view_id.to_owned(), field_id: field.id.clone(), group, delegate, @@ -38,7 +39,21 @@ impl DefaultGroupController { } } +#[async_trait] impl GroupController for DefaultGroupController { + async fn load_group_data(&mut self) -> FlowyResult<()> { + let row_details = self.delegate.get_all_rows(&self.view_id).await; + let rows = row_details + .iter() + .map(|row| row.as_ref()) + .collect::>(); + + rows.iter().for_each(|row| { + self.group.add_row((*row).clone()); + }); + Ok(()) + } + fn get_grouping_field_id(&self) -> &str { &self.field_id } @@ -51,14 +66,14 @@ impl GroupController for DefaultGroupController { Some((0, self.group.clone())) } - fn fill_groups(&mut self, rows: &[&RowDetail], _field: &Field) -> FlowyResult<()> { + fn fill_groups(&mut self, rows: &[&Row], _field: &Field) -> FlowyResult<()> { rows.iter().for_each(|row| { self.group.add_row((*row).clone()); }); Ok(()) } - fn create_group( + async fn create_group( &mut self, _name: String, ) -> FlowyResult<(Option, Option)> { @@ -69,27 +84,24 @@ impl GroupController for DefaultGroupController { Ok(()) } - fn did_create_row( - &mut self, - row_detail: &RowDetail, - index: usize, - ) -> Vec { - self.group.add_row((*row_detail).clone()); + fn did_create_row(&mut self, row: &Row, index: usize) -> Vec { + self.group.add_row((*row).clone()); vec![GroupRowsNotificationPB::insert( self.group.id.clone(), vec![InsertedRowPB { - row_meta: (*row_detail).clone().into(), + row_meta: row.into(), index: Some(index as i32), is_new: true, + is_hidden_in_view: false, }], )] } fn did_update_group_row( &mut self, - _old_row_detail: &Option, - _row_detail: &RowDetail, + _old_row: &Option, + _new_row: &Row, _field: &Field, ) -> FlowyResult { Ok(DidUpdateGroupRowResult { @@ -102,6 +114,11 @@ impl GroupController for DefaultGroupController { fn did_delete_row(&mut self, row: &Row) -> FlowyResult { let mut changeset = GroupRowsNotificationPB::new(self.group.id.clone()); if self.group.contains_row(&row.id) { + trace!( + "[RowOrder]: delete row:{} from group: {}", + row.id, + self.group.id + ); self.group.remove_row(&row.id); changeset.deleted_rows.push(row.id.clone().into_inner()); } @@ -121,15 +138,14 @@ impl GroupController for DefaultGroupController { }) } - fn did_update_group_field(&mut self, _field: &Field) -> FlowyResult> { - Ok(None) - } - - fn delete_group(&mut self, _group_id: &str) -> FlowyResult<(Vec, Option)> { + async fn delete_group( + &mut self, + _group_id: &str, + ) -> FlowyResult<(Vec, Option)> { Ok((vec![], None)) } - fn apply_group_changeset( + async fn apply_group_changeset( &mut self, _changeset: &[GroupChangeset], ) -> FlowyResult<(Vec, Option)> { diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs index cae19109f6..e5bae5ba8d 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs @@ -1,14 +1,14 @@ use async_trait::async_trait; +use collab_database::fields::select_type_option::{MultiSelectTypeOption, SelectOption}; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail}; +use collab_database::rows::{new_cell_builder, Cell, Cells, Row}; use flowy_error::{FlowyError, FlowyResult}; use serde::{Deserialize, Serialize}; use crate::entities::{FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB}; use crate::services::cell::insert_select_option_cell; use crate::services::field::{ - MultiSelectTypeOption, SelectOption, SelectOptionCellDataParser, SelectTypeOptionSharedAction, - TypeOption, + SelectOptionCellDataParser, SelectTypeOptionSharedAction, TypeOption, }; use crate::services::group::action::GroupCustomize; use crate::services::group::controller::BaseGroupController; @@ -31,6 +31,7 @@ pub type MultiSelectGroupController = BaseGroupController< SelectOptionCellDataParser, >; +#[async_trait] impl GroupCustomize for MultiSelectGroupController { type GroupTypeOption = MultiSelectTypeOption; @@ -43,21 +44,19 @@ impl GroupCustomize for MultiSelectGroupController { } fn placeholder_cell(&self) -> Option { - Some( - new_cell_builder(FieldType::MultiSelect) - .insert_str_value("data", "") - .build(), - ) + let mut cell = new_cell_builder(FieldType::MultiSelect); + cell.insert("data".into(), "".into()); + Some(cell) } fn add_or_remove_row_when_cell_changed( &mut self, - row_detail: &RowDetail, + row: &Row, cell_data: &::CellProtobufType, ) -> Vec { let mut changesets = vec![]; self.context.iter_mut_status_groups(|group| { - if let Some(changeset) = add_or_remove_select_option_row(group, cell_data, row_detail) { + if let Some(changeset) = add_or_remove_select_option_row(group, cell_data, row) { changesets.push(changeset); } }); @@ -88,11 +87,11 @@ impl GroupCustomize for MultiSelectGroupController { group_changeset } - fn create_group( + async fn create_group( &mut self, name: String, ) -> FlowyResult<(Option, Option)> { - let mut new_type_option = self.get_grouping_field_type_option().ok_or_else(|| { + let mut new_type_option = self.get_grouping_field_type_option().await.ok_or_else(|| { FlowyError::internal().with_context("Failed to get grouping field type option") })?; let new_select_option = new_type_option.create_option(&name); @@ -104,8 +103,8 @@ impl GroupCustomize for MultiSelectGroupController { Ok((Some(new_type_option.into()), Some(inserted_group_pb))) } - fn delete_group(&mut self, group_id: &str) -> FlowyResult> { - let mut new_type_option = self.get_grouping_field_type_option().ok_or_else(|| { + async fn delete_group(&mut self, group_id: &str) -> FlowyResult> { + let mut new_type_option = self.get_grouping_field_type_option().await.ok_or_else(|| { FlowyError::internal().with_context("Failed to get grouping field type option") })?; if let Some(option_index) = new_type_option @@ -115,6 +114,7 @@ impl GroupCustomize for MultiSelectGroupController { { // Remove the option if the group is found new_type_option.options.remove(option_index); + self.context.delete_group(group_id)?; Ok(Some(new_type_option.into())) } else { Ok(None) diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/single_select_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/single_select_controller.rs index d26ef50b70..0a82fe1fd4 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/single_select_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/single_select_controller.rs @@ -1,14 +1,14 @@ use async_trait::async_trait; +use collab_database::fields::select_type_option::{SelectOption, SingleSelectTypeOption}; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail}; +use collab_database::rows::{new_cell_builder, Cell, Cells, Row}; use flowy_error::{FlowyError, FlowyResult}; use serde::{Deserialize, Serialize}; use crate::entities::{FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB}; use crate::services::cell::insert_select_option_cell; use crate::services::field::{ - SelectOption, SelectOptionCellDataParser, SelectTypeOptionSharedAction, SingleSelectTypeOption, - TypeOption, + SelectOptionCellDataParser, SelectTypeOptionSharedAction, TypeOption, }; use crate::services::group::action::GroupCustomize; use crate::services::group::controller::BaseGroupController; @@ -33,6 +33,7 @@ pub type SingleSelectGroupController = BaseGroupController< SelectOptionCellDataParser, >; +#[async_trait] impl GroupCustomize for SingleSelectGroupController { type GroupTypeOption = SingleSelectTypeOption; @@ -45,21 +46,19 @@ impl GroupCustomize for SingleSelectGroupController { } fn placeholder_cell(&self) -> Option { - Some( - new_cell_builder(FieldType::SingleSelect) - .insert_str_value("data", "") - .build(), - ) + let mut cell = new_cell_builder(FieldType::SingleSelect); + cell.insert("data".into(), "".into()); + Some(cell) } fn add_or_remove_row_when_cell_changed( &mut self, - row_detail: &RowDetail, + row: &Row, cell_data: &::CellProtobufType, ) -> Vec { let mut changesets = vec![]; self.context.iter_mut_status_groups(|group| { - if let Some(changeset) = add_or_remove_select_option_row(group, cell_data, row_detail) { + if let Some(changeset) = add_or_remove_select_option_row(group, cell_data, row) { changesets.push(changeset); } }); @@ -90,11 +89,11 @@ impl GroupCustomize for SingleSelectGroupController { group_changeset } - fn create_group( + async fn create_group( &mut self, name: String, ) -> FlowyResult<(Option, Option)> { - let mut new_type_option = self.get_grouping_field_type_option().ok_or_else(|| { + let mut new_type_option = self.get_grouping_field_type_option().await.ok_or_else(|| { FlowyError::internal().with_context("Failed to get grouping field type option") })?; let new_select_option = new_type_option.create_option(&name); @@ -106,8 +105,8 @@ impl GroupCustomize for SingleSelectGroupController { Ok((Some(new_type_option.into()), Some(inserted_group_pb))) } - fn delete_group(&mut self, group_id: &str) -> FlowyResult> { - let mut new_type_option = self.get_grouping_field_type_option().ok_or_else(|| { + async fn delete_group(&mut self, group_id: &str) -> FlowyResult> { + let mut new_type_option = self.get_grouping_field_type_option().await.ok_or_else(|| { FlowyError::internal().with_context("Failed to get grouping field type option") })?; if let Some(option_index) = new_type_option @@ -117,6 +116,7 @@ impl GroupCustomize for SingleSelectGroupController { { // Remove the option if the group is found new_type_option.options.remove(option_index); + self.context.delete_group(group_id)?; Ok(Some(new_type_option.into())) } else { Ok(None) diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/util.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/util.rs index 01bd4cdc0d..827e4f0e53 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/util.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/util.rs @@ -1,43 +1,40 @@ -use chrono::NaiveDateTime; -use collab_database::fields::Field; -use collab_database::rows::{Cell, Row, RowDetail}; - use crate::entities::{ FieldType, GroupRowsNotificationPB, InsertedRowPB, RowMetaPB, SelectOptionCellDataPB, }; use crate::services::cell::{ insert_checkbox_cell, insert_date_cell, insert_select_option_cell, insert_url_cell, }; -use crate::services::field::{SelectOption, SelectOptionIds, CHECK}; +use crate::services::field::CHECK; use crate::services::group::{Group, GroupData, MoveGroupRowContext}; +use chrono::NaiveDateTime; +use collab_database::fields::select_type_option::{SelectOption, SelectOptionIds}; +use collab_database::fields::Field; +use collab_database::rows::{Cell, Row}; +use tracing::debug; pub fn add_or_remove_select_option_row( group: &mut GroupData, cell_data: &SelectOptionCellDataPB, - row_detail: &RowDetail, + row: &Row, ) -> Option { let mut changeset = GroupRowsNotificationPB::new(group.id.clone()); if cell_data.select_options.is_empty() { - if group.contains_row(&row_detail.row.id) { - group.remove_row(&row_detail.row.id); - changeset - .deleted_rows - .push(row_detail.row.id.clone().into_inner()); + if group.contains_row(&row.id) { + group.remove_row(&row.id); + changeset.deleted_rows.push(row.id.clone().into_inner()); } } else { cell_data.select_options.iter().for_each(|option| { if option.id == group.id { - if !group.contains_row(&row_detail.row.id) { + if !group.contains_row(&row.id) { changeset .inserted_rows - .push(InsertedRowPB::new(RowMetaPB::from(row_detail))); - group.add_row(row_detail.clone()); + .push(InsertedRowPB::new(RowMetaPB::from(row.clone()))); + group.add_row(row.clone()); } - } else if group.contains_row(&row_detail.row.id) { - group.remove_row(&row_detail.row.id); - changeset - .deleted_rows - .push(row_detail.row.id.clone().into_inner()); + } else if group.contains_row(&row.id) { + group.remove_row(&row.id); + changeset.deleted_rows.push(row.id.clone().into_inner()); } }); } @@ -75,55 +72,42 @@ pub fn move_group_row( ) -> Option { let mut changeset = GroupRowsNotificationPB::new(group.id.clone()); let MoveGroupRowContext { - row_detail, - row_changeset, + row, + updated_cells, field, to_group_id, to_row_id, } = context; - let from_index = group.index_of_row(&row_detail.row.id); + let from_index = group.index_of_row(&row.id); let to_index = match to_row_id { None => None, Some(to_row_id) => group.index_of_row(to_row_id), }; // Remove the row in which group contains it - if let Some(from_index) = &from_index { - changeset - .deleted_rows - .push(row_detail.row.id.clone().into_inner()); - tracing::debug!( - "Group:{} remove {} at {}", - group.id, - row_detail.row.id, - from_index - ); - group.remove_row(&row_detail.row.id); + if from_index.is_some() { + changeset.deleted_rows.push(row.id.clone().into_inner()); + group.remove_row(&row.id); } if group.id == *to_group_id { - let mut inserted_row = InsertedRowPB::new(RowMetaPB::from((*row_detail).clone())); + let mut inserted_row = InsertedRowPB::new(RowMetaPB::from((*row).clone())); match to_index { None => { changeset.inserted_rows.push(inserted_row); - tracing::debug!("Group:{} append row:{}", group.id, row_detail.row.id); - group.add_row(row_detail.clone()); + group.add_row(row.clone()); }, Some(to_index) => { if to_index < group.number_of_row() { - tracing::debug!( - "Group:{} insert {} at {} ", - group.id, - row_detail.row.id, + inserted_row.index = Some(to_index as i32); + group.insert_row(to_index, row.clone()); + } else { + tracing::warn!( + "[Database Group]: Move to index: {} is out of bounds", to_index ); - inserted_row.index = Some(to_index as i32); - group.insert_row(to_index, (*row_detail).clone()); - } else { - tracing::warn!("Move to index: {} is out of bounds", to_index); - tracing::debug!("Group:{} append row:{}", group.id, row_detail.row.id); - group.add_row((*row_detail).clone()); + group.add_row(row.clone()); } changeset.inserted_rows.push(inserted_row); }, @@ -135,14 +119,11 @@ pub fn move_group_row( if from_index.is_none() { let cell = make_inserted_cell(&group.id, field); if let Some(cell) = cell { - tracing::debug!( - "Update content of the cell in the row:{} to group:{}", - row_detail.row.id, - group.id + debug!( + "[Database Group]: Update content of the cell in the row:{} to group:{}", + row.id, group.id ); - row_changeset - .cell_by_field_id - .insert(field.id.clone(), cell); + updated_cells.insert(field.id.clone(), cell); } } } @@ -176,7 +157,7 @@ pub fn make_inserted_cell(group_id: &str, field: &Field) -> Option { let date = NaiveDateTime::parse_from_str(&format!("{} 00:00:00", group_id), "%Y/%m/%d %H:%M:%S") .unwrap(); - let cell = insert_date_cell(date.timestamp(), None, Some(false), field); + let cell = insert_date_cell(date.and_utc().timestamp(), None, Some(false), field); Some(cell) }, _ => { diff --git a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/url_controller.rs b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/url_controller.rs index 9d9a0468cb..aae0777b71 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/url_controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller_impls/url_controller.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; +use collab_database::fields::url_type_option::{URLCellData, URLTypeOption}; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail}; +use collab_database::rows::{new_cell_builder, Cell, Cells, Row}; use serde::{Deserialize, Serialize}; use flowy_error::FlowyResult; @@ -9,7 +10,7 @@ use crate::entities::{ FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, RowMetaPB, }; use crate::services::cell::insert_url_cell; -use crate::services::field::{TypeOption, URLCellData, URLCellDataParser, URLTypeOption}; +use crate::services::field::{TypeOption, URLCellDataParser}; use crate::services::group::action::GroupCustomize; use crate::services::group::configuration::GroupControllerContext; use crate::services::group::controller::BaseGroupController; @@ -27,15 +28,14 @@ pub type URLGroupController = pub type URLGroupControllerContext = GroupControllerContext; +#[async_trait] impl GroupCustomize for URLGroupController { type GroupTypeOption = URLTypeOption; fn placeholder_cell(&self) -> Option { - Some( - new_cell_builder(FieldType::URL) - .insert_str_value("data", "") - .build(), - ) + let mut cell = new_cell_builder(FieldType::URL); + cell.insert("data".into(), "".into()); + Some(cell) } fn can_group( @@ -48,7 +48,7 @@ impl GroupCustomize for URLGroupController { fn create_or_delete_group_when_cell_changed( &mut self, - _row_detail: &RowDetail, + _row: &Row, _old_cell_data: Option<&::CellProtobufType>, _cell_data: &::CellProtobufType, ) -> FlowyResult<(Option, Option)> { @@ -58,7 +58,7 @@ impl GroupCustomize for URLGroupController { let cell_data: URLCellData = _cell_data.clone().into(); let group = Group::new(cell_data.data); let mut new_group = self.context.add_new_group(group)?; - new_group.group.rows.push(RowMetaPB::from(_row_detail)); + new_group.group.rows.push(RowMetaPB::from(_row.clone())); inserted_group = Some(new_group); } @@ -89,24 +89,22 @@ impl GroupCustomize for URLGroupController { fn add_or_remove_row_when_cell_changed( &mut self, - row_detail: &RowDetail, + row: &Row, cell_data: &::CellProtobufType, ) -> Vec { let mut changesets = vec![]; self.context.iter_mut_status_groups(|group| { let mut changeset = GroupRowsNotificationPB::new(group.id.clone()); if group.id == cell_data.content { - if !group.contains_row(&row_detail.row.id) { + if !group.contains_row(&row.id) { changeset .inserted_rows - .push(InsertedRowPB::new(RowMetaPB::from(row_detail))); - group.add_row(row_detail.clone()); + .push(InsertedRowPB::new(RowMetaPB::from(row))); + group.add_row(row.clone()); } - } else if group.contains_row(&row_detail.row.id) { - group.remove_row(&row_detail.row.id); - changeset - .deleted_rows - .push(row_detail.row.id.clone().into_inner()); + } else if group.contains_row(&row.id) { + group.remove_row(&row.id); + changeset.deleted_rows.push(row.id.clone().into_inner()); } if !changeset.is_empty() { @@ -157,7 +155,7 @@ impl GroupCustomize for URLGroupController { group_changeset } - fn delete_group_when_move_row( + fn delete_group_after_moving_row( &mut self, _row: &Row, cell_data: &::CellProtobufType, @@ -174,7 +172,7 @@ impl GroupCustomize for URLGroupController { deleted_group } - fn delete_group(&mut self, group_id: &str) -> FlowyResult> { + async fn delete_group(&mut self, group_id: &str) -> FlowyResult> { self.context.delete_group(group_id)?; Ok(None) } diff --git a/frontend/rust-lib/flowy-database2/src/services/group/entities.rs b/frontend/rust-lib/flowy-database2/src/services/group/entities.rs index 12692fd812..296a9960f7 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/entities.rs @@ -1,23 +1,27 @@ -use anyhow::bail; -use collab::core::any_map::AnyMapExtension; +use collab::preclude::encoding::serde::{from_any, to_any}; +use collab::preclude::Any; use collab_database::database::gen_database_group_id; -use collab_database::rows::{RowDetail, RowId}; +use collab_database::rows::{Row, RowId}; use collab_database::views::{GroupMap, GroupMapBuilder, GroupSettingBuilder, GroupSettingMap}; use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use std::sync::Arc; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] pub struct GroupSetting { pub id: String, pub field_id: String, + #[serde(rename = "ty")] pub field_type: i64, + #[serde(default)] pub groups: Vec, + #[serde(default)] pub content: String, } #[derive(Clone, Default, Debug)] pub struct GroupChangeset { pub group_id: String, - pub field_id: String, pub name: Option, pub visible: Option, } @@ -44,38 +48,20 @@ impl TryFrom for GroupSetting { type Error = anyhow::Error; fn try_from(value: GroupSettingMap) -> Result { - match ( - value.get_str_value(GROUP_ID), - value.get_str_value(FIELD_ID), - value.get_i64_value(FIELD_TYPE), - ) { - (Some(id), Some(field_id), Some(field_type)) => { - let content = value.get_str_value(CONTENT).unwrap_or_default(); - let groups = value.try_get_array(GROUPS); - Ok(Self { - id, - field_id, - field_type, - groups, - content, - }) - }, - _ => { - bail!("Invalid group setting data") - }, - } + from_any(&Any::from(value)).map_err(|e| e.into()) } } impl From for GroupSettingMap { fn from(setting: GroupSetting) -> Self { - GroupSettingBuilder::new() - .insert_str_value(GROUP_ID, setting.id) - .insert_str_value(FIELD_ID, setting.field_id) - .insert_i64_value(FIELD_TYPE, setting.field_type) - .insert_maps(GROUPS, setting.groups) - .insert_str_value(CONTENT, setting.content) - .build() + let groups = to_any(&setting.groups).unwrap_or_else(|_| Any::Array(Arc::from([]))); + GroupSettingBuilder::from([ + (GROUP_ID.into(), setting.id.into()), + (FIELD_ID.into(), setting.field_id.into()), + (FIELD_TYPE.into(), Any::BigInt(setting.field_type)), + (GROUPS.into(), groups), + (CONTENT.into(), setting.content.into()), + ]) } } @@ -90,22 +76,16 @@ impl TryFrom for Group { type Error = anyhow::Error; fn try_from(value: GroupMap) -> Result { - match value.get_str_value("id") { - None => bail!("Invalid group data"), - Some(id) => { - let visible = value.get_bool_value("visible").unwrap_or_default(); - Ok(Self { id, visible }) - }, - } + from_any(&Any::from(value)).map_err(|e| e.into()) } } impl From for GroupMap { fn from(group: Group) -> Self { - GroupMapBuilder::new() - .insert_str_value("id", group.id) - .insert_bool_value("visible", group.visible) - .build() + GroupMapBuilder::from([ + ("id".into(), group.id.into()), + ("visible".into(), group.visible.into()), + ]) } } @@ -123,7 +103,13 @@ pub struct GroupData { pub field_id: String, pub is_default: bool, pub is_visible: bool, - pub(crate) rows: Vec, + pub(crate) rows: Vec, +} + +impl Display for GroupData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "GroupData:{}, {} rows", self.id, self.rows.len()) + } } impl GroupData { @@ -139,18 +125,18 @@ impl GroupData { } pub fn contains_row(&self, row_id: &RowId) -> bool { - self - .rows - .iter() - .any(|row_detail| &row_detail.row.id == row_id) + self.rows.iter().any(|row| &row.id == row_id) } pub fn remove_row(&mut self, row_id: &RowId) { - match self - .rows - .iter() - .position(|row_detail| &row_detail.row.id == row_id) - { + #[cfg(feature = "verbose_log")] + tracing::trace!( + "[Database Group]: Remove row:{} from group:{}", + row_id, + self.id + ); + + match self.rows.iter().position(|row| &row.id == row_id) { None => {}, Some(pos) => { self.rows.remove(pos); @@ -158,18 +144,27 @@ impl GroupData { } } - pub fn add_row(&mut self, row_detail: RowDetail) { - match self.rows.iter().find(|r| r.row.id == row_detail.row.id) { + pub fn add_row(&mut self, row: Row) { + #[cfg(feature = "verbose_log")] + tracing::trace!("[Database Group]: Add row:{} to group:{}", row.id, self.id); + match self.rows.iter().find(|r| r.id == row.id) { None => { - self.rows.push(row_detail); + self.rows.push(row); }, Some(_) => {}, } } - pub fn insert_row(&mut self, index: usize, row_detail: RowDetail) { + pub fn insert_row(&mut self, index: usize, row: Row) { + #[cfg(feature = "verbose_log")] + tracing::trace!( + "[Database Group]: Insert row:{} to group:{} at index:{}", + row.id, + self.id, + index + ); if index < self.rows.len() { - self.rows.insert(index, row_detail); + self.rows.insert(index, row); } else { tracing::error!( "Insert row index:{} beyond the bounds:{},", @@ -180,10 +175,7 @@ impl GroupData { } pub fn index_of_row(&self, row_id: &RowId) -> Option { - self - .rows - .iter() - .position(|row_detail| &row_detail.row.id == row_id) + self.rows.iter().position(|row| &row.id == row_id) } pub fn number_of_row(&self) -> usize { diff --git a/frontend/rust-lib/flowy-database2/src/services/group/group_builder.rs b/frontend/rust-lib/flowy-database2/src/services/group/group_builder.rs index 8eb677ed26..96b8a0837c 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/group_builder.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/group_builder.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use collab_database::fields::Field; -use collab_database::rows::{Cell, RowDetail, RowId}; - +use collab_database::rows::{Cell, Row, RowId}; use flowy_error::FlowyResult; use crate::entities::FieldType; @@ -36,37 +35,16 @@ pub struct GeneratedGroups { } pub struct MoveGroupRowContext<'a> { - pub row_detail: &'a RowDetail, - pub row_changeset: &'a mut RowChangeset, + pub row: &'a Row, + pub updated_cells: &'a mut UpdatedCells, pub field: &'a Field, pub to_group_id: &'a str, pub to_row_id: Option, } -#[derive(Debug, Clone)] -pub struct RowChangeset { - pub row_id: RowId, - pub height: Option, - pub visibility: Option, - // Contains the key/value changes represents as the update of the cells. For example, - // if there is one cell was changed, then the `cell_by_field_id` will only have one key/value. - pub cell_by_field_id: HashMap, -} - -impl RowChangeset { - pub fn new(row_id: RowId) -> Self { - Self { - row_id, - height: None, - visibility: None, - cell_by_field_id: Default::default(), - } - } - - pub fn is_empty(&self) -> bool { - self.height.is_none() && self.visibility.is_none() && self.cell_by_field_id.is_empty() - } -} +/// A map of the updated cells. +/// The key is the field id, the value is the updated cell. +pub type UpdatedCells = HashMap; /// Returns a group controller. /// @@ -85,7 +63,7 @@ impl RowChangeset { fields(grouping_field_id=%grouping_field.id, grouping_field_type) err )] -pub async fn make_group_controller( +pub(crate) async fn make_group_controller( view_id: &str, grouping_field: Field, delegate: D, @@ -94,9 +72,9 @@ where D: GroupContextDelegate + GroupControllerDelegate, { let grouping_field_type = FieldType::from(grouping_field.field_type); - tracing::Span::current().record("grouping_field", &grouping_field_type.default_name()); + tracing::Span::current().record("grouping_field", grouping_field_type.default_name()); - let mut group_controller: Box; + let group_controller: Box; let delegate = Arc::new(delegate); match grouping_field_type { @@ -157,22 +135,19 @@ where }, _ => { group_controller = Box::new(DefaultGroupController::new( + view_id, &grouping_field, delegate.clone(), )); }, } - // Separates the rows into different groups - let row_details = delegate.get_all_rows(view_id).await; - - let rows = row_details - .iter() - .map(|row| row.as_ref()) - .collect::>(); - - group_controller.fill_groups(rows.as_slice(), &grouping_field)?; - + #[cfg(feature = "verbose_log")] + { + for group in group_controller.get_all_groups() { + tracing::trace!("[Database]: group: {}", group); + } + } Ok(group_controller) } diff --git a/frontend/rust-lib/flowy-database2/src/services/group/mod.rs b/frontend/rust-lib/flowy-database2/src/services/group/mod.rs index c2ac8300b4..9fba427e11 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/mod.rs @@ -5,7 +5,7 @@ mod controller_impls; mod entities; mod group_builder; -pub(crate) use action::GroupController; +pub(crate) use action::*; pub(crate) use configuration::*; pub(crate) use controller::*; pub(crate) use controller_impls::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs b/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs index 7cfe093725..d40ab58d72 100644 --- a/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/setting/entities.rs @@ -1,50 +1,44 @@ -use collab::core::any_map::AnyMapExtension; +use collab::preclude::encoding::serde::from_any; +use collab::preclude::Any; use collab_database::views::{LayoutSetting, LayoutSettingBuilder}; use serde::{Deserialize, Serialize}; use serde_repr::*; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CalendarLayoutSetting { + #[serde(default)] pub layout_ty: CalendarLayout, + #[serde(default)] pub first_day_of_week: i32, + #[serde(default)] pub show_weekends: bool, + #[serde(default)] pub show_week_numbers: bool, + #[serde(default)] pub field_id: String, } impl From for CalendarLayoutSetting { fn from(setting: LayoutSetting) -> Self { - let layout_ty = setting - .get_i64_value("layout_ty") - .map(CalendarLayout::from) - .unwrap_or_default(); - let first_day_of_week = setting - .get_i64_value("first_day_of_week") - .unwrap_or(DEFAULT_FIRST_DAY_OF_WEEK as i64) as i32; - let show_weekends = setting.get_bool_value("show_weekends").unwrap_or_default(); - let show_week_numbers = setting - .get_bool_value("show_week_numbers") - .unwrap_or_default(); - let field_id = setting.get_str_value("field_id").unwrap_or_default(); - Self { - layout_ty, - first_day_of_week, - show_weekends, - show_week_numbers, - field_id, - } + from_any(&Any::from(setting)).unwrap() } } impl From for LayoutSetting { fn from(setting: CalendarLayoutSetting) -> Self { - LayoutSettingBuilder::new() - .insert_i64_value("layout_ty", setting.layout_ty.value()) - .insert_i64_value("first_day_of_week", setting.first_day_of_week as i64) - .insert_bool_value("show_week_numbers", setting.show_week_numbers) - .insert_bool_value("show_weekends", setting.show_weekends) - .insert_str_value("field_id", setting.field_id) - .build() + LayoutSettingBuilder::from([ + ("layout_ty".into(), Any::BigInt(setting.layout_ty.value())), + ( + "first_day_of_week".into(), + Any::BigInt(setting.first_day_of_week as i64), + ), + ( + "show_week_numbers".into(), + Any::Bool(setting.show_week_numbers), + ), + ("show_weekends".into(), Any::Bool(setting.show_weekends)), + ("field_id".into(), setting.field_id.into()), + ]) } } @@ -90,36 +84,40 @@ pub const DEFAULT_FIRST_DAY_OF_WEEK: i32 = 0; pub const DEFAULT_SHOW_WEEKENDS: bool = true; pub const DEFAULT_SHOW_WEEK_NUMBERS: bool = true; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] pub struct BoardLayoutSetting { + #[serde(default)] pub hide_ungrouped_column: bool, + #[serde(default)] pub collapse_hidden_groups: bool, } impl BoardLayoutSetting { pub fn new() -> Self { - Self::default() + Self { + hide_ungrouped_column: false, + collapse_hidden_groups: true, + } } } impl From for BoardLayoutSetting { fn from(setting: LayoutSetting) -> Self { - Self { - hide_ungrouped_column: setting - .get_bool_value("hide_ungrouped_column") - .unwrap_or_default(), - collapse_hidden_groups: setting - .get_bool_value("collapse_hidden_groups") - .unwrap_or_default(), - } + from_any(&Any::from(setting)).unwrap() } } impl From for LayoutSetting { fn from(setting: BoardLayoutSetting) -> Self { - LayoutSettingBuilder::new() - .insert_bool_value("hide_ungrouped_column", setting.hide_ungrouped_column) - .insert_bool_value("collapse_hidden_groups", setting.collapse_hidden_groups) - .build() + LayoutSettingBuilder::from([ + ( + "hide_ungrouped_column".into(), + setting.hide_ungrouped_column.into(), + ), + ( + "collapse_hidden_groups".into(), + setting.collapse_hidden_groups.into(), + ), + ]) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs b/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs index 8cb59a1872..3eab243fd7 100644 --- a/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs +++ b/frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs @@ -1,13 +1,14 @@ use collab_database::database::Database; use collab_database::fields::Field; use collab_database::rows::Cell; +use collab_database::template::timestamp_parse::TimestampCellData; +use futures::StreamExt; use indexmap::IndexMap; use flowy_error::{FlowyError, FlowyResult}; use crate::entities::FieldType; use crate::services::cell::stringify_cell; -use crate::services::field::{TimestampCellData, TimestampCellDataWrapper}; #[derive(Debug, Clone, Copy)] pub enum CSVFormat { @@ -21,10 +22,16 @@ pub enum CSVFormat { pub struct CSVExport; impl CSVExport { - pub fn export_database(&self, database: &Database, style: CSVFormat) -> FlowyResult { + pub async fn export_database( + &self, + database: &Database, + style: CSVFormat, + ) -> FlowyResult { let mut wtr = csv::Writer::from_writer(vec![]); - let inline_view_id = database.get_inline_view_id(); - let fields = database.get_fields_in_view(&inline_view_id, None); + let view_id = database + .get_first_database_view_id() + .ok_or_else(|| FlowyError::internal().with_context("failed to get first database view"))?; + let fields = database.get_fields_in_view(&view_id, None); // Write fields let field_records = fields @@ -43,7 +50,12 @@ impl CSVExport { fields.into_iter().for_each(|field| { field_by_field_id.insert(field.id.clone(), field); }); - let rows = database.get_rows_for_view(&inline_view_id); + let rows = database + .get_rows_for_view(&view_id, 20, None) + .await + .filter_map(|result| async { result.ok() }) + .collect::>() + .await; let stringify = |cell: &Cell, field: &Field, style: CSVFormat| match style { CSVFormat::Original => stringify_cell(cell, field), @@ -62,7 +74,7 @@ impl CSVExport { } else { TimestampCellData::new(row.modified_at) }; - let cell = Cell::from(TimestampCellDataWrapper::from((field_type, cell_data))); + let cell = cell_data.to_cell(field.field_type); stringify(&cell, field, style) }, _ => match row.cells.get(field_id) { diff --git a/frontend/rust-lib/flowy-database2/src/services/share/csv/import.rs b/frontend/rust-lib/flowy-database2/src/services/share/csv/import.rs index dd1f0c1f6b..5f10691879 100644 --- a/frontend/rust-lib/flowy-database2/src/services/share/csv/import.rs +++ b/frontend/rust-lib/flowy-database2/src/services/share/csv/import.rs @@ -1,11 +1,11 @@ -use std::{fs::File, io::prelude::*}; - use collab_database::database::{gen_database_id, gen_field_id, gen_row_id, timestamp}; +use collab_database::entity::{CreateDatabaseParams, CreateViewParams, EncodedCollabInfo}; use collab_database::fields::Field; use collab_database::rows::{new_cell_builder, Cell, CreateRowParams}; -use collab_database::views::{CreateDatabaseParams, CreateViewParams, DatabaseLayout}; - +use collab_database::views::DatabaseLayout; use flowy_error::{FlowyError, FlowyResult}; +use std::fmt::Display; +use std::{fs::File, io::prelude::*}; use crate::entities::FieldType; use crate::services::field::{default_type_option_data_from_type, CELL_DATA}; @@ -108,17 +108,18 @@ fn database_from_fields_and_rows( let field_type = FieldType::from(field.field_type); // Make the cell based on the style. - let cell = match format { - CSVFormat::Original => new_cell_builder(field_type) - .insert_str_value(CELL_DATA, cell_content.to_string()) - .build(), - CSVFormat::META => match serde_json::from_str::(cell_content) { - Ok(cell) => cell, - Err(_) => new_cell_builder(field_type) - .insert_str_value(CELL_DATA, "".to_string()) - .build(), + let mut cell = new_cell_builder(field_type); + match format { + CSVFormat::Original => { + cell.insert(CELL_DATA.into(), cell_content.as_str().into()); }, - }; + CSVFormat::META => match serde_json::from_str::(cell_content) { + Ok(cell_json) => cell = cell_json, + Err(_) => { + cell.insert(CELL_DATA.into(), "".into()); + }, + }, + } params.cells.insert(field.id.clone(), cell); } } @@ -130,7 +131,6 @@ fn database_from_fields_and_rows( CreateDatabaseParams { database_id: database_id.clone(), - inline_view_id: view_id.to_string(), rows, fields, views: vec![CreateViewParams { @@ -166,8 +166,26 @@ impl FieldsRows { pub struct ImportResult { pub database_id: String, pub view_id: String, + pub encoded_collabs: Vec, } +impl Display for ImportResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let total_size: usize = self + .encoded_collabs + .iter() + .map(|c| c.encoded_collab.doc_state.len()) + .sum(); + write!( + f, + "ImportResult {{ database_id: {}, view_id: {}, num collabs: {}, size: {} }}", + self.database_id, + self.view_id, + self.encoded_collabs.len(), + total_size + ) + } +} #[cfg(test)] mod tests { use collab_database::database::gen_database_view_id; diff --git a/frontend/rust-lib/flowy-database2/src/services/sort/controller.rs b/frontend/rust-lib/flowy-database2/src/services/sort/controller.rs index 330f46f7f7..8dc3393a0f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/sort/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/sort/controller.rs @@ -1,43 +1,43 @@ +use async_trait::async_trait; use std::cmp::Ordering; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; use collab_database::fields::Field; -use collab_database::rows::{Cell, Row, RowDetail, RowId}; +use collab_database::rows::{Cell, Row, RowId}; +use collab_database::template::timestamp_parse::TimestampCellData; use rayon::prelude::ParallelSliceMut; use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; +use tokio::sync::RwLock as TokioRwLock; use flowy_error::FlowyResult; -use lib_infra::future::Fut; use lib_infra::priority_task::{QualityOfService, Task, TaskContent, TaskDispatcher}; use crate::entities::SortChangesetNotificationPB; use crate::entities::{FieldType, SortWithIndexPB}; use crate::services::cell::CellCache; use crate::services::database_view::{DatabaseViewChanged, DatabaseViewChangedNotifier}; -use crate::services::field::{ - default_order, TimestampCellData, TimestampCellDataWrapper, TypeOptionCellExt, -}; +use crate::services::field::{default_order, TypeOptionCellExt}; use crate::services::sort::{ - InsertRowResult, ReorderAllRowsResult, ReorderSingleRowResult, Sort, SortChangeset, SortCondition, + ReorderAllRowsResult, ReorderSingleRowResult, Sort, SortChangeset, SortCondition, }; +#[async_trait] pub trait SortDelegate: Send + Sync { - fn get_sort(&self, view_id: &str, sort_id: &str) -> Fut>>; + async fn get_sort(&self, view_id: &str, sort_id: &str) -> Option>; /// Returns all the rows after applying grid's filter - fn get_rows(&self, view_id: &str) -> Fut>>; - fn filter_row(&self, row_detail: &RowDetail) -> Fut; - fn get_field(&self, field_id: &str) -> Option; - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>; + async fn get_rows(&self, view_id: &str) -> Vec>; + async fn filter_row(&self, row_detail: &Row) -> bool; + async fn get_field(&self, field_id: &str) -> Option; + async fn get_fields(&self, view_id: &str, field_ids: Option>) -> Vec; } pub struct SortController { view_id: String, handler_id: String, delegate: Box, - task_scheduler: Arc>, + task_scheduler: Arc>, sorts: Vec>, cell_cache: CellCache, row_index_cache: HashMap, @@ -56,7 +56,7 @@ impl SortController { handler_id: &str, sorts: Vec>, delegate: T, - task_scheduler: Arc>, + task_scheduler: Arc>, cell_cache: CellCache, notifier: DatabaseViewChangedNotifier, ) -> Self @@ -83,6 +83,10 @@ impl SortController { } } + pub async fn has_sorts(&self) -> bool { + !self.sorts.is_empty() + } + pub async fn did_receive_row_changed(&self, row_id: RowId) { if !self.sorts.is_empty() { self @@ -94,27 +98,31 @@ impl SortController { } } - pub async fn did_create_row(&self, preliminary_index: usize, row_detail: &RowDetail) { - if !self.delegate.filter_row(row_detail).await { - return; + pub async fn did_create_row(&mut self, row: &Row) -> Option { + if !self.delegate.filter_row(row).await { + return None; } if !self.sorts.is_empty() { - self - .gen_task( - SortEvent::NewRowInserted(row_detail.clone()), - QualityOfService::Background, - ) - .await; + let mut rows = self.delegate.get_rows(&self.view_id).await; + self.sort_rows(&mut rows).await; + + let row_index = self + .row_index_cache + .get(&row.id) + .cloned() + .map(|val| val as u32); + + if row_index.is_none() { + tracing::trace!("The row index cache is outdated"); + } + row_index } else { - let result = InsertRowResult { - view_id: self.view_id.clone(), - row: row_detail.clone(), - index: preliminary_index, - }; - let _ = self - .notifier - .send(DatabaseViewChanged::InsertRowNotification(result)); + let rows = self.delegate.get_rows(&self.view_id).await; + rows + .iter() + .position(|val| val.id == row.id) + .map(|val| val as u32) } } @@ -129,31 +137,15 @@ impl SortController { // #[tracing::instrument(name = "process_sort_task", level = "trace", skip_all, err)] pub async fn process(&mut self, predicate: &str) -> FlowyResult<()> { let event_type = SortEvent::from_str(predicate).unwrap(); - let mut row_details = self.delegate.get_rows(&self.view_id).await; + let mut rows = self.delegate.get_rows(&self.view_id).await; match event_type { SortEvent::SortDidChanged | SortEvent::DeleteAllSorts => { - self.sort_rows(&mut row_details).await; - let row_orders = row_details - .iter() - .map(|row_detail| row_detail.row.id.to_string()) - .collect::>(); - - let notification = ReorderAllRowsResult { - view_id: self.view_id.clone(), - row_orders, - }; - - let _ = self - .notifier - .send(DatabaseViewChanged::ReorderAllRowsNotification( - notification, - )); + self.sort_rows_and_notify(&mut rows).await; }, SortEvent::RowDidChanged(row_id) => { let old_row_index = self.row_index_cache.get(&row_id).cloned(); - - self.sort_rows(&mut row_details).await; + self.sort_rows(&mut rows).await; let new_row_index = self.row_index_cache.get(&row_id).cloned(); match (old_row_index, new_row_index) { (Some(old_row_index), Some(new_row_index)) => { @@ -175,24 +167,6 @@ impl SortController { _ => tracing::trace!("The row index cache is outdated"), } }, - SortEvent::NewRowInserted(row_detail) => { - self.sort_rows(&mut row_details).await; - let row_index = self.row_index_cache.get(&row_detail.row.id).cloned(); - match row_index { - Some(row_index) => { - let notification = InsertRowResult { - view_id: self.view_id.clone(), - row: row_detail.clone(), - index: row_index, - }; - self.row_index_cache.insert(row_detail.row.id, row_index); - let _ = self - .notifier - .send(DatabaseViewChanged::InsertRowNotification(notification)); - }, - _ => tracing::trace!("The row index cache is outdated"), - } - }, } Ok(()) } @@ -203,26 +177,38 @@ impl SortController { let task = Task::new( &self.handler_id, task_id, - TaskContent::Text(task_type.to_string()), + TaskContent::Text(task_type.to_json_string()), qos, ); self.task_scheduler.write().await.add_task(task); } - pub async fn sort_rows(&mut self, rows: &mut Vec>) { - if self.sorts.is_empty() { - return; - } + pub async fn sort_rows_and_notify(&mut self, rows: &mut Vec>) { + self.sort_rows(rows).await; + let row_orders = rows + .iter() + .map(|row| row.id.to_string()) + .collect::>(); + let notification = ReorderAllRowsResult { + view_id: self.view_id.clone(), + row_orders, + }; + + let _ = self + .notifier + .send(DatabaseViewChanged::ReorderAllRowsNotification( + notification, + )); + } + + pub async fn sort_rows(&mut self, rows: &mut Vec>) { let fields = self.delegate.get_fields(&self.view_id, None).await; for sort in self.sorts.iter().rev() { - rows - .par_sort_by(|left, right| cmp_row(&left.row, &right.row, sort, &fields, &self.cell_cache)); + rows.par_sort_by(|left, right| cmp_row(left, right, sort, &fields, &self.cell_cache)); } - rows.iter().enumerate().for_each(|(index, row_detail)| { - self - .row_index_cache - .insert(row_detail.row.id.clone(), index); + rows.iter().enumerate().for_each(|(index, row)| { + self.row_index_cache.insert(row.id.clone(), index); }); } @@ -320,10 +306,10 @@ fn cmp_row( (left.modified_at, right.modified_at) }; let (left_cell, right_cell) = ( - TimestampCellDataWrapper::from((field_type, TimestampCellData::new(left_cell))), - TimestampCellDataWrapper::from((field_type, TimestampCellData::new(right_cell))), + TimestampCellData::new(left_cell).to_cell(field_rev.field_type), + TimestampCellData::new(right_cell).to_cell(field_rev.field_type), ); - Some((Some(left_cell.into()), Some(right_cell.into()))) + Some((Some(left_cell), Some(right_cell))) }, _ => None, }; @@ -362,12 +348,11 @@ fn cmp_cell( enum SortEvent { SortDidChanged, RowDidChanged(RowId), - NewRowInserted(RowDetail), DeleteAllSorts, } -impl ToString for SortEvent { - fn to_string(&self) -> String { +impl SortEvent { + fn to_json_string(&self) -> String { serde_json::to_string(self).unwrap() } } diff --git a/frontend/rust-lib/flowy-database2/src/services/sort/entities.rs b/frontend/rust-lib/flowy-database2/src/services/sort/entities.rs index 9f9d37d4fb..9d66ece5cc 100644 --- a/frontend/rust-lib/flowy-database2/src/services/sort/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/sort/entities.rs @@ -1,8 +1,9 @@ use std::cmp::Ordering; use anyhow::bail; -use collab::core::any_map::AnyMapExtension; -use collab_database::rows::{RowDetail, RowId}; +use collab::preclude::Any; +use collab::util::AnyMapExt; +use collab_database::rows::RowId; use collab_database::views::{SortMap, SortMapBuilder}; #[derive(Debug, Clone)] @@ -20,10 +21,13 @@ impl TryFrom for Sort { type Error = anyhow::Error; fn try_from(value: SortMap) -> Result { - match (value.get_str_value(SORT_ID), value.get_str_value(FIELD_ID)) { + match ( + value.get_as::(SORT_ID), + value.get_as::(FIELD_ID), + ) { (Some(id), Some(field_id)) => { let condition = value - .get_i64_value(SORT_CONDITION) + .get_as::(SORT_CONDITION) .map(SortCondition::from) .unwrap_or_default(); Ok(Self { @@ -41,11 +45,11 @@ impl TryFrom for Sort { impl From for SortMap { fn from(data: Sort) -> Self { - SortMapBuilder::new() - .insert_str_value(SORT_ID, data.id) - .insert_str_value(FIELD_ID, data.field_id) - .insert_i64_value(SORT_CONDITION, data.condition.value()) - .build() + SortMapBuilder::from([ + (SORT_ID.into(), data.id.into()), + (FIELD_ID.into(), data.field_id.into()), + (SORT_CONDITION.into(), Any::BigInt(data.condition.value())), + ]) } } @@ -83,7 +87,7 @@ impl From for SortCondition { } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct ReorderAllRowsResult { pub view_id: String, pub row_orders: Vec, @@ -98,7 +102,7 @@ impl ReorderAllRowsResult { } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct ReorderSingleRowResult { pub view_id: String, pub row_id: RowId, @@ -106,13 +110,6 @@ pub struct ReorderSingleRowResult { pub new_index: usize, } -#[derive(Clone)] -pub struct InsertRowResult { - pub view_id: String, - pub row: RowDetail, - pub index: usize, -} - #[derive(Debug, Default)] pub struct SortChangeset { pub(crate) insert_sort: Option, diff --git a/frontend/rust-lib/flowy-database2/src/services/sort/task.rs b/frontend/rust-lib/flowy-database2/src/services/sort/task.rs index 107f318dec..6b77e87a33 100644 --- a/frontend/rust-lib/flowy-database2/src/services/sort/task.rs +++ b/frontend/rust-lib/flowy-database2/src/services/sort/task.rs @@ -1,5 +1,6 @@ use crate::services::sort::SortController; -use lib_infra::future::BoxResultFuture; +use async_trait::async_trait; + use lib_infra::priority_task::{TaskContent, TaskHandler}; use std::sync::Arc; use tokio::sync::RwLock; @@ -19,6 +20,7 @@ impl SortTaskHandler { } } +#[async_trait] impl TaskHandler for SortTaskHandler { fn handler_id(&self) -> &str { &self.handler_id @@ -28,18 +30,16 @@ impl TaskHandler for SortTaskHandler { "SortTaskHandler" } - fn run(&self, content: TaskContent) -> BoxResultFuture<(), anyhow::Error> { + async fn run(&self, content: TaskContent) -> Result<(), anyhow::Error> { let sort_controller = self.sort_controller.clone(); - Box::pin(async move { - if let TaskContent::Text(predicate) = content { - sort_controller - .write() - .await - .process(&predicate) - .await - .map_err(anyhow::Error::from)?; - } - Ok(()) - }) + if let TaskContent::Text(predicate) = content { + sort_controller + .write() + .await + .process(&predicate) + .await + .map_err(anyhow::Error::from)?; + } + Ok(()) } } diff --git a/frontend/rust-lib/flowy-database2/src/template.rs b/frontend/rust-lib/flowy-database2/src/template.rs index c0347be580..32d8df5062 100644 --- a/frontend/rust-lib/flowy-database2/src/template.rs +++ b/frontend/rust-lib/flowy-database2/src/template.rs @@ -1,14 +1,14 @@ use collab_database::database::{gen_database_id, gen_row_id, timestamp}; -use collab_database::rows::CreateRowParams; -use collab_database::views::{ - CreateDatabaseParams, CreateViewParams, DatabaseLayout, LayoutSettings, +use collab_database::entity::{CreateDatabaseParams, CreateViewParams}; +use collab_database::fields::select_type_option::{ + SelectOption, SelectOptionColor, SingleSelectTypeOption, }; +use collab_database::rows::CreateRowParams; +use collab_database::views::{DatabaseLayout, LayoutSettings}; use crate::entities::FieldType; use crate::services::cell::{insert_select_option_cell, insert_text_cell}; -use crate::services::field::{ - FieldBuilder, SelectOption, SelectOptionColor, SingleSelectTypeOption, -}; +use crate::services::field::FieldBuilder; use crate::services::field_settings::default_field_settings_for_fields; use crate::services::setting::{BoardLayoutSetting, CalendarLayoutSetting}; @@ -35,7 +35,6 @@ pub fn make_default_grid(view_id: &str, name: &str) -> CreateDatabaseParams { CreateDatabaseParams { database_id: database_id.clone(), - inline_view_id: view_id.to_string(), views: vec![CreateViewParams { database_id: database_id.clone(), view_id: view_id.to_string(), @@ -106,7 +105,6 @@ pub fn make_default_board(view_id: &str, name: &str) -> CreateDatabaseParams { CreateDatabaseParams { database_id: database_id.clone(), - inline_view_id: view_id.to_string(), views: vec![CreateViewParams { database_id, view_id: view_id.to_string(), @@ -159,7 +157,6 @@ pub fn make_default_calendar(view_id: &str, name: &str) -> CreateDatabaseParams CreateDatabaseParams { database_id: database_id.clone(), - inline_view_id: view_id.to_string(), views: vec![CreateViewParams { database_id, view_id: view_id.to_string(), diff --git a/frontend/rust-lib/flowy-database2/src/utils/cache.rs b/frontend/rust-lib/flowy-database2/src/utils/cache.rs index 5f9bda50c9..840bdbb1b4 100644 --- a/frontend/rust-lib/flowy-database2/src/utils/cache.rs +++ b/frontend/rust-lib/flowy-database2/src/utils/cache.rs @@ -1,23 +1,25 @@ -use parking_lot::RwLock; +use dashmap::mapref::one::{MappedRef, MappedRefMut}; +use dashmap::DashMap; use std::any::{type_name, Any}; -use std::collections::HashMap; use std::fmt::Debug; use std::hash::Hash; use std::sync::Arc; #[derive(Default, Debug)] /// The better option is use LRU cache -pub struct AnyTypeCache(HashMap); - -impl AnyTypeCache +pub struct AnyTypeCache(DashMap) where - TypeValueKey: Clone + Hash + Eq, + K: Clone + Hash + Eq; + +impl AnyTypeCache +where + K: Clone + Hash + Eq, { - pub fn new() -> Arc>> { - Arc::new(RwLock::new(AnyTypeCache(HashMap::default()))) + pub fn new() -> Arc> { + Arc::new(AnyTypeCache(DashMap::default())) } - pub fn insert(&mut self, key: &TypeValueKey, val: T) -> Option + pub fn insert(&self, key: &K, val: T) -> Option where T: 'static + Send + Sync, { @@ -27,31 +29,27 @@ where .and_then(downcast_owned) } - pub fn remove(&mut self, key: &TypeValueKey) { + pub fn remove(&self, key: &K) { self.0.remove(key); } - pub fn get(&self, key: &TypeValueKey) -> Option<&T> + pub fn get(&self, key: &K) -> Option> where T: 'static + Send + Sync, { - self - .0 - .get(key) - .and_then(|type_value| type_value.boxed.downcast_ref()) + let cell = self.0.get(key)?; + cell.try_map(|v| v.boxed.downcast_ref()).ok() } - pub fn get_mut(&mut self, key: &TypeValueKey) -> Option<&mut T> + pub fn get_mut(&self, key: &K) -> Option> where T: 'static + Send + Sync, { - self - .0 - .get_mut(key) - .and_then(|type_value| type_value.boxed.downcast_mut()) + let cell = self.0.get_mut(key)?; + cell.try_map(|v| v.boxed.downcast_mut()).ok() } - pub fn contains(&self, key: &TypeValueKey) -> bool { + pub fn contains(&self, key: &K) -> bool { self.0.contains_key(key) } @@ -65,7 +63,7 @@ fn downcast_owned(type_value: TypeValue) -> Option } #[derive(Debug)] -struct TypeValue { +pub struct TypeValue { boxed: Box, #[allow(dead_code)] ty: &'static str, diff --git a/frontend/rust-lib/flowy-database2/tests/database/block_test/row_test.rs b/frontend/rust-lib/flowy-database2/tests/database/block_test/row_test.rs index 648de5edc7..08ce2e954e 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/block_test/row_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/block_test/row_test.rs @@ -1,27 +1,27 @@ +use collab_database::fields::date_type_option::DateCellData; +use flowy_database2::entities::FieldType; +use lib_infra::util::timestamp; use std::time::Duration; -use flowy_database2::entities::FieldType; -use flowy_database2::services::field::DateCellData; -use lib_infra::util::timestamp; - use crate::database::block_test::script::DatabaseRowTest; -use crate::database::block_test::script::RowScript::*; -// Create a new row at the end of the grid and check the create time is valid. #[tokio::test] async fn created_at_field_test() { let mut test = DatabaseRowTest::new().await; - let row_count = test.row_details.len(); - test - .run_scripts(vec![CreateEmptyRow, AssertRowCount(row_count + 1)]) - .await; + + // Get initial row count + let row_count = test.rows.len(); + + // Create a new row and assert the row count has increased by 1 + test.create_empty_row().await; + test.assert_row_count(row_count + 1).await; // Get created time of the new row. - let row_detail = test.get_rows().await.last().cloned().unwrap(); - let updated_at_field = test.get_first_field(FieldType::CreatedTime); + let row = test.get_rows().await.last().cloned().unwrap(); + let created_at_field = test.get_first_field(FieldType::CreatedTime).await; let cell = test .editor - .get_cell(&updated_at_field.id, &row_detail.row.id) + .get_cell(&created_at_field.id, &row.id) .await .unwrap(); let created_at_timestamp = DateCellData::from(&cell).timestamp.unwrap(); @@ -30,35 +30,35 @@ async fn created_at_field_test() { assert!(created_at_timestamp <= timestamp()); } -// Update row and check the update time is valid. #[tokio::test] async fn update_at_field_test() { let mut test = DatabaseRowTest::new().await; - let row_detail = test.get_rows().await.remove(0); - let last_edit_field = test.get_first_field(FieldType::LastEditedTime); + + // Get the first row and the current LastEditedTime field + let row = test.get_rows().await.remove(0); + let last_edit_field = test.get_first_field(FieldType::LastEditedTime).await; let cell = test .editor - .get_cell(&last_edit_field.id, &row_detail.row.id) + .get_cell(&last_edit_field.id, &row.id) .await .unwrap(); let old_updated_at = DateCellData::from(&cell).timestamp.unwrap(); + // Wait for 1 second before updating the row tokio::time::sleep(Duration::from_millis(1000)).await; - test - .run_script(UpdateTextCell { - row_id: row_detail.row.id.clone(), - content: "test".to_string(), - }) - .await; - // Get the updated time of the row. - let row_detail = test.get_rows().await.remove(0); - let last_edit_field = test.get_first_field(FieldType::LastEditedTime); + // Update the text cell of the first row + test.update_text_cell(row.id.clone(), "test").await; + + // Get the updated time of the row + let row = test.get_rows().await.remove(0); + let last_edit_field = test.get_first_field(FieldType::LastEditedTime).await; let cell = test .editor - .get_cell(&last_edit_field.id, &row_detail.row.id) + .get_cell(&last_edit_field.id, &row.id) .await .unwrap(); let new_updated_at = DateCellData::from(&cell).timestamp.unwrap(); + assert!(old_updated_at < new_updated_at); } diff --git a/frontend/rust-lib/flowy-database2/tests/database/block_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/block_test/script.rs index 72b62b55df..b8035767ef 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/block_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/block_test/script.rs @@ -1,14 +1,6 @@ -use collab_database::rows::RowId; - -use flowy_database2::entities::CreateRowPayloadPB; - use crate::database::database_editor::DatabaseEditorTest; - -pub enum RowScript { - CreateEmptyRow, - UpdateTextCell { row_id: RowId, content: String }, - AssertRowCount(usize), -} +use collab_database::rows::RowId; +use flowy_database2::entities::CreateRowPayloadPB; pub struct DatabaseRowTest { inner: DatabaseEditorTest, @@ -20,32 +12,24 @@ impl DatabaseRowTest { Self { inner: editor_test } } - pub async fn run_scripts(&mut self, scripts: Vec) { - for script in scripts { - self.run_script(script).await; - } + pub async fn create_empty_row(&mut self) { + let params = CreateRowPayloadPB { + view_id: self.view_id.clone(), + ..Default::default() + }; + let row_detail = self.editor.create_row(params).await.unwrap().unwrap(); + self + .row_by_row_id + .insert(row_detail.row.id.to_string(), row_detail.into()); + self.rows = self.get_rows().await; } - pub async fn run_script(&mut self, script: RowScript) { - match script { - RowScript::CreateEmptyRow => { - let params = CreateRowPayloadPB { - view_id: self.view_id.clone(), - ..Default::default() - }; - let row_detail = self.editor.create_row(params).await.unwrap().unwrap(); - self - .row_by_row_id - .insert(row_detail.row.id.to_string(), row_detail.into()); - self.row_details = self.get_rows().await; - }, - RowScript::UpdateTextCell { row_id, content } => { - self.update_text_cell(row_id, &content).await.unwrap(); - }, - RowScript::AssertRowCount(expected_row_count) => { - assert_eq!(expected_row_count, self.row_details.len()); - }, - } + pub async fn update_text_cell(&mut self, row_id: RowId, content: &str) { + self.inner.update_text_cell(row_id, content).await.unwrap(); + } + + pub async fn assert_row_count(&self, expected_row_count: usize) { + assert_eq!(expected_row_count, self.rows.len()); } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/calculations_test/calculation_test.rs b/frontend/rust-lib/flowy-database2/tests/database/calculations_test/calculation_test.rs index 2800596900..23e70976e2 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/calculations_test/calculation_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/calculations_test/calculation_test.rs @@ -1,21 +1,21 @@ use std::sync::Arc; -use crate::database::calculations_test::script::{CalculationScript::*, DatabaseCalculationTest}; - +use crate::database::calculations_test::script::DatabaseCalculationTest; use collab_database::fields::Field; use flowy_database2::entities::{CalculationType, FieldType, UpdateCalculationChangesetPB}; +use lib_infra::box_any::BoxAny; #[tokio::test] async fn calculations_test() { let mut test = DatabaseCalculationTest::new().await; - let expected_sum = 25.00000; - let expected_min = 1.00000; - let expected_average = 5.00000; - let expected_max = 14.00000; - let expected_median = 3.00000; + let expected_sum = 25.00; + let expected_min = 1.00; + let expected_average = 5.00; + let expected_max = 14.00; + let expected_median = 3.00; - let view_id = &test.view_id; + let view_id = &test.view_id(); let number_fields = test .fields .clone() @@ -25,63 +25,171 @@ async fn calculations_test() { let field_id = &number_fields.first().unwrap().id; let calculation_id = "calc_id".to_owned(); - let scripts = vec![ - // Insert Sum calculation first time - InsertCalculation { - payload: UpdateCalculationChangesetPB { - view_id: view_id.to_owned(), - field_id: field_id.to_owned(), - calculation_id: Some(calculation_id.clone()), - calculation_type: CalculationType::Sum, - }, - }, - AssertCalculationValue { - expected: expected_sum, - }, - InsertCalculation { - payload: UpdateCalculationChangesetPB { - view_id: view_id.to_owned(), - field_id: field_id.to_owned(), - calculation_id: Some(calculation_id.clone()), - calculation_type: CalculationType::Min, - }, - }, - AssertCalculationValue { - expected: expected_min, - }, - InsertCalculation { - payload: UpdateCalculationChangesetPB { - view_id: view_id.to_owned(), - field_id: field_id.to_owned(), - calculation_id: Some(calculation_id.clone()), - calculation_type: CalculationType::Average, - }, - }, - AssertCalculationValue { - expected: expected_average, - }, - InsertCalculation { - payload: UpdateCalculationChangesetPB { - view_id: view_id.to_owned(), - field_id: field_id.to_owned(), - calculation_id: Some(calculation_id.clone()), - calculation_type: CalculationType::Max, - }, - }, - AssertCalculationValue { - expected: expected_max, - }, - InsertCalculation { - payload: UpdateCalculationChangesetPB { - view_id: view_id.to_owned(), - field_id: field_id.to_owned(), - calculation_id: Some(calculation_id), - calculation_type: CalculationType::Median, - }, - }, - AssertCalculationValue { - expected: expected_median, - }, - ]; - test.run_scripts(scripts).await; + + // Insert Sum calculation and assert its value + test + .insert_calculation(UpdateCalculationChangesetPB { + view_id: view_id.to_owned(), + field_id: field_id.to_owned(), + calculation_id: Some(calculation_id.clone()), + calculation_type: CalculationType::Sum, + }) + .await; + + test.assert_calculation_float_value(expected_sum).await; + + // Insert Min calculation and assert its value + test + .insert_calculation(UpdateCalculationChangesetPB { + view_id: view_id.to_owned(), + field_id: field_id.to_owned(), + calculation_id: Some(calculation_id.clone()), + calculation_type: CalculationType::Min, + }) + .await; + + test.assert_calculation_float_value(expected_min).await; + + // Insert Average calculation and assert its value + test + .insert_calculation(UpdateCalculationChangesetPB { + view_id: view_id.to_owned(), + field_id: field_id.to_owned(), + calculation_id: Some(calculation_id.clone()), + calculation_type: CalculationType::Average, + }) + .await; + + test.assert_calculation_float_value(expected_average).await; + + // Insert Max calculation and assert its value + test + .insert_calculation(UpdateCalculationChangesetPB { + view_id: view_id.to_owned(), + field_id: field_id.to_owned(), + calculation_id: Some(calculation_id.clone()), + calculation_type: CalculationType::Max, + }) + .await; + + test.assert_calculation_float_value(expected_max).await; + + // Insert Median calculation and assert its value + test + .insert_calculation(UpdateCalculationChangesetPB { + view_id: view_id.to_owned(), + field_id: field_id.to_owned(), + calculation_id: Some(calculation_id.clone()), + calculation_type: CalculationType::Median, + }) + .await; + + test.assert_calculation_float_value(expected_median).await; +} + +#[tokio::test] +async fn calculations_empty_test() { + let mut test = DatabaseCalculationTest::new().await; + + let view_id = &test.view_id(); + let text_fields = test + .fields + .clone() + .into_iter() + .filter(|field| field.field_type == FieldType::RichText as i64) + .collect::>>(); + let field_id = &text_fields.first().unwrap().id.clone(); + let calculation_id = "calc_id".to_owned(); + + test + .insert_calculation(UpdateCalculationChangesetPB { + view_id: view_id.clone(), + field_id: field_id.clone(), + calculation_id: Some(calculation_id.clone()), + calculation_type: CalculationType::CountEmpty, + }) + .await; + test.assert_calculation_value("1").await; + + // Update the cell with a non-empty value + test + .update_cell( + field_id, + test.rows[1].id.clone(), + BoxAny::new("change".to_string()), + ) + .await + .unwrap(); + + // sleep for 3 seconds to wait for the calculation to update + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + test.assert_calculation_value("0").await; +} + +#[tokio::test] +async fn calculations_non_empty_test() { + let mut test = DatabaseCalculationTest::new().await; + + let view_id = &test.view_id(); + let text_fields = test + .fields + .clone() + .into_iter() + .filter(|field| field.field_type == FieldType::RichText as i64) + .collect::>>(); + let field_id = &text_fields.first().unwrap().id.clone(); + let calculation_id = "calc_id".to_owned(); + + test + .insert_calculation(UpdateCalculationChangesetPB { + view_id: view_id.clone(), + field_id: field_id.clone(), + calculation_id: Some(calculation_id.clone()), + calculation_type: CalculationType::CountNonEmpty, + }) + .await; + test.assert_calculation_value("6").await; + + // Update the cell with a non-empty value + test + .update_cell( + field_id, + test.rows[1].id.clone(), + BoxAny::new("change".to_string()), + ) + .await + .unwrap(); + + // sleep for 3 seconds to wait for the calculation to update + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + test.assert_calculation_value("7").await; +} + +#[tokio::test] +async fn calculations_count_test() { + let mut test = DatabaseCalculationTest::new().await; + + let view_id = &test.view_id(); + let text_fields = test + .fields + .clone() + .into_iter() + .filter(|field| field.field_type == FieldType::RichText as i64) + .collect::>>(); + let field_id = &text_fields.first().unwrap().id.clone(); + let calculation_id = "calc_id".to_owned(); + + test + .insert_calculation(UpdateCalculationChangesetPB { + view_id: view_id.clone(), + field_id: field_id.clone(), + calculation_id: Some(calculation_id.clone()), + calculation_type: CalculationType::Count, + }) + .await; + test.assert_calculation_value("7").await; + test.duplicate_row(&test.rows[1].id).await; + + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + test.assert_calculation_value("8").await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/calculations_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/calculations_test/script.rs index 978acd8463..d11ca64c4a 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/calculations_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/calculations_test/script.rs @@ -1,3 +1,4 @@ +use collab_database::rows::RowId; use tokio::sync::broadcast::Receiver; use flowy_database2::entities::UpdateCalculationChangesetPB; @@ -5,15 +6,6 @@ use flowy_database2::services::database_view::DatabaseViewChanged; use crate::database::database_editor::DatabaseEditorTest; -pub enum CalculationScript { - InsertCalculation { - payload: UpdateCalculationChangesetPB, - }, - AssertCalculationValue { - expected: f64, - }, -} - pub struct DatabaseCalculationTest { inner: DatabaseEditorTest, recv: Option>, @@ -32,30 +24,35 @@ impl DatabaseCalculationTest { self.view_id.clone() } - pub async fn run_scripts(&mut self, scripts: Vec) { - for script in scripts { - self.run_script(script).await; - } + pub async fn insert_calculation(&mut self, payload: UpdateCalculationChangesetPB) { + self.recv = Some( + self + .editor + .subscribe_view_changed(&self.view_id()) + .await + .unwrap(), + ); + self.editor.update_calculation(payload).await.unwrap(); } - pub async fn run_script(&mut self, script: CalculationScript) { - match script { - CalculationScript::InsertCalculation { payload } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); - self.editor.update_calculation(payload).await.unwrap(); - }, - CalculationScript::AssertCalculationValue { expected } => { - let calculations = self.editor.get_all_calculations(&self.view_id()).await; - let calculation = calculations.items.first().unwrap(); - assert_eq!(calculation.value, format!("{:.5}", expected)); - }, - } + pub async fn assert_calculation_float_value(&mut self, expected: f64) { + let calculations = self.editor.get_all_calculations(&self.view_id()).await; + let calculation = calculations.items.first().unwrap(); + assert_eq!(calculation.value, format!("{:.2}", expected)); + } + + pub async fn assert_calculation_value(&mut self, expected: &str) { + let calculations = self.editor.get_all_calculations(&self.view_id()).await; + let calculation = calculations.items.first().unwrap(); + assert_eq!(calculation.value, expected); + } + + pub async fn duplicate_row(&self, row_id: &RowId) { + self + .editor + .duplicate_row(&self.view_id, row_id) + .await + .unwrap(); } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/cell_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/cell_test/script.rs index 833ce832b5..68b3ebe8ca 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/cell_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/cell_test/script.rs @@ -1,18 +1,6 @@ -use collab_database::rows::RowId; - -use lib_infra::box_any::BoxAny; - use crate::database::database_editor::DatabaseEditorTest; - -pub enum CellScript { - UpdateCell { - view_id: String, - field_id: String, - row_id: RowId, - changeset: BoxAny, - is_err: bool, - }, -} +use collab_database::rows::RowId; +use lib_infra::box_any::BoxAny; pub struct DatabaseCellTest { inner: DatabaseEditorTest, @@ -24,41 +12,18 @@ impl DatabaseCellTest { Self { inner } } - pub async fn run_scripts(&mut self, scripts: Vec) { - for script in scripts { - self.run_script(script).await; - } - } - - pub async fn run_script(&mut self, script: CellScript) { - // let grid_manager = self.sdk.grid_manager.clone(); - // let pool = self.sdk.user_session.db_pool().unwrap(); - // let rev_manager = self.editor.rev_manager(); - // let _cache = rev_manager.revision_cache().await; - - match script { - CellScript::UpdateCell { - view_id, - field_id, - row_id, - changeset, - is_err: _, - } => { - self - .editor - .update_cell_with_changeset(&view_id, &row_id, &field_id, changeset) - .await - .unwrap(); - }, - // CellScript::AssertGridRevisionPad => { - // sleep(Duration::from_millis(2 * REVISION_WRITE_INTERVAL_IN_MILLIS)).await; - // let mut grid_rev_manager = grid_manager - // .make_grid_rev_manager(&self.grid_id, pool.clone()) - // .unwrap(); - // let grid_pad = grid_rev_manager.load::(None).await.unwrap(); - // println!("{}", grid_pad.delta_str()); - // }, - } + pub async fn update_cell( + &self, + view_id: &str, + field_id: &str, + row_id: &RowId, + changeset: BoxAny, + ) { + self + .editor + .update_cell_with_changeset(view_id, row_id, field_id, changeset) + .await + .unwrap(); } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs index 2ed9db16ff..379d558b8e 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs @@ -1,24 +1,27 @@ -use std::time::Duration; - -use flowy_database2::entities::FieldType; +use crate::database::cell_test::script::DatabaseCellTest; +use collab_database::fields::date_type_option::DateCellData; +use collab_database::fields::media_type_option::{MediaFile, MediaFileType, MediaUploadType}; +use collab_database::fields::select_type_option::{MultiSelectTypeOption, SingleSelectTypeOption}; +use collab_database::fields::url_type_option::URLCellData; +use collab_database::template::time_parse::TimeCellData; +use flowy_database2::entities::{FieldType, MediaCellChangeset}; +use flowy_database2::services::field::checklist_filter::{ + ChecklistCellChangeset, ChecklistCellInsertChangeset, +}; +use flowy_database2::services::field::date_filter::DateCellChangeset; use flowy_database2::services::field::{ - ChecklistCellChangeset, DateCellChangeset, DateCellData, MultiSelectTypeOption, - RelationCellChangeset, SelectOptionCellChangeset, SingleSelectTypeOption, StringCellData, - URLCellData, + RelationCellChangeset, SelectOptionCellChangeset, StringCellData, }; use lib_infra::box_any::BoxAny; - -use crate::database::cell_test::script::CellScript::UpdateCell; -use crate::database::cell_test::script::DatabaseCellTest; +use std::time::Duration; #[tokio::test] async fn grid_cell_update() { - let mut test = DatabaseCellTest::new().await; - let fields = test.get_fields(); - let rows = &test.row_details; + let test = DatabaseCellTest::new().await; + let fields = test.get_fields().await; + let rows = &test.rows; - let mut scripts = vec![]; - for row_detail in rows.iter() { + for row in rows.iter() { for field in &fields { let field_type = FieldType::from(field.field_type); if field_type == FieldType::LastEditedTime || field_type == FieldType::CreatedTime { @@ -28,7 +31,7 @@ async fn grid_cell_update() { FieldType::RichText => BoxAny::new("".to_string()), FieldType::Number => BoxAny::new("123".to_string()), FieldType::DateTime => BoxAny::new(DateCellChangeset { - date: Some(123), + timestamp: Some(123), ..Default::default() }), FieldType::SingleSelect => { @@ -48,7 +51,10 @@ async fn grid_cell_update() { )) }, FieldType::Checklist => BoxAny::new(ChecklistCellChangeset { - insert_options: vec![("new option".to_string(), false)], + insert_tasks: vec![ChecklistCellInsertChangeset::new( + "new option".to_string(), + false, + )], ..Default::default() }), FieldType::Checkbox => BoxAny::new("1".to_string()), @@ -57,26 +63,31 @@ async fn grid_cell_update() { inserted_row_ids: vec!["abcdefabcdef".to_string().into()], ..Default::default() }), + FieldType::Media => BoxAny::new(MediaCellChangeset { + inserted_files: vec![MediaFile { + id: "abcdefghijk".to_string(), + name: "link".to_string(), + url: "https://www.appflowy.io".to_string(), + file_type: MediaFileType::Link, + upload_type: MediaUploadType::Network, + }], + removed_ids: vec![], + }), _ => BoxAny::new("".to_string()), }; - scripts.push(UpdateCell { - view_id: test.view_id.clone(), - field_id: field.id.clone(), - row_id: row_detail.row.id.clone(), - changeset: cell_changeset, - is_err: false, - }); + // Call the new `update_cell` function directly + test + .update_cell(&test.view_id, &field.id, &row.id, cell_changeset) + .await; } } - - test.run_scripts(scripts).await; } #[tokio::test] async fn text_cell_data_test() { let test = DatabaseCellTest::new().await; - let text_field = test.get_first_field(FieldType::RichText); + let text_field = test.get_first_field(FieldType::RichText).await; let cells = test .editor @@ -100,7 +111,7 @@ async fn text_cell_data_test() { #[tokio::test] async fn url_cell_data_test() { let test = DatabaseCellTest::new().await; - let url_field = test.get_first_field(FieldType::URL); + let url_field = test.get_first_field(FieldType::URL).await; let cells = test .editor .get_cells_for_field(&test.view_id, &url_field.id) @@ -121,8 +132,8 @@ async fn url_cell_data_test() { #[tokio::test] async fn update_updated_at_field_on_other_cell_update() { - let mut test = DatabaseCellTest::new().await; - let updated_at_field = test.get_first_field(FieldType::LastEditedTime); + let test = DatabaseCellTest::new().await; + let updated_at_field = test.get_first_field(FieldType::LastEditedTime).await; let text_field = test .fields @@ -131,14 +142,15 @@ async fn update_updated_at_field_on_other_cell_update() { .unwrap(); let before_update_timestamp = chrono::offset::Utc::now().timestamp(); + + // Directly call the `update_cell` function test - .run_script(UpdateCell { - view_id: test.view_id.clone(), - row_id: test.row_details[0].row.id.clone(), - field_id: text_field.id.clone(), - changeset: BoxAny::new("change".to_string()), - is_err: false, - }) + .update_cell( + &test.view_id, + &text_field.id, + &test.rows[0].id, + BoxAny::new("change".to_string()), + ) .await; let cells = test @@ -166,37 +178,29 @@ async fn update_updated_at_field_on_other_cell_update() { timestamp, after_update_timestamp ), - 1 => assert!( + _ => assert!( timestamp <= before_update_timestamp, "{} <= {}", timestamp, before_update_timestamp ), - 2 => assert!( - timestamp <= before_update_timestamp, - "{} <= {}", - timestamp, - before_update_timestamp - ), - 3 => assert!( - timestamp <= before_update_timestamp, - "{} <= {}", - timestamp, - before_update_timestamp - ), - 4 => assert!( - timestamp <= before_update_timestamp, - "{} <= {}", - timestamp, - before_update_timestamp - ), - 5 => assert!( - timestamp <= before_update_timestamp, - "{} <= {}", - timestamp, - before_update_timestamp - ), - _ => {}, } } } + +#[tokio::test] +async fn time_cell_data_test() { + let test = DatabaseCellTest::new().await; + let time_field = test.get_first_field(FieldType::Time).await; + let cells = test + .editor + .get_cells_for_field(&test.view_id, &time_field.id) + .await; + + if let Some(cell) = cells[0].cell.as_ref() { + let cell = TimeCellData::from(cell); + + assert!(cell.0.is_some()); + assert_eq!(cell.0.unwrap_or_default(), 75); + } +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/database_editor.rs b/frontend/rust-lib/flowy-database2/tests/database/database_editor.rs index 2d087cce00..852a4ea591 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/database_editor.rs @@ -2,23 +2,23 @@ use std::collections::HashMap; use std::sync::Arc; use collab_database::database::gen_database_view_id; +use collab_database::fields::checkbox_type_option::CheckboxTypeOption; +use collab_database::fields::checklist_type_option::ChecklistTypeOption; +use collab_database::fields::select_type_option::{ + MultiSelectTypeOption, SelectOption, SingleSelectTypeOption, +}; use collab_database::fields::Field; -use collab_database::rows::{RowDetail, RowId}; +use collab_database::rows::{Row, RowId}; use lib_infra::box_any::BoxAny; use strum::EnumCount; use event_integration_test::folder_event::ViewTest; use event_integration_test::EventIntegrationTest; -use flowy_database2::entities::{FieldType, FilterPB, RowMetaPB}; +use flowy_database2::entities::{DatabasePB, FieldType, FilterPB, RowMetaPB}; use flowy_database2::services::database::DatabaseEditor; -use flowy_database2::services::field::checklist_type_option::{ - ChecklistCellChangeset, ChecklistTypeOption, -}; -use flowy_database2::services::field::{ - CheckboxTypeOption, MultiSelectTypeOption, SelectOption, SelectOptionCellChangeset, - SingleSelectTypeOption, -}; +use flowy_database2::services::field::checklist_filter::ChecklistCellChangeset; +use flowy_database2::services::field::SelectOptionCellChangeset; use flowy_database2::services::share::csv::{CSVFormat, ImportResult}; use flowy_error::FlowyResult; @@ -31,7 +31,7 @@ pub struct DatabaseEditorTest { pub view_id: String, pub editor: Arc, pub fields: Vec>, - pub row_details: Vec>, + pub rows: Vec>, pub field_count: usize, pub row_by_row_id: HashMap, } @@ -76,45 +76,54 @@ impl DatabaseEditorTest { pub async fn new(sdk: EventIntegrationTest, test: ViewTest) -> Self { let editor = sdk .database_manager - .get_database_with_view_id(&test.child_view.id) + .get_database_editor_with_view_id(&test.child_view.id) .await .unwrap(); let fields = editor .get_fields(&test.child_view.id, None) + .await .into_iter() .map(Arc::new) .collect(); let rows = editor - .get_rows(&test.child_view.id) + .get_all_rows(&test.child_view.id) .await .unwrap() .into_iter() .collect(); let view_id = test.child_view.id; - Self { + let this = Self { sdk, - view_id, + view_id: view_id.clone(), editor, fields, - row_details: rows, + rows, field_count: FieldType::COUNT, row_by_row_id: HashMap::default(), - } + }; + this.get_database_data(&view_id).await; + this } + pub async fn get_database_data(&self, view_id: &str) -> DatabasePB { + self.editor.open_database_view(view_id, None).await.unwrap() + } + + #[allow(dead_code)] pub async fn database_filters(&self) -> Vec { self.editor.get_all_filters(&self.view_id).await.items } - pub async fn get_rows(&self) -> Vec> { - self.editor.get_rows(&self.view_id).await.unwrap() + pub async fn get_rows(&self) -> Vec> { + self.editor.get_all_rows(&self.view_id).await.unwrap() } - pub fn get_field(&self, field_id: &str, field_type: FieldType) -> Field { + pub async fn get_field(&self, field_id: &str, field_type: FieldType) -> Field { self .editor .get_fields(&self.view_id, None) + .await .into_iter() .filter(|field| { let t_field_type = FieldType::from(field.field_type); @@ -127,10 +136,11 @@ impl DatabaseEditorTest { /// returns the first `Field` in the build-in test grid. /// Not support duplicate `FieldType` in test grid yet. - pub fn get_first_field(&self, field_type: FieldType) -> Field { + pub async fn get_first_field(&self, field_type: FieldType) -> Field { self .editor .get_fields(&self.view_id, None) + .await .into_iter() .filter(|field| { let t_field_type = FieldType::from(field.field_type); @@ -141,48 +151,50 @@ impl DatabaseEditorTest { .unwrap() } - pub fn get_fields(&self) -> Vec { - self.editor.get_fields(&self.view_id, None) + pub async fn get_fields(&self) -> Vec { + self.editor.get_fields(&self.view_id, None).await } - pub fn get_multi_select_type_option(&self, field_id: &str) -> Vec { + pub async fn get_multi_select_type_option(&self, field_id: &str) -> Vec { let field_type = FieldType::MultiSelect; - let field = self.get_field(field_id, field_type); + let field = self.get_field(field_id, field_type).await; let type_option = field .get_type_option::(field_type) - .unwrap(); + .unwrap() + .0; type_option.options } - pub fn get_single_select_type_option(&self, field_id: &str) -> Vec { + pub async fn get_single_select_type_option(&self, field_id: &str) -> Vec { let field_type = FieldType::SingleSelect; - let field = self.get_field(field_id, field_type); + let field = self.get_field(field_id, field_type).await; let type_option = field .get_type_option::(field_type) - .unwrap(); + .unwrap() + .0; type_option.options } #[allow(dead_code)] - pub fn get_checklist_type_option(&self, field_id: &str) -> ChecklistTypeOption { + pub async fn get_checklist_type_option(&self, field_id: &str) -> ChecklistTypeOption { let field_type = FieldType::Checklist; - let field = self.get_field(field_id, field_type); + let field = self.get_field(field_id, field_type).await; field .get_type_option::(field_type) .unwrap() } #[allow(dead_code)] - pub fn get_checkbox_type_option(&self, field_id: &str) -> CheckboxTypeOption { + pub async fn get_checkbox_type_option(&self, field_id: &str) -> CheckboxTypeOption { let field_type = FieldType::Checkbox; - let field = self.get_field(field_id, field_type); + let field = self.get_field(field_id, field_type).await; field .get_type_option::(field_type) .unwrap() } pub async fn update_cell( - &mut self, + &self, field_id: &str, row_id: RowId, cell_changeset: BoxAny, @@ -190,6 +202,7 @@ impl DatabaseEditorTest { let field = self .editor .get_fields(&self.view_id, None) + .await .into_iter() .find(|field| field.id == field_id) .unwrap(); @@ -204,6 +217,7 @@ impl DatabaseEditorTest { let field = self .editor .get_fields(&self.view_id, None) + .await .iter() .find(|field| { let field_type = FieldType::from(field.field_type); @@ -225,6 +239,7 @@ impl DatabaseEditorTest { let field = self .editor .get_fields(&self.view_id, None) + .await .iter() .find(|field| { let field_type = FieldType::from(field.field_type); @@ -233,7 +248,7 @@ impl DatabaseEditorTest { .unwrap() .clone(); let cell_changeset = ChecklistCellChangeset { - selected_option_ids: selected_options, + completed_task_ids: selected_options, ..Default::default() }; self @@ -250,6 +265,7 @@ impl DatabaseEditorTest { let field = self .editor .get_fields(&self.view_id, None) + .await .iter() .find(|field| { let field_type = FieldType::from(field.field_type); @@ -277,7 +293,7 @@ impl DatabaseEditorTest { self .sdk .database_manager - .get_database(database_id) + .get_or_init_database_editor(database_id) .await .ok() } diff --git a/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/script.rs index b87684163b..c6755ef009 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/script.rs @@ -1,79 +1,102 @@ -use flowy_database2::entities::{FieldSettingsChangesetPB, FieldVisibility}; - use crate::database::database_editor::DatabaseEditorTest; +use collab_database::fields::{Field, TypeOptionData}; +use flowy_database2::entities::{CreateFieldParams, FieldChangesetPB, FieldType}; +use flowy_database2::services::cell::stringify_cell; -pub struct FieldSettingsTest { +pub struct DatabaseFieldTest { inner: DatabaseEditorTest, } -impl FieldSettingsTest { - pub async fn new_grid() -> Self { - let inner = DatabaseEditorTest::new_grid().await; - Self { inner } +impl DatabaseFieldTest { + pub async fn new() -> Self { + let editor_test = DatabaseEditorTest::new_grid().await; + Self { inner: editor_test } } - pub async fn new_board() -> Self { - let inner = DatabaseEditorTest::new_board().await; - Self { inner } + pub fn view_id(&self) -> String { + self.view_id.clone() } - pub async fn new_calendar() -> Self { - let inner = DatabaseEditorTest::new_calendar().await; - Self { inner } + pub fn field_count(&self) -> usize { + self.field_count } - pub async fn assert_field_settings( - &mut self, - field_ids: Vec, - visibility: FieldVisibility, - width: i32, - ) { - let field_settings = self - .editor - .get_field_settings(&self.view_id, field_ids) - .await - .unwrap(); + pub async fn create_field(&mut self, params: CreateFieldParams) { + self.field_count += 1; + let _ = self.editor.create_field_with_type_option(params).await; + let fields = self.editor.get_fields(&self.view_id, None).await; + assert_eq!(self.field_count, fields.len()); + } - for field_setting in field_settings { - assert_eq!(field_setting.width, width); - assert_eq!(field_setting.visibility, visibility); + pub async fn update_field(&mut self, changeset: FieldChangesetPB) { + self.editor.update_field(changeset).await.unwrap(); + } + + pub async fn delete_field(&mut self, field: Field) { + if self.editor.get_field(&field.id).await.is_some() { + self.field_count -= 1; } + + self.editor.delete_field(&field.id).await.unwrap(); + let fields = self.editor.get_fields(&self.view_id, None).await; + assert_eq!(self.field_count, fields.len()); } - pub async fn assert_all_field_settings(&mut self, visibility: FieldVisibility, width: i32) { - let field_settings = self - .editor - .get_all_field_settings(&self.view_id) - .await - .unwrap(); - - for field_setting in field_settings { - assert_eq!(field_setting.width, width); - assert_eq!(field_setting.visibility, visibility); - } - } - - pub async fn update_field_settings( + pub async fn switch_to_field( &mut self, + view_id: String, field_id: String, - visibility: Option, - width: Option, + new_field_type: FieldType, ) { - let params = FieldSettingsChangesetPB { - view_id: self.view_id.clone(), - field_id, - visibility, - width, - wrap_cell_content: None, - }; - let _ = self + self .editor - .update_field_settings_with_changeset(params) - .await; + .switch_to_field_type(&view_id, &field_id, new_field_type, None) + .await + .unwrap(); + } + + pub async fn update_type_option(&mut self, field_id: String, type_option: TypeOptionData) { + let old_field = self.editor.get_field(&field_id).await.unwrap(); + self + .editor + .update_field_type_option(&field_id, type_option, old_field) + .await + .unwrap(); + } + + pub async fn assert_field_count(&self, count: usize) { + assert_eq!(self.get_fields().await.len(), count); + } + + pub async fn assert_field_type_option_equal( + &self, + field_index: usize, + expected_type_option_data: TypeOptionData, + ) { + let fields = self.get_fields().await; + let field = &fields[field_index]; + let type_option_data = field.get_any_type_option(field.field_type).unwrap(); + assert_eq!(type_option_data, expected_type_option_data); + } + + pub async fn assert_cell_content( + &self, + field_id: String, + row_index: usize, + expected_content: String, + ) { + let field = self.editor.get_field(&field_id).await.unwrap(); + + let rows = self.editor.get_all_rows(&self.view_id()).await.unwrap(); + let row = rows.get(row_index).unwrap(); + + let cell = row.cells.get(&field_id).unwrap().clone(); + let content = stringify_cell(&cell, &field); + assert_eq!(content, expected_content); } } -impl std::ops::Deref for FieldSettingsTest { +impl std::ops::Deref for DatabaseFieldTest { type Target = DatabaseEditorTest; fn deref(&self) -> &Self::Target { @@ -81,7 +104,7 @@ impl std::ops::Deref for FieldSettingsTest { } } -impl std::ops::DerefMut for FieldSettingsTest { +impl std::ops::DerefMut for DatabaseFieldTest { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } diff --git a/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/test.rs index b550567699..ec2f188be8 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/field_settings_test/test.rs @@ -1,119 +1,253 @@ -use flowy_database2::entities::FieldType; -use flowy_database2::entities::FieldVisibility; -use flowy_database2::services::field_settings::DEFAULT_WIDTH; - -use crate::database::field_settings_test::script::FieldSettingsTest; - -/// Check default field settings for grid, kanban and calendar -#[tokio::test] -async fn get_default_grid_field_settings() { - // grid - let mut test = FieldSettingsTest::new_grid().await; - test - .assert_all_field_settings(FieldVisibility::AlwaysShown, DEFAULT_WIDTH) - .await; -} +use crate::database::field_settings_test::script::DatabaseFieldTest; +use crate::database::field_test::util::*; +use collab_database::database::gen_option_id; +use collab_database::fields::select_type_option::SingleSelectTypeOption; +use collab_database::fields::select_type_option::{SelectOption, SelectTypeOption}; +use flowy_database2::entities::{FieldChangesetPB, FieldType}; +use flowy_database2::services::field::{CHECK, UNCHECK}; #[tokio::test] -async fn get_default_board_field_settings() { - // board - let mut test = FieldSettingsTest::new_board().await; - let non_primary_field_ids: Vec = test - .get_fields() - .into_iter() - .filter(|field| !field.is_primary) - .map(|field| field.id) - .collect(); - let primary_field_id = test.get_first_field(FieldType::RichText).id; +async fn grid_create_field() { + let mut test = DatabaseFieldTest::new().await; + + // Create and assert a text field + let (params, field) = create_text_field(&test.view_id()); + test.create_field(params).await; test - .assert_field_settings( - non_primary_field_ids.clone(), - FieldVisibility::HideWhenEmpty, - DEFAULT_WIDTH, + .assert_field_type_option_equal( + test.field_count() - 1, + field.get_any_type_option(field.field_type).unwrap(), ) .await; + + // Create and assert a single select field + let (params, field) = create_single_select_field(&test.view_id()); + test.create_field(params).await; test - .assert_field_settings( - vec![primary_field_id.clone()], - FieldVisibility::AlwaysShown, - DEFAULT_WIDTH, + .assert_field_type_option_equal( + test.field_count() - 1, + field.get_any_type_option(field.field_type).unwrap(), + ) + .await; + + // Create and assert a timestamp field + let (params, field) = create_timestamp_field(&test.view_id(), FieldType::CreatedTime); + test.create_field(params).await; + test + .assert_field_type_option_equal( + test.field_count() - 1, + field.get_any_type_option(field.field_type).unwrap(), + ) + .await; + + // Create and assert a time field + let (params, field) = create_time_field(&test.view_id()); + test.create_field(params).await; + test + .assert_field_type_option_equal( + test.field_count() - 1, + field.get_any_type_option(field.field_type).unwrap(), ) .await; } #[tokio::test] -async fn get_default_calendar_field_settings() { - // calendar - let mut test = FieldSettingsTest::new_calendar().await; - let non_primary_field_ids: Vec = test - .get_fields() - .into_iter() - .filter(|field| !field.is_primary) - .map(|field| field.id) - .collect(); - let primary_field_id = test.get_first_field(FieldType::RichText).id; - test - .assert_field_settings( - non_primary_field_ids.clone(), - FieldVisibility::HideWhenEmpty, - DEFAULT_WIDTH, - ) - .await; - test - .assert_field_settings( - vec![primary_field_id.clone()], - FieldVisibility::AlwaysShown, - DEFAULT_WIDTH, - ) - .await; +async fn grid_create_duplicate_field() { + let mut test = DatabaseFieldTest::new().await; + let (params, _) = create_text_field(&test.view_id()); + let field_count = test.field_count(); + let expected_field_count = field_count + 1; + + test.create_field(params.clone()).await; + test.assert_field_count(expected_field_count).await; } -/// Update field settings for a field #[tokio::test] -async fn update_field_settings_test() { - let mut test = FieldSettingsTest::new_board().await; - let non_primary_field_ids: Vec = test - .get_fields() - .into_iter() - .filter(|field| !field.is_primary) - .map(|field| field.id) - .collect(); - let primary_field_id = test.get_first_field(FieldType::RichText).id; +async fn grid_update_field_with_empty_change() { + let mut test = DatabaseFieldTest::new().await; + let (params, _) = create_single_select_field(&test.view_id()); + let create_field_index = test.field_count(); + test.create_field(params).await; - test - .assert_field_settings( - non_primary_field_ids.clone(), - FieldVisibility::HideWhenEmpty, - DEFAULT_WIDTH, - ) - .await; - test - .assert_field_settings( - vec![primary_field_id.clone()], - FieldVisibility::AlwaysShown, - DEFAULT_WIDTH, - ) - .await; + let field = test.get_fields().await.pop().unwrap().clone(); + let changeset = FieldChangesetPB { + field_id: field.id.clone(), + view_id: test.view_id(), + ..Default::default() + }; + test.update_field(changeset).await; test - .update_field_settings( - primary_field_id.clone(), - Some(FieldVisibility::HideWhenEmpty), - None, - ) - .await; - test - .assert_field_settings( - non_primary_field_ids.clone(), - FieldVisibility::HideWhenEmpty, - DEFAULT_WIDTH, - ) - .await; - test - .assert_field_settings( - vec![primary_field_id.clone()], - FieldVisibility::HideWhenEmpty, - DEFAULT_WIDTH, + .assert_field_type_option_equal( + create_field_index, + field.get_any_type_option(field.field_type).unwrap(), ) .await; } + +#[tokio::test] +async fn grid_delete_field() { + let mut test = DatabaseFieldTest::new().await; + let original_field_count = test.field_count(); + let (params, _) = create_text_field(&test.view_id()); + test.create_field(params).await; + + let field = test.get_fields().await.pop().unwrap(); + test.delete_field(field).await; + test.assert_field_count(original_field_count).await; +} + +#[tokio::test] +async fn grid_switch_from_select_option_to_checkbox_test() { + let mut test = DatabaseFieldTest::new().await; + let field = test.get_first_field(FieldType::SingleSelect).await; + let view_id = test.view_id(); + + // Update the type option data of the single select option + let mut options = test.get_single_select_type_option(&field.id).await; + options.clear(); + options.push(SelectOption { + id: gen_option_id(), + name: CHECK.to_string(), + color: Default::default(), + }); + options.push(SelectOption { + id: gen_option_id(), + name: UNCHECK.to_string(), + color: Default::default(), + }); + + test + .update_type_option( + field.id.clone(), + SingleSelectTypeOption(SelectTypeOption { + options, + disable_color: false, + }) + .into(), + ) + .await; + + // Switch to checkbox field + test + .switch_to_field(view_id, field.id.clone(), FieldType::Checkbox) + .await; +} + +#[tokio::test] +async fn grid_switch_from_checkbox_to_select_option_test() { + let mut test = DatabaseFieldTest::new().await; + let checkbox_field = test.get_first_field(FieldType::Checkbox).await.clone(); + + // Switch to single-select field + test + .switch_to_field( + test.view_id(), + checkbox_field.id.clone(), + FieldType::SingleSelect, + ) + .await; + + // Assert cell content after switching the field type + test + .assert_cell_content( + checkbox_field.id.clone(), + 1, // row_index + CHECK.to_string(), // expected content + ) + .await; + + // Check that the options contain both "CHECK" and "UNCHECK" + let options = test.get_single_select_type_option(&checkbox_field.id).await; + assert_eq!(options.len(), 2); + assert!(options.iter().any(|option| option.name == UNCHECK)); + assert!(options.iter().any(|option| option.name == CHECK)); +} + +#[tokio::test] +async fn grid_switch_from_multi_select_to_text_test() { + let mut test = DatabaseFieldTest::new().await; + let field_rev = test.get_first_field(FieldType::MultiSelect).await.clone(); + let multi_select_type_option = test.get_multi_select_type_option(&field_rev.id).await; + + test + .switch_to_field(test.view_id(), field_rev.id.clone(), FieldType::RichText) + .await; + + test + .assert_cell_content( + field_rev.id.clone(), + 0, // row_index + format!( + "{},{}", + multi_select_type_option.first().unwrap().name, + multi_select_type_option.get(1).unwrap().name + ), + ) + .await; +} + +#[tokio::test] +async fn grid_switch_from_checkbox_to_text_test() { + let mut test = DatabaseFieldTest::new().await; + let field_rev = test.get_first_field(FieldType::Checkbox).await; + + test + .switch_to_field(test.view_id(), field_rev.id.clone(), FieldType::RichText) + .await; + + test + .assert_cell_content(field_rev.id.clone(), 1, "Yes".to_string()) + .await; + test + .assert_cell_content(field_rev.id.clone(), 2, "No".to_string()) + .await; +} + +#[tokio::test] +async fn grid_switch_from_date_to_text_test() { + let mut test = DatabaseFieldTest::new().await; + let field = test.get_first_field(FieldType::DateTime).await.clone(); + + test + .switch_to_field(test.view_id(), field.id.clone(), FieldType::RichText) + .await; + + test + .assert_cell_content(field.id.clone(), 2, "2022/03/14".to_string()) + .await; + test + .assert_cell_content(field.id.clone(), 3, "2022/11/17".to_string()) + .await; +} + +#[tokio::test] +async fn grid_switch_from_number_to_text_test() { + let mut test = DatabaseFieldTest::new().await; + let field = test.get_first_field(FieldType::Number).await.clone(); + + test + .switch_to_field(test.view_id(), field.id.clone(), FieldType::RichText) + .await; + + test + .assert_cell_content(field.id.clone(), 0, "$1".to_string()) + .await; + test + .assert_cell_content(field.id.clone(), 4, "".to_string()) + .await; +} + +#[tokio::test] +async fn grid_switch_from_checklist_to_text_test() { + let mut test = DatabaseFieldTest::new().await; + let field_rev = test.get_first_field(FieldType::Checklist).await; + + test + .switch_to_field(test.view_id(), field_rev.id.clone(), FieldType::RichText) + .await; + + test + .assert_cell_content(field_rev.id.clone(), 0, "First thing".to_string()) + .await; +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/field_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/field_test/script.rs index 554b5a7b21..c6755ef009 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/field_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/field_test/script.rs @@ -1,39 +1,7 @@ -use collab_database::fields::{Field, TypeOptionData}; - -use flowy_database2::entities::{CreateFieldParams, FieldChangesetParams, FieldType}; -use flowy_database2::services::cell::stringify_cell; - use crate::database::database_editor::DatabaseEditorTest; - -pub enum FieldScript { - CreateField { - params: CreateFieldParams, - }, - UpdateField { - changeset: FieldChangesetParams, - }, - DeleteField { - field: Field, - }, - SwitchToField { - field_id: String, - new_field_type: FieldType, - }, - UpdateTypeOption { - field_id: String, - type_option: TypeOptionData, - }, - AssertFieldCount(usize), - AssertFieldTypeOptionEqual { - field_index: usize, - expected_type_option_data: TypeOptionData, - }, - AssertCellContent { - field_id: String, - row_index: usize, - expected_content: String, - }, -} +use collab_database::fields::{Field, TypeOptionData}; +use flowy_database2::entities::{CreateFieldParams, FieldChangesetPB, FieldType}; +use flowy_database2::services::cell::stringify_cell; pub struct DatabaseFieldTest { inner: DatabaseEditorTest, @@ -53,82 +21,78 @@ impl DatabaseFieldTest { self.field_count } - pub async fn run_scripts(&mut self, scripts: Vec) { - for script in scripts { - self.run_script(script).await; - } + pub async fn create_field(&mut self, params: CreateFieldParams) { + self.field_count += 1; + let _ = self.editor.create_field_with_type_option(params).await; + let fields = self.editor.get_fields(&self.view_id, None).await; + assert_eq!(self.field_count, fields.len()); } - pub async fn run_script(&mut self, script: FieldScript) { - match script { - FieldScript::CreateField { params } => { - self.field_count += 1; - let _ = self.editor.create_field_with_type_option(params).await; - let fields = self.editor.get_fields(&self.view_id, None); - assert_eq!(self.field_count, fields.len()); - }, - FieldScript::UpdateField { changeset: change } => { - self.editor.update_field(change).await.unwrap(); - }, - FieldScript::DeleteField { field } => { - if self.editor.get_field(&field.id).is_some() { - self.field_count -= 1; - } + pub async fn update_field(&mut self, changeset: FieldChangesetPB) { + self.editor.update_field(changeset).await.unwrap(); + } - self.editor.delete_field(&field.id).await.unwrap(); - let fields = self.editor.get_fields(&self.view_id, None); - assert_eq!(self.field_count, fields.len()); - }, - FieldScript::SwitchToField { - field_id, - new_field_type, - } => { - // - self - .editor - .switch_to_field_type(&field_id, new_field_type) - .await - .unwrap(); - }, - FieldScript::UpdateTypeOption { - field_id, - type_option, - } => { - // - let old_field = self.editor.get_field(&field_id).unwrap(); - self - .editor - .update_field_type_option(&field_id, type_option, old_field) - .await - .unwrap(); - }, - FieldScript::AssertFieldCount(count) => { - assert_eq!(self.get_fields().len(), count); - }, - FieldScript::AssertFieldTypeOptionEqual { - field_index, - expected_type_option_data, - } => { - let fields = self.get_fields(); - let field = &fields[field_index]; - let type_option_data = field.get_any_type_option(field.field_type).unwrap(); - assert_eq!(type_option_data, expected_type_option_data); - }, - FieldScript::AssertCellContent { - field_id, - row_index, - expected_content, - } => { - let field = self.editor.get_field(&field_id).unwrap(); - - let rows = self.editor.get_rows(&self.view_id()).await.unwrap(); - let row_detail = rows.get(row_index).unwrap(); - - let cell = row_detail.row.cells.get(&field_id).unwrap().clone(); - let content = stringify_cell(&cell, &field); - assert_eq!(content, expected_content); - }, + pub async fn delete_field(&mut self, field: Field) { + if self.editor.get_field(&field.id).await.is_some() { + self.field_count -= 1; } + + self.editor.delete_field(&field.id).await.unwrap(); + let fields = self.editor.get_fields(&self.view_id, None).await; + assert_eq!(self.field_count, fields.len()); + } + + pub async fn switch_to_field( + &mut self, + view_id: String, + field_id: String, + new_field_type: FieldType, + ) { + self + .editor + .switch_to_field_type(&view_id, &field_id, new_field_type, None) + .await + .unwrap(); + } + + pub async fn update_type_option(&mut self, field_id: String, type_option: TypeOptionData) { + let old_field = self.editor.get_field(&field_id).await.unwrap(); + self + .editor + .update_field_type_option(&field_id, type_option, old_field) + .await + .unwrap(); + } + + pub async fn assert_field_count(&self, count: usize) { + assert_eq!(self.get_fields().await.len(), count); + } + + pub async fn assert_field_type_option_equal( + &self, + field_index: usize, + expected_type_option_data: TypeOptionData, + ) { + let fields = self.get_fields().await; + let field = &fields[field_index]; + let type_option_data = field.get_any_type_option(field.field_type).unwrap(); + assert_eq!(type_option_data, expected_type_option_data); + } + + pub async fn assert_cell_content( + &self, + field_id: String, + row_index: usize, + expected_content: String, + ) { + let field = self.editor.get_field(&field_id).await.unwrap(); + + let rows = self.editor.get_all_rows(&self.view_id()).await.unwrap(); + let row = rows.get(row_index).unwrap(); + + let cell = row.cells.get(&field_id).unwrap().clone(); + let content = stringify_cell(&cell, &field); + assert_eq!(content, expected_content); } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs index e53be13266..488ae4d577 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs @@ -1,45 +1,55 @@ use collab_database::database::gen_option_id; - -use flowy_database2::entities::{FieldChangesetParams, FieldType}; -use flowy_database2::services::field::{SelectOption, SingleSelectTypeOption, CHECK, UNCHECK}; +use collab_database::fields::select_type_option::{SelectOption, SelectTypeOption}; +use flowy_database2::entities::{FieldChangesetPB, FieldType}; +use flowy_database2::services::field::{CHECK, UNCHECK}; use crate::database::field_test::script::DatabaseFieldTest; -use crate::database::field_test::script::FieldScript::*; use crate::database::field_test::util::*; +use collab_database::fields::select_type_option::SingleSelectTypeOption; #[tokio::test] async fn grid_create_field() { let mut test = DatabaseFieldTest::new().await; + + // Create and assert text field let (params, field) = create_text_field(&test.view_id()); + test.create_field(params).await; + test + .assert_field_type_option_equal( + test.field_count() - 1, + field.get_any_type_option(field.field_type).unwrap(), + ) + .await; - let scripts = vec![ - CreateField { params }, - AssertFieldTypeOptionEqual { - field_index: test.field_count(), - expected_type_option_data: field.get_any_type_option(field.field_type).unwrap(), - }, - ]; - test.run_scripts(scripts).await; - + // Create and assert single select field let (params, field) = create_single_select_field(&test.view_id()); - let scripts = vec![ - CreateField { params }, - AssertFieldTypeOptionEqual { - field_index: test.field_count(), - expected_type_option_data: field.get_any_type_option(field.field_type).unwrap(), - }, - ]; - test.run_scripts(scripts).await; + test.create_field(params).await; + test + .assert_field_type_option_equal( + test.field_count() - 1, + field.get_any_type_option(field.field_type).unwrap(), + ) + .await; + // Create and assert timestamp field let (params, field) = create_timestamp_field(&test.view_id(), FieldType::CreatedTime); - let scripts = vec![ - CreateField { params }, - AssertFieldTypeOptionEqual { - field_index: test.field_count(), - expected_type_option_data: field.get_any_type_option(field.field_type).unwrap(), - }, - ]; - test.run_scripts(scripts).await; + test.create_field(params).await; + test + .assert_field_type_option_equal( + test.field_count() - 1, + field.get_any_type_option(field.field_type).unwrap(), + ) + .await; + + // Create and assert time field + let (params, field) = create_time_field(&test.view_id()); + test.create_field(params).await; + test + .assert_field_type_option_equal( + test.field_count() - 1, + field.get_any_type_option(field.field_type).unwrap(), + ) + .await; } #[tokio::test] @@ -48,13 +58,9 @@ async fn grid_create_duplicate_field() { let (params, _) = create_text_field(&test.view_id()); let field_count = test.field_count(); let expected_field_count = field_count + 1; - let scripts = vec![ - CreateField { - params: params.clone(), - }, - AssertFieldCount(expected_field_count), - ]; - test.run_scripts(scripts).await; + + test.create_field(params.clone()).await; + test.assert_field_count(expected_field_count).await; } #[tokio::test] @@ -62,24 +68,23 @@ async fn grid_update_field_with_empty_change() { let mut test = DatabaseFieldTest::new().await; let (params, _) = create_single_select_field(&test.view_id()); let create_field_index = test.field_count(); - let scripts = vec![CreateField { params }]; - test.run_scripts(scripts).await; - let field = test.get_fields().pop().unwrap().clone(); - let changeset = FieldChangesetParams { + test.create_field(params).await; + + let field = test.get_fields().await.pop().unwrap().clone(); + let changeset = FieldChangesetPB { field_id: field.id.clone(), view_id: test.view_id(), ..Default::default() }; - let scripts = vec![ - UpdateField { changeset }, - AssertFieldTypeOptionEqual { - field_index: create_field_index, - expected_type_option_data: field.get_any_type_option(field.field_type).unwrap(), - }, - ]; - test.run_scripts(scripts).await; + test.update_field(changeset).await; + test + .assert_field_type_option_equal( + create_field_index, + field.get_any_type_option(field.field_type).unwrap(), + ) + .await; } #[tokio::test] @@ -87,215 +92,166 @@ async fn grid_delete_field() { let mut test = DatabaseFieldTest::new().await; let original_field_count = test.field_count(); let (params, _) = create_text_field(&test.view_id()); - let scripts = vec![CreateField { params }]; - test.run_scripts(scripts).await; - let field = test.get_fields().pop().unwrap(); - let scripts = vec![ - DeleteField { field }, - AssertFieldCount(original_field_count), - ]; - test.run_scripts(scripts).await; + test.create_field(params).await; + + let field = test.get_fields().await.pop().unwrap(); + test.delete_field(field).await; + test.assert_field_count(original_field_count).await; } #[tokio::test] async fn grid_switch_from_select_option_to_checkbox_test() { let mut test = DatabaseFieldTest::new().await; - let field = test.get_first_field(FieldType::SingleSelect); + let field = test.get_first_field(FieldType::SingleSelect).await; + let view_id = test.view_id(); // Update the type option data of single select option - let mut options = test.get_single_select_type_option(&field.id); + let mut options = test.get_single_select_type_option(&field.id).await; options.clear(); - // Add a new option with name CHECK options.push(SelectOption { id: gen_option_id(), name: CHECK.to_string(), color: Default::default(), }); - // Add a new option with name UNCHECK options.push(SelectOption { id: gen_option_id(), name: UNCHECK.to_string(), color: Default::default(), }); - let scripts = vec![ - UpdateTypeOption { - field_id: field.id.clone(), - type_option: SingleSelectTypeOption { + test + .update_type_option( + field.id.clone(), + SingleSelectTypeOption(SelectTypeOption { options, disable_color: false, - } + }) .into(), - }, - SwitchToField { - field_id: field.id.clone(), - new_field_type: FieldType::Checkbox, - }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Switch to checkbox field + test + .switch_to_field(view_id, field.id.clone(), FieldType::Checkbox) + .await; } #[tokio::test] async fn grid_switch_from_checkbox_to_select_option_test() { let mut test = DatabaseFieldTest::new().await; - let checkbox_field = test.get_first_field(FieldType::Checkbox).clone(); - let scripts = vec![ - // switch to single-select field type - SwitchToField { - field_id: checkbox_field.id.clone(), - new_field_type: FieldType::SingleSelect, - }, - // Assert the cell content after switch the field type. The cell content will be changed if - // the FieldType::SingleSelect implement the cell data TypeOptionTransform. Check out the - // TypeOptionTransform trait for more information. - // - // Make sure which cell of the row you want to check. - AssertCellContent { - field_id: checkbox_field.id.clone(), - // the mock data of the checkbox with row_index one is "true" - row_index: 1, - // The content of the checkbox should transform to the corresponding option name. - expected_content: CHECK.to_string(), - }, - ]; - test.run_scripts(scripts).await; + let checkbox_field = test.get_first_field(FieldType::Checkbox).await.clone(); - let options = test.get_single_select_type_option(&checkbox_field.id); + // Switch to single-select field + test + .switch_to_field( + test.view_id(), + checkbox_field.id.clone(), + FieldType::SingleSelect, + ) + .await; + + // Assert cell content after switching the field type + test + .assert_cell_content( + checkbox_field.id.clone(), + 1, // row_index + CHECK.to_string(), // expected content + ) + .await; + + // Check that the options contain both "CHECK" and "UNCHECK" + let options = test.get_single_select_type_option(&checkbox_field.id).await; assert_eq!(options.len(), 2); assert!(options.iter().any(|option| option.name == UNCHECK)); assert!(options.iter().any(|option| option.name == CHECK)); } -// Test when switching the current field from Multi-select to Text test -// The build-in test data is located in `make_test_grid` method(flowy-database/tests/grid_editor.rs). -// input: -// option1, option2 -> "option1.name, option2.name" #[tokio::test] async fn grid_switch_from_multi_select_to_text_test() { let mut test = DatabaseFieldTest::new().await; - let field_rev = test.get_first_field(FieldType::MultiSelect).clone(); + let field_rev = test.get_first_field(FieldType::MultiSelect).await.clone(); - let multi_select_type_option = test.get_multi_select_type_option(&field_rev.id); + let multi_select_type_option = test.get_multi_select_type_option(&field_rev.id).await; - let script_switch_field = vec![SwitchToField { - field_id: field_rev.id.clone(), - new_field_type: FieldType::RichText, - }]; + test + .switch_to_field(test.view_id(), field_rev.id.clone(), FieldType::RichText) + .await; - test.run_scripts(script_switch_field).await; - - let script_assert_field = vec![AssertCellContent { - field_id: field_rev.id.clone(), - row_index: 0, - expected_content: format!( - "{},{}", - multi_select_type_option.first().unwrap().name, - multi_select_type_option.get(1).unwrap().name - ), - }]; - - test.run_scripts(script_assert_field).await; + test + .assert_cell_content( + field_rev.id.clone(), + 0, // row_index + format!( + "{},{}", + multi_select_type_option.first().unwrap().name, + multi_select_type_option.get(1).unwrap().name + ), + ) + .await; } -// Test when switching the current field from Checkbox to Text test -// input: -// check -> "Yes" -// unchecked -> "No" #[tokio::test] async fn grid_switch_from_checkbox_to_text_test() { let mut test = DatabaseFieldTest::new().await; - let field_rev = test.get_first_field(FieldType::Checkbox); + let field_rev = test.get_first_field(FieldType::Checkbox).await; - let scripts = vec![ - SwitchToField { - field_id: field_rev.id.clone(), - new_field_type: FieldType::RichText, - }, - AssertCellContent { - field_id: field_rev.id.clone(), - row_index: 1, - expected_content: "Yes".to_string(), - }, - AssertCellContent { - field_id: field_rev.id.clone(), - row_index: 2, - expected_content: "No".to_string(), - }, - ]; - test.run_scripts(scripts).await; + test + .switch_to_field(test.view_id(), field_rev.id.clone(), FieldType::RichText) + .await; + + test + .assert_cell_content(field_rev.id.clone(), 1, "Yes".to_string()) + .await; + test + .assert_cell_content(field_rev.id.clone(), 2, "No".to_string()) + .await; } -// Test when switching the current field from Date to Text test -// input: -// 1647251762 -> Mar 14,2022 (This string will be different base on current data setting) #[tokio::test] async fn grid_switch_from_date_to_text_test() { let mut test = DatabaseFieldTest::new().await; - let field = test.get_first_field(FieldType::DateTime).clone(); - let scripts = vec![ - SwitchToField { - field_id: field.id.clone(), - new_field_type: FieldType::RichText, - }, - AssertCellContent { - field_id: field.id.clone(), - row_index: 2, - expected_content: "2022/03/14".to_string(), - }, - AssertCellContent { - field_id: field.id.clone(), - row_index: 3, - expected_content: "2022/11/17".to_string(), - }, - ]; - test.run_scripts(scripts).await; + let field = test.get_first_field(FieldType::DateTime).await.clone(); + + test + .switch_to_field(test.view_id(), field.id.clone(), FieldType::RichText) + .await; + + test + .assert_cell_content(field.id.clone(), 2, "2022/03/14".to_string()) + .await; + test + .assert_cell_content(field.id.clone(), 3, "2022/11/17".to_string()) + .await; } -// Test when switching the current field from Number to Text test -// input: -// $1 -> "$1"(This string will be different base on current data setting) #[tokio::test] async fn grid_switch_from_number_to_text_test() { let mut test = DatabaseFieldTest::new().await; - let field = test.get_first_field(FieldType::Number).clone(); + let field = test.get_first_field(FieldType::Number).await.clone(); - let scripts = vec![ - SwitchToField { - field_id: field.id.clone(), - new_field_type: FieldType::RichText, - }, - AssertCellContent { - field_id: field.id.clone(), - row_index: 0, - expected_content: "$1".to_string(), - }, - AssertCellContent { - field_id: field.id.clone(), - row_index: 4, - expected_content: "".to_string(), - }, - ]; + test + .switch_to_field(test.view_id(), field.id.clone(), FieldType::RichText) + .await; - test.run_scripts(scripts).await; + test + .assert_cell_content(field.id.clone(), 0, "$1".to_string()) + .await; + test + .assert_cell_content(field.id.clone(), 4, "".to_string()) + .await; } -/// Test when switching the current field from Checklist to Text test #[tokio::test] async fn grid_switch_from_checklist_to_text_test() { let mut test = DatabaseFieldTest::new().await; - let field_rev = test.get_first_field(FieldType::Checklist); + let field_rev = test.get_first_field(FieldType::Checklist).await; - let scripts = vec![ - SwitchToField { - field_id: field_rev.id.clone(), - new_field_type: FieldType::RichText, - }, - AssertCellContent { - field_id: field_rev.id.clone(), - row_index: 0, - expected_content: "First thing".to_string(), - }, - ]; - test.run_scripts(scripts).await; + test + .switch_to_field(test.view_id(), field_rev.id.clone(), FieldType::RichText) + .await; + + test + .assert_cell_content(field_rev.id.clone(), 0, "First thing".to_string()) + .await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs b/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs index a5f2703869..0728cbab95 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs @@ -1,15 +1,18 @@ +use collab_database::fields::date_type_option::{ + DateFormat, DateTypeOption, TimeFormat, TimeTypeOption, +}; +use collab_database::fields::select_type_option::{SelectOption, SingleSelectTypeOption}; +use collab_database::fields::text_type_option::RichTextTypeOption; +use collab_database::fields::timestamp_type_option::TimestampTypeOption; use collab_database::fields::Field; use collab_database::views::OrderObjectPosition; use flowy_database2::entities::{CreateFieldParams, FieldType}; -use flowy_database2::services::field::{ - type_option_to_pb, DateFormat, DateTypeOption, FieldBuilder, RichTextTypeOption, SelectOption, - SingleSelectTypeOption, TimeFormat, TimestampTypeOption, -}; +use flowy_database2::services::field::{type_option_to_pb, FieldBuilder}; pub fn create_text_field(grid_id: &str) -> (CreateFieldParams, Field) { let field_type = FieldType::RichText; - let type_option = RichTextTypeOption::default(); + let type_option = RichTextTypeOption; let text_field = FieldBuilder::new(field_type, type_option.clone()) .name("Name") .primary(true) @@ -74,7 +77,8 @@ pub fn create_timestamp_field(grid_id: &str, field_type: FieldType) -> (CreateFi date_format: DateFormat::US, time_format: TimeFormat::TwentyFourHour, include_time: true, - field_type, + field_type: field_type.into(), + timezone: None, }; let field: Field = match field_type { @@ -98,3 +102,21 @@ pub fn create_timestamp_field(grid_id: &str, field_type: FieldType) -> (CreateFi }; (params, field) } + +pub fn create_time_field(grid_id: &str) -> (CreateFieldParams, Field) { + let field_type = FieldType::Time; + let type_option = TimeTypeOption; + let text_field = FieldBuilder::new(field_type, type_option.clone()) + .name("Time field") + .build(); + + let type_option_data = type_option_to_pb(type_option.into(), &field_type).to_vec(); + let params = CreateFieldParams { + view_id: grid_id.to_owned(), + field_type, + type_option_data: Some(type_option_data), + field_name: None, + position: OrderObjectPosition::default(), + }; + (params, text_field) +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/advanced_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/advanced_filter_test.rs index 107e588fed..d077b4c61f 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/advanced_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/advanced_filter_test.rs @@ -1,3 +1,4 @@ +use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; use bytes::Bytes; use flowy_database2::entities::{ CheckboxFilterConditionPB, CheckboxFilterPB, DateFilterConditionPB, DateFilterPB, FieldType, @@ -7,8 +8,6 @@ use lib_infra::box_any::BoxAny; use protobuf::ProtobufError; use std::convert::TryInto; -use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged, FilterScript::*}; - /// Create a single advanced filter: /// /// 1. Add an OR filter @@ -27,7 +26,7 @@ async fn create_advanced_filter_test() { let create_date_filter = || -> DateFilterPB { DateFilterPB { - condition: DateFilterConditionPB::DateAfter, + condition: DateFilterConditionPB::DateStartsAfter, timestamp: Some(1651366800), ..Default::default() } @@ -40,73 +39,69 @@ async fn create_advanced_filter_test() { } }; - let scripts = vec![ - CreateOrFilter { - parent_filter_id: None, - changed: None, - }, - Wait { millisecond: 100 }, - AssertFilters { - expected: vec![FilterPB { - id: "".to_string(), - filter_type: FilterType::Or, - children: vec![], - data: None, - }], - }, - ]; - test.run_scripts(scripts).await; - // OR + // Create OR Filter + test.create_or_filter(None, None).await; + test.wait(100).await; + + test + .assert_filters(vec![FilterPB { + id: "".to_string(), + filter_type: FilterType::Or, + children: vec![], + data: None, + }]) + .await; let or_filter = test.get_filter(FilterType::Or, None).await.unwrap(); let checkbox_filter_bytes: Result = create_checkbox_filter().try_into(); let checkbox_filter_bytes = checkbox_filter_bytes.unwrap().to_vec(); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: Some(or_filter.id.clone()), - field_type: FieldType::Checkbox, - data: BoxAny::new(create_checkbox_filter()), - changed: Some(FilterRowChanged { + // Create Checkbox Filter and AND Filter + test + .create_data_filter( + Some(or_filter.id.clone()), + FieldType::Checkbox, + BoxAny::new(create_checkbox_filter()), + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 4, }), - }, - CreateAndFilter { - parent_filter_id: Some(or_filter.id), - changed: None, - }, - Wait { millisecond: 100 }, - AssertFilters { - expected: vec![FilterPB { - id: "".to_string(), - filter_type: FilterType::Or, - children: vec![ - FilterPB { - id: "".to_string(), - filter_type: FilterType::Data, - children: vec![], - data: Some(FilterDataPB { - field_id: "".to_string(), - field_type: FieldType::Checkbox, - data: checkbox_filter_bytes.clone(), - }), - }, - FilterPB { - id: "".to_string(), - filter_type: FilterType::And, - children: vec![], - data: None, - }, - ], - data: None, - }], - }, - AssertNumberOfVisibleRows { expected: 3 }, - ]; - test.run_scripts(scripts).await; - // IS_CHECK OR AND + ) + .await; + + test + .create_and_filter(Some(or_filter.id.clone()), None) + .await; + test.wait(100).await; + + test + .assert_filters(vec![FilterPB { + id: "".to_string(), + filter_type: FilterType::Or, + children: vec![ + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::Checkbox, + data: checkbox_filter_bytes.clone(), + }), + }, + FilterPB { + id: "".to_string(), + filter_type: FilterType::And, + children: vec![], + data: None, + }, + ], + data: None, + }]) + .await; + + test.assert_number_of_visible_rows(3).await; let and_filter = test.get_filter(FilterType::And, None).await.unwrap(); @@ -115,70 +110,75 @@ async fn create_advanced_filter_test() { let number_filter_bytes: Result = create_number_filter().try_into(); let number_filter_bytes = number_filter_bytes.unwrap().to_vec(); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: Some(and_filter.id.clone()), - field_type: FieldType::DateTime, - data: BoxAny::new(create_date_filter()), - changed: None, - }, - CreateDataFilter { - parent_filter_id: Some(and_filter.id), - field_type: FieldType::Number, - data: BoxAny::new(create_number_filter()), - changed: None, - }, - Wait { millisecond: 100 }, - AssertFilters { - expected: vec![FilterPB { - id: "".to_string(), - filter_type: FilterType::Or, - children: vec![ - FilterPB { - id: "".to_string(), - filter_type: FilterType::Data, - children: vec![], - data: Some(FilterDataPB { - field_id: "".to_string(), - field_type: FieldType::Checkbox, - data: checkbox_filter_bytes, - }), - }, - FilterPB { - id: "".to_string(), - filter_type: FilterType::And, - children: vec![ - FilterPB { - id: "".to_string(), - filter_type: FilterType::Data, - children: vec![], - data: Some(FilterDataPB { - field_id: "".to_string(), - field_type: FieldType::DateTime, - data: date_filter_bytes, - }), - }, - FilterPB { - id: "".to_string(), - filter_type: FilterType::Data, - children: vec![], - data: Some(FilterDataPB { - field_id: "".to_string(), - field_type: FieldType::Number, - data: number_filter_bytes, - }), - }, - ], - data: None, - }, - ], - data: None, - }], - }, - AssertNumberOfVisibleRows { expected: 4 }, - ]; - test.run_scripts(scripts).await; - // IS_CHECK OR (DATE > 1651366800 AND NUMBER NOT EMPTY) + // Create Date Filter and Number Filter + test + .create_data_filter( + Some(and_filter.id.clone()), + FieldType::DateTime, + BoxAny::new(create_date_filter()), + None, + ) + .await; + + test + .create_data_filter( + Some(and_filter.id), + FieldType::Number, + BoxAny::new(create_number_filter()), + None, + ) + .await; + + test.wait(100).await; + + test + .assert_filters(vec![FilterPB { + id: "".to_string(), + filter_type: FilterType::Or, + children: vec![ + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::Checkbox, + data: checkbox_filter_bytes, + }), + }, + FilterPB { + id: "".to_string(), + filter_type: FilterType::And, + children: vec![ + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::DateTime, + data: date_filter_bytes, + }), + }, + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::Number, + data: number_filter_bytes, + }), + }, + ], + data: None, + }, + ], + data: None, + }]) + .await; + + test.assert_number_of_visible_rows(4).await; } /// Create the same advanced filter single advanced filter: @@ -199,7 +199,7 @@ async fn create_advanced_filter_with_conversion_test() { let create_date_filter = || -> DateFilterPB { DateFilterPB { - condition: DateFilterConditionPB::DateAfter, + condition: DateFilterConditionPB::DateStartsAfter, timestamp: Some(1651366800), ..Default::default() } @@ -212,34 +212,32 @@ async fn create_advanced_filter_with_conversion_test() { } }; - let scripts = vec![CreateOrFilter { - parent_filter_id: None, - changed: None, - }]; - test.run_scripts(scripts).await; - // IS_CHECK OR DATE > 1651366800 + // Create OR Filter + test.create_or_filter(None, None).await; let or_filter = test.get_filter(FilterType::Or, None).await.unwrap(); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: Some(or_filter.id.clone()), - field_type: FieldType::Checkbox, - data: BoxAny::new(create_checkbox_filter()), - changed: Some(FilterRowChanged { + // Create Checkbox Filter and Date Filter + test + .create_data_filter( + Some(or_filter.id.clone()), + FieldType::Checkbox, + BoxAny::new(create_checkbox_filter()), + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 4, }), - }, - CreateDataFilter { - parent_filter_id: Some(or_filter.id.clone()), - field_type: FieldType::DateTime, - data: BoxAny::new(create_date_filter()), - changed: None, - }, - ]; - test.run_scripts(scripts).await; - // OR + ) + .await; + + test + .create_data_filter( + Some(or_filter.id.clone()), + FieldType::DateTime, + BoxAny::new(create_date_filter()), + None, + ) + .await; let date_filter = test .get_filter(FilterType::Data, Some(FieldType::DateTime)) @@ -253,62 +251,64 @@ async fn create_advanced_filter_with_conversion_test() { let number_filter_bytes: Result = create_number_filter().try_into(); let number_filter_bytes = number_filter_bytes.unwrap().to_vec(); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: Some(date_filter.id), - field_type: FieldType::Number, - data: BoxAny::new(create_number_filter()), - changed: None, - }, - Wait { millisecond: 100 }, - AssertFilters { - expected: vec![FilterPB { - id: "".to_string(), - filter_type: FilterType::Or, - children: vec![ - FilterPB { - id: "".to_string(), - filter_type: FilterType::Data, - children: vec![], - data: Some(FilterDataPB { - field_id: "".to_string(), - field_type: FieldType::Checkbox, - data: checkbox_filter_bytes, - }), - }, - FilterPB { - id: "".to_string(), - filter_type: FilterType::And, - children: vec![ - FilterPB { - id: "".to_string(), - filter_type: FilterType::Data, - children: vec![], - data: Some(FilterDataPB { - field_id: "".to_string(), - field_type: FieldType::DateTime, - data: date_filter_bytes, - }), - }, - FilterPB { - id: "".to_string(), - filter_type: FilterType::Data, - children: vec![], - data: Some(FilterDataPB { - field_id: "".to_string(), - field_type: FieldType::Number, - data: number_filter_bytes, - }), - }, - ], - data: None, - }, - ], - data: None, - }], - }, - AssertNumberOfVisibleRows { expected: 4 }, - ]; - test.run_scripts(scripts).await; - // IS_CHECK OR (DATE > 1651366800 AND NUMBER NOT EMPTY) + // Create Number Filter + test + .create_data_filter( + Some(date_filter.id), + FieldType::Number, + BoxAny::new(create_number_filter()), + None, + ) + .await; + + test.wait(100).await; + + test + .assert_filters(vec![FilterPB { + id: "".to_string(), + filter_type: FilterType::Or, + children: vec![ + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::Checkbox, + data: checkbox_filter_bytes, + }), + }, + FilterPB { + id: "".to_string(), + filter_type: FilterType::And, + children: vec![ + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::DateTime, + data: date_filter_bytes, + }), + }, + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::Number, + data: number_filter_bytes, + }), + }, + ], + data: None, + }, + ], + data: None, + }]) + .await; + + test.assert_number_of_visible_rows(4).await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/checkbox_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/checkbox_filter_test.rs index 881a1cebf9..26a018d8de 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/checkbox_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/checkbox_filter_test.rs @@ -1,51 +1,51 @@ +use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; use flowy_database2::entities::{CheckboxFilterConditionPB, CheckboxFilterPB, FieldType}; use lib_infra::box_any::BoxAny; -use crate::database::filter_test::script::FilterScript::*; -use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; - #[tokio::test] async fn grid_filter_checkbox_is_check_test() { let mut test = DatabaseFilterTest::new().await; let expected = 3; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); + // The initial number of checked is 3 // The initial number of unchecked is 4 - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::Checkbox, - data: BoxAny::new(CheckboxFilterPB { + test + .create_data_filter( + None, + FieldType::Checkbox, + BoxAny::new(CheckboxFilterPB { condition: CheckboxFilterConditionPB::IsChecked, }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_checkbox_is_uncheck_test() { let mut test = DatabaseFilterTest::new().await; let expected = 4; - let row_count = test.row_details.len(); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::Checkbox, - data: BoxAny::new(CheckboxFilterPB { + let row_count = test.rows.len(); + + test + .create_data_filter( + None, + FieldType::Checkbox, + BoxAny::new(CheckboxFilterPB { condition: CheckboxFilterConditionPB::IsUnChecked, }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + test.assert_number_of_visible_rows(expected).await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/checklist_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/checklist_filter_test.rs index 3da9cab5a2..88d48bceca 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/checklist_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/checklist_filter_test.rs @@ -1,71 +1,73 @@ -use flowy_database2::entities::{ChecklistFilterConditionPB, ChecklistFilterPB, FieldType}; -use flowy_database2::services::field::checklist_type_option::ChecklistCellData; -use lib_infra::box_any::BoxAny; - -use crate::database::filter_test::script::FilterScript::*; use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; +use collab_database::template::check_list_parse::ChecklistCellData; +use flowy_database2::entities::{ChecklistFilterConditionPB, ChecklistFilterPB, FieldType}; +use lib_infra::box_any::BoxAny; #[tokio::test] async fn grid_filter_checklist_is_incomplete_test() { let mut test = DatabaseFilterTest::new().await; let expected = 5; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let option_ids = get_checklist_cell_options(&test).await; - let scripts = vec![ - UpdateChecklistCell { - row_id: test.row_details[0].row.id.clone(), - selected_option_ids: option_ids, - }, - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::Checklist, - data: BoxAny::new(ChecklistFilterPB { + // Update checklist cell with selected option IDs + test + .update_checklist_cell(test.rows[0].id.clone(), option_ids) + .await; + + // Create Checklist "Is Incomplete" filter + test + .create_data_filter( + None, + FieldType::Checklist, + BoxAny::new(ChecklistFilterPB { condition: ChecklistFilterConditionPB::IsIncomplete, }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_checklist_is_complete_test() { let mut test = DatabaseFilterTest::new().await; let expected = 2; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let option_ids = get_checklist_cell_options(&test).await; - let scripts = vec![ - UpdateChecklistCell { - row_id: test.row_details[0].row.id.clone(), - selected_option_ids: option_ids, - }, - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::Checklist, - data: BoxAny::new(ChecklistFilterPB { + + // Update checklist cell with selected option IDs + test + .update_checklist_cell(test.rows[0].id.clone(), option_ids) + .await; + + // Create Checklist "Is Complete" filter + test + .create_data_filter( + None, + FieldType::Checklist, + BoxAny::new(ChecklistFilterPB { condition: ChecklistFilterConditionPB::IsComplete, }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } async fn get_checklist_cell_options(test: &DatabaseFilterTest) -> Vec { - let field = test.get_first_field(FieldType::Checklist); - let row_cell = test - .editor - .get_cell(&field.id, &test.row_details[0].row.id) - .await; + let field = test.get_first_field(FieldType::Checklist).await; + let row_cell = test.editor.get_cell(&field.id, &test.rows[0].id).await; row_cell .map_or(ChecklistCellData::default(), |cell| { ChecklistCellData::from(&cell) diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/date_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/date_filter_test.rs index 34964b9720..deff2a5888 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/date_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/date_filter_test.rs @@ -1,130 +1,143 @@ +use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; use flowy_database2::entities::{DateFilterConditionPB, DateFilterPB, FieldType}; use lib_infra::box_any::BoxAny; -use crate::database::filter_test::script::FilterScript::*; -use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; - #[tokio::test] async fn grid_filter_date_is_test() { let mut test = DatabaseFilterTest::new().await; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let expected = 3; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::DateTime, - data: BoxAny::new(DateFilterPB { - condition: DateFilterConditionPB::DateIs, + + // Create "Date Is" filter + test + .create_data_filter( + None, + FieldType::DateTime, + BoxAny::new(DateFilterPB { + condition: DateFilterConditionPB::DateStartsOn, start: None, end: None, timestamp: Some(1647251762), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_date_after_test() { let mut test = DatabaseFilterTest::new().await; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let expected = 3; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::DateTime, - data: BoxAny::new(DateFilterPB { - condition: DateFilterConditionPB::DateAfter, + + // Create "Date After" filter + test + .create_data_filter( + None, + FieldType::DateTime, + BoxAny::new(DateFilterPB { + condition: DateFilterConditionPB::DateStartsAfter, start: None, end: None, timestamp: Some(1647251762), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_date_on_or_after_test() { let mut test = DatabaseFilterTest::new().await; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let expected = 3; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::DateTime, - data: BoxAny::new(DateFilterPB { - condition: DateFilterConditionPB::DateOnOrAfter, + + // Create "Date On Or After" filter + test + .create_data_filter( + None, + FieldType::DateTime, + BoxAny::new(DateFilterPB { + condition: DateFilterConditionPB::DateStartsOnOrAfter, start: None, end: None, timestamp: Some(1668359085), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_date_on_or_before_test() { let mut test = DatabaseFilterTest::new().await; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let expected = 4; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::DateTime, - data: BoxAny::new(DateFilterPB { - condition: DateFilterConditionPB::DateOnOrBefore, + + // Create "Date On Or Before" filter + test + .create_data_filter( + None, + FieldType::DateTime, + BoxAny::new(DateFilterPB { + condition: DateFilterConditionPB::DateStartsOnOrBefore, start: None, end: None, timestamp: Some(1668359085), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_date_within_test() { let mut test = DatabaseFilterTest::new().await; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let expected = 5; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::DateTime, - data: BoxAny::new(DateFilterPB { - condition: DateFilterConditionPB::DateWithIn, + + // Create "Date Within Range" filter + test + .create_data_filter( + None, + FieldType::DateTime, + BoxAny::new(DateFilterPB { + condition: DateFilterConditionPB::DateStartsBetween, start: Some(1647251762), end: Some(1668704685), timestamp: None, }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs index bf5d1513c9..e99cc725d5 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs @@ -6,3 +6,4 @@ mod number_filter_test; mod script; mod select_option_filter_test; mod text_filter_test; +mod time_filter_test; diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/number_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/number_filter_test.rs index e041ba1b4c..509b520361 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/number_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/number_filter_test.rs @@ -1,144 +1,160 @@ +use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; use flowy_database2::entities::{FieldType, NumberFilterConditionPB, NumberFilterPB}; use lib_infra::box_any::BoxAny; -use crate::database::filter_test::script::FilterScript::*; -use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; - #[tokio::test] async fn grid_filter_number_is_equal_test() { let mut test = DatabaseFilterTest::new().await; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let expected = 1; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::Number, - data: BoxAny::new(NumberFilterPB { + + // Create Number "Equal" filter + test + .create_data_filter( + None, + FieldType::Number, + BoxAny::new(NumberFilterPB { condition: NumberFilterConditionPB::Equal, content: "1".to_string(), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_number_is_less_than_test() { let mut test = DatabaseFilterTest::new().await; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let expected = 2; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::Number, - data: BoxAny::new(NumberFilterPB { + + // Create Number "Less Than" filter + test + .create_data_filter( + None, + FieldType::Number, + BoxAny::new(NumberFilterPB { condition: NumberFilterConditionPB::LessThan, content: "3".to_string(), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] #[should_panic] async fn grid_filter_number_is_less_than_test2() { let mut test = DatabaseFilterTest::new().await; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let expected = 2; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::Number, - data: BoxAny::new(NumberFilterPB { + + // Create Number "Less Than" filter with invalid content (should panic) + test + .create_data_filter( + None, + FieldType::Number, + BoxAny::new(NumberFilterPB { condition: NumberFilterConditionPB::LessThan, content: "$3".to_string(), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_number_is_less_than_or_equal_test() { let mut test = DatabaseFilterTest::new().await; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let expected = 3; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::Number, - data: BoxAny::new(NumberFilterPB { + + // Create Number "Less Than Or Equal" filter + test + .create_data_filter( + None, + FieldType::Number, + BoxAny::new(NumberFilterPB { condition: NumberFilterConditionPB::LessThanOrEqualTo, content: "3".to_string(), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_number_is_empty_test() { let mut test = DatabaseFilterTest::new().await; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let expected = 2; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::Number, - data: BoxAny::new(NumberFilterPB { + + // Create Number "Is Empty" filter + test + .create_data_filter( + None, + FieldType::Number, + BoxAny::new(NumberFilterPB { condition: NumberFilterConditionPB::NumberIsEmpty, content: "".to_string(), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_number_is_not_empty_test() { let mut test = DatabaseFilterTest::new().await; - let row_count = test.row_details.len(); + let row_count = test.rows.len(); let expected = 5; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::Number, - data: BoxAny::new(NumberFilterPB { + + // Create Number "Is Not Empty" filter + test + .create_data_filter( + None, + FieldType::Number, + BoxAny::new(NumberFilterPB { condition: NumberFilterConditionPB::NumberIsNotEmpty, content: "".to_string(), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/script.rs index f2b58070e7..6592c04305 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/script.rs @@ -11,7 +11,6 @@ use flowy_database2::entities::{ DatabaseViewSettingPB, FieldType, FilterPB, FilterType, TextFilterConditionPB, TextFilterPB, }; use flowy_database2::services::database_view::DatabaseViewChanged; -use lib_dispatch::prelude::af_spawn; use crate::database::database_editor::DatabaseEditorTest; @@ -20,72 +19,6 @@ pub struct FilterRowChanged { pub(crate) hiding_num_of_rows: usize, } -pub enum FilterScript { - UpdateTextCell { - row_id: RowId, - text: String, - changed: Option, - }, - UpdateChecklistCell { - row_id: RowId, - selected_option_ids: Vec, - }, - UpdateSingleSelectCell { - row_id: RowId, - option_id: String, - changed: Option, - }, - CreateDataFilter { - parent_filter_id: Option, - field_type: FieldType, - data: BoxAny, - changed: Option, - }, - UpdateTextFilter { - filter: FilterPB, - condition: TextFilterConditionPB, - content: String, - changed: Option, - }, - CreateAndFilter { - parent_filter_id: Option, - changed: Option, - }, - CreateOrFilter { - parent_filter_id: Option, - changed: Option, - }, - DeleteFilter { - filter_id: String, - field_id: String, - changed: Option, - }, - // CreateSimpleAdvancedFilter, - // CreateComplexAdvancedFilter, - AssertFilterCount { - count: usize, - }, - AssertNumberOfVisibleRows { - expected: usize, - }, - AssertFilters { - /// 1. assert that the filter type is correct - /// 2. if the filter is data, assert that the field_type, condition and content are correct - /// (no field_id) - /// 3. if the filter is and/or, assert that each child is correct as well. - expected: Vec, - }, - // AssertSimpleAdvancedFilter, - // AssertComplexAdvancedFilterResult, - #[allow(dead_code)] - AssertGridSetting { - expected_setting: DatabaseViewSettingPB, - }, - Wait { - millisecond: u64, - }, -} - pub struct DatabaseFilterTest { inner: DatabaseEditorTest, recv: Option>, @@ -148,166 +81,171 @@ impl DatabaseFilterTest { } } - pub async fn run_scripts(&mut self, scripts: Vec) { - for script in scripts { - self.run_script(script).await; + pub async fn update_text_cell_with_change( + &mut self, + row_id: RowId, + text: String, + changed: Option, + ) { + self.subscribe_view_changed().await; + self.assert_future_changed(changed).await; + self.update_text_cell(row_id, &text).await.unwrap(); + } + + pub async fn update_checklist_cell(&mut self, row_id: RowId, selected_option_ids: Vec) { + self + .set_checklist_cell(row_id, selected_option_ids) + .await + .unwrap(); + } + + pub async fn update_single_select_cell_with_change( + &mut self, + row_id: RowId, + option_id: String, + changed: Option, + ) { + self.subscribe_view_changed().await; + self.assert_future_changed(changed).await; + self + .update_single_select_cell(row_id, &option_id) + .await + .unwrap(); + } + + pub async fn create_data_filter( + &mut self, + parent_filter_id: Option, + field_type: FieldType, + data: BoxAny, + changed: Option, + ) { + self.subscribe_view_changed().await; + self.assert_future_changed(changed).await; + let field = self.get_first_field(field_type).await; + let params = FilterChangeset::Insert { + parent_filter_id, + data: FilterInner::Data { + field_id: field.id, + field_type, + condition_and_content: data, + }, + }; + self + .editor + .modify_view_filters(&self.view_id, params) + .await + .unwrap(); + } + + pub async fn update_text_filter( + &mut self, + filter: FilterPB, + condition: TextFilterConditionPB, + content: String, + changed: Option, + ) { + self.subscribe_view_changed().await; + self.assert_future_changed(changed).await; + let current_filter = filter.data.unwrap(); + let params = FilterChangeset::UpdateData { + filter_id: filter.id, + data: FilterInner::Data { + field_id: current_filter.field_id, + field_type: current_filter.field_type, + condition_and_content: BoxAny::new(TextFilterPB { condition, content }), + }, + }; + self + .editor + .modify_view_filters(&self.view_id, params) + .await + .unwrap(); + } + + pub async fn create_and_filter( + &mut self, + parent_filter_id: Option, + changed: Option, + ) { + self.subscribe_view_changed().await; + self.assert_future_changed(changed).await; + let params = FilterChangeset::Insert { + parent_filter_id, + data: FilterInner::And { children: vec![] }, + }; + self + .editor + .modify_view_filters(&self.view_id, params) + .await + .unwrap(); + } + + pub async fn create_or_filter( + &mut self, + parent_filter_id: Option, + changed: Option, + ) { + self.subscribe_view_changed().await; + self.assert_future_changed(changed).await; + let params = FilterChangeset::Insert { + parent_filter_id, + data: FilterInner::Or { children: vec![] }, + }; + self + .editor + .modify_view_filters(&self.view_id, params) + .await + .unwrap(); + } + + pub async fn delete_filter(&mut self, filter_id: String, changed: Option) { + self.subscribe_view_changed().await; + self.assert_future_changed(changed).await; + let params = FilterChangeset::Delete { filter_id }; + self + .editor + .modify_view_filters(&self.view_id, params) + .await + .unwrap(); + } + + pub async fn assert_filter_count(&self, count: usize) { + let filters = self.editor.get_all_filters(&self.view_id).await.items; + assert_eq!(count, filters.len()); + } + + pub async fn assert_grid_setting(&self, expected_setting: DatabaseViewSettingPB) { + let setting = self + .editor + .get_database_view_setting(&self.view_id) + .await + .unwrap(); + assert_eq!(expected_setting, setting); + } + + pub async fn assert_filters(&self, expected: Vec) { + let actual = self.get_all_filters().await; + for (actual_filter, expected_filter) in actual.iter().zip(expected.iter()) { + Self::assert_filter(actual_filter, expected_filter); } } - pub async fn run_script(&mut self, script: FilterScript) { - match script { - FilterScript::UpdateTextCell { - row_id, - text, - changed, - } => { - self.subscribe_view_changed().await; - self.assert_future_changed(changed).await; - self.update_text_cell(row_id, &text).await.unwrap(); - }, - FilterScript::UpdateChecklistCell { - row_id, - selected_option_ids, - } => { - self - .set_checklist_cell(row_id, selected_option_ids) - .await - .unwrap(); - }, - FilterScript::UpdateSingleSelectCell { - row_id, - option_id, - changed, - } => { - self.subscribe_view_changed().await; - self.assert_future_changed(changed).await; - self - .update_single_select_cell(row_id, &option_id) - .await - .unwrap(); - }, - FilterScript::CreateDataFilter { - parent_filter_id, - field_type, - data, - changed, - } => { - self.subscribe_view_changed().await; - self.assert_future_changed(changed).await; - let field = self.get_first_field(field_type); - let params = FilterChangeset::Insert { - parent_filter_id, - data: FilterInner::Data { - field_id: field.id, - field_type, - condition_and_content: data, - }, - }; - self - .editor - .modify_view_filters(&self.view_id, params) - .await - .unwrap(); - }, - FilterScript::UpdateTextFilter { - filter, - condition, - content, - changed, - } => { - self.subscribe_view_changed().await; + pub async fn assert_number_of_visible_rows(&self, expected: usize) { + let (tx, rx) = tokio::sync::oneshot::channel(); + let _ = self + .editor + .open_database_view(&self.view_id, Some(tx)) + .await + .unwrap(); + rx.await.unwrap(); - self.assert_future_changed(changed).await; - let current_filter = filter.data.unwrap(); - let params = FilterChangeset::UpdateData { - filter_id: filter.id, - data: FilterInner::Data { - field_id: current_filter.field_id, - field_type: current_filter.field_type, - condition_and_content: BoxAny::new(TextFilterPB { condition, content }), - }, - }; - self - .editor - .modify_view_filters(&self.view_id, params) - .await - .unwrap(); - }, - FilterScript::CreateAndFilter { - parent_filter_id, - changed, - } => { - self.subscribe_view_changed().await; - self.assert_future_changed(changed).await; - let params = FilterChangeset::Insert { - parent_filter_id, - data: FilterInner::And { children: vec![] }, - }; - self - .editor - .modify_view_filters(&self.view_id, params) - .await - .unwrap(); - }, - FilterScript::CreateOrFilter { - parent_filter_id, - changed, - } => { - self.subscribe_view_changed().await; - self.assert_future_changed(changed).await; - let params = FilterChangeset::Insert { - parent_filter_id, - data: FilterInner::Or { children: vec![] }, - }; - self - .editor - .modify_view_filters(&self.view_id, params) - .await - .unwrap(); - }, - FilterScript::AssertFilterCount { count } => { - let filters = self.editor.get_all_filters(&self.view_id).await.items; - assert_eq!(count, filters.len()); - }, - FilterScript::DeleteFilter { - filter_id, - field_id, - changed, - } => { - self.subscribe_view_changed().await; - self.assert_future_changed(changed).await; - let params = FilterChangeset::Delete { - filter_id, - field_id, - }; - self - .editor - .modify_view_filters(&self.view_id, params) - .await - .unwrap(); - }, - FilterScript::AssertGridSetting { expected_setting } => { - let setting = self - .editor - .get_database_view_setting(&self.view_id) - .await - .unwrap(); - assert_eq!(expected_setting, setting); - }, - FilterScript::AssertFilters { expected } => { - let actual = self.get_all_filters().await; - for (actual_filter, expected_filter) in actual.iter().zip(expected.iter()) { - Self::assert_filter(actual_filter, expected_filter); - } - }, - FilterScript::AssertNumberOfVisibleRows { expected } => { - let grid = self.editor.get_database_data(&self.view_id).await.unwrap(); - assert_eq!(grid.rows.len(), expected); - }, - FilterScript::Wait { millisecond } => { - tokio::time::sleep(Duration::from_millis(millisecond)).await; - }, - } + let rows = self.editor.get_all_rows(&self.view_id).await.unwrap(); + assert_eq!(rows.len(), expected); + } + + pub async fn wait(&self, millisecond: u64) { + tokio::time::sleep(Duration::from_millis(millisecond)).await; } async fn subscribe_view_changed(&mut self) { @@ -326,7 +264,7 @@ impl DatabaseFilterTest { } let change = change.unwrap(); let mut receiver = self.recv.take().unwrap(); - af_spawn(async move { + tokio::spawn(async move { match tokio::time::timeout(Duration::from_secs(2), receiver.recv()).await { Ok(changed) => { if let DatabaseViewChanged::FilterNotification(notification) = changed.unwrap() { diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/select_option_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/select_option_filter_test.rs index eb808d0bc3..4a92f4c93f 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/select_option_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/select_option_filter_test.rs @@ -1,211 +1,192 @@ +use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; use flowy_database2::entities::{FieldType, SelectOptionFilterConditionPB, SelectOptionFilterPB}; use lib_infra::box_any::BoxAny; -use crate::database::filter_test::script::FilterScript::*; -use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; - #[tokio::test] async fn grid_filter_multi_select_is_empty_test() { let mut test = DatabaseFilterTest::new().await; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::MultiSelect, - data: BoxAny::new(SelectOptionFilterPB { + + // Create Multi-Select "Is Empty" filter + test + .create_data_filter( + None, + FieldType::MultiSelect, + BoxAny::new(SelectOptionFilterPB { condition: SelectOptionFilterConditionPB::OptionIsEmpty, option_ids: vec![], }), - changed: None, - }, - AssertNumberOfVisibleRows { expected: 2 }, - ]; - test.run_scripts(scripts).await; + None, + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(2).await; } #[tokio::test] async fn grid_filter_multi_select_is_not_empty_test() { let mut test = DatabaseFilterTest::new().await; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::MultiSelect, - data: BoxAny::new(SelectOptionFilterPB { + + // Create Multi-Select "Is Not Empty" filter + test + .create_data_filter( + None, + FieldType::MultiSelect, + BoxAny::new(SelectOptionFilterPB { condition: SelectOptionFilterConditionPB::OptionIsNotEmpty, option_ids: vec![], }), - changed: None, - }, - AssertNumberOfVisibleRows { expected: 5 }, - ]; - test.run_scripts(scripts).await; -} + None, + ) + .await; -#[tokio::test] -async fn grid_filter_multi_select_is_test() { - let mut test = DatabaseFilterTest::new().await; - let field = test.get_first_field(FieldType::MultiSelect); - let mut options = test.get_multi_select_type_option(&field.id); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::MultiSelect, - data: BoxAny::new(SelectOptionFilterPB { - condition: SelectOptionFilterConditionPB::OptionIs, - option_ids: vec![options.remove(0).id, options.remove(0).id], - }), - changed: None, - }, - AssertNumberOfVisibleRows { expected: 1 }, - ]; - test.run_scripts(scripts).await; -} - -#[tokio::test] -async fn grid_filter_multi_select_is_test2() { - let mut test = DatabaseFilterTest::new().await; - let field = test.get_first_field(FieldType::MultiSelect); - let mut options = test.get_multi_select_type_option(&field.id); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::MultiSelect, - data: BoxAny::new(SelectOptionFilterPB { - condition: SelectOptionFilterConditionPB::OptionIs, - option_ids: vec![options.remove(1).id], - }), - changed: None, - }, - AssertNumberOfVisibleRows { expected: 1 }, - ]; - test.run_scripts(scripts).await; + // Assert the number of visible rows + test.assert_number_of_visible_rows(5).await; } #[tokio::test] async fn grid_filter_single_select_is_empty_test() { let mut test = DatabaseFilterTest::new().await; let expected = 3; - let row_count = test.row_details.len(); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::SingleSelect, - data: BoxAny::new(SelectOptionFilterPB { + let row_count = test.rows.len(); + + // Create Single-Select "Is Empty" filter + test + .create_data_filter( + None, + FieldType::SingleSelect, + BoxAny::new(SelectOptionFilterPB { condition: SelectOptionFilterConditionPB::OptionIsEmpty, option_ids: vec![], }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_single_select_is_test() { let mut test = DatabaseFilterTest::new().await; - let field = test.get_first_field(FieldType::SingleSelect); - let mut options = test.get_single_select_type_option(&field.id); + let field = test.get_first_field(FieldType::SingleSelect).await; + let mut options = test.get_single_select_type_option(&field.id).await; let expected = 2; - let row_count = test.row_details.len(); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::SingleSelect, - data: BoxAny::new(SelectOptionFilterPB { + let row_count = test.rows.len(); + + // Create Single-Select "Is" filter with a specific option ID + test + .create_data_filter( + None, + FieldType::SingleSelect, + BoxAny::new(SelectOptionFilterPB { condition: SelectOptionFilterConditionPB::OptionIs, option_ids: vec![options.remove(0).id], }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, }), - }, - AssertNumberOfVisibleRows { expected: 2 }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(expected).await; } #[tokio::test] async fn grid_filter_single_select_is_test2() { let mut test = DatabaseFilterTest::new().await; - let field = test.get_first_field(FieldType::SingleSelect); + let field = test.get_first_field(FieldType::SingleSelect).await; let row_details = test.get_rows().await; - let mut options = test.get_single_select_type_option(&field.id); + let mut options = test.get_single_select_type_option(&field.id).await; let option = options.remove(0); - let row_count = test.row_details.len(); + let row_count = test.rows.len(); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::SingleSelect, - data: BoxAny::new(SelectOptionFilterPB { + // Create Single-Select "Is" filter + test + .create_data_filter( + None, + FieldType::SingleSelect, + BoxAny::new(SelectOptionFilterPB { condition: SelectOptionFilterConditionPB::OptionIs, option_ids: vec![option.id.clone()], }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - 2, }), - }, - AssertNumberOfVisibleRows { expected: 2 }, - UpdateSingleSelectCell { - row_id: row_details[1].row.id.clone(), - option_id: option.id.clone(), - changed: None, - }, - AssertNumberOfVisibleRows { expected: 3 }, - UpdateSingleSelectCell { - row_id: row_details[1].row.id.clone(), - option_id: "".to_string(), - changed: Some(FilterRowChanged { + ) + .await; + + test.assert_number_of_visible_rows(2).await; + + // Update single-select cell to match the filter + test + .update_single_select_cell_with_change(row_details[1].id.clone(), option.id.clone(), None) + .await; + test.assert_number_of_visible_rows(3).await; + + // Update single-select cell to remove the option + test + .update_single_select_cell_with_change( + row_details[1].id.clone(), + "".to_string(), + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 1, }), - }, - AssertNumberOfVisibleRows { expected: 2 }, - ]; - test.run_scripts(scripts).await; + ) + .await; + test.assert_number_of_visible_rows(2).await; } #[tokio::test] async fn grid_filter_multi_select_contains_test() { let mut test = DatabaseFilterTest::new().await; - let field = test.get_first_field(FieldType::MultiSelect); - let mut options = test.get_multi_select_type_option(&field.id); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::MultiSelect, - data: BoxAny::new(SelectOptionFilterPB { + let field = test.get_first_field(FieldType::MultiSelect).await; + let mut options = test.get_multi_select_type_option(&field.id).await; + + // Create Multi-Select "Contains" filter + test + .create_data_filter( + None, + FieldType::MultiSelect, + BoxAny::new(SelectOptionFilterPB { condition: SelectOptionFilterConditionPB::OptionContains, option_ids: vec![options.remove(0).id, options.remove(0).id], }), - changed: None, - }, - AssertNumberOfVisibleRows { expected: 5 }, - ]; - test.run_scripts(scripts).await; + None, + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(5).await; } #[tokio::test] async fn grid_filter_multi_select_contains_test2() { let mut test = DatabaseFilterTest::new().await; - let field = test.get_first_field(FieldType::MultiSelect); - let mut options = test.get_multi_select_type_option(&field.id); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::MultiSelect, - data: BoxAny::new(SelectOptionFilterPB { + let field = test.get_first_field(FieldType::MultiSelect).await; + let mut options = test.get_multi_select_type_option(&field.id).await; + + // Create Multi-Select "Contains" filter with a specific option ID + test + .create_data_filter( + None, + FieldType::MultiSelect, + BoxAny::new(SelectOptionFilterPB { condition: SelectOptionFilterConditionPB::OptionContains, option_ids: vec![options.remove(1).id], }), - changed: None, - }, - AssertNumberOfVisibleRows { expected: 4 }, - ]; - test.run_scripts(scripts).await; + None, + ) + .await; + + // Assert the number of visible rows + test.assert_number_of_visible_rows(4).await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/text_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/text_filter_test.rs index 600f4342fa..88b2c0382f 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/text_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/text_filter_test.rs @@ -1,285 +1,314 @@ +use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; use flowy_database2::entities::{FieldType, TextFilterConditionPB, TextFilterPB}; use lib_infra::box_any::BoxAny; -use crate::database::filter_test::script::FilterScript::*; -use crate::database::filter_test::script::*; - #[tokio::test] async fn grid_filter_text_is_empty_test() { let mut test = DatabaseFilterTest::new().await; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::RichText, - data: BoxAny::new(TextFilterPB { + + // Create Text "Is Empty" filter + test + .create_data_filter( + None, + FieldType::RichText, + BoxAny::new(TextFilterPB { condition: TextFilterConditionPB::TextIsEmpty, content: "".to_string(), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 5, }), - }, - AssertFilterCount { count: 1 }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert filter count + test.assert_filter_count(1).await; } #[tokio::test] async fn grid_filter_text_is_not_empty_test() { let mut test = DatabaseFilterTest::new().await; - // Only one row's text of the initial rows is "" - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::RichText, - data: BoxAny::new(TextFilterPB { + + // Create Text "Is Not Empty" filter + test + .create_data_filter( + None, + FieldType::RichText, + BoxAny::new(TextFilterPB { condition: TextFilterConditionPB::TextIsNotEmpty, content: "".to_string(), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 1, }), - }, - AssertFilterCount { count: 1 }, - ]; - test.run_scripts(scripts).await; - - let filter = test.database_filters().await.pop().unwrap(); - test - .run_scripts(vec![ - DeleteFilter { - filter_id: filter.id, - field_id: filter.data.unwrap().field_id, - changed: Some(FilterRowChanged { - showing_num_of_rows: 1, - hiding_num_of_rows: 0, - }), - }, - AssertFilterCount { count: 0 }, - ]) + ) .await; + + // Assert filter count + test.assert_filter_count(1).await; + + // Delete the filter + let filter = test.get_all_filters().await.pop().unwrap(); + test + .delete_filter( + filter.id, + Some(FilterRowChanged { + showing_num_of_rows: 1, + hiding_num_of_rows: 0, + }), + ) + .await; + + // Assert filter count after deletion + test.assert_filter_count(0).await; } #[tokio::test] async fn grid_filter_is_text_test() { let mut test = DatabaseFilterTest::new().await; - // Only one row's text of the initial rows is "A" - let scripts = vec![CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::RichText, - data: BoxAny::new(TextFilterPB { - condition: TextFilterConditionPB::TextIs, - content: "A".to_string(), - }), - changed: Some(FilterRowChanged { - showing_num_of_rows: 0, - hiding_num_of_rows: 5, - }), - }]; - test.run_scripts(scripts).await; + + // Create Text "Is" filter + test + .create_data_filter( + None, + FieldType::RichText, + BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextIs, + content: "A".to_string(), + }), + Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: 5, + }), + ) + .await; } #[tokio::test] async fn grid_filter_contain_text_test() { let mut test = DatabaseFilterTest::new().await; - let scripts = vec![CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::RichText, - data: BoxAny::new(TextFilterPB { - condition: TextFilterConditionPB::TextContains, - content: "A".to_string(), - }), - changed: Some(FilterRowChanged { - showing_num_of_rows: 0, - hiding_num_of_rows: 2, - }), - }]; - test.run_scripts(scripts).await; + + // Create Text "Contains" filter + test + .create_data_filter( + None, + FieldType::RichText, + BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextContains, + content: "A".to_string(), + }), + Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: 2, + }), + ) + .await; } #[tokio::test] async fn grid_filter_contain_text_test2() { let mut test = DatabaseFilterTest::new().await; - let row_detail = test.row_details.clone(); + let row_detail = test.rows.clone(); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::RichText, - data: BoxAny::new(TextFilterPB { + // Create Text "Contains" filter + test + .create_data_filter( + None, + FieldType::RichText, + BoxAny::new(TextFilterPB { condition: TextFilterConditionPB::TextContains, content: "A".to_string(), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 2, }), - }, - UpdateTextCell { - row_id: row_detail[1].row.id.clone(), - text: "ABC".to_string(), - changed: Some(FilterRowChanged { + ) + .await; + + // Update the text of a row + test + .update_text_cell_with_change( + row_detail[1].id.clone(), + "ABC".to_string(), + Some(FilterRowChanged { showing_num_of_rows: 1, hiding_num_of_rows: 0, }), - }, - ]; - test.run_scripts(scripts).await; + ) + .await; } #[tokio::test] async fn grid_filter_does_not_contain_text_test() { let mut test = DatabaseFilterTest::new().await; - // None of the initial rows contains the text "AB" - let scripts = vec![CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::RichText, - data: BoxAny::new(TextFilterPB { - condition: TextFilterConditionPB::TextDoesNotContain, - content: "AB".to_string(), - }), - changed: Some(FilterRowChanged { - showing_num_of_rows: 0, - hiding_num_of_rows: 0, - }), - }]; - test.run_scripts(scripts).await; + + // Create Text "Does Not Contain" filter + test + .create_data_filter( + None, + FieldType::RichText, + BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextDoesNotContain, + content: "AB".to_string(), + }), + Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: 0, + }), + ) + .await; } #[tokio::test] async fn grid_filter_start_with_text_test() { let mut test = DatabaseFilterTest::new().await; - let scripts = vec![CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::RichText, - data: BoxAny::new(TextFilterPB { - condition: TextFilterConditionPB::TextStartsWith, - content: "A".to_string(), - }), - changed: Some(FilterRowChanged { - showing_num_of_rows: 0, - hiding_num_of_rows: 3, - }), - }]; - test.run_scripts(scripts).await; + + // Create Text "Starts With" filter + test + .create_data_filter( + None, + FieldType::RichText, + BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextStartsWith, + content: "A".to_string(), + }), + Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: 3, + }), + ) + .await; } #[tokio::test] async fn grid_filter_ends_with_text_test() { let mut test = DatabaseFilterTest::new().await; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::RichText, - data: BoxAny::new(TextFilterPB { + + // Create Text "Ends With" filter + test + .create_data_filter( + None, + FieldType::RichText, + BoxAny::new(TextFilterPB { condition: TextFilterConditionPB::TextEndsWith, content: "A".to_string(), }), - changed: None, - }, - AssertNumberOfVisibleRows { expected: 2 }, - ]; - test.run_scripts(scripts).await; + None, + ) + .await; + + // Assert number of visible rows + test.assert_number_of_visible_rows(2).await; } #[tokio::test] async fn grid_update_text_filter_test() { let mut test = DatabaseFilterTest::new().await; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::RichText, - data: BoxAny::new(TextFilterPB { + + // Create Text "Ends With" filter + test + .create_data_filter( + None, + FieldType::RichText, + BoxAny::new(TextFilterPB { condition: TextFilterConditionPB::TextEndsWith, content: "A".to_string(), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 4, }), - }, - AssertNumberOfVisibleRows { expected: 2 }, - AssertFilterCount { count: 1 }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert number of visible rows and filter count + test.assert_number_of_visible_rows(2).await; + test.assert_filter_count(1).await; // Update the filter let filter = test.get_all_filters().await.pop().unwrap(); - let scripts = vec![ - UpdateTextFilter { + test + .update_text_filter( filter, - condition: TextFilterConditionPB::TextIs, - content: "A".to_string(), - changed: Some(FilterRowChanged { + TextFilterConditionPB::TextIs, + "A".to_string(), + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 1, }), - }, - AssertNumberOfVisibleRows { expected: 1 }, - ]; - test.run_scripts(scripts).await; + ) + .await; + + // Assert number of visible rows after update + test.assert_number_of_visible_rows(1).await; } #[tokio::test] async fn grid_filter_delete_test() { let mut test = DatabaseFilterTest::new().await; - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::RichText, - changed: None, - data: BoxAny::new(TextFilterPB { + + // Create Text "Is Empty" filter + test + .create_data_filter( + None, + FieldType::RichText, + BoxAny::new(TextFilterPB { condition: TextFilterConditionPB::TextIsEmpty, content: "".to_string(), }), - }, - AssertFilterCount { count: 1 }, - AssertNumberOfVisibleRows { expected: 1 }, - ]; - test.run_scripts(scripts).await; - - let filter = test.database_filters().await.pop().unwrap(); - test - .run_scripts(vec![ - DeleteFilter { - filter_id: filter.id, - field_id: filter.data.unwrap().field_id, - changed: None, - }, - AssertFilterCount { count: 0 }, - AssertNumberOfVisibleRows { expected: 7 }, - ]) + None, + ) .await; + + // Assert filter count and number of visible rows + test.assert_filter_count(1).await; + test.assert_number_of_visible_rows(1).await; + + // Delete the filter + let filter = test.get_all_filters().await.pop().unwrap(); + test.delete_filter(filter.id, None).await; + + // Assert filter count and number of visible rows after deletion + test.assert_filter_count(0).await; + test.assert_number_of_visible_rows(7).await; } #[tokio::test] async fn grid_filter_update_empty_text_cell_test() { let mut test = DatabaseFilterTest::new().await; - let row_details = test.row_details.clone(); - let scripts = vec![ - CreateDataFilter { - parent_filter_id: None, - field_type: FieldType::RichText, - data: BoxAny::new(TextFilterPB { + let row = test.rows.clone(); + + // Create Text "Is Empty" filter + test + .create_data_filter( + None, + FieldType::RichText, + BoxAny::new(TextFilterPB { condition: TextFilterConditionPB::TextIsEmpty, content: "".to_string(), }), - changed: Some(FilterRowChanged { + Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 5, }), - }, - AssertFilterCount { count: 1 }, - UpdateTextCell { - row_id: row_details[0].row.id.clone(), - text: "".to_string(), - changed: Some(FilterRowChanged { + ) + .await; + + // Assert filter count + test.assert_filter_count(1).await; + + // Update the text of a row + test + .update_text_cell_with_change( + row[0].id.clone(), + "".to_string(), + Some(FilterRowChanged { showing_num_of_rows: 1, hiding_num_of_rows: 0, }), - }, - ]; - test.run_scripts(scripts).await; + ) + .await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/time_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/time_filter_test.rs new file mode 100644 index 0000000000..6512947c16 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/time_filter_test.rs @@ -0,0 +1,133 @@ +use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; +use flowy_database2::entities::{FieldType, NumberFilterConditionPB, TimeFilterPB}; +use lib_infra::box_any::BoxAny; + +#[tokio::test] +async fn grid_filter_time_is_equal_test() { + let mut test = DatabaseFilterTest::new().await; + let row_count = test.rows.len(); + let expected = 1; + + // Create Time "Equal" filter + test + .create_data_filter( + None, + FieldType::Time, + BoxAny::new(TimeFilterPB { + condition: NumberFilterConditionPB::Equal, + content: "75".to_string(), + }), + Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: row_count - expected, + }), + ) + .await; + + // Assert number of visible rows + test.assert_number_of_visible_rows(expected).await; +} + +#[tokio::test] +async fn grid_filter_time_is_less_than_test() { + let mut test = DatabaseFilterTest::new().await; + let row_count = test.rows.len(); + let expected = 1; + + // Create Time "Less Than" filter + test + .create_data_filter( + None, + FieldType::Time, + BoxAny::new(TimeFilterPB { + condition: NumberFilterConditionPB::LessThan, + content: "80".to_string(), + }), + Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: row_count - expected, + }), + ) + .await; + + // Assert number of visible rows + test.assert_number_of_visible_rows(expected).await; +} + +#[tokio::test] +async fn grid_filter_time_is_less_than_or_equal_test() { + let mut test = DatabaseFilterTest::new().await; + let row_count = test.rows.len(); + let expected = 1; + + // Create Time "Less Than or Equal" filter + test + .create_data_filter( + None, + FieldType::Time, + BoxAny::new(TimeFilterPB { + condition: NumberFilterConditionPB::LessThanOrEqualTo, + content: "75".to_string(), + }), + Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: row_count - expected, + }), + ) + .await; + + // Assert number of visible rows + test.assert_number_of_visible_rows(expected).await; +} + +#[tokio::test] +async fn grid_filter_time_is_empty_test() { + let mut test = DatabaseFilterTest::new().await; + let row_count = test.rows.len(); + let expected = 6; + + // Create Time "Is Empty" filter + test + .create_data_filter( + None, + FieldType::Time, + BoxAny::new(TimeFilterPB { + condition: NumberFilterConditionPB::NumberIsEmpty, + content: "".to_string(), + }), + Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: row_count - expected, + }), + ) + .await; + + // Assert number of visible rows + test.assert_number_of_visible_rows(expected).await; +} + +#[tokio::test] +async fn grid_filter_time_is_not_empty_test() { + let mut test = DatabaseFilterTest::new().await; + let row_count = test.rows.len(); + let expected = 1; + + // Create Time "Is Not Empty" filter + test + .create_data_filter( + None, + FieldType::Time, + BoxAny::new(TimeFilterPB { + condition: NumberFilterConditionPB::NumberIsNotEmpty, + content: "".to_string(), + }), + Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: row_count - expected, + }), + ) + .await; + + // Assert number of visible rows + test.assert_number_of_visible_rows(expected).await; +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/group_test/date_group_test.rs b/frontend/rust-lib/flowy-database2/tests/database/group_test/date_group_test.rs index 418dafa0f7..1110e0a8d7 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/group_test/date_group_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/group_test/date_group_test.rs @@ -1,19 +1,13 @@ -use std::collections::HashMap; -use std::vec; - -use chrono::NaiveDateTime; -use chrono::{offset, Duration}; - -use flowy_database2::entities::{CreateRowPayloadPB, FieldType}; -use flowy_database2::services::field::DateCellData; - use crate::database::group_test::script::DatabaseGroupTest; -use crate::database::group_test::script::GroupScript::*; +use chrono::{offset, Duration, NaiveDateTime}; +use collab_database::fields::date_type_option::DateCellData; +use flowy_database2::entities::{CreateRowPayloadPB, FieldType}; +use std::collections::HashMap; #[tokio::test] async fn group_by_date_test() { let date_diffs = vec![-1, 0, 7, -15, -1]; - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let date_field = test.get_field(FieldType::DateTime).await; for diff in date_diffs { @@ -53,147 +47,82 @@ async fn group_by_date_test() { .format("%Y/%m/%d") .to_string(); - let scripts = vec![ - GroupByField { - field_id: date_field.id.clone(), - }, - AssertGroupCount(7), - AssertGroupRowCount { - group_index: 0, - row_count: 0, - }, - // Added via `make_test_board` - AssertGroupId { - group_index: 1, - group_id: "2022/03/01".to_string(), - }, - AssertGroupRowCount { - group_index: 1, - row_count: 3, - }, - // Added via `make_test_board` - AssertGroupId { - group_index: 2, - group_id: "2022/11/01".to_string(), - }, - AssertGroupRowCount { - group_index: 2, - row_count: 2, - }, - AssertGroupId { - group_index: 3, - group_id: last_30_days, - }, - AssertGroupRowCount { - group_index: 3, - row_count: 1, - }, - AssertGroupId { - group_index: 4, - group_id: last_day, - }, - AssertGroupRowCount { - group_index: 4, - row_count: 2, - }, - AssertGroupId { - group_index: 5, - group_id: today.format("%Y/%m/%d").to_string(), - }, - AssertGroupRowCount { - group_index: 5, - row_count: 1, - }, - AssertGroupId { - group_index: 6, - group_id: next_7_days, - }, - AssertGroupRowCount { - group_index: 6, - row_count: 1, - }, - ]; - test.run_scripts(scripts).await; + // Group by date field + test.group_by_field(&date_field.id).await; + test.assert_group_count(7).await; + + test.assert_group_row_count(0, 0).await; // Empty group + test.assert_group_id(1, "2022/03/01").await; + test.assert_group_row_count(1, 3).await; + test.assert_group_id(2, "2022/11/01").await; + test.assert_group_row_count(2, 2).await; + test.assert_group_id(3, &last_30_days).await; + test.assert_group_row_count(3, 1).await; + test.assert_group_id(4, &last_day).await; + test.assert_group_row_count(4, 2).await; + test + .assert_group_id(5, &today.format("%Y/%m/%d").to_string()) + .await; + test.assert_group_row_count(5, 1).await; + test.assert_group_id(6, &next_7_days).await; + test.assert_group_row_count(6, 1).await; } #[tokio::test] async fn change_row_group_on_date_cell_changed_test() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let date_field = test.get_field(FieldType::DateTime).await; - let scripts = vec![ - GroupByField { - field_id: date_field.id.clone(), - }, - AssertGroupCount(3), - // Nov 2, 2022 - UpdateGroupedCellWithData { - from_group_index: 1, - row_index: 0, - cell_data: "1667408732".to_string(), - }, - AssertGroupRowCount { - group_index: 1, - row_count: 2, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 3, - }, - ]; - test.run_scripts(scripts).await; + + // Group by date field + test.group_by_field(&date_field.id).await; + test.assert_group_count(3).await; + + // Update date cell to a new timestamp + test + .update_grouped_cell_with_data(1, 0, "1667408732".to_string()) + .await; + + // Check that row counts in groups have updated correctly + test.assert_group_row_count(1, 2).await; + test.assert_group_row_count(2, 3).await; } #[tokio::test] async fn change_date_on_moving_row_to_another_group() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let date_field = test.get_field(FieldType::DateTime).await; - let scripts = vec![ - GroupByField { - field_id: date_field.id.clone(), - }, - AssertGroupCount(3), - AssertGroupRowCount { - group_index: 1, - row_count: 3, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 2, - }, - MoveRow { - from_group_index: 1, - from_row_index: 0, - to_group_index: 2, - to_row_index: 0, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 2, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 3, - }, - AssertGroupId { - group_index: 2, - group_id: "2022/11/01".to_string(), - }, - ]; - test.run_scripts(scripts).await; + // Group by date field + test.group_by_field(&date_field.id).await; + test.assert_group_count(3).await; + test.assert_group_row_count(1, 3).await; + test.assert_group_row_count(2, 2).await; + + // Move a row from one group to another + test.move_row(1, 0, 2, 0).await; + + // Verify row counts after the move + test.assert_group_row_count(1, 2).await; + test.assert_group_row_count(2, 3).await; + test.assert_group_id(2, "2022/11/01").await; + + // Verify the timestamp of the moved row matches the new group's date let group = test.group_at_index(2).await; - let rows = group.clone().rows; + let rows = group.rows; let row_id = &rows.first().unwrap().id; - let row_detail = test + let row = test .get_rows() .await .into_iter() - .find(|r| r.row.id.to_string() == *row_id) + .find(|r| r.id.to_string() == *row_id) .unwrap(); - let cell = row_detail.row.cells.get(&date_field.id.clone()).unwrap(); + let cell = row.cells.get(&date_field.id).unwrap(); let date_cell = DateCellData::from(cell); - let date_time = + let expected_date_time = NaiveDateTime::parse_from_str("2022/11/01 00:00:00", "%Y/%m/%d %H:%M:%S").unwrap(); - assert_eq!(date_time.timestamp(), date_cell.timestamp.unwrap()); + assert_eq!( + expected_date_time.and_utc().timestamp(), + date_cell.timestamp.unwrap() + ); } diff --git a/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs index 035795f88c..1b700b96f3 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs @@ -1,73 +1,15 @@ +use crate::database::database_editor::DatabaseEditorTest; +use collab_database::fields::select_type_option::{SelectOption, SingleSelectTypeOption}; use collab_database::fields::Field; use collab_database::rows::RowId; - use flowy_database2::entities::{CreateRowPayloadPB, FieldType, GroupPB, RowMetaPB}; use flowy_database2::services::cell::{ delete_select_option_cell, insert_date_cell, insert_select_option_cell, insert_url_cell, }; use flowy_database2::services::field::{ - edit_single_select_type_option, SelectOption, SelectTypeOptionSharedAction, - SingleSelectTypeOption, + edit_single_select_type_option, SelectTypeOptionSharedAction, }; - -use crate::database::database_editor::DatabaseEditorTest; - -pub enum GroupScript { - AssertGroupRowCount { - group_index: usize, - row_count: usize, - }, - AssertGroupCount(usize), - AssertGroup { - group_index: usize, - expected_group: GroupPB, - }, - AssertRow { - group_index: usize, - row_index: usize, - row: RowMetaPB, - }, - MoveRow { - from_group_index: usize, - from_row_index: usize, - to_group_index: usize, - to_row_index: usize, - }, - CreateRow { - group_index: usize, - }, - DeleteRow { - group_index: usize, - row_index: usize, - }, - UpdateGroupedCell { - from_group_index: usize, - row_index: usize, - to_group_index: usize, - }, - UpdateGroupedCellWithData { - from_group_index: usize, - row_index: usize, - cell_data: String, - }, - MoveGroup { - from_group_index: usize, - to_group_index: usize, - }, - UpdateSingleSelectSelectOption { - inserted_options: Vec, - }, - GroupByField { - field_id: String, - }, - AssertGroupId { - group_index: usize, - group_id: String, - }, - CreateGroup { - name: String, - }, -} +use std::time::Duration; pub struct DatabaseGroupTest { inner: DatabaseEditorTest, @@ -79,197 +21,171 @@ impl DatabaseGroupTest { Self { inner: editor_test } } - pub async fn run_scripts(&mut self, scripts: Vec) { - for script in scripts { - self.run_script(script).await; - } + pub async fn assert_group_row_count(&self, group_index: usize, row_count: usize) { + tokio::time::sleep(Duration::from_secs(3)).await; // Sleep to allow updates to complete + assert_eq!(row_count, self.group_at_index(group_index).await.rows.len()); } - pub async fn run_script(&mut self, script: GroupScript) { - match script { - GroupScript::AssertGroupRowCount { - group_index, - row_count, - } => { - assert_eq!(row_count, self.group_at_index(group_index).await.rows.len()); - }, - GroupScript::AssertGroupCount(count) => { - let groups = self.editor.load_groups(&self.view_id).await.unwrap(); - assert_eq!(count, groups.len()); - }, - GroupScript::MoveRow { - from_group_index, - from_row_index, - to_group_index, - to_row_index, - } => { - let groups: Vec = self.editor.load_groups(&self.view_id).await.unwrap().items; - let from_group = groups.get(from_group_index).unwrap(); - let from_row = from_group.rows.get(from_row_index).unwrap(); - let to_group = groups.get(to_group_index).unwrap(); - let to_row = to_group.rows.get(to_row_index).unwrap(); - let from_row = RowId::from(from_row.id.clone()); - let to_row = RowId::from(to_row.id.clone()); + pub async fn assert_group_count(&self, count: usize) { + let groups = self.editor.load_groups(&self.view_id).await.unwrap(); + assert_eq!(count, groups.len()); + } - self - .editor - .move_group_row( - &self.view_id, - &from_group.group_id, - &to_group.group_id, - from_row, - Some(to_row), - ) - .await - .unwrap(); - }, - GroupScript::AssertRow { - group_index, - row_index, - row, - } => { - // - let group = self.group_at_index(group_index).await; - let compare_row = group.rows.get(row_index).unwrap().clone(); - assert_eq!(row.id, compare_row.id); - }, - GroupScript::CreateRow { group_index } => { - let group = self.group_at_index(group_index).await; - let params = CreateRowPayloadPB { - view_id: self.view_id.clone(), - row_position: Default::default(), - group_id: Some(group.group_id), - data: Default::default(), - }; - let _ = self.editor.create_row(params).await.unwrap(); - }, - GroupScript::DeleteRow { - group_index, - row_index, - } => { - let row = self.row_at_index(group_index, row_index).await; - let row_id = RowId::from(row.id); - self.editor.delete_row(&row_id).await; - }, - GroupScript::UpdateGroupedCell { - from_group_index, - row_index, - to_group_index, - } => { - let from_group = self.group_at_index(from_group_index).await; - let to_group = self.group_at_index(to_group_index).await; - let field_id = from_group.field_id; - let field = self.editor.get_field(&field_id).unwrap(); - let field_type = FieldType::from(field.field_type); + pub async fn move_row( + &self, + from_group_index: usize, + from_row_index: usize, + to_group_index: usize, + to_row_index: usize, + ) { + let groups: Vec = self.editor.load_groups(&self.view_id).await.unwrap().items; + let from_group = groups.get(from_group_index).unwrap(); + let from_row = from_group.rows.get(from_row_index).unwrap(); + let to_group = groups.get(to_group_index).unwrap(); + let to_row = to_group.rows.get(to_row_index).unwrap(); + let from_row = RowId::from(from_row.id.clone()); + let to_row = RowId::from(to_row.id.clone()); - let cell = if to_group.is_default { - match field_type { - FieldType::SingleSelect => { - delete_select_option_cell(vec![to_group.group_id.clone()], &field) - }, - FieldType::MultiSelect => { - delete_select_option_cell(vec![to_group.group_id.clone()], &field) - }, - _ => { - panic!("Unsupported group field type"); - }, - } - } else { - match field_type { - FieldType::SingleSelect => { - insert_select_option_cell(vec![to_group.group_id.clone()], &field) - }, - FieldType::MultiSelect => { - insert_select_option_cell(vec![to_group.group_id.clone()], &field) - }, - FieldType::URL => insert_url_cell(to_group.group_id.clone(), &field), - _ => { - panic!("Unsupported group field type"); - }, - } - }; + self + .editor + .move_group_row( + &self.view_id, + &from_group.group_id, + &to_group.group_id, + from_row, + Some(to_row), + ) + .await + .unwrap(); + } - let row_id = RowId::from(self.row_at_index(from_group_index, row_index).await.id); - self - .editor - .update_cell(&self.view_id, &row_id, &field_id, cell) - .await - .unwrap(); - }, - GroupScript::UpdateGroupedCellWithData { - from_group_index, - row_index, - cell_data, - } => { - let from_group = self.group_at_index(from_group_index).await; - let field_id = from_group.field_id; - let field = self.editor.get_field(&field_id).unwrap(); - let field_type = FieldType::from(field.field_type); - let cell = match field_type { - FieldType::URL => insert_url_cell(cell_data, &field), - FieldType::DateTime => { - insert_date_cell(cell_data.parse::().unwrap(), None, Some(true), &field) - }, - _ => { - panic!("Unsupported group field type"); - }, - }; + pub async fn assert_row(&self, group_index: usize, row_index: usize, row: RowMetaPB) { + let group = self.group_at_index(group_index).await; + let compare_row = group.rows.get(row_index).unwrap().clone(); + assert_eq!(row.id, compare_row.id); + } - let row_id = RowId::from(self.row_at_index(from_group_index, row_index).await.id); - self - .editor - .update_cell(&self.view_id, &row_id, &field_id, cell) - .await - .unwrap(); + pub async fn create_row(&self, group_index: usize) { + let group = self.group_at_index(group_index).await; + let params = CreateRowPayloadPB { + view_id: self.view_id.clone(), + row_position: Default::default(), + group_id: Some(group.group_id), + data: Default::default(), + }; + self.editor.create_row(params).await.unwrap(); + } + + pub async fn delete_row(&self, group_index: usize, row_index: usize) { + let row = self.row_at_index(group_index, row_index).await; + let row_ids = vec![RowId::from(row.id)]; + self.editor.delete_rows(&row_ids).await; + tokio::time::sleep(Duration::from_secs(1)).await; // Sleep to allow deletion to propagate + } + + pub async fn update_grouped_cell( + &self, + from_group_index: usize, + row_index: usize, + to_group_index: usize, + ) { + let from_group = self.group_at_index(from_group_index).await; + let to_group = self.group_at_index(to_group_index).await; + let field_id = from_group.field_id; + let field = self.editor.get_field(&field_id).await.unwrap(); + let field_type = FieldType::from(field.field_type); + + let cell = if to_group.is_default { + match field_type { + FieldType::SingleSelect | FieldType::MultiSelect => { + delete_select_option_cell(vec![to_group.group_id.clone()], &field) + }, + _ => panic!("Unsupported group field type"), + } + } else { + match field_type { + FieldType::SingleSelect | FieldType::MultiSelect => { + insert_select_option_cell(vec![to_group.group_id.clone()], &field) + }, + FieldType::URL => insert_url_cell(to_group.group_id.clone(), &field), + _ => panic!("Unsupported group field type"), + } + }; + + let row_id = RowId::from(self.row_at_index(from_group_index, row_index).await.id); + self + .editor + .update_cell(&self.view_id, &row_id, &field_id, cell) + .await + .unwrap(); + } + + pub async fn update_grouped_cell_with_data( + &self, + from_group_index: usize, + row_index: usize, + cell_data: String, + ) { + let from_group = self.group_at_index(from_group_index).await; + let field_id = from_group.field_id; + let field = self.editor.get_field(&field_id).await.unwrap(); + let field_type = FieldType::from(field.field_type); + let cell = match field_type { + FieldType::URL => insert_url_cell(cell_data, &field), + FieldType::DateTime => { + insert_date_cell(cell_data.parse::().unwrap(), None, Some(true), &field) }, - GroupScript::MoveGroup { - from_group_index, - to_group_index, - } => { - let from_group = self.group_at_index(from_group_index).await; - let to_group = self.group_at_index(to_group_index).await; - self - .editor - .move_group(&self.view_id, &from_group.group_id, &to_group.group_id) - .await - .unwrap(); - }, - GroupScript::AssertGroup { - group_index, - expected_group: group_pb, - } => { - let group = self.group_at_index(group_index).await; - assert_eq!(group.group_id, group_pb.group_id); - }, - GroupScript::UpdateSingleSelectSelectOption { inserted_options } => { - self - .edit_single_select_type_option(|type_option| { - for inserted_option in inserted_options { - type_option.insert_option(inserted_option); - } - }) - .await; - }, - GroupScript::GroupByField { field_id } => { - self - .editor - .group_by_field(&self.view_id, &field_id) - .await - .unwrap(); - }, - GroupScript::AssertGroupId { - group_index, - group_id, - } => { - let group = self.group_at_index(group_index).await; - assert_eq!(group_id, group.group_id, "group index: {}", group_index); - }, - GroupScript::CreateGroup { name } => self - .editor - .create_group(&self.view_id, &name) - .await - .unwrap(), - } + _ => panic!("Unsupported group field type"), + }; + + let row_id = RowId::from(self.row_at_index(from_group_index, row_index).await.id); + self + .editor + .update_cell(&self.view_id, &row_id, &field_id, cell) + .await + .unwrap(); + } + + pub async fn move_group(&self, from_group_index: usize, to_group_index: usize) { + let from_group = self.group_at_index(from_group_index).await; + let to_group = self.group_at_index(to_group_index).await; + self + .editor + .move_group(&self.view_id, &from_group.group_id, &to_group.group_id) + .await + .unwrap(); + } + + pub async fn assert_group(&self, group_index: usize, expected_group: GroupPB) { + let group = self.group_at_index(group_index).await; + assert_eq!(group.group_id, expected_group.group_id); + } + + pub async fn update_single_select_option(&self, inserted_options: Vec) { + self + .edit_single_select_type_option(|type_option| { + for inserted_option in inserted_options { + type_option.insert_option(inserted_option); + } + }) + .await; + } + + pub async fn group_by_field(&self, field_id: &str) { + self + .editor + .group_by_field(&self.view_id, field_id) + .await + .unwrap(); + } + + pub async fn assert_group_id(&self, group_index: usize, group_id: &str) { + let group = self.group_at_index(group_index).await; + assert_eq!(group_id, group.group_id, "group index: {}", group_index); + } + + pub async fn create_group(&self, name: &str) { + self.editor.create_group(&self.view_id, name).await.unwrap(); } pub async fn group_at_index(&self, index: usize) -> GroupPB { @@ -309,11 +225,9 @@ impl DatabaseGroupTest { self .inner .get_fields() + .await .into_iter() - .find(|field| { - let ft = FieldType::from(field.field_type); - ft == field_type - }) + .find(|field| FieldType::from(field.field_type) == field_type) .unwrap() } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/group_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/group_test/test.rs index 33e2b1563c..b93df800e1 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/group_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/group_test/test.rs @@ -1,506 +1,234 @@ -use flowy_database2::services::field::SelectOption; - use crate::database::group_test::script::DatabaseGroupTest; -use crate::database::group_test::script::GroupScript::*; +use collab_database::fields::select_type_option::SelectOption; #[tokio::test] async fn group_init_test() { - let mut test = DatabaseGroupTest::new().await; - let scripts = vec![ - AssertGroupCount(4), - AssertGroupRowCount { - group_index: 1, - row_count: 2, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 2, - }, - AssertGroupRowCount { - group_index: 3, - row_count: 1, - }, - AssertGroupRowCount { - group_index: 0, - row_count: 0, - }, - ]; - test.run_scripts(scripts).await; + let test = DatabaseGroupTest::new().await; + test.assert_group_count(4).await; + test.assert_group_row_count(1, 2).await; + test.assert_group_row_count(2, 2).await; + test.assert_group_row_count(3, 1).await; + test.assert_group_row_count(0, 0).await; } #[tokio::test] async fn group_move_row_test() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let group = test.group_at_index(1).await; - let scripts = vec![ - // Move the row at 0 in group0 to group1 at 1 - MoveRow { - from_group_index: 1, - from_row_index: 0, - to_group_index: 1, - to_row_index: 1, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 2, - }, - AssertRow { - group_index: 1, - row_index: 1, - row: group.rows.first().unwrap().clone(), - }, - ]; - test.run_scripts(scripts).await; + + test.move_row(1, 0, 1, 1).await; + test.assert_group_row_count(1, 2).await; + test + .assert_row(1, 1, group.rows.first().unwrap().clone()) + .await; } #[tokio::test] -async fn group_move_row_to_other_group_test() { - let mut test = DatabaseGroupTest::new().await; - let group = test.group_at_index(1).await; - let scripts = vec![ - MoveRow { - from_group_index: 1, - from_row_index: 0, - to_group_index: 2, - to_row_index: 1, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 1, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 3, - }, - AssertRow { - group_index: 2, - row_index: 1, - row: group.rows.first().unwrap().clone(), - }, - ]; - test.run_scripts(scripts).await; +async fn test_row_movement_between_groups_with_assertions() { + let test = DatabaseGroupTest::new().await; + for _ in 0..5 { + test.move_row(1, 0, 2, 1).await; + test.assert_group_row_count(1, 1).await; + test.assert_group_row_count(2, 3).await; + + // Move the row back to the original group + test.move_row(2, 1, 1, 0).await; + test.assert_group_row_count(2, 2).await; + test.assert_group_row_count(1, 2).await; + + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } } #[tokio::test] async fn group_move_two_row_to_other_group_test() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let group_1 = test.group_at_index(1).await; - let scripts = vec![ - // Move row at index 0 from group 1 to group 2 at index 1 - MoveRow { - from_group_index: 1, - from_row_index: 0, - to_group_index: 2, - to_row_index: 1, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 1, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 3, - }, - AssertRow { - group_index: 2, - row_index: 1, - row: group_1.rows.first().unwrap().clone(), - }, - ]; - test.run_scripts(scripts).await; + + test.move_row(1, 0, 2, 1).await; + test.assert_group_row_count(1, 1).await; + test.assert_group_row_count(2, 3).await; + test + .assert_row(2, 1, group_1.rows.first().unwrap().clone()) + .await; let group_1 = test.group_at_index(1).await; - // Move row at index 0 from group 1 to group 2 at index 1 - let scripts = vec![ - MoveRow { - from_group_index: 1, - from_row_index: 0, - to_group_index: 2, - to_row_index: 1, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 0, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 4, - }, - AssertRow { - group_index: 2, - row_index: 1, - row: group_1.rows.first().unwrap().clone(), - }, - ]; - test.run_scripts(scripts).await; + test.move_row(1, 0, 2, 1).await; + test.assert_group_row_count(1, 0).await; + test.assert_group_row_count(2, 4).await; + test + .assert_row(2, 1, group_1.rows.first().unwrap().clone()) + .await; } #[tokio::test] async fn group_move_row_to_other_group_and_reorder_from_up_to_down_test() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let group_1 = test.group_at_index(1).await; let group_2 = test.group_at_index(2).await; - let scripts = vec![ - MoveRow { - from_group_index: 1, - from_row_index: 0, - to_group_index: 2, - to_row_index: 1, - }, - AssertRow { - group_index: 2, - row_index: 1, - row: group_1.rows.first().unwrap().clone(), - }, - ]; - test.run_scripts(scripts).await; - let scripts = vec![ - MoveRow { - from_group_index: 2, - from_row_index: 0, - to_group_index: 2, - to_row_index: 2, - }, - AssertRow { - group_index: 2, - row_index: 2, - row: group_2.rows.first().unwrap().clone(), - }, - ]; - test.run_scripts(scripts).await; + test.move_row(1, 0, 2, 1).await; + test + .assert_row(2, 1, group_1.rows.first().unwrap().clone()) + .await; + + test.move_row(2, 0, 2, 2).await; + test + .assert_row(2, 2, group_2.rows.first().unwrap().clone()) + .await; } #[tokio::test] async fn group_move_row_to_other_group_and_reorder_from_bottom_to_up_test() { - let mut test = DatabaseGroupTest::new().await; - let scripts = vec![MoveRow { - from_group_index: 1, - from_row_index: 0, - to_group_index: 2, - to_row_index: 1, - }]; - test.run_scripts(scripts).await; + let test = DatabaseGroupTest::new().await; + test.move_row(1, 0, 2, 1).await; let group = test.group_at_index(2).await; - let scripts = vec![ - AssertGroupRowCount { - group_index: 2, - row_count: 3, - }, - MoveRow { - from_group_index: 2, - from_row_index: 2, - to_group_index: 2, - to_row_index: 0, - }, - AssertRow { - group_index: 2, - row_index: 0, - row: group.rows.get(2).unwrap().clone(), - }, - ]; - test.run_scripts(scripts).await; + test.assert_group_row_count(2, 3).await; + + test.move_row(2, 2, 2, 0).await; + test + .assert_row(2, 0, group.rows.get(2).unwrap().clone()) + .await; } + #[tokio::test] async fn group_create_row_test() { - let mut test = DatabaseGroupTest::new().await; - let scripts = vec![ - CreateRow { group_index: 1 }, - AssertGroupRowCount { - group_index: 1, - row_count: 3, - }, - CreateRow { group_index: 2 }, - CreateRow { group_index: 2 }, - AssertGroupRowCount { - group_index: 2, - row_count: 4, - }, - ]; - test.run_scripts(scripts).await; + let test = DatabaseGroupTest::new().await; + test.create_row(1).await; + test.assert_group_row_count(1, 3).await; + + test.create_row(2).await; + test.create_row(2).await; + test.assert_group_row_count(2, 4).await; } #[tokio::test] async fn group_delete_row_test() { - let mut test = DatabaseGroupTest::new().await; - let scripts = vec![ - DeleteRow { - group_index: 1, - row_index: 0, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 1, - }, - ]; - test.run_scripts(scripts).await; + let test = DatabaseGroupTest::new().await; + test.delete_row(1, 0).await; + test.assert_group_row_count(1, 1).await; } #[tokio::test] async fn group_delete_all_row_test() { - let mut test = DatabaseGroupTest::new().await; - let scripts = vec![ - DeleteRow { - group_index: 1, - row_index: 0, - }, - DeleteRow { - group_index: 1, - row_index: 0, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 0, - }, - ]; - test.run_scripts(scripts).await; + let test = DatabaseGroupTest::new().await; + test.delete_row(1, 0).await; + test.delete_row(1, 0).await; + test.assert_group_row_count(1, 0).await; } #[tokio::test] async fn group_update_row_test() { - let mut test = DatabaseGroupTest::new().await; - let scripts = vec![ - // Update the row at 0 in group0 by setting the row's group field data - UpdateGroupedCell { - from_group_index: 1, - row_index: 0, - to_group_index: 2, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 1, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 3, - }, - ]; - test.run_scripts(scripts).await; + let test = DatabaseGroupTest::new().await; + test.update_grouped_cell(1, 0, 2).await; + test.assert_group_row_count(1, 1).await; + test.assert_group_row_count(2, 3).await; } #[tokio::test] async fn group_reorder_group_test() { - let mut test = DatabaseGroupTest::new().await; - let scripts = vec![ - // Update the row at 0 in group0 by setting the row's group field data - UpdateGroupedCell { - from_group_index: 1, - row_index: 0, - to_group_index: 2, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 1, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 3, - }, - ]; - test.run_scripts(scripts).await; + let test = DatabaseGroupTest::new().await; + test.update_grouped_cell(1, 0, 2).await; + test.assert_group_row_count(1, 1).await; + test.assert_group_row_count(2, 3).await; } #[tokio::test] async fn group_move_to_default_group_test() { - let mut test = DatabaseGroupTest::new().await; - let scripts = vec![ - UpdateGroupedCell { - from_group_index: 1, - row_index: 0, - to_group_index: 0, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 1, - }, - AssertGroupRowCount { - group_index: 0, - row_count: 1, - }, - ]; - test.run_scripts(scripts).await; + let test = DatabaseGroupTest::new().await; + test.update_grouped_cell(1, 0, 0).await; + test.assert_group_row_count(1, 1).await; + test.assert_group_row_count(0, 1).await; } #[tokio::test] async fn group_move_from_default_group_test() { - let mut test = DatabaseGroupTest::new().await; - // Move one row from group 1 to group 0 - let scripts = vec![ - UpdateGroupedCell { - from_group_index: 1, - row_index: 0, - to_group_index: 0, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 1, - }, - AssertGroupRowCount { - group_index: 0, - row_count: 1, - }, - ]; - test.run_scripts(scripts).await; + let test = DatabaseGroupTest::new().await; + test.update_grouped_cell(1, 0, 0).await; + test.assert_group_row_count(1, 1).await; + test.assert_group_row_count(0, 1).await; - // Move one row from group 0 to group 1 - let scripts = vec![ - UpdateGroupedCell { - from_group_index: 0, - row_index: 0, - to_group_index: 1, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 2, - }, - AssertGroupRowCount { - group_index: 0, - row_count: 0, - }, - ]; - test.run_scripts(scripts).await; + test.update_grouped_cell(0, 0, 1).await; + test.assert_group_row_count(1, 2).await; + test.assert_group_row_count(0, 0).await; } #[tokio::test] async fn group_move_group_test() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let group_0 = test.group_at_index(0).await; let group_1 = test.group_at_index(1).await; - let scripts = vec![ - MoveGroup { - from_group_index: 0, - to_group_index: 1, - }, - AssertGroupRowCount { - group_index: 0, - row_count: 2, - }, - AssertGroup { - group_index: 0, - expected_group: group_1, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 0, - }, - AssertGroup { - group_index: 1, - expected_group: group_0, - }, - ]; - test.run_scripts(scripts).await; + + test.move_group(0, 1).await; + test.assert_group_row_count(0, 2).await; + test.assert_group(0, group_1).await; + test.assert_group_row_count(1, 0).await; + test.assert_group(1, group_0).await; } #[tokio::test] async fn group_move_group_row_after_move_group_test() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let group_1 = test.group_at_index(1).await; let group_2 = test.group_at_index(2).await; - let scripts = vec![ - MoveGroup { - from_group_index: 1, - to_group_index: 2, - }, - AssertGroup { - group_index: 1, - expected_group: group_2, - }, - AssertGroup { - group_index: 2, - expected_group: group_1, - }, - MoveRow { - from_group_index: 1, - from_row_index: 0, - to_group_index: 2, - to_row_index: 0, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 1, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 3, - }, - ]; - test.run_scripts(scripts).await; + + test.move_group(1, 2).await; + test.assert_group(1, group_2).await; + test.assert_group(2, group_1).await; + + test.move_row(1, 0, 2, 0).await; + test.assert_group_row_count(1, 1).await; + test.assert_group_row_count(2, 3).await; } #[tokio::test] async fn group_move_group_to_default_group_pos_test() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let group_0 = test.group_at_index(0).await; let group_3 = test.group_at_index(3).await; - let scripts = vec![ - MoveGroup { - from_group_index: 3, - to_group_index: 0, - }, - AssertGroup { - group_index: 0, - expected_group: group_3, - }, - AssertGroup { - group_index: 1, - expected_group: group_0, - }, - ]; - test.run_scripts(scripts).await; + + test.move_group(3, 0).await; + test.assert_group(0, group_3).await; + test.assert_group(1, group_0).await; } #[tokio::test] async fn group_insert_single_select_option_test() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let new_option_name = "New option"; - let scripts = vec![ - AssertGroupCount(4), - UpdateSingleSelectSelectOption { - inserted_options: vec![SelectOption { - id: new_option_name.to_string(), - name: new_option_name.to_string(), - color: Default::default(), - }], - }, - AssertGroupCount(5), - ]; - test.run_scripts(scripts).await; + + test.assert_group_count(4).await; + test + .update_single_select_option(vec![SelectOption { + id: new_option_name.to_string(), + name: new_option_name.to_string(), + color: Default::default(), + }]) + .await; + + test.assert_group_count(5).await; let new_group = test.group_at_index(4).await; assert_eq!(new_group.group_id, new_option_name); } #[tokio::test] async fn group_group_by_other_field() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let multi_select_field = test.get_multi_select_field().await; - let scripts = vec![ - GroupByField { - field_id: multi_select_field.id.clone(), - }, - AssertGroupRowCount { - group_index: 1, - row_count: 3, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 2, - }, - AssertGroupCount(4), - ]; - test.run_scripts(scripts).await; + + test.group_by_field(&multi_select_field.id).await; + test.assert_group_row_count(1, 3).await; + test.assert_group_row_count(2, 2).await; + test.assert_group_count(4).await; } #[tokio::test] async fn group_manual_create_new_group() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let new_group_name = "Resumed"; - let scripts = vec![ - AssertGroupCount(4), - CreateGroup { - name: new_group_name.to_string(), - }, - AssertGroupCount(5), - ]; - test.run_scripts(scripts).await; + + test.assert_group_count(4).await; + test.create_group(new_group_name).await; + test.assert_group_count(5).await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/group_test/url_group_test.rs b/frontend/rust-lib/flowy-database2/tests/database/group_test/url_group_test.rs index 83a38b07d3..b27e8b4060 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/group_test/url_group_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/group_test/url_group_test.rs @@ -1,148 +1,102 @@ use crate::database::group_test::script::DatabaseGroupTest; -use crate::database::group_test::script::GroupScript::*; #[tokio::test] async fn group_group_by_url() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let url_field = test.get_url_field().await; - let scripts = vec![ - GroupByField { - field_id: url_field.id.clone(), - }, - // no status group - AssertGroupRowCount { - group_index: 0, - row_count: 2, - }, - // https://appflowy.io - AssertGroupRowCount { - group_index: 1, - row_count: 2, - }, - // https://github.com/AppFlowy-IO/AppFlowy - AssertGroupRowCount { - group_index: 2, - row_count: 1, - }, - AssertGroupCount(3), - ]; - test.run_scripts(scripts).await; + + // Group by URL field + test.group_by_field(&url_field.id).await; + + // Check group row counts + test.assert_group_row_count(0, 2).await; // No status group + test.assert_group_row_count(1, 2).await; // https://appflowy.io group + test.assert_group_row_count(2, 1).await; // https://github.com/AppFlowy-IO/AppFlowy group + test.assert_group_count(3).await; } #[tokio::test] async fn group_alter_url_to_another_group_url_test() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let url_field = test.get_url_field().await; - let scripts = vec![ - GroupByField { - field_id: url_field.id.clone(), - }, - // no status group - AssertGroupRowCount { - group_index: 0, - row_count: 2, - }, - // https://appflowy.io - AssertGroupRowCount { - group_index: 1, - row_count: 2, - }, - // https://github.com/AppFlowy-IO/AppFlowy - AssertGroupRowCount { - group_index: 2, - row_count: 1, - }, - // When moving the last row from 2nd group to 1nd group, the 2nd group will be removed - UpdateGroupedCell { - from_group_index: 2, - row_index: 0, - to_group_index: 1, - }, - AssertGroupCount(2), - ]; - test.run_scripts(scripts).await; + + // Group by URL field + test.group_by_field(&url_field.id).await; + + // Check initial group row counts + test.assert_group_row_count(0, 2).await; // No status group + test.assert_group_row_count(1, 2).await; // https://appflowy.io group + test.assert_group_row_count(2, 1).await; // https://github.com/AppFlowy-IO/AppFlowy group + + // Move the last row from group 2 to group 1 + test.update_grouped_cell(2, 0, 1).await; + + // Verify group counts after moving + test.assert_group_count(2).await; } #[tokio::test] async fn group_alter_url_to_new_url_test() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let url_field = test.get_url_field().await; - let scripts = vec![ - GroupByField { - field_id: url_field.id.clone(), - }, - // When moving the last row from 2nd group to 1nd group, the 2nd group will be removed - UpdateGroupedCellWithData { - from_group_index: 0, - row_index: 0, - cell_data: "https://github.com/AppFlowy-IO".to_string(), - }, - // no status group - AssertGroupRowCount { - group_index: 0, - row_count: 1, - }, - // https://appflowy.io - AssertGroupRowCount { - group_index: 1, - row_count: 2, - }, - // https://github.com/AppFlowy-IO/AppFlowy - AssertGroupRowCount { - group_index: 2, - row_count: 1, - }, - AssertGroupRowCount { - group_index: 3, - row_count: 1, - }, - AssertGroupCount(4), - ]; - test.run_scripts(scripts).await; + + // Group by URL field + test.group_by_field(&url_field.id).await; + + // Change the URL of a row to a new value + test + .update_grouped_cell_with_data(0, 0, "https://github.com/AppFlowy-IO".to_string()) + .await; + + // Verify group row counts after URL update + test.assert_group_row_count(0, 1).await; // No status group + test.assert_group_row_count(1, 2).await; // https://appflowy.io group + test.assert_group_row_count(2, 1).await; // https://github.com/AppFlowy-IO/AppFlowy group + test.assert_group_row_count(3, 1).await; // https://github.com/AppFlowy-IO group + test.assert_group_count(4).await; } #[tokio::test] async fn group_move_url_group_row_test() { - let mut test = DatabaseGroupTest::new().await; + let test = DatabaseGroupTest::new().await; let url_field = test.get_url_field().await; - let scripts = vec![ - GroupByField { - field_id: url_field.id.clone(), - }, - // no status group - AssertGroupRowCount { - group_index: 0, - row_count: 2, - }, - // https://appflowy.io - AssertGroupRowCount { - group_index: 1, - row_count: 2, - }, - // https://github.com/AppFlowy-IO/AppFlowy - AssertGroupRowCount { - group_index: 2, - row_count: 1, - }, - AssertGroupCount(3), - MoveRow { - from_group_index: 0, - from_row_index: 0, - to_group_index: 1, - to_row_index: 0, - }, - AssertGroupRowCount { - group_index: 0, - row_count: 1, - }, - AssertGroupRowCount { - group_index: 1, - row_count: 3, - }, - AssertGroupRowCount { - group_index: 2, - row_count: 1, - }, - ]; - test.run_scripts(scripts).await; + + // Group by URL field + test.group_by_field(&url_field.id).await; + + // Check initial group row counts + test.assert_group_row_count(0, 2).await; // No status group + test.assert_group_row_count(1, 2).await; // https://appflowy.io group + test.assert_group_row_count(2, 1).await; // https://github.com/AppFlowy-IO/AppFlowy group + test.assert_group_count(3).await; + + // Move a row from one group to another + test.move_row(0, 0, 1, 0).await; + + // Verify row counts after the move + test.assert_group_row_count(0, 1).await; + test.assert_group_row_count(1, 3).await; + test.assert_group_row_count(2, 1).await; +} + +// Create a URL field in the default board and then set it as the grouping field. +#[tokio::test] +async fn set_group_by_url_field_test() { + let test = DatabaseGroupTest::new().await; + let url_field = test.get_url_field().await; + + // group by URL field + test + .editor + .set_group_by_field(&test.view_id, &url_field.id, vec![]) + .await + .unwrap(); + + // assert number of groups + test.assert_group_count(3).await; + + // close the database view + test.editor.close_view(&test.view_id).await; + + test.assert_group_count(3).await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/layout_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/layout_test/script.rs index 6800a7e4db..61a6193898 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/layout_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/layout_test/script.rs @@ -6,15 +6,6 @@ use flowy_database2::services::setting::{BoardLayoutSetting, CalendarLayoutSetti use crate::database::database_editor::DatabaseEditorTest; -pub enum LayoutScript { - AssertBoardLayoutSetting { expected: BoardLayoutSetting }, - AssertCalendarLayoutSetting { expected: CalendarLayoutSetting }, - UpdateBoardLayoutSetting { new_setting: BoardLayoutSetting }, - AssertDefaultAllCalendarEvents, - AssertAllCalendarEventsCount { expected: usize }, - UpdateDatabaseLayout { layout: DatabaseLayout }, -} - pub struct DatabaseLayoutTest { database_test: DatabaseEditorTest, } @@ -36,7 +27,10 @@ impl DatabaseLayoutTest { } pub async fn get_first_date_field(&self) -> Field { - self.database_test.get_first_field(FieldType::DateTime) + self + .database_test + .get_first_field(FieldType::DateTime) + .await } async fn get_layout_setting( @@ -52,100 +46,97 @@ impl DatabaseLayoutTest { .unwrap() } - pub async fn run_scripts(&mut self, scripts: Vec) { - for script in scripts { - self.run_script(script).await; - } + pub async fn update_database_layout(&mut self, layout: DatabaseLayout) { + self + .database_test + .editor + .update_view_layout(&self.database_test.view_id, layout) + .await + .unwrap(); } - pub async fn run_script(&mut self, script: LayoutScript) { - match script { - LayoutScript::UpdateDatabaseLayout { layout } => { - self - .database_test - .editor - .update_view_layout(&self.database_test.view_id, layout) - .await - .unwrap(); - }, - LayoutScript::AssertAllCalendarEventsCount { expected } => { - let events = self - .database_test - .editor - .get_all_calendar_events(&self.database_test.view_id) - .await; - assert_eq!(events.len(), expected); - }, - LayoutScript::AssertBoardLayoutSetting { expected } => { - let view_id = self.database_test.view_id.clone(); - let layout_ty = DatabaseLayout::Board; + pub async fn assert_all_calendar_events_count(&self, expected: usize) { + let events = self + .database_test + .editor + .get_all_calendar_events(&self.database_test.view_id) + .await; + assert_eq!(events.len(), expected); + } - let layout_settings = self.get_layout_setting(&view_id, layout_ty).await; + pub async fn assert_board_layout_setting(&self, expected: BoardLayoutSetting) { + let view_id = self.database_test.view_id.clone(); + let layout_ty = DatabaseLayout::Board; - assert!(layout_settings.calendar.is_none()); - assert_eq!( - layout_settings.board.unwrap().hide_ungrouped_column, - expected.hide_ungrouped_column - ); - }, - LayoutScript::AssertCalendarLayoutSetting { expected } => { - let view_id = self.database_test.view_id.clone(); - let layout_ty = DatabaseLayout::Calendar; + let layout_settings = self.get_layout_setting(&view_id, layout_ty).await; - let layout_settings = self.get_layout_setting(&view_id, layout_ty).await; + assert!(layout_settings.calendar.is_none()); + assert_eq!( + layout_settings.board.unwrap().hide_ungrouped_column, + expected.hide_ungrouped_column + ); + } - assert!(layout_settings.board.is_none()); + pub async fn assert_calendar_layout_setting(&self, expected: CalendarLayoutSetting) { + let view_id = self.database_test.view_id.clone(); + let layout_ty = DatabaseLayout::Calendar; - let calendar_setting = layout_settings.calendar.unwrap(); - assert_eq!(calendar_setting.layout_ty, expected.layout_ty); - assert_eq!( - calendar_setting.first_day_of_week, - expected.first_day_of_week - ); - assert_eq!(calendar_setting.show_weekends, expected.show_weekends); - }, - LayoutScript::UpdateBoardLayoutSetting { new_setting } => { - let changeset = LayoutSettingChangeset { - view_id: self.database_test.view_id.clone(), - layout_type: DatabaseLayout::Board, - board: Some(new_setting), - calendar: None, - }; - self - .database_test - .editor - .set_layout_setting(&self.database_test.view_id, changeset) - .await - .unwrap() - }, - LayoutScript::AssertDefaultAllCalendarEvents => { - let events = self - .database_test - .editor - .get_all_calendar_events(&self.database_test.view_id) - .await; - assert_eq!(events.len(), 5); + let layout_settings = self.get_layout_setting(&view_id, layout_ty).await; - for (index, event) in events.into_iter().enumerate() { - if index == 0 { - assert_eq!(event.title, "A"); - assert_eq!(event.timestamp, 1678090778); - } + assert!(layout_settings.board.is_none()); - if index == 1 { - assert_eq!(event.title, "B"); - assert_eq!(event.timestamp, 1677917978); - } - if index == 2 { - assert_eq!(event.title, "C"); - assert_eq!(event.timestamp, 1679213978); - } - if index == 4 { - assert_eq!(event.title, "E"); - assert_eq!(event.timestamp, 1678695578); - } - } - }, + let calendar_setting = layout_settings.calendar.unwrap(); + assert_eq!(calendar_setting.layout_ty, expected.layout_ty); + assert_eq!( + calendar_setting.first_day_of_week, + expected.first_day_of_week + ); + assert_eq!(calendar_setting.show_weekends, expected.show_weekends); + } + + pub async fn update_board_layout_setting(&mut self, new_setting: BoardLayoutSetting) { + let changeset = LayoutSettingChangeset { + view_id: self.database_test.view_id.clone(), + layout_type: DatabaseLayout::Board, + board: Some(new_setting), + calendar: None, + }; + self + .database_test + .editor + .set_layout_setting(&self.database_test.view_id, changeset) + .await + .unwrap(); + } + + pub async fn assert_default_all_calendar_events(&self) { + let events = self + .database_test + .editor + .get_all_calendar_events(&self.database_test.view_id) + .await; + assert_eq!(events.len(), 5); + + for (index, event) in events.into_iter().enumerate() { + match index { + 0 => { + assert_eq!(event.title, "A"); + assert_eq!(event.timestamp, Some(1678090778)); + }, + 1 => { + assert_eq!(event.title, "B"); + assert_eq!(event.timestamp, Some(1677917978)); + }, + 2 => { + assert_eq!(event.title, "C"); + assert_eq!(event.timestamp, Some(1679213978)); + }, + 4 => { + assert_eq!(event.title, "E"); + assert_eq!(event.timestamp, Some(1678695578)); + }, + _ => {}, + } } } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/layout_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/layout_test/test.rs index 41f2f88d0e..e949bd9179 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/layout_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/layout_test/test.rs @@ -1,9 +1,6 @@ -use collab_database::views::DatabaseLayout; -use flowy_database2::services::setting::BoardLayoutSetting; -use flowy_database2::services::setting::CalendarLayoutSetting; - use crate::database::layout_test::script::DatabaseLayoutTest; -use crate::database::layout_test::script::LayoutScript::*; +use collab_database::views::DatabaseLayout; +use flowy_database2::services::setting::{BoardLayoutSetting, CalendarLayoutSetting}; #[tokio::test] async fn board_layout_setting_test() { @@ -13,46 +10,44 @@ async fn board_layout_setting_test() { hide_ungrouped_column: true, ..default_board_setting }; - let scripts = vec![ - AssertBoardLayoutSetting { - expected: default_board_setting, - }, - UpdateBoardLayoutSetting { - new_setting: new_board_setting.clone(), - }, - AssertBoardLayoutSetting { - expected: new_board_setting, - }, - ]; - test.run_scripts(scripts).await; + + // Assert the initial default board layout setting + test + .assert_board_layout_setting(default_board_setting) + .await; + + // Update the board layout setting and assert the changes + test + .update_board_layout_setting(new_board_setting.clone()) + .await; + test.assert_board_layout_setting(new_board_setting).await; } #[tokio::test] async fn calendar_initial_layout_setting_test() { - let mut test = DatabaseLayoutTest::new_calendar().await; + let test = DatabaseLayoutTest::new_calendar().await; let date_field = test.get_first_date_field().await; let default_calendar_setting = CalendarLayoutSetting::new(date_field.id.clone()); - let scripts = vec![AssertCalendarLayoutSetting { - expected: default_calendar_setting, - }]; - test.run_scripts(scripts).await; + + // Assert the initial calendar layout setting + test + .assert_calendar_layout_setting(default_calendar_setting) + .await; } #[tokio::test] async fn calendar_get_events_test() { - let mut test = DatabaseLayoutTest::new_calendar().await; - let scripts = vec![AssertDefaultAllCalendarEvents]; - test.run_scripts(scripts).await; + let test = DatabaseLayoutTest::new_calendar().await; + + // Assert the default calendar events + test.assert_default_all_calendar_events().await; } #[tokio::test] async fn grid_to_calendar_layout_test() { let mut test = DatabaseLayoutTest::new_no_date_grid().await; - let scripts = vec![ - UpdateDatabaseLayout { - layout: DatabaseLayout::Calendar, - }, - AssertAllCalendarEventsCount { expected: 3 }, - ]; - test.run_scripts(scripts).await; + + // Update layout to calendar and assert the number of calendar events + test.update_database_layout(DatabaseLayout::Calendar).await; + test.assert_all_calendar_events_count(3).await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs index 44657d8c23..c2c4851e86 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs @@ -1,16 +1,20 @@ use collab_database::database::{gen_database_id, gen_database_view_id, gen_row_id, DatabaseData}; -use collab_database::views::{DatabaseLayout, DatabaseView, LayoutSetting, LayoutSettings}; +use collab_database::entity::DatabaseView; +use collab_database::fields::checklist_type_option::ChecklistTypeOption; +use collab_database::fields::date_type_option::{DateFormat, DateTypeOption, TimeFormat}; +use collab_database::fields::relation_type_option::RelationTypeOption; +use collab_database::fields::select_type_option::{ + MultiSelectTypeOption, SelectOption, SelectOptionColor, SingleSelectTypeOption, +}; +use collab_database::fields::summary_type_option::SummarizationTypeOption; +use collab_database::fields::timestamp_type_option::TimestampTypeOption; +use collab_database::views::{DatabaseLayout, LayoutSetting, LayoutSettings}; use strum::IntoEnumIterator; use crate::database::mock_data::{COMPLETED, FACEBOOK, GOOGLE, PAUSED, PLANNED, TWITTER}; use event_integration_test::database_event::TestRowBuilder; use flowy_database2::entities::FieldType; -use flowy_database2::services::field::checklist_type_option::ChecklistTypeOption; -use flowy_database2::services::field::summary_type_option::summary::SummarizationTypeOption; -use flowy_database2::services::field::{ - DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, RelationTypeOption, - SelectOption, SelectOptionColor, SingleSelectTypeOption, TimeFormat, TimestampTypeOption, -}; +use flowy_database2::services::field::FieldBuilder; use flowy_database2::services::field_settings::default_field_settings_for_fields; use flowy_database2::services::setting::BoardLayoutSetting; @@ -55,7 +59,8 @@ pub fn make_test_board() -> DatabaseData { date_format: DateFormat::US, time_format: TimeFormat::TwentyFourHour, include_time: true, - field_type, + field_type: field_type.into(), + timezone: None, }; let name = match field_type { FieldType::LastEditedTime => "Last Modified", @@ -134,6 +139,13 @@ pub fn make_test_board() -> DatabaseData { .build(); fields.push(relation_field); }, + FieldType::Time => { + let time_field = FieldBuilder::from_field_type(field_type) + .name("Estimated time") + .build(); + fields.push(time_field); + }, + FieldType::Translate | FieldType::Media => {}, } } @@ -151,9 +163,7 @@ pub fn make_test_board() -> DatabaseData { FieldType::RichText => row_builder.insert_text_cell("A"), FieldType::Number => row_builder.insert_number_cell("1"), // 1647251762 => Mar 14,2022 - FieldType::DateTime => { - row_builder.insert_date_cell(1647251762, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1647251762, None, &field_type), FieldType::SingleSelect => { row_builder.insert_single_select_cell(|mut options| options.remove(0)) }, @@ -171,9 +181,7 @@ pub fn make_test_board() -> DatabaseData { FieldType::RichText => row_builder.insert_text_cell("B"), FieldType::Number => row_builder.insert_number_cell("2"), // 1647251762 => Mar 14,2022 - FieldType::DateTime => { - row_builder.insert_date_cell(1647251762, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1647251762, None, &field_type), FieldType::SingleSelect => { row_builder.insert_single_select_cell(|mut options| options.remove(0)) }, @@ -190,9 +198,7 @@ pub fn make_test_board() -> DatabaseData { FieldType::RichText => row_builder.insert_text_cell("C"), FieldType::Number => row_builder.insert_number_cell("3"), // 1647251762 => Mar 14,2022 - FieldType::DateTime => { - row_builder.insert_date_cell(1647251762, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1647251762, None, &field_type), FieldType::SingleSelect => { row_builder.insert_single_select_cell(|mut options| options.remove(1)) }, @@ -212,9 +218,7 @@ pub fn make_test_board() -> DatabaseData { match field_type { FieldType::RichText => row_builder.insert_text_cell("DA"), FieldType::Number => row_builder.insert_number_cell("4"), - FieldType::DateTime => { - row_builder.insert_date_cell(1668704685, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1668704685, None, &field_type), FieldType::SingleSelect => { row_builder.insert_single_select_cell(|mut options| options.remove(1)) }, @@ -229,9 +233,7 @@ pub fn make_test_board() -> DatabaseData { match field_type { FieldType::RichText => row_builder.insert_text_cell("AE"), FieldType::Number => row_builder.insert_number_cell(""), - FieldType::DateTime => { - row_builder.insert_date_cell(1668359085, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1668359085, None, &field_type), FieldType::SingleSelect => { row_builder.insert_single_select_cell(|mut options| options.remove(2)) }, @@ -250,10 +252,8 @@ pub fn make_test_board() -> DatabaseData { let mut layout_settings = LayoutSettings::new(); layout_settings.insert(DatabaseLayout::Board, board_setting); - let inline_view_id = gen_database_view_id(); - let view = DatabaseView { - id: inline_view_id.clone(), + id: gen_database_view_id(), database_id: database_id.clone(), name: "".to_string(), layout: DatabaseLayout::Board, @@ -266,11 +266,11 @@ pub fn make_test_board() -> DatabaseData { created_at: 0, modified_at: 0, field_settings, + is_inline: false, }; DatabaseData { database_id, - inline_view_id, views: vec![view], fields, rows, diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/calendar_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/calendar_mock_data.rs index 4c7553f754..4e789305ba 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/calendar_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/calendar_mock_data.rs @@ -1,11 +1,13 @@ use collab_database::database::{gen_database_id, gen_database_view_id, gen_row_id, DatabaseData}; -use collab_database::views::{DatabaseLayout, DatabaseView, LayoutSetting, LayoutSettings}; +use collab_database::entity::DatabaseView; +use collab_database::fields::select_type_option::MultiSelectTypeOption; +use collab_database::views::{DatabaseLayout, LayoutSetting, LayoutSettings}; use flowy_database2::services::field_settings::default_field_settings_for_fields; use strum::IntoEnumIterator; use event_integration_test::database_event::TestRowBuilder; use flowy_database2::entities::FieldType; -use flowy_database2::services::field::{FieldBuilder, MultiSelectTypeOption}; +use flowy_database2::services::field::FieldBuilder; use flowy_database2::services::setting::CalendarLayoutSetting; // Calendar unit test mock data @@ -46,9 +48,7 @@ pub fn make_test_calendar() -> DatabaseData { for field_type in FieldType::iter() { match field_type { FieldType::RichText => row_builder.insert_text_cell("A"), - FieldType::DateTime => { - row_builder.insert_date_cell(1678090778, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1678090778, None, &field_type), _ => "".to_owned(), }; } @@ -57,9 +57,7 @@ pub fn make_test_calendar() -> DatabaseData { for field_type in FieldType::iter() { match field_type { FieldType::RichText => row_builder.insert_text_cell("B"), - FieldType::DateTime => { - row_builder.insert_date_cell(1677917978, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1677917978, None, &field_type), _ => "".to_owned(), }; } @@ -68,9 +66,7 @@ pub fn make_test_calendar() -> DatabaseData { for field_type in FieldType::iter() { match field_type { FieldType::RichText => row_builder.insert_text_cell("C"), - FieldType::DateTime => { - row_builder.insert_date_cell(1679213978, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1679213978, None, &field_type), _ => "".to_owned(), }; } @@ -79,9 +75,7 @@ pub fn make_test_calendar() -> DatabaseData { for field_type in FieldType::iter() { match field_type { FieldType::RichText => row_builder.insert_text_cell("D"), - FieldType::DateTime => { - row_builder.insert_date_cell(1678695578, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1678695578, None, &field_type), _ => "".to_owned(), }; } @@ -90,9 +84,7 @@ pub fn make_test_calendar() -> DatabaseData { for field_type in FieldType::iter() { match field_type { FieldType::RichText => row_builder.insert_text_cell("E"), - FieldType::DateTime => { - row_builder.insert_date_cell(1678695578, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1678695578, None, &field_type), _ => "".to_owned(), }; } @@ -106,11 +98,9 @@ pub fn make_test_calendar() -> DatabaseData { let mut layout_settings = LayoutSettings::new(); layout_settings.insert(DatabaseLayout::Calendar, calendar_setting); - let inline_view_id = gen_database_view_id(); - let view = DatabaseView { database_id: database_id.clone(), - id: inline_view_id.clone(), + id: gen_database_view_id(), name: "".to_string(), layout: DatabaseLayout::Calendar, layout_settings, @@ -122,11 +112,11 @@ pub fn make_test_calendar() -> DatabaseData { created_at: 0, modified_at: 0, field_settings, + is_inline: false, }; DatabaseData { database_id, - inline_view_id, views: vec![view], fields, rows, diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs index 6ef8d08c3a..5039a37b39 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs @@ -1,16 +1,26 @@ use collab_database::database::{gen_database_id, gen_database_view_id, gen_row_id, DatabaseData}; -use collab_database::views::{DatabaseLayout, DatabaseView}; +use collab_database::entity::DatabaseView; +use collab_database::fields::checklist_type_option::ChecklistTypeOption; +use collab_database::fields::date_type_option::{ + DateFormat, DateTypeOption, TimeFormat, TimeTypeOption, +}; +use collab_database::fields::media_type_option::MediaTypeOption; +use collab_database::fields::number_type_option::{NumberFormat, NumberTypeOption}; +use collab_database::fields::relation_type_option::RelationTypeOption; +use collab_database::fields::select_type_option::{ + MultiSelectTypeOption, SelectOption, SelectOptionColor, SingleSelectTypeOption, +}; +use collab_database::fields::summary_type_option::SummarizationTypeOption; +use collab_database::fields::timestamp_type_option::TimestampTypeOption; +use collab_database::fields::translate_type_option::TranslateTypeOption; +use collab_database::views::DatabaseLayout; use strum::IntoEnumIterator; use crate::database::mock_data::{COMPLETED, FACEBOOK, GOOGLE, PAUSED, PLANNED, TWITTER}; use event_integration_test::database_event::TestRowBuilder; use flowy_database2::entities::FieldType; -use flowy_database2::services::field::summary_type_option::summary::SummarizationTypeOption; -use flowy_database2::services::field::{ - ChecklistTypeOption, DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, - NumberFormat, NumberTypeOption, RelationTypeOption, SelectOption, SelectOptionColor, - SingleSelectTypeOption, TimeFormat, TimestampTypeOption, -}; +use flowy_database2::services::field::checklist_filter::ChecklistCellInsertChangeset; +use flowy_database2::services::field::FieldBuilder; use flowy_database2::services::field_settings::default_field_settings_for_fields; pub fn make_test_grid() -> DatabaseData { @@ -57,7 +67,8 @@ pub fn make_test_grid() -> DatabaseData { date_format: DateFormat::US, time_format: TimeFormat::TwentyFourHour, include_time: true, - field_type, + field_type: field_type.into(), + timezone: None, }; let name = match field_type { FieldType::LastEditedTime => "Last Modified", @@ -132,6 +143,33 @@ pub fn make_test_grid() -> DatabaseData { .build(); fields.push(relation_field); }, + FieldType::Time => { + let type_option = TimeTypeOption; + let time_field = FieldBuilder::new(field_type, type_option) + .name("Estimated time") + .build(); + fields.push(time_field); + }, + FieldType::Translate => { + let type_option = TranslateTypeOption { + auto_fill: false, + language_type: 0, + }; + let translate_field = FieldBuilder::new(field_type, type_option) + .name("AI translate") + .build(); + fields.push(translate_field); + }, + FieldType::Media => { + let type_option = MediaTypeOption { + hide_file_names: true, + }; + + let media_field = FieldBuilder::new(field_type, type_option) + .name("Media") + .build(); + fields.push(media_field); + }, } } @@ -145,9 +183,7 @@ pub fn make_test_grid() -> DatabaseData { match field_type { FieldType::RichText => row_builder.insert_text_cell("A"), FieldType::Number => row_builder.insert_number_cell("1"), - FieldType::DateTime => { - row_builder.insert_date_cell(1647251762, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1647251762, None, &field_type), FieldType::MultiSelect => row_builder .insert_multi_select_cell(|mut options| vec![options.remove(0), options.remove(0)]), FieldType::Checkbox => row_builder.insert_checkbox_cell("true"), @@ -155,8 +191,12 @@ pub fn make_test_grid() -> DatabaseData { row_builder.insert_url_cell("AppFlowy website - https://www.appflowy.io") }, FieldType::Checklist => { - row_builder.insert_checklist_cell(vec![("First thing".to_string(), false)]) + row_builder.insert_checklist_cell(vec![ChecklistCellInsertChangeset::new( + "First thing".to_string(), + false, + )]) }, + FieldType::Time => row_builder.insert_time_cell(75), _ => "".to_owned(), }; } @@ -166,18 +206,16 @@ pub fn make_test_grid() -> DatabaseData { match field_type { FieldType::RichText => row_builder.insert_text_cell(""), FieldType::Number => row_builder.insert_number_cell("2"), - FieldType::DateTime => { - row_builder.insert_date_cell(1647251762, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1647251762, None, &field_type), FieldType::MultiSelect => row_builder .insert_multi_select_cell(|mut options| vec![options.remove(0), options.remove(1)]), FieldType::Checkbox => row_builder.insert_checkbox_cell("true"), FieldType::Checklist => row_builder.insert_checklist_cell(vec![ - ("Have breakfast".to_string(), true), - ("Have lunch".to_string(), true), - ("Take a nap".to_string(), false), - ("Have dinner".to_string(), true), - ("Shower and head to bed".to_string(), false), + ChecklistCellInsertChangeset::new("Have breakfast".to_string(), true), + ChecklistCellInsertChangeset::new("Have lunch".to_string(), true), + ChecklistCellInsertChangeset::new("Take a nap".to_string(), false), + ChecklistCellInsertChangeset::new("Have dinner".to_string(), true), + ChecklistCellInsertChangeset::new("Shower and head to bed".to_string(), false), ]), _ => "".to_owned(), }; @@ -188,9 +226,7 @@ pub fn make_test_grid() -> DatabaseData { match field_type { FieldType::RichText => row_builder.insert_text_cell("C"), FieldType::Number => row_builder.insert_number_cell("3"), - FieldType::DateTime => { - row_builder.insert_date_cell(1647251762, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1647251762, None, &field_type), FieldType::SingleSelect => { row_builder.insert_single_select_cell(|mut options| options.remove(0)) }, @@ -207,15 +243,16 @@ pub fn make_test_grid() -> DatabaseData { match field_type { FieldType::RichText => row_builder.insert_text_cell("DA"), FieldType::Number => row_builder.insert_number_cell("14"), - FieldType::DateTime => { - row_builder.insert_date_cell(1668704685, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1668704685, None, &field_type), FieldType::SingleSelect => { row_builder.insert_single_select_cell(|mut options| options.remove(0)) }, FieldType::Checkbox => row_builder.insert_checkbox_cell("false"), FieldType::Checklist => { - row_builder.insert_checklist_cell(vec![("Task 1".to_string(), true)]) + row_builder.insert_checklist_cell(vec![ChecklistCellInsertChangeset::new( + "Task 1".to_string(), + true, + )]) }, _ => "".to_owned(), }; @@ -226,9 +263,7 @@ pub fn make_test_grid() -> DatabaseData { match field_type { FieldType::RichText => row_builder.insert_text_cell("AE"), FieldType::Number => row_builder.insert_number_cell(""), - FieldType::DateTime => { - row_builder.insert_date_cell(1668359085, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1668359085, None, &field_type), FieldType::SingleSelect => { row_builder.insert_single_select_cell(|mut options| options.remove(1)) }, @@ -244,9 +279,7 @@ pub fn make_test_grid() -> DatabaseData { match field_type { FieldType::RichText => row_builder.insert_text_cell("AE"), FieldType::Number => row_builder.insert_number_cell("5"), - FieldType::DateTime => { - row_builder.insert_date_cell(1671938394, None, None, &field_type) - }, + FieldType::DateTime => row_builder.insert_date_cell(1671938394, None, &field_type), FieldType::SingleSelect => { row_builder.insert_single_select_cell(|mut options| options.remove(1)) }, @@ -255,9 +288,9 @@ pub fn make_test_grid() -> DatabaseData { }, FieldType::Checkbox => row_builder.insert_checkbox_cell("true"), FieldType::Checklist => row_builder.insert_checklist_cell(vec![ - ("Sprint".to_string(), true), - ("Sprint some more".to_string(), false), - ("Rest".to_string(), true), + ChecklistCellInsertChangeset::new("Sprint".to_string(), true), + ChecklistCellInsertChangeset::new("Sprint some more".to_string(), false), + ChecklistCellInsertChangeset::new("Rest".to_string(), true), ]), _ => "".to_owned(), }; @@ -273,11 +306,9 @@ pub fn make_test_grid() -> DatabaseData { rows.push(row); } - let inline_view_id = gen_database_view_id(); - let view = DatabaseView { database_id: database_id.clone(), - id: inline_view_id.clone(), + id: gen_database_view_id(), name: "".to_string(), layout: DatabaseLayout::Grid, field_settings, @@ -286,7 +317,6 @@ pub fn make_test_grid() -> DatabaseData { DatabaseData { database_id, - inline_view_id, views: vec![view], fields, rows, @@ -361,11 +391,9 @@ pub fn make_no_date_test_grid() -> DatabaseData { rows.push(row); } - let inline_view_id = gen_database_view_id(); - let view = DatabaseView { database_id: database_id.clone(), - id: inline_view_id.clone(), + id: gen_database_view_id(), name: "".to_string(), layout: DatabaseLayout::Grid, field_settings, @@ -374,7 +402,6 @@ pub fn make_no_date_test_grid() -> DatabaseData { DatabaseData { database_id, - inline_view_id, views: vec![view], fields, rows, diff --git a/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_according_to_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_according_to_filter_test.rs index b47bf2e99b..a0b3e5da3e 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_according_to_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_according_to_filter_test.rs @@ -1,297 +1,231 @@ +use crate::database::pre_fill_cell_test::script::DatabasePreFillRowCellTest; use flowy_database2::entities::{ CheckboxFilterConditionPB, CheckboxFilterPB, DateFilterConditionPB, DateFilterPB, FieldType, FilterDataPB, SelectOptionFilterConditionPB, SelectOptionFilterPB, TextFilterConditionPB, TextFilterPB, }; -use flowy_database2::services::field::SELECTION_IDS_SEPARATOR; - -use crate::database::pre_fill_cell_test::script::{ - DatabasePreFillRowCellTest, PreFillRowCellTestScript::*, -}; - -// This suite of tests cover creating an empty row into a database that has -// active filters. Where appropriate, the row's cell data will be pre-filled -// into the row's cells before creating it in collab. #[tokio::test] async fn according_to_text_contains_filter_test() { let mut test = DatabasePreFillRowCellTest::new().await; + let text_field = test.get_first_field(FieldType::RichText).await; - let text_field = test.get_first_field(FieldType::RichText); - - let scripts = vec![ - InsertFilter { - filter: FilterDataPB { - field_id: text_field.id.clone(), - field_type: FieldType::RichText, - data: TextFilterPB { - condition: TextFilterConditionPB::TextContains, - content: "sample".to_string(), - } - .try_into() - .unwrap(), - }, - }, - Wait { milliseconds: 100 }, - CreateEmptyRow, - Wait { milliseconds: 100 }, - ]; - - test.run_scripts(scripts).await; - - let scripts = vec![ - AssertCellExistence { + test + .insert_filter(FilterDataPB { field_id: text_field.id.clone(), - row_index: test.row_details.len() - 1, - exists: true, - }, - AssertCellContent { - field_id: text_field.id, - row_index: test.row_details.len() - 1, + field_type: FieldType::RichText, + data: TextFilterPB { + condition: TextFilterConditionPB::TextContains, + content: "sample".to_string(), + } + .try_into() + .unwrap(), + }) + .await; - expected_content: "sample".to_string(), - }, - ]; + test.wait(100).await; + test.create_empty_row().await; + test.wait(100).await; - test.run_scripts(scripts).await; + test + .assert_cell_existence(text_field.id.clone(), test.rows.len() - 1, true) + .await; + test + .assert_cell_content(text_field.id, test.rows.len() - 1, "sample".to_string()) + .await; } #[tokio::test] async fn according_to_empty_text_contains_filter_test() { let mut test = DatabasePreFillRowCellTest::new().await; + let text_field = test.get_first_field(FieldType::RichText).await; - let text_field = test.get_first_field(FieldType::RichText); + test + .insert_filter(FilterDataPB { + field_id: text_field.id.clone(), + field_type: FieldType::RichText, + data: TextFilterPB { + condition: TextFilterConditionPB::TextContains, + content: "".to_string(), + } + .try_into() + .unwrap(), + }) + .await; - let scripts = vec![ - InsertFilter { - filter: FilterDataPB { - field_id: text_field.id.clone(), - field_type: FieldType::RichText, - data: TextFilterPB { - condition: TextFilterConditionPB::TextContains, - content: "".to_string(), - } - .try_into() - .unwrap(), - }, - }, - Wait { milliseconds: 100 }, - CreateEmptyRow, - Wait { milliseconds: 100 }, - ]; + test.wait(100).await; + test.create_empty_row().await; + test.wait(100).await; - test.run_scripts(scripts).await; - - let scripts = vec![AssertCellExistence { - field_id: text_field.id.clone(), - row_index: test.row_details.len() - 1, - exists: false, - }]; - - test.run_scripts(scripts).await; + test + .assert_cell_existence(text_field.id.clone(), test.rows.len() - 1, false) + .await; } #[tokio::test] async fn according_to_text_is_not_empty_filter_test() { let mut test = DatabasePreFillRowCellTest::new().await; + let text_field = test.get_first_field(FieldType::RichText).await; - let text_field = test.get_first_field(FieldType::RichText); + test.assert_row_count(7).await; - let scripts = vec![ - AssertRowCount(7), - InsertFilter { - filter: FilterDataPB { - field_id: text_field.id.clone(), - field_type: FieldType::RichText, - data: TextFilterPB { - condition: TextFilterConditionPB::TextIsNotEmpty, - content: "".to_string(), - } - .try_into() - .unwrap(), - }, - }, - Wait { milliseconds: 100 }, - AssertRowCount(6), - CreateEmptyRow, - Wait { milliseconds: 100 }, - AssertRowCount(6), - ]; + test + .insert_filter(FilterDataPB { + field_id: text_field.id.clone(), + field_type: FieldType::RichText, + data: TextFilterPB { + condition: TextFilterConditionPB::TextIsNotEmpty, + content: "".to_string(), + } + .try_into() + .unwrap(), + }) + .await; - test.run_scripts(scripts).await; + test.wait(100).await; + test.assert_row_count(6).await; + test.create_empty_row().await; + test.wait(100).await; + test.assert_row_count(6).await; } #[tokio::test] async fn according_to_checkbox_is_unchecked_filter_test() { let mut test = DatabasePreFillRowCellTest::new().await; + let checkbox_field = test.get_first_field(FieldType::Checkbox).await; - let checkbox_field = test.get_first_field(FieldType::Checkbox); + test.assert_row_count(7).await; - let scripts = vec![ - AssertRowCount(7), - InsertFilter { - filter: FilterDataPB { - field_id: checkbox_field.id.clone(), - field_type: FieldType::Checkbox, - data: CheckboxFilterPB { - condition: CheckboxFilterConditionPB::IsUnChecked, - } - .try_into() - .unwrap(), - }, - }, - Wait { milliseconds: 100 }, - AssertRowCount(4), - CreateEmptyRow, - Wait { milliseconds: 100 }, - AssertRowCount(5), - ]; + test + .insert_filter(FilterDataPB { + field_id: checkbox_field.id.clone(), + field_type: FieldType::Checkbox, + data: CheckboxFilterPB { + condition: CheckboxFilterConditionPB::IsUnChecked, + } + .try_into() + .unwrap(), + }) + .await; - test.run_scripts(scripts).await; + test.wait(100).await; + test.assert_row_count(4).await; + test.create_empty_row().await; + test.wait(100).await; + test.assert_row_count(5).await; - let scripts = vec![AssertCellExistence { - field_id: checkbox_field.id.clone(), - row_index: 4, - exists: false, - }]; - - test.run_scripts(scripts).await; + test + .assert_cell_existence(checkbox_field.id.clone(), 4, false) + .await; } #[tokio::test] async fn according_to_checkbox_is_checked_filter_test() { let mut test = DatabasePreFillRowCellTest::new().await; + let checkbox_field = test.get_first_field(FieldType::Checkbox).await; - let checkbox_field = test.get_first_field(FieldType::Checkbox); + test.assert_row_count(7).await; - let scripts = vec![ - AssertRowCount(7), - InsertFilter { - filter: FilterDataPB { - field_id: checkbox_field.id.clone(), - field_type: FieldType::Checkbox, - data: CheckboxFilterPB { - condition: CheckboxFilterConditionPB::IsChecked, - } - .try_into() - .unwrap(), - }, - }, - Wait { milliseconds: 100 }, - AssertRowCount(3), - CreateEmptyRow, - Wait { milliseconds: 100 }, - AssertRowCount(4), - ]; - - test.run_scripts(scripts).await; - - let scripts = vec![ - AssertCellExistence { + test + .insert_filter(FilterDataPB { field_id: checkbox_field.id.clone(), - row_index: 3, - exists: true, - }, - AssertCellContent { - field_id: checkbox_field.id, - row_index: 3, + field_type: FieldType::Checkbox, + data: CheckboxFilterPB { + condition: CheckboxFilterConditionPB::IsChecked, + } + .try_into() + .unwrap(), + }) + .await; - expected_content: "Yes".to_string(), - }, - ]; + test.wait(100).await; + test.assert_row_count(3).await; + test.create_empty_row().await; + test.wait(100).await; + test.assert_row_count(4).await; - test.run_scripts(scripts).await; + test + .assert_cell_existence(checkbox_field.id.clone(), 3, true) + .await; + test + .assert_cell_content(checkbox_field.id, 3, "Yes".to_string()) + .await; } #[tokio::test] async fn according_to_date_time_is_filter_test() { let mut test = DatabasePreFillRowCellTest::new().await; + let datetime_field = test.get_first_field(FieldType::DateTime).await; - let datetime_field = test.get_first_field(FieldType::DateTime); + test.assert_row_count(7).await; - let scripts = vec![ - AssertRowCount(7), - InsertFilter { - filter: FilterDataPB { - field_id: datetime_field.id.clone(), - field_type: FieldType::DateTime, - data: DateFilterPB { - condition: DateFilterConditionPB::DateIs, - timestamp: Some(1710510086), - ..Default::default() - } - .try_into() - .unwrap(), - }, - }, - Wait { milliseconds: 100 }, - AssertRowCount(0), - CreateEmptyRow, - Wait { milliseconds: 100 }, - AssertRowCount(1), - ]; - - test.run_scripts(scripts).await; - - let scripts = vec![ - AssertCellExistence { + test + .insert_filter(FilterDataPB { field_id: datetime_field.id.clone(), - row_index: 0, - exists: true, - }, - AssertCellContent { - field_id: datetime_field.id, - row_index: 0, + field_type: FieldType::DateTime, + data: DateFilterPB { + condition: DateFilterConditionPB::DateStartsOn, + timestamp: Some(1710510086), + ..Default::default() + } + .try_into() + .unwrap(), + }) + .await; - expected_content: "2024/03/15".to_string(), - }, - ]; + test.wait(100).await; + test.assert_row_count(0).await; + test.create_empty_row().await; + test.wait(100).await; + test.assert_row_count(1).await; - test.run_scripts(scripts).await; + test + .assert_cell_existence(datetime_field.id.clone(), 0, true) + .await; + test + .assert_cell_content(datetime_field.id, 0, "2024/03/15".to_string()) + .await; } #[tokio::test] async fn according_to_invalid_date_time_is_filter_test() { let mut test = DatabasePreFillRowCellTest::new().await; + let datetime_field = test.get_first_field(FieldType::DateTime).await; - let datetime_field = test.get_first_field(FieldType::DateTime); + test.assert_row_count(7).await; - let scripts = vec![ - AssertRowCount(7), - InsertFilter { - filter: FilterDataPB { - field_id: datetime_field.id.clone(), - field_type: FieldType::DateTime, - data: DateFilterPB { - condition: DateFilterConditionPB::DateIs, - timestamp: None, - ..Default::default() - } - .try_into() - .unwrap(), - }, - }, - Wait { milliseconds: 100 }, - AssertRowCount(7), - CreateEmptyRow, - Wait { milliseconds: 100 }, - AssertRowCount(8), - AssertCellExistence { + test + .insert_filter(FilterDataPB { field_id: datetime_field.id.clone(), - row_index: test.row_details.len(), - exists: false, - }, - ]; + field_type: FieldType::DateTime, + data: DateFilterPB { + condition: DateFilterConditionPB::DateStartsOn, + timestamp: None, + ..Default::default() + } + .try_into() + .unwrap(), + }) + .await; - test.run_scripts(scripts).await; + test.wait(100).await; + test.assert_row_count(7).await; + test.create_empty_row().await; + test.wait(100).await; + test.assert_row_count(8).await; + + test + .assert_cell_existence(datetime_field.id.clone(), test.rows.len() - 1, false) + .await; } #[tokio::test] async fn according_to_select_option_is_filter_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let multi_select_field = test.get_first_field(FieldType::MultiSelect); - let options = test.get_multi_select_type_option(&multi_select_field.id); + let single_select_field = test.get_first_field(FieldType::SingleSelect).await; + let options = test + .get_single_select_type_option(&single_select_field.id) + .await; let filtering_options = [options[1].clone(), options[2].clone()]; let ids = filtering_options @@ -299,52 +233,46 @@ async fn according_to_select_option_is_filter_test() { .map(|option| option.id.clone()) .collect(); let stringified_expected = filtering_options - .iter() + .first() .map(|option| option.name.clone()) - .collect::>() - .join(SELECTION_IDS_SEPARATOR); + .unwrap_or_default(); - let scripts = vec![ - AssertRowCount(7), - InsertFilter { - filter: FilterDataPB { - field_id: multi_select_field.id.clone(), - field_type: FieldType::MultiSelect, - data: SelectOptionFilterPB { - condition: SelectOptionFilterConditionPB::OptionIs, - option_ids: ids, - } - .try_into() - .unwrap(), - }, - }, - Wait { milliseconds: 100 }, - AssertRowCount(1), - CreateEmptyRow, - Wait { milliseconds: 100 }, - AssertRowCount(2), - AssertCellExistence { - field_id: multi_select_field.id.clone(), - row_index: 1, - exists: true, - }, - AssertCellContent { - field_id: multi_select_field.id, - row_index: 1, + test.assert_row_count(7).await; - expected_content: stringified_expected, - }, - ]; + test + .insert_filter(FilterDataPB { + field_id: single_select_field.id.clone(), + field_type: FieldType::SingleSelect, + data: SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIs, + option_ids: ids, + } + .try_into() + .unwrap(), + }) + .await; - test.run_scripts(scripts).await; + test.wait(100).await; + test.assert_row_count(2).await; + test.create_empty_row().await; + test.wait(100).await; + test.assert_row_count(3).await; + + test + .assert_cell_existence(single_select_field.id.clone(), 1, true) + .await; + test + .assert_cell_content(single_select_field.id, 1, stringified_expected) + .await; } #[tokio::test] async fn according_to_select_option_contains_filter_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let multi_select_field = test.get_first_field(FieldType::MultiSelect); - let options = test.get_multi_select_type_option(&multi_select_field.id); + let multi_select_field = test.get_first_field(FieldType::MultiSelect).await; + let options = test + .get_multi_select_type_option(&multi_select_field.id) + .await; let filtering_options = [options[1].clone(), options[2].clone()]; let ids = filtering_options @@ -353,81 +281,70 @@ async fn according_to_select_option_contains_filter_test() { .collect(); let stringified_expected = filtering_options.first().unwrap().name.clone(); - let scripts = vec![ - AssertRowCount(7), - InsertFilter { - filter: FilterDataPB { - field_id: multi_select_field.id.clone(), - field_type: FieldType::MultiSelect, - data: SelectOptionFilterPB { - condition: SelectOptionFilterConditionPB::OptionContains, - option_ids: ids, - } - .try_into() - .unwrap(), - }, - }, - Wait { milliseconds: 100 }, - AssertRowCount(5), - CreateEmptyRow, - Wait { milliseconds: 100 }, - AssertRowCount(6), - AssertCellExistence { + test.assert_row_count(7).await; + + test + .insert_filter(FilterDataPB { field_id: multi_select_field.id.clone(), - row_index: 5, - exists: true, - }, - AssertCellContent { - field_id: multi_select_field.id, - row_index: 5, + field_type: FieldType::MultiSelect, + data: SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionContains, + option_ids: ids, + } + .try_into() + .unwrap(), + }) + .await; - expected_content: stringified_expected, - }, - ]; + test.wait(100).await; + test.assert_row_count(5).await; + test.create_empty_row().await; + test.wait(100).await; + test.assert_row_count(6).await; - test.run_scripts(scripts).await; + test + .assert_cell_existence(multi_select_field.id.clone(), 5, true) + .await; + test + .assert_cell_content(multi_select_field.id, 5, stringified_expected) + .await; } #[tokio::test] async fn according_to_select_option_is_not_empty_filter_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let multi_select_field = test.get_first_field(FieldType::MultiSelect); - let options = test.get_multi_select_type_option(&multi_select_field.id); + let multi_select_field = test.get_first_field(FieldType::MultiSelect).await; + let options = test + .get_multi_select_type_option(&multi_select_field.id) + .await; let stringified_expected = options.first().unwrap().name.clone(); - let scripts = vec![ - AssertRowCount(7), - InsertFilter { - filter: FilterDataPB { - field_id: multi_select_field.id.clone(), - field_type: FieldType::MultiSelect, - data: SelectOptionFilterPB { - condition: SelectOptionFilterConditionPB::OptionIsNotEmpty, - ..Default::default() - } - .try_into() - .unwrap(), - }, - }, - Wait { milliseconds: 100 }, - AssertRowCount(5), - CreateEmptyRow, - Wait { milliseconds: 100 }, - AssertRowCount(6), - AssertCellExistence { + test.assert_row_count(7).await; + + test + .insert_filter(FilterDataPB { field_id: multi_select_field.id.clone(), - row_index: 5, - exists: true, - }, - AssertCellContent { - field_id: multi_select_field.id, - row_index: 5, + field_type: FieldType::MultiSelect, + data: SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIsNotEmpty, + ..Default::default() + } + .try_into() + .unwrap(), + }) + .await; - expected_content: stringified_expected, - }, - ]; + test.wait(100).await; + test.assert_row_count(5).await; + test.create_empty_row().await; + test.wait(100).await; + test.assert_row_count(6).await; - test.run_scripts(scripts).await; + test + .assert_cell_existence(multi_select_field.id.clone(), 5, true) + .await; + test + .assert_cell_content(multi_select_field.id, 5, stringified_expected) + .await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_with_payload_test.rs b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_with_payload_test.rs index a67bad48f3..8007051037 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_with_payload_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_with_payload_test.rs @@ -1,315 +1,235 @@ -use std::collections::HashMap; - +use crate::database::pre_fill_cell_test::script::DatabasePreFillRowCellTest; +use collab_database::fields::date_type_option::DateCellData; +use collab_database::fields::select_type_option::SELECTION_IDS_SEPARATOR; +use collab_database::template::util::ToCellString; use flowy_database2::entities::{CreateRowPayloadPB, FieldType}; -use flowy_database2::services::field::{DateCellData, SELECTION_IDS_SEPARATOR}; - -use crate::database::pre_fill_cell_test::script::{ - DatabasePreFillRowCellTest, PreFillRowCellTestScript::*, -}; - -// This suite of tests cover creating a row using `CreateRowPayloadPB` that passes -// in some cell data in its `data` field of `HashMap` which is a -// map of `field_id` to its corresponding cell data as a String. If valid, the cell -// data will be pre-filled into the row's cells before creating it in collab. +use std::collections::HashMap; #[tokio::test] async fn row_data_payload_with_empty_hashmap_test() { let mut test = DatabasePreFillRowCellTest::new().await; + let text_field = test.get_first_field(FieldType::RichText).await; - let text_field = test.get_first_field(FieldType::RichText); + test + .create_row_with_payload(CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::new(), + ..Default::default() + }) + .await; - let scripts = vec![ - CreateRowWithPayload { - payload: CreateRowPayloadPB { - view_id: test.view_id.clone(), - data: HashMap::new(), - ..Default::default() - }, - }, - Wait { milliseconds: 100 }, - AssertCellExistence { - field_id: text_field.id.clone(), - row_index: test.row_details.len(), - exists: false, - }, - AssertCellContent { - field_id: text_field.id, - row_index: test.row_details.len(), - - expected_content: "".to_string(), - }, - ]; - - test.run_scripts(scripts).await; + test.wait(100).await; + let index = test.rows.len() - 1; + test + .assert_cell_existence(text_field.id.clone(), index, false) + .await; + test + .assert_cell_content(text_field.id, index, "".to_string()) + .await; } #[tokio::test] async fn row_data_payload_with_unknown_field_id_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let text_field = test.get_first_field(FieldType::RichText); + let text_field = test.get_first_field(FieldType::RichText).await; let malformed_field_id = "this_field_id_will_never_exist"; - let scripts = vec![ - CreateRowWithPayload { - payload: CreateRowPayloadPB { - view_id: test.view_id.clone(), - data: HashMap::from([( - malformed_field_id.to_string(), - "sample cell data".to_string(), - )]), - ..Default::default() - }, - }, - Wait { milliseconds: 100 }, - AssertCellExistence { - field_id: text_field.id.clone(), - row_index: test.row_details.len(), - exists: false, - }, - AssertCellContent { - field_id: text_field.id.clone(), - row_index: test.row_details.len(), + test + .create_row_with_payload(CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([( + malformed_field_id.to_string(), + "sample cell data".to_string(), + )]), + ..Default::default() + }) + .await; - expected_content: "".to_string(), - }, - AssertCellExistence { - field_id: malformed_field_id.to_string(), - row_index: test.row_details.len(), - exists: false, - }, - ]; - - test.run_scripts(scripts).await; + test.wait(100).await; + let index = test.rows.len() - 1; + test + .assert_cell_existence(text_field.id.clone(), index, false) + .await; + test + .assert_cell_content(text_field.id.clone(), index, "".to_string()) + .await; + test + .assert_cell_existence(malformed_field_id.to_string(), index, false) + .await; } #[tokio::test] async fn row_data_payload_with_empty_string_text_data_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let text_field = test.get_first_field(FieldType::RichText); + let text_field = test.get_first_field(FieldType::RichText).await; let cell_data = ""; - let scripts = vec![ - CreateRowWithPayload { - payload: CreateRowPayloadPB { - view_id: test.view_id.clone(), - data: HashMap::from([(text_field.id.clone(), cell_data.to_string())]), - ..Default::default() - }, - }, - Wait { milliseconds: 100 }, - AssertCellExistence { - field_id: text_field.id.clone(), - row_index: test.row_details.len(), - exists: true, - }, - AssertCellContent { - field_id: text_field.id, - row_index: test.row_details.len(), + test + .create_row_with_payload(CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(text_field.id.clone(), cell_data.to_string())]), + ..Default::default() + }) + .await; - expected_content: cell_data.to_string(), - }, - ]; - - test.run_scripts(scripts).await; + test.wait(100).await; + let index = test.rows.len() - 1; + test + .assert_cell_existence(text_field.id.clone(), index, true) + .await; + test + .assert_cell_content(text_field.id, index, cell_data.to_string()) + .await; } #[tokio::test] async fn row_data_payload_with_text_data_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let text_field = test.get_first_field(FieldType::RichText); + let text_field = test.get_first_field(FieldType::RichText).await; let cell_data = "sample cell data"; - let scripts = vec![ - CreateRowWithPayload { - payload: CreateRowPayloadPB { - view_id: test.view_id.clone(), - data: HashMap::from([(text_field.id.clone(), cell_data.to_string())]), - ..Default::default() - }, - }, - Wait { milliseconds: 100 }, - AssertCellExistence { - field_id: text_field.id.clone(), - row_index: test.row_details.len(), - exists: true, - }, - AssertCellContent { - field_id: text_field.id.clone(), - row_index: test.row_details.len(), + test + .create_row_with_payload(CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(text_field.id.clone(), cell_data.to_string())]), + ..Default::default() + }) + .await; - expected_content: cell_data.to_string(), - }, - ]; - - test.run_scripts(scripts).await; + test.wait(100).await; + let index = test.rows.len() - 1; + test + .assert_cell_existence(text_field.id.clone(), index, true) + .await; + test + .assert_cell_content(text_field.id.clone(), index, cell_data.to_string()) + .await; } #[tokio::test] async fn row_data_payload_with_multi_text_data_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let text_field = test.get_first_field(FieldType::RichText); - let number_field = test.get_first_field(FieldType::Number); - let url_field = test.get_first_field(FieldType::URL); + let text_field = test.get_first_field(FieldType::RichText).await; + let number_field = test.get_first_field(FieldType::Number).await; + let url_field = test.get_first_field(FieldType::URL).await; let text_cell_data = "sample cell data"; let number_cell_data = "1234"; let url_cell_data = "appflowy.io"; - let scripts = vec![ - CreateRowWithPayload { - payload: CreateRowPayloadPB { - view_id: test.view_id.clone(), - data: HashMap::from([ - (text_field.id.clone(), text_cell_data.to_string()), - (number_field.id.clone(), number_cell_data.to_string()), - (url_field.id.clone(), url_cell_data.to_string()), - ]), - ..Default::default() - }, - }, - Wait { milliseconds: 100 }, - AssertCellExistence { - field_id: text_field.id.clone(), - row_index: test.row_details.len(), - exists: true, - }, - AssertCellContent { - field_id: text_field.id, - row_index: test.row_details.len(), + test + .create_row_with_payload(CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([ + (text_field.id.clone(), text_cell_data.to_string()), + (number_field.id.clone(), number_cell_data.to_string()), + (url_field.id.clone(), url_cell_data.to_string()), + ]), + ..Default::default() + }) + .await; - expected_content: text_cell_data.to_string(), - }, - AssertCellExistence { - field_id: number_field.id.clone(), - row_index: test.row_details.len(), - exists: true, - }, - AssertCellContent { - field_id: number_field.id, - row_index: test.row_details.len(), - - expected_content: "$1,234".to_string(), - }, - AssertCellExistence { - field_id: url_field.id.clone(), - row_index: test.row_details.len(), - exists: true, - }, - AssertCellContent { - field_id: url_field.id, - row_index: test.row_details.len(), - - expected_content: url_cell_data.to_string(), - }, - ]; - - test.run_scripts(scripts).await; + test.wait(100).await; + let index = test.rows.len() - 1; + test + .assert_cell_existence(text_field.id.clone(), index, true) + .await; + test + .assert_cell_content(text_field.id, index, text_cell_data.to_string()) + .await; + test + .assert_cell_existence(number_field.id.clone(), index, true) + .await; + test + .assert_cell_content(number_field.id, index, "$1,234".to_string()) + .await; + test + .assert_cell_existence(url_field.id.clone(), index, true) + .await; + test + .assert_cell_content(url_field.id, index, url_cell_data.to_string()) + .await; } #[tokio::test] async fn row_data_payload_with_date_time_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let date_field = test.get_first_field(FieldType::DateTime); + let date_field = test.get_first_field(FieldType::DateTime).await; let cell_data = "1710510086"; - let scripts = vec![ - CreateRowWithPayload { - payload: CreateRowPayloadPB { - view_id: test.view_id.clone(), - data: HashMap::from([(date_field.id.clone(), cell_data.to_string())]), - ..Default::default() - }, - }, - Wait { milliseconds: 100 }, - AssertCellExistence { - field_id: date_field.id.clone(), - row_index: test.row_details.len(), - exists: true, - }, - AssertCellContent { - field_id: date_field.id.clone(), - row_index: test.row_details.len(), + test + .create_row_with_payload(CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(date_field.id.clone(), cell_data.to_string())]), + ..Default::default() + }) + .await; - expected_content: "2024/03/15".to_string(), - }, - ]; - - test.run_scripts(scripts).await; + test.wait(100).await; + let index = test.rows.len() - 1; + test + .assert_cell_existence(date_field.id.clone(), index, true) + .await; + test + .assert_cell_content(date_field.id.clone(), index, "2024/03/15".to_string()) + .await; } #[tokio::test] async fn row_data_payload_with_invalid_date_time_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let date_field = test.get_first_field(FieldType::DateTime); + let date_field = test.get_first_field(FieldType::DateTime).await; let cell_data = DateCellData { timestamp: Some(1710510086), ..Default::default() } - .to_string(); + .to_cell_string(); - let scripts = vec![ - CreateRowWithPayload { - payload: CreateRowPayloadPB { - view_id: test.view_id.clone(), - data: HashMap::from([(date_field.id.clone(), cell_data.to_string())]), - ..Default::default() - }, - }, - Wait { milliseconds: 100 }, - AssertCellExistence { - field_id: date_field.id.clone(), - row_index: test.row_details.len(), - exists: false, - }, - ]; + test + .create_row_with_payload(CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(date_field.id.clone(), cell_data.to_string())]), + ..Default::default() + }) + .await; - test.run_scripts(scripts).await; + test.wait(100).await; + let index = test.rows.len() - 1; + test + .assert_cell_existence(date_field.id.clone(), index, false) + .await; } #[tokio::test] async fn row_data_payload_with_checkbox_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let checkbox_field = test.get_first_field(FieldType::Checkbox); + let checkbox_field = test.get_first_field(FieldType::Checkbox).await; let cell_data = "Yes"; - let scripts = vec![ - CreateRowWithPayload { - payload: CreateRowPayloadPB { - view_id: test.view_id.clone(), - data: HashMap::from([(checkbox_field.id.clone(), cell_data.to_string())]), - ..Default::default() - }, - }, - Wait { milliseconds: 100 }, - AssertCellExistence { - field_id: checkbox_field.id.clone(), - row_index: test.row_details.len(), - exists: true, - }, - AssertCellContent { - field_id: checkbox_field.id.clone(), - row_index: test.row_details.len(), - - expected_content: cell_data.to_string(), - }, - ]; - - test.run_scripts(scripts).await; + test + .create_row_with_payload(CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(checkbox_field.id.clone(), cell_data.to_string())]), + ..Default::default() + }) + .await; + let index = test.rows.len() - 1; + test.wait(100).await; + test + .assert_cell_existence(checkbox_field.id.clone(), index, true) + .await; + test + .assert_cell_content(checkbox_field.id.clone(), index, cell_data.to_string()) + .await; } #[tokio::test] async fn row_data_payload_with_select_option_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let multi_select_field = test.get_first_field(FieldType::MultiSelect); - let options = test.get_multi_select_type_option(&multi_select_field.id); + let multi_select_field = test.get_first_field(FieldType::MultiSelect).await; + let options = test + .get_multi_select_type_option(&multi_select_field.id) + .await; let ids = options .iter() @@ -323,71 +243,60 @@ async fn row_data_payload_with_select_option_test() { .collect::>() .join(SELECTION_IDS_SEPARATOR); - let scripts = vec![ - CreateRowWithPayload { - payload: CreateRowPayloadPB { - view_id: test.view_id.clone(), - data: HashMap::from([(multi_select_field.id.clone(), ids)]), - ..Default::default() - }, - }, - Wait { milliseconds: 100 }, - AssertCellExistence { - field_id: multi_select_field.id.clone(), - row_index: test.row_details.len(), - exists: true, - }, - AssertCellContent { - field_id: multi_select_field.id.clone(), - row_index: test.row_details.len(), + test + .create_row_with_payload(CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(multi_select_field.id.clone(), ids)]), + ..Default::default() + }) + .await; - expected_content: stringified_cell_data, - }, - ]; - - test.run_scripts(scripts).await; + test.wait(100).await; + let index = test.rows.len() - 1; + test + .assert_cell_existence(multi_select_field.id.clone(), index, true) + .await; + test + .assert_cell_content(multi_select_field.id.clone(), index, stringified_cell_data) + .await; } #[tokio::test] async fn row_data_payload_with_invalid_select_option_id_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let multi_select_field = test.get_first_field(FieldType::MultiSelect); - let mut options = test.get_multi_select_type_option(&multi_select_field.id); + let multi_select_field = test.get_first_field(FieldType::MultiSelect).await; + let mut options = test + .get_multi_select_type_option(&multi_select_field.id) + .await; let first_id = options.swap_remove(0).id; let ids = [first_id.clone(), "nonsense".to_string()].join(SELECTION_IDS_SEPARATOR); - let scripts = vec![ - CreateRowWithPayload { - payload: CreateRowPayloadPB { - view_id: test.view_id.clone(), - data: HashMap::from([(multi_select_field.id.clone(), ids.to_string())]), - ..Default::default() - }, - }, - Wait { milliseconds: 100 }, - AssertCellExistence { - field_id: multi_select_field.id.clone(), - row_index: test.row_details.len(), - exists: true, - }, - AssertSelectOptionCellStrict { - field_id: multi_select_field.id.clone(), - row_index: test.row_details.len(), - expected_content: first_id, - }, - ]; + test + .create_row_with_payload(CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(multi_select_field.id.clone(), ids.to_string())]), + ..Default::default() + }) + .await; - test.run_scripts(scripts).await; + test.wait(100).await; + let index = test.rows.len() - 1; + test + .assert_cell_existence(multi_select_field.id.clone(), index, true) + .await; + test + .assert_select_option_cell_strict(multi_select_field.id.clone(), index, first_id) + .await; } #[tokio::test] async fn row_data_payload_with_too_many_select_option_test() { let mut test = DatabasePreFillRowCellTest::new().await; - - let single_select_field = test.get_first_field(FieldType::SingleSelect); - let mut options = test.get_single_select_type_option(&single_select_field.id); + let single_select_field = test.get_first_field(FieldType::SingleSelect).await; + let mut options = test + .get_single_select_type_option(&single_select_field.id) + .await; let ids = options .iter() @@ -397,26 +306,20 @@ async fn row_data_payload_with_too_many_select_option_test() { let stringified_cell_data = options.swap_remove(0).id; - let scripts = vec![ - CreateRowWithPayload { - payload: CreateRowPayloadPB { - view_id: test.view_id.clone(), - data: HashMap::from([(single_select_field.id.clone(), ids.to_string())]), - ..Default::default() - }, - }, - Wait { milliseconds: 100 }, - AssertCellExistence { - field_id: single_select_field.id.clone(), - row_index: test.row_details.len(), - exists: true, - }, - AssertSelectOptionCellStrict { - field_id: single_select_field.id.clone(), - row_index: test.row_details.len(), - expected_content: stringified_cell_data, - }, - ]; + test + .create_row_with_payload(CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(single_select_field.id.clone(), ids.to_string())]), + ..Default::default() + }) + .await; - test.run_scripts(scripts).await; + test.wait(100).await; + let index = test.rows.len() - 1; + test + .assert_cell_existence(single_select_field.id.clone(), index, true) + .await; + test + .assert_select_option_cell_strict(single_select_field.id.clone(), index, stringified_cell_data) + .await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/script.rs index e41e42207e..8bc93cf0de 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/script.rs @@ -1,40 +1,9 @@ -use std::ops::{Deref, DerefMut}; -use std::time::Duration; - +use crate::database::database_editor::DatabaseEditorTest; +use collab_database::fields::select_type_option::{SelectOptionIds, SELECTION_IDS_SEPARATOR}; use flowy_database2::entities::{CreateRowPayloadPB, FilterDataPB, InsertFilterPB}; use flowy_database2::services::cell::stringify_cell; -use flowy_database2::services::field::{SelectOptionIds, SELECTION_IDS_SEPARATOR}; - -use crate::database::database_editor::DatabaseEditorTest; - -pub enum PreFillRowCellTestScript { - CreateEmptyRow, - CreateRowWithPayload { - payload: CreateRowPayloadPB, - }, - InsertFilter { - filter: FilterDataPB, - }, - AssertRowCount(usize), - AssertCellExistence { - field_id: String, - row_index: usize, - exists: bool, - }, - AssertCellContent { - field_id: String, - row_index: usize, - expected_content: String, - }, - AssertSelectOptionCellStrict { - field_id: String, - row_index: usize, - expected_content: String, - }, - Wait { - milliseconds: u64, - }, -} +use std::ops::{Deref, DerefMut}; +use std::time::Duration; pub struct DatabasePreFillRowCellTest { inner: DatabaseEditorTest, @@ -46,103 +15,83 @@ impl DatabasePreFillRowCellTest { Self { inner: editor_test } } - pub async fn run_scripts(&mut self, scripts: Vec) { - for script in scripts { - self.run_script(script).await; - } + pub async fn create_empty_row(&mut self) { + let params = CreateRowPayloadPB { + view_id: self.view_id.clone(), + ..Default::default() + }; + let row_detail = self.editor.create_row(params).await.unwrap().unwrap(); + self + .row_by_row_id + .insert(row_detail.row.id.to_string(), row_detail.into()); + self.rows = self.get_rows().await; } - pub async fn run_script(&mut self, script: PreFillRowCellTestScript) { - match script { - PreFillRowCellTestScript::CreateEmptyRow => { - let params = CreateRowPayloadPB { - view_id: self.view_id.clone(), - ..Default::default() - }; - let row_detail = self.editor.create_row(params).await.unwrap().unwrap(); - self - .row_by_row_id - .insert(row_detail.row.id.to_string(), row_detail.into()); - self.row_details = self.get_rows().await; - }, - PreFillRowCellTestScript::CreateRowWithPayload { payload } => { - let row_detail = self.editor.create_row(payload).await.unwrap().unwrap(); - self - .row_by_row_id - .insert(row_detail.row.id.to_string(), row_detail.into()); - self.row_details = self.get_rows().await; - }, - PreFillRowCellTestScript::InsertFilter { filter } => self - .editor - .modify_view_filters( - &self.view_id, - InsertFilterPB { - parent_filter_id: None, - data: filter, - } - .try_into() - .unwrap(), - ) - .await + pub async fn create_row_with_payload(&mut self, payload: CreateRowPayloadPB) { + let row_detail = self.editor.create_row(payload).await.unwrap().unwrap(); + self + .row_by_row_id + .insert(row_detail.row.id.to_string(), row_detail.into()); + self.rows = self.get_rows().await; + } + + pub async fn insert_filter(&mut self, filter: FilterDataPB) { + self + .editor + .modify_view_filters( + &self.view_id, + InsertFilterPB { + parent_filter_id: None, + data: filter, + } + .try_into() .unwrap(), - PreFillRowCellTestScript::AssertRowCount(expected_row_count) => { - let rows = self.editor.get_rows(&self.view_id).await.unwrap(); - assert_eq!(expected_row_count, rows.len()); - }, - PreFillRowCellTestScript::AssertCellExistence { - field_id, - row_index, - exists, - } => { - let rows = self.editor.get_rows(&self.view_id).await.unwrap(); - let row_detail = rows.get(row_index).unwrap(); + ) + .await + .unwrap(); + } - let cell = row_detail.row.cells.get(&field_id).cloned(); + pub async fn assert_row_count(&self, expected_row_count: usize) { + let rows = self.editor.get_all_rows(&self.view_id).await.unwrap(); + assert_eq!(expected_row_count, rows.len()); + } - assert_eq!(exists, cell.is_some()); - }, - PreFillRowCellTestScript::AssertCellContent { - field_id, - row_index, - expected_content, - } => { - let field = self.editor.get_field(&field_id).unwrap(); + pub async fn assert_cell_existence(&self, field_id: String, row_index: usize, exists: bool) { + let rows = self.editor.get_all_rows(&self.view_id).await.unwrap(); + let row = rows.get(row_index).unwrap(); + let cell = row.cells.get(&field_id).cloned(); + assert_eq!(exists, cell.is_some()); + } - let rows = self.editor.get_rows(&self.view_id).await.unwrap(); - let row_detail = rows.get(row_index).unwrap(); + pub async fn assert_cell_content( + &self, + field_id: String, + row_index: usize, + expected_content: String, + ) { + let field = self.editor.get_field(&field_id).await.unwrap(); + let rows = self.editor.get_all_rows(&self.view_id).await.unwrap(); + let row = rows.get(row_index).unwrap(); + let cell = row.cells.get(&field_id).cloned().unwrap_or_default(); + let content = stringify_cell(&cell, &field); + assert_eq!(content, expected_content); + } - let cell = row_detail - .row - .cells - .get(&field_id) - .cloned() - .unwrap_or_default(); - let content = stringify_cell(&cell, &field); - assert_eq!(content, expected_content); - }, - PreFillRowCellTestScript::AssertSelectOptionCellStrict { - field_id, - row_index, - expected_content, - } => { - let rows = self.editor.get_rows(&self.view_id).await.unwrap(); - let row_detail = rows.get(row_index).unwrap(); + pub async fn assert_select_option_cell_strict( + &self, + field_id: String, + row_index: usize, + expected_content: String, + ) { + let rows = self.editor.get_all_rows(&self.view_id).await.unwrap(); + let row = rows.get(row_index).unwrap(); + let cell = row.cells.get(&field_id).cloned().unwrap_or_default(); + let content = SelectOptionIds::from(&cell).join(SELECTION_IDS_SEPARATOR); + assert_eq!(content, expected_content); + } - let cell = row_detail - .row - .cells - .get(&field_id) - .cloned() - .unwrap_or_default(); - - let content = SelectOptionIds::from(&cell).join(SELECTION_IDS_SEPARATOR); - - assert_eq!(content, expected_content); - }, - PreFillRowCellTestScript::Wait { milliseconds } => { - tokio::time::sleep(Duration::from_millis(milliseconds)).await; - }, - } + pub async fn wait(&self, milliseconds: u64) { + tokio::time::sleep(Duration::from_millis(milliseconds)).await; } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs b/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs index a54fd17996..72a85d1340 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs @@ -32,8 +32,8 @@ async fn export_and_then_import_meta_csv_test() { let result = test.import(csv_1.clone(), format).await; let database = test.get_database(&result.database_id).await.unwrap(); - let fields = database.get_fields(&result.view_id, None); - let rows = database.get_rows(&result.view_id).await.unwrap(); + let fields = database.get_fields(&result.view_id, None).await; + let rows = database.get_all_rows(&result.view_id).await.unwrap(); assert_eq!(fields[0].field_type, 0); assert_eq!(fields[1].field_type, 1); assert_eq!(fields[2].field_type, 2); @@ -46,8 +46,8 @@ async fn export_and_then_import_meta_csv_test() { assert_eq!(fields[9].field_type, 9); for field in fields { - for (index, row_detail) in rows.iter().enumerate() { - if let Some(cell) = row_detail.row.cells.get(&field.id) { + for (index, row) in rows.iter().enumerate() { + if let Some(cell) = row.cells.get(&field.id) { let field_type = FieldType::from(field.field_type); let s = stringify_cell(cell, &field); match &field_type { @@ -76,18 +76,21 @@ async fn export_and_then_import_meta_csv_test() { assert_eq!(s, "Google,Facebook"); } }, - FieldType::Checkbox => {}, - FieldType::URL => {}, - FieldType::Checklist => {}, - FieldType::LastEditedTime => {}, - FieldType::CreatedTime => {}, - FieldType::Relation => {}, - FieldType::Summary => {}, + FieldType::Checkbox + | FieldType::URL + | FieldType::Checklist + | FieldType::LastEditedTime + | FieldType::CreatedTime + | FieldType::Relation + | FieldType::Summary + | FieldType::Time + | FieldType::Translate + | FieldType::Media => {}, } } else { panic!( "Can not found the cell with id: {} in {:?}", - field.id, row_detail.row.cells + field.id, row.cells ); } } @@ -109,8 +112,8 @@ async fn history_database_import_test() { let result = test.import(csv.to_string(), format).await; let database = test.get_database(&result.database_id).await.unwrap(); - let fields = database.get_fields(&result.view_id, None); - let rows = database.get_rows(&result.view_id).await.unwrap(); + let fields = database.get_fields(&result.view_id, None).await; + let rows = database.get_all_rows(&result.view_id).await.unwrap(); assert_eq!(fields[0].field_type, 0); assert_eq!(fields[1].field_type, 1); assert_eq!(fields[2].field_type, 2); @@ -121,8 +124,8 @@ async fn history_database_import_test() { assert_eq!(fields[7].field_type, 7); for field in fields { - for (index, row_detail) in rows.iter().enumerate() { - if let Some(cell) = row_detail.row.cells.get(&field.id) { + for (index, row) in rows.iter().enumerate() { + if let Some(cell) = row.cells.get(&field.id) { let field_type = FieldType::from(field.field_type); let s = stringify_cell(cell, &field); match &field_type { @@ -161,16 +164,19 @@ async fn history_database_import_test() { assert_eq!(s, "AppFlowy website - https://www.appflowy.io"); } }, - FieldType::Checklist => {}, - FieldType::LastEditedTime => {}, - FieldType::CreatedTime => {}, - FieldType::Relation => {}, - FieldType::Summary => {}, + FieldType::Checklist + | FieldType::LastEditedTime + | FieldType::CreatedTime + | FieldType::Relation + | FieldType::Summary + | FieldType::Time + | FieldType::Translate + | FieldType::Media => {}, } } else { panic!( "Can not found the cell with id: {} in {:?}", - field.id, row_detail.row.cells + field.id, row.cells ); } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/sort_test/multi_sort_test.rs b/frontend/rust-lib/flowy-database2/tests/database/sort_test/multi_sort_test.rs index 7fe1874984..ca15cc309b 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/sort_test/multi_sort_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/sort_test/multi_sort_test.rs @@ -2,100 +2,125 @@ use flowy_database2::entities::FieldType; use flowy_database2::services::sort::SortCondition; use crate::database::sort_test::script::DatabaseSortTest; -use crate::database::sort_test::script::SortScript::*; #[tokio::test] async fn sort_checkbox_and_then_text_by_descending_test() { let mut test = DatabaseSortTest::new().await; - let checkbox_field = test.get_first_field(FieldType::Checkbox); - let text_field = test.get_first_field(FieldType::RichText); - let scripts = vec![ - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["Yes", "Yes", "No", "No", "No", "Yes", ""], - }, - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "", "C", "DA", "AE", "AE", "CB"], - }, - // Insert checkbox sort - InsertSort { - field: checkbox_field.clone(), - condition: SortCondition::Descending, - }, - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["Yes", "Yes", "Yes", "No", "No", "No", ""], - }, - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "", "AE", "C", "DA", "AE", "CB"], - }, - // Insert text sort - InsertSort { - field: text_field.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["Yes", "Yes", "Yes", "No", "No", "", "No"], - }, - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "AE", "", "AE", "C", "CB", "DA"], - }, - ]; - test.run_scripts(scripts).await; + let checkbox_field = test.get_first_field(FieldType::Checkbox).await; + let text_field = test.get_first_field(FieldType::RichText).await; + + // Assert initial cell content order for checkbox and text fields + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["Yes", "Yes", "No", "No", "No", "Yes", ""], + ) + .await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "", "C", "DA", "AE", "AE", "CB"], + ) + .await; + + // Insert checkbox sort (Descending) + test + .insert_sort(checkbox_field.clone(), SortCondition::Descending) + .await; + + // Assert sorted order for checkbox and text fields + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["Yes", "Yes", "Yes", "No", "No", "No", ""], + ) + .await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "", "AE", "C", "DA", "AE", "CB"], + ) + .await; + + // Insert text sort (Ascending) + test + .insert_sort(text_field.clone(), SortCondition::Ascending) + .await; + + // Assert sorted order after adding text sort + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["Yes", "Yes", "Yes", "No", "No", "", "No"], + ) + .await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "AE", "", "AE", "C", "CB", "DA"], + ) + .await; } #[tokio::test] async fn reorder_sort_test() { let mut test = DatabaseSortTest::new().await; - let checkbox_field = test.get_first_field(FieldType::Checkbox); - let text_field = test.get_first_field(FieldType::RichText); - // Use the same sort set up as above - let scripts = vec![ - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["Yes", "Yes", "No", "No", "No", "Yes", ""], - }, - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "", "C", "DA", "AE", "AE", "CB"], - }, - InsertSort { - field: checkbox_field.clone(), - condition: SortCondition::Descending, - }, - InsertSort { - field: text_field.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["Yes", "Yes", "Yes", "No", "No", "", "No"], - }, - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "AE", "", "AE", "C", "CB", "DA"], - }, - ]; - test.run_scripts(scripts).await; + let checkbox_field = test.get_first_field(FieldType::Checkbox).await; + let text_field = test.get_first_field(FieldType::RichText).await; + // Assert initial cell content order for checkbox and text fields + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["Yes", "Yes", "No", "No", "No", "Yes", ""], + ) + .await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "", "C", "DA", "AE", "AE", "CB"], + ) + .await; + + // Insert checkbox and text sorts + test + .insert_sort(checkbox_field.clone(), SortCondition::Descending) + .await; + test + .insert_sort(text_field.clone(), SortCondition::Ascending) + .await; + + // Assert sorted order after applying both sorts + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["Yes", "Yes", "Yes", "No", "No", "", "No"], + ) + .await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "AE", "", "AE", "C", "CB", "DA"], + ) + .await; + + // Reorder sorts let sorts = test.editor.get_all_sorts(&test.view_id).await.items; - let scripts = vec![ - ReorderSort { - from_sort_id: sorts[1].id.clone(), - to_sort_id: sorts[0].id.clone(), - }, - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["Yes", "Yes", "No", "No", "", "No", "Yes"], - }, - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "AE", "AE", "C", "CB", "DA", ""], - }, - ]; - test.run_scripts(scripts).await; + test + .reorder_sort(sorts[1].id.clone(), sorts[0].id.clone()) + .await; + + // Assert the order after reorder + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["Yes", "Yes", "No", "No", "", "No", "Yes"], + ) + .await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "AE", "AE", "C", "CB", "DA", ""], + ) + .await; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/sort_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/sort_test/script.rs index a6b99dc99c..75694401b6 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/sort_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/sort_test/script.rs @@ -7,44 +7,15 @@ use collab_database::rows::RowId; use futures::stream::StreamExt; use tokio::sync::broadcast::Receiver; +use crate::database::database_editor::DatabaseEditorTest; use flowy_database2::entities::{ - CreateRowPayloadPB, DeleteSortPayloadPB, ReorderSortPayloadPB, UpdateSortPayloadPB, + CreateRowPayloadPB, DeleteSortPayloadPB, FieldType, ReorderSortPayloadPB, UpdateSortPayloadPB, }; use flowy_database2::services::cell::stringify_cell; use flowy_database2::services::database_view::DatabaseViewChanged; +use flowy_database2::services::filter::{FilterChangeset, FilterInner}; use flowy_database2::services::sort::SortCondition; - -use crate::database::database_editor::DatabaseEditorTest; - -pub enum SortScript { - InsertSort { - field: Field, - condition: SortCondition, - }, - ReorderSort { - from_sort_id: String, - to_sort_id: String, - }, - DeleteSort { - sort_id: String, - }, - AssertCellContentOrder { - field_id: String, - orders: Vec<&'static str>, - }, - UpdateTextCell { - row_id: RowId, - text: String, - }, - AddNewRow, - AssertSortChanged { - old_row_orders: Vec<&'static str>, - new_row_orders: Vec<&'static str>, - }, - Wait { - millis: u64, - }, -} +use lib_infra::box_any::BoxAny; pub struct DatabaseSortTest { inner: DatabaseEditorTest, @@ -59,132 +30,145 @@ impl DatabaseSortTest { recv: None, } } - pub async fn run_scripts(&mut self, scripts: Vec) { - for script in scripts { - self.run_script(script).await; + + pub async fn insert_sort(&mut self, field: Field, condition: SortCondition) { + self.recv = Some( + self + .editor + .subscribe_view_changed(&self.view_id) + .await + .unwrap(), + ); + let params = UpdateSortPayloadPB { + view_id: self.view_id.clone(), + field_id: field.id.clone(), + sort_id: None, + condition: condition.into(), + }; + self.editor.create_or_update_sort(params).await.unwrap(); + } + + pub async fn insert_filter(&mut self, field_type: FieldType, data: BoxAny) { + let field = self.get_first_field(field_type).await; + let params = FilterChangeset::Insert { + parent_filter_id: None, + data: FilterInner::Data { + field_id: field.id, + field_type, + condition_and_content: data, + }, + }; + self + .editor + .modify_view_filters(&self.view_id, params) + .await + .unwrap(); + } + + pub async fn reorder_sort(&mut self, from_sort_id: String, to_sort_id: String) { + self.recv = Some( + self + .editor + .subscribe_view_changed(&self.view_id) + .await + .unwrap(), + ); + let params = ReorderSortPayloadPB { + view_id: self.view_id.clone(), + from_sort_id, + to_sort_id, + }; + self.editor.reorder_sort(params).await.unwrap(); + } + + pub async fn delete_sort(&mut self, sort_id: String) { + self.recv = Some( + self + .editor + .subscribe_view_changed(&self.view_id) + .await + .unwrap(), + ); + let params = DeleteSortPayloadPB { + view_id: self.view_id.clone(), + sort_id, + }; + self.editor.delete_sort(params).await.unwrap(); + } + + pub async fn assert_cell_content_order(&mut self, field_id: String, orders: Vec<&'static str>) { + let mut cells = vec![]; + let rows = self.editor.get_all_rows(&self.view_id).await.unwrap(); + let field = self.editor.get_field(&field_id).await.unwrap(); + for row in rows { + if let Some(cell) = row.cells.get(&field_id) { + let content = stringify_cell(cell, &field); + cells.push(content); + } else { + cells.push("".to_string()); + } + } + if orders.is_empty() { + assert_eq!(cells, orders); + } else { + let len = min(cells.len(), orders.len()); + assert_eq!(cells.split_at(len).0, orders); } } - pub async fn run_script(&mut self, script: SortScript) { - match script { - SortScript::InsertSort { condition, field } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id) - .await - .unwrap(), - ); - let params = UpdateSortPayloadPB { - view_id: self.view_id.clone(), - field_id: field.id.clone(), - sort_id: None, - condition: condition.into(), - }; - let _ = self.editor.create_or_update_sort(params).await.unwrap(); - }, - SortScript::ReorderSort { - from_sort_id, - to_sort_id, - } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id) - .await - .unwrap(), - ); - let params = ReorderSortPayloadPB { - view_id: self.view_id.clone(), - from_sort_id, - to_sort_id, - }; - self.editor.reorder_sort(params).await.unwrap(); - }, - SortScript::DeleteSort { sort_id } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id) - .await - .unwrap(), - ); - let params = DeleteSortPayloadPB { - view_id: self.view_id.clone(), - sort_id, - }; - self.editor.delete_sort(params).await.unwrap(); - }, - SortScript::AssertCellContentOrder { field_id, orders } => { - let mut cells = vec![]; - let rows = self.editor.get_rows(&self.view_id).await.unwrap(); - let field = self.editor.get_field(&field_id).unwrap(); - for row_detail in rows { - if let Some(cell) = row_detail.row.cells.get(&field_id) { - let content = stringify_cell(cell, &field); - cells.push(content); - } else { - cells.push("".to_string()); - } - } - if orders.is_empty() { - assert_eq!(cells, orders); - } else { - let len = min(cells.len(), orders.len()); - assert_eq!(cells.split_at(len).0, orders); - } - }, - SortScript::UpdateTextCell { row_id, text } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id) - .await - .unwrap(), - ); - self.update_text_cell(row_id, &text).await.unwrap(); - }, - SortScript::AddNewRow => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id) - .await - .unwrap(), - ); - self - .editor - .create_row(CreateRowPayloadPB { - view_id: self.view_id.clone(), - ..Default::default() - }) - .await - .unwrap(); - }, - SortScript::AssertSortChanged { - new_row_orders, - old_row_orders, - } => { - if let Some(receiver) = self.recv.take() { - assert_sort_changed( - receiver, - new_row_orders - .into_iter() - .map(|order| order.to_owned()) - .collect(), - old_row_orders - .into_iter() - .map(|order| order.to_owned()) - .collect(), - ) - .await; - } - }, - SortScript::Wait { millis } => { - tokio::time::sleep(Duration::from_millis(millis)).await; - }, + pub async fn update_text_cell(&mut self, row_id: RowId, text: String) { + self.recv = Some( + self + .editor + .subscribe_view_changed(&self.view_id) + .await + .unwrap(), + ); + self.inner.update_text_cell(row_id, &text).await.unwrap(); + } + + pub async fn add_new_row(&mut self) { + self.recv = Some( + self + .editor + .subscribe_view_changed(&self.view_id) + .await + .unwrap(), + ); + self + .editor + .create_row(CreateRowPayloadPB { + view_id: self.view_id.clone(), + ..Default::default() + }) + .await + .unwrap(); + } + + pub async fn assert_sort_changed( + &mut self, + new_row_orders: Vec<&'static str>, + old_row_orders: Vec<&'static str>, + ) { + if let Some(receiver) = self.recv.take() { + assert_sort_changed( + receiver, + new_row_orders + .into_iter() + .map(|order| order.to_owned()) + .collect(), + old_row_orders + .into_iter() + .map(|order| order.to_owned()) + .collect(), + ) + .await; } } + + pub async fn wait(&mut self, millis: u64) { + tokio::time::sleep(Duration::from_millis(millis)).await; + } } async fn assert_sort_changed( @@ -211,7 +195,6 @@ async fn assert_sort_changed( old_row_orders.insert(changed.new_index, old); assert_eq!(old_row_orders, new_row_orders); }, - DatabaseViewChanged::InsertRowNotification(_changed) => {}, _ => {}, } }) diff --git a/frontend/rust-lib/flowy-database2/tests/database/sort_test/single_sort_test.rs b/frontend/rust-lib/flowy-database2/tests/database/sort_test/single_sort_test.rs index 63f3b08422..dbcf86ad4f 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/sort_test/single_sort_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/sort_test/single_sort_test.rs @@ -1,189 +1,205 @@ -use flowy_database2::entities::FieldType; +use crate::database::sort_test::script::DatabaseSortTest; +use flowy_database2::entities::{CheckboxFilterConditionPB, CheckboxFilterPB, FieldType}; use flowy_database2::services::sort::SortCondition; - -use crate::database::sort_test::script::{DatabaseSortTest, SortScript::*}; +use lib_infra::box_any::BoxAny; #[tokio::test] async fn sort_text_by_ascending_test() { let mut test = DatabaseSortTest::new().await; - let text_field = test.get_first_field(FieldType::RichText); - let scripts = vec![ - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "", "C", "DA", "AE", "AE", "CB"], - }, - InsertSort { - field: text_field.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "AE", "AE", "C", "CB", "DA", ""], - }, - ]; - test.run_scripts(scripts).await; + let text_field = test.get_first_field(FieldType::RichText).await; + + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "", "C", "DA", "AE", "AE", "CB"], + ) + .await; + test + .insert_sort(text_field.clone(), SortCondition::Ascending) + .await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "AE", "AE", "C", "CB", "DA", ""], + ) + .await; + + let checkbox_filter = CheckboxFilterPB { + condition: CheckboxFilterConditionPB::IsChecked, + }; + test + .insert_filter(FieldType::Checkbox, BoxAny::new(checkbox_filter)) + .await; + + test + .assert_cell_content_order(text_field.id.clone(), vec!["A", "AE", ""]) + .await; } #[tokio::test] async fn sort_text_by_descending_test() { let mut test = DatabaseSortTest::new().await; - let text_field = test.get_first_field(FieldType::RichText); - let scripts = vec![ - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "", "C", "DA", "AE", "AE", "CB"], - }, - InsertSort { - field: text_field.clone(), - condition: SortCondition::Descending, - }, - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["DA", "CB", "C", "AE", "AE", "A", ""], - }, - ]; - test.run_scripts(scripts).await; + let text_field = test.get_first_field(FieldType::RichText).await; + + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "", "C", "DA", "AE", "AE", "CB"], + ) + .await; + test + .insert_sort(text_field.clone(), SortCondition::Descending) + .await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["DA", "CB", "C", "AE", "AE", "A", ""], + ) + .await; } #[tokio::test] async fn sort_change_notification_by_update_text_test() { let mut test = DatabaseSortTest::new().await; - let text_field = test.get_first_field(FieldType::RichText).clone(); - let scripts = vec![ - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "", "C", "DA", "AE", "AE", "CB"], - }, - InsertSort { - field: text_field.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "AE", "AE", "C", "CB", "DA", ""], - }, - // Wait the insert task to finish. The cost of time should be less than 200 milliseconds. - Wait { millis: 200 }, - ]; - test.run_scripts(scripts).await; + let text_field = test.get_first_field(FieldType::RichText).await; - let row_details = test.get_rows().await; - let scripts = vec![ - UpdateTextCell { - row_id: row_details[1].row.id.clone(), - text: "E".to_string(), - }, - AssertSortChanged { - old_row_orders: vec!["A", "E", "AE", "C", "CB", "DA", ""], - new_row_orders: vec!["A", "AE", "C", "CB", "DA", "E", ""], - }, - ]; - test.run_scripts(scripts).await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "", "C", "DA", "AE", "AE", "CB"], + ) + .await; + test + .insert_sort(text_field.clone(), SortCondition::Ascending) + .await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "AE", "AE", "C", "CB", "DA", ""], + ) + .await; + test.wait(200).await; + + let row = test.get_rows().await; + test + .update_text_cell(row[1].id.clone(), "E".to_string()) + .await; + test + .assert_sort_changed( + vec!["A", "AE", "C", "CB", "DA", "E", ""], + vec!["A", "E", "AE", "C", "CB", "DA", ""], + ) + .await; } #[tokio::test] async fn sort_after_new_row_test() { let mut test = DatabaseSortTest::new().await; - let checkbox_field = test.get_first_field(FieldType::Checkbox); - let scripts = vec![ - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["Yes", "Yes", "No", "No", "No", "Yes", ""], - }, - InsertSort { - field: checkbox_field.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["No", "No", "No", "", "Yes", "Yes", "Yes"], - }, - AddNewRow {}, - AssertCellContentOrder { - field_id: checkbox_field.id, - orders: vec!["No", "No", "No", "", "", "Yes", "Yes", "Yes"], - }, - ]; - test.run_scripts(scripts).await; + let checkbox_field = test.get_first_field(FieldType::Checkbox).await; + + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["Yes", "Yes", "No", "No", "No", "Yes", ""], + ) + .await; + test + .insert_sort(checkbox_field.clone(), SortCondition::Ascending) + .await; + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["No", "No", "No", "", "Yes", "Yes", "Yes"], + ) + .await; + + test.add_new_row().await; + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["No", "No", "No", "", "", "Yes", "Yes", "Yes"], + ) + .await; } #[tokio::test] async fn sort_text_by_ascending_and_delete_sort_test() { let mut test = DatabaseSortTest::new().await; - let text_field = test.get_first_field(FieldType::RichText); - let scripts = vec![ - InsertSort { - field: text_field.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "AE", "AE", "C", "CB", "DA", ""], - }, - ]; - test.run_scripts(scripts).await; + let text_field = test.get_first_field(FieldType::RichText).await; + + test + .insert_sort(text_field.clone(), SortCondition::Ascending) + .await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "AE", "AE", "C", "CB", "DA", ""], + ) + .await; let sort = test.editor.get_all_sorts(&test.view_id).await.items[0].clone(); - let scripts = vec![ - DeleteSort { sort_id: sort.id }, - AssertCellContentOrder { - field_id: text_field.id.clone(), - orders: vec!["A", "", "C", "DA", "AE", "AE", "CB"], - }, - ]; - test.run_scripts(scripts).await; + test.delete_sort(sort.id.clone()).await; + test + .assert_cell_content_order( + text_field.id.clone(), + vec!["A", "", "C", "DA", "AE", "AE", "CB"], + ) + .await; } #[tokio::test] async fn sort_checkbox_by_ascending_test() { let mut test = DatabaseSortTest::new().await; - let checkbox_field = test.get_first_field(FieldType::Checkbox); - let scripts = vec![ - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["Yes", "Yes", "No", "No", "No", "Yes", ""], - }, - InsertSort { - field: checkbox_field.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["No", "No", "No", "", "Yes", "Yes", "Yes"], - }, - ]; - test.run_scripts(scripts).await; + let checkbox_field = test.get_first_field(FieldType::Checkbox).await; + + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["Yes", "Yes", "No", "No", "No", "Yes", ""], + ) + .await; + test + .insert_sort(checkbox_field.clone(), SortCondition::Ascending) + .await; + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["No", "No", "No", "", "Yes", "Yes", "Yes"], + ) + .await; } #[tokio::test] async fn sort_checkbox_by_descending_test() { let mut test = DatabaseSortTest::new().await; - let checkbox_field = test.get_first_field(FieldType::Checkbox); - let scripts = vec![ - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["Yes", "Yes", "No", "No", "No", "Yes", ""], - }, - InsertSort { - field: checkbox_field.clone(), - condition: SortCondition::Descending, - }, - AssertCellContentOrder { - field_id: checkbox_field.id.clone(), - orders: vec!["Yes", "Yes", "Yes", "No", "No", "No", ""], - }, - ]; - test.run_scripts(scripts).await; + let checkbox_field = test.get_first_field(FieldType::Checkbox).await; + + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["Yes", "Yes", "No", "No", "No", "Yes", ""], + ) + .await; + test + .insert_sort(checkbox_field.clone(), SortCondition::Descending) + .await; + test + .assert_cell_content_order( + checkbox_field.id.clone(), + vec!["Yes", "Yes", "Yes", "No", "No", "No", ""], + ) + .await; } #[tokio::test] async fn sort_date_by_ascending_test() { let mut test = DatabaseSortTest::new().await; - let date_field = test.get_first_field(FieldType::DateTime); - let scripts = vec![ - AssertCellContentOrder { - field_id: date_field.id.clone(), - orders: vec![ + let date_field = test.get_first_field(FieldType::DateTime).await; + + test + .assert_cell_content_order( + date_field.id.clone(), + vec![ "2022/03/14", "2022/03/14", "2022/03/14", @@ -192,14 +208,15 @@ async fn sort_date_by_ascending_test() { "2022/12/25", "", ], - }, - InsertSort { - field: date_field.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: date_field.id.clone(), - orders: vec![ + ) + .await; + test + .insert_sort(date_field.clone(), SortCondition::Ascending) + .await; + test + .assert_cell_content_order( + date_field.id.clone(), + vec![ "2022/03/14", "2022/03/14", "2022/03/14", @@ -208,19 +225,19 @@ async fn sort_date_by_ascending_test() { "2022/12/25", "", ], - }, - ]; - test.run_scripts(scripts).await; + ) + .await; } #[tokio::test] async fn sort_date_by_descending_test() { let mut test = DatabaseSortTest::new().await; - let date_field = test.get_first_field(FieldType::DateTime); - let scripts = vec![ - AssertCellContentOrder { - field_id: date_field.id.clone(), - orders: vec![ + let date_field = test.get_first_field(FieldType::DateTime).await; + + test + .assert_cell_content_order( + date_field.id.clone(), + vec![ "2022/03/14", "2022/03/14", "2022/03/14", @@ -229,14 +246,15 @@ async fn sort_date_by_descending_test() { "2022/12/25", "", ], - }, - InsertSort { - field: date_field.clone(), - condition: SortCondition::Descending, - }, - AssertCellContentOrder { - field_id: date_field.id.clone(), - orders: vec![ + ) + .await; + test + .insert_sort(date_field.clone(), SortCondition::Descending) + .await; + test + .assert_cell_content_order( + date_field.id.clone(), + vec![ "2022/12/25", "2022/11/17", "2022/11/13", @@ -245,239 +263,50 @@ async fn sort_date_by_descending_test() { "2022/03/14", "", ], - }, - ]; - test.run_scripts(scripts).await; + ) + .await; } #[tokio::test] async fn sort_number_by_ascending_test() { let mut test = DatabaseSortTest::new().await; - let number_field = test.get_first_field(FieldType::Number); - let scripts = vec![ - AssertCellContentOrder { - field_id: number_field.id.clone(), - orders: vec!["$1", "$2", "$3", "$14", "", "$5", ""], - }, - InsertSort { - field: number_field.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: number_field.id.clone(), - orders: vec!["$1", "$2", "$3", "$5", "$14", "", ""], - }, - ]; - test.run_scripts(scripts).await; + let number_field = test.get_first_field(FieldType::Number).await; + + test + .assert_cell_content_order( + number_field.id.clone(), + vec!["$1", "$2", "$3", "$14", "", "$5", ""], + ) + .await; + test + .insert_sort(number_field.clone(), SortCondition::Ascending) + .await; + test + .assert_cell_content_order( + number_field.id.clone(), + vec!["$1", "$2", "$3", "$5", "$14", "", ""], + ) + .await; } #[tokio::test] async fn sort_number_by_descending_test() { let mut test = DatabaseSortTest::new().await; - let number_field = test.get_first_field(FieldType::Number); - let scripts = vec![ - AssertCellContentOrder { - field_id: number_field.id.clone(), - orders: vec!["$1", "$2", "$3", "$14", "", "$5", ""], - }, - InsertSort { - field: number_field.clone(), - condition: SortCondition::Descending, - }, - AssertCellContentOrder { - field_id: number_field.id.clone(), - orders: vec!["$14", "$5", "$3", "$2", "$1", "", ""], - }, - ]; - test.run_scripts(scripts).await; -} + let number_field = test.get_first_field(FieldType::Number).await; -#[tokio::test] -async fn sort_single_select_by_ascending_test() { - let mut test = DatabaseSortTest::new().await; - let single_select = test.get_first_field(FieldType::SingleSelect); - let scripts = vec![ - AssertCellContentOrder { - field_id: single_select.id.clone(), - orders: vec!["", "", "Completed", "Completed", "Planned", "Planned", ""], - }, - InsertSort { - field: single_select.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: single_select.id.clone(), - orders: vec!["Completed", "Completed", "Planned", "Planned", "", "", ""], - }, - ]; - test.run_scripts(scripts).await; -} - -#[tokio::test] -async fn sort_single_select_by_descending_test() { - let mut test = DatabaseSortTest::new().await; - let single_select = test.get_first_field(FieldType::SingleSelect); - let scripts = vec![ - AssertCellContentOrder { - field_id: single_select.id.clone(), - orders: vec!["", "", "Completed", "Completed", "Planned", "Planned", ""], - }, - InsertSort { - field: single_select.clone(), - condition: SortCondition::Descending, - }, - AssertCellContentOrder { - field_id: single_select.id.clone(), - orders: vec!["Planned", "Planned", "Completed", "Completed", "", "", ""], - }, - ]; - test.run_scripts(scripts).await; -} - -#[tokio::test] -async fn sort_multi_select_by_ascending_test() { - let mut test = DatabaseSortTest::new().await; - let multi_select = test.get_first_field(FieldType::MultiSelect); - let scripts = vec![ - AssertCellContentOrder { - field_id: multi_select.id.clone(), - orders: vec![ - "Google,Facebook", - "Google,Twitter", - "Facebook,Google,Twitter", - "", - "Facebook,Twitter", - "Facebook", - "", - ], - }, - InsertSort { - field: multi_select.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: multi_select.id.clone(), - orders: vec![ - "Facebook", - "Facebook,Twitter", - "Google,Facebook", - "Google,Twitter", - "Facebook,Google,Twitter", - "", - "", - ], - }, - ]; - test.run_scripts(scripts).await; -} - -#[tokio::test] -async fn sort_multi_select_by_descending_test() { - let mut test = DatabaseSortTest::new().await; - let multi_select = test.get_first_field(FieldType::MultiSelect); - let scripts = vec![ - AssertCellContentOrder { - field_id: multi_select.id.clone(), - orders: vec![ - "Google,Facebook", - "Google,Twitter", - "Facebook,Google,Twitter", - "", - "Facebook,Twitter", - "Facebook", - "", - ], - }, - InsertSort { - field: multi_select.clone(), - condition: SortCondition::Descending, - }, - AssertCellContentOrder { - field_id: multi_select.id.clone(), - orders: vec![ - "Facebook,Google,Twitter", - "Google,Twitter", - "Google,Facebook", - "Facebook,Twitter", - "Facebook", - "", - "", - ], - }, - ]; - test.run_scripts(scripts).await; -} - -#[tokio::test] -async fn sort_checklist_by_ascending_test() { - let mut test = DatabaseSortTest::new().await; - let checklist_field = test.get_first_field(FieldType::Checklist); - let scripts = vec![ - AssertCellContentOrder { - field_id: checklist_field.id.clone(), - orders: vec![ - "First thing", - "Have breakfast,Have lunch,Take a nap,Have dinner,Shower and head to bed", - "", - "Task 1", - "", - "Sprint,Sprint some more,Rest", - "", - ], - }, - InsertSort { - field: checklist_field.clone(), - condition: SortCondition::Ascending, - }, - AssertCellContentOrder { - field_id: checklist_field.id.clone(), - orders: vec![ - "First thing", - "Have breakfast,Have lunch,Take a nap,Have dinner,Shower and head to bed", - "Sprint,Sprint some more,Rest", - "Task 1", - "", - "", - "", - ], - }, - ]; - test.run_scripts(scripts).await; -} - -#[tokio::test] -async fn sort_checklist_by_descending_test() { - let mut test = DatabaseSortTest::new().await; - let checklist_field = test.get_first_field(FieldType::Checklist); - let scripts = vec![ - AssertCellContentOrder { - field_id: checklist_field.id.clone(), - orders: vec![ - "First thing", - "Have breakfast,Have lunch,Take a nap,Have dinner,Shower and head to bed", - "", - "Task 1", - "", - "Sprint,Sprint some more,Rest", - "", - ], - }, - InsertSort { - field: checklist_field.clone(), - condition: SortCondition::Descending, - }, - AssertCellContentOrder { - field_id: checklist_field.id.clone(), - orders: vec![ - "Task 1", - "Sprint,Sprint some more,Rest", - "Have breakfast,Have lunch,Take a nap,Have dinner,Shower and head to bed", - "First thing", - "", - "", - "", - ], - }, - ]; - test.run_scripts(scripts).await; + test + .assert_cell_content_order( + number_field.id.clone(), + vec!["$1", "$2", "$3", "$14", "", "$5", ""], + ) + .await; + test + .insert_sort(number_field.clone(), SortCondition::Descending) + .await; + test + .assert_cell_content_order( + number_field.id.clone(), + vec!["$14", "$5", "$3", "$2", "$1", "", ""], + ) + .await; } diff --git a/frontend/rust-lib/flowy-date/Cargo.toml b/frontend/rust-lib/flowy-date/Cargo.toml index 40015cad77..d04dfd8416 100644 --- a/frontend/rust-lib/flowy-date/Cargo.toml +++ b/frontend/rust-lib/flowy-date/Cargo.toml @@ -24,4 +24,3 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] diff --git a/frontend/rust-lib/flowy-date/build.rs b/frontend/rust-lib/flowy-date/build.rs index e015eb2580..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-date/build.rs +++ b/frontend/rust-lib/flowy-date/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-document-pub/Cargo.toml b/frontend/rust-lib/flowy-document-pub/Cargo.toml index 93a282f5cc..cbb74de5c4 100644 --- a/frontend/rust-lib/flowy-document-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-document-pub/Cargo.toml @@ -9,5 +9,5 @@ edition = "2021" lib-infra = { workspace = true } flowy-error = { workspace = true } collab-document = { workspace = true } -anyhow.workspace = true -collab = { workspace = true } \ No newline at end of file +collab = { workspace = true } +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-document-pub/src/cloud.rs b/frontend/rust-lib/flowy-document-pub/src/cloud.rs index 2f4da1bd37..d5c25053a8 100644 --- a/frontend/rust-lib/flowy-document-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-document-pub/src/cloud.rs @@ -1,31 +1,39 @@ -use anyhow::Error; +use collab::entity::EncodedCollab; pub use collab_document::blocks::DocumentData; - use flowy_error::FlowyError; -use lib_infra::future::FutureResult; +use lib_infra::async_trait::async_trait; +use uuid::Uuid; /// A trait for document cloud service. /// Each kind of server should implement this trait. Check out the [AppFlowyServerProvider] of /// [flowy-server] crate for more information. +#[async_trait] pub trait DocumentCloudService: Send + Sync + 'static { - fn get_document_doc_state( + async fn get_document_doc_state( &self, - document_id: &str, - workspace_id: &str, - ) -> FutureResult, FlowyError>; + document_id: &Uuid, + workspace_id: &Uuid, + ) -> Result, FlowyError>; - fn get_document_snapshots( + async fn get_document_snapshots( &self, - document_id: &str, + document_id: &Uuid, limit: usize, workspace_id: &str, - ) -> FutureResult, Error>; + ) -> Result, FlowyError>; - fn get_document_data( + async fn get_document_data( &self, - document_id: &str, - workspace_id: &str, - ) -> FutureResult, Error>; + document_id: &Uuid, + workspace_id: &Uuid, + ) -> Result, FlowyError>; + + async fn create_document_collab( + &self, + workspace_id: &Uuid, + document_id: &Uuid, + encoded_collab: EncodedCollab, + ) -> Result<(), FlowyError>; } pub struct DocumentSnapshot { diff --git a/frontend/rust-lib/flowy-document/Cargo.toml b/frontend/rust-lib/flowy-document/Cargo.toml index 813d87d589..aaaef4938e 100644 --- a/frontend/rust-lib/flowy-document/Cargo.toml +++ b/frontend/rust-lib/flowy-document/Cargo.toml @@ -14,17 +14,16 @@ collab-entity = { workspace = true } collab-plugins = { workspace = true } collab-integrate = { workspace = true } flowy-document-pub = { workspace = true } -flowy-storage = { workspace = true } +flowy-storage-pub = { workspace = true } flowy-derive.workspace = true flowy-notification = { workspace = true } flowy-error = { path = "../flowy-error", features = ["impl_from_serde", "impl_from_dispatch_error", "impl_from_collab_document", "impl_from_collab_persistence"] } lib-dispatch = { workspace = true } lib-infra = { workspace = true } -validator = { version = "0.16.0", features = ["derive"] } +validator = { workspace = true, features = ["derive"] } protobuf.workspace = true bytes.workspace = true nanoid = "0.4.0" -parking_lot.workspace = true strum_macros = "0.21" serde.workspace = true serde_json.workspace = true @@ -35,7 +34,7 @@ indexmap = { version = "2.1.0", features = ["serde"] } uuid.workspace = true futures.workspace = true tokio-stream = { workspace = true, features = ["sync"] } -dashmap = "5" +dashmap.workspace = true scraper = "0.18.0" [target.'cfg(target_arch = "wasm32")'.dependencies] @@ -44,17 +43,12 @@ getrandom = { version = "0.2", features = ["js"] } [dev-dependencies] tempfile = "3.4.0" tracing-subscriber = { version = "0.3.3", features = ["env-filter"] } -collab-integrate = { workspace = true } +tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } [build-dependencies] flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] -web_ts = [ - "flowy-codegen/ts", -] - # search "Enable/Disable AppFlowy Verbose Log" to find the place that can enable verbose log verbose_log = ["collab-document/verbose_log"] diff --git a/frontend/rust-lib/flowy-document/build.rs b/frontend/rust-lib/flowy-document/build.rs index 9fdde3edf6..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-document/build.rs +++ b/frontend/rust-lib/flowy-document/build.rs @@ -4,37 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } - - #[cfg(feature = "web_ts")] - { - flowy_codegen::ts_event::gen( - "document", - flowy_codegen::Project::Web { - relative_path: "../../".to_string(), - }, - ); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - "document", - flowy_codegen::Project::Web { - relative_path: "../../".to_string(), - }, - ); - } } diff --git a/frontend/rust-lib/flowy-document/src/document.rs b/frontend/rust-lib/flowy-document/src/document.rs index 6ec018f171..aa871cf4bc 100644 --- a/frontend/rust-lib/flowy-document/src/document.rs +++ b/frontend/rust-lib/flowy-document/src/document.rs @@ -1,91 +1,42 @@ use crate::entities::{ DocEventPB, DocumentAwarenessStatesPB, DocumentSnapshotStatePB, DocumentSyncStatePB, }; -use crate::notification::{send_notification, DocumentNotification}; -use collab::core::collab::MutexCollab; -use collab_document::document::DocumentIndexContent; -use collab_document::{blocks::DocumentData, document::Document}; -use flowy_error::FlowyResult; +use crate::notification::{document_notification_builder, DocumentNotification}; +use collab::preclude::Collab; +use collab_document::document::Document; use futures::StreamExt; -use lib_dispatch::prelude::af_spawn; -use parking_lot::Mutex; -use std::{ - ops::{Deref, DerefMut}, - sync::Arc, -}; -use tracing::{instrument, warn}; +use lib_infra::sync_trace; +use uuid::Uuid; -/// This struct wrap the document::Document -#[derive(Clone)] -pub struct MutexDocument(Arc>); +pub fn subscribe_document_changed(doc_id: &Uuid, document: &mut Document) { + let doc_id_clone_for_block_changed = doc_id.to_string(); + document.subscribe_block_changed("key", move |events, is_remote| { + sync_trace!( + "[Document] block changed in doc_id: {}, is_remote: {}, events: {:?}", + doc_id_clone_for_block_changed, + is_remote, + events + ); -impl MutexDocument { - /// Open a document with the given collab. - /// # Arguments - /// * `collab` - the identifier of the collaboration instance - /// - /// # Returns - /// * `Result` - a Result containing either a new Document object or an Error if the document creation failed - pub fn open(doc_id: &str, collab: Arc) -> FlowyResult { - #[allow(clippy::arc_with_non_send_sync)] - let document = Document::open(collab.clone()).map(|inner| Self(Arc::new(Mutex::new(inner))))?; - subscribe_document_changed(doc_id, &document); - subscribe_document_snapshot_state(&collab); - subscribe_document_sync_state(&collab); - Ok(document) - } - - /// Creates and returns a new Document object with initial data. - /// # Arguments - /// * `collab` - the identifier of the collaboration instance - /// * `data` - the initial data to include in the document - /// - /// # Returns - /// * `Result` - a Result containing either a new Document object or an Error if the document creation failed - pub fn create_with_data(collab: Arc, data: DocumentData) -> FlowyResult { - #[allow(clippy::arc_with_non_send_sync)] - let document = - Document::create_with_data(collab, data).map(|inner| Self(Arc::new(Mutex::new(inner))))?; - Ok(document) - } - - #[instrument(level = "debug", skip_all)] - pub fn start_init_sync(&self) { - if let Some(document) = self.0.try_lock() { - if let Some(collab) = document.get_collab().try_lock() { - collab.start_init_sync(); - } else { - warn!("Failed to start init sync, collab is locked"); - } - } else { - warn!("Failed to start init sync, document is locked"); - } - } -} - -fn subscribe_document_changed(doc_id: &str, document: &MutexDocument) { - let doc_id_clone_for_block_changed = doc_id.to_owned(); - document - .lock() - .subscribe_block_changed(move |events, is_remote| { - #[cfg(feature = "verbose_log")] - tracing::trace!("subscribe_document_changed: {:?}", events); - - // send notification to the client. - send_notification( - &doc_id_clone_for_block_changed, - DocumentNotification::DidReceiveUpdate, - ) - .payload::((events, is_remote, None).into()) - .send(); - }); + // send notification to the client. + document_notification_builder( + &doc_id_clone_for_block_changed, + DocumentNotification::DidReceiveUpdate, + ) + .payload::((events, is_remote, None).into()) + .send(); + }); let doc_id_clone_for_awareness_state = doc_id.to_owned(); - document.lock().subscribe_awareness_state(move |events| { - #[cfg(feature = "verbose_log")] - tracing::trace!("subscribe_awareness_state: {:?}", events); - send_notification( - &doc_id_clone_for_awareness_state, + document.subscribe_awareness_state("key", move |events| { + sync_trace!( + "[Document] awareness state in doc_id: {}, events: {:?}", + doc_id_clone_for_awareness_state, + events + ); + + document_notification_builder( + &doc_id_clone_for_awareness_state.to_string(), DocumentNotification::DidUpdateDocumentAwarenessState, ) .payload::(events.into()) @@ -93,14 +44,14 @@ fn subscribe_document_changed(doc_id: &str, document: &MutexDocument) { }); } -fn subscribe_document_snapshot_state(collab: &Arc) { - let document_id = collab.lock().object_id.clone(); - let mut snapshot_state = collab.lock().subscribe_snapshot_state(); - af_spawn(async move { +pub fn subscribe_document_snapshot_state(collab: &Collab) { + let document_id = collab.object_id().to_string(); + let mut snapshot_state = collab.subscribe_snapshot_state(); + tokio::spawn(async move { while let Some(snapshot_state) = snapshot_state.next().await { if let Some(new_snapshot_id) = snapshot_state.snapshot_id() { tracing::debug!("Did create document remote snapshot: {}", new_snapshot_id); - send_notification( + document_notification_builder( &document_id, DocumentNotification::DidUpdateDocumentSnapshotState, ) @@ -111,12 +62,12 @@ fn subscribe_document_snapshot_state(collab: &Arc) { }); } -fn subscribe_document_sync_state(collab: &Arc) { - let document_id = collab.lock().object_id.clone(); - let mut sync_state_stream = collab.lock().subscribe_sync_state(); - af_spawn(async move { +pub fn subscribe_document_sync_state(collab: &Collab) { + let document_id = collab.object_id().to_string(); + let mut sync_state_stream = collab.subscribe_sync_state(); + tokio::spawn(async move { while let Some(sync_state) = sync_state_stream.next().await { - send_notification( + document_notification_builder( &document_id, DocumentNotification::DidUpdateDocumentSyncState, ) @@ -125,27 +76,3 @@ fn subscribe_document_sync_state(collab: &Arc) { } }); } - -unsafe impl Sync for MutexDocument {} -unsafe impl Send for MutexDocument {} - -impl Deref for MutexDocument { - type Target = Arc>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for MutexDocument { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl From<&MutexDocument> for DocumentIndexContent { - fn from(doc: &MutexDocument) -> Self { - let doc = doc.lock(); - DocumentIndexContent::from(&*doc) - } -} diff --git a/frontend/rust-lib/flowy-document/src/entities.rs b/frontend/rust-lib/flowy-document/src/entities.rs index ad5912dfeb..c8a6765fd6 100644 --- a/frontend/rust-lib/flowy-document/src/entities.rs +++ b/frontend/rust-lib/flowy-document/src/entities.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use collab::core::collab_state::SyncState; use collab_document::{ blocks::{json_str_to_hashmap, Block, BlockAction, DocumentData}, @@ -8,14 +6,24 @@ use collab_document::{ DocumentAwarenessUser, }, }; - use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; use lib_infra::validator_fn::{required_not_empty_str, required_valid_path}; +use std::collections::HashMap; +use std::str::FromStr; +use uuid::Uuid; use validator::Validate; use crate::parse::{NotEmptyStr, NotEmptyVec}; +#[derive(Default, ProtoBuf)] +pub struct EncodedCollabPB { + #[pb(index = 1)] + pub state_vector: Vec, + #[pb(index = 2)] + pub doc_state: Vec, +} + #[derive(Default, ProtoBuf)] pub struct OpenDocumentPayloadPB { #[pb(index = 1)] @@ -23,7 +31,7 @@ pub struct OpenDocumentPayloadPB { } pub struct OpenDocumentParams { - pub document_id: String, + pub document_id: Uuid, } impl TryInto for OpenDocumentPayloadPB { @@ -31,9 +39,9 @@ impl TryInto for OpenDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; - Ok(OpenDocumentParams { - document_id: document_id.0, - }) + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; + + Ok(OpenDocumentParams { document_id }) } } @@ -44,7 +52,7 @@ pub struct DocumentRedoUndoPayloadPB { } pub struct DocumentRedoUndoParams { - pub document_id: String, + pub document_id: Uuid, } impl TryInto for DocumentRedoUndoPayloadPB { @@ -52,9 +60,8 @@ impl TryInto for DocumentRedoUndoPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; - Ok(DocumentRedoUndoParams { - document_id: document_id.0, - }) + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; + Ok(DocumentRedoUndoParams { document_id }) } } @@ -73,15 +80,16 @@ pub struct DocumentRedoUndoResponsePB { #[derive(Default, ProtoBuf, Validate)] pub struct UploadFileParamsPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub workspace_id: String, #[pb(index = 2)] - #[validate(custom = "required_valid_path")] - pub local_file_path: String, + #[validate(custom(function = "required_not_empty_str"))] + pub document_id: String, #[pb(index = 3)] - pub is_async: bool, + #[validate(custom(function = "required_valid_path"))] + pub local_file_path: String, } #[derive(Default, ProtoBuf, Validate)] @@ -91,10 +99,28 @@ pub struct UploadedFilePB { pub url: String, #[pb(index = 2)] - #[validate(custom = "required_valid_path")] + #[validate(custom(function = "required_valid_path"))] pub local_file_path: String, } +#[derive(Default, ProtoBuf, Validate)] +pub struct DownloadFilePB { + #[pb(index = 1)] + #[validate(url)] + pub url: String, + + #[pb(index = 2)] + #[validate(custom(function = "required_valid_path"))] + pub local_file_path: String, +} + +#[derive(Default, ProtoBuf, Validate)] +pub struct DeleteFilePB { + #[pb(index = 1)] + #[validate(url)] + pub url: String, +} + #[derive(Default, ProtoBuf)] pub struct CreateDocumentPayloadPB { #[pb(index = 1)] @@ -105,7 +131,7 @@ pub struct CreateDocumentPayloadPB { } pub struct CreateDocumentParams { - pub document_id: String, + pub document_id: Uuid, pub initial_data: Option, } @@ -114,9 +140,10 @@ impl TryInto for CreateDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let initial_data = self.initial_data.map(|data| data.into()); Ok(CreateDocumentParams { - document_id: document_id.0, + document_id, initial_data, }) } @@ -129,7 +156,7 @@ pub struct CloseDocumentPayloadPB { } pub struct CloseDocumentParams { - pub document_id: String, + pub document_id: Uuid, } impl TryInto for CloseDocumentPayloadPB { @@ -137,9 +164,8 @@ impl TryInto for CloseDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; - Ok(CloseDocumentParams { - document_id: document_id.0, - }) + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; + Ok(CloseDocumentParams { document_id }) } } @@ -153,7 +179,7 @@ pub struct ApplyActionPayloadPB { } pub struct ApplyActionParams { - pub document_id: String, + pub document_id: Uuid, pub actions: Vec, } @@ -162,10 +188,11 @@ impl TryInto for ApplyActionPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let actions = NotEmptyVec::parse(self.actions).map_err(|_| ErrorCode::ApplyActionsIsEmpty)?; let actions = actions.0.into_iter().map(BlockAction::from).collect(); Ok(ApplyActionParams { - document_id: document_id.0, + document_id, actions, }) } @@ -183,6 +210,11 @@ pub struct DocumentDataPB { pub meta: MetaPB, } +#[derive(Default, Debug, ProtoBuf)] +pub struct DocumentTextPB { + #[pb(index = 1)] + pub text: String, +} #[derive(Default, ProtoBuf, Debug, Clone)] pub struct BlockPB { #[pb(index = 1)] @@ -493,7 +525,7 @@ pub struct TextDeltaPayloadPB { } pub struct TextDeltaParams { - pub document_id: String, + pub document_id: Uuid, pub text_id: String, pub delta: String, } @@ -503,10 +535,11 @@ impl TryInto for TextDeltaPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::from_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let text_id = NotEmptyStr::parse(self.text_id).map_err(|_| ErrorCode::TextIdIsEmpty)?; let delta = self.delta.map_or_else(|| "".to_string(), |delta| delta); Ok(TextDeltaParams { - document_id: document_id.0, + document_id, text_id: text_id.0, delta, }) diff --git a/frontend/rust-lib/flowy-document/src/event_handler.rs b/frontend/rust-lib/flowy-document/src/event_handler.rs index c87ed6ec79..acf45777eb 100644 --- a/frontend/rust-lib/flowy-document/src/event_handler.rs +++ b/frontend/rust-lib/flowy-document/src/event_handler.rs @@ -3,7 +3,7 @@ * as well as performing actions on documents. These functions make use of a DocumentManager, * which you can think of as a higher-level interface to interact with documents. */ - +use std::str::FromStr; use std::sync::{Arc, Weak}; use collab_document::blocks::{ @@ -11,10 +11,6 @@ use collab_document::blocks::{ DocumentData, }; -use flowy_error::{FlowyError, FlowyResult}; -use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; -use tracing::instrument; - use crate::entities::*; use crate::parser::document_data_parser::DocumentDataParser; use crate::parser::external::parser::ExternalDataToNestedJSONParser; @@ -23,6 +19,11 @@ use crate::parser::parser_entities::{ ConvertDocumentParams, ConvertDocumentPayloadPB, ConvertDocumentResponsePB, }; use crate::{manager::DocumentManager, parser::json::parser::JsonToDocumentParser}; +use flowy_error::{FlowyError, FlowyResult}; +use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use lib_infra::sync_trace; +use tracing::instrument; +use uuid::Uuid; fn upgrade_document( document_manager: AFPluginState>, @@ -33,6 +34,22 @@ fn upgrade_document( Ok(manager) } +// Handler for getting the document state +#[instrument(level = "debug", skip_all, err)] +pub(crate) async fn get_encode_collab_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_document(manager)?; + let params: OpenDocumentParams = data.into_inner().try_into()?; + let doc_id = params.document_id; + let state = manager.get_encoded_collab_with_view_id(&doc_id).await?; + data_result_ok(EncodedCollabPB { + state_vector: Vec::from(state.state_vector), + doc_state: Vec::from(state.doc_state), + }) +} + // Handler for creating a new document pub(crate) async fn create_document_handler( data: AFPluginData, @@ -56,8 +73,10 @@ pub(crate) async fn open_document_handler( let manager = upgrade_document(manager)?; let params: OpenDocumentParams = data.into_inner().try_into()?; let doc_id = params.document_id; - let document = manager.get_document(&doc_id).await?; - let document_data = document.lock().get_document_data()?; + manager.open_document(&doc_id).await?; + + let document = manager.editable_document(&doc_id).await?; + let document_data = document.read().await.get_document_data()?; data_result_ok(DocumentDataPB::from(document_data)) } @@ -85,6 +104,17 @@ pub(crate) async fn get_document_data_handler( data_result_ok(DocumentDataPB::from(document_data)) } +pub(crate) async fn get_document_text_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_document(manager)?; + let params: OpenDocumentParams = data.into_inner().try_into()?; + let doc_id = params.document_id; + let text = manager.get_document_text(&doc_id).await?; + data_result_ok(DocumentTextPB { text }) +} + // Handler for applying an action to a document pub(crate) async fn apply_action_handler( data: AFPluginData, @@ -93,12 +123,10 @@ pub(crate) async fn apply_action_handler( let manager = upgrade_document(manager)?; let params: ApplyActionParams = data.into_inner().try_into()?; let doc_id = params.document_id; - let document = manager.get_document(&doc_id).await?; + let document = manager.editable_document(&doc_id).await?; let actions = params.actions; - if cfg!(feature = "verbose_log") { - tracing::trace!("{} applying actions: {:?}", doc_id, actions); - } - document.lock().apply_action(actions); + sync_trace!("{} applying action: {:?}", doc_id, actions); + document.write().await.apply_action(actions)?; Ok(()) } @@ -110,9 +138,10 @@ pub(crate) async fn create_text_handler( let manager = upgrade_document(manager)?; let params: TextDeltaParams = data.into_inner().try_into()?; let doc_id = params.document_id; - let document = manager.get_document(&doc_id).await?; - let document = document.lock(); - document.create_text(¶ms.text_id, params.delta); + let document = manager.editable_document(&doc_id).await?; + let mut document = document.write().await; + sync_trace!("{} creating text: {:?}", doc_id, params.delta); + document.apply_text_delta(¶ms.text_id, params.delta); Ok(()) } @@ -124,13 +153,11 @@ pub(crate) async fn apply_text_delta_handler( let manager = upgrade_document(manager)?; let params: TextDeltaParams = data.into_inner().try_into()?; let doc_id = params.document_id; - let document = manager.get_document(&doc_id).await?; + let document = manager.editable_document(&doc_id).await?; let text_id = params.text_id; let delta = params.delta; - let document = document.lock(); - if cfg!(feature = "verbose_log") { - tracing::trace!("{} applying delta: {:?}", doc_id, delta); - } + let mut document = document.write().await; + sync_trace!("{} applying delta: {:?}", doc_id, delta); document.apply_text_delta(&text_id, delta); Ok(()) } @@ -165,8 +192,8 @@ pub(crate) async fn redo_handler( let manager = upgrade_document(manager)?; let params: DocumentRedoUndoParams = data.into_inner().try_into()?; let doc_id = params.document_id; - let document = manager.get_document(&doc_id).await?; - let document = document.lock(); + let document = manager.editable_document(&doc_id).await?; + let mut document = document.write().await; let redo = document.redo(); let can_redo = document.can_redo(); let can_undo = document.can_undo(); @@ -184,8 +211,8 @@ pub(crate) async fn undo_handler( let manager = upgrade_document(manager)?; let params: DocumentRedoUndoParams = data.into_inner().try_into()?; let doc_id = params.document_id; - let document = manager.get_document(&doc_id).await?; - let document = document.lock(); + let document = manager.editable_document(&doc_id).await?; + let mut document = document.write().await; let undo = document.undo(); let can_redo = document.can_redo(); let can_undo = document.can_undo(); @@ -203,11 +230,10 @@ pub(crate) async fn can_undo_redo_handler( let manager = upgrade_document(manager)?; let params: DocumentRedoUndoParams = data.into_inner().try_into()?; let doc_id = params.document_id; - let document = manager.get_document(&doc_id).await?; - let document = document.lock(); + let document = manager.editable_document(&doc_id).await?; + let document = document.read().await; let can_redo = document.can_redo(); let can_undo = document.can_undo(); - drop(document); data_result_ok(DocumentRedoUndoResponsePB { can_redo, can_undo, @@ -359,8 +385,7 @@ pub async fn convert_document_handler( let manager = upgrade_document(manager)?; let params: ConvertDocumentParams = data.into_inner().try_into()?; - let document = manager.get_document(¶ms.document_id).await?; - let document_data = document.lock().get_document_data()?; + let document_data = manager.get_document_data(¶ms.document_id).await?; let parser = DocumentDataParser::new(Arc::new(document_data), params.range); if !params.parse_types.any_enabled() { @@ -418,32 +443,39 @@ pub(crate) async fn upload_file_handler( params: AFPluginData, manager: AFPluginState>, ) -> DataResult { - let AFPluginData(UploadFileParamsPB { + let UploadFileParamsPB { workspace_id, + document_id, local_file_path, - is_async, - }) = params; + } = params.try_into_inner()?; let manager = upgrade_document(manager)?; - let url = manager - .upload_file(workspace_id, &local_file_path, is_async) - .await?; + let (tx, rx) = tokio::sync::oneshot::channel(); + let cloned_local_file_path = local_file_path.clone(); + tokio::spawn(async move { + let result = manager + .upload_file(workspace_id, &document_id, &cloned_local_file_path) + .await; - Ok(AFPluginData(UploadedFilePB { - url, + let _ = tx.send(result); + Ok::<(), FlowyError>(()) + }); + let upload = rx.await??; + data_result_ok(UploadedFilePB { + url: upload.url, local_file_path, - })) + }) } #[instrument(level = "debug", skip_all, err)] pub(crate) async fn download_file_handler( - params: AFPluginData, + params: AFPluginData, manager: AFPluginState>, ) -> FlowyResult<()> { - let AFPluginData(UploadedFilePB { + let DownloadFilePB { url, local_file_path, - }) = params; + } = params.try_into_inner()?; let manager = upgrade_document(manager)?; manager.download_file(local_file_path, url).await @@ -451,15 +483,12 @@ pub(crate) async fn download_file_handler( // Handler for deleting file pub(crate) async fn delete_file_handler( - params: AFPluginData, + params: AFPluginData, manager: AFPluginState>, ) -> FlowyResult<()> { - let AFPluginData(UploadedFilePB { - url, - local_file_path, - }) = params; + let DeleteFilePB { url } = params.try_into_inner()?; let manager = upgrade_document(manager)?; - manager.delete_file(local_file_path, url).await + manager.delete_file(url).await } pub(crate) async fn set_awareness_local_state_handler( @@ -468,7 +497,7 @@ pub(crate) async fn set_awareness_local_state_handler( ) -> FlowyResult<()> { let manager = upgrade_document(manager)?; let data = data.into_inner(); - let doc_id = data.document_id.clone(); + let doc_id = Uuid::from_str(&data.document_id)?; manager .set_document_awareness_local_state(&doc_id, data) .await?; diff --git a/frontend/rust-lib/flowy-document/src/event_map.rs b/frontend/rust-lib/flowy-document/src/event_map.rs index 1e11db6356..1931d32161 100644 --- a/frontend/rust-lib/flowy-document/src/event_map.rs +++ b/frontend/rust-lib/flowy-document/src/event_map.rs @@ -18,6 +18,11 @@ pub fn init(document_manager: Weak) -> AFPlugin { .event(DocumentEvent::CloseDocument, close_document_handler) .event(DocumentEvent::ApplyAction, apply_action_handler) .event(DocumentEvent::GetDocumentData, get_document_data_handler) + .event(DocumentEvent::GetDocumentText, get_document_text_handler) + .event( + DocumentEvent::GetDocEncodedCollab, + get_encode_collab_handler, + ) .event( DocumentEvent::ConvertDataToDocument, convert_data_to_document, @@ -119,11 +124,17 @@ pub enum DocumentEvent { #[event(input = "UploadFileParamsPB", output = "UploadedFilePB")] UploadFile = 15, - #[event(input = "UploadedFilePB")] + #[event(input = "DownloadFilePB")] DownloadFile = 16, - #[event(input = "UploadedFilePB")] + #[event(input = "DeleteFilePB")] DeleteFile = 17, #[event(input = "UpdateDocumentAwarenessStatePB")] SetAwarenessState = 18, + + #[event(input = "OpenDocumentPayloadPB", output = "EncodedCollabPB")] + GetDocEncodedCollab = 19, + + #[event(input = "OpenDocumentPayloadPB", output = "DocumentTextPB")] + GetDocumentText = 20, } diff --git a/frontend/rust-lib/flowy-document/src/manager.rs b/frontend/rust-lib/flowy-document/src/manager.rs index 06639257d4..9c6a383bae 100644 --- a/frontend/rust-lib/flowy-document/src/manager.rs +++ b/frontend/rust-lib/flowy-document/src/manager.rs @@ -1,9 +1,11 @@ use std::sync::Arc; use std::sync::Weak; -use collab::core::collab::{DataSource, MutexCollab}; +use collab::core::collab::DataSource; +use collab::core::collab_plugin::CollabPersistence; use collab::core::origin::CollabOrigin; use collab::entity::EncodedCollab; +use collab::lock::RwLock; use collab::preclude::Collab; use collab_document::blocks::DocumentData; use collab_document::document::Document; @@ -11,21 +13,23 @@ use collab_document::document_awareness::DocumentAwarenessState; use collab_document::document_awareness::DocumentAwarenessUser; use collab_document::document_data::default_document_data; use collab_entity::CollabType; + +use crate::document::{ + subscribe_document_changed, subscribe_document_snapshot_state, subscribe_document_sync_state, +}; +use collab_integrate::collab_builder::{ + AppFlowyCollabBuilder, CollabBuilderConfig, CollabPersistenceImpl, +}; use collab_plugins::CollabKVDB; use dashmap::DashMap; -use flowy_storage::object_from_disk; -use lib_infra::util::timestamp; -use tokio::io::AsyncWriteExt; -use tracing::{error, trace}; -use tracing::{event, instrument}; - -use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabBuilderConfig}; use flowy_document_pub::cloud::DocumentCloudService; use flowy_error::{internal_error, ErrorCode, FlowyError, FlowyResult}; -use flowy_storage::ObjectStorageService; -use lib_dispatch::prelude::af_spawn; +use flowy_storage_pub::storage::{CreatedUpload, StorageService}; +use lib_infra::util::timestamp; +use tracing::{event, instrument}; +use tracing::{info, trace}; +use uuid::Uuid; -use crate::document::MutexDocument; use crate::entities::UpdateDocumentAwarenessStatePB; use crate::entities::{ DocumentSnapshotData, DocumentSnapshotMeta, DocumentSnapshotMetaPB, DocumentSnapshotPB, @@ -35,7 +39,7 @@ use crate::reminder::DocumentReminderAction; pub trait DocumentUserService: Send + Sync { fn user_id(&self) -> Result; fn device_id(&self) -> Result; - fn workspace_id(&self) -> Result; + fn workspace_id(&self) -> Result; fn collab_db(&self, uid: i64) -> Result, FlowyError>; } @@ -50,10 +54,10 @@ pub trait DocumentSnapshotService: Send + Sync { pub struct DocumentManager { pub user_service: Arc, collab_builder: Arc, - documents: Arc>>, - removing_documents: Arc>>, + documents: Arc>>>, + removing_documents: Arc>>>, cloud_service: Arc, - storage_service: Weak, + storage_service: Weak, snapshot_service: Arc, } @@ -62,7 +66,7 @@ impl DocumentManager { user_service: Arc, collab_builder: Arc, cloud_service: Arc, - storage_service: Weak, + storage_service: Weak, snapshot_service: Arc, ) -> Self { Self { @@ -76,18 +80,49 @@ impl DocumentManager { } } + /// Get the encoded collab of the document. + pub async fn get_encoded_collab_with_view_id(&self, doc_id: &Uuid) -> FlowyResult { + let uid = self.user_service.user_id()?; + let workspace_id = self.user_service.workspace_id()?; + let doc_state = + CollabPersistenceImpl::new(self.user_service.collab_db(uid)?, uid, workspace_id) + .into_data_source(); + let collab = self + .collab_for_document(uid, doc_id, doc_state, false) + .await?; + let encoded_collab = collab + .try_read() + .unwrap() + .encode_collab_v1(|collab| CollabType::Document.validate_require_data(collab)) + .map_err(internal_error)?; + Ok(encoded_collab) + } + pub async fn initialize(&self, _uid: i64) -> FlowyResult<()> { + trace!("initialize document manager"); self.documents.clear(); + self.removing_documents.clear(); Ok(()) } #[instrument( - name = "document_initialize_with_new_user", + name = "document_initialize_after_sign_up", level = "debug", skip_all, err )] - pub async fn initialize_with_new_user(&self, uid: i64) -> FlowyResult<()> { + pub async fn initialize_after_sign_up(&self, uid: i64) -> FlowyResult<()> { + self.initialize(uid).await?; + Ok(()) + } + + pub async fn initialize_after_open_workspace(&self, uid: i64) -> FlowyResult<()> { + self.initialize(uid).await?; + Ok(()) + } + + #[instrument(level = "debug", skip_all, err)] + pub async fn initialize_after_sign_in(&self, uid: i64) -> FlowyResult<()> { self.initialize(uid).await?; Ok(()) } @@ -100,6 +135,13 @@ impl DocumentManager { } } + fn persistence(&self) -> FlowyResult { + let uid = self.user_service.user_id()?; + let workspace_id = self.user_service.workspace_id()?; + let db = self.user_service.collab_db(uid)?; + Ok(CollabPersistenceImpl::new(db, uid, workspace_id)) + } + /// Create a new document. /// /// if the document already exists, return the existing document. @@ -107,34 +149,64 @@ impl DocumentManager { #[instrument(level = "info", skip(self, data))] pub async fn create_document( &self, - uid: i64, - doc_id: &str, + _uid: i64, + doc_id: &Uuid, data: Option, - ) -> FlowyResult<()> { + ) -> FlowyResult { if self.is_doc_exist(doc_id).await.unwrap_or(false) { Err(FlowyError::new( ErrorCode::RecordAlreadyExists, format!("document {} already exists", doc_id), )) } else { - let doc_state = - doc_state_from_document_data(doc_id, data.unwrap_or_else(default_document_data)) - .await? - .doc_state - .to_vec(); - let collab = self - .collab_for_document(uid, doc_id, DataSource::DocStateV1(doc_state), false) - .await?; - collab.lock().flush(); - Ok(()) + let encoded_collab = doc_state_from_document_data(doc_id, data).await?; + self + .persistence()? + .save_collab_to_disk(doc_id.to_string().as_str(), encoded_collab.clone()) + .map_err(internal_error)?; + + // Send the collab data to server with a background task. + let cloud_service = self.cloud_service.clone(); + let cloned_encoded_collab = encoded_collab.clone(); + let workspace_id = self.user_service.workspace_id()?; + let doc_id = *doc_id; + tokio::spawn(async move { + let _ = cloud_service + .create_document_collab(&workspace_id, &doc_id, cloned_encoded_collab) + .await; + }); + Ok(encoded_collab) } } - /// Returns Document for given object id - /// If the document does not exist in local disk, try get the doc state from the cloud. - /// If the document exists, open the document and cache it - #[tracing::instrument(level = "info", skip(self), err)] - pub async fn get_document(&self, doc_id: &str) -> FlowyResult> { + async fn collab_for_document( + &self, + uid: i64, + doc_id: &Uuid, + data_source: DataSource, + sync_enable: bool, + ) -> FlowyResult>> { + let db = self.user_service.collab_db(uid)?; + let workspace_id = self.user_service.workspace_id()?; + let collab_object = + self + .collab_builder + .collab_object(&workspace_id, uid, doc_id, CollabType::Document)?; + let document = self + .collab_builder + .create_document( + collab_object, + data_source, + db, + CollabBuilderConfig::default().sync_enable(sync_enable), + None, + ) + .await?; + Ok(document) + } + + /// Return a document instance if the document is already opened. + pub async fn editable_document(&self, doc_id: &Uuid) -> FlowyResult>> { if let Some(doc) = self.documents.get(doc_id).map(|item| item.value().clone()) { return Ok(doc); } @@ -143,10 +215,27 @@ impl DocumentManager { return Ok(doc); } - let mut doc_state = DataSource::Disk; + Err(FlowyError::internal().with_context("Call open document first")) + } + + /// Returns Document for given object id + /// If the document does not exist in local disk, try get the doc state from the cloud. + /// If the document exists, open the document and cache it + #[tracing::instrument(level = "info", skip(self), err)] + async fn create_document_instance( + &self, + doc_id: &Uuid, + enable_sync: bool, + ) -> FlowyResult>> { + let uid = self.user_service.user_id()?; + let mut doc_state = self.persistence()?.into_data_source(); // If the document does not exist in local disk, try get the doc state from the cloud. This happens // When user_device_a create a document and user_device_b open the document. if !self.is_doc_exist(doc_id).await? { + info!( + "document {} not found in local disk, try to get the doc state from the cloud", + doc_id + ); doc_state = DataSource::DocStateV1( self .cloud_service @@ -163,68 +252,93 @@ impl DocumentManager { } } - let uid = self.user_service.user_id()?; - event!(tracing::Level::DEBUG, "Initialize document: {}", doc_id); - let collab = self - .collab_for_document(uid, doc_id, doc_state, true) - .await?; - - match MutexDocument::open(doc_id, collab) { + event!( + tracing::Level::DEBUG, + "Initialize document: {}, workspace_id: {:?}", + doc_id, + self.user_service.workspace_id() + ); + let result = self + .collab_for_document(uid, doc_id, doc_state, enable_sync) + .await; + match result { Ok(document) => { - let document = Arc::new(document); - self.documents.insert(doc_id.to_string(), document.clone()); + // Only push the document to the cache if the sync is enabled. + if enable_sync { + { + let mut lock = document.write().await; + subscribe_document_changed(doc_id, &mut lock); + subscribe_document_snapshot_state(&lock); + subscribe_document_sync_state(&lock); + } + self.documents.insert(*doc_id, document.clone()); + } Ok(document) }, Err(err) => { if err.is_invalid_data() { - if let Some(db) = self.user_service.collab_db(uid)?.upgrade() { - db.delete_doc(uid, doc_id).await?; - } + self.delete_document(doc_id).await?; } return Err(err); }, } } - pub async fn get_document_data(&self, doc_id: &str) -> FlowyResult { - let mut doc_state = DataSource::Disk; - if !self.is_doc_exist(doc_id).await? { - doc_state = DataSource::DocStateV1( - self - .cloud_service - .get_document_doc_state(doc_id, &self.user_service.workspace_id()?) - .await?, - ); - } - let uid = self.user_service.user_id()?; - let collab = self - .collab_for_document(uid, doc_id, doc_state, false) - .await?; - Document::open(collab)? - .get_document_data() - .map_err(internal_error) + pub async fn get_document_data(&self, doc_id: &Uuid) -> FlowyResult { + let document = self.get_document(doc_id).await?; + let document = document.read().await; + document.get_document_data().map_err(internal_error) + } + pub async fn get_document_text(&self, doc_id: &Uuid) -> FlowyResult { + let document = self.get_document(doc_id).await?; + let document = document.read().await; + let text = document.paragraphs().join("\n"); + Ok(text) } - pub async fn open_document(&self, doc_id: &str) -> FlowyResult<()> { - if let Some(mutex_document) = self.restore_document_from_removing(doc_id) { - mutex_document.start_init_sync(); + /// Return a document instance. + /// The returned document might or might not be able to sync with the cloud. + async fn get_document(&self, doc_id: &Uuid) -> FlowyResult>> { + if let Some(doc) = self.documents.get(doc_id).map(|item| item.value().clone()) { + return Ok(doc); } + + if let Some(doc) = self.restore_document_from_removing(doc_id) { + return Ok(doc); + } + + let document = self.create_document_instance(doc_id, false).await?; + Ok(document) + } + + pub async fn open_document(&self, doc_id: &Uuid) -> FlowyResult<()> { + if let Some(mutex_document) = self.restore_document_from_removing(doc_id) { + let lock = mutex_document.read().await; + lock.start_init_sync(); + } + + if self.documents.contains_key(doc_id) { + return Ok(()); + } + + let _ = self.create_document_instance(doc_id, true).await?; Ok(()) } - pub async fn close_document(&self, doc_id: &str) -> FlowyResult<()> { + pub async fn close_document(&self, doc_id: &Uuid) -> FlowyResult<()> { if let Some((doc_id, document)) = self.documents.remove(doc_id) { - if let Some(doc) = document.try_lock() { + { // clear the awareness state when close the document - doc.clean_awareness_local_state(); - let _ = doc.flush(); + let mut lock = document.write().await; + lock.clean_awareness_local_state(); } - let clone_doc_id = doc_id.clone(); + + let clone_doc_id = doc_id; trace!("move document to removing_documents: {}", doc_id); self.removing_documents.insert(doc_id, document); let weak_removing_documents = Arc::downgrade(&self.removing_documents); - af_spawn(async move { + tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(120)).await; if let Some(removing_documents) = weak_removing_documents.upgrade() { if removing_documents.remove(&clone_doc_id).is_some() { @@ -237,37 +351,39 @@ impl DocumentManager { Ok(()) } - pub async fn delete_document(&self, doc_id: &str) -> FlowyResult<()> { + pub async fn delete_document(&self, doc_id: &Uuid) -> FlowyResult<()> { let uid = self.user_service.user_id()?; + let workspace_id = self.user_service.workspace_id()?; if let Some(db) = self.user_service.collab_db(uid)?.upgrade() { - db.delete_doc(uid, doc_id).await?; + db.delete_doc(uid, &workspace_id.to_string(), &doc_id.to_string()) + .await?; // When deleting a document, we need to remove it from the cache. self.documents.remove(doc_id); } Ok(()) } + #[instrument(level = "debug", skip_all, err)] pub async fn set_document_awareness_local_state( &self, - doc_id: &str, + doc_id: &Uuid, state: UpdateDocumentAwarenessStatePB, ) -> FlowyResult { let uid = self.user_service.user_id()?; let device_id = self.user_service.device_id()?; - if let Ok(doc) = self.get_document(doc_id).await { - if let Some(doc) = doc.try_lock() { - let user = DocumentAwarenessUser { uid, device_id }; - let selection = state.selection.map(|s| s.into()); - let state = DocumentAwarenessState { - version: 1, - user, - selection, - metadata: state.metadata, - timestamp: timestamp(), - }; - doc.set_awareness_local_state(state); - return Ok(true); - } + if let Ok(doc) = self.editable_document(doc_id).await { + let doc = doc.write().await; + let user = DocumentAwarenessUser { uid, device_id }; + let selection = state.selection.map(|s| s.into()); + let state = DocumentAwarenessState { + version: 1, + user, + selection, + metadata: state.metadata, + timestamp: timestamp(), + }; + doc.set_awareness_local_state(state); + return Ok(true); } Ok(false) } @@ -275,12 +391,12 @@ impl DocumentManager { /// Return the list of snapshots of the document. pub async fn get_document_snapshot_meta( &self, - document_id: &str, + document_id: &Uuid, _limit: usize, ) -> FlowyResult> { let metas = self .snapshot_service - .get_document_snapshot_metas(document_id)? + .get_document_snapshot_metas(document_id.to_string().as_str())? .into_iter() .map(|meta| DocumentSnapshotMetaPB { snapshot_id: meta.snapshot_id, @@ -303,108 +419,47 @@ impl DocumentManager { Ok(snapshot) } + #[instrument(level = "debug", skip_all, err)] pub async fn upload_file( &self, workspace_id: String, + document_id: &str, local_file_path: &str, - is_async: bool, - ) -> FlowyResult { - let (object_identity, object_value) = object_from_disk(&workspace_id, local_file_path).await?; + ) -> FlowyResult { let storage_service = self.storage_service_upgrade()?; - let url = storage_service.get_object_url(object_identity).await?; - - let clone_url = url.clone(); - - match is_async { - false => storage_service.put_object(clone_url, object_value).await?, - true => { - // let the upload happen in the background - af_spawn(async move { - if let Err(e) = storage_service.put_object(clone_url, object_value).await { - error!("upload file failed: {}", e); - } - }); - }, - } - Ok(url) + let upload = storage_service + .create_upload(&workspace_id, document_id, local_file_path) + .await? + .0; + Ok(upload) } pub async fn download_file(&self, local_file_path: String, url: String) -> FlowyResult<()> { - // TODO(nathan): save file when the current target is wasm - #[cfg(not(target_arch = "wasm32"))] - { - if tokio::fs::metadata(&local_file_path).await.is_ok() { - tracing::warn!("file already exist in user local disk: {}", local_file_path); - return Ok(()); - } - - let storage_service = self.storage_service_upgrade()?; - let object_value = storage_service.get_object(url).await?; - // create file if not exist - let mut file = tokio::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&local_file_path) - .await?; - - let n = file.write(&object_value.raw).await?; - tracing::info!("downloaded {} bytes to file: {}", n, local_file_path); - } - Ok(()) - } - - pub async fn delete_file(&self, local_file_path: String, url: String) -> FlowyResult<()> { - // TODO(nathan): delete file when the current target is wasm - #[cfg(not(target_arch = "wasm32"))] - // delete file from local - tokio::fs::remove_file(local_file_path).await?; - - // delete from cloud let storage_service = self.storage_service_upgrade()?; - af_spawn(async move { - if let Err(e) = storage_service.delete_object(url).await { - // TODO: add WAL to log the delete operation. - // keep a list of files to be deleted, and retry later - error!("delete file failed: {}", e); - } - }); - + storage_service.download_object(url, local_file_path)?; Ok(()) } - async fn collab_for_document( - &self, - uid: i64, - doc_id: &str, - doc_state: DataSource, - sync_enable: bool, - ) -> FlowyResult> { - let db = self.user_service.collab_db(uid)?; - let workspace_id = self.user_service.workspace_id()?; - let collab = self.collab_builder.build_with_config( - &workspace_id, - uid, - doc_id, - CollabType::Document, - db, - doc_state, - CollabBuilderConfig::default().sync_enable(sync_enable), - )?; - Ok(collab) + pub async fn delete_file(&self, url: String) -> FlowyResult<()> { + let storage_service = self.storage_service_upgrade()?; + storage_service.delete_object(url).await?; + Ok(()) } - async fn is_doc_exist(&self, doc_id: &str) -> FlowyResult { + async fn is_doc_exist(&self, doc_id: &Uuid) -> FlowyResult { let uid = self.user_service.user_id()?; + let workspace_id = self.user_service.workspace_id()?; if let Some(collab_db) = self.user_service.collab_db(uid)?.upgrade() { - let is_exist = collab_db.is_exist(uid, doc_id).await?; + let is_exist = collab_db + .is_exist(uid, &workspace_id.to_string(), &doc_id.to_string()) + .await?; Ok(is_exist) } else { Ok(false) } } - fn storage_service_upgrade(&self) -> FlowyResult> { + fn storage_service_upgrade(&self) -> FlowyResult> { let storage_service = self.storage_service.upgrade().ok_or_else(|| { FlowyError::internal().with_context("The file storage service is already dropped") })?; @@ -418,11 +473,11 @@ impl DocumentManager { } /// Only expose this method for testing #[cfg(debug_assertions)] - pub fn get_file_storage_service(&self) -> &Weak { + pub fn get_file_storage_service(&self) -> &Weak { &self.storage_service } - fn restore_document_from_removing(&self, doc_id: &str) -> Option> { + fn restore_document_from_removing(&self, doc_id: &Uuid) -> Option>> { let (doc_id, doc) = self.removing_documents.remove(doc_id)?; trace!( "move document {} from removing_documents to documents", @@ -434,19 +489,21 @@ impl DocumentManager { } async fn doc_state_from_document_data( - doc_id: &str, - data: DocumentData, + doc_id: &Uuid, + data: Option, ) -> Result { let doc_id = doc_id.to_string(); + let data = data.unwrap_or_else(|| { + trace!( + "{} document data is None, use default document data", + doc_id.to_string() + ); + default_document_data(&doc_id) + }); // spawn_blocking is used to avoid blocking the tokio thread pool if the document is large. let encoded_collab = tokio::task::spawn_blocking(move || { - let collab = Arc::new(MutexCollab::new(Collab::new_with_origin( - CollabOrigin::Empty, - doc_id, - vec![], - false, - ))); - let document = Document::create_with_data(collab.clone(), data).map_err(internal_error)?; + let collab = Collab::new_with_origin(CollabOrigin::Empty, doc_id, vec![], false); + let document = Document::create_with_data(collab, data).map_err(internal_error)?; let encode_collab = document.encode_collab()?; Ok::<_, FlowyError>(encode_collab) }) diff --git a/frontend/rust-lib/flowy-document/src/notification.rs b/frontend/rust-lib/flowy-document/src/notification.rs index 9909971667..5d843014e7 100644 --- a/frontend/rust-lib/flowy-document/src/notification.rs +++ b/frontend/rust-lib/flowy-document/src/notification.rs @@ -32,6 +32,9 @@ impl std::convert::From for DocumentNotification { } #[tracing::instrument(level = "trace")] -pub(crate) fn send_notification(id: &str, ty: DocumentNotification) -> NotificationBuilder { +pub(crate) fn document_notification_builder( + id: &str, + ty: DocumentNotification, +) -> NotificationBuilder { NotificationBuilder::new(id, ty, DOCUMENT_OBSERVABLE_SOURCE) } diff --git a/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs b/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs index cb7bf35e27..94680b32d3 100644 --- a/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs +++ b/frontend/rust-lib/flowy-document/src/parser/parser_entities.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::sync::Arc; +use uuid::Uuid; use validator::Validate; #[derive(Default, ProtoBuf)] @@ -96,7 +97,7 @@ pub struct ParseType { } pub struct ConvertDocumentParams { - pub document_id: String, + pub document_id: Uuid, pub range: Option, pub parse_types: ParseType, } @@ -140,10 +141,11 @@ impl TryInto for ConvertDocumentPayloadPB { fn try_into(self) -> Result { let document_id = NotEmptyStr::parse(self.document_id).map_err(|_| ErrorCode::DocumentIdIsEmpty)?; + let document_id = Uuid::parse_str(&document_id.0).map_err(|_| ErrorCode::InvalidParams)?; let range = self.range.map(|data| data.into()); Ok(ConvertDocumentParams { - document_id: document_id.0, + document_id, range, parse_types: self.parse_types.into(), }) @@ -508,7 +510,7 @@ pub enum InputType { #[derive(Default, ProtoBuf, Debug, Validate)] pub struct ConvertDataToJsonPayloadPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub data: String, #[pb(index = 2)] diff --git a/frontend/rust-lib/flowy-document/tests/document/document_insert_test.rs b/frontend/rust-lib/flowy-document/tests/document/document_insert_test.rs index 1181395cae..28c02641e8 100644 --- a/frontend/rust-lib/flowy-document/tests/document/document_insert_test.rs +++ b/frontend/rust-lib/flowy-document/tests/document/document_insert_test.rs @@ -31,9 +31,13 @@ async fn document_apply_insert_block_with_empty_parent_id() { text_id: None, }, }; - document.lock().apply_action(vec![insert_text_action]); + document + .write() + .await + .apply_action(vec![insert_text_action]) + .unwrap(); // read the text block and it's parent id should be the page id - let block = document.lock().get_block(&text_block_id).unwrap(); + let block = document.read().await.get_block(&text_block_id).unwrap(); assert_eq!(block.parent, page_id); } diff --git a/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs b/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs index 54be6a2d52..2a47ec93c4 100644 --- a/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs +++ b/frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs @@ -9,8 +9,8 @@ use crate::document::util::{gen_document_id, gen_id, DocumentTest}; async fn undo_redo_test() { let test = DocumentTest::new(); - let doc_id: String = gen_document_id(); - let data = default_document_data(); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test @@ -22,8 +22,9 @@ async fn undo_redo_test() { .await; // open a document - let document = test.get_document(&doc_id).await.unwrap(); - let document = document.lock(); + test.open_document(&doc_id).await.unwrap(); + let document = test.editable_document(&doc_id).await.unwrap(); + let mut document = document.write().await; let page_block = document.get_block(&data.page_id).unwrap(); let page_id = page_block.id; let text_block_id = gen_id(); @@ -48,7 +49,7 @@ async fn undo_redo_test() { text_id: None, }, }; - document.apply_action(vec![insert_text_action]); + document.apply_action(vec![insert_text_action]).unwrap(); let can_undo = document.can_undo(); assert!(can_undo); diff --git a/frontend/rust-lib/flowy-document/tests/document/document_test.rs b/frontend/rust-lib/flowy-document/tests/document/document_test.rs index 1296bc3c7c..8323a645c7 100644 --- a/frontend/rust-lib/flowy-document/tests/document/document_test.rs +++ b/frontend/rust-lib/flowy-document/tests/document/document_test.rs @@ -11,8 +11,8 @@ async fn restore_document() { let test = DocumentTest::new(); // create a document - let doc_id: String = gen_document_id(); - let data = default_document_data(); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); let uid = test.user_service.user_id().unwrap(); test .create_document(uid, &doc_id, Some(data.clone())) @@ -20,12 +20,14 @@ async fn restore_document() { .unwrap(); let data_a = test.get_document_data(&doc_id).await.unwrap(); assert_eq!(data_a, data); + test.open_document(&doc_id).await.unwrap(); let data_b = test - .get_document(&doc_id) + .editable_document(&doc_id) .await .unwrap() - .lock() + .read() + .await .get_document_data() .unwrap(); // close a document @@ -36,10 +38,11 @@ async fn restore_document() { _ = test.create_document(uid, &doc_id, Some(data.clone())).await; // open a document let data_b = test - .get_document(&doc_id) + .editable_document(&doc_id) .await .unwrap() - .lock() + .read() + .await .get_document_data() .unwrap(); // close a document @@ -52,15 +55,17 @@ async fn restore_document() { async fn document_apply_insert_action() { let test = DocumentTest::new(); let uid = test.user_service.user_id().unwrap(); - let doc_id: String = gen_document_id(); - let data = default_document_data(); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test.create_document(uid, &doc_id, Some(data.clone())).await; // open a document - let document = test.get_document(&doc_id).await.unwrap(); - let page_block = document.lock().get_block(&data.page_id).unwrap(); + test.open_document(&doc_id).await.unwrap(); + let document = test.editable_document(&doc_id).await.unwrap(); + let mut document = document.write().await; + let page_block = document.get_block(&data.page_id).unwrap(); // insert a text block let text_block = Block { @@ -82,17 +87,19 @@ async fn document_apply_insert_action() { text_id: None, }, }; - document.lock().apply_action(vec![insert_text_action]); - let data_a = document.lock().get_document_data().unwrap(); + document.apply_action(vec![insert_text_action]).unwrap(); + let data_a = document.get_document_data().unwrap(); + drop(document); // close the original document _ = test.close_document(&doc_id).await; // re-open the document let data_b = test - .get_document(&doc_id) + .editable_document(&doc_id) .await .unwrap() - .lock() + .read() + .await .get_document_data() .unwrap(); // close a document @@ -104,16 +111,18 @@ async fn document_apply_insert_action() { #[tokio::test] async fn document_apply_update_page_action() { let test = DocumentTest::new(); - let doc_id: String = gen_document_id(); + let doc_id = gen_document_id(); let uid = test.user_service.user_id().unwrap(); - let data = default_document_data(); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test.create_document(uid, &doc_id, Some(data.clone())).await; // open a document - let document = test.get_document(&doc_id).await.unwrap(); - let page_block = document.lock().get_block(&data.page_id).unwrap(); + test.open_document(&doc_id).await.unwrap(); + let document = test.editable_document(&doc_id).await.unwrap(); + let mut document = document.write().await; + let page_block = document.get_block(&data.page_id).unwrap(); let mut page_block_clone = page_block; page_block_clone.data = HashMap::new(); @@ -133,13 +142,14 @@ async fn document_apply_update_page_action() { }; let actions = vec![action]; tracing::trace!("{:?}", &actions); - document.lock().apply_action(actions); - let page_block_old = document.lock().get_block(&data.page_id).unwrap(); + document.apply_action(actions).unwrap(); + let page_block_old = document.get_block(&data.page_id).unwrap(); + drop(document); _ = test.close_document(&doc_id).await; // re-open the document - let document = test.get_document(&doc_id).await.unwrap(); - let page_block_new = document.lock().get_block(&data.page_id).unwrap(); + let document = test.editable_document(&doc_id).await.unwrap(); + let page_block_new = document.read().await.get_block(&data.page_id).unwrap(); assert_eq!(page_block_old, page_block_new); assert!(page_block_new.data.contains_key("delta")); } @@ -148,15 +158,17 @@ async fn document_apply_update_page_action() { async fn document_apply_update_action() { let test = DocumentTest::new(); let uid = test.user_service.user_id().unwrap(); - let doc_id: String = gen_document_id(); - let data = default_document_data(); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); // create a document _ = test.create_document(uid, &doc_id, Some(data.clone())).await; // open a document - let document = test.get_document(&doc_id).await.unwrap(); - let page_block = document.lock().get_block(&data.page_id).unwrap(); + test.open_document(&doc_id).await.unwrap(); + let document = test.editable_document(&doc_id).await.unwrap(); + let mut document = document.write().await; + let page_block = document.get_block(&data.page_id).unwrap(); // insert a text block let text_block_id = gen_id(); @@ -179,10 +191,10 @@ async fn document_apply_update_action() { text_id: None, }, }; - document.lock().apply_action(vec![insert_text_action]); + document.apply_action(vec![insert_text_action]).unwrap(); // update the text block - let existing_text_block = document.lock().get_block(&text_block_id).unwrap(); + let existing_text_block = document.get_block(&text_block_id).unwrap(); let mut updated_text_block_data = HashMap::new(); updated_text_block_data.insert("delta".to_string(), Value::String("delta".to_string())); let updated_text_block = Block { @@ -204,13 +216,14 @@ async fn document_apply_update_action() { text_id: None, }, }; - document.lock().apply_action(vec![update_text_action]); + document.apply_action(vec![update_text_action]).unwrap(); + drop(document); // close the original document _ = test.close_document(&doc_id).await; // re-open the document - let document = test.get_document(&doc_id).await.unwrap(); - let block = document.lock().get_block(&text_block_id).unwrap(); + let document = test.editable_document(&doc_id).await.unwrap(); + let block = document.read().await.get_block(&text_block_id).unwrap(); assert_eq!(block.data, updated_text_block_data); // close a document _ = test.close_document(&doc_id).await; diff --git a/frontend/rust-lib/flowy-document/tests/document/util.rs b/frontend/rust-lib/flowy-document/tests/document/util.rs index 4367b4d157..231bb3852e 100644 --- a/frontend/rust-lib/flowy-document/tests/document/util.rs +++ b/frontend/rust-lib/flowy-document/tests/document/util.rs @@ -1,28 +1,28 @@ use std::ops::Deref; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; -use anyhow::Error; +use collab::entity::EncodedCollab; use collab::preclude::CollabPlugin; use collab_document::blocks::DocumentData; +use collab_document::document::Document; use collab_document::document_data::default_document_data; -use nanoid::nanoid; -use parking_lot::Once; -use tempfile::TempDir; -use tracing_subscriber::{fmt::Subscriber, util::SubscriberInitExt, EnvFilter}; - use collab_integrate::collab_builder::{ AppFlowyCollabBuilder, CollabCloudPluginProvider, CollabPluginProviderContext, CollabPluginProviderType, WorkspaceCollabIntegrate, }; use collab_integrate::CollabKVDB; -use flowy_document::document::MutexDocument; use flowy_document::entities::{DocumentSnapshotData, DocumentSnapshotMeta}; use flowy_document::manager::{DocumentManager, DocumentSnapshotService, DocumentUserService}; use flowy_document_pub::cloud::*; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_storage::ObjectStorageService; +use flowy_storage_pub::storage::{CreatedUpload, FileProgressReceiver, StorageService}; use lib_infra::async_trait::async_trait; -use lib_infra::future::FutureResult; +use lib_infra::box_any::BoxAny; +use nanoid::nanoid; +use tempfile::TempDir; +use tokio::sync::RwLock; +use tracing_subscriber::{fmt::Subscriber, util::SubscriberInitExt, EnvFilter}; +use uuid::Uuid; pub struct DocumentTest { inner: DocumentManager, @@ -32,13 +32,13 @@ impl DocumentTest { pub fn new() -> Self { let user = FakeUser::new(); let cloud_service = Arc::new(LocalTestDocumentCloudServiceImpl()); - let file_storage = Arc::new(DocumentTestFileStorageService) as Arc; + let file_storage = Arc::new(DocumentTestFileStorageService) as Arc; let document_snapshot = Arc::new(DocumentTestSnapshot); let builder = Arc::new(AppFlowyCollabBuilder::new( DefaultCollabStorageProvider(), WorkspaceCollabIntegrateImpl { - workspace_id: user.workspace_id.clone(), + workspace_id: user.workspace_id, }, )); @@ -62,7 +62,7 @@ impl Deref for DocumentTest { } pub struct FakeUser { - workspace_id: String, + workspace_id: Uuid, collab_db: Arc, } @@ -73,7 +73,7 @@ impl FakeUser { let tempdir = TempDir::new().unwrap(); let path = tempdir.into_path(); let collab_db = Arc::new(CollabKVDB::open(path).unwrap()); - let workspace_id = uuid::Uuid::new_v4().to_string(); + let workspace_id = uuid::Uuid::new_v4(); Self { collab_db, @@ -87,8 +87,8 @@ impl DocumentUserService for FakeUser { Ok(1) } - fn workspace_id(&self) -> Result { - Ok(self.workspace_id.clone()) + fn workspace_id(&self) -> Result { + Ok(self.workspace_id) } fn collab_db(&self, _uid: i64) -> Result, FlowyError> { @@ -101,8 +101,8 @@ impl DocumentUserService for FakeUser { } pub fn setup_log() { - static START: Once = Once::new(); - START.call_once(|| { + static START: OnceLock<()> = OnceLock::new(); + START.get_or_init(|| { std::env::set_var("RUST_LOG", "collab_persistence=trace"); let subscriber = Subscriber::builder() .with_env_filter(EnvFilter::from_default_env()) @@ -112,10 +112,10 @@ pub fn setup_log() { }); } -pub async fn create_and_open_empty_document() -> (DocumentTest, Arc, String) { +pub async fn create_and_open_empty_document() -> (DocumentTest, Arc>, String) { let test = DocumentTest::new(); - let doc_id: String = gen_document_id(); - let data = default_document_data(); + let doc_id = gen_document_id(); + let data = default_document_data(&doc_id.to_string()); let uid = test.user_service.user_id().unwrap(); // create a document test @@ -123,14 +123,14 @@ pub async fn create_and_open_empty_document() -> (DocumentTest, Arc String { - let uuid = uuid::Uuid::new_v4(); - uuid.to_string() +pub fn gen_document_id() -> Uuid { + uuid::Uuid::new_v4() } pub fn gen_id() -> String { @@ -138,61 +138,87 @@ pub fn gen_id() -> String { } pub struct LocalTestDocumentCloudServiceImpl(); + +#[async_trait] impl DocumentCloudService for LocalTestDocumentCloudServiceImpl { - fn get_document_doc_state( + async fn get_document_doc_state( &self, - document_id: &str, - _workspace_id: &str, - ) -> FutureResult, FlowyError> { + document_id: &Uuid, + _workspace_id: &Uuid, + ) -> Result, FlowyError> { let document_id = document_id.to_string(); - FutureResult::new(async move { - Err(FlowyError::new( - ErrorCode::RecordNotFound, - format!("Document {} not found", document_id), - )) - }) + Err(FlowyError::new( + ErrorCode::RecordNotFound, + format!("Document {} not found", document_id), + )) } - fn get_document_snapshots( + async fn get_document_snapshots( &self, - _document_id: &str, + _document_id: &Uuid, _limit: usize, _workspace_id: &str, - ) -> FutureResult, Error> { - FutureResult::new(async move { Ok(vec![]) }) + ) -> Result, FlowyError> { + Ok(vec![]) } - fn get_document_data( + async fn get_document_data( &self, - _document_id: &str, - _workspace_id: &str, - ) -> FutureResult, Error> { - FutureResult::new(async move { Ok(None) }) + _document_id: &Uuid, + _workspace_id: &Uuid, + ) -> Result, FlowyError> { + Ok(None) + } + + async fn create_document_collab( + &self, + _workspace_id: &Uuid, + _document_id: &Uuid, + _encoded_collab: EncodedCollab, + ) -> Result<(), FlowyError> { + Ok(()) } } pub struct DocumentTestFileStorageService; -impl ObjectStorageService for DocumentTestFileStorageService { - fn get_object_url( + +#[async_trait] +impl StorageService for DocumentTestFileStorageService { + async fn delete_object(&self, _url: String) -> FlowyResult<()> { + todo!() + } + + fn download_object(&self, _url: String, _local_file_path: String) -> FlowyResult<()> { + todo!() + } + + async fn create_upload( &self, - _object_id: flowy_storage::ObjectIdentity, - ) -> FutureResult { + _workspace_id: &str, + _parent_dir: &str, + _local_file_path: &str, + ) -> Result<(CreatedUpload, Option), flowy_error::FlowyError> { todo!() } - fn put_object( + async fn start_upload(&self, _record: &BoxAny) -> Result<(), FlowyError> { + todo!() + } + + async fn resume_upload( &self, - _url: String, - _object_value: flowy_storage::ObjectValue, - ) -> FutureResult<(), FlowyError> { + _workspace_id: &str, + _parent_dir: &str, + _file_id: &str, + ) -> Result<(), FlowyError> { todo!() } - fn delete_object(&self, _url: String) -> FutureResult<(), FlowyError> { - todo!() - } - - fn get_object(&self, _url: String) -> FutureResult { + async fn subscribe_file_progress( + &self, + _parent_idr: &str, + _url: &str, + ) -> Result, FlowyError> { todo!() } } @@ -229,14 +255,14 @@ impl DocumentSnapshotService for DocumentTestSnapshot { } struct WorkspaceCollabIntegrateImpl { - workspace_id: String, + workspace_id: Uuid, } impl WorkspaceCollabIntegrate for WorkspaceCollabIntegrateImpl { - fn workspace_id(&self) -> Result { - Ok(self.workspace_id.clone()) + fn workspace_id(&self) -> Result { + Ok(self.workspace_id) } - fn device_id(&self) -> Result { + fn device_id(&self) -> Result { Ok("fake_device_id".to_string()) } } diff --git a/frontend/rust-lib/flowy-document/tests/parser/json/parser_test.rs b/frontend/rust-lib/flowy-document/tests/parser/json/parser_test.rs index 096b4fd5ce..f60c19e945 100644 --- a/frontend/rust-lib/flowy-document/tests/parser/json/parser_test.rs +++ b/frontend/rust-lib/flowy-document/tests/parser/json/parser_test.rs @@ -112,7 +112,7 @@ async fn parse_readme_test() { let data = json_str_to_hashmap(&block.data).ok(); assert!(data.is_some()); if let Some(data) = data { - assert!(data.get("delta").is_none()); + assert!(!data.contains_key("delta")); } if let Some(external_id) = &block.external_id { diff --git a/frontend/rust-lib/flowy-encrypt/Cargo.toml b/frontend/rust-lib/flowy-encrypt/Cargo.toml deleted file mode 100644 index 5ea42c60e2..0000000000 --- a/frontend/rust-lib/flowy-encrypt/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "flowy-encrypt" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -aes-gcm = "0.10.2" -rand = "0.8" -pbkdf2 = "0.12.2" -hmac = "0.12.1" -sha2 = "0.10.7" -anyhow.workspace = true -base64 = "0.21.2" - -[target.'cfg(target_arch = "wasm32")'.dependencies] -getrandom = { version = "0.2", features = ["js"]} \ No newline at end of file diff --git a/frontend/rust-lib/flowy-encrypt/src/encrypt.rs b/frontend/rust-lib/flowy-encrypt/src/encrypt.rs deleted file mode 100644 index 88658858bb..0000000000 --- a/frontend/rust-lib/flowy-encrypt/src/encrypt.rs +++ /dev/null @@ -1,172 +0,0 @@ -use aes_gcm::aead::generic_array::GenericArray; -use aes_gcm::aead::Aead; -use aes_gcm::{Aes256Gcm, KeyInit}; -use anyhow::Result; -use base64::engine::general_purpose::STANDARD; -use base64::Engine; -use pbkdf2::hmac::Hmac; -use pbkdf2::pbkdf2; -use rand::distributions::Alphanumeric; -use rand::Rng; -use sha2::Sha256; - -/// The length of the salt in bytes. -const SALT_LENGTH: usize = 16; - -/// The length of the derived encryption key in bytes. -const KEY_LENGTH: usize = 32; - -/// The number of iterations for the PBKDF2 key derivation. -const ITERATIONS: u32 = 1000; - -/// The length of the nonce for AES-GCM encryption. -const NONCE_LENGTH: usize = 12; - -/// Delimiter used to concatenate the passphrase and salt. -const CONCATENATED_DELIMITER: &str = "$"; - -/// Generate a new encryption secret consisting of a passphrase and a salt. -pub fn generate_encryption_secret() -> String { - let passphrase = generate_random_passphrase(); - let salt = generate_random_salt(); - combine_passphrase_and_salt(&passphrase, &salt) -} - -/// Encrypt a byte slice using AES-GCM. -/// -/// # Arguments -/// * `data`: The data to encrypt. -/// * `combined_passphrase_salt`: The concatenated passphrase and salt. -pub fn encrypt_data>(data: T, combined_passphrase_salt: &str) -> Result> { - let (passphrase, salt) = split_passphrase_and_salt(combined_passphrase_salt)?; - let key = derive_key(passphrase, &salt)?; - let cipher = Aes256Gcm::new(GenericArray::from_slice(&key)); - let nonce: [u8; NONCE_LENGTH] = rand::thread_rng().gen(); - let ciphertext = cipher - .encrypt(GenericArray::from_slice(&nonce), data.as_ref()) - .unwrap(); - - Ok(nonce.into_iter().chain(ciphertext).collect()) -} - -/// Decrypt a byte slice using AES-GCM. -/// -/// # Arguments -/// * `data`: The data to decrypt. -/// * `combined_passphrase_salt`: The concatenated passphrase and salt. -pub fn decrypt_data>(data: T, combined_passphrase_salt: &str) -> Result> { - if data.as_ref().len() <= NONCE_LENGTH { - return Err(anyhow::anyhow!("Ciphertext too short to include nonce.")); - } - let (passphrase, salt) = split_passphrase_and_salt(combined_passphrase_salt)?; - let key = derive_key(passphrase, &salt)?; - let cipher = Aes256Gcm::new(GenericArray::from_slice(&key)); - let (nonce, cipher_data) = data.as_ref().split_at(NONCE_LENGTH); - cipher - .decrypt(GenericArray::from_slice(nonce), cipher_data) - .map_err(|e| anyhow::anyhow!("Decryption error: {:?}", e)) -} - -/// Encrypt a string using AES-GCM and return the result as a base64 encoded string. -/// -/// # Arguments -/// * `data`: The string data to encrypt. -/// * `combined_passphrase_salt`: The concatenated passphrase and salt. -pub fn encrypt_text>(data: T, combined_passphrase_salt: &str) -> Result { - let encrypted = encrypt_data(data.as_ref(), combined_passphrase_salt)?; - Ok(STANDARD.encode(encrypted)) -} - -/// Decrypt a base64 encoded string using AES-GCM. -/// -/// # Arguments -/// * `data`: The base64 encoded string to decrypt. -/// * `combined_passphrase_salt`: The concatenated passphrase and salt. -pub fn decrypt_text>(data: T, combined_passphrase_salt: &str) -> Result { - let encrypted = STANDARD.decode(data)?; - let decrypted = decrypt_data(encrypted, combined_passphrase_salt)?; - Ok(String::from_utf8(decrypted)?) -} - -/// Generates a random passphrase consisting of alphanumeric characters. -/// -/// This function creates a passphrase with both uppercase and lowercase letters -/// as well as numbers. The passphrase is 30 characters in length. -/// -/// # Returns -/// -/// A `String` representing the generated passphrase. -/// -/// # Security Considerations -/// -/// The passphrase is derived from the `Alphanumeric` character set which includes 62 possible -/// characters (26 lowercase letters, 26 uppercase letters, 10 numbers). This results in a total -/// of `62^30` possible combinations, making it strong against brute force attacks. -/// -fn generate_random_passphrase() -> String { - rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(30) // e.g., 30 characters - .map(char::from) - .collect() -} - -fn generate_random_salt() -> [u8; SALT_LENGTH] { - let mut rng = rand::thread_rng(); - let salt: [u8; SALT_LENGTH] = rng.gen(); - salt -} - -fn combine_passphrase_and_salt(passphrase: &str, salt: &[u8; SALT_LENGTH]) -> String { - let salt_base64 = STANDARD.encode(salt); - format!("{}{}{}", passphrase, CONCATENATED_DELIMITER, salt_base64) -} - -fn split_passphrase_and_salt(combined: &str) -> Result<(&str, [u8; SALT_LENGTH]), anyhow::Error> { - let parts: Vec<&str> = combined.split(CONCATENATED_DELIMITER).collect(); - if parts.len() != 2 { - return Err(anyhow::anyhow!("Invalid combined format")); - } - let passphrase = parts[0]; - let salt = STANDARD.decode(parts[1])?; - if salt.len() != SALT_LENGTH { - return Err(anyhow::anyhow!("Incorrect salt length")); - } - let mut salt_array = [0u8; SALT_LENGTH]; - salt_array.copy_from_slice(&salt); - Ok((passphrase, salt_array)) -} - -fn derive_key(passphrase: &str, salt: &[u8; SALT_LENGTH]) -> Result<[u8; KEY_LENGTH]> { - let mut key = [0u8; KEY_LENGTH]; - pbkdf2::>(passphrase.as_bytes(), salt, ITERATIONS, &mut key)?; - Ok(key) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encrypt_decrypt_test() { - let secret = generate_encryption_secret(); - let data = b"hello world"; - let encrypted = encrypt_data(data, &secret).unwrap(); - let decrypted = decrypt_data(encrypted, &secret).unwrap(); - assert_eq!(data, decrypted.as_slice()); - - let s = "123".to_string(); - let encrypted = encrypt_text(&s, &secret).unwrap(); - let decrypted_str = decrypt_text(encrypted, &secret).unwrap(); - assert_eq!(s, decrypted_str); - } - - #[test] - fn decrypt_with_invalid_secret_test() { - let secret = generate_encryption_secret(); - let data = b"hello world"; - let encrypted = encrypt_data(data, &secret).unwrap(); - let decrypted = decrypt_data(encrypted, "invalid secret"); - assert!(decrypted.is_err()) - } -} diff --git a/frontend/rust-lib/flowy-encrypt/src/lib.rs b/frontend/rust-lib/flowy-encrypt/src/lib.rs deleted file mode 100644 index a72d275af9..0000000000 --- a/frontend/rust-lib/flowy-encrypt/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub use encrypt::*; - -mod encrypt; diff --git a/frontend/rust-lib/flowy-error/Cargo.toml b/frontend/rust-lib/flowy-error/Cargo.toml index e671be811f..61a7422f17 100644 --- a/frontend/rust-lib/flowy-error/Cargo.toml +++ b/frontend/rust-lib/flowy-error/Cargo.toml @@ -13,7 +13,7 @@ protobuf.workspace = true bytes.workspace = true anyhow.workspace = true thiserror = "1.0" -validator = "0.16.0" +validator.workspace = true tokio = { workspace = true, features = ["sync", "rt"] } fancy-regex = { version = "0.11.0" } @@ -22,28 +22,30 @@ serde_json.workspace = true serde_repr.workspace = true serde.workspace = true reqwest = { version = "0.11.14", optional = true, features = [ - "native-tls-vendored", + "native-tls-vendored", ] } flowy-sqlite = { workspace = true, optional = true } r2d2 = { version = "0.8", optional = true } url = { version = "2.2", optional = true } +collab = { workspace = true } collab-database = { workspace = true, optional = true } collab-document = { workspace = true, optional = true } collab-plugins = { workspace = true, optional = true } collab-folder = { workspace = true, optional = true } client-api = { workspace = true, optional = true } -tantivy = { version = "0.21.1", optional = true } - +tantivy = { workspace = true, optional = true } +uuid.workspace = true [features] +default = ["impl_from_dispatch_error", "impl_from_serde", "impl_from_reqwest", "impl_from_sqlite"] impl_from_dispatch_error = ["lib-dispatch"] impl_from_serde = [] impl_from_reqwest = ["reqwest"] impl_from_collab_persistence = ["collab-plugins"] impl_from_collab_document = [ - "collab-document", - "impl_from_reqwest", - "collab-plugins", + "collab-document", + "impl_from_reqwest", + "collab-plugins", ] impl_from_collab_folder = ["collab-folder"] impl_from_collab_database = ["collab-database"] @@ -53,8 +55,6 @@ impl_from_tantivy = ["tantivy"] impl_from_sqlite = ["flowy-sqlite", "r2d2"] impl_from_appflowy_cloud = ["client-api"] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] -web_ts = ["flowy-codegen/ts"] [build-dependencies] flowy-codegen = { workspace = true, features = ["proto_gen"] } diff --git a/frontend/rust-lib/flowy-error/build.rs b/frontend/rust-lib/flowy-error/build.rs index c3081d7488..8dfda67156 100644 --- a/frontend/rust-lib/flowy-error/build.rs +++ b/frontend/rust-lib/flowy-error/build.rs @@ -1,27 +1,4 @@ fn main() { #[cfg(feature = "dart")] flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } - - #[cfg(feature = "web_ts")] - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - "error", - flowy_codegen::Project::Web { - relative_path: "../../".to_string(), - }, - ); } diff --git a/frontend/rust-lib/flowy-error/src/code.rs b/frontend/rust-lib/flowy-error/src/code.rs index b013c1fbe5..4112883e61 100644 --- a/frontend/rust-lib/flowy-error/src/code.rs +++ b/frontend/rust-lib/flowy-error/src/code.rs @@ -164,8 +164,8 @@ pub enum ErrorCode { #[error("Sql error")] SqlError = 58, - #[error("Http error")] - HttpError = 59, + #[error("Network error")] + NetworkError = 59, #[error("The content should not be empty")] UnexpectedEmpty = 60, @@ -257,8 +257,8 @@ pub enum ErrorCode { #[error("AppFlowy data folder import error")] AppFlowyDataFolderImportError = 89, - #[error("Cloud request payload too large")] - CloudRequestPayloadTooLarge = 90, + #[error("payload too large")] + PayloadTooLarge = 90, #[error("Workspace limit exceeded")] WorkspaceLimitExceeded = 91, @@ -280,6 +280,109 @@ pub enum ErrorCode { #[error("Workspace data not match")] WorkspaceDataNotMatch = 97, + + #[error("Local AI error")] + LocalAIError = 98, + + #[error("Local AI unavailable")] + LocalAIUnavailable = 99, + + #[error("Storage limit exceeded")] + FileStorageLimitExceeded = 100, + + #[error("AI Response limit exceeded")] + AIResponseLimitExceeded = 101, + + #[error("Duplicate record")] + DuplicateSqliteRecord = 102, + + #[error("Response timeout")] + ResponseTimeout = 103, + + #[error("Unsupported file format")] + UnsupportedFileFormat = 104, + + #[error("AppFlowy LAI not ready")] + AppFlowyLAINotReady = 105, + + #[error("Invalid Request")] + InvalidRequest = 106, + + #[error("In progress")] + // when client receives InProgress, it should retry + InProgress = 107, + + #[error("Upload part size exceeds the limit")] + SingleUploadLimitExceeded = 108, + + #[error("Group name is empty")] + GroupNameIsEmpty = 109, + + #[error("Not available for current workspace plan")] + LimitedByWorkspacePlan = 110, + + #[error("Invalid namespace")] + InvalidNamespace = 111, + + #[error("Invalid publish name")] + InvalidPublishName = 112, + + #[error("Custom namespace requires Pro Plan upgrade")] + CustomNamespaceRequirePlanUpgrade = 113, + + #[error("Requested namespace is not allowed")] + CustomNamespaceNotAllowed = 114, + + #[error("Requested namespace is already taken")] + CustomNamespaceAlreadyTaken = 115, + + #[error("Requested namespace is too short")] + CustomNamespaceTooShort = 116, + + #[error("Requested namespace is too long")] + CustomNamespaceTooLong = 117, + + #[error("Requested namespace is reserved")] + CustomNamespaceReserved = 118, + + #[error("Publish name is already used for another published view")] + PublishNameAlreadyExists = 119, + + #[error("Publish name contains one or more invalid characters")] + PublishNameInvalidCharacter = 120, + + #[error("Publish name has exceeded the maximum length allowable")] + PublishNameTooLong = 121, + + #[error("Requested namespace has one or more invalid characters")] + CustomNamespaceInvalidCharacter = 122, + + #[error("AI Service is unavailable")] + AIServiceUnavailable = 123, + + #[error("AI Image Response limit exceeded")] + AIImageResponseLimitExceeded = 124, + + #[error("AI Max Required")] + AIMaxRequired = 125, + + #[error("View is locked")] + ViewIsLocked = 126, + + #[error("Request timeout")] + RequestTimeout = 127, + + #[error("Local AI is not ready")] + LocalAINotReady = 128, + + #[error("MCP error")] + MCPError = 129, + + #[error("Local AI disabled")] + LocalAIDisabled = 130, + + #[error("User not login")] + UserNotLogin = 131, } impl ErrorCode { diff --git a/frontend/rust-lib/flowy-error/src/errors.rs b/frontend/rust-lib/flowy-error/src/errors.rs index a54ac046b6..a9a2b6fa2b 100644 --- a/frontend/rust-lib/flowy-error/src/errors.rs +++ b/frontend/rust-lib/flowy-error/src/errors.rs @@ -1,7 +1,7 @@ -use std::convert::TryInto; -use std::fmt::Debug; - +use collab::error::CollabError; use protobuf::ProtobufError; +use std::convert::TryInto; +use std::fmt::{Debug, Display}; use thiserror::Error; use tokio::task::JoinError; use validator::{ValidationError, ValidationErrors}; @@ -13,7 +13,7 @@ use crate::code::ErrorCode; pub type FlowyResult = anyhow::Result; #[derive(Debug, Default, Clone, ProtoBuf, Error)] -#[error("{code:?}: {msg}")] +#[error("code:{code}, message:{msg}")] pub struct FlowyError { #[pb(index = 1)] pub code: ErrorCode, @@ -42,8 +42,8 @@ impl FlowyError { payload: vec![], } } - pub fn with_context(mut self, error: T) -> Self { - self.msg = format!("{:?}", error); + pub fn with_context(mut self, error: T) -> Self { + self.msg = format!("{}", error); self } @@ -72,6 +72,41 @@ impl FlowyError { self.code == ErrorCode::LocalVersionNotSupport } + pub fn is_file_limit_exceeded(&self) -> bool { + self.code == ErrorCode::FileStorageLimitExceeded + } + + pub fn is_single_file_limit_exceeded(&self) -> bool { + self.code == ErrorCode::SingleUploadLimitExceeded + } + + pub fn should_retry_upload(&self) -> bool { + !matches!( + self.code, + ErrorCode::FileStorageLimitExceeded | ErrorCode::SingleUploadLimitExceeded + ) + } + + pub fn is_ai_response_limit_exceeded(&self) -> bool { + self.code == ErrorCode::AIResponseLimitExceeded + } + + pub fn is_ai_image_response_limit_exceeded(&self) -> bool { + self.code == ErrorCode::AIImageResponseLimitExceeded + } + + pub fn is_local_ai_not_ready(&self) -> bool { + self.code == ErrorCode::LocalAINotReady + } + + pub fn is_local_ai_disabled(&self) -> bool { + self.code == ErrorCode::LocalAIDisabled + } + + pub fn is_ai_max_required(&self) -> bool { + self.code == ErrorCode::AIMaxRequired + } + static_flowy_error!(internal, ErrorCode::Internal); static_flowy_error!(record_not_found, ErrorCode::RecordNotFound); static_flowy_error!(workspace_initialize, ErrorCode::WorkspaceInitializeError); @@ -104,7 +139,7 @@ impl FlowyError { static_flowy_error!(serde, ErrorCode::Serde); static_flowy_error!(field_record_not_found, ErrorCode::FieldRecordNotFound); static_flowy_error!(payload_none, ErrorCode::UnexpectedEmpty); - static_flowy_error!(http, ErrorCode::HttpError); + static_flowy_error!(http, ErrorCode::NetworkError); static_flowy_error!( unexpect_calendar_field_type, ErrorCode::UnexpectedCalendarFieldType @@ -118,6 +153,15 @@ impl FlowyError { ErrorCode::FolderIndexManagerUnavailable ); static_flowy_error!(workspace_data_not_match, ErrorCode::WorkspaceDataNotMatch); + static_flowy_error!(local_ai, ErrorCode::LocalAIError); + static_flowy_error!(local_ai_unavailable, ErrorCode::LocalAIUnavailable); + static_flowy_error!(response_timeout, ErrorCode::ResponseTimeout); + static_flowy_error!(file_storage_limit, ErrorCode::FileStorageLimitExceeded); + + static_flowy_error!(view_is_locked, ErrorCode::ViewIsLocked); + static_flowy_error!(local_ai_not_ready, ErrorCode::LocalAINotReady); + static_flowy_error!(local_ai_disabled, ErrorCode::LocalAIDisabled); + static_flowy_error!(user_not_login, ErrorCode::UserNotLogin); } impl std::convert::From for FlowyError { @@ -135,7 +179,7 @@ pub fn internal_error(e: T) -> FlowyError where T: std::fmt::Debug, { - FlowyError::internal().with_context(e) + FlowyError::internal().with_context(format!("{:?}", e)) } impl std::convert::From for FlowyError { @@ -186,3 +230,36 @@ impl From for FlowyError { FlowyError::internal().with_context(e) } } + +impl From for FlowyError { + fn from(e: String) -> Self { + FlowyError::internal().with_context(e) + } +} + +impl From for FlowyError { + fn from(value: CollabError) -> Self { + match value { + CollabError::SerdeJson(err) => FlowyError::serde().with_context(err), + CollabError::UnexpectedEmpty(err) => FlowyError::payload_none().with_context(err), + CollabError::AcquiredWriteTxnFail => FlowyError::internal(), + CollabError::AcquiredReadTxnFail => FlowyError::internal(), + CollabError::YrsTransactionError(err) => FlowyError::internal().with_context(err), + CollabError::YrsEncodeStateError(err) => FlowyError::internal().with_context(err), + CollabError::UndoManagerNotEnabled => { + FlowyError::not_support().with_context("UndoManager is not enabled") + }, + CollabError::DecodeUpdate(err) => FlowyError::internal().with_context(err), + CollabError::NoRequiredData(err) => FlowyError::internal().with_context(err), + CollabError::Awareness(err) => FlowyError::internal().with_context(err), + CollabError::UpdateFailed(err) => FlowyError::internal().with_context(err), + CollabError::Internal(err) => FlowyError::internal().with_context(err), + } + } +} + +impl From for FlowyError { + fn from(value: uuid::Error) -> Self { + FlowyError::internal().with_context(value) + } +} diff --git a/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs b/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs index 3c38bc4005..53617c8c36 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs @@ -1,6 +1,5 @@ -use client_api::error::{AppResponseError, ErrorCode as AppErrorCode}; - use crate::{ErrorCode, FlowyError}; +use client_api::error::{AppResponseError, ErrorCode as AppErrorCode}; impl From for FlowyError { fn from(error: AppResponseError) -> Self { @@ -15,17 +14,32 @@ impl From for FlowyError { AppErrorCode::MissingPayload => ErrorCode::MissingPayload, AppErrorCode::OpenError => ErrorCode::Internal, AppErrorCode::InvalidUrl => ErrorCode::InvalidURL, - AppErrorCode::InvalidRequest => ErrorCode::InvalidParams, + AppErrorCode::InvalidRequest => ErrorCode::InvalidRequest, AppErrorCode::InvalidOAuthProvider => ErrorCode::InvalidAuthConfig, AppErrorCode::NotLoggedIn => ErrorCode::UserUnauthorized, AppErrorCode::NotEnoughPermissions => ErrorCode::NotEnoughPermissions, - AppErrorCode::NetworkError => ErrorCode::HttpError, - AppErrorCode::PayloadTooLarge => ErrorCode::CloudRequestPayloadTooLarge, - AppErrorCode::UserUnAuthorized => match &*error.message { - "Workspace Limit Exceeded" => ErrorCode::WorkspaceLimitExceeded, - "Workspace Member Limit Exceeded" => ErrorCode::WorkspaceMemberLimitExceeded, - _ => ErrorCode::UserUnauthorized, - }, + AppErrorCode::NetworkError => ErrorCode::NetworkError, + AppErrorCode::RequestTimeout => ErrorCode::RequestTimeout, + AppErrorCode::PayloadTooLarge => ErrorCode::PayloadTooLarge, + AppErrorCode::UserUnAuthorized => ErrorCode::UserUnauthorized, + AppErrorCode::WorkspaceLimitExceeded => ErrorCode::WorkspaceLimitExceeded, + AppErrorCode::WorkspaceMemberLimitExceeded => ErrorCode::WorkspaceMemberLimitExceeded, + AppErrorCode::AIResponseLimitExceeded => ErrorCode::AIResponseLimitExceeded, + AppErrorCode::AIImageResponseLimitExceeded => ErrorCode::AIImageResponseLimitExceeded, + AppErrorCode::AIMaxRequired => ErrorCode::AIMaxRequired, + AppErrorCode::FileStorageLimitExceeded => ErrorCode::FileStorageLimitExceeded, + AppErrorCode::SingleUploadLimitExceeded => ErrorCode::SingleUploadLimitExceeded, + AppErrorCode::CustomNamespaceDisabled => ErrorCode::CustomNamespaceRequirePlanUpgrade, + AppErrorCode::CustomNamespaceDisallowed => ErrorCode::CustomNamespaceNotAllowed, + AppErrorCode::PublishNamespaceAlreadyTaken => ErrorCode::CustomNamespaceAlreadyTaken, + AppErrorCode::CustomNamespaceTooShort => ErrorCode::CustomNamespaceTooShort, + AppErrorCode::CustomNamespaceTooLong => ErrorCode::CustomNamespaceTooLong, + AppErrorCode::CustomNamespaceReserved => ErrorCode::CustomNamespaceReserved, + AppErrorCode::PublishNameAlreadyExists => ErrorCode::PublishNameAlreadyExists, + AppErrorCode::PublishNameInvalidCharacter => ErrorCode::PublishNameInvalidCharacter, + AppErrorCode::PublishNameTooLong => ErrorCode::PublishNameTooLong, + AppErrorCode::CustomNamespaceInvalidCharacter => ErrorCode::CustomNamespaceInvalidCharacter, + AppErrorCode::AIServiceUnavailable => ErrorCode::AIServiceUnavailable, _ => ErrorCode::Internal, }; diff --git a/frontend/rust-lib/flowy-error/src/impl_from/database.rs b/frontend/rust-lib/flowy-error/src/impl_from/database.rs index 3a72a7cdf3..077ff2b708 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/database.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/database.rs @@ -1,8 +1,12 @@ use crate::FlowyError; +use flowy_sqlite::Error; impl std::convert::From for FlowyError { fn from(error: flowy_sqlite::Error) -> Self { - FlowyError::internal().with_context(error) + match error { + Error::NotFound => FlowyError::record_not_found(), + _ => FlowyError::internal().with_context(error), + } } } diff --git a/frontend/rust-lib/flowy-error/src/impl_from/mod.rs b/frontend/rust-lib/flowy-error/src/impl_from/mod.rs index b3d0351cd4..a2d11c66e4 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/mod.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/mod.rs @@ -13,7 +13,11 @@ pub mod reqwest; #[cfg(feature = "impl_from_sqlite")] pub mod database; -#[cfg(feature = "impl_from_collab_document")] +#[cfg(any( + feature = "impl_from_collab_document", + feature = "impl_from_collab_folder", + feature = "impl_from_collab_database" +))] pub mod collab; #[cfg(feature = "impl_from_collab_persistence")] diff --git a/frontend/rust-lib/flowy-folder-pub/Cargo.toml b/frontend/rust-lib/flowy-folder-pub/Cargo.toml index 13f13935f7..9f33bfe1c4 100644 --- a/frontend/rust-lib/flowy-folder-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-folder-pub/Cargo.toml @@ -12,6 +12,10 @@ collab = { workspace = true } collab-entity = { workspace = true } uuid.workspace = true anyhow.workspace = true +serde = { version = "1.0.202", features = ["derive"] } +serde_json.workspace = true +client-api = { workspace = true } +flowy-error.workspace = true [dev-dependencies] tokio.workspace = true diff --git a/frontend/rust-lib/flowy-folder-pub/src/cloud.rs b/frontend/rust-lib/flowy-folder-pub/src/cloud.rs index d88b4df203..05cc8f867d 100644 --- a/frontend/rust-lib/flowy-folder-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-folder-pub/src/cloud.rs @@ -1,58 +1,108 @@ +use crate::entities::PublishPayload; pub use anyhow::Error; +use client_api::entity::{workspace_dto::PublishInfoView, PublishInfo}; +use collab::entity::EncodedCollab; use collab_entity::CollabType; pub use collab_folder::{Folder, FolderData, Workspace}; +use flowy_error::FlowyError; +use lib_infra::async_trait::async_trait; use uuid::Uuid; -use lib_infra::future::FutureResult; - /// [FolderCloudService] represents the cloud service for folder. +#[async_trait] pub trait FolderCloudService: Send + Sync + 'static { - /// Creates a new workspace for the user. - /// Returns error if the cloud service doesn't support multiple workspaces - fn create_workspace(&self, uid: i64, name: &str) -> FutureResult; - - fn open_workspace(&self, workspace_id: &str) -> FutureResult<(), Error>; - - /// Returns all workspaces of the user. - /// Returns vec![] if the cloud service doesn't support multiple workspaces - fn get_all_workspace(&self) -> FutureResult, Error>; - - fn get_folder_data( - &self, - workspace_id: &str, - uid: &i64, - ) -> FutureResult, Error>; - - fn get_folder_snapshots( + async fn get_folder_snapshots( &self, workspace_id: &str, limit: usize, - ) -> FutureResult, Error>; + ) -> Result, FlowyError>; - fn get_folder_doc_state( + async fn get_folder_doc_state( &self, - workspace_id: &str, + workspace_id: &Uuid, uid: i64, collab_type: CollabType, - object_id: &str, - ) -> FutureResult, Error>; + object_id: &Uuid, + ) -> Result, FlowyError>; - fn batch_create_folder_collab_objects( + async fn full_sync_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, + params: FullSyncCollabParams, + ) -> Result<(), FlowyError>; + + async fn batch_create_folder_collab_objects( + &self, + workspace_id: &Uuid, objects: Vec, - ) -> FutureResult<(), Error>; + ) -> Result<(), FlowyError>; fn service_name(&self) -> String; + + async fn publish_view( + &self, + workspace_id: &Uuid, + payload: Vec, + ) -> Result<(), FlowyError>; + + async fn unpublish_views( + &self, + workspace_id: &Uuid, + view_ids: Vec, + ) -> Result<(), FlowyError>; + + async fn get_publish_info(&self, view_id: &Uuid) -> Result; + + async fn set_publish_name( + &self, + workspace_id: &Uuid, + view_id: Uuid, + new_name: String, + ) -> Result<(), FlowyError>; + + async fn set_publish_namespace( + &self, + workspace_id: &Uuid, + new_namespace: String, + ) -> Result<(), FlowyError>; + + async fn list_published_views( + &self, + workspace_id: &Uuid, + ) -> Result, FlowyError>; + + async fn get_default_published_view_info( + &self, + workspace_id: &Uuid, + ) -> Result; + + async fn set_default_published_view( + &self, + workspace_id: &Uuid, + view_id: uuid::Uuid, + ) -> Result<(), FlowyError>; + + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError>; + + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result; + + async fn import_zip(&self, file_path: &str) -> Result<(), FlowyError>; } #[derive(Debug)] pub struct FolderCollabParams { - pub object_id: String, + pub object_id: Uuid, pub encoded_collab_v1: Vec, pub collab_type: CollabType, } +#[derive(Debug)] +pub struct FullSyncCollabParams { + pub object_id: Uuid, + pub encoded_collab: EncodedCollab, + pub collab_type: CollabType, +} + pub struct FolderSnapshot { pub snapshot_id: i64, pub database_id: String, diff --git a/frontend/rust-lib/flowy-folder-pub/src/entities.rs b/frontend/rust-lib/flowy-folder-pub/src/entities.rs index 41163fae73..d06eb50e25 100644 --- a/frontend/rust-lib/flowy-folder-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-folder-pub/src/entities.rs @@ -1,21 +1,30 @@ -use crate::folder_builder::ParentChildViews; +use collab_folder::hierarchy_builder::ParentChildViews; +use collab_folder::{ViewIcon, ViewLayout}; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -pub enum ImportData { - AppFlowyDataFolder { items: Vec }, +pub struct ImportedAppFlowyData { + pub source: ImportFrom, + pub folder_data: ImportedFolderData, + pub collab_data: ImportedCollabData, + pub parent_view_id: Option, } -pub enum AppFlowyData { - Folder { - views: Vec, - /// Used to update the [DatabaseViewTrackerList] when importing the database. - database_view_ids_by_database_id: HashMap>, - }, - CollabObject { - row_object_ids: Vec, - document_object_ids: Vec, - database_object_ids: Vec, - }, +pub enum ImportFrom { + AnonUser, + AppFlowyDataFolder, +} + +pub struct ImportedFolderData { + pub views: Vec, + pub orphan_views: Vec, + /// Used to update the [DatabaseViewTrackerList] when importing the database. + pub database_view_ids_by_database_id: HashMap>, +} +pub struct ImportedCollabData { + pub row_object_ids: Vec, + pub document_object_ids: Vec, + pub database_object_ids: Vec, } pub struct ImportViews { @@ -39,3 +48,71 @@ pub struct SearchData { /// The data that is stored in the search index row. pub data: String, } + +#[derive(Serialize, Clone, Debug, Eq, PartialEq)] +pub struct PublishViewInfo { + pub view_id: String, + pub name: String, + pub icon: Option, + pub layout: ViewLayout, + pub extra: Option, + pub created_by: Option, + pub last_edited_by: Option, + pub last_edited_time: i64, + pub created_at: i64, + pub child_views: Option>, +} + +#[derive(Serialize, Clone, Debug, Eq, PartialEq)] +pub struct PublishViewMetaData { + pub view: PublishViewInfo, + pub child_views: Vec, + pub ancestor_views: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublishViewMeta { + pub metadata: PublishViewMetaData, + pub view_id: String, + pub publish_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)] +pub struct PublishDatabaseData { + /// The encoded collab data for the database itself + pub database_collab: Vec, + + /// The encoded collab data for the database rows + /// Use the row_id as the key + pub database_row_collabs: HashMap>, + + /// The encoded collab data for the documents inside the database rows + pub database_row_document_collabs: HashMap>, + + /// Visible view ids + pub visible_database_view_ids: Vec, + + /// Relation view id map + pub database_relations: HashMap, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublishDocumentPayload { + pub meta: PublishViewMeta, + + /// The encoded collab data for the document + pub data: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublishDatabasePayload { + pub meta: PublishViewMeta, + pub data: PublishDatabaseData, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PublishPayload { + Document(PublishDocumentPayload), + Database(PublishDatabasePayload), + Unknown, +} diff --git a/frontend/rust-lib/flowy-folder-pub/src/folder_builder.rs b/frontend/rust-lib/flowy-folder-pub/src/folder_builder.rs deleted file mode 100644 index 20604b0018..0000000000 --- a/frontend/rust-lib/flowy-folder-pub/src/folder_builder.rs +++ /dev/null @@ -1,321 +0,0 @@ -use std::future::Future; - -use crate::cloud::gen_view_id; -use collab_folder::{RepeatedViewIdentifier, View, ViewIcon, ViewIdentifier, ViewLayout}; -use lib_infra::util::timestamp; - -/// A builder for creating views, each able to have children views of -/// their own. -pub struct NestedViewBuilder { - pub uid: i64, - pub parent_view_id: String, - pub views: Vec, -} - -impl NestedViewBuilder { - pub fn new(parent_view_id: String, uid: i64) -> Self { - Self { - uid, - parent_view_id, - views: vec![], - } - } - - pub async fn with_view_builder(&mut self, view_builder: F) - where - F: Fn(ViewBuilder) -> O, - O: Future, - { - let builder = ViewBuilder::new(self.uid, self.parent_view_id.clone()); - self.views.push(view_builder(builder).await); - } - - pub fn build(&mut self) -> Vec { - std::mem::take(&mut self.views) - } -} - -/// A builder for creating a view. -/// The default layout of the view is [ViewLayout::Document] -pub struct ViewBuilder { - uid: i64, - parent_view_id: String, - view_id: String, - name: String, - desc: String, - layout: ViewLayout, - child_views: Vec, - is_favorite: bool, - icon: Option, -} - -impl ViewBuilder { - pub fn new(uid: i64, parent_view_id: String) -> Self { - Self { - uid, - parent_view_id, - view_id: gen_view_id().to_string(), - name: Default::default(), - desc: Default::default(), - layout: ViewLayout::Document, - child_views: vec![], - is_favorite: false, - - icon: None, - } - } - - pub fn view_id(&self) -> &str { - &self.view_id - } - - pub fn with_view_id(mut self, view_id: T) -> Self { - self.view_id = view_id.to_string(); - self - } - - pub fn with_layout(mut self, layout: ViewLayout) -> Self { - self.layout = layout; - self - } - - pub fn with_name(mut self, name: T) -> Self { - self.name = name.to_string(); - self - } - - pub fn with_desc(mut self, desc: &str) -> Self { - self.desc = desc.to_string(); - self - } - - pub fn with_icon(mut self, icon: &str) -> Self { - self.icon = Some(ViewIcon { - ty: collab_folder::IconType::Emoji, - value: icon.to_string(), - }); - self - } - - pub fn with_view(mut self, view: ParentChildViews) -> Self { - self.child_views.push(view); - self - } - - pub fn with_child_views(mut self, mut views: Vec) -> Self { - self.child_views.append(&mut views); - self - } - - /// Create a child view for the current view. - /// The view created by this builder will be the next level view of the current view. - pub async fn with_child_view_builder(mut self, child_view_builder: F) -> Self - where - F: Fn(ViewBuilder) -> O, - O: Future, - { - let builder = ViewBuilder::new(self.uid, self.view_id.clone()); - self.child_views.push(child_view_builder(builder).await); - self - } - - pub fn build(self) -> ParentChildViews { - let view = View { - id: self.view_id, - parent_view_id: self.parent_view_id, - name: self.name, - desc: self.desc, - created_at: timestamp(), - is_favorite: self.is_favorite, - layout: self.layout, - icon: self.icon, - created_by: Some(self.uid), - last_edited_time: 0, - children: RepeatedViewIdentifier::new( - self - .child_views - .iter() - .map(|v| ViewIdentifier { - id: v.parent_view.id.clone(), - }) - .collect(), - ), - last_edited_by: Some(self.uid), - extra: None, - }; - ParentChildViews { - parent_view: view, - child_views: self.child_views, - } - } -} - -#[derive(Clone)] -pub struct ParentChildViews { - pub parent_view: View, - pub child_views: Vec, -} - -impl ParentChildViews { - pub fn new(view: View) -> Self { - Self { - parent_view: view, - child_views: vec![], - } - } - - pub fn flatten(self) -> Vec { - FlattedViews::flatten_views(vec![self]) - } -} - -pub struct FlattedViews; - -impl FlattedViews { - pub fn flatten_views(views: Vec) -> Vec { - let mut result = vec![]; - for view in views { - result.push(view.parent_view); - result.append(&mut Self::flatten_views(view.child_views)); - } - result - } -} - -#[cfg(test)] -mod tests { - use crate::folder_builder::{FlattedViews, NestedViewBuilder}; - - #[tokio::test] - async fn create_first_level_views_test() { - let workspace_id = "w1".to_string(); - let mut builder = NestedViewBuilder::new(workspace_id, 1); - builder - .with_view_builder(|view_builder| async { view_builder.with_name("1").build() }) - .await; - builder - .with_view_builder(|view_builder| async { view_builder.with_name("2").build() }) - .await; - builder - .with_view_builder(|view_builder| async { view_builder.with_name("3").build() }) - .await; - let workspace_views = builder.build(); - assert_eq!(workspace_views.len(), 3); - - let views = FlattedViews::flatten_views(workspace_views); - assert_eq!(views.len(), 3); - } - - #[tokio::test] - async fn create_view_with_child_views_test() { - let workspace_id = "w1".to_string(); - let mut builder = NestedViewBuilder::new(workspace_id, 1); - builder - .with_view_builder(|view_builder| async { - view_builder - .with_name("1") - .with_child_view_builder(|child_view_builder| async { - child_view_builder.with_name("1_1").build() - }) - .await - .with_child_view_builder(|child_view_builder| async { - child_view_builder.with_name("1_2").build() - }) - .await - .build() - }) - .await; - builder - .with_view_builder(|view_builder| async { - view_builder - .with_name("2") - .with_child_view_builder(|child_view_builder| async { - child_view_builder.with_name("2_1").build() - }) - .await - .build() - }) - .await; - let workspace_views = builder.build(); - assert_eq!(workspace_views.len(), 2); - - assert_eq!(workspace_views[0].parent_view.name, "1"); - assert_eq!(workspace_views[0].child_views.len(), 2); - assert_eq!(workspace_views[0].child_views[0].parent_view.name, "1_1"); - assert_eq!(workspace_views[0].child_views[1].parent_view.name, "1_2"); - assert_eq!(workspace_views[1].child_views.len(), 1); - assert_eq!(workspace_views[1].child_views[0].parent_view.name, "2_1"); - - let views = FlattedViews::flatten_views(workspace_views); - assert_eq!(views.len(), 5); - } - - #[tokio::test] - async fn create_three_level_view_test() { - let workspace_id = "w1".to_string(); - let mut builder = NestedViewBuilder::new(workspace_id, 1); - builder - .with_view_builder(|view_builder| async { - view_builder - .with_name("1") - .with_child_view_builder(|child_view_builder| async { - child_view_builder - .with_name("1_1") - .with_child_view_builder(|b| async { b.with_name("1_1_1").build() }) - .await - .with_child_view_builder(|b| async { b.with_name("1_1_2").build() }) - .await - .build() - }) - .await - .with_child_view_builder(|child_view_builder| async { - child_view_builder - .with_name("1_2") - .with_child_view_builder(|b| async { b.with_name("1_2_1").build() }) - .await - .with_child_view_builder(|b| async { b.with_name("1_2_2").build() }) - .await - .build() - }) - .await - .build() - }) - .await; - let workspace_views = builder.build(); - assert_eq!(workspace_views.len(), 1); - - assert_eq!(workspace_views[0].parent_view.name, "1"); - assert_eq!(workspace_views[0].child_views.len(), 2); - assert_eq!(workspace_views[0].child_views[0].parent_view.name, "1_1"); - assert_eq!(workspace_views[0].child_views[1].parent_view.name, "1_2"); - - assert_eq!( - workspace_views[0].child_views[0].child_views[0] - .parent_view - .name, - "1_1_1" - ); - assert_eq!( - workspace_views[0].child_views[0].child_views[1] - .parent_view - .name, - "1_1_2" - ); - - assert_eq!( - workspace_views[0].child_views[1].child_views[0] - .parent_view - .name, - "1_2_1" - ); - assert_eq!( - workspace_views[0].child_views[1].child_views[1] - .parent_view - .name, - "1_2_2" - ); - - let views = FlattedViews::flatten_views(workspace_views); - assert_eq!(views.len(), 7); - } -} diff --git a/frontend/rust-lib/flowy-folder-pub/src/lib.rs b/frontend/rust-lib/flowy-folder-pub/src/lib.rs index feaa5c2a0e..38d61c8e9c 100644 --- a/frontend/rust-lib/flowy-folder-pub/src/lib.rs +++ b/frontend/rust-lib/flowy-folder-pub/src/lib.rs @@ -1,3 +1,3 @@ pub mod cloud; pub mod entities; -pub mod folder_builder; +pub mod query; diff --git a/frontend/rust-lib/flowy-folder-pub/src/query.rs b/frontend/rust-lib/flowy-folder-pub/src/query.rs new file mode 100644 index 0000000000..74761e44db --- /dev/null +++ b/frontend/rust-lib/flowy-folder-pub/src/query.rs @@ -0,0 +1,31 @@ +use collab::entity::EncodedCollab; +use collab_entity::CollabType; +use collab_folder::ViewLayout; +use flowy_error::FlowyResult; +use lib_infra::async_trait::async_trait; +use uuid::Uuid; + +pub struct QueryCollab { + pub collab_type: CollabType, + pub encoded_collab: EncodedCollab, +} + +pub trait FolderService: FolderQueryService + FolderViewEdit {} + +#[async_trait] +pub trait FolderQueryService: Send + Sync + 'static { + /// gets the parent view and all of the ids of its children views matching + /// the provided view layout, given that the parent view is not a space + async fn get_surrounding_view_ids_with_view_layout( + &self, + parent_view_id: &Uuid, + view_layout: ViewLayout, + ) -> Vec; + + async fn get_collab(&self, object_id: &Uuid, collab_type: CollabType) -> Option; +} + +#[async_trait] +pub trait FolderViewEdit: Send + Sync + 'static { + async fn set_view_title_if_empty(&self, view_id: &Uuid, title: &str) -> FlowyResult<()>; +} diff --git a/frontend/rust-lib/flowy-folder/Cargo.toml b/frontend/rust-lib/flowy-folder/Cargo.toml index ba4a2e3481..13b19e48b8 100644 --- a/frontend/rust-lib/flowy-folder/Cargo.toml +++ b/frontend/rust-lib/flowy-folder/Cargo.toml @@ -14,15 +14,16 @@ collab-plugins = { workspace = true } collab-integrate = { workspace = true } flowy-folder-pub = { workspace = true } flowy-search-pub = { workspace = true } - +flowy-user-pub = { workspace = true } +flowy-sqlite = { workspace = true } flowy-derive.workspace = true flowy-notification = { workspace = true } -parking_lot.workspace = true +arc-swap.workspace = true unicode-segmentation = "1.10" tracing.workspace = true flowy-error = { path = "../flowy-error", features = [ - "impl_from_dispatch_error", - "impl_from_collab_folder", + "impl_from_dispatch_error", + "impl_from_collab_folder", ] } lib-dispatch = { workspace = true } bytes.workspace = true @@ -35,15 +36,19 @@ strum_macros = "0.21" protobuf.workspace = true uuid.workspace = true tokio-stream = { workspace = true, features = ["sync"] } +serde = { workspace = true, features = ["derive"] } serde_json.workspace = true -validator = "0.16.0" +validator.workspace = true async-trait.workspace = true +client-api = { workspace = true } +regex = "1.9.5" +futures = "0.3.31" +dashmap.workspace = true + [build-dependencies] flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] -web_ts = ["flowy-codegen/ts", "flowy-notification/web_ts"] test_helper = [] diff --git a/frontend/rust-lib/flowy-folder/build.rs b/frontend/rust-lib/flowy-folder/build.rs index fac4cc65ae..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-folder/build.rs +++ b/frontend/rust-lib/flowy-folder/build.rs @@ -4,37 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } - - #[cfg(feature = "web_ts")] - { - flowy_codegen::ts_event::gen( - "folder", - flowy_codegen::Project::Web { - relative_path: "../../".to_string(), - }, - ); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - "folder", - flowy_codegen::Project::Web { - relative_path: "../../".to_string(), - }, - ); - } } diff --git a/frontend/rust-lib/flowy-folder/src/entities/icon.rs b/frontend/rust-lib/flowy-folder/src/entities/icon.rs index 2342b02246..62e1760293 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/icon.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/icon.rs @@ -31,6 +31,16 @@ impl From for ViewIconTypePB { } } +impl From for ViewIconTypePB { + fn from(val: client_api::entity::workspace_dto::IconType) -> Self { + match val { + client_api::entity::workspace_dto::IconType::Emoji => ViewIconTypePB::Emoji, + client_api::entity::workspace_dto::IconType::Url => ViewIconTypePB::Url, + client_api::entity::workspace_dto::IconType::Icon => ViewIconTypePB::Icon, + } + } +} + #[derive(Default, ProtoBuf, Debug, Clone, PartialEq, Eq)] pub struct ViewIconPB { #[pb(index = 1)] @@ -39,7 +49,7 @@ pub struct ViewIconPB { pub value: String, } -impl std::convert::From for ViewIcon { +impl From for ViewIcon { fn from(rev: ViewIconPB) -> Self { ViewIcon { ty: rev.ty.into(), @@ -57,6 +67,15 @@ impl From for ViewIconPB { } } +impl From for ViewIconPB { + fn from(val: client_api::entity::workspace_dto::ViewIcon) -> Self { + ViewIconPB { + ty: val.ty.into(), + value: val.value, + } + } +} + #[derive(Default, ProtoBuf)] pub struct UpdateViewIconPayloadPB { #[pb(index = 1)] diff --git a/frontend/rust-lib/flowy-folder/src/entities/import.rs b/frontend/rust-lib/flowy-folder/src/entities/import.rs index 363ad2b2c2..83e8bdf874 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/import.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/import.rs @@ -1,15 +1,20 @@ use crate::entities::parser::empty_str::NotEmptyStr; use crate::entities::ViewLayoutPB; -use crate::share::{ImportParams, ImportType}; +use crate::share::{ImportData, ImportItem, ImportParams, ImportType}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::FlowyError; +use lib_infra::validator_fn::required_not_empty_str; +use std::str::FromStr; +use uuid::Uuid; +use validator::Validate; #[derive(Clone, Debug, ProtoBuf_Enum)] pub enum ImportTypePB { HistoryDocument = 0, HistoryDatabase = 1, - RawDatabase = 2, - CSV = 3, + Markdown = 2, + AFDatabase = 3, + CSV = 4, } impl From for ImportType { @@ -17,7 +22,8 @@ impl From for ImportType { match pb { ImportTypePB::HistoryDocument => ImportType::HistoryDocument, ImportTypePB::HistoryDatabase => ImportType::HistoryDatabase, - ImportTypePB::RawDatabase => ImportType::RawDatabase, + ImportTypePB::Markdown => ImportType::Markdown, + ImportTypePB::AFDatabase => ImportType::AFDatabase, ImportTypePB::CSV => ImportType::CSV, } } @@ -25,32 +31,46 @@ impl From for ImportType { impl Default for ImportTypePB { fn default() -> Self { - Self::HistoryDocument + Self::Markdown } } #[derive(Clone, Debug, ProtoBuf, Default)] -pub struct ImportPB { +pub struct ImportItemPayloadPB { + // the name of the import page #[pb(index = 1)] - pub parent_view_id: String, - - #[pb(index = 2)] pub name: String, - #[pb(index = 3, one_of)] + // the data of the import page + // if the data is empty, the file_path must be provided + #[pb(index = 2, one_of)] pub data: Option>, - #[pb(index = 4, one_of)] + // the file path of the import page + // if the file_path is empty, the data must be provided + #[pb(index = 3, one_of)] pub file_path: Option, - #[pb(index = 5)] + // the layout of the import page + #[pb(index = 4)] pub view_layout: ViewLayoutPB, - #[pb(index = 6)] + // the type of the import page + #[pb(index = 5)] pub import_type: ImportTypePB, } -impl TryInto for ImportPB { +#[derive(Clone, Debug, Validate, ProtoBuf, Default)] +pub struct ImportPayloadPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub parent_view_id: String, + + #[pb(index = 2)] + pub items: Vec, +} + +impl TryInto for ImportPayloadPB { type Error = FlowyError; fn try_into(self) -> Result { @@ -58,28 +78,48 @@ impl TryInto for ImportPB { .map_err(|_| FlowyError::invalid_view_id())? .0; - let name = if self.name.is_empty() { - "Untitled".to_string() - } else { - self.name - }; + let parent_view_id = Uuid::from_str(&parent_view_id)?; - let file_path = match self.file_path { - None => None, - Some(file_path) => Some( - NotEmptyStr::parse(file_path) - .map_err(|_| FlowyError::invalid_data().with_context("The import file path is empty"))? - .0, - ), - }; + let items = self + .items + .into_iter() + .map(|item| { + let name = if item.name.is_empty() { + "Untitled".to_string() + } else { + item.name + }; + + let data = match (item.file_path, item.data) { + (Some(file_path), None) => ImportData::FilePath { file_path }, + (None, Some(bytes)) => ImportData::Bytes { bytes }, + (None, None) => { + return Err(FlowyError::invalid_data().with_context("The import data is empty")); + }, + (Some(_), Some(_)) => { + return Err(FlowyError::invalid_data().with_context("The import data is ambiguous")); + }, + }; + + Ok(ImportItem { + name, + data, + view_layout: item.view_layout.into(), + import_type: item.import_type.into(), + }) + }) + .collect::, _>>()?; Ok(ImportParams { parent_view_id, - name, - data: self.data, - file_path, - view_layout: self.view_layout.into(), - import_type: self.import_type.into(), + items, }) } } + +#[derive(Clone, Debug, Validate, ProtoBuf, Default)] +pub struct ImportZipPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub file_path: String, +} diff --git a/frontend/rust-lib/flowy-folder/src/entities/mod.rs b/frontend/rust-lib/flowy-folder/src/entities/mod.rs index b496f334b5..24e5475caa 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/mod.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/mod.rs @@ -1,12 +1,14 @@ pub mod icon; mod import; mod parser; +pub mod publish; pub mod trash; pub mod view; pub mod workspace; pub use icon::*; pub use import::*; +pub use publish::*; pub use trash::*; pub use view::*; pub use workspace::*; diff --git a/frontend/rust-lib/flowy-folder/src/entities/parser/trash/trash_id.rs b/frontend/rust-lib/flowy-folder/src/entities/parser/trash/trash_id.rs index 3b6a4c4f6d..45f5a46ac3 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/parser/trash/trash_id.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/parser/trash/trash_id.rs @@ -17,18 +17,3 @@ impl AsRef for TrashIdentify { &self.0 } } - -#[derive(Debug)] -pub struct TrashIds(pub Vec); - -impl TrashIds { - #[allow(dead_code)] - pub fn parse(ids: Vec) -> Result { - let mut trash_ids = vec![]; - for id in ids { - let id = TrashIdentify::parse(id)?; - trash_ids.push(id.0); - } - Ok(Self(trash_ids)) - } -} diff --git a/frontend/rust-lib/flowy-folder/src/entities/publish.rs b/frontend/rust-lib/flowy-folder/src/entities/publish.rs new file mode 100644 index 0000000000..ac23d810f5 --- /dev/null +++ b/frontend/rust-lib/flowy-folder/src/entities/publish.rs @@ -0,0 +1,110 @@ +use client_api::entity::workspace_dto::{FolderViewMinimal, PublishInfoView}; +use client_api::entity::PublishInfo; +use flowy_derive::ProtoBuf; + +use super::{RepeatedViewIdPB, ViewIconPB, ViewLayoutPB}; + +#[derive(Default, ProtoBuf)] +pub struct PublishViewParamsPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2, one_of)] + pub publish_name: Option, + + #[pb(index = 3, one_of)] + pub selected_view_ids: Option, +} + +#[derive(Default, ProtoBuf)] +pub struct UnpublishViewsPayloadPB { + #[pb(index = 1)] + pub view_ids: Vec, +} + +#[derive(Default, ProtoBuf)] +pub struct PublishInfoViewPB { + #[pb(index = 1)] + pub view: FolderViewMinimalPB, + #[pb(index = 2)] + pub info: PublishInfoResponsePB, +} + +impl From for PublishInfoViewPB { + fn from(info_view: PublishInfoView) -> Self { + Self { + view: info_view.view.into(), + info: info_view.info.into(), + } + } +} + +#[derive(Default, ProtoBuf)] +pub struct FolderViewMinimalPB { + #[pb(index = 1)] + pub view_id: String, + #[pb(index = 2)] + pub name: String, + #[pb(index = 3, one_of)] + pub icon: Option, + #[pb(index = 4)] + pub layout: ViewLayoutPB, +} + +impl From for FolderViewMinimalPB { + fn from(view: FolderViewMinimal) -> Self { + Self { + view_id: view.view_id, + name: view.name, + icon: view.icon.map(Into::into), + layout: view.layout.into(), + } + } +} + +#[derive(Default, ProtoBuf)] +pub struct PublishInfoResponsePB { + #[pb(index = 1)] + pub view_id: String, + #[pb(index = 2)] + pub publish_name: String, + #[pb(index = 3, one_of)] + pub namespace: Option, + #[pb(index = 4)] + pub publisher_email: String, + #[pb(index = 5)] + pub publish_timestamp_sec: i64, + #[pb(index = 6, one_of)] + pub unpublished_at_timestamp_sec: Option, +} + +impl From for PublishInfoResponsePB { + fn from(info: PublishInfo) -> Self { + Self { + view_id: info.view_id.to_string(), + publish_name: info.publish_name, + namespace: Some(info.namespace), + publisher_email: info.publisher_email, + publish_timestamp_sec: info.publish_timestamp.timestamp(), + unpublished_at_timestamp_sec: info.unpublished_timestamp.map(|t| t.timestamp()), + } + } +} + +#[derive(Default, ProtoBuf)] +pub struct RepeatedPublishInfoViewPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(Default, ProtoBuf)] +pub struct SetPublishNamespacePayloadPB { + #[pb(index = 1)] + pub new_namespace: String, +} + +#[derive(Default, ProtoBuf)] +pub struct PublishNamespacePB { + #[pb(index = 1)] + pub namespace: String, +} diff --git a/frontend/rust-lib/flowy-folder/src/entities/view.rs b/frontend/rust-lib/flowy-folder/src/entities/view.rs index 004f793e11..4f2304846b 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/view.rs @@ -1,16 +1,17 @@ -use std::collections::HashMap; -use std::convert::TryInto; -use std::ops::{Deref, DerefMut}; -use std::sync::Arc; - -use collab_folder::{View, ViewLayout}; - +use collab_folder::{View, ViewIcon, ViewLayout}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; use flowy_folder_pub::cloud::gen_view_id; +use std::collections::HashMap; +use std::convert::TryInto; +use std::ops::{Deref, DerefMut}; +use std::str::FromStr; +use std::sync::Arc; +use uuid::Uuid; use crate::entities::icon::ViewIconPB; use crate::entities::parser::view::{ViewIdentify, ViewName, ViewThumbnail}; +use crate::view_operation::ViewData; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct ChildViewUpdatePB { @@ -60,6 +61,23 @@ pub struct ViewPB { #[pb(index = 9, one_of)] pub extra: Option, + + // user_id + #[pb(index = 10, one_of)] + pub created_by: Option, + + // timestamp + #[pb(index = 11)] + pub last_edited: i64, + + // user_id + #[pb(index = 12, one_of)] + pub last_edited_by: Option, + + // is_locked + // If true, the view is locked and cannot be edited. + #[pb(index = 13, one_of)] + pub is_locked: Option, } pub fn view_pb_without_child_views(view: View) -> ViewPB { @@ -73,6 +91,10 @@ pub fn view_pb_without_child_views(view: View) -> ViewPB { icon: view.icon.clone().map(|icon| icon.into()), is_favorite: view.is_favorite, extra: view.extra, + created_by: view.created_by, + last_edited: view.last_edited_time, + last_edited_by: view.last_edited_by, + is_locked: view.is_locked, } } @@ -87,6 +109,10 @@ pub fn view_pb_without_child_views_from_arc(view: Arc) -> ViewPB { icon: view.icon.clone().map(|icon| icon.into()), is_favorite: view.is_favorite, extra: view.extra.clone(), + created_by: view.created_by, + last_edited: view.last_edited_time, + last_edited_by: view.last_edited_by, + is_locked: view.is_locked, } } @@ -105,6 +131,10 @@ pub fn view_pb_with_child_views(view: Arc, child_views: Vec>) -> icon: view.icon.clone().map(|icon| icon.into()), is_favorite: view.is_favorite, extra: view.extra.clone(), + created_by: view.created_by, + last_edited: view.last_edited_time, + last_edited_by: view.last_edited_by, + is_locked: view.is_locked, } } @@ -115,6 +145,7 @@ pub enum ViewLayoutPB { Grid = 1, Board = 2, Calendar = 3, + Chat = 4, } impl ViewLayoutPB { @@ -133,6 +164,19 @@ impl std::convert::From for ViewLayoutPB { ViewLayout::Board => ViewLayoutPB::Board, ViewLayout::Document => ViewLayoutPB::Document, ViewLayout::Calendar => ViewLayoutPB::Calendar, + ViewLayout::Chat => ViewLayoutPB::Chat, + } + } +} + +impl From for ViewLayoutPB { + fn from(val: client_api::entity::workspace_dto::ViewLayout) -> Self { + match val { + client_api::entity::workspace_dto::ViewLayout::Document => ViewLayoutPB::Document, + client_api::entity::workspace_dto::ViewLayout::Grid => ViewLayoutPB::Grid, + client_api::entity::workspace_dto::ViewLayout::Board => ViewLayoutPB::Board, + client_api::entity::workspace_dto::ViewLayout::Calendar => ViewLayoutPB::Calendar, + client_api::entity::workspace_dto::ViewLayout::Chat => ViewLayoutPB::Chat, } } } @@ -152,6 +196,35 @@ pub struct RepeatedViewPB { pub items: Vec, } +#[derive(Eq, PartialEq, Debug, Default, ProtoBuf, Clone)] +pub struct RepeatedFavoriteViewPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(Eq, PartialEq, Debug, Default, ProtoBuf, Clone)] +pub struct ReadRecentViewsPB { + #[pb(index = 1)] + pub start: u64, + + #[pb(index = 2)] + pub limit: u64, +} + +#[derive(Eq, PartialEq, Debug, Default, ProtoBuf, Clone)] +pub struct RepeatedRecentViewPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(Eq, PartialEq, Debug, Default, ProtoBuf, Clone)] +pub struct SectionViewPB { + #[pb(index = 1)] + pub item: ViewPB, + #[pb(index = 2)] + pub timestamp: i64, +} + impl std::convert::From> for RepeatedViewPB { fn from(items: Vec) -> Self { RepeatedViewPB { items } @@ -186,35 +259,40 @@ pub struct CreateViewPayloadPB { #[pb(index = 2)] pub name: String, - #[pb(index = 3)] - pub desc: String, - - #[pb(index = 4, one_of)] + #[pb(index = 3, one_of)] pub thumbnail: Option, - #[pb(index = 5)] + #[pb(index = 4)] pub layout: ViewLayoutPB, - #[pb(index = 6)] + #[pb(index = 5)] pub initial_data: Vec, - #[pb(index = 7)] + #[pb(index = 6)] pub meta: HashMap, // Mark the view as current view after creation. - #[pb(index = 8)] + #[pb(index = 7)] pub set_as_current: bool, // The index of the view in the parent view. // If the index is None or the index is out of range, the view will be appended to the end of the parent view. - #[pb(index = 9, one_of)] + #[pb(index = 8, one_of)] pub index: Option, // The section of the view. // Only the view in public section will be shown in the shared workspace view list. // The view in private section will only be shown in the user's private view list. - #[pb(index = 10, one_of)] + #[pb(index = 9, one_of)] pub section: Option, + + #[pb(index = 10, one_of)] + pub view_id: Option, + + // The extra data of the view. + // Refer to the extra field in the collab + #[pb(index = 11, one_of)] + pub extra: Option, } #[derive(Eq, PartialEq, Hash, Debug, ProtoBuf_Enum, Clone, Default)] @@ -237,23 +315,19 @@ pub struct CreateOrphanViewPayloadPB { pub name: String, #[pb(index = 3)] - pub desc: String, - - #[pb(index = 4)] pub layout: ViewLayoutPB, - #[pb(index = 5)] + #[pb(index = 4)] pub initial_data: Vec, } #[derive(Debug, Clone)] pub struct CreateViewParams { - pub parent_view_id: String, + pub parent_view_id: Uuid, pub name: String, - pub desc: String, pub layout: ViewLayoutPB, - pub view_id: String, - pub initial_data: Vec, + pub view_id: Uuid, + pub initial_data: ViewData, pub meta: HashMap, // Mark the view as current view after creation. pub set_as_current: bool, @@ -262,6 +336,10 @@ pub struct CreateViewParams { pub index: Option, // The section of the view. pub section: Option, + // The icon of the view. + pub icon: Option, + // The extra data of the view. + pub extra: Option, } impl TryInto for CreateViewPayloadPB { @@ -269,20 +347,26 @@ impl TryInto for CreateViewPayloadPB { fn try_into(self) -> Result { let name = ViewName::parse(self.name)?.0; - let parent_view_id = ViewIdentify::parse(self.parent_view_id)?.0; - let view_id = gen_view_id().to_string(); + let parent_view_id = ViewIdentify::parse(self.parent_view_id) + .and_then(|id| Uuid::from_str(&id.0).map_err(|_| ErrorCode::InvalidParams))?; + // if view_id is not provided, generate a new view_id + let view_id = self + .view_id + .and_then(|v| Uuid::parse_str(&v).ok()) + .unwrap_or_else(gen_view_id); Ok(CreateViewParams { parent_view_id, name, - desc: self.desc, layout: self.layout, view_id, - initial_data: self.initial_data, + initial_data: ViewData::Data(self.initial_data.into()), meta: self.meta, set_as_current: self.set_as_current, index: self.index, section: self.section, + icon: None, + extra: self.extra, }) } } @@ -292,19 +376,20 @@ impl TryInto for CreateOrphanViewPayloadPB { fn try_into(self) -> Result { let name = ViewName::parse(self.name)?.0; - let parent_view_id = ViewIdentify::parse(self.view_id.clone())?.0; + let view_id = Uuid::parse_str(&self.view_id).map_err(|_| ErrorCode::InvalidParams)?; Ok(CreateViewParams { - parent_view_id, + parent_view_id: view_id, name, - desc: self.desc, layout: self.layout, - view_id: self.view_id, - initial_data: self.initial_data, + view_id, + initial_data: ViewData::Data(self.initial_data.into()), meta: Default::default(), set_as_current: false, index: None, section: None, + icon: None, + extra: None, }) } } @@ -323,6 +408,15 @@ impl std::convert::From<&str> for ViewIdPB { } } +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct SetPublishNamePB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub new_name: String, +} + #[derive(Default, ProtoBuf, Clone, Debug)] pub struct DeletedViewPB { #[pb(index = 1)] @@ -475,9 +569,9 @@ impl TryInto for MoveViewPayloadPB { #[derive(Debug)] pub struct MoveNestedViewParams { - pub view_id: String, - pub new_parent_id: String, - pub prev_view_id: Option, + pub view_id: Uuid, + pub new_parent_id: Uuid, + pub prev_view_id: Option, pub from_section: Option, pub to_section: Option, } @@ -486,9 +580,20 @@ impl TryInto for MoveNestedViewPayloadPB { type Error = ErrorCode; fn try_into(self) -> Result { - let view_id = ViewIdentify::parse(self.view_id)?.0; + let view_id = Uuid::from_str(&ViewIdentify::parse(self.view_id)?.0) + .map_err(|_| ErrorCode::InvalidParams)?; + let new_parent_id = ViewIdentify::parse(self.new_parent_id)?.0; - let prev_view_id = self.prev_view_id; + let new_parent_id = Uuid::from_str(&new_parent_id).map_err(|_| ErrorCode::InvalidParams)?; + + let prev_view_id = match self.prev_view_id { + Some(prev_view_id) => Some( + Uuid::from_str(&ViewIdentify::parse(prev_view_id)?.0) + .map_err(|_| ErrorCode::InvalidParams)?, + ), + None => None, + }; + Ok(MoveNestedViewParams { view_id, new_parent_id, @@ -519,6 +624,62 @@ pub struct UpdateViewVisibilityStatusPayloadPB { pub is_public: bool, } +#[derive(Default, ProtoBuf)] +pub struct DuplicateViewPayloadPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub open_after_duplicate: bool, + + #[pb(index = 3)] + pub include_children: bool, + + // duplicate the view to the specified parent view. + // if the parent_view_id is None, the view will be duplicated to the same parent view. + #[pb(index = 4, one_of)] + pub parent_view_id: Option, + + // The suffix of the duplicated view name. + // If the suffix is None, the duplicated view will have the same name with (copy) suffix. + #[pb(index = 5, one_of)] + pub suffix: Option, + + #[pb(index = 6)] + pub sync_after_create: bool, +} + +#[derive(Debug)] +pub struct DuplicateViewParams { + pub view_id: String, + + pub open_after_duplicate: bool, + + pub include_children: bool, + + pub parent_view_id: Option, + + pub suffix: Option, + + pub sync_after_create: bool, +} + +impl TryInto for DuplicateViewPayloadPB { + type Error = ErrorCode; + + fn try_into(self) -> Result { + let view_id = ViewIdentify::parse(self.view_id)?.0; + Ok(DuplicateViewParams { + view_id, + open_after_duplicate: self.open_after_duplicate, + include_children: self.include_children, + parent_view_id: self.parent_view_id, + suffix: self.suffix, + sync_after_create: self.sync_after_create, + }) + } +} + // impl<'de> Deserialize<'de> for ViewDataType { // fn deserialize(deserializer: D) -> Result>::Error> // where diff --git a/frontend/rust-lib/flowy-folder/src/entities/workspace.rs b/frontend/rust-lib/flowy-folder/src/entities/workspace.rs index 21ff046226..72e50562f3 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/workspace.rs @@ -134,7 +134,7 @@ impl TryInto for GetWorkspaceViewPB { } #[derive(Default, ProtoBuf, Debug, Clone)] -pub struct WorkspaceSettingPB { +pub struct WorkspaceLatestPB { #[pb(index = 1)] pub workspace_id: String, diff --git a/frontend/rust-lib/flowy-folder/src/event_handler.rs b/frontend/rust-lib/flowy-folder/src/event_handler.rs index d6d36b683e..ec11d57517 100644 --- a/frontend/rust-lib/flowy-folder/src/event_handler.rs +++ b/frontend/rust-lib/flowy-folder/src/event_handler.rs @@ -1,8 +1,9 @@ -use std::sync::{Arc, Weak}; -use tracing::instrument; - use flowy_error::{FlowyError, FlowyResult}; use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use std::str::FromStr; +use std::sync::{Arc, Weak}; +use tracing::instrument; +use uuid::Uuid; use crate::entities::*; use crate::manager::FolderManager; @@ -17,28 +18,6 @@ fn upgrade_folder( Ok(folder) } -#[tracing::instrument(level = "debug", skip(data, folder), err)] -pub(crate) async fn create_workspace_handler( - data: AFPluginData, - folder: AFPluginState>, -) -> DataResult { - let folder = upgrade_folder(folder)?; - let params: CreateWorkspaceParams = data.into_inner().try_into()?; - let workspace = folder.create_workspace(params).await?; - let views = folder - .get_views_belong_to(&workspace.id) - .await? - .into_iter() - .map(|view| view_pb_without_child_views(view.as_ref().clone())) - .collect::>(); - data_result_ok(WorkspacePB { - id: workspace.id, - name: workspace.name, - views, - create_time: workspace.created_at, - }) -} - #[tracing::instrument(level = "debug", skip_all, err)] pub(crate) async fn get_all_workspace_handler( _data: AFPluginData, @@ -83,7 +62,7 @@ pub(crate) async fn read_private_views_handler( #[tracing::instrument(level = "debug", skip(folder), err)] pub(crate) async fn read_current_workspace_setting_handler( folder: AFPluginState>, -) -> DataResult { +) -> DataResult { let folder = upgrade_folder(folder)?; let setting = folder.get_workspace_setting_pb().await?; data_result_ok(setting) @@ -105,9 +84,9 @@ pub(crate) async fn create_view_handler( let folder = upgrade_folder(folder)?; let params: CreateViewParams = data.into_inner().try_into()?; let set_as_current = params.set_as_current; - let view = folder.create_view_with_params(params).await?; + let (view, _) = folder.create_view_with_params(params, true).await?; if set_as_current { - let _ = folder.set_current_view(&view.id).await; + let _ = folder.set_current_view(view.id.clone()).await; } data_result_ok(view_pb_without_child_views(view)) } @@ -121,7 +100,7 @@ pub(crate) async fn create_orphan_view_handler( let set_as_current = params.set_as_current; let view = folder.create_orphan_view_with_params(params).await?; if set_as_current { - let _ = folder.set_current_view(&view.id).await; + let _ = folder.set_current_view(view.id.clone()).await; } data_result_ok(view_pb_without_child_views(view)) } @@ -226,7 +205,7 @@ pub(crate) async fn set_latest_view_handler( ) -> Result<(), FlowyError> { let folder = upgrade_folder(folder)?; let view_id: ViewIdPB = data.into_inner(); - let _ = folder.set_current_view(&view_id.value).await; + let _ = folder.set_current_view(view_id.value.clone()).await; Ok(()) } @@ -266,43 +245,60 @@ pub(crate) async fn move_nested_view_handler( #[tracing::instrument(level = "debug", skip(data, folder), err)] pub(crate) async fn duplicate_view_handler( - data: AFPluginData, + data: AFPluginData, folder: AFPluginState>, -) -> Result<(), FlowyError> { +) -> DataResult { let folder = upgrade_folder(folder)?; - let view: ViewPB = data.into_inner(); - folder.duplicate_view(&view.id).await?; - Ok(()) + let params: DuplicateViewParams = data.into_inner().try_into()?; + + let view_pb = folder.duplicate_view(params).await?; + data_result_ok(view_pb) } #[tracing::instrument(level = "debug", skip(folder), err)] pub(crate) async fn read_favorites_handler( folder: AFPluginState>, -) -> DataResult { +) -> DataResult { let folder = upgrade_folder(folder)?; let favorite_items = folder.get_all_favorites().await; let mut views = vec![]; for item in favorite_items { if let Ok(view) = folder.get_view_pb(&item.id).await { - views.push(view); + views.push(SectionViewPB { + item: view, + timestamp: item.timestamp, + }); } } - data_result_ok(RepeatedViewPB { items: views }) + data_result_ok(RepeatedFavoriteViewPB { items: views }) } #[tracing::instrument(level = "debug", skip(folder), err)] pub(crate) async fn read_recent_views_handler( + data: AFPluginData, folder: AFPluginState>, -) -> DataResult { +) -> DataResult { let folder = upgrade_folder(folder)?; let recent_items = folder.get_my_recent_sections().await; - let mut views = vec![]; - for item in recent_items { - if let Ok(view) = folder.get_view_pb(&item.id).await { - views.push(view); - } - } - data_result_ok(RepeatedViewPB { items: views }) + let start = data.start; + let limit = data.limit; + let ids = recent_items + .iter() + .rev() // the most recent view is at the end of the list + .map(|item| item.id.clone()) + .skip(start as usize) + .take(limit as usize) + .collect::>(); + let views = folder.get_view_pbs_without_children(ids).await?; + let items = views + .into_iter() + .zip(recent_items.into_iter().rev()) + .map(|(view, item)| SectionViewPB { + item: view, + timestamp: item.timestamp, + }) + .collect::>(); + data_result_ok(RepeatedRecentViewPB { items }) } #[tracing::instrument(level = "debug", skip(folder), err)] @@ -357,14 +353,24 @@ pub(crate) async fn delete_my_trash_handler( #[tracing::instrument(level = "debug", skip(data, folder), err)] pub(crate) async fn import_data_handler( - data: AFPluginData, + data: AFPluginData, folder: AFPluginState>, -) -> DataResult { +) -> DataResult { let folder = upgrade_folder(folder)?; let params: ImportParams = data.into_inner().try_into()?; - let view = folder.import(params).await?; - let view_pb = view_pb_without_child_views(view); - data_result_ok(view_pb) + let views = folder.import(params).await?; + data_result_ok(views) +} + +#[tracing::instrument(level = "debug", skip(data, folder), err)] +pub(crate) async fn import_zip_file_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let folder = upgrade_folder(folder)?; + let data = data.try_into_inner()?; + folder.import_zip_file(&data.file_path).await?; + Ok(()) } #[tracing::instrument(level = "debug", skip(folder), err)] @@ -385,6 +391,153 @@ pub(crate) async fn update_view_visibility_status_handler( ) -> Result<(), FlowyError> { let folder = upgrade_folder(folder)?; let params = data.into_inner(); - folder.set_views_visibility(params.view_ids, params.is_public); + folder + .set_views_visibility(params.view_ids, params.is_public) + .await; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(data, folder), err)] +pub(crate) async fn publish_view_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let folder = upgrade_folder(folder)?; + let params = data.into_inner(); + let selected_view_ids = params.selected_view_ids.map(|ids| ids.items); + folder + .publish_view( + params.view_id.as_str(), + params.publish_name, + selected_view_ids, + ) + .await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(data, folder), err)] +pub(crate) async fn unpublish_views_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let folder = upgrade_folder(folder)?; + let params = data.into_inner(); + let view_ids = params + .view_ids + .into_iter() + .flat_map(|id| Uuid::from_str(&id).ok()) + .collect::>(); + folder.unpublish_views(view_ids).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(data, folder))] +pub(crate) async fn get_publish_info_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> DataResult { + let folder = upgrade_folder(folder)?; + let view_id = data.into_inner().value; + let view_id = Uuid::from_str(&view_id)?; + let info = folder.get_publish_info(&view_id).await?; + data_result_ok(PublishInfoResponsePB::from(info)) +} + +#[tracing::instrument(level = "debug", skip(data, folder))] +pub(crate) async fn set_publish_name_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let folder = upgrade_folder(folder)?; + let SetPublishNamePB { view_id, new_name } = data.into_inner(); + let view_id = Uuid::from_str(&view_id)?; + folder.set_publish_name(view_id, new_name).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(data, folder), err)] +pub(crate) async fn set_publish_namespace_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let folder = upgrade_folder(folder)?; + let namespace = data.into_inner().new_namespace; + folder.set_publish_namespace(namespace).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(folder), err)] +pub(crate) async fn get_publish_namespace_handler( + folder: AFPluginState>, +) -> DataResult { + let folder = upgrade_folder(folder)?; + let namespace = folder.get_publish_namespace().await?; + data_result_ok(PublishNamespacePB { namespace }) +} + +#[tracing::instrument(level = "debug", skip(folder), err)] +pub(crate) async fn list_published_views_handler( + folder: AFPluginState>, +) -> DataResult { + let folder = upgrade_folder(folder)?; + let published_views = folder.list_published_views().await?; + let items: Vec = published_views + .into_iter() + .map(|view| view.into()) + .collect(); + data_result_ok(RepeatedPublishInfoViewPB { items }) +} + +#[tracing::instrument(level = "debug", skip(folder))] +pub(crate) async fn get_default_publish_info_handler( + folder: AFPluginState>, +) -> DataResult { + let folder = upgrade_folder(folder)?; + let default_published_view = folder.get_default_published_view_info().await?; + data_result_ok(default_published_view.into()) +} + +#[tracing::instrument(level = "debug", skip(folder))] +pub(crate) async fn set_default_publish_view_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let folder = upgrade_folder(folder)?; + let view_id: uuid::Uuid = data.into_inner().value.parse().map_err(|err| { + tracing::error!("Failed to parse view id: {}", err); + FlowyError::invalid_data() + })?; + folder.set_default_published_view(view_id).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(folder))] +pub(crate) async fn remove_default_publish_view_handler( + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let folder = upgrade_folder(folder)?; + folder.remove_default_published_view().await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(data, folder))] +pub(crate) async fn lock_view_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let folder = upgrade_folder(folder)?; + let view_id = data.into_inner().value; + folder.lock_view(&view_id).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(data, folder))] +pub(crate) async fn unlock_view_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let folder = upgrade_folder(folder)?; + let view_id = data.into_inner().value; + folder.unlock_view(&view_id).await?; Ok(()) } diff --git a/frontend/rust-lib/flowy-folder/src/event_map.rs b/frontend/rust-lib/flowy-folder/src/event_map.rs index 31034bd143..c857353c4b 100644 --- a/frontend/rust-lib/flowy-folder/src/event_map.rs +++ b/frontend/rust-lib/flowy-folder/src/event_map.rs @@ -11,7 +11,6 @@ use crate::manager::FolderManager; pub fn init(folder: Weak) -> AFPlugin { AFPlugin::new().name("Flowy-Folder").state(folder) // Workspace - .event(FolderEvent::CreateFolderWorkspace, create_workspace_handler) .event(FolderEvent::GetCurrentWorkspaceSetting, read_current_workspace_setting_handler) .event(FolderEvent::ReadCurrentWorkspace, read_current_workspace_handler) .event(FolderEvent::ReadWorkspaceViews, get_workspace_views_handler) @@ -32,6 +31,7 @@ pub fn init(folder: Weak) -> AFPlugin { .event(FolderEvent::RecoverAllTrashItems, restore_all_trash_handler) .event(FolderEvent::PermanentlyDeleteAllTrashItem, delete_my_trash_handler) .event(FolderEvent::ImportData, import_data_handler) + .event(FolderEvent::ImportZipFile, import_zip_file_handler) .event(FolderEvent::GetFolderSnapshots, get_folder_snapshots_handler) .event(FolderEvent::UpdateViewIcon, update_view_icon_handler) .event(FolderEvent::ReadFavorites, read_favorites_handler) @@ -42,17 +42,28 @@ pub fn init(folder: Weak) -> AFPlugin { .event(FolderEvent::ReadCurrentWorkspaceViews, get_current_workspace_views_handler) .event(FolderEvent::UpdateViewVisibilityStatus, update_view_visibility_status_handler) .event(FolderEvent::GetViewAncestors, get_view_ancestors_handler) + .event(FolderEvent::PublishView, publish_view_handler) + .event(FolderEvent::GetPublishInfo, get_publish_info_handler) + .event(FolderEvent::SetPublishName, set_publish_name_handler) + .event(FolderEvent::UnpublishViews, unpublish_views_handler) + .event(FolderEvent::SetPublishNamespace, set_publish_namespace_handler) + .event(FolderEvent::GetPublishNamespace, get_publish_namespace_handler) + .event(FolderEvent::ListPublishedViews, list_published_views_handler) + .event(FolderEvent::GetDefaultPublishInfo, get_default_publish_info_handler) + .event(FolderEvent::SetDefaultPublishView, set_default_publish_view_handler) + .event(FolderEvent::RemoveDefaultPublishView, remove_default_publish_view_handler) + .event(FolderEvent::LockView, lock_view_handler) + .event(FolderEvent::UnlockView, unlock_view_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] #[event_err = "FlowyError"] pub enum FolderEvent { - /// Create a new workspace - #[event(input = "CreateWorkspacePayloadPB", output = "WorkspacePB")] + /// Deprecated: Create a new workspace CreateFolderWorkspace = 0, /// Read the current opening workspace. Currently, we only support one workspace - #[event(output = "WorkspaceSettingPB")] + #[event(output = "WorkspaceLatestPB")] GetCurrentWorkspaceSetting = 1, /// Return a list of workspaces that the current user can access. @@ -85,7 +96,7 @@ pub enum FolderEvent { DeleteView = 13, /// Duplicate the view - #[event(input = "ViewPB")] + #[event(input = "DuplicateViewPayloadPB", output = "ViewPB")] DuplicateView = 14, /// Close and release the resources that are used by this view. @@ -132,7 +143,7 @@ pub enum FolderEvent { #[event()] PermanentlyDeleteAllTrashItem = 27, - #[event(input = "ImportPB", output = "ViewPB")] + #[event(input = "ImportPayloadPB", output = "RepeatedViewPB")] ImportData = 30, #[event(input = "WorkspaceIdPB", output = "RepeatedFolderSnapshotPB")] @@ -146,7 +157,7 @@ pub enum FolderEvent { #[event(input = "MoveNestedViewPayloadPB")] MoveNestedView = 32, - #[event(output = "RepeatedViewPB")] + #[event(output = "RepeatedFavoriteViewPB")] ReadFavorites = 33, #[event(input = "RepeatedViewIdPB")] @@ -155,7 +166,7 @@ pub enum FolderEvent { #[event(input = "UpdateViewIconPayloadPB")] UpdateViewIcon = 35, - #[event(output = "RepeatedViewPB")] + #[event(input = "ReadRecentViewsPB", output = "RepeatedRecentViewPB")] ReadRecentViews = 36, // used for add or remove recent views, like history @@ -176,4 +187,43 @@ pub enum FolderEvent { /// Return the ancestors of the view #[event(input = "ViewIdPB", output = "RepeatedViewPB")] GetViewAncestors = 42, + + #[event(input = "PublishViewParamsPB")] + PublishView = 43, + + #[event(input = "ViewIdPB", output = "PublishInfoResponsePB")] + GetPublishInfo = 44, + + #[event(output = "PublishNamespacePB")] + GetPublishNamespace = 45, + + #[event(input = "SetPublishNamespacePayloadPB")] + SetPublishNamespace = 46, + + #[event(input = "UnpublishViewsPayloadPB")] + UnpublishViews = 47, + + #[event(input = "ImportZipPB")] + ImportZipFile = 48, + + #[event(output = "RepeatedPublishInfoViewPB")] + ListPublishedViews = 49, + + #[event(output = "PublishInfoResponsePB")] + GetDefaultPublishInfo = 50, + + #[event(input = "ViewIdPB")] + SetDefaultPublishView = 51, + + #[event(input = "SetPublishNamePB")] + SetPublishName = 52, + + #[event()] + RemoveDefaultPublishView = 53, + + #[event(input = "ViewIdPB")] + LockView = 54, + + #[event(input = "ViewIdPB")] + UnlockView = 55, } diff --git a/frontend/rust-lib/flowy-folder/src/lib.rs b/frontend/rust-lib/flowy-folder/src/lib.rs index bc927d20c7..d08b94dbfa 100644 --- a/frontend/rust-lib/flowy-folder/src/lib.rs +++ b/frontend/rust-lib/flowy-folder/src/lib.rs @@ -11,10 +11,7 @@ pub mod view_operation; mod manager_init; mod manager_observer; -#[cfg(debug_assertions)] -pub mod manager_test_util; +pub mod publish_util; pub mod share; -#[cfg(feature = "test_helper")] -mod test_helper; mod util; diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index 2433a181a5..37533ae500 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -1,99 +1,149 @@ use crate::entities::icon::UpdateViewIconParams; use crate::entities::{ view_pb_with_child_views, view_pb_without_child_views, view_pb_without_child_views_from_arc, - CreateViewParams, CreateWorkspaceParams, DeletedViewPB, FolderSnapshotPB, MoveNestedViewParams, - RepeatedTrashPB, RepeatedViewIdPB, RepeatedViewPB, UpdateViewParams, ViewPB, ViewSectionPB, - WorkspacePB, WorkspaceSettingPB, + CreateViewParams, DeletedViewPB, DuplicateViewParams, FolderSnapshotPB, MoveNestedViewParams, + RepeatedTrashPB, RepeatedViewIdPB, RepeatedViewPB, UpdateViewParams, ViewLayoutPB, ViewPB, + ViewSectionPB, WorkspaceLatestPB, WorkspacePB, }; use crate::manager_observer::{ notify_child_views_changed, notify_did_update_workspace, notify_parent_view_did_change, ChildViewChangeReason, }; use crate::notification::{ - send_notification, send_workspace_setting_notification, FolderNotification, + folder_notification_builder, send_current_workspace_notification, FolderNotification, }; -use crate::share::ImportParams; -use crate::util::{ - folder_not_init_error, insert_parent_child_views, workspace_data_not_sync_error, +use crate::publish_util::{generate_publish_name, view_pb_to_publish_view}; +use crate::share::{ImportData, ImportItem, ImportParams}; +use crate::util::{folder_not_init_error, workspace_data_not_sync_error}; +use crate::view_operation::{ + create_view, FolderOperationHandler, FolderOperationHandlers, GatherEncodedCollab, ViewData, }; -use crate::view_operation::{create_view, FolderOperationHandler, FolderOperationHandlers}; -use collab::core::collab::{DataSource, MutexCollab}; -use collab_entity::CollabType; -use collab_folder::error::FolderError; +use arc_swap::ArcSwapOption; +use client_api::entity::workspace_dto::PublishInfoView; +use client_api::entity::PublishInfo; +use collab::core::collab::DataSource; +use collab::lock::RwLock; +use collab_entity::{CollabType, EncodedCollab}; +use collab_folder::hierarchy_builder::{ParentChildViews, ViewExtraBuilder}; use collab_folder::{ - Folder, FolderNotify, Section, SectionItem, TrashInfo, UserId, View, ViewLayout, ViewUpdate, + Folder, FolderData, FolderNotify, Section, SectionItem, TrashInfo, View, ViewLayout, ViewUpdate, Workspace, }; -use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabBuilderConfig}; +use collab_integrate::collab_builder::{ + AppFlowyCollabBuilder, CollabBuilderConfig, CollabPersistenceImpl, +}; use collab_integrate::CollabKVDB; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_folder_pub::cloud::{gen_view_id, FolderCloudService}; -use flowy_folder_pub::folder_builder::ParentChildViews; +use flowy_error::{internal_error, ErrorCode, FlowyError, FlowyResult}; +use flowy_folder_pub::cloud::{gen_view_id, FolderCloudService, FolderCollabParams}; +use flowy_folder_pub::entities::{ + PublishDatabaseData, PublishDatabasePayload, PublishDocumentPayload, PublishPayload, + PublishViewInfo, PublishViewMeta, PublishViewMetaData, +}; use flowy_search_pub::entities::FolderIndexManager; -use parking_lot::RwLock; +use flowy_sqlite::kv::KVStorePreferences; +use futures::future; +use std::collections::HashMap; use std::fmt::{Display, Formatter}; -use std::ops::Deref; +use std::str::FromStr; use std::sync::{Arc, Weak}; +use tokio::sync::RwLockWriteGuard; use tracing::{error, info, instrument}; +use uuid::Uuid; pub trait FolderUser: Send + Sync { fn user_id(&self) -> Result; - fn workspace_id(&self) -> Result; + fn workspace_id(&self) -> Result; fn collab_db(&self, uid: i64) -> Result, FlowyError>; + + fn is_folder_exist_on_disk(&self, uid: i64, workspace_id: &Uuid) -> FlowyResult; } pub struct FolderManager { - /// MutexFolder is the folder that is used to store the data. - pub(crate) mutex_folder: Arc, + pub(crate) mutex_folder: ArcSwapOption>, pub(crate) collab_builder: Arc, pub(crate) user: Arc, pub(crate) operation_handlers: FolderOperationHandlers, pub cloud_service: Arc, pub(crate) folder_indexer: Arc, + pub(crate) store_preferences: Arc, } impl FolderManager { - pub async fn new( + pub fn new( user: Arc, collab_builder: Arc, - operation_handlers: FolderOperationHandlers, cloud_service: Arc, folder_indexer: Arc, + store_preferences: Arc, ) -> FlowyResult { - let mutex_folder = Arc::new(MutexFolder::default()); let manager = Self { user, - mutex_folder, + mutex_folder: Default::default(), collab_builder, - operation_handlers, + operation_handlers: Default::default(), cloud_service, folder_indexer, + store_preferences, }; Ok(manager) } + pub fn register_operation_handler( + &self, + layout: ViewLayout, + handler: Arc, + ) { + self.operation_handlers.insert(layout, handler); + } + #[instrument(level = "debug", skip(self), err)] pub async fn get_current_workspace(&self) -> FlowyResult { let workspace_id = self.user.workspace_id()?; - self.with_folder( - || { + match self.mutex_folder.load_full() { + None => { let uid = self.user.user_id()?; Err(workspace_data_not_sync_error(uid, &workspace_id)) }, - |folder| { + Some(lock) => { + let folder = lock.read().await; let workspace_pb_from_workspace = |workspace: Workspace, folder: &Folder| { let views = get_workspace_public_view_pbs(&workspace_id, folder); let workspace: WorkspacePB = (workspace, views).into(); Ok::(workspace) }; - match folder.get_workspace_info(&workspace_id) { + match folder.get_workspace_info(&workspace_id.to_string()) { None => Err(FlowyError::record_not_found().with_context("Can not find the workspace")), - Some(workspace) => workspace_pb_from_workspace(workspace, folder), + Some(workspace) => workspace_pb_from_workspace(workspace, &folder), } }, - ) + } + } + + pub async fn get_folder_data(&self) -> FlowyResult { + let workspace_id = self.user.workspace_id()?; + let data = self + .mutex_folder + .load_full() + .ok_or_else(|| internal_error("The folder is not initialized"))? + .read() + .await + .get_folder_data(&workspace_id.to_string()) + .ok_or_else(|| internal_error("Workspace id not match the id in current folder"))?; + Ok(data) + } + + pub async fn gather_publish_encode_collab( + &self, + view_id: &Uuid, + layout: &ViewLayout, + ) -> FlowyResult { + let handler = self.get_handler(layout)?; + let encoded_collab = handler + .gather_publish_encode_collab(&self.user, view_id) + .await?; + Ok(encoded_collab) } /// Return a list of views of the current workspace. @@ -105,119 +155,127 @@ impl FolderManager { pub async fn get_workspace_public_views(&self) -> FlowyResult> { let workspace_id = self.user.workspace_id()?; - Ok(self.with_folder(Vec::new, |folder| { - get_workspace_public_view_pbs(&workspace_id, folder) - })) + match self.mutex_folder.load_full() { + None => Ok(Vec::default()), + Some(lock) => { + let folder = lock.read().await; + Ok(get_workspace_public_view_pbs(&workspace_id, &folder)) + }, + } } pub async fn get_workspace_private_views(&self) -> FlowyResult> { let workspace_id = self.user.workspace_id()?; - Ok(self.with_folder(Vec::new, |folder| { - get_workspace_private_view_pbs(&workspace_id, folder) - })) + match self.mutex_folder.load_full() { + None => Ok(Vec::default()), + Some(folder) => { + let folder = folder.read().await; + Ok(get_workspace_private_view_pbs(&workspace_id, &folder)) + }, + } } #[instrument(level = "trace", skip_all, err)] pub(crate) async fn make_folder>>( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, collab_db: Weak, - doc_state: DataSource, + data_source: Option, folder_notifier: T, - ) -> Result { + ) -> Result>, FlowyError> { let folder_notifier = folder_notifier.into(); // only need the check the workspace id when the doc state is not from the disk. - let should_check_workspace_id = !matches!(doc_state, DataSource::Disk); - let should_auto_initialize = !should_check_workspace_id; - let config = CollabBuilderConfig::default() - .sync_enable(true) - .auto_initialize(should_auto_initialize); + let config = CollabBuilderConfig::default().sync_enable(true); + + let data_source = data_source.unwrap_or_else(|| { + CollabPersistenceImpl::new(collab_db.clone(), uid, *workspace_id).into_data_source() + }); let object_id = workspace_id; - let collab = self.collab_builder.build_with_config( - workspace_id, - uid, - object_id, - CollabType::Folder, - collab_db, - doc_state, - config, - )?; - let (should_clear, err) = match Folder::open(UserId::from(uid), collab.clone(), folder_notifier) - { - Ok(folder) => { - if should_check_workspace_id { - // check the workspace id in the folder is matched with the workspace id. Just in case the folder - // is overwritten by another workspace. - let folder_workspace_id = folder.get_workspace_id(); - if folder_workspace_id != workspace_id { - error!( - "expect workspace_id: {}, actual workspace_id: {}", - workspace_id, folder_workspace_id - ); - return Err(FlowyError::workspace_data_not_match()); - } - // Initialize the folder manually - collab.lock().initialize(); - } - return Ok(folder); - }, - Err(err) => (matches!(err, FolderError::NoRequiredData(_)), err), - }; + let collab_object = + self + .collab_builder + .collab_object(workspace_id, uid, object_id, CollabType::Folder)?; + let result = self + .collab_builder + .create_folder( + collab_object, + data_source, + collab_db, + config, + folder_notifier, + None, + ) + .await; // If opening the folder fails due to missing required data (indicated by a `FolderError::NoRequiredData`), // the function logs an informational message and attempts to clear the folder data by deleting its // document from the collaborative database. It then returns the encountered error. - if should_clear { - info!("Clear the folder data and try to open the folder again"); - if let Some(db) = self.user.collab_db(uid).ok().and_then(|a| a.upgrade()) { - let _ = db.delete_doc(uid, workspace_id).await; - } + match result { + Ok(folder) => Ok(folder), + Err(err) => { + info!( + "Clear the folder data and try to open the folder again due to: {}", + err + ); + + if let Some(db) = self.user.collab_db(uid).ok().and_then(|a| a.upgrade()) { + let _ = db + .delete_doc(uid, &workspace_id.to_string(), &object_id.to_string()) + .await; + } + Err(err.into()) + }, } - Err(err.into()) } - pub(crate) async fn create_empty_collab( + pub(crate) async fn create_folder_with_data( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, collab_db: Weak, - ) -> Result, FlowyError> { + notifier: Option, + folder_data: Option, + ) -> Result>, FlowyError> { let object_id = workspace_id; - let collab = self.collab_builder.build_with_config( - workspace_id, - uid, - object_id, - CollabType::Folder, - collab_db, - DataSource::Disk, - CollabBuilderConfig::default().sync_enable(true), - )?; - Ok(collab) + let collab_object = + self + .collab_builder + .collab_object(workspace_id, uid, object_id, CollabType::Folder)?; + + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), uid, *workspace_id).into_data_source(); + let folder = self + .collab_builder + .create_folder( + collab_object, + doc_state, + collab_db, + CollabBuilderConfig::default().sync_enable(true), + notifier, + folder_data, + ) + .await?; + Ok(folder) } /// Initialize the folder with the given workspace id. /// Fetch the folder updates from the cloud service and initialize the folder. - #[tracing::instrument(skip(self, user_id), err)] - pub async fn initialize_with_workspace_id(&self, user_id: i64) -> FlowyResult<()> { + #[tracing::instrument(skip_all, err)] + pub async fn initialize_after_sign_in( + &self, + user_id: i64, + data_source: FolderInitDataSource, + ) -> FlowyResult<()> { let workspace_id = self.user.workspace_id()?; - let object_id = &workspace_id; - let folder_doc_state = self - .cloud_service - .get_folder_doc_state(&workspace_id, user_id, CollabType::Folder, object_id) - .await?; - if let Err(err) = self - .initialize( - user_id, - &workspace_id, - FolderInitDataSource::Cloud(folder_doc_state), - ) - .await - { + if let Err(err) = self.initialize(user_id, &workspace_id, data_source).await { // If failed to open folder with remote data, open from local disk. After open from the local // disk. the data will be synced to the remote server. - error!("initialize folder with error {:?}, fallback local", err); + error!( + "initialize folder for user {} with workspace {} encountered error: {:?}, fallback local", + user_id, workspace_id, err + ); self .initialize( user_id, @@ -228,19 +286,28 @@ impl FolderManager { ) .await?; } + Ok(()) } + pub async fn initialize_after_open_workspace( + &self, + uid: i64, + data_source: FolderInitDataSource, + ) -> FlowyResult<()> { + self.initialize_after_sign_in(uid, data_source).await + } + /// Initialize the folder for the new user. /// Using the [DefaultFolderBuilder] to create the default workspace for the new user. #[instrument(level = "info", skip_all, err)] - pub async fn initialize_with_new_user( + pub async fn initialize_after_sign_up( &self, user_id: i64, _token: &str, is_new: bool, data_source: FolderInitDataSource, - workspace_id: &str, + workspace_id: &Uuid, ) -> FlowyResult<()> { // Create the default workspace if the user is new info!("initialize_when_sign_up: is_new: {}", is_new); @@ -286,59 +353,136 @@ impl FolderManager { /// pub async fn clear(&self, _user_id: i64) {} - #[tracing::instrument(level = "info", skip_all, err)] - pub async fn create_workspace(&self, params: CreateWorkspaceParams) -> FlowyResult { - let uid = self.user.user_id()?; - let new_workspace = self - .cloud_service - .create_workspace(uid, ¶ms.name) - .await?; - Ok(new_workspace) - } - - pub async fn get_workspace_setting_pb(&self) -> FlowyResult { + pub async fn get_workspace_setting_pb(&self) -> FlowyResult { let workspace_id = self.user.workspace_id()?; let latest_view = self.get_current_view().await; - Ok(WorkspaceSettingPB { - workspace_id, + Ok(WorkspaceLatestPB { + workspace_id: workspace_id.to_string(), latest_view, }) } - pub async fn insert_parent_child_views( + /// All the views will become a space under the workspace. + pub async fn insert_views_as_spaces( &self, - views: Vec, + mut views: Vec, + orphan_views: Vec, ) -> Result<(), FlowyError> { - self.with_folder( - || Err(FlowyError::internal().with_context("The folder is not initialized")), - |folder| { - for view in views { - insert_parent_child_views(folder, view); - } - Ok(()) - }, - )?; + let lock = self + .mutex_folder + .load_full() + .ok_or_else(|| FlowyError::internal().with_context("The folder is not initialized"))?; + let mut folder = lock.write().await; + let workspace_id = folder + .get_workspace_id() + .ok_or_else(|| FlowyError::internal().with_context("Cannot find the workspace ID"))?; + views.iter_mut().for_each(|view| { + view.view.parent_view_id.clone_from(&workspace_id); + view.view.extra = + Some(serde_json::to_string(&ViewExtraBuilder::new().is_space(true).build()).unwrap()); + }); + let all_views = views.into_iter().chain(orphan_views.into_iter()).collect(); + folder.insert_nested_views(all_views); + + Ok(()) + } + + /// Inserts parent-child views into the folder. If a `parent_view_id` is provided, + /// it will be used to set the `parent_view_id` for all child views. If not, the latest + /// view (by `last_edited_time`) from the workspace will be used as the parent view. + /// + #[instrument(level = "info", skip_all, err)] + pub async fn insert_views_with_parent( + &self, + mut views: Vec, + orphan_views: Vec, + parent_view_id: Option, + ) -> Result<(), FlowyError> { + let lock = self + .mutex_folder + .load_full() + .ok_or_else(|| FlowyError::internal().with_context("The folder is not initialized"))?; + + // Obtain a write lock on the folder. + let mut folder = lock.write().await; + let parent_view_id = parent_view_id.as_deref().filter(|id| !id.is_empty()); + // Set the parent view ID for the child views. + if let Some(parent_view_id) = parent_view_id { + // If a valid parent_view_id is provided, set it for each child view. + if folder.get_view(parent_view_id).is_some() { + info!( + "[AppFlowyData]: Attach parent-child views with the latest view: {:?}", + parent_view_id + ); + views.iter_mut().for_each(|child_view| { + child_view.view.parent_view_id = parent_view_id.to_string(); + }); + } else { + error!( + "[AppFlowyData]: The provided parent_view_id: {} is not found in the folder", + parent_view_id + ); + Self::insert_into_latest_view(&mut views, &mut folder)?; + } + } else { + // If no parent_view_id is provided, find the latest view in the workspace. + Self::insert_into_latest_view(&mut views, &mut folder)?; + } + + // Insert the views into the folder. + let all_views = views.into_iter().chain(orphan_views.into_iter()).collect(); + folder.insert_nested_views(all_views); + Ok(()) + } + + #[instrument(level = "info", skip_all, err)] + fn insert_into_latest_view( + views: &mut [ParentChildViews], + folder: &mut RwLockWriteGuard, + ) -> Result<(), FlowyError> { + let workspace_id = folder + .get_workspace_id() + .ok_or_else(|| FlowyError::internal().with_context("Cannot find the workspace ID"))?; + + // Get the latest view based on the last_edited_time in the workspace. + match folder + .get_views_belong_to(&workspace_id) + .iter() + .max_by_key(|view| view.last_edited_time) + { + None => info!("[AppFlowyData]: No views found in the workspace"), + Some(latest_view) => { + info!( + "[AppFlowyData]: Attach parent-child views with the latest view: {}:{}, is_space: {:?}", + latest_view.id, + latest_view.name, + latest_view.space_info(), + ); + views.iter_mut().for_each(|child_view| { + child_view.view.parent_view_id.clone_from(&latest_view.id); + }); + }, + } Ok(()) } pub async fn get_workspace_pb(&self) -> FlowyResult { let workspace_id = self.user.workspace_id()?; - let guard = self.mutex_folder.read(); - let folder = guard - .as_ref() - .ok_or(FlowyError::internal().with_context("folder is not initialized"))?; + let lock = self + .mutex_folder + .load_full() + .ok_or_else(|| FlowyError::internal().with_context("folder is not initialized"))?; + let folder = lock.read().await; let workspace = folder - .get_workspace_info(&workspace_id) + .get_workspace_info(&workspace_id.to_string()) .ok_or_else(|| FlowyError::record_not_found().with_context("Can not find the workspace"))?; let views = folder - .views .get_views_belong_to(&workspace.id) .into_iter() .map(|view| view_pb_without_child_views(view.as_ref().clone())) .collect::>(); - drop(guard); Ok(WorkspacePB { id: workspace.id, @@ -348,71 +492,61 @@ impl FolderManager { }) } - /// This function acquires a lock on the `mutex_folder` and checks its state. - /// If the folder is `None`, it invokes the `none_callback`, otherwise, it passes the folder to the `f2` callback. + /// Asynchronously creates a view with provided parameters and notifies the workspace if update is needed. /// - /// # Parameters - /// - /// * `none_callback`: A callback function that is invoked when `mutex_folder` contains `None`. - /// * `f2`: A callback function that is invoked when `mutex_folder` contains a `Some` value. The contained folder is passed as an argument to this callback. - fn with_folder(&self, none_callback: F1, f2: F2) -> Output - where - F1: FnOnce() -> Output, - F2: FnOnce(&Folder) -> Output, - { - let folder = self.mutex_folder.read(); - match &*folder { - None => none_callback(), - Some(folder) => f2(folder), - } - } - - pub async fn create_view_with_params(&self, params: CreateViewParams) -> FlowyResult { + /// Commonly, the notify_workspace_update parameter is set to true when the view is created in the workspace. + /// If you're handling multiple views in the same hierarchy and want to notify the workspace only after the last view is created, + /// you can set notify_workspace_update to false to avoid multiple notifications. + pub async fn create_view_with_params( + &self, + params: CreateViewParams, + notify_workspace_update: bool, + ) -> FlowyResult<(View, Option)> { let workspace_id = self.user.workspace_id()?; let view_layout: ViewLayout = params.layout.clone().into(); let handler = self.get_handler(&view_layout)?; let user_id = self.user.user_id()?; - let meta = params.meta.clone(); + let mut encoded_collab: Option = None; - if meta.is_empty() && params.initial_data.is_empty() { - tracing::trace!("Create view with build-in data"); + info!( + "{} create view {}, name:{}, layout:{:?}", + handler.name(), + params.view_id, + params.name, + params.layout + ); + if params.meta.is_empty() && params.initial_data.is_empty() { handler - .create_built_in_view(user_id, ¶ms.view_id, ¶ms.name, view_layout.clone()) - .await?; - } else { - tracing::trace!("Create view with view data"); - handler - .create_view_with_view_data( + .create_default_view( user_id, + ¶ms.parent_view_id, ¶ms.view_id, ¶ms.name, - params.initial_data.clone(), view_layout.clone(), - meta, ) .await?; + } else { + encoded_collab = handler + .create_view_with_view_data(user_id, params.clone()) + .await?; } let index = params.index; let section = params.section.clone().unwrap_or(ViewSectionPB::Public); let is_private = section == ViewSectionPB::Private; let view = create_view(self.user.user_id()?, params, view_layout); - self.with_folder( - || (), - |folder| { - folder.insert_view(view.clone(), index); - if is_private { - folder.add_private_view_ids(vec![view.id.clone()]); - } - }, - ); - - let folder = &self.mutex_folder.read(); - if let Some(folder) = folder.as_ref() { - notify_did_update_workspace(&workspace_id, folder); + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + folder.insert_view(view.clone(), index); + if is_private { + folder.add_private_view_ids(vec![view.id.clone()]); + } + if notify_workspace_update { + notify_did_update_workspace(&workspace_id, &folder); + } } - Ok(view) + Ok((view, encoded_collab)) } /// The orphan view is meant to be a view that is not attached to any parent view. By default, this @@ -427,24 +561,35 @@ impl FolderManager { let handler = self.get_handler(&view_layout)?; let user_id = self.user.user_id()?; handler - .create_built_in_view(user_id, ¶ms.view_id, ¶ms.name, view_layout.clone()) + .create_default_view( + user_id, + ¶ms.parent_view_id, + ¶ms.view_id, + ¶ms.name, + view_layout.clone(), + ) .await?; let view = create_view(self.user.user_id()?, params, view_layout); - self.with_folder( - || (), - |folder| { - folder.insert_view(view.clone(), None); - }, - ); + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + folder.insert_view(view.clone(), None); + } Ok(view) } #[tracing::instrument(level = "debug", skip(self), err)] pub(crate) async fn close_view(&self, view_id: &str) -> Result<(), FlowyError> { - if let Some(view) = self.with_folder(|| None, |folder| folder.views.get_view(view_id)) { - let handler = self.get_handler(&view.layout)?; - handler.close_view(view_id).await?; + if let Some(lock) = self.mutex_folder.load_full() { + let folder = lock.read().await; + if let Some(view) = folder.get_view(view_id) { + // Drop the folder lock explicitly to avoid deadlock when following calls contains 'self' + drop(folder); + + let view_id = Uuid::from_str(view_id)?; + let handler = self.get_handler(&view.layout)?; + handler.close_view(&view_id).await?; + } } Ok(()) } @@ -460,11 +605,14 @@ impl FolderManager { pub async fn get_view_pb(&self, view_id: &str) -> FlowyResult { let view_id = view_id.to_string(); - let folder = self.mutex_folder.read(); - let folder = folder.as_ref().ok_or_else(folder_not_init_error)?; + let lock = self + .mutex_folder + .load_full() + .ok_or_else(folder_not_init_error)?; + let folder = lock.read().await; // trash views and other private views should not be accessed - let view_ids_should_be_filtered = self.get_view_ids_should_be_filtered(folder); + let view_ids_should_be_filtered = Self::get_view_ids_should_be_filtered(&folder); if view_ids_should_be_filtered.contains(&view_id) { return Err(FlowyError::new( @@ -473,14 +621,13 @@ impl FolderManager { )); } - match folder.views.get_view(&view_id) { + match folder.get_view(&view_id) { None => { error!("Can't find the view with id: {}", view_id); Err(FlowyError::record_not_found()) }, Some(view) => { let child_views = folder - .views .get_views_belong_to(&view.id) .into_iter() .filter(|view| !view_ids_should_be_filtered.contains(&view.id)) @@ -491,6 +638,41 @@ impl FolderManager { } } + /// Retrieves the views corresponding to the specified view IDs. + /// + /// It is important to note that if the target view contains child views, + /// this method only provides access to the first level of child views. + /// + /// Therefore, to access a nested child view within one of the initial child views, you must invoke this method + /// again using the ID of the child view you wish to access. + #[tracing::instrument(level = "debug", skip(self))] + pub async fn get_view_pbs_without_children( + &self, + view_ids: Vec, + ) -> FlowyResult> { + let lock = self + .mutex_folder + .load_full() + .ok_or_else(folder_not_init_error)?; + + // trash views and other private views should not be accessed + let folder = lock.read().await; + let view_ids_should_be_filtered = Self::get_view_ids_should_be_filtered(&folder); + + let views = view_ids + .into_iter() + .filter_map(|view_id| { + if view_ids_should_be_filtered.contains(&view_id) { + return None; + } + folder.get_view(&view_id) + }) + .map(view_pb_without_child_views_from_arc) + .collect::>(); + + Ok(views) + } + /// Retrieves all views. /// /// It is important to note that this will return a flat map of all views, @@ -499,13 +681,16 @@ impl FolderManager { /// #[tracing::instrument(level = "debug", skip(self))] pub async fn get_all_views_pb(&self) -> FlowyResult> { - let folder = self.mutex_folder.read(); - let folder = folder.as_ref().ok_or_else(folder_not_init_error)?; + let lock = self + .mutex_folder + .load_full() + .ok_or_else(folder_not_init_error)?; // trash views and other private views should not be accessed - let view_ids_should_be_filtered = self.get_view_ids_should_be_filtered(folder); + let folder = lock.read().await; + let view_ids_should_be_filtered = Self::get_view_ids_should_be_filtered(&folder); - let all_views = folder.views.get_all_views(); + let all_views = folder.get_all_views(); let views = all_views .into_iter() .filter(|view| !view_ids_should_be_filtered.contains(&view.id)) @@ -527,13 +712,18 @@ impl FolderManager { pub async fn get_view_ancestors_pb(&self, view_id: &str) -> FlowyResult> { let mut ancestors = vec![]; let mut parent_view_id = view_id.to_string(); - while let Some(view) = - self.with_folder(|| None, |folder| folder.views.get_view(&parent_view_id)) - { - ancestors.push(view_pb_without_child_views(view.as_ref().clone())); - parent_view_id = view.parent_view_id.clone(); + if let Some(lock) = self.mutex_folder.load_full() { + let folder = lock.read().await; + while let Some(view) = folder.get_view(&parent_view_id) { + // If the view is already in the ancestors list, then break the loop + if ancestors.iter().any(|v: &ViewPB| v.id == view.id) { + break; + } + ancestors.push(view_pb_without_child_views(view.as_ref().clone())); + parent_view_id.clone_from(&view.parent_view_id); + } + ancestors.reverse(); } - ancestors.reverse(); Ok(ancestors) } @@ -542,34 +732,52 @@ impl FolderManager { /// All the favorite views being trashed will be unfavorited first to remove it from favorites list as well. The process of unfavoriting concerned view is handled by `unfavorite_view_and_decendants()` #[tracing::instrument(level = "debug", skip(self), err)] pub async fn move_view_to_trash(&self, view_id: &str) -> FlowyResult<()> { - self.with_folder( - || (), - |folder| { - if let Some(view) = folder.views.get_view(view_id) { - self.unfavorite_view_and_decendants(view.clone(), folder); - folder.add_trash_view_ids(vec![view_id.to_string()]); - // notify the parent view that the view is moved to trash - send_notification(view_id, FolderNotification::DidMoveViewToTrash) - .payload(DeletedViewPB { - view_id: view_id.to_string(), - index: None, - }) - .send(); + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + // Check if the view is already in trash, if not we can move the same + // view to trash multiple times (duplicates) + let trash_info = folder.get_my_trash_info(); + if trash_info.into_iter().any(|info| info.id == view_id) { + return Err(FlowyError::new( + ErrorCode::Internal, + format!( + "Can't move the view({}) to trash, it is already in trash", + view_id + ), + )); + } - notify_child_views_changed( - view_pb_without_child_views(view.as_ref().clone()), - ChildViewChangeReason::Delete, - ); + if let Some(view) = folder.get_view(view_id) { + // if the view is locked, the view can't be moved to trash + if view.is_locked.unwrap_or(false) { + return Err(FlowyError::view_is_locked()); } - }, - ); + + Self::unfavorite_view_and_decendants(view.clone(), &mut folder); + folder.add_trash_view_ids(vec![view_id.to_string()]); + drop(folder); + + // notify the parent view that the view is moved to trash + folder_notification_builder(view_id, FolderNotification::DidMoveViewToTrash) + .payload(DeletedViewPB { + view_id: view_id.to_string(), + index: None, + }) + .send(); + + notify_child_views_changed( + view_pb_without_child_views(view.as_ref().clone()), + ChildViewChangeReason::Delete, + ); + } + } Ok(()) } - fn unfavorite_view_and_decendants(&self, view: Arc, folder: &Folder) { + fn unfavorite_view_and_decendants(view: Arc, folder: &mut Folder) { let mut all_descendant_views: Vec> = vec![view.clone()]; - all_descendant_views.extend(folder.views.get_views_belong_to(&view.id)); + all_descendant_views.extend(folder.get_views_belong_to(&view.id)); let favorite_descendant_views: Vec = all_descendant_views .iter() @@ -584,7 +792,7 @@ impl FolderManager { .map(|v| v.id.clone()) .collect(), ); - send_notification("favorite", FolderNotification::DidUnfavoriteView) + folder_notification_builder("favorite", FolderNotification::DidUnfavoriteView) .payload(RepeatedViewPB { items: favorite_descendant_views, }) @@ -617,27 +825,29 @@ impl FolderManager { let prev_view_id = params.prev_view_id; let from_section = params.from_section; let to_section = params.to_section; - let view = self.get_view_pb(&view_id).await?; - let old_parent_id = view.parent_view_id; - self.with_folder( - || (), - |folder| { - folder.move_nested_view(&view_id, &new_parent_id, prev_view_id); + let view = self.get_view_pb(&view_id.to_string()).await?; + // if the view is locked, the view can't be moved + if view.is_locked.unwrap_or(false) { + return Err(FlowyError::view_is_locked()); + } - if from_section != to_section { - if to_section == Some(ViewSectionPB::Private) { - folder.add_private_view_ids(vec![view_id.clone()]); - } else { - folder.delete_private_view_ids(vec![view_id.clone()]); - } + let old_parent_id = Uuid::from_str(&view.parent_view_id)?; + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + folder.move_nested_view( + &view_id.to_string(), + &new_parent_id.to_string(), + prev_view_id.map(|s| s.to_string()), + ); + if from_section != to_section { + if to_section == Some(ViewSectionPB::Private) { + folder.add_private_view_ids(vec![view_id.to_string()]); + } else { + folder.delete_private_view_ids(vec![view_id.to_string()]); } - }, - ); - notify_parent_view_did_change( - &workspace_id, - self.mutex_folder.clone(), - vec![new_parent_id, old_parent_id], - ); + } + notify_parent_view_did_change(workspace_id, &folder, vec![new_parent_id, old_parent_id]); + } Ok(()) } @@ -648,6 +858,12 @@ impl FolderManager { #[tracing::instrument(level = "trace", skip(self), err)] pub async fn move_view(&self, view_id: &str, from: usize, to: usize) -> FlowyResult<()> { let workspace_id = self.user.workspace_id()?; + let view = self.get_view_pb(view_id).await?; + // if the view is locked, the view can't be moved + if view.is_locked.unwrap_or(false) { + return Err(FlowyError::view_is_locked()); + } + if let Some((is_workspace, parent_view_id, child_views)) = self.get_view_relation(view_id).await { // The display parent view is the view that is displayed in the UI @@ -678,17 +894,12 @@ impl FolderManager { if let (Some(actual_from_index), Some(actual_to_index)) = (actual_from_index, actual_to_index) { - self.with_folder( - || (), - |folder| { - folder.move_view(view_id, actual_from_index as u32, actual_to_index as u32); - }, - ); - notify_parent_view_did_change( - &workspace_id, - self.mutex_folder.clone(), - vec![parent_view_id], - ); + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + folder.move_view(view_id, actual_from_index as u32, actual_to_index as u32); + let parent_view_id = Uuid::from_str(&parent_view_id)?; + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); + } } } } @@ -698,17 +909,52 @@ impl FolderManager { /// Return a list of views that belong to the given parent view id. #[tracing::instrument(level = "debug", skip(self, parent_view_id), err)] pub async fn get_views_belong_to(&self, parent_view_id: &str) -> FlowyResult>> { - let views = self.with_folder(Vec::new, |folder| { - folder.views.get_views_belong_to(parent_view_id) - }); - Ok(views) + match self.mutex_folder.load_full() { + Some(folder) => Ok(folder.read().await.get_views_belong_to(parent_view_id)), + None => Ok(Vec::default()), + } + } + + /// Return a list of views that belong to the given parent view id, and not + /// in the trash section. + pub async fn get_untrashed_views_belong_to( + &self, + parent_view_id: &str, + ) -> FlowyResult>> { + match self.mutex_folder.load_full() { + Some(folder) => { + let folder = folder.read().await; + let views = folder + .get_views_belong_to(parent_view_id) + .into_iter() + .filter(|view| !folder.is_view_in_section(Section::Trash, &view.id)) + .collect(); + + Ok(views) + }, + None => Ok(vec![]), + } + } + + pub async fn get_view(&self, view_id: &str) -> FlowyResult> { + match self.mutex_folder.load_full() { + Some(folder) => { + let folder = folder.read().await; + Ok( + folder + .get_view(view_id) + .ok_or_else(FlowyError::record_not_found)?, + ) + }, + None => Err(FlowyError::internal().with_context("The folder is not initialized")), + } } /// Update the view with the given params. #[tracing::instrument(level = "trace", skip(self), err)] pub async fn update_view_with_params(&self, params: UpdateViewParams) -> FlowyResult<()> { self - .update_view(¶ms.view_id, |update| { + .update_view(¶ms.view_id, true, |update| { update .set_name_if_not_none(params.name) .set_desc_if_not_none(params.desc) @@ -727,101 +973,304 @@ impl FolderManager { params: UpdateViewIconParams, ) -> FlowyResult<()> { self - .update_view(¶ms.view_id, |update| { + .update_view(¶ms.view_id, true, |update| { update.set_icon(params.icon).done() }) .await } - /// Duplicate the view with the given view id. + /// Lock the view with the given view id. + /// + /// If the view is locked, it cannot be edited. #[tracing::instrument(level = "debug", skip(self), err)] - pub(crate) async fn duplicate_view(&self, view_id: &str) -> Result<(), FlowyError> { - let view = self - .with_folder(|| None, |folder| folder.views.get_view(view_id)) + pub async fn lock_view(&self, view_id: &str) -> FlowyResult<()> { + self + .update_view(view_id, false, |update| { + update.set_page_lock_status(true).done() + }) + .await + } + + /// Unlock the view with the given view id. + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn unlock_view(&self, view_id: &str) -> FlowyResult<()> { + self + .update_view(view_id, false, |update| { + update.set_page_lock_status(false).done() + }) + .await + } + + /// Duplicate the view with the given view id. + /// + /// Including the view data (icon, cover, extra) and the child views. + #[tracing::instrument(level = "debug", skip(self), err)] + pub(crate) async fn duplicate_view( + &self, + params: DuplicateViewParams, + ) -> Result { + let lock = self + .mutex_folder + .load_full() + .ok_or_else(|| FlowyError::record_not_found().with_context("Can't duplicate the view"))?; + let folder = lock.read().await; + let view = folder + .get_view(¶ms.view_id) .ok_or_else(|| FlowyError::record_not_found().with_context("Can't duplicate the view"))?; - let handler = self.get_handler(&view.layout)?; - let view_data = handler.duplicate_view(&view.id).await?; + // Explicitly drop the folder lock to avoid deadlock when following calls contains 'self' + drop(folder); - // get the current view index in the parent view, because we need to insert the duplicated view below the current view. - let index = if let Some((_, __, views)) = self.get_view_relation(&view.parent_view_id).await { - views.iter().position(|id| id == view_id).map(|i| i as u32) - } else { - None + let parent_view_id = params + .parent_view_id + .clone() + .unwrap_or(view.parent_view_id.clone()); + self + .duplicate_view_with_parent_id( + &view.id, + &parent_view_id, + params.open_after_duplicate, + params.include_children, + params.suffix, + params.sync_after_create, + ) + .await + } + + /// Duplicate the view with the given view id and parent view id. + /// + /// If the view id is the same as the parent view id, it will return an error. + /// If the view id is not found, it will return an error. + pub(crate) async fn duplicate_view_with_parent_id( + &self, + view_id: &str, + parent_view_id: &str, + open_after_duplicated: bool, + include_children: bool, + suffix: Option, + sync_after_create: bool, + ) -> Result { + if view_id == parent_view_id { + return Err(FlowyError::new( + ErrorCode::Internal, + format!("Can't duplicate the view({}) to itself", view_id), + )); + } + + // filter the view ids that in the trash or private section + let filtered_view_ids = match self.mutex_folder.load_full() { + None => Vec::default(), + Some(lock) => { + let folder = lock.read().await; + Self::get_view_ids_should_be_filtered(&folder) + }, }; - let is_private = self.with_folder( - || false, - |folder| folder.is_view_in_section(Section::Private, &view.id), - ); - let section = if is_private { - ViewSectionPB::Private - } else { - ViewSectionPB::Public - }; + // only apply the `open_after_duplicated` and the `include_children` to the first view + let mut is_source_view = true; + let mut new_view_id = String::default(); + // use a stack to duplicate the view and its children + let mut stack = vec![(view_id.to_string(), parent_view_id.to_string())]; + let mut objects = vec![]; + let suffix = suffix.unwrap_or(" (copy)".to_string()); - let duplicate_params = CreateViewParams { - parent_view_id: view.parent_view_id.clone(), - name: format!("{} (copy)", &view.name), - desc: view.desc.clone(), - layout: view.layout.clone().into(), - initial_data: view_data.to_vec(), - view_id: gen_view_id().to_string(), - meta: Default::default(), - set_as_current: true, - index, - section: Some(section), + let lock = match self.mutex_folder.load_full() { + None => { + return Err( + FlowyError::record_not_found() + .with_context(format!("Can't duplicate the view({})", view_id)), + ) + }, + Some(lock) => lock, }; + while let Some((current_view_id, current_parent_id)) = stack.pop() { + let view = lock + .read() + .await + .get_view(¤t_view_id) + .ok_or_else(|| { + FlowyError::record_not_found() + .with_context(format!("Can't duplicate the view({})", view_id)) + })?; - self.create_view_with_params(duplicate_params).await?; - Ok(()) + let handler = self.get_handler(&view.layout)?; + info!( + "{} duplicate view{}, name:{}, layout:{:?}", + handler.name(), + view.id, + view.name, + view.layout + ); + let view_id = Uuid::from_str(&view.id)?; + let view_data = handler.duplicate_view(&view_id).await?; + + let index = self + .get_view_relation(¤t_parent_id) + .await + .and_then(|(_, _, views)| { + views + .iter() + .filter(|id| filtered_view_ids.contains(id)) + .position(|id| *id == current_view_id) + .map(|i| i as u32) + }); + + let section = { + let folder = lock.read().await; + if folder.is_view_in_section(Section::Private, &view.id) { + ViewSectionPB::Private + } else { + ViewSectionPB::Public + } + }; + + let name = if is_source_view { + format!( + "{}{}", + if view.name.is_empty() { + "Untitled" + } else { + view.name.as_str() + }, + suffix + ) + } else { + view.name.clone() + }; + + let parent_view_id = Uuid::from_str(¤t_parent_id)?; + let duplicate_params = CreateViewParams { + parent_view_id, + name, + layout: view.layout.clone().into(), + initial_data: ViewData::DuplicateData(view_data), + view_id: gen_view_id(), + meta: Default::default(), + set_as_current: is_source_view && open_after_duplicated, + index, + section: Some(section), + extra: view.extra.clone(), + icon: view.icon.clone(), + }; + + // set the notify_workspace_update to false to avoid multiple notifications + let (duplicated_view, encoded_collab) = self + .create_view_with_params(duplicate_params, false) + .await?; + + if is_source_view { + new_view_id.clone_from(&duplicated_view.id); + } + + if sync_after_create { + if let Some(encoded_collab) = encoded_collab { + let object_id = Uuid::from_str(&duplicated_view.id)?; + let collab_type = match duplicated_view.layout { + ViewLayout::Document => CollabType::Document, + ViewLayout::Board | ViewLayout::Grid | ViewLayout::Calendar => CollabType::Database, + ViewLayout::Chat => CollabType::Unknown, + }; + // don't block the whole import process if the view can't be encoded + if collab_type != CollabType::Unknown { + match self.get_folder_collab_params(object_id, collab_type, encoded_collab) { + Ok(params) => objects.push(params), + Err(e) => { + error!("duplicate error {}", e); + }, + } + } + } + } + + if include_children { + let child_views = self.get_views_belong_to(¤t_view_id).await?; + // reverse the child views to keep the order + for child_view in child_views.iter().rev() { + // skip the view_id should be filtered and the child_view is the duplicated view + if !filtered_view_ids.contains(&child_view.id) && child_view.layout != ViewLayout::Chat { + stack.push((child_view.id.clone(), duplicated_view.id.clone())); + } + } + } + + is_source_view = false + } + + let workspace_id = self.user.workspace_id()?; + let parent_view_id = Uuid::from_str(parent_view_id)?; + + // Sync the view to the cloud + if sync_after_create { + self + .cloud_service + .batch_create_folder_collab_objects(&workspace_id, objects) + .await?; + } + + // notify the update here + let folder = lock.read().await; + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); + let duplicated_view = self.get_view_pb(&new_view_id).await?; + + Ok(duplicated_view) } #[tracing::instrument(level = "trace", skip(self), err)] - pub(crate) async fn set_current_view(&self, view_id: &str) -> Result<(), FlowyError> { - self.with_folder( - || Err(FlowyError::record_not_found()), - |folder| { - folder.set_current_view(view_id); - folder.add_recent_view_ids(vec![view_id.to_string()]); - Ok(()) - }, - )?; + pub(crate) async fn set_current_view(&self, view_id: String) -> Result<(), FlowyError> { + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + folder.set_current_view(view_id.clone()); + folder.add_recent_view_ids(vec![view_id.clone()]); + } else { + return Err(FlowyError::record_not_found()); + } let view = self.get_current_view().await; if let Some(view) = &view { let view_layout: ViewLayout = view.layout.clone().into(); if let Some(handle) = self.operation_handlers.get(&view_layout) { - let _ = handle.open_view(view_id).await; + info!("Open view: {}-{}", view.name, view.id); + let view_id = Uuid::from_str(&view.id)?; + if let Err(err) = handle.open_view(&view_id).await { + error!("Open view error: {:?}", err); + } } } let workspace_id = self.user.workspace_id()?; - send_workspace_setting_notification(workspace_id, view); + let setting = WorkspaceLatestPB { + workspace_id: workspace_id.to_string(), + latest_view: view, + }; + send_current_workspace_notification(FolderNotification::DidUpdateWorkspaceSetting, setting); Ok(()) } #[tracing::instrument(level = "trace", skip(self))] pub(crate) async fn get_current_view(&self) -> Option { - let view_id = self.with_folder(|| None, |folder| folder.get_current_view())?; + let view_id = { + let lock = self.mutex_folder.load_full()?; + let folder = lock.read().await; + let view = folder.get_current_view()?; + drop(folder); + view + }; self.get_view_pb(&view_id).await.ok() } /// Toggles the favorite status of a view identified by `view_id`If the view is not a favorite, it will be added to the favorites list; otherwise, it will be removed from the list. #[tracing::instrument(level = "debug", skip(self), err)] pub async fn toggle_favorites(&self, view_id: &str) -> FlowyResult<()> { - self.with_folder( - || (), - |folder| { - if let Some(old_view) = folder.views.get_view(view_id) { - if old_view.is_favorite { - folder.delete_favorite_view_ids(vec![view_id.to_string()]); - } else { - folder.add_favorite_view_ids(vec![view_id.to_string()]); - } + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + if let Some(old_view) = folder.get_view(view_id) { + if old_view.is_favorite { + folder.delete_favorite_view_ids(vec![view_id.to_string()]); + } else { + folder.add_favorite_view_ids(vec![view_id.to_string()]); } - }, - ); + } + } self.send_toggle_favorite_notification(view_id).await; Ok(()) } @@ -829,12 +1278,10 @@ impl FolderManager { /// Add the view to the recent view list / history. #[tracing::instrument(level = "debug", skip(self), err)] pub async fn add_recent_views(&self, view_ids: Vec) -> FlowyResult<()> { - self.with_folder( - || (), - |folder| { - folder.add_recent_view_ids(view_ids); - }, - ); + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + folder.add_recent_view_ids(view_ids); + } self.send_update_recent_views_notification().await; Ok(()) } @@ -842,16 +1289,329 @@ impl FolderManager { /// Add the view to the recent view list / history. #[tracing::instrument(level = "debug", skip(self), err)] pub async fn remove_recent_views(&self, view_ids: Vec) -> FlowyResult<()> { - self.with_folder( - || (), - |folder| { - folder.delete_recent_view_ids(view_ids); - }, - ); + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + folder.delete_recent_view_ids(view_ids); + } self.send_update_recent_views_notification().await; Ok(()) } + /// Publishes a view identified by the given `view_id`. + /// + /// If `publish_name` is `None`, a default name will be generated using the view name and view id. + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn publish_view( + &self, + view_id: &str, + publish_name: Option, + selected_view_ids: Option>, + ) -> FlowyResult<()> { + let view = { + let lock = match self.mutex_folder.load_full() { + None => { + return Err( + FlowyError::record_not_found() + .with_context(format!("Can't find the view with ID: {}", view_id)), + ) + }, + Some(lock) => lock, + }; + let read_guard = lock.read().await; + read_guard.get_view(view_id).ok_or_else(|| { + FlowyError::record_not_found() + .with_context(format!("Can't find the view with ID: {}", view_id)) + })? + }; + + if view.layout == ViewLayout::Chat { + return Err(FlowyError::new( + ErrorCode::NotSupportYet, + "The chat view is not supported to publish.".to_string(), + )); + } + + // Retrieve the view payload and its child views recursively + let payload = self + .get_batch_publish_payload(view_id, publish_name, false) + .await?; + + // set the selected view ids to the payload + let payload = if let Some(selected_view_ids) = selected_view_ids { + payload + .into_iter() + .map(|mut p| { + if let PublishPayload::Database(p) = &mut p { + p.data + .visible_database_view_ids + .clone_from(&selected_view_ids); + } + p + }) + .collect::>() + } else { + payload + }; + + let workspace_id = self.user.workspace_id()?; + self + .cloud_service + .publish_view(&workspace_id, payload) + .await?; + Ok(()) + } + + /// Unpublish the view with the given view id. + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn unpublish_views(&self, view_ids: Vec) -> FlowyResult<()> { + let workspace_id = self.user.workspace_id()?; + self + .cloud_service + .unpublish_views(&workspace_id, view_ids) + .await?; + Ok(()) + } + + /// Get the publish info of the view with the given view id. + /// The publish info contains the namespace and publish_name of the view. + #[tracing::instrument(level = "debug", skip(self))] + pub async fn get_publish_info(&self, view_id: &Uuid) -> FlowyResult { + let publish_info = self.cloud_service.get_publish_info(view_id).await?; + Ok(publish_info) + } + + /// Sets the publish name of the view with the given view id. + #[tracing::instrument(level = "debug", skip(self))] + pub async fn set_publish_name(&self, view_id: Uuid, new_name: String) -> FlowyResult<()> { + let workspace_id = self.user.workspace_id()?; + self + .cloud_service + .set_publish_name(&workspace_id, view_id, new_name) + .await?; + Ok(()) + } + + /// Get the namespace of the current workspace. + /// The namespace is used to generate the URL of the published view. + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn set_publish_namespace(&self, new_namespace: String) -> FlowyResult<()> { + let workspace_id = self.user.workspace_id()?; + self + .cloud_service + .set_publish_namespace(&workspace_id, new_namespace) + .await?; + Ok(()) + } + + /// Get the namespace of the current workspace. + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn get_publish_namespace(&self) -> FlowyResult { + let workspace_id = self.user.workspace_id()?; + let namespace = self + .cloud_service + .get_publish_namespace(&workspace_id) + .await?; + Ok(namespace) + } + + /// List all published views of the current workspace. + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn list_published_views(&self) -> FlowyResult> { + let workspace_id = self.user.workspace_id()?; + let published_views = self + .cloud_service + .list_published_views(&workspace_id) + .await?; + Ok(published_views) + } + + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn get_default_published_view_info(&self) -> FlowyResult { + let workspace_id = self.user.workspace_id()?; + let default_published_view_info = self + .cloud_service + .get_default_published_view_info(&workspace_id) + .await?; + Ok(default_published_view_info) + } + + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn set_default_published_view(&self, view_id: uuid::Uuid) -> FlowyResult<()> { + let workspace_id = self.user.workspace_id()?; + self + .cloud_service + .set_default_published_view(&workspace_id, view_id) + .await?; + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn remove_default_published_view(&self) -> FlowyResult<()> { + let workspace_id = self.user.workspace_id()?; + self + .cloud_service + .remove_default_published_view(&workspace_id) + .await?; + Ok(()) + } + + /// Retrieves the publishing payload for a specified view and optionally its child views. + /// + /// # Arguments + /// * `view_id` - The ID of the view to publish. + /// * `publish_name` - Optional name for the published view. + /// * `include_children` - Flag to include child views in the payload. + pub async fn get_batch_publish_payload( + &self, + view_id: &str, + publish_name: Option, + include_children: bool, + ) -> FlowyResult> { + let mut stack = vec![view_id.to_string()]; + let mut payloads = Vec::new(); + + while let Some(current_view_id) = stack.pop() { + let view = match self.get_view_pb(¤t_view_id).await { + Ok(view) => view, + Err(_) => continue, + }; + + // Skip the chat view + if view.layout == ViewLayoutPB::Chat { + continue; + } + + let layout: ViewLayout = view.layout.into(); + + // Only support set the publish_name for the current view, not for the child views + let publish_name = if current_view_id == view_id { + publish_name.clone() + } else { + None + }; + + if let Ok(payload) = self + .get_publish_payload(&Uuid::from_str(¤t_view_id)?, publish_name, layout) + .await + { + payloads.push(payload); + } + + if include_children { + // Add the child views to the stack + stack.extend(view.child_views.iter().map(|child| child.id.clone())); + } + } + + Ok(payloads) + } + + async fn build_publish_views(&self, view_id: &str) -> Option { + let view_pb = self.get_view_pb(view_id).await.ok()?; + + let mut child_views_futures = vec![]; + + for child in &view_pb.child_views { + let future = self.build_publish_views(&child.id); + child_views_futures.push(future); + } + + let child_views = future::join_all(child_views_futures) + .await + .into_iter() + .flatten() + .collect::>(); + + let view_child_views = if child_views.is_empty() { + None + } else { + Some(child_views) + }; + + let view = view_pb_to_publish_view(&view_pb); + + let view = PublishViewInfo { + child_views: view_child_views, + ..view + }; + + Some(view) + } + + async fn get_publish_payload( + &self, + view_id: &Uuid, + publish_name: Option, + layout: ViewLayout, + ) -> FlowyResult { + let handler = self.get_handler(&layout)?; + let encoded_collab_wrapper: GatherEncodedCollab = handler + .gather_publish_encode_collab(&self.user, view_id) + .await?; + + let view_str_id = view_id.to_string(); + let view = self.get_view_pb(&view_str_id).await?; + + let publish_name = publish_name.unwrap_or_else(|| generate_publish_name(&view.id, &view.name)); + + let child_views = self + .build_publish_views(&view_str_id) + .await + .and_then(|v| v.child_views) + .unwrap_or_default(); + + let ancestor_views = self + .get_view_ancestors_pb(&view_str_id) + .await? + .iter() + .map(view_pb_to_publish_view) + .collect::>(); + + let metadata = PublishViewMetaData { + view: view_pb_to_publish_view(&view), + child_views, + ancestor_views, + }; + let meta = PublishViewMeta { + view_id: view.id.clone(), + publish_name, + metadata, + }; + + let payload = match encoded_collab_wrapper { + GatherEncodedCollab::Database(v) => { + let database_collab = v.database_encoded_collab.doc_state.to_vec(); + let database_relations = v.database_relations; + let database_row_collabs = v + .database_row_encoded_collabs + .into_iter() + .map(|v| (v.0, v.1.doc_state.to_vec())) // Convert to HashMap + .collect::>>(); + let database_row_document_collabs = v + .database_row_document_encoded_collabs + .into_iter() + .map(|v| (v.0, v.1.doc_state.to_vec())) // Convert to HashMap + .collect::>>(); + + let data = PublishDatabaseData { + database_collab, + database_row_collabs, + database_relations, + database_row_document_collabs, + ..Default::default() + }; + PublishPayload::Database(PublishDatabasePayload { meta, data }) + }, + GatherEncodedCollab::Document(v) => { + let data = v.doc_state.to_vec(); + PublishPayload::Document(PublishDocumentPayload { meta, data }) + }, + GatherEncodedCollab::Unknown => PublishPayload::Unknown, + }; + + Ok(payload) + } + // Used by toggle_favorites to send notification to frontend, after the favorite status of view has been changed.It sends two distinct notifications: one to correctly update the concerned view's is_favorite status, and another to update the list of favorites that is to be displayed. async fn send_toggle_favorite_notification(&self, view_id: &str) { if let Ok(view) = self.get_view_pb(view_id).await { @@ -860,13 +1620,13 @@ impl FolderManager { } else { FolderNotification::DidUnfavoriteView }; - send_notification("favorite", notification_type) + folder_notification_builder("favorite", notification_type) .payload(RepeatedViewPB { items: vec![view.clone()], }) .send(); - send_notification(&view.id, FolderNotification::DidUpdateView) + folder_notification_builder(&view.id, FolderNotification::DidUpdateView) .payload(view) .send() } @@ -874,7 +1634,7 @@ impl FolderManager { async fn send_update_recent_views_notification(&self) { let recent_views = self.get_my_recent_sections().await; - send_notification("recent_views", FolderNotification::DidUpdateRecentViews) + folder_notification_builder("recent_views", FolderNotification::DidUpdateRecentViews) .payload(RepeatedViewIdPB { items: recent_views.into_iter().map(|item| item.id).collect(), }) @@ -883,52 +1643,57 @@ impl FolderManager { #[tracing::instrument(level = "trace", skip(self))] pub(crate) async fn get_all_favorites(&self) -> Vec { - self.get_sections(Section::Favorite) + self.get_sections(Section::Favorite).await } #[tracing::instrument(level = "debug", skip(self))] pub(crate) async fn get_my_recent_sections(&self) -> Vec { - self.get_sections(Section::Recent) + self.get_sections(Section::Recent).await } #[tracing::instrument(level = "trace", skip(self))] pub(crate) async fn get_my_trash_info(&self) -> Vec { - self.with_folder(Vec::new, |folder| folder.get_my_trash_info()) + match self.mutex_folder.load_full() { + None => Vec::default(), + Some(folder) => folder.read().await.get_my_trash_info(), + } } #[tracing::instrument(level = "trace", skip(self))] pub(crate) async fn restore_all_trash(&self) { - self.with_folder( - || (), - |folder| { - folder.remove_all_my_trash_sections(); - }, - ); - send_notification("trash", FolderNotification::DidUpdateTrash) - .payload(RepeatedTrashPB { items: vec![] }) - .send(); + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + folder.remove_all_my_trash_sections(); + folder_notification_builder("trash", FolderNotification::DidUpdateTrash) + .payload(RepeatedTrashPB { items: vec![] }) + .send(); + } } #[tracing::instrument(level = "trace", skip(self))] pub(crate) async fn restore_trash(&self, trash_id: &str) { - self.with_folder( - || (), - |folder| { - folder.delete_trash_view_ids(vec![trash_id.to_string()]); - }, - ); + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + folder.delete_trash_view_ids(vec![trash_id.to_string()]); + } } /// Delete all the trash permanently. #[tracing::instrument(level = "trace", skip(self))] pub(crate) async fn delete_my_trash(&self) { - let deleted_trash = self.with_folder(Vec::new, |folder| folder.get_my_trash_info()); - for trash in deleted_trash { - let _ = self.delete_trash(&trash.id).await; + if let Some(lock) = self.mutex_folder.load_full() { + let deleted_trash = lock.read().await.get_my_trash_info(); + + // Explicitly drop the folder lock to avoid deadlock when following calls contains 'self' + drop(lock); + + for trash in deleted_trash { + let _ = self.delete_trash(&trash.id).await; + } + folder_notification_builder("trash", FolderNotification::DidUpdateTrash) + .payload(RepeatedTrashPB { items: vec![] }) + .send(); } - send_notification("trash", FolderNotification::DidUpdateTrash) - .payload(RepeatedTrashPB { items: vec![] }) - .send(); } /// Delete the trash permanently. @@ -936,95 +1701,152 @@ impl FolderManager { /// is a database view. Then the database will be deleted as well. #[tracing::instrument(level = "debug", skip(self, view_id), err)] pub async fn delete_trash(&self, view_id: &str) -> FlowyResult<()> { - let view = self.with_folder(|| None, |folder| folder.views.get_view(view_id)); - self.with_folder( - || (), - |folder| { + if let Some(lock) = self.mutex_folder.load_full() { + let view = { + let mut folder = lock.write().await; + let view = folder.get_view(view_id); folder.delete_trash_view_ids(vec![view_id.to_string()]); - folder.views.delete_views(vec![view_id]); - }, - ); - if let Some(view) = view { - if let Ok(handler) = self.get_handler(&view.layout) { - handler.delete_view(view_id).await?; + folder.delete_views(vec![view_id]); + view + }; + + if let Some(view) = view { + let view_id = Uuid::from_str(view_id)?; + if let Ok(handler) = self.get_handler(&view.layout) { + handler.delete_view(&view_id).await?; + } } } Ok(()) } - pub(crate) async fn import(&self, import_data: ImportParams) -> FlowyResult { - let workspace_id = self.user.workspace_id()?; - if import_data.data.is_none() && import_data.file_path.is_none() { - return Err(FlowyError::new( - ErrorCode::InvalidParams, - "data or file_path is required", - )); - } - + /// Imports a single file to the folder and returns the encoded collab for immediate cloud sync. + #[allow(clippy::type_complexity)] + #[instrument(level = "debug", skip_all, err)] + pub(crate) async fn import_single_file( + &self, + parent_view_id: Uuid, + import_data: ImportItem, + ) -> FlowyResult<(View, Vec<(String, CollabType, EncodedCollab)>)> { let handler = self.get_handler(&import_data.view_layout)?; - let view_id = gen_view_id().to_string(); + let view_id = gen_view_id(); let uid = self.user.user_id()?; - if let Some(data) = import_data.data { - handler - .import_from_bytes( - uid, - &view_id, - &import_data.name, - import_data.import_type, - data, - ) - .await?; - } + let mut encoded_collab = vec![]; - if let Some(file_path) = import_data.file_path { - handler - .import_from_file_path(&view_id, &import_data.name, file_path) - .await?; + info!("import single file from:{}", import_data.data); + match import_data.data { + ImportData::FilePath { file_path } => { + handler + .import_from_file_path(&view_id.to_string(), &import_data.name, file_path) + .await?; + }, + ImportData::Bytes { bytes } => { + encoded_collab = handler + .import_from_bytes( + uid, + &view_id, + &import_data.name, + import_data.import_type, + bytes, + ) + .await?; + }, } let params = CreateViewParams { - parent_view_id: import_data.parent_view_id, + parent_view_id, name: import_data.name, - desc: "".to_string(), layout: import_data.view_layout.clone().into(), - initial_data: vec![], + initial_data: ViewData::Empty, view_id, meta: Default::default(), set_as_current: false, index: None, section: None, + extra: None, + icon: None, }; let view = create_view(self.user.user_id()?, params, import_data.view_layout); - self.with_folder( - || (), - |folder| { - folder.insert_view(view.clone(), None); - }, - ); - notify_parent_view_did_change( - &workspace_id, - self.mutex_folder.clone(), - vec![view.parent_view_id.clone()], - ); - Ok(view) + + // Insert the new view into the folder + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + folder.insert_view(view.clone(), None); + } + + Ok((view, encoded_collab)) + } + + pub(crate) async fn import_zip_file(&self, zip_file_path: &str) -> FlowyResult<()> { + self.cloud_service.import_zip(zip_file_path).await?; + Ok(()) + } + + /// Import function to handle the import of data. + pub(crate) async fn import(&self, import_data: ImportParams) -> FlowyResult { + let workspace_id = self.user.workspace_id()?; + let mut objects = vec![]; + let mut views = vec![]; + for data in import_data.items { + // Import a single file and get the view and encoded collab data + let (view, encoded_collabs) = self + .import_single_file(import_data.parent_view_id, data) + .await?; + views.push(view_pb_without_child_views(view)); + + for (object_id, collab_type, encode_collab) in encoded_collabs { + if let Ok(object_id) = Uuid::from_str(&object_id) { + match self.get_folder_collab_params(object_id, collab_type, encode_collab) { + Ok(params) => objects.push(params), + Err(e) => { + error!("import error {}", e); + }, + } + } + } + } + + info!("Syncing the imported {} collab to the cloud", objects.len()); + self + .cloud_service + .batch_create_folder_collab_objects(&workspace_id, objects) + .await?; + + // Notify that the parent view has changed + if let Some(lock) = self.mutex_folder.load_full() { + let folder = lock.read().await; + notify_parent_view_did_change(workspace_id, &folder, vec![import_data.parent_view_id]); + } + + Ok(RepeatedViewPB { items: views }) } /// Update the view with the provided view_id using the specified function. - async fn update_view(&self, view_id: &str, f: F) -> FlowyResult<()> + /// + /// If the check_locked is true, it will check the lock status of the view. If the view is locked, + /// it will return an error. + async fn update_view(&self, view_id: &str, check_locked: bool, f: F) -> FlowyResult<()> where F: FnOnce(ViewUpdate) -> Option, { let workspace_id = self.user.workspace_id()?; - let value = self.with_folder( - || None, - |folder| { - let old_view = folder.views.get_view(view_id); - let new_view = folder.views.update_view(view_id, f); + let value = match self.mutex_folder.load_full() { + None => None, + Some(lock) => { + let mut folder = lock.write().await; + let old_view = folder.get_view(view_id); + + // Check if the view is locked + if check_locked && old_view.as_ref().and_then(|v| v.is_locked).unwrap_or(false) { + return Err(FlowyError::view_is_locked()); + } + + let new_view = folder.update_view(view_id, f); Some((old_view, new_view)) }, - ); + }; if let Some((Some(old_view), Some(new_view))) = value { if let Ok(handler) = self.get_handler(&old_view.layout) { @@ -1033,13 +1855,13 @@ impl FolderManager { } if let Ok(view_pb) = self.get_view_pb(view_id).await { - send_notification(&view_pb.id, FolderNotification::DidUpdateView) + folder_notification_builder(&view_pb.id, FolderNotification::DidUpdateView) .payload(view_pb) .send(); - let folder = &self.mutex_folder.read(); - if let Some(folder) = folder.as_ref() { - notify_did_update_workspace(&workspace_id, folder); + if let Some(lock) = self.mutex_folder.load_full() { + let folder = lock.read().await; + notify_did_update_workspace(&workspace_id, &folder); } } @@ -1047,10 +1869,7 @@ impl FolderManager { } /// Returns a handler that implements the [FolderOperationHandler] trait - fn get_handler( - &self, - view_layout: &ViewLayout, - ) -> FlowyResult> { + fn get_handler(&self, view_layout: &ViewLayout) -> FlowyResult> { match self.operation_handlers.get(view_layout) { None => Err(FlowyError::internal().with_context(format!( "Get data processor failed. Unknown layout type: {:?}", @@ -1060,43 +1879,58 @@ impl FolderManager { } } + fn get_folder_collab_params( + &self, + object_id: Uuid, + collab_type: CollabType, + encoded_collab: EncodedCollab, + ) -> FlowyResult { + // Try to encode the collaboration data to bytes + let encoded_collab_v1: Result, FlowyError> = + encoded_collab.encode_to_bytes().map_err(internal_error); + encoded_collab_v1.map(|encoded_collab_v1| FolderCollabParams { + object_id, + encoded_collab_v1, + collab_type, + }) + } + /// Returns the relation of the view. The relation is a tuple of (is_workspace, parent_view_id, /// child_view_ids). If the view is a workspace, then the parent_view_id is the workspace id. /// Otherwise, the parent_view_id is the parent view id of the view. The child_view_ids is the /// child view ids of the view. async fn get_view_relation(&self, view_id: &str) -> Option<(bool, String, Vec)> { let workspace_id = self.user.workspace_id().ok()?; - self.with_folder( - || None, - |folder| { - let view = folder.views.get_view(view_id)?; - match folder.views.get_view(&view.parent_view_id) { - None => folder.get_workspace_info(&workspace_id).map(|workspace| { - ( - true, - workspace.id, - workspace - .child_views - .items - .into_iter() - .map(|view| view.id) - .collect::>(), - ) - }), - Some(parent_view) => Some(( - false, - parent_view.id.clone(), - parent_view - .children + let lock = self.mutex_folder.load_full()?; + let folder = lock.read().await; + let view = folder.get_view(view_id)?; + match folder.get_view(&view.parent_view_id) { + None => folder + .get_workspace_info(&workspace_id.to_string()) + .map(|workspace| { + ( + true, + workspace.id, + workspace + .child_views .items - .clone() .into_iter() .map(|view| view.id) .collect::>(), - )), - } - }, - ) + ) + }), + Some(parent_view) => Some(( + false, + parent_view.id.clone(), + parent_view + .children + .items + .clone() + .into_iter() + .map(|view| view.id) + .collect::>(), + )), + } } pub async fn get_folder_snapshots( @@ -1120,39 +1954,41 @@ impl FolderManager { Ok(snapshots) } - pub fn set_views_visibility(&self, view_ids: Vec, is_public: bool) { - self.with_folder( - || (), - |folder| { - if is_public { - folder.delete_private_view_ids(view_ids); - } else { - folder.add_private_view_ids(view_ids); - } - }, - ); + pub async fn set_views_visibility(&self, view_ids: Vec, is_public: bool) { + if let Some(lock) = self.mutex_folder.load_full() { + let mut folder = lock.write().await; + if is_public { + folder.delete_private_view_ids(view_ids); + } else { + folder.add_private_view_ids(view_ids); + } + } } /// Only support getting the Favorite and Recent sections. - fn get_sections(&self, section_type: Section) -> Vec { - self.with_folder(Vec::new, |folder| { - let views = match section_type { - Section::Favorite => folder.get_my_favorite_sections(), - Section::Recent => folder.get_my_recent_sections(), - _ => vec![], - }; - let view_ids_should_be_filtered = self.get_view_ids_should_be_filtered(folder); - views - .into_iter() - .filter(|view| !view_ids_should_be_filtered.contains(&view.id)) - .collect() - }) + async fn get_sections(&self, section_type: Section) -> Vec { + match self.mutex_folder.load_full() { + None => Vec::default(), + Some(lock) => { + let folder = lock.read().await; + let views = match section_type { + Section::Favorite => folder.get_my_favorite_sections(), + Section::Recent => folder.get_my_recent_sections(), + _ => vec![], + }; + let view_ids_should_be_filtered = Self::get_view_ids_should_be_filtered(&folder); + views + .into_iter() + .filter(|view| !view_ids_should_be_filtered.contains(&view.id)) + .collect() + }, + } } /// Get all the view that are in the trash, including the child views of the child views. /// For example, if A view which is in the trash has a child view B, this function will return /// both A and B. - fn get_all_trash_ids(&self, folder: &Folder) -> Vec { + fn get_all_trash_ids(folder: &Folder) -> Vec { let trash_ids = folder .get_all_trash_sections() .into_iter() @@ -1166,13 +2002,13 @@ impl FolderManager { } /// Filter the views that are in the trash and belong to the other private sections. - fn get_view_ids_should_be_filtered(&self, folder: &Folder) -> Vec { - let trash_ids = self.get_all_trash_ids(folder); - let other_private_view_ids = self.get_other_private_view_ids(folder); + fn get_view_ids_should_be_filtered(folder: &Folder) -> Vec { + let trash_ids = Self::get_all_trash_ids(folder); + let other_private_view_ids = Self::get_other_private_view_ids(folder); [trash_ids, other_private_view_ids].concat() } - fn get_other_private_view_ids(&self, folder: &Folder) -> Vec { + fn get_other_private_view_ids(folder: &Folder) -> Vec { let my_private_view_ids = folder .get_my_private_sections() .into_iter() @@ -1190,10 +2026,19 @@ impl FolderManager { .filter(|id| !my_private_view_ids.contains(id)) .collect() } + + pub async fn remove_indices_for_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { + self + .folder_indexer + .remove_indices_for_workspace(*workspace_id) + .await?; + + Ok(()) + } } /// Return the views that belong to the workspace. The views are filtered by the trash and all the private views. -pub(crate) fn get_workspace_public_view_pbs(workspace_id: &str, folder: &Folder) -> Vec { +pub(crate) fn get_workspace_public_view_pbs(workspace_id: &Uuid, folder: &Folder) -> Vec { // get the trash ids let trash_ids = folder .get_all_trash_sections() @@ -1208,7 +2053,7 @@ pub(crate) fn get_workspace_public_view_pbs(workspace_id: &str, folder: &Folder) .map(|view| view.id) .collect::>(); - let mut views = folder.views.get_views_belong_to(workspace_id); + let mut views = folder.get_views_belong_to(&workspace_id.to_string()); // filter the views that are in the trash and all the private views views.retain(|view| !trash_ids.contains(&view.id) && !private_view_ids.contains(&view.id)); @@ -1216,11 +2061,9 @@ pub(crate) fn get_workspace_public_view_pbs(workspace_id: &str, folder: &Folder) .into_iter() .map(|view| { // Get child views - let child_views = folder - .views - .get_views_belong_to(&view.id) - .into_iter() - .collect(); + let mut child_views: Vec> = + folder.get_views_belong_to(&view.id).into_iter().collect(); + child_views.retain(|view| !trash_ids.contains(&view.id)); view_pb_with_child_views(view, child_views) }) .collect() @@ -1228,21 +2071,15 @@ pub(crate) fn get_workspace_public_view_pbs(workspace_id: &str, folder: &Folder) /// Get all the child views belong to the view id, including the child views of the child views. fn get_all_child_view_ids(folder: &Folder, view_id: &str) -> Vec { - let child_view_ids = folder - .views - .get_views_belong_to(view_id) - .into_iter() + folder + .get_view_recursively(view_id) + .iter() .map(|view| view.id.clone()) - .collect::>(); - let mut all_child_view_ids = child_view_ids.clone(); - for child_view_id in child_view_ids { - all_child_view_ids.extend(get_all_child_view_ids(folder, &child_view_id)); - } - all_child_view_ids + .collect() } /// Get the current private views of the user. -pub(crate) fn get_workspace_private_view_pbs(workspace_id: &str, folder: &Folder) -> Vec { +pub(crate) fn get_workspace_private_view_pbs(workspace_id: &Uuid, folder: &Folder) -> Vec { // get the trash ids let trash_ids = folder .get_all_trash_sections() @@ -1257,7 +2094,7 @@ pub(crate) fn get_workspace_private_view_pbs(workspace_id: &str, folder: &Folder .map(|view| view.id) .collect::>(); - let mut views = folder.views.get_views_belong_to(workspace_id); + let mut views = folder.get_views_belong_to(&workspace_id.to_string()); // filter the views that are in the trash and not in the private view ids views.retain(|view| !trash_ids.contains(&view.id) && private_view_ids.contains(&view.id)); @@ -1265,30 +2102,16 @@ pub(crate) fn get_workspace_private_view_pbs(workspace_id: &str, folder: &Folder .into_iter() .map(|view| { // Get child views - let child_views = folder - .views - .get_views_belong_to(&view.id) - .into_iter() - .collect(); + let mut child_views: Vec> = + folder.get_views_belong_to(&view.id).into_iter().collect(); + child_views.retain(|view| !trash_ids.contains(&view.id)); view_pb_with_child_views(view, child_views) }) .collect() } -/// The MutexFolder is a wrapper of the [Folder] that is used to share the folder between different -/// threads. -#[derive(Clone, Default)] -pub struct MutexFolder(Arc>>); -impl Deref for MutexFolder { - type Target = Arc>>; - fn deref(&self) -> &Self::Target { - &self.0 - } -} -unsafe impl Sync for MutexFolder {} -unsafe impl Send for MutexFolder {} - #[allow(clippy::large_enum_variant)] +#[derive(Debug)] pub enum FolderInitDataSource { /// It means using the data stored on local disk to initialize the folder LocalDisk { create_if_not_exist: bool }, diff --git a/frontend/rust-lib/flowy-folder/src/manager_init.rs b/frontend/rust-lib/flowy-folder/src/manager_init.rs index 6332a683ff..62cce7c394 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_init.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_init.rs @@ -2,13 +2,15 @@ use crate::manager::{FolderInitDataSource, FolderManager}; use crate::manager_observer::*; use crate::user_default::DefaultFolderBuilder; use collab::core::collab::DataSource; -use collab_entity::CollabType; -use collab_folder::{Folder, FolderNotify, UserId}; +use collab::lock::RwLock; +use collab_entity::{CollabType, EncodedCollab}; +use collab_folder::{Folder, FolderNotify}; use collab_integrate::CollabKVDB; use flowy_error::{FlowyError, FlowyResult}; use std::sync::{Arc, Weak}; use tokio::task::spawn_blocking; use tracing::{event, info, Level}; +use uuid::Uuid; impl FolderManager { /// Called immediately after the application launched if the user already sign in/sign up. @@ -16,7 +18,7 @@ impl FolderManager { pub async fn initialize( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, initial_data: FolderInitDataSource, ) -> FlowyResult<()> { // Update the workspace id @@ -27,12 +29,15 @@ impl FolderManager { initial_data ); - if let Some(old_folder) = self.mutex_folder.write().take() { + if let Some(old_folder) = self.mutex_folder.swap(None) { + let old_folder = old_folder.read().await; old_folder.close(); - info!("remove old folder: {}", old_folder.get_workspace_id()); + info!( + "remove old folder: {}", + old_folder.get_workspace_id().unwrap_or_default() + ); } - let workspace_id = workspace_id.to_string(); // Get the collab db for the user with given user id. let collab_db = self.user.collab_db(uid)?; @@ -47,40 +52,37 @@ impl FolderManager { FolderInitDataSource::LocalDisk { create_if_not_exist, } => { - let is_exist = self.is_workspace_exist_in_local(uid, &workspace_id).await; + let is_exist = self + .user + .is_folder_exist_on_disk(uid, workspace_id) + .unwrap_or(false); // 1. if the folder exists, open it from local disk if is_exist { event!(Level::INFO, "Init folder from local disk"); self - .make_folder( - uid, - &workspace_id, - collab_db, - DataSource::Disk, - folder_notifier, - ) + .make_folder(uid, workspace_id, collab_db, None, folder_notifier) .await? } else if create_if_not_exist { // 2. if the folder doesn't exist and create_if_not_exist is true, create a default folder // Currently, this branch is only used when the server type is supabase. For appflowy cloud, // the default workspace is already created when the user sign up. self - .create_default_folder(uid, &workspace_id, collab_db, folder_notifier) + .create_default_folder(uid, workspace_id, collab_db, folder_notifier) .await? } else { // 3. If the folder doesn't exist and create_if_not_exist is false, try to fetch the folder data from cloud/ // This will happen user can't fetch the folder data when the user sign in. let doc_state = self .cloud_service - .get_folder_doc_state(&workspace_id, uid, CollabType::Folder, &workspace_id) + .get_folder_doc_state(workspace_id, uid, CollabType::Folder, workspace_id) .await?; self .make_folder( uid, - &workspace_id, + workspace_id, collab_db.clone(), - DataSource::DocStateV1(doc_state), + Some(DataSource::DocStateV1(doc_state)), folder_notifier.clone(), ) .await? @@ -90,22 +92,16 @@ impl FolderManager { if doc_state.is_empty() { event!(Level::ERROR, "remote folder data is empty, open from local"); self - .make_folder( - uid, - &workspace_id, - collab_db, - DataSource::Disk, - folder_notifier, - ) + .make_folder(uid, workspace_id, collab_db, None, folder_notifier) .await? } else { event!(Level::INFO, "Restore folder from remote data"); self .make_folder( uid, - &workspace_id, + workspace_id, collab_db.clone(), - DataSource::DocStateV1(doc_state), + Some(DataSource::DocStateV1(doc_state)), folder_notifier.clone(), ) .await? @@ -113,68 +109,52 @@ impl FolderManager { }, }; - let folder_state_rx = folder.subscribe_sync_state(); - let index_content_rx = folder.subscribe_index_content(); - self - .folder_indexer - .set_index_content_receiver(index_content_rx, workspace_id.clone()); + let folder_state_rx = { + let folder = folder.read().await; + let folder_state_rx = folder.subscribe_sync_state(); + let index_content_rx = folder.subscribe_index_content(); + self + .folder_indexer + .set_index_content_receiver(index_content_rx, *workspace_id) + .await; + self.handle_index_folder(*workspace_id, &folder).await; + folder_state_rx + }; - // Index all views in the folder if needed - if !self.folder_indexer.is_indexed() { - let views = folder.views.get_all_views(); - let folder_indexer = self.folder_indexer.clone(); + self.mutex_folder.store(Some(folder.clone())); - // We spawn a blocking task to index all views in the folder - let wid = workspace_id.clone(); - spawn_blocking(move || { - folder_indexer.index_all_views(views, wid); - }); - } - - *self.mutex_folder.write() = Some(folder); - - let weak_mutex_folder = Arc::downgrade(&self.mutex_folder); - subscribe_folder_sync_state_changed( - workspace_id.clone(), - folder_state_rx, - Arc::downgrade(&self.user), - ); - subscribe_folder_snapshot_state_changed( - workspace_id.clone(), - &weak_mutex_folder, - Arc::downgrade(&self.user), - ); + let weak_mutex_folder = Arc::downgrade(&folder); + subscribe_folder_sync_state_changed(*workspace_id, folder_state_rx, Arc::downgrade(&self.user)); subscribe_folder_trash_changed( - workspace_id.clone(), + *workspace_id, section_change_rx, - &weak_mutex_folder, + weak_mutex_folder.clone(), Arc::downgrade(&self.user), ); subscribe_folder_view_changed( - workspace_id.clone(), + *workspace_id, view_rx, - &weak_mutex_folder, + weak_mutex_folder.clone(), Arc::downgrade(&self.user), ); - Ok(()) - } - async fn is_workspace_exist_in_local(&self, uid: i64, workspace_id: &str) -> bool { - if let Ok(weak_collab) = self.user.collab_db(uid) { - if let Some(collab_db) = weak_collab.upgrade() { - return collab_db.is_exist(uid, workspace_id).await.unwrap_or(false); + let weak_folder_indexer = Arc::downgrade(&self.folder_indexer); + tokio::spawn(async move { + if let Some(folder_indexer) = weak_folder_indexer.upgrade() { + folder_indexer.initialize().await; } - } - false + }); + + Ok(()) } async fn create_default_folder( &self, uid: i64, - workspace_id: &str, + workspace_id: &Uuid, collab_db: Weak, folder_notifier: FolderNotify, - ) -> Result { + ) -> Result>, FlowyError> { event!( Level::INFO, "Create folder:{} with default folder builder", @@ -182,14 +162,62 @@ impl FolderManager { ); let folder_data = DefaultFolderBuilder::build(uid, workspace_id.to_string(), &self.operation_handlers).await; - let collab = self - .create_empty_collab(uid, workspace_id, collab_db) + let folder = self + .create_folder_with_data( + uid, + workspace_id, + collab_db, + Some(folder_notifier), + Some(folder_data), + ) .await?; - Ok(Folder::create( - UserId::from(uid), - collab, - Some(folder_notifier), - folder_data, - )) + Ok(folder) + } + + async fn handle_index_folder(&self, workspace_id: Uuid, folder: &Folder) { + let mut index_all = true; + + let encoded_collab = self + .store_preferences + .get_object::(workspace_id.to_string().as_str()); + + if let Some(encoded_collab) = encoded_collab { + if let Ok(changes) = folder.calculate_view_changes(encoded_collab) { + let folder_indexer = self.folder_indexer.clone(); + + let views = folder.get_all_views(); + if !changes.is_empty() && !views.is_empty() { + spawn_blocking(move || { + // We index the changes + folder_indexer.index_view_changes(views, changes, workspace_id); + }); + index_all = false; + } + } + } + + if index_all { + let views = folder.get_all_views(); + let folder_indexer = self.folder_indexer.clone(); + let _ = folder_indexer + .remove_indices_for_workspace(workspace_id) + .await; + // We spawn a blocking task to index all views in the folder + spawn_blocking(move || { + folder_indexer.index_all_views(views, workspace_id); + }); + } + + self.save_collab_to_preferences(folder); + } + + fn save_collab_to_preferences(&self, folder: &Folder) { + if let Some(workspace_id) = folder.get_workspace_id() { + let encoded_collab = folder.encode_collab(); + + if let Ok(encoded) = encoded_collab { + let _ = self.store_preferences.set_object(&workspace_id, &encoded); + } + } } } diff --git a/frontend/rust-lib/flowy-folder/src/manager_observer.rs b/frontend/rust-lib/flowy-folder/src/manager_observer.rs index ef604b3a11..5d3034b5aa 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_observer.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_observer.rs @@ -1,32 +1,33 @@ use crate::entities::{ - view_pb_with_child_views, view_pb_without_child_views, ChildViewUpdatePB, FolderSnapshotStatePB, - FolderSyncStatePB, RepeatedTrashPB, RepeatedViewPB, SectionViewsPB, ViewPB, ViewSectionPB, + view_pb_with_child_views, view_pb_without_child_views, ChildViewUpdatePB, FolderSyncStatePB, + RepeatedTrashPB, RepeatedViewPB, SectionViewsPB, ViewPB, ViewSectionPB, }; -use crate::manager::{ - get_workspace_private_view_pbs, get_workspace_public_view_pbs, FolderUser, MutexFolder, -}; -use crate::notification::{send_notification, FolderNotification}; +use crate::manager::{get_workspace_private_view_pbs, get_workspace_public_view_pbs, FolderUser}; +use crate::notification::{folder_notification_builder, FolderNotification}; use collab::core::collab_state::SyncState; +use collab::lock::RwLock; use collab_folder::{ Folder, SectionChange, SectionChangeReceiver, TrashSectionChange, View, ViewChange, ViewChangeReceiver, }; -use lib_dispatch::prelude::af_spawn; +use lib_infra::sync_trace; + use std::collections::HashSet; -use std::sync::{Arc, Weak}; +use std::str::FromStr; +use std::sync::Weak; use tokio_stream::wrappers::WatchStream; use tokio_stream::StreamExt; use tracing::{event, trace, Level}; +use uuid::Uuid; /// Listen on the [ViewChange] after create/delete/update events happened pub(crate) fn subscribe_folder_view_changed( - workspace_id: String, + workspace_id: Uuid, mut rx: ViewChangeReceiver, - weak_mutex_folder: &Weak, + weak_mutex_folder: Weak>, user: Weak, ) { - let weak_mutex_folder = weak_mutex_folder.clone(); - af_spawn(async move { + tokio::spawn(async move { while let Ok(value) = rx.recv().await { if let Some(user) = user.upgrade() { if let Ok(actual_workspace_id) = user.workspace_id() { @@ -38,18 +39,24 @@ pub(crate) fn subscribe_folder_view_changed( } } - if let Some(folder) = weak_mutex_folder.upgrade() { - tracing::trace!("Did receive view change: {:?}", value); + if let Some(lock) = weak_mutex_folder.upgrade() { + trace!("Did receive view change: {:?}", value); match value { ViewChange::DidCreateView { view } => { notify_child_views_changed( view_pb_without_child_views(view.clone()), ChildViewChangeReason::Create, ); - notify_parent_view_did_change(&workspace_id, folder.clone(), vec![view.parent_view_id]); + let folder = lock.read().await; + if let Ok(parent_view_id) = Uuid::from_str(&view.parent_view_id) { + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); + sync_trace!("[Folder] create view: {:?}", view); + } }, ViewChange::DidDeleteView { views } => { for view in views { + sync_trace!("[Folder] delete view: {:?}", view); + notify_child_views_changed( view_pb_without_child_views(view.as_ref().clone()), ChildViewChangeReason::Delete, @@ -57,16 +64,17 @@ pub(crate) fn subscribe_folder_view_changed( } }, ViewChange::DidUpdate { view } => { + sync_trace!("[Folder] update view: {:?}", view); + notify_view_did_change(view.clone()); notify_child_views_changed( view_pb_without_child_views(view.clone()), ChildViewChangeReason::Update, ); - notify_parent_view_did_change( - &workspace_id, - folder.clone(), - vec![view.parent_view_id.clone()], - ); + let folder = lock.read().await; + if let Ok(parent_view_id) = Uuid::from_str(&view.parent_view_id) { + notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id]); + } }, }; } @@ -74,49 +82,12 @@ pub(crate) fn subscribe_folder_view_changed( }); } -pub(crate) fn subscribe_folder_snapshot_state_changed( - workspace_id: String, - weak_mutex_folder: &Weak, - user: Weak, -) { - let weak_mutex_folder = weak_mutex_folder.clone(); - af_spawn(async move { - if let Some(mutex_folder) = weak_mutex_folder.upgrade() { - let stream = mutex_folder - .read() - .as_ref() - .map(|folder| folder.subscribe_snapshot_state()); - if let Some(mut state_stream) = stream { - while let Some(snapshot_state) = state_stream.next().await { - if let Some(user) = user.upgrade() { - if let Ok(actual_workspace_id) = user.workspace_id() { - if actual_workspace_id != workspace_id { - // break the loop when the workspace id is not matched. - break; - } - } - } - if let Some(new_snapshot_id) = snapshot_state.snapshot_id() { - tracing::debug!("Did create folder remote snapshot: {}", new_snapshot_id); - send_notification( - &workspace_id, - FolderNotification::DidUpdateFolderSnapshotState, - ) - .payload(FolderSnapshotStatePB { new_snapshot_id }) - .send(); - } - } - } - } - }); -} - pub(crate) fn subscribe_folder_sync_state_changed( - workspace_id: String, + workspace_id: Uuid, mut folder_sync_state_rx: WatchStream, user: Weak, ) { - af_spawn(async move { + tokio::spawn(async move { while let Some(state) = folder_sync_state_rx.next().await { if let Some(user) = user.upgrade() { if let Ok(actual_workspace_id) = user.workspace_id() { @@ -127,22 +98,24 @@ pub(crate) fn subscribe_folder_sync_state_changed( } } - send_notification(&workspace_id, FolderNotification::DidUpdateFolderSyncUpdate) - .payload(FolderSyncStatePB::from(state)) - .send(); + folder_notification_builder( + workspace_id.to_string(), + FolderNotification::DidUpdateFolderSyncUpdate, + ) + .payload(FolderSyncStatePB::from(state)) + .send(); } }); } /// Listen on the [TrashChange]s and notify the frontend some views were changed. pub(crate) fn subscribe_folder_trash_changed( - workspace_id: String, + workspace_id: Uuid, mut rx: SectionChangeReceiver, - weak_mutex_folder: &Weak, + weak_mutex_folder: Weak>, user: Weak, ) { - let weak_mutex_folder = weak_mutex_folder.clone(); - af_spawn(async move { + tokio::spawn(async move { while let Ok(value) = rx.recv().await { if let Some(user) = user.upgrade() { if let Ok(actual_workspace_id) = user.workspace_id() { @@ -153,7 +126,7 @@ pub(crate) fn subscribe_folder_trash_changed( } } - if let Some(folder) = weak_mutex_folder.upgrade() { + if let Some(lock) = weak_mutex_folder.upgrade() { let mut unique_ids = HashSet::new(); tracing::trace!("Did receive trash change: {:?}", value); @@ -163,20 +136,21 @@ pub(crate) fn subscribe_folder_trash_changed( TrashSectionChange::TrashItemAdded { ids } => ids, TrashSectionChange::TrashItemRemoved { ids } => ids, }; - if let Some(folder) = folder.read().as_ref() { - let views = folder.views.get_views(&ids); - for view in views { - unique_ids.insert(view.parent_view_id.clone()); + let folder = lock.read().await; + let views = folder.get_views(&ids); + for view in views { + if let Ok(parent_view_id) = Uuid::from_str(&view.parent_view_id) { + unique_ids.insert(parent_view_id); } - - let repeated_trash: RepeatedTrashPB = folder.get_my_trash_info().into(); - send_notification("trash", FolderNotification::DidUpdateTrash) - .payload(repeated_trash) - .send(); } + let repeated_trash: RepeatedTrashPB = folder.get_my_trash_info().into(); + folder_notification_builder("trash", FolderNotification::DidUpdateTrash) + .payload(repeated_trash) + .send(); + let parent_view_ids = unique_ids.into_iter().collect(); - notify_parent_view_did_change(&workspace_id, folder.clone(), parent_view_ids); + notify_parent_view_did_change(workspace_id, &folder, parent_view_ids); }, } } @@ -186,13 +160,11 @@ pub(crate) fn subscribe_folder_trash_changed( /// Notify the list of parent view ids that its child views were changed. #[tracing::instrument(level = "debug", skip(folder, parent_view_ids))] -pub(crate) fn notify_parent_view_did_change>( - workspace_id: &str, - folder: Arc, - parent_view_ids: Vec, +pub(crate) fn notify_parent_view_did_change( + workspace_id: Uuid, + folder: &Folder, + parent_view_ids: Vec, ) -> Option<()> { - let folder = folder.read(); - let folder = folder.as_ref()?; let trash_ids = folder .get_all_trash_sections() .into_iter() @@ -200,24 +172,23 @@ pub(crate) fn notify_parent_view_did_change>( .collect::>(); for parent_view_id in parent_view_ids { - let parent_view_id = parent_view_id.as_ref(); - // if the view's parent id equal to workspace id. Then it will fetch the current // workspace views. Because the workspace is not a view stored in the views map. if parent_view_id == workspace_id { - notify_did_update_workspace(workspace_id, folder); - notify_did_update_section_views(workspace_id, folder); + notify_did_update_workspace(&workspace_id, folder); + notify_did_update_section_views(&workspace_id, folder); } else { // Parent view can contain a list of child views. Currently, only get the first level // child views. - let parent_view = folder.views.get_view(parent_view_id)?; - let mut child_views = folder.views.get_views_belong_to(parent_view_id); + let parent_view_id = parent_view_id.to_string(); + let parent_view = folder.get_view(&parent_view_id)?; + let mut child_views = folder.get_views_belong_to(&parent_view_id); child_views.retain(|view| !trash_ids.contains(&view.id)); event!(Level::DEBUG, child_views_count = child_views.len()); // Post the notification let parent_view_pb = view_pb_with_child_views(parent_view, child_views); - send_notification(parent_view_id, FolderNotification::DidUpdateView) + folder_notification_builder(&parent_view_id, FolderNotification::DidUpdateView) .payload(parent_view_pb) .send(); } @@ -226,18 +197,17 @@ pub(crate) fn notify_parent_view_did_change>( None } -pub(crate) fn notify_did_update_section_views(workspace_id: &str, folder: &Folder) { +pub(crate) fn notify_did_update_section_views(workspace_id: &Uuid, folder: &Folder) { let public_views = get_workspace_public_view_pbs(workspace_id, folder); let private_views = get_workspace_private_view_pbs(workspace_id, folder); - tracing::trace!( + trace!( "Did update section views: public len = {}, private len = {}", public_views.len(), private_views.len() ); - // TODO(Lucas.xu) - Only notify the section changed, not the public/private both. // Notify the public views - send_notification(workspace_id, FolderNotification::DidUpdateSectionViews) + folder_notification_builder(workspace_id, FolderNotification::DidUpdateSectionViews) .payload(SectionViewsPB { section: ViewSectionPB::Public, views: public_views, @@ -245,7 +215,7 @@ pub(crate) fn notify_did_update_section_views(workspace_id: &str, folder: &Folde .send(); // Notify the private views - send_notification(workspace_id, FolderNotification::DidUpdateSectionViews) + folder_notification_builder(workspace_id, FolderNotification::DidUpdateSectionViews) .payload(SectionViewsPB { section: ViewSectionPB::Private, views: private_views, @@ -253,9 +223,9 @@ pub(crate) fn notify_did_update_section_views(workspace_id: &str, folder: &Folde .send(); } -pub(crate) fn notify_did_update_workspace(workspace_id: &str, folder: &Folder) { +pub(crate) fn notify_did_update_workspace(workspace_id: &Uuid, folder: &Folder) { let repeated_view: RepeatedViewPB = get_workspace_public_view_pbs(workspace_id, folder).into(); - send_notification(workspace_id, FolderNotification::DidUpdateWorkspaceViews) + folder_notification_builder(workspace_id, FolderNotification::DidUpdateWorkspaceViews) .payload(repeated_view) .send(); } @@ -263,7 +233,7 @@ pub(crate) fn notify_did_update_workspace(workspace_id: &str, folder: &Folder) { fn notify_view_did_change(view: View) -> Option<()> { let view_id = view.id.clone(); let view_pb = view_pb_without_child_views(view); - send_notification(&view_id, FolderNotification::DidUpdateView) + folder_notification_builder(&view_id, FolderNotification::DidUpdateView) .payload(view_pb) .send(); None @@ -296,7 +266,7 @@ pub(crate) fn notify_child_views_changed(view_pb: ViewPB, reason: ChildViewChang }, } - send_notification(&parent_view_id, FolderNotification::DidUpdateChildViews) + folder_notification_builder(&parent_view_id, FolderNotification::DidUpdateChildViews) .payload(payload) .send(); } diff --git a/frontend/rust-lib/flowy-folder/src/manager_test_util.rs b/frontend/rust-lib/flowy-folder/src/manager_test_util.rs deleted file mode 100644 index 4280c788d9..0000000000 --- a/frontend/rust-lib/flowy-folder/src/manager_test_util.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::manager::{FolderManager, FolderUser, MutexFolder}; -use crate::view_operation::FolderOperationHandlers; -use collab_integrate::collab_builder::AppFlowyCollabBuilder; -use flowy_folder_pub::cloud::FolderCloudService; -use flowy_search_pub::entities::FolderIndexManager; -use std::sync::Arc; - -impl FolderManager { - pub fn get_mutex_folder(&self) -> Arc { - self.mutex_folder.clone() - } - - pub fn get_cloud_service(&self) -> Arc { - self.cloud_service.clone() - } - - pub fn get_user(&self) -> Arc { - self.user.clone() - } - - pub fn get_indexer(&self) -> Arc { - self.folder_indexer.clone() - } - - pub fn get_collab_builder(&self) -> Arc { - self.collab_builder.clone() - } - - pub fn get_operation_handlers(&self) -> FolderOperationHandlers { - self.operation_handlers.clone() - } -} diff --git a/frontend/rust-lib/flowy-folder/src/notification.rs b/frontend/rust-lib/flowy-folder/src/notification.rs index c57450a5d6..5629ef4133 100644 --- a/frontend/rust-lib/flowy-folder/src/notification.rs +++ b/frontend/rust-lib/flowy-folder/src/notification.rs @@ -1,8 +1,7 @@ use flowy_derive::ProtoBuf_Enum; use flowy_notification::NotificationBuilder; use lib_dispatch::prelude::ToBytes; - -use crate::entities::{ViewPB, WorkspaceSettingPB}; +use tracing::trace; const FOLDER_OBSERVABLE_SOURCE: &str = "Workspace"; @@ -12,7 +11,7 @@ pub enum FolderNotification { Unknown = 0, /// Trigger after creating a workspace DidCreateWorkspace = 1, - // /// Trigger after updating a workspace + /// Trigger after updating a workspace DidUpdateWorkspace = 2, DidUpdateWorkspaceViews = 3, @@ -70,28 +69,21 @@ impl std::convert::From for FolderNotification { } } -#[tracing::instrument(level = "trace")] -pub(crate) fn send_notification(id: &str, ty: FolderNotification) -> NotificationBuilder { - NotificationBuilder::new(id, ty, FOLDER_OBSERVABLE_SOURCE) +#[tracing::instrument(level = "trace", skip_all)] +pub(crate) fn folder_notification_builder( + id: T, + ty: FolderNotification, +) -> NotificationBuilder { + let id = id.to_string(); + trace!("folder_notification_builder: id = {id}, ty = {ty:?}"); + NotificationBuilder::new(&id, ty, FOLDER_OBSERVABLE_SOURCE) } /// The [CURRENT_WORKSPACE] represents as the current workspace that opened by the /// user. Only one workspace can be opened at a time. const CURRENT_WORKSPACE: &str = "current-workspace"; -pub(crate) fn send_workspace_notification(ty: FolderNotification, payload: T) { - send_notification(CURRENT_WORKSPACE, ty) +pub(crate) fn send_current_workspace_notification(ty: FolderNotification, payload: T) { + folder_notification_builder(CURRENT_WORKSPACE, ty) .payload(payload) .send(); } - -pub(crate) fn send_workspace_setting_notification( - workspace_id: String, - latest_view: Option, -) -> Option<()> { - let setting = WorkspaceSettingPB { - workspace_id, - latest_view, - }; - send_workspace_notification(FolderNotification::DidUpdateWorkspaceSetting, setting); - None -} diff --git a/frontend/rust-lib/flowy-folder/src/publish_util.rs b/frontend/rust-lib/flowy-folder/src/publish_util.rs new file mode 100644 index 0000000000..735614ffa4 --- /dev/null +++ b/frontend/rust-lib/flowy-folder/src/publish_util.rs @@ -0,0 +1,37 @@ +use crate::entities::ViewPB; +use flowy_folder_pub::entities::PublishViewInfo; +use regex::Regex; +use tracing::trace; + +fn replace_invalid_url_chars(input: &str) -> String { + let regex = Regex::new(r"[^\w-]").unwrap(); + regex.replace_all(input, "-").to_string() +} + +pub fn generate_publish_name(id: &str, name: &str) -> String { + let id_len = id.len(); + let name = replace_invalid_url_chars(name); + let name_len = name.len(); + // The backend limits the publish name to a maximum of 50 characters. + // If the combined length of the ID and the name exceeds 50 characters, + // we will truncate the name to ensure the final result is within the limit. + // The name should only contain alphanumeric characters and hyphens. + let result = format!("{}-{}", &name[..std::cmp::min(49 - id_len, name_len)], id); + trace!("generate_publish_name: {}", result); + result +} + +pub fn view_pb_to_publish_view(view: &ViewPB) -> PublishViewInfo { + PublishViewInfo { + view_id: view.id.clone(), + name: view.name.clone(), + layout: view.layout.clone().into(), + icon: view.icon.clone().map(|icon| icon.into()), + child_views: None, + extra: view.extra.clone(), + created_by: view.created_by, + last_edited_by: view.last_edited_by, + last_edited_time: view.last_edited, + created_at: view.create_time, + } +} diff --git a/frontend/rust-lib/flowy-folder/src/share/import.rs b/frontend/rust-lib/flowy-folder/src/share/import.rs index 531461a232..6fd8d8feab 100644 --- a/frontend/rust-lib/flowy-folder/src/share/import.rs +++ b/frontend/rust-lib/flowy-folder/src/share/import.rs @@ -1,19 +1,41 @@ use collab_folder::ViewLayout; +use std::fmt::{Display, Formatter}; +use uuid::Uuid; #[derive(Clone, Debug)] pub enum ImportType { HistoryDocument = 0, HistoryDatabase = 1, - RawDatabase = 2, - CSV = 3, + Markdown = 2, + AFDatabase = 3, + CSV = 4, +} + +#[derive(Clone, Debug)] +pub struct ImportItem { + pub name: String, + pub data: ImportData, + pub view_layout: ViewLayout, + pub import_type: ImportType, +} + +#[derive(Clone, Debug)] +pub enum ImportData { + FilePath { file_path: String }, + Bytes { bytes: Vec }, +} + +impl Display for ImportData { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ImportData::FilePath { file_path } => write!(f, "file: {}", file_path), + ImportData::Bytes { .. } => write!(f, "binary"), + } + } } #[derive(Clone, Debug)] pub struct ImportParams { - pub parent_view_id: String, - pub name: String, - pub data: Option>, - pub file_path: Option, - pub view_layout: ViewLayout, - pub import_type: ImportType, + pub parent_view_id: Uuid, + pub items: Vec, } diff --git a/frontend/rust-lib/flowy-folder/src/test_helper.rs b/frontend/rust-lib/flowy-folder/src/test_helper.rs deleted file mode 100644 index 50e4b290ff..0000000000 --- a/frontend/rust-lib/flowy-folder/src/test_helper.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::collections::HashMap; - -use flowy_folder_pub::cloud::gen_view_id; - -use crate::entities::{CreateViewParams, ViewLayoutPB, ViewSectionPB}; -use crate::manager::FolderManager; - -#[cfg(feature = "test_helper")] -impl FolderManager { - pub async fn create_test_grid_view( - &self, - app_id: &str, - name: &str, - ext: HashMap, - ) -> String { - self - .create_test_view(app_id, name, ViewLayoutPB::Grid, ext) - .await - } - - pub async fn create_test_board_view( - &self, - app_id: &str, - name: &str, - ext: HashMap, - ) -> String { - self - .create_test_view(app_id, name, ViewLayoutPB::Board, ext) - .await - } - - async fn create_test_view( - &self, - app_id: &str, - name: &str, - layout: ViewLayoutPB, - ext: HashMap, - ) -> String { - let view_id = gen_view_id().to_string(); - let params = CreateViewParams { - parent_view_id: app_id.to_string(), - name: name.to_string(), - desc: "".to_string(), - layout, - view_id: view_id.clone(), - initial_data: vec![], - meta: ext, - set_as_current: true, - index: None, - section: Some(ViewSectionPB::Public), - }; - self.create_view_with_params(params).await.unwrap(); - view_id - } -} diff --git a/frontend/rust-lib/flowy-folder/src/user_default.rs b/frontend/rust-lib/flowy-folder/src/user_default.rs index 0f4fca0d54..82fe1730fc 100644 --- a/frontend/rust-lib/flowy-folder/src/user_default.rs +++ b/frontend/rust-lib/flowy-folder/src/user_default.rs @@ -1,13 +1,13 @@ use std::sync::Arc; +use collab_folder::hierarchy_builder::{FlattedViews, NestedViewBuilder, ParentChildViews}; use collab_folder::{FolderData, RepeatedViewIdentifier, ViewIdentifier, Workspace}; -use flowy_folder_pub::folder_builder::{FlattedViews, NestedViewBuilder, ParentChildViews}; use tokio::sync::RwLock; use lib_infra::util::timestamp; use crate::entities::{view_pb_with_child_views, ViewPB}; -use crate::view_operation::FolderOperationHandlers; +use crate::view_operation::{FolderOperationHandler, FolderOperationHandlers}; pub struct DefaultFolderBuilder(); impl DefaultFolderBuilder { @@ -20,7 +20,19 @@ impl DefaultFolderBuilder { workspace_id.clone(), uid, ))); - for handler in handlers.values() { + + // Collect all handlers from the DashMap into a vector. + // + // - `DashMap::iter()` returns references to the stored values, which are not `Send` + // and can cause issues in an `async` context where thread-safety is required. + // - By cloning the values into a `Vec`, we ensure they are owned and implement + // `Send + Sync`, making them safe to use in asynchronous operations. + // - This avoids lifetime conflicts and allows the handlers to be used in the + // asynchronous loop without tying their lifetimes to the DashMap. + // + let handler_clones: Vec> = + handlers.iter().map(|entry| entry.value().clone()).collect(); + for handler in handler_clones { let _ = handler .create_workspace_view(uid, workspace_view_builder.clone()) .await; @@ -28,12 +40,12 @@ impl DefaultFolderBuilder { let views = workspace_view_builder.write().await.build(); // Safe to unwrap because we have at least one view. check out the DocumentFolderOperation. - let first_view = views.first().unwrap().parent_view.clone(); + let first_view = views.first().unwrap().view.clone(); let first_level_views = views .iter() .map(|value| ViewIdentifier { - id: value.parent_view.id.clone(), + id: value.view.id.clone(), }) .collect::>(); @@ -50,7 +62,7 @@ impl DefaultFolderBuilder { FolderData { workspace, current_view: first_view.id, - views: FlattedViews::flatten_views(views), + views: FlattedViews::flatten_views(views.into_inner()), favorites: Default::default(), recent: Default::default(), trash: Default::default(), @@ -62,11 +74,11 @@ impl DefaultFolderBuilder { impl From<&ParentChildViews> for ViewPB { fn from(value: &ParentChildViews) -> Self { view_pb_with_child_views( - Arc::new(value.parent_view.clone()), + Arc::new(value.view.clone()), value - .child_views + .children .iter() - .map(|v| Arc::new(v.parent_view.clone())) + .map(|v| Arc::new(v.view.clone())) .collect(), ) } diff --git a/frontend/rust-lib/flowy-folder/src/util.rs b/frontend/rust-lib/flowy-folder/src/util.rs index a56db33511..98b87be52d 100644 --- a/frontend/rust-lib/flowy-folder/src/util.rs +++ b/frontend/rust-lib/flowy-folder/src/util.rs @@ -1,30 +1,14 @@ use crate::entities::UserFolderPB; -use collab_folder::Folder; use flowy_error::{ErrorCode, FlowyError}; -use flowy_folder_pub::folder_builder::ParentChildViews; -use tracing::{event, instrument}; +use uuid::Uuid; pub(crate) fn folder_not_init_error() -> FlowyError { FlowyError::internal().with_context("Folder not initialized") } -pub(crate) fn workspace_data_not_sync_error(uid: i64, workspace_id: &str) -> FlowyError { +pub(crate) fn workspace_data_not_sync_error(uid: i64, workspace_id: &Uuid) -> FlowyError { FlowyError::from(ErrorCode::WorkspaceDataNotSync).with_payload(UserFolderPB { uid, workspace_id: workspace_id.to_string(), }) } - -#[instrument(level = "debug", skip(folder, view))] -pub(crate) fn insert_parent_child_views(folder: &Folder, view: ParentChildViews) { - event!( - tracing::Level::DEBUG, - "Inserting view: {}, view children: {}", - view.parent_view.id, - view.child_views.len() - ); - folder.insert_view(view.parent_view, None); - for child_view in view.child_views { - insert_parent_child_views(folder, child_view); - } -} diff --git a/frontend/rust-lib/flowy-folder/src/view_operation.rs b/frontend/rust-lib/flowy-folder/src/view_operation.rs index c5dfcf6007..17919e07b1 100644 --- a/frontend/rust-lib/flowy-folder/src/view_operation.rs +++ b/frontend/rust-lib/flowy-folder/src/view_operation.rs @@ -1,49 +1,76 @@ -use std::collections::HashMap; -use std::sync::Arc; - +use async_trait::async_trait; use bytes::Bytes; - +use collab::entity::EncodedCollab; +use collab_entity::CollabType; +use collab_folder::hierarchy_builder::NestedViewBuilder; pub use collab_folder::View; use collab_folder::ViewLayout; -use tokio::sync::RwLock; - +use dashmap::DashMap; use flowy_error::FlowyError; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; -use flowy_folder_pub::folder_builder::NestedViewBuilder; -use lib_infra::future::FutureResult; use lib_infra::util::timestamp; use crate::entities::{CreateViewParams, ViewLayoutPB}; +use crate::manager::FolderUser; use crate::share::ImportType; -pub type ViewData = Bytes; +#[derive(Debug, Clone)] +pub enum GatherEncodedCollab { + Document(EncodedCollab), + Database(DatabaseEncodedCollab), + Unknown, +} + +#[derive(Debug, Clone)] +pub struct DatabaseEncodedCollab { + pub database_encoded_collab: EncodedCollab, + pub database_row_encoded_collabs: HashMap, + pub database_row_document_encoded_collabs: HashMap, + pub database_relations: HashMap, +} + +pub type ImportedData = (String, CollabType, EncodedCollab); /// The handler will be used to handler the folder operation for a specific /// view layout. Each [ViewLayout] will have a handler. So when creating a new /// view, the [ViewLayout] will be used to get the handler. -/// -pub trait FolderOperationHandler { +#[async_trait] +pub trait FolderOperationHandler: Send + Sync { + fn name(&self) -> &str; /// Create the view for the workspace of new user. /// Only called once when the user is created. - fn create_workspace_view( + async fn create_workspace_view( &self, _uid: i64, _workspace_view_builder: Arc>, - ) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) + ) -> Result<(), FlowyError> { + Ok(()) } - fn open_view(&self, view_id: &str) -> FutureResult<(), FlowyError>; + async fn open_view(&self, view_id: &Uuid) -> Result<(), FlowyError>; /// Closes the view and releases the resources that this view has in /// the backend - fn close_view(&self, view_id: &str) -> FutureResult<(), FlowyError>; + async fn close_view(&self, view_id: &Uuid) -> Result<(), FlowyError>; /// Called when the view is deleted. /// This will called after the view is deleted from the trash. - fn delete_view(&self, view_id: &str) -> FutureResult<(), FlowyError>; + async fn delete_view(&self, view_id: &Uuid) -> Result<(), FlowyError>; /// Returns the [ViewData] that can be used to create the same view. - fn duplicate_view(&self, view_id: &str) -> FutureResult; + async fn duplicate_view(&self, view_id: &Uuid) -> Result; + + /// get the encoded collab data from the disk. + async fn gather_publish_encode_collab( + &self, + _user: &Arc, + _view_id: &Uuid, + ) -> Result { + Err(FlowyError::not_support()) + } /// Create a view with the data. /// @@ -60,53 +87,55 @@ pub trait FolderOperationHandler { /// * `layout`: the layout of the view /// * `meta`: use to carry extra information. For example, the database view will use this /// to carry the reference database id. - fn create_view_with_view_data( + /// + /// The return value is the [Option] that can be used to create the view. + /// It can be used in syncing the view data to cloud. + async fn create_view_with_view_data( &self, user_id: i64, - view_id: &str, - name: &str, - data: Vec, - layout: ViewLayout, - meta: HashMap, - ) -> FutureResult<(), FlowyError>; + params: CreateViewParams, + ) -> Result, FlowyError>; /// Create a view with the pre-defined data. /// For example, the initial data of the grid/calendar/kanban board when /// you create a new view. - fn create_built_in_view( + async fn create_default_view( &self, user_id: i64, - view_id: &str, + parent_view_id: &Uuid, + view_id: &Uuid, name: &str, layout: ViewLayout, - ) -> FutureResult<(), FlowyError>; + ) -> Result<(), FlowyError>; /// Create a view by importing data - fn import_from_bytes( + /// + /// The return value + async fn import_from_bytes( &self, uid: i64, - view_id: &str, + view_id: &Uuid, name: &str, import_type: ImportType, bytes: Vec, - ) -> FutureResult<(), FlowyError>; + ) -> Result, FlowyError>; /// Create a view by importing data from a file - fn import_from_file_path( + async fn import_from_file_path( &self, view_id: &str, name: &str, path: String, - ) -> FutureResult<(), FlowyError>; + ) -> Result<(), FlowyError>; /// Called when the view is updated. The handler is the `old` registered handler. - fn did_update_view(&self, _old: &View, _new: &View) -> FutureResult<(), FlowyError> { - FutureResult::new(async move { Ok(()) }) + async fn did_update_view(&self, _old: &View, _new: &View) -> Result<(), FlowyError> { + Ok(()) } } pub type FolderOperationHandlers = - Arc>>; + Arc>>; impl From for ViewLayout { fn from(pb: ViewLayoutPB) -> Self { @@ -115,6 +144,7 @@ impl From for ViewLayout { ViewLayoutPB::Grid => ViewLayout::Grid, ViewLayoutPB::Board => ViewLayout::Board, ViewLayoutPB::Calendar => ViewLayout::Calendar, + ViewLayoutPB::Chat => ViewLayout::Chat, } } } @@ -122,18 +152,37 @@ impl From for ViewLayout { pub(crate) fn create_view(uid: i64, params: CreateViewParams, layout: ViewLayout) -> View { let time = timestamp(); View { - id: params.view_id, - parent_view_id: params.parent_view_id, + id: params.view_id.to_string(), + parent_view_id: params.parent_view_id.to_string(), name: params.name, - desc: params.desc, - children: Default::default(), created_at: time, is_favorite: false, layout, - icon: None, + icon: params.icon, created_by: Some(uid), last_edited_time: 0, last_edited_by: Some(uid), - extra: None, + extra: params.extra, + children: Default::default(), + is_locked: None, + } +} + +#[derive(Debug, Clone)] +pub enum ViewData { + /// Indicate the data is duplicated from another view. + DuplicateData(Bytes), + /// Indicate the data is created by the user. + Data(Bytes), + Empty, +} + +impl ViewData { + pub fn is_empty(&self) -> bool { + match self { + ViewData::DuplicateData(data) => data.is_empty(), + ViewData::Data(data) => data.is_empty(), + ViewData::Empty => true, + } } } diff --git a/frontend/rust-lib/flowy-notification/Cargo.toml b/frontend/rust-lib/flowy-notification/Cargo.toml index b459c9afbf..3851546541 100644 --- a/frontend/rust-lib/flowy-notification/Cargo.toml +++ b/frontend/rust-lib/flowy-notification/Cargo.toml @@ -13,7 +13,7 @@ protobuf.workspace = true tracing.workspace = true bytes.workspace = true serde = { workspace = true, features = ["derive"] } -dashmap = "5.5" +dashmap.workspace = true tokio-util = "0.7" tokio = { workspace = true, features = ["time"] } @@ -25,5 +25,3 @@ flowy-codegen.workspace = true [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] -web_ts = ["flowy-codegen/ts"] diff --git a/frontend/rust-lib/flowy-notification/build.rs b/frontend/rust-lib/flowy-notification/build.rs index 0be74ea9bc..8dfda67156 100644 --- a/frontend/rust-lib/flowy-notification/build.rs +++ b/frontend/rust-lib/flowy-notification/build.rs @@ -1,27 +1,4 @@ fn main() { #[cfg(feature = "dart")] flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } - - #[cfg(feature = "web_ts")] - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - "notification", - flowy_codegen::Project::Web { - relative_path: "../../".to_string(), - }, - ); } diff --git a/frontend/rust-lib/flowy-search-pub/Cargo.toml b/frontend/rust-lib/flowy-search-pub/Cargo.toml index 1a534d16b3..907942303d 100644 --- a/frontend/rust-lib/flowy-search-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-search-pub/Cargo.toml @@ -6,7 +6,9 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +lib-infra = { workspace = true } collab = { workspace = true } collab-folder = { workspace = true } - flowy-error = { workspace = true } +client-api = { workspace = true } +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-search-pub/src/cloud.rs b/frontend/rust-lib/flowy-search-pub/src/cloud.rs new file mode 100644 index 0000000000..8108cbed9a --- /dev/null +++ b/frontend/rust-lib/flowy-search-pub/src/cloud.rs @@ -0,0 +1,22 @@ +pub use client_api::entity::search_dto::{ + SearchDocumentResponseItem, SearchResult, SearchSummaryResult, +}; +use flowy_error::FlowyError; +use lib_infra::async_trait::async_trait; +use uuid::Uuid; + +#[async_trait] +pub trait SearchCloudService: Send + Sync + 'static { + async fn document_search( + &self, + workspace_id: &Uuid, + query: String, + ) -> Result, FlowyError>; + + async fn generate_search_summary( + &self, + workspace_id: &Uuid, + query: String, + search_results: Vec, + ) -> Result; +} diff --git a/frontend/rust-lib/flowy-search-pub/src/entities.rs b/frontend/rust-lib/flowy-search-pub/src/entities.rs index dfa741bf50..fc4c19359c 100644 --- a/frontend/rust-lib/flowy-search-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-search-pub/src/entities.rs @@ -1,28 +1,51 @@ -use std::any::Any; use std::sync::Arc; use collab::core::collab::IndexContentReceiver; -use collab_folder::{View, ViewIcon, ViewLayout}; +use collab_folder::{folder_diff::FolderViewChange, View, ViewIcon, ViewLayout}; use flowy_error::FlowyError; +use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub struct IndexableData { pub id: String, pub data: String, pub icon: Option, pub layout: ViewLayout, - pub workspace_id: String, + pub workspace_id: Uuid, } +impl IndexableData { + pub fn from_view(view: Arc, workspace_id: Uuid) -> Self { + IndexableData { + id: view.id.clone(), + data: view.name.clone(), + icon: view.icon.clone(), + layout: view.layout.clone(), + workspace_id, + } + } +} + +#[async_trait] pub trait IndexManager: Send + Sync { - fn set_index_content_receiver(&self, rx: IndexContentReceiver, workspace_id: String); - fn add_index(&self, data: IndexableData) -> Result<(), FlowyError>; - fn update_index(&self, data: IndexableData) -> Result<(), FlowyError>; - fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError>; - fn is_indexed(&self) -> bool; - - fn as_any(&self) -> &dyn Any; + async fn set_index_content_receiver(&self, rx: IndexContentReceiver, workspace_id: Uuid); + async fn add_index(&self, data: IndexableData) -> Result<(), FlowyError>; + async fn update_index(&self, data: IndexableData) -> Result<(), FlowyError>; + async fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError>; + async fn remove_indices_for_workspace(&self, workspace_id: Uuid) -> Result<(), FlowyError>; + async fn is_indexed(&self) -> bool; } +#[async_trait] pub trait FolderIndexManager: IndexManager { - fn index_all_views(&self, views: Vec>, workspace_id: String); + async fn initialize(&self); + + fn index_all_views(&self, views: Vec>, workspace_id: Uuid); + + fn index_view_changes( + &self, + views: Vec>, + changes: Vec, + workspace_id: Uuid, + ); } diff --git a/frontend/rust-lib/flowy-search-pub/src/lib.rs b/frontend/rust-lib/flowy-search-pub/src/lib.rs index 0b8f0b5a5a..ee0ade69c4 100644 --- a/frontend/rust-lib/flowy-search-pub/src/lib.rs +++ b/frontend/rust-lib/flowy-search-pub/src/lib.rs @@ -1 +1,2 @@ +pub mod cloud; pub mod entities; diff --git a/frontend/rust-lib/flowy-search/Cargo.toml b/frontend/rust-lib/flowy-search/Cargo.toml index ac015fb324..a803ad894f 100644 --- a/frontend/rust-lib/flowy-search/Cargo.toml +++ b/frontend/rust-lib/flowy-search/Cargo.toml @@ -11,43 +11,35 @@ collab-folder = { workspace = true } flowy-derive.workspace = true flowy-error = { workspace = true, features = [ - "impl_from_sqlite", - "impl_from_dispatch_error", - "impl_from_collab_document", - "impl_from_tantivy", - "impl_from_serde", + "impl_from_sqlite", + "impl_from_dispatch_error", + "impl_from_collab_document", + "impl_from_tantivy", + "impl_from_serde", ] } -flowy-notification.workspace = true -flowy-sqlite.workspace = true flowy-user.workspace = true flowy-search-pub.workspace = true - +flowy-folder = { workspace = true } bytes.workspace = true -futures.workspace = true lib-dispatch.workspace = true +lib-infra = { workspace = true } protobuf.workspace = true serde.workspace = true serde_json.workspace = true tokio = { workspace = true, features = ["full", "rt-multi-thread", "tracing"] } tracing.workspace = true - -async-stream = "0.3.4" +derive_builder.workspace = true strsim = "0.11.0" strum_macros = "0.26.1" -tantivy = { version = "0.21.1" } -tempfile = "3.9.0" -validator = { version = "0.16.0", features = ["derive"] } - -diesel.workspace = true -diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } -diesel_migrations = { version = "2.1.0", features = ["sqlite"] } +tantivy.workspace = true +uuid.workspace = true +allo-isolate = { version = "^0.1", features = ["catch-unwind"] } +futures.workspace = true +tokio-stream.workspace = true +async-stream = "0.3.6" [build-dependencies] flowy-codegen.workspace = true -[dev-dependencies] -tempfile = "3.10.0" - [features] dart = ["flowy-codegen/dart"] -tauri_ts = ["flowy-codegen/ts"] diff --git a/frontend/rust-lib/flowy-search/build.rs b/frontend/rust-lib/flowy-search/build.rs index 2600d32fb7..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-search/build.rs +++ b/frontend/rust-lib/flowy-search/build.rs @@ -1,19 +1,7 @@ -#[cfg(feature = "tauri_ts")] -use flowy_codegen::Project; - fn main() { - #[cfg(any(feature = "dart", feature = "tauri_ts"))] - let crate_name = env!("CARGO_PKG_NAME"); - #[cfg(feature = "dart")] { - flowy_codegen::protobuf_file::dart_gen(crate_name); - flowy_codegen::dart_event::gen(crate_name); - } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::protobuf_file::ts_gen(crate_name, crate_name, Project::Tauri); - flowy_codegen::ts_event::gen(crate_name, Project::Tauri); + flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); + flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } } diff --git a/frontend/rust-lib/flowy-search/src/document/handler.rs b/frontend/rust-lib/flowy-search/src/document/handler.rs new file mode 100644 index 0000000000..2127ef0d98 --- /dev/null +++ b/frontend/rust-lib/flowy-search/src/document/handler.rs @@ -0,0 +1,192 @@ +use crate::entities::{ + CreateSearchResultPBArgs, RepeatedSearchResponseItemPB, RepeatedSearchSummaryPB, + SearchResponsePB, SearchSourcePB, SearchSummaryPB, +}; +use crate::{ + entities::{ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResponseItemPB}, + services::manager::{SearchHandler, SearchType}, +}; +use async_stream::stream; +use flowy_error::FlowyResult; +use flowy_folder::entities::ViewPB; +use flowy_folder::{manager::FolderManager, ViewLayout}; +use flowy_search_pub::cloud::{SearchCloudService, SearchResult}; +use lib_infra::async_trait::async_trait; +use std::pin::Pin; +use std::str::FromStr; +use std::sync::Arc; +use tokio_stream::{self, Stream}; +use tracing::{trace, warn}; +use uuid::Uuid; + +pub struct DocumentSearchHandler { + pub cloud_service: Arc, + pub folder_manager: Arc, +} + +impl DocumentSearchHandler { + pub fn new( + cloud_service: Arc, + folder_manager: Arc, + ) -> Self { + Self { + cloud_service, + folder_manager, + } + } +} +#[async_trait] +impl SearchHandler for DocumentSearchHandler { + fn search_type(&self) -> SearchType { + SearchType::Document + } + + async fn perform_search( + &self, + query: String, + filter: Option, + ) -> Pin> + Send + 'static>> { + let cloud_service = self.cloud_service.clone(); + let folder_manager = self.folder_manager.clone(); + + Box::pin(stream! { + // Exit early if there is no filter. + let filter = if let Some(f) = filter { + f + } else { + yield Ok(CreateSearchResultPBArgs::default().build().unwrap()); + return; + }; + + // Parse workspace id. + let workspace_id = match Uuid::from_str(&filter.workspace_id) { + Ok(id) => id, + Err(e) => { + yield Err(e.into()); + return; + } + }; + + // Retrieve all available views. + let views = match folder_manager.get_all_views_pb().await { + Ok(views) => views, + Err(e) => { + yield Err(e); + return; + } + }; + + // Execute document search. + yield Ok( + CreateSearchResultPBArgs::default().searching(true) + .build() + .unwrap(), + ); + + let result_items = match cloud_service.document_search(&workspace_id, query.clone()).await { + Ok(items) => items, + Err(e) => { + yield Err(e); + return; + } + }; + trace!("[Search] search result: {:?}", result_items); + + // Prepare input for search summary generation. + let summary_input: Vec = result_items + .iter() + .map(|v| SearchResult { + object_id: v.object_id, + content: v.content.clone(), + }) + .collect(); + + // Build search response items. + let mut items: Vec = Vec::new(); + for item in &result_items { + if let Some(view) = views.iter().find(|v| v.id == item.object_id.to_string()) { + items.push(SearchResponseItemPB { + id: item.object_id.to_string(), + display_name: view.name.clone(), + icon: extract_icon(view), + workspace_id: item.workspace_id.to_string(), + content: item.content.clone()} + ); + } else { + warn!("No view found for search result: {:?}", item); + } + } + + // Yield primary search result. + let search_result = RepeatedSearchResponseItemPB { items }; + yield Ok( + CreateSearchResultPBArgs::default() + .searching(false) + .search_result(Some(search_result)) + .generating_ai_summary(!result_items.is_empty()) + .build() + .unwrap(), + ); + + if result_items.is_empty() { + return; + } + + // Generate and yield search summary. + match cloud_service.generate_search_summary(&workspace_id, query.clone(), summary_input).await { + Ok(summary_result) => { + trace!("[Search] search summary: {:?}", summary_result); + let summaries: Vec = summary_result + .summaries + .into_iter() + .map(|v| { + let sources: Vec = v.sources + .iter() + .flat_map(|id| { + views.iter().find(|v| v.id == id.to_string()).map(|view| SearchSourcePB { + id: id.to_string(), + display_name: view.name.clone(), + icon: extract_icon(view), + }) + }) + .collect(); + + SearchSummaryPB { content: v.content, sources, highlights: v.highlights } + }) + .collect(); + + let summary_result = RepeatedSearchSummaryPB { items: summaries }; + yield Ok( + CreateSearchResultPBArgs::default() + .search_summary(Some(summary_result)) + .generating_ai_summary(false) + .build() + .unwrap(), + ); + } + Err(e) => { + warn!("Failed to generate search summary: {:?}", e); + yield Ok( + CreateSearchResultPBArgs::default() + .generating_ai_summary(false) + .build() + .unwrap(), + ); + } + } + }) + } +} + +fn extract_icon(view: &ViewPB) -> Option { + match view.icon.clone() { + Some(view_icon) => Some(ResultIconPB::from(view_icon)), + None => { + let view_layout_ty: i64 = ViewLayout::from(view.layout.clone()).into(); + Some(ResultIconPB { + ty: ResultIconTypePB::Icon, + value: view_layout_ty.to_string(), + }) + }, + } +} diff --git a/frontend/rust-lib/flowy-search/src/document/mod.rs b/frontend/rust-lib/flowy-search/src/document/mod.rs new file mode 100644 index 0000000000..062ae9d9be --- /dev/null +++ b/frontend/rust-lib/flowy-search/src/document/mod.rs @@ -0,0 +1 @@ +pub mod handler; diff --git a/frontend/rust-lib/flowy-search/src/entities/index_type.rs b/frontend/rust-lib/flowy-search/src/entities/index_type.rs deleted file mode 100644 index 0f7a7de2e5..0000000000 --- a/frontend/rust-lib/flowy-search/src/entities/index_type.rs +++ /dev/null @@ -1,30 +0,0 @@ -use flowy_derive::ProtoBuf_Enum; - -#[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] -pub enum IndexTypePB { - View = 0, - DocumentBlock = 1, - DatabaseRow = 2, -} - -impl Default for IndexTypePB { - fn default() -> Self { - Self::View - } -} - -impl std::convert::From for i32 { - fn from(notification: IndexTypePB) -> Self { - notification as i32 - } -} - -impl std::convert::From for IndexTypePB { - fn from(notification: i32) -> Self { - match notification { - 1 => IndexTypePB::View, - 2 => IndexTypePB::DocumentBlock, - _ => IndexTypePB::DatabaseRow, - } - } -} diff --git a/frontend/rust-lib/flowy-search/src/entities/mod.rs b/frontend/rust-lib/flowy-search/src/entities/mod.rs index b4d7c682b9..dc6aaace08 100644 --- a/frontend/rust-lib/flowy-search/src/entities/mod.rs +++ b/frontend/rust-lib/flowy-search/src/entities/mod.rs @@ -1,10 +1,8 @@ -mod index_type; mod notification; mod query; mod result; mod search_filter; -pub use index_type::*; pub use notification::*; pub use query::*; pub use result::*; diff --git a/frontend/rust-lib/flowy-search/src/entities/notification.rs b/frontend/rust-lib/flowy-search/src/entities/notification.rs index 64b9872f93..4f12305d9a 100644 --- a/frontend/rust-lib/flowy-search/src/entities/notification.rs +++ b/frontend/rust-lib/flowy-search/src/entities/notification.rs @@ -1,17 +1,13 @@ +use super::SearchResponsePB; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; -use super::SearchResultPB; - #[derive(ProtoBuf, Default, Debug, Clone)] -pub struct SearchResultNotificationPB { - #[pb(index = 1)] - pub items: Vec, +pub struct SearchStatePB { + #[pb(index = 1, one_of)] + pub response: Option, #[pb(index = 2)] - pub closed: bool, - - #[pb(index = 3, one_of)] - pub channel: Option, + pub search_id: String, } #[derive(ProtoBuf_Enum, Debug, Default)] @@ -19,7 +15,6 @@ pub enum SearchNotification { #[default] Unknown = 0, DidUpdateResults = 1, - DidCloseResults = 2, } impl std::convert::From for i32 { @@ -32,7 +27,6 @@ impl std::convert::From for SearchNotification { fn from(notification: i32) -> Self { match notification { 1 => SearchNotification::DidUpdateResults, - 2 => SearchNotification::DidCloseResults, _ => SearchNotification::Unknown, } } diff --git a/frontend/rust-lib/flowy-search/src/entities/query.rs b/frontend/rust-lib/flowy-search/src/entities/query.rs index 8ffbcf3d46..65c92ebed0 100644 --- a/frontend/rust-lib/flowy-search/src/entities/query.rs +++ b/frontend/rust-lib/flowy-search/src/entities/query.rs @@ -13,13 +13,9 @@ pub struct SearchQueryPB { #[pb(index = 3, one_of)] pub filter: Option, - /// Used to identify the channel of the search - /// - /// This can be used to have multiple search notification listeners in place. - /// It is up to the client to decide how to handle this. - /// - /// If not set, then no channel is used. - /// - #[pb(index = 4, one_of)] - pub channel: Option, + #[pb(index = 4)] + pub search_id: String, + + #[pb(index = 5)] + pub stream_port: i64, } diff --git a/frontend/rust-lib/flowy-search/src/entities/result.rs b/frontend/rust-lib/flowy-search/src/entities/result.rs index 4830057ee9..a01f01b074 100644 --- a/frontend/rust-lib/flowy-search/src/entities/result.rs +++ b/frontend/rust-lib/flowy-search/src/entities/result.rs @@ -1,50 +1,106 @@ use collab_folder::{IconType, ViewIcon}; +use derive_builder::Builder; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; +use flowy_folder::entities::ViewIconPB; -use super::IndexTypePB; +#[derive(Debug, Default, ProtoBuf, Builder, Clone)] +#[builder(name = "CreateSearchResultPBArgs")] +#[builder(pattern = "mutable")] +pub struct SearchResponsePB { + #[pb(index = 1, one_of)] + #[builder(default)] + pub search_result: Option, -#[derive(Debug, Default, ProtoBuf, Clone)] -pub struct RepeatedSearchResultPB { - #[pb(index = 1)] - pub items: Vec, + #[pb(index = 2, one_of)] + #[builder(default)] + pub search_summary: Option, + + #[pb(index = 3, one_of)] + #[builder(default)] + pub local_search_result: Option, + + #[pb(index = 4)] + #[builder(default)] + pub searching: bool, + + #[pb(index = 5)] + #[builder(default)] + pub generating_ai_summary: bool, } #[derive(ProtoBuf, Default, Debug, Clone)] -pub struct SearchResultPB { +pub struct RepeatedSearchSummaryPB { #[pb(index = 1)] - pub index_type: IndexTypePB, - - #[pb(index = 2)] - pub view_id: String, - - #[pb(index = 3)] - pub id: String, - - #[pb(index = 4)] - pub data: String, - - #[pb(index = 5, one_of)] - pub icon: Option, - - #[pb(index = 6)] - pub score: f64, - - #[pb(index = 7)] - pub workspace_id: String, + pub items: Vec, } -impl SearchResultPB { - pub fn with_score(&self, score: f64) -> Self { - SearchResultPB { - index_type: self.index_type.clone(), - view_id: self.view_id.clone(), - id: self.id.clone(), - data: self.data.clone(), - icon: self.icon.clone(), - score, - workspace_id: self.workspace_id.clone(), - } - } +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct SearchSummaryPB { + #[pb(index = 1)] + pub content: String, + + #[pb(index = 2)] + pub sources: Vec, + + #[pb(index = 3)] + pub highlights: String, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct SearchSourcePB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub display_name: String, + + #[pb(index = 3, one_of)] + pub icon: Option, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct RepeatedSearchResponseItemPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct SearchResponseItemPB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub display_name: String, + + #[pb(index = 3, one_of)] + pub icon: Option, + + #[pb(index = 4)] + pub workspace_id: String, + + #[pb(index = 5)] + pub content: String, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct RepeatedLocalSearchResponseItemPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct LocalSearchResponseItemPB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub display_name: String, + + #[pb(index = 3, one_of)] + pub icon: Option, + + #[pb(index = 4)] + pub workspace_id: String, } #[derive(ProtoBuf_Enum, Clone, Debug, PartialEq, Eq, Default)] @@ -122,3 +178,12 @@ impl From for ResultIconPB { } } } + +impl From for ResultIconPB { + fn from(val: ViewIconPB) -> Self { + ResultIconPB { + ty: IconType::from(val.ty).into(), + value: val.value, + } + } +} diff --git a/frontend/rust-lib/flowy-search/src/entities/search_filter.rs b/frontend/rust-lib/flowy-search/src/entities/search_filter.rs index 33031b3b2c..2059971a0d 100644 --- a/frontend/rust-lib/flowy-search/src/entities/search_filter.rs +++ b/frontend/rust-lib/flowy-search/src/entities/search_filter.rs @@ -2,6 +2,6 @@ use flowy_derive::ProtoBuf; #[derive(Eq, PartialEq, ProtoBuf, Default, Debug, Clone)] pub struct SearchFilterPB { - #[pb(index = 1, one_of)] - pub workspace_id: Option, + #[pb(index = 1)] + pub workspace_id: String, } diff --git a/frontend/rust-lib/flowy-search/src/event_handler.rs b/frontend/rust-lib/flowy-search/src/event_handler.rs index de611a078f..d79a719f6f 100644 --- a/frontend/rust-lib/flowy-search/src/event_handler.rs +++ b/frontend/rust-lib/flowy-search/src/event_handler.rs @@ -21,7 +21,14 @@ pub(crate) async fn search_handler( ) -> Result<(), FlowyError> { let query = data.into_inner(); let manager = upgrade_manager(manager)?; - manager.perform_search(query.search, query.filter, query.channel); + manager + .perform_search( + query.search, + query.stream_port, + query.filter, + query.search_id, + ) + .await; Ok(()) } diff --git a/frontend/rust-lib/flowy-search/src/folder/entities.rs b/frontend/rust-lib/flowy-search/src/folder/entities.rs index 15123bcbd9..1bb763b4a6 100644 --- a/frontend/rust-lib/flowy-search/src/folder/entities.rs +++ b/frontend/rust-lib/flowy-search/src/folder/entities.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::entities::{IndexTypePB, ResultIconPB, SearchResultPB}; +use crate::entities::{LocalSearchResponseItemPB, ResultIconPB}; #[derive(Debug, Serialize, Deserialize)] pub struct FolderIndexData { @@ -11,7 +11,7 @@ pub struct FolderIndexData { pub workspace_id: String, } -impl From for SearchResultPB { +impl From for LocalSearchResponseItemPB { fn from(data: FolderIndexData) -> Self { let icon = if data.icon.is_empty() { None @@ -23,11 +23,8 @@ impl From for SearchResultPB { }; Self { - index_type: IndexTypePB::View, - view_id: data.id.clone(), id: data.id, - data: data.title, - score: 0.0, + display_name: data.title, icon, workspace_id: data.workspace_id, } diff --git a/frontend/rust-lib/flowy-search/src/folder/handler.rs b/frontend/rust-lib/flowy-search/src/folder/handler.rs index c97f258105..e21ce1c98c 100644 --- a/frontend/rust-lib/flowy-search/src/folder/handler.rs +++ b/frontend/rust-lib/flowy-search/src/folder/handler.rs @@ -1,11 +1,14 @@ -use crate::{ - entities::{SearchFilterPB, SearchResultPB}, - services::manager::{SearchHandler, SearchType}, -}; -use flowy_error::FlowyResult; -use std::sync::Arc; - use super::indexer::FolderIndexManagerImpl; +use crate::entities::{ + CreateSearchResultPBArgs, RepeatedLocalSearchResponseItemPB, SearchFilterPB, SearchResponsePB, +}; +use crate::services::manager::{SearchHandler, SearchType}; +use async_stream::stream; +use flowy_error::FlowyResult; +use lib_infra::async_trait::async_trait; +use std::pin::Pin; +use std::sync::Arc; +use tokio_stream::{self, Stream}; pub struct FolderSearchHandler { pub index_manager: Arc, @@ -17,28 +20,36 @@ impl FolderSearchHandler { } } +#[async_trait] impl SearchHandler for FolderSearchHandler { fn search_type(&self) -> SearchType { SearchType::Folder } - fn perform_search( + async fn perform_search( &self, query: String, filter: Option, - ) -> FlowyResult> { - let mut results = self.index_manager.search(query, filter.clone())?; - if let Some(filter) = filter { - if let Some(workspace_id) = filter.workspace_id { - // Filter results by workspace ID - results.retain(|result| result.workspace_id == workspace_id); - } - } + ) -> Pin> + Send + 'static>> { + let index_manager = self.index_manager.clone(); - Ok(results) - } + Box::pin(stream! { + // Perform search (if search() returns a Result) + let mut items = match index_manager.search(query).await { + Ok(items) => items, + Err(err) => { + yield Err(err); + return; + } + }; - fn index_count(&self) -> u64 { - self.index_manager.num_docs() + if let Some(filter) = filter { + items.retain(|result| result.workspace_id == filter.workspace_id); + } + + // Build the search result. + let search_result = RepeatedLocalSearchResponseItemPB {items}; + yield Ok(CreateSearchResultPBArgs::default().local_search_result(Some(search_result)).build().unwrap()) + }) } } diff --git a/frontend/rust-lib/flowy-search/src/folder/indexer.rs b/frontend/rust-lib/flowy-search/src/folder/indexer.rs index 834efe949a..71ac5d5e60 100644 --- a/frontend/rust-lib/flowy-search/src/folder/indexer.rs +++ b/frontend/rust-lib/flowy-search/src/folder/indexer.rs @@ -1,199 +1,126 @@ -use std::{ - any::Any, - collections::HashMap, - fs, - ops::Deref, - path::Path, - sync::{Arc, Mutex, MutexGuard, Weak}, -}; - -use crate::{ - entities::{ResultIconTypePB, SearchFilterPB, SearchResultPB}, - folder::schema::{ - FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME, - FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME, - }, +use super::entities::FolderIndexData; +use crate::entities::{LocalSearchResponseItemPB, ResultIconTypePB}; +use crate::folder::schema::{ + FolderSchema, FOLDER_ICON_FIELD_NAME, FOLDER_ICON_TY_FIELD_NAME, FOLDER_ID_FIELD_NAME, + FOLDER_TITLE_FIELD_NAME, FOLDER_WORKSPACE_ID_FIELD_NAME, }; use collab::core::collab::{IndexContent, IndexContentReceiver}; -use collab_folder::{View, ViewIcon, ViewIndexContent, ViewLayout}; +use collab_folder::{folder_diff::FolderViewChange, View, ViewIcon, ViewIndexContent, ViewLayout}; use flowy_error::{FlowyError, FlowyResult}; use flowy_search_pub::entities::{FolderIndexManager, IndexManager, IndexableData}; use flowy_user::services::authenticate_user::AuthenticateUser; -use lib_dispatch::prelude::af_spawn; -use strsim::levenshtein; +use lib_infra::async_trait::async_trait; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; +use std::{collections::HashMap, fs}; use tantivy::{ - collector::TopDocs, directory::MmapDirectory, doc, query::QueryParser, Index, IndexReader, - IndexWriter, Term, + collector::TopDocs, directory::MmapDirectory, doc, query::QueryParser, schema::Field, Document, + Index, IndexReader, IndexWriter, TantivyDocument, TantivyError, Term, }; +use tokio::sync::RwLock; +use tracing::{error, info}; +use uuid::Uuid; -use super::entities::FolderIndexData; +pub struct TantivyState { + pub path: PathBuf, + pub index: Index, + pub folder_schema: FolderSchema, + pub index_reader: IndexReader, + pub index_writer: IndexWriter, +} -#[derive(Clone)] -pub struct FolderIndexManagerImpl { - folder_schema: Option, - index: Option, - index_reader: Option, - index_writer: Option>>, +impl Drop for TantivyState { + fn drop(&mut self) { + tracing::trace!("Dropping TantivyState at {:?}", self.path); + } } const FOLDER_INDEX_DIR: &str = "folder_index"; +#[derive(Clone)] +pub struct FolderIndexManagerImpl { + auth_user: Weak, + state: Arc>>, +} + impl FolderIndexManagerImpl { - pub fn new(auth_user: Option>) -> Self { - // TODO(Mathias): Temporarily disable seaerch - let auth_user = match auth_user { - Some(auth_user) => auth_user, - None => { - return FolderIndexManagerImpl::empty(); - }, - }; - - // AuthenticateUser is required to get the index path - let authenticate_user = auth_user.upgrade(); - - // Storage path is the users data path with an index directory - // Eg. /usr/flowy-data/indexes - let storage_path = match authenticate_user { - Some(auth_user) => auth_user.get_index_path(), - None => { - tracing::error!("FolderIndexManager: AuthenticateUser is not available"); - return FolderIndexManagerImpl::empty(); - }, - }; - - // We check if the `folder_index` directory exists, if not we create it - let index_path = storage_path.join(Path::new(FOLDER_INDEX_DIR)); - if !index_path.exists() { - let res = fs::create_dir_all(&index_path); - if let Err(e) = res { - tracing::error!( - "FolderIndexManager failed to create index directory: {:?}", - e - ); - return FolderIndexManagerImpl::empty(); - } - } - - // We open the existing or newly created folder_index directory - // This is required by the Tantivy Index, as it will use it to store - // and read index data - let dir = MmapDirectory::open(index_path); - if let Err(e) = dir { - tracing::error!("FolderIndexManager failed to open index directory: {:?}", e); - return FolderIndexManagerImpl::empty(); - } - - // The folder schema is used to define the fields of the index along - // with how they are stored and if the field is indexed - let folder_schema = FolderSchema::new(); - - // We open or create an index that takes the directory r/w and the schema. - let index_res = Index::open_or_create(dir.unwrap(), folder_schema.schema.clone()); - if let Err(e) = index_res { - tracing::error!("FolderIndexManager failed to open index: {:?}", e); - return FolderIndexManagerImpl::empty(); - } - - let index = index_res.unwrap(); - - // We read the index reader, we only need one IndexReader per index - let index_reader = index.reader(); - if let Err(e) = index_reader { - tracing::error!( - "FolderIndexManager failed to instantiate index reader: {:?}", - e - ); - return FolderIndexManagerImpl::empty(); - } - - let index_writer = index.writer(50_000_000); - if let Err(e) = index_writer { - tracing::error!( - "FolderIndexManager failed to instantiate index writer: {:?}", - e - ); - return FolderIndexManagerImpl::empty(); - } - + pub fn new(auth_user: Weak) -> Self { Self { - folder_schema: Some(folder_schema), - index: Some(index), - index_reader: Some(index_reader.unwrap()), - index_writer: Some(Arc::new(Mutex::new(index_writer.unwrap()))), + auth_user, + state: Arc::new(RwLock::new(None)), } } - fn index_all(&self, indexes: Vec) -> Result<(), FlowyError> { - if self.is_indexed() || indexes.is_empty() { - return Ok(()); + async fn with_writer(&self, f: F) -> FlowyResult + where + F: FnOnce(&mut IndexWriter, &FolderSchema) -> FlowyResult, + { + let mut lock = self.state.write().await; + if let Some(ref mut state) = *lock { + f(&mut state.index_writer, &state.folder_schema) + } else { + Err(FlowyError::internal().with_context("Index not initialized. Call initialize first")) + } + } + + /// Initializes the state using the workspace directory. + async fn initialize(&self) -> FlowyResult<()> { + if let Some(state) = self.state.write().await.take() { + info!("Re-initializing folder indexer"); + drop(state); } - let mut index_writer = self.get_index_writer()?; + // Since the directory lock may not be immediately released, + // a workaround is implemented by waiting for 3 seconds before proceeding further. This delay helps + // to avoid errors related to trying to open an index directory while an IndexWriter is still active. + // + // Also, we don't need to initialize the indexer immediately. + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; - let folder_schema = self.get_folder_schema()?; + let auth_user = self + .auth_user + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("AuthenticateUser is not available"))?; - let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; - let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; - let icon_field = folder_schema.schema.get_field(FOLDER_ICON_FIELD_NAME)?; - let icon_ty_field = folder_schema.schema.get_field(FOLDER_ICON_TY_FIELD_NAME)?; - let workspace_id_field = folder_schema - .schema - .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; - - for data in indexes { - let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); - - let _ = index_writer.add_document(doc![ - id_field => data.id.clone(), - title_field => data.data.clone(), - icon_field => icon.unwrap_or_default(), - icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id.clone(), - ]); + let index_path = auth_user.get_index_path()?.join(FOLDER_INDEX_DIR); + if !index_path.exists() { + fs::create_dir_all(&index_path).map_err(|e| { + error!("Failed to create folder index directory: {:?}", e); + FlowyError::internal().with_context("Failed to create folder index") + })?; } - index_writer.commit()?; + info!("Folder indexer initialized at: {:?}", index_path); + let folder_schema = FolderSchema::new(); + let dir = MmapDirectory::open(index_path.clone())?; + let index = Index::open_or_create(dir, folder_schema.schema.clone())?; + let index_reader = index.reader()?; + + let index_writer = match index.writer::<_>(50_000_000) { + Ok(index_writer) => index_writer, + Err(err) => { + if let TantivyError::LockFailure(_, _) = err { + error!( + "Failed to acquire lock for index writer: {:?}, retry later", + err + ); + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + } + index.writer::<_>(50_000_000)? + }, + }; + + *self.state.write().await = Some(TantivyState { + path: index_path, + index, + folder_schema, + index_reader, + index_writer, + }); Ok(()) } - pub fn num_docs(&self) -> u64 { - self - .index_reader - .clone() - .map(|reader| reader.searcher().num_docs()) - .unwrap_or(0) - } - - fn empty() -> Self { - Self { - folder_schema: None, - index: None, - index_reader: None, - index_writer: None, - } - } - - fn get_index_writer(&self) -> FlowyResult> { - match &self.index_writer { - Some(index_writer) => match index_writer.deref().lock() { - Ok(writer) => Ok(writer), - Err(e) => { - tracing::error!("FolderIndexManager failed to lock index writer: {:?}", e); - Err(FlowyError::folder_index_manager_unavailable()) - }, - }, - None => Err(FlowyError::folder_index_manager_unavailable()), - } - } - - fn get_folder_schema(&self) -> FlowyResult { - match &self.folder_schema { - Some(folder_schema) => Ok(folder_schema.clone()), - None => Err(FlowyError::folder_index_manager_unavailable()), - } - } - fn extract_icon( &self, view_icon: Option, @@ -209,113 +136,99 @@ impl FolderIndexManagerImpl { icon = Some(view_icon.value); } else { icon_ty = ResultIconTypePB::Icon.into(); - let layout_ty: i64 = view_layout.into(); + let layout_ty = view_layout as i64; icon = Some(layout_ty.to_string()); } - (icon, icon_ty) } - pub fn search( - &self, - query: String, - _filter: Option, - ) -> Result, FlowyError> { - let folder_schema = self.get_folder_schema()?; + /// Simple implementation to index all given data by spawning async tasks. + fn index_all(&self, data_vec: Vec) -> Result<(), FlowyError> { + for data in data_vec { + let indexer = self.clone(); + tokio::spawn(async move { + let _ = indexer.add_index(data).await; + }); + } + Ok(()) + } - let index = match &self.index { - Some(index) => index, - None => return Err(FlowyError::folder_index_manager_unavailable()), - }; + /// Searches the index using the given query string. + pub async fn search(&self, query: String) -> Result, FlowyError> { + let lock = self.state.read().await; + let state = lock + .as_ref() + .ok_or_else(FlowyError::folder_index_manager_unavailable)?; + let schema = &state.folder_schema; + let index = &state.index; + let reader = &state.index_reader; - let index_reader = match &self.index_reader { - Some(index_reader) => index_reader, - None => return Err(FlowyError::folder_index_manager_unavailable()), - }; + let title_field = schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; + let mut parser = QueryParser::for_index(index, vec![title_field]); + parser.set_field_fuzzy(title_field, true, 2, true); - let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; - - let length = query.len(); - let distance: u8 = if length >= 2 { 2 } else { 1 }; - - let mut query_parser = QueryParser::for_index(&index.clone(), vec![title_field]); - query_parser.set_field_fuzzy(title_field, true, distance, true); - let built_query = query_parser.parse_query(&query.clone())?; - - let searcher = index_reader.searcher(); - let mut search_results: Vec = vec![]; + let built_query = parser.parse_query(&query)?; + let searcher = reader.searcher(); let top_docs = searcher.search(&built_query, &TopDocs::with_limit(10))?; - for (_score, doc_address) in top_docs { - let retrieved_doc = searcher.doc(doc_address)?; + let mut results = Vec::new(); + for (_score, doc_address) in top_docs { + let doc: TantivyDocument = searcher.doc(doc_address)?; + let named_doc = doc.to_named_doc(&schema.schema); let mut content = HashMap::new(); - let named_doc = folder_schema.schema.to_named_doc(&retrieved_doc); for (k, v) in named_doc.0 { content.insert(k, v[0].clone()); } - - if content.is_empty() { - continue; + if !content.is_empty() { + let s = serde_json::to_string(&content)?; + let result: LocalSearchResponseItemPB = serde_json::from_str::(&s)?.into(); + results.push(result); } - - let s = serde_json::to_string(&content)?; - let result: SearchResultPB = serde_json::from_str::(&s)?.into(); - let score = self.score_result(&query, &result.data); - search_results.push(result.with_score(score)); } - Ok(search_results) - } - - // Score result by distance - fn score_result(&self, query: &str, term: &str) -> f64 { - let distance = levenshtein(query, term) as f64; - 1.0 / (distance + 1.0) + Ok(results) } } +#[async_trait] impl IndexManager for FolderIndexManagerImpl { - fn is_indexed(&self) -> bool { - self - .index_reader - .clone() - .map(|reader| reader.searcher().num_docs() > 0) - .unwrap_or(false) - } - - fn set_index_content_receiver(&self, mut rx: IndexContentReceiver, workspace_id: String) { + async fn set_index_content_receiver(&self, mut rx: IndexContentReceiver, workspace_id: Uuid) { let indexer = self.clone(); - let wid = workspace_id.clone(); - af_spawn(async move { + let wid = workspace_id; + tokio::spawn(async move { while let Ok(msg) = rx.recv().await { match msg { IndexContent::Create(value) => match serde_json::from_value::(value) { Ok(view) => { - let _ = indexer.add_index(IndexableData { - id: view.id, - data: view.name, - icon: view.icon, - layout: view.layout, - workspace_id: wid.clone(), - }); + let _ = indexer + .add_index(IndexableData { + id: view.id, + data: view.name, + icon: view.icon, + layout: view.layout, + workspace_id: wid, + }) + .await; }, - Err(err) => tracing::error!("FolderIndexManager error deserialize: {:?}", err), + Err(err) => tracing::error!("FolderIndexManager error deserialize (create): {:?}", err), }, IndexContent::Update(value) => match serde_json::from_value::(value) { Ok(view) => { - let _ = indexer.update_index(IndexableData { - id: view.id, - data: view.name, - icon: view.icon, - layout: view.layout, - workspace_id: wid.clone(), - }); + let _ = indexer + .update_index(IndexableData { + id: view.id, + data: view.name, + icon: view.icon, + layout: view.layout, + workspace_id: wid, + }) + .await; }, - Err(err) => tracing::error!("FolderIndexManager error deserialize: {:?}", err), + Err(err) => error!("FolderIndexManager error deserialize (update): {:?}", err), }, IndexContent::Delete(ids) => { - if let Err(e) = indexer.remove_indices(ids) { - tracing::error!("FolderIndexManager error deserialize: {:?}", e); + if let Err(e) = indexer.remove_indices(ids).await { + error!("FolderIndexManager error (delete): {:?}", e); } }, } @@ -323,103 +236,165 @@ impl IndexManager for FolderIndexManagerImpl { }); } - fn update_index(&self, data: IndexableData) -> Result<(), FlowyError> { - let mut index_writer = self.get_index_writer()?; - - let folder_schema = self.get_folder_schema()?; - let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; - let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; - let icon_field = folder_schema.schema.get_field(FOLDER_ICON_FIELD_NAME)?; - let icon_ty_field: tantivy::schema::Field = - folder_schema.schema.get_field(FOLDER_ICON_TY_FIELD_NAME)?; - let workspace_id_field = folder_schema - .schema - .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; - - let delete_term = Term::from_field_text(id_field, &data.id.clone()); - - // Remove old index - index_writer.delete_term(delete_term); - + async fn add_index(&self, data: IndexableData) -> Result<(), FlowyError> { let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); - - // Add new index - let _ = index_writer.add_document(doc![ - id_field => data.id.clone(), - title_field => data.data, - icon_field => icon.unwrap_or_default(), - icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id.clone(), - ]); - - index_writer.commit()?; - - Ok(()) - } - - fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError> { - let mut index_writer = self.get_index_writer()?; - - let folder_schema = self.get_folder_schema()?; - - let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; - for id in ids { - let delete_term = Term::from_field_text(id_field, &id); - index_writer.delete_term(delete_term); - } - - index_writer.commit()?; - - Ok(()) - } - - fn add_index(&self, data: IndexableData) -> Result<(), FlowyError> { - let mut index_writer = self.get_index_writer()?; - - let folder_schema = self.get_folder_schema()?; - - let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; - let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; - let icon_field = folder_schema.schema.get_field(FOLDER_ICON_FIELD_NAME)?; - let icon_ty_field = folder_schema.schema.get_field(FOLDER_ICON_TY_FIELD_NAME)?; - let workspace_id_field = folder_schema - .schema - .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; - - let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); - - // Add new index - let _ = index_writer.add_document(doc![ - id_field => data.id, - title_field => data.data, - icon_field => icon.unwrap_or_default(), - icon_ty_field => icon_ty, - workspace_id_field => data.workspace_id, - ]); - - index_writer.commit()?; - - Ok(()) - } - - fn as_any(&self) -> &dyn Any { self + .with_writer(|index_writer, folder_schema| { + let (id_field, title_field, icon_field, icon_ty_field, workspace_id_field) = + get_schema_fields(folder_schema)?; + let _ = index_writer.add_document(doc![ + id_field => data.id, + title_field => data.data, + icon_field => icon.unwrap_or_default(), + icon_ty_field => icon_ty, + workspace_id_field => data.workspace_id.to_string(), + ]); + index_writer.commit()?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn update_index(&self, data: IndexableData) -> Result<(), FlowyError> { + self + .with_writer(|index_writer, folder_schema| { + let (id_field, title_field, icon_field, icon_ty_field, workspace_id_field) = + get_schema_fields(folder_schema)?; + let delete_term = Term::from_field_text(id_field, &data.id); + index_writer.delete_term(delete_term); + + let (icon, icon_ty) = self.extract_icon(data.icon, data.layout); + let _ = index_writer.add_document(doc![ + id_field => data.id, + title_field => data.data, + icon_field => icon.unwrap_or_default(), + icon_ty_field => icon_ty, + workspace_id_field => data.workspace_id.to_string(), + ]); + + index_writer.commit()?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn remove_indices(&self, ids: Vec) -> Result<(), FlowyError> { + self + .with_writer(|index_writer, folder_schema| { + let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; + for id in ids { + let delete_term = Term::from_field_text(id_field, &id); + index_writer.delete_term(delete_term); + } + + index_writer.commit()?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn remove_indices_for_workspace(&self, workspace_id: Uuid) -> Result<(), FlowyError> { + self + .with_writer(|index_writer, folder_schema| { + let id_field = folder_schema + .schema + .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; + + let delete_term = Term::from_field_text(id_field, &workspace_id.to_string()); + index_writer.delete_term(delete_term); + index_writer.commit()?; + Ok(()) + }) + .await?; + Ok(()) + } + + async fn is_indexed(&self) -> bool { + let lock = self.state.read().await; + if let Some(ref state) = *lock { + state.index_reader.searcher().num_docs() > 0 + } else { + false + } } } +#[async_trait] impl FolderIndexManager for FolderIndexManagerImpl { - fn index_all_views(&self, views: Vec>, workspace_id: String) { + async fn initialize(&self) { + if let Err(e) = self.initialize().await { + error!("Failed to initialize FolderIndexManager: {:?}", e); + } + } + + fn index_all_views(&self, views: Vec>, workspace_id: Uuid) { let indexable_data = views .into_iter() - .map(|view| IndexableData { - id: view.id.clone(), - data: view.name.clone(), - icon: view.icon.clone(), - layout: view.layout.clone(), - workspace_id: workspace_id.clone(), - }) + .map(|view| IndexableData::from_view(view, workspace_id)) .collect(); - let _ = self.index_all(indexable_data); } + + fn index_view_changes( + &self, + views: Vec>, + changes: Vec, + workspace_id: Uuid, + ) { + let mut views_iter = views.into_iter(); + for change in changes { + match change { + FolderViewChange::Inserted { view_id } => { + if let Some(view) = views_iter.find(|view| view.id == view_id) { + let indexable_data = IndexableData::from_view(view, workspace_id); + let f = self.clone(); + tokio::spawn(async move { + let _ = f.add_index(indexable_data).await; + }); + } + }, + FolderViewChange::Updated { view_id } => { + if let Some(view) = views_iter.find(|view| view.id == view_id) { + let indexable_data = IndexableData::from_view(view, workspace_id); + let f = self.clone(); + tokio::spawn(async move { + let _ = f.update_index(indexable_data).await; + }); + } + }, + FolderViewChange::Deleted { view_ids } => { + let f = self.clone(); + tokio::spawn(async move { + let _ = f.remove_indices(view_ids).await; + }); + }, + } + } + } +} + +fn get_schema_fields( + folder_schema: &FolderSchema, +) -> Result<(Field, Field, Field, Field, Field), FlowyError> { + let id_field = folder_schema.schema.get_field(FOLDER_ID_FIELD_NAME)?; + let title_field = folder_schema.schema.get_field(FOLDER_TITLE_FIELD_NAME)?; + let icon_field = folder_schema.schema.get_field(FOLDER_ICON_FIELD_NAME)?; + let icon_ty_field = folder_schema.schema.get_field(FOLDER_ICON_TY_FIELD_NAME)?; + let workspace_id_field = folder_schema + .schema + .get_field(FOLDER_WORKSPACE_ID_FIELD_NAME)?; + + Ok(( + id_field, + title_field, + icon_field, + icon_ty_field, + workspace_id_field, + )) } diff --git a/frontend/rust-lib/flowy-search/src/lib.rs b/frontend/rust-lib/flowy-search/src/lib.rs index 9b2ea272d8..820a6d9cb3 100644 --- a/frontend/rust-lib/flowy-search/src/lib.rs +++ b/frontend/rust-lib/flowy-search/src/lib.rs @@ -1,3 +1,4 @@ +pub mod document; pub mod entities; pub mod event_handler; pub mod event_map; diff --git a/frontend/rust-lib/flowy-search/src/services/manager.rs b/frontend/rust-lib/flowy-search/src/services/manager.rs index 487c589d05..a71449d5d2 100644 --- a/frontend/rust-lib/flowy-search/src/services/manager.rs +++ b/frontend/rust-lib/flowy-search/src/services/manager.rs @@ -1,27 +1,31 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use super::notifier::{SearchNotifier, SearchResultChanged, SearchResultReceiverRunner}; -use crate::entities::{SearchFilterPB, SearchResultNotificationPB, SearchResultPB}; +use crate::entities::{SearchFilterPB, SearchResponsePB, SearchStatePB}; +use allo_isolate::Isolate; use flowy_error::FlowyResult; -use lib_dispatch::prelude::af_spawn; -use tokio::{sync::broadcast, task::spawn_blocking}; +use lib_infra::async_trait::async_trait; +use lib_infra::isolate_stream::{IsolateSink, SinkExt}; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use tokio_stream::{self, Stream, StreamExt}; +use tracing::{error, trace}; + #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum SearchType { Folder, + Document, } +#[async_trait] pub trait SearchHandler: Send + Sync + 'static { /// returns the type of search this handler is responsible for fn search_type(&self) -> SearchType; - /// performs a search and returns the results - fn perform_search( + + /// performs a search and returns a stream of results + async fn perform_search( &self, query: String, filter: Option, - ) -> FlowyResult>; - /// returns the number of indexed objects - fn index_count(&self) -> u64; + ) -> Pin> + Send + 'static>>; } /// The [SearchManager] is used to inject multiple [SearchHandler]'s @@ -30,7 +34,7 @@ pub trait SearchHandler: Send + Sync + 'static { /// pub struct SearchManager { pub handlers: HashMap>, - notifier: SearchNotifier, + current_search: Arc>>, } impl SearchManager { @@ -40,47 +44,87 @@ impl SearchManager { .map(|handler| (handler.search_type(), handler)) .collect(); - // Initialize Search Notifier - let (notifier, _) = broadcast::channel(100); - af_spawn(SearchResultReceiverRunner(Some(notifier.subscribe())).run()); - - Self { handlers, notifier } + Self { + handlers, + current_search: Arc::new(tokio::sync::Mutex::new(None)), + } } pub fn get_handler(&self, search_type: SearchType) -> Option<&Arc> { self.handlers.get(&search_type) } - pub fn perform_search( + pub async fn perform_search( &self, query: String, + stream_port: i64, filter: Option, - channel: Option, + search_id: String, ) { - let mut sends: usize = 0; - let max: usize = self.handlers.len(); + // Cancel previous search by updating current_search + *self.current_search.lock().await = Some(search_id.clone()); + let handlers = self.handlers.clone(); + let sink = IsolateSink::new(Isolate::new(stream_port)); + let mut join_handles = vec![]; + let current_search = self.current_search.clone(); + tracing::info!("[Search] perform search: {}", query); for (_, handler) in handlers { - let q = query.clone(); - let f = filter.clone(); - let ch = channel.clone(); - let notifier = self.notifier.clone(); + let mut clone_sink = sink.clone(); + let query = query.clone(); + let filter = filter.clone(); + let search_id = search_id.clone(); + let current_search = current_search.clone(); - spawn_blocking(move || { - let res = handler.perform_search(q, f); - sends += 1; + let handle = tokio::spawn(async move { + if !is_current_search(¤t_search, &search_id).await { + trace!("[Search] cancel search: {}", query); + return; + } - let close = sends == max; - let items = res.unwrap_or_default(); - let notification = SearchResultNotificationPB { - items, - closed: close, - channel: ch, + let mut stream = handler.perform_search(query.clone(), filter).await; + while let Some(Ok(search_result)) = stream.next().await { + if !is_current_search(¤t_search, &search_id).await { + trace!("[Search] discard search stream: {}", query); + return; + } + + let resp = SearchStatePB { + response: Some(search_result), + search_id: search_id.clone(), + }; + if let Ok::, _>(data) = resp.try_into() { + if let Err(err) = clone_sink.send(data).await { + error!("Failed to send search result: {}", err); + break; + } + } + } + + if !is_current_search(¤t_search, &search_id).await { + trace!("[Search] discard search result: {}", query); + return; + } + + let resp = SearchStatePB { + response: None, + search_id: search_id.clone(), }; - - let _ = notifier.send(SearchResultChanged::SearchResultUpdate(notification)); + if let Ok::, _>(data) = resp.try_into() { + let _ = clone_sink.send(data).await; + } }); + join_handles.push(handle); } + futures::future::join_all(join_handles).await; } } + +async fn is_current_search( + current_search: &Arc>>, + search_id: &str, +) -> bool { + let current = current_search.lock().await; + current.as_ref().map_or(false, |id| id == search_id) +} diff --git a/frontend/rust-lib/flowy-search/src/services/mod.rs b/frontend/rust-lib/flowy-search/src/services/mod.rs index 2a417e6c62..ff8de9eb9a 100644 --- a/frontend/rust-lib/flowy-search/src/services/mod.rs +++ b/frontend/rust-lib/flowy-search/src/services/mod.rs @@ -1,2 +1 @@ pub mod manager; -pub mod notifier; diff --git a/frontend/rust-lib/flowy-search/src/services/notifier.rs b/frontend/rust-lib/flowy-search/src/services/notifier.rs deleted file mode 100644 index 83ec113daf..0000000000 --- a/frontend/rust-lib/flowy-search/src/services/notifier.rs +++ /dev/null @@ -1,63 +0,0 @@ -use async_stream::stream; -use flowy_notification::NotificationBuilder; -use futures::stream::StreamExt; -use tokio::sync::broadcast; - -use crate::entities::{SearchNotification, SearchResultNotificationPB}; - -const SEARCH_OBSERVABLE_SOURCE: &str = "Search"; -const SEARCH_ID: &str = "SEARCH_IDENTIFIER"; - -#[derive(Clone)] -pub enum SearchResultChanged { - SearchResultUpdate(SearchResultNotificationPB), -} - -pub type SearchNotifier = broadcast::Sender; - -pub(crate) struct SearchResultReceiverRunner( - pub(crate) Option>, -); - -impl SearchResultReceiverRunner { - pub(crate) async fn run(mut self) { - let mut receiver = self.0.take().expect("Only take once"); - let stream = stream! { - while let Ok(changed) = receiver.recv().await { - yield changed; - } - }; - stream - .for_each(|changed| async { - match changed { - SearchResultChanged::SearchResultUpdate(notification) => { - let ty = if notification.closed { - SearchNotification::DidCloseResults - } else { - SearchNotification::DidUpdateResults - }; - - send_notification(SEARCH_ID, ty, notification.channel.clone()) - .payload(notification) - .send(); - }, - } - }) - .await; - } -} - -#[tracing::instrument(level = "trace")] -pub fn send_notification( - id: &str, - ty: SearchNotification, - channel: Option, -) -> NotificationBuilder { - let observable_source = &format!( - "{}{}", - SEARCH_OBSERVABLE_SOURCE, - channel.unwrap_or_default() - ); - - NotificationBuilder::new(id, ty, observable_source) -} diff --git a/frontend/rust-lib/flowy-search/tests/tantivy_test.rs b/frontend/rust-lib/flowy-search/tests/tantivy_test.rs index b07853c7de..f68b06d610 100644 --- a/frontend/rust-lib/flowy-search/tests/tantivy_test.rs +++ b/frontend/rust-lib/flowy-search/tests/tantivy_test.rs @@ -47,7 +47,7 @@ fn search_folder_test() { for (_score, doc_address) in top_docs { // Retrieve the actual content of documents given its `doc_address`. - let retrieved_doc = searcher.doc(doc_address).unwrap(); - println!("{}", schema.to_json(&retrieved_doc)); + let retrieved_doc: TantivyDocument = searcher.doc(doc_address).unwrap(); + println!("{}", retrieved_doc.to_json(&schema)); } } diff --git a/frontend/rust-lib/flowy-server-pub/src/lib.rs b/frontend/rust-lib/flowy-server-pub/src/lib.rs index 4736587f4e..ee43b3c40c 100644 --- a/frontend/rust-lib/flowy-server-pub/src/lib.rs +++ b/frontend/rust-lib/flowy-server-pub/src/lib.rs @@ -28,15 +28,12 @@ if_wasm! { } } -pub mod supabase_config; - pub const CLOUT_TYPE_STR: &str = "APPFLOWY_CLOUD_ENV_CLOUD_TYPE"; #[derive(Deserialize_repr, Debug, Clone, PartialEq, Eq)] #[repr(u8)] pub enum AuthenticatorType { Local = 0, - Supabase = 1, AppFlowyCloud = 2, } @@ -50,7 +47,6 @@ impl AuthenticatorType { fn from_str(s: &str) -> Self { match s { "0" => AuthenticatorType::Local, - "1" => AuthenticatorType::Supabase, "2" => AuthenticatorType::AppFlowyCloud, _ => AuthenticatorType::Local, } diff --git a/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs b/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs index d75c30a673..9c74850fcd 100644 --- a/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs +++ b/frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs @@ -7,12 +7,17 @@ use flowy_error::{ErrorCode, FlowyError}; pub const APPFLOWY_CLOUD_BASE_URL: &str = "APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_BASE_URL"; pub const APPFLOWY_CLOUD_WS_BASE_URL: &str = "APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_WS_BASE_URL"; pub const APPFLOWY_CLOUD_GOTRUE_URL: &str = "APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_GOTRUE_URL"; +pub const APPFLOWY_ENABLE_SYNC_TRACE: &str = "APPFLOWY_ENABLE_SYNC_TRACE"; #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct AFCloudConfiguration { pub base_url: String, pub ws_base_url: String, pub gotrue_url: String, + #[serde(default)] + pub enable_sync_trace: bool, + #[serde(default)] + pub maximum_upload_file_size_in_bytes: Option, } impl Display for AFCloudConfiguration { @@ -53,10 +58,16 @@ impl AFCloudConfiguration { ); } + let enable_sync_trace = std::env::var(APPFLOWY_ENABLE_SYNC_TRACE) + .map(|v| v == "true" || v == "1") + .unwrap_or(true); + Ok(Self { base_url, ws_base_url, gotrue_url, + enable_sync_trace, + maximum_upload_file_size_in_bytes: None, }) } @@ -65,5 +76,13 @@ impl AFCloudConfiguration { std::env::set_var(APPFLOWY_CLOUD_BASE_URL, &self.base_url); std::env::set_var(APPFLOWY_CLOUD_WS_BASE_URL, &self.ws_base_url); std::env::set_var(APPFLOWY_CLOUD_GOTRUE_URL, &self.gotrue_url); + std::env::set_var( + APPFLOWY_ENABLE_SYNC_TRACE, + if self.enable_sync_trace { + "true" + } else { + "false" + }, + ); } } diff --git a/frontend/rust-lib/flowy-server-pub/src/supabase_config.rs b/frontend/rust-lib/flowy-server-pub/src/supabase_config.rs deleted file mode 100644 index 90dbe39bc5..0000000000 --- a/frontend/rust-lib/flowy-server-pub/src/supabase_config.rs +++ /dev/null @@ -1,41 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use flowy_error::{ErrorCode, FlowyError}; - -pub const SUPABASE_URL: &str = "APPFLOWY_CLOUD_ENV_SUPABASE_URL"; -pub const SUPABASE_ANON_KEY: &str = "APPFLOWY_CLOUD_ENV_SUPABASE_ANON_KEY"; - -/// The configuration for the postgres database. It supports deserializing from the json string that -/// passed from the frontend application. [AppFlowyEnv::parser] -#[derive(Debug, Serialize, Deserialize, Clone, Default)] -pub struct SupabaseConfiguration { - /// The url of the supabase server. - pub url: String, - /// The key of the supabase server. - pub anon_key: String, -} - -impl SupabaseConfiguration { - pub fn from_env() -> Result { - let url = std::env::var(SUPABASE_URL) - .map_err(|_| FlowyError::new(ErrorCode::InvalidAuthConfig, "Missing SUPABASE_URL"))?; - - let anon_key = std::env::var(SUPABASE_ANON_KEY) - .map_err(|_| FlowyError::new(ErrorCode::InvalidAuthConfig, "Missing SUPABASE_ANON_KEY"))?; - - if url.is_empty() || anon_key.is_empty() { - return Err(FlowyError::new( - ErrorCode::InvalidAuthConfig, - "Missing SUPABASE_URL or SUPABASE_ANON_KEY", - )); - } - - Ok(Self { url, anon_key }) - } - - /// Write the configuration to the environment variables. - pub fn write_env(&self) { - std::env::set_var(SUPABASE_URL, &self.url); - std::env::set_var(SUPABASE_ANON_KEY, &self.anon_key); - } -} diff --git a/frontend/rust-lib/flowy-server/Cargo.toml b/frontend/rust-lib/flowy-server/Cargo.toml index b64b30fa49..c8710470b0 100644 --- a/frontend/rust-lib/flowy-server/Cargo.toml +++ b/frontend/rust-lib/flowy-server/Cargo.toml @@ -12,26 +12,22 @@ crate-type = ["cdylib", "rlib"] tracing.workspace = true futures.workspace = true futures-util = "0.3.26" -reqwest = { version = "0.11.20", features = ["native-tls-vendored", "multipart", "blocking"] } -hyper = "0.14" serde.workspace = true serde_json.workspace = true thiserror = "1.0" tokio = { workspace = true, features = ["sync"] } -parking_lot.workspace = true lazy_static = "1.4.0" bytes = { workspace = true, features = ["serde"] } -tokio-retry = "0.3" anyhow.workspace = true +arc-swap.workspace = true uuid.workspace = true -chrono = { workspace = true, default-features = false, features = ["clock", "serde"] } collab = { workspace = true } collab-plugins = { workspace = true } collab-document = { workspace = true } collab-entity = { workspace = true } collab-folder = { workspace = true } -hex = "0.4.3" -postgrest = "1.0" +collab-database = { workspace = true } +collab-user = { workspace = true } lib-infra = { workspace = true } flowy-user-pub = { workspace = true } flowy-folder-pub = { workspace = true } @@ -39,24 +35,26 @@ flowy-database-pub = { workspace = true } flowy-document-pub = { workspace = true } flowy-error = { workspace = true, features = ["impl_from_serde", "impl_from_reqwest", "impl_from_url", "impl_from_appflowy_cloud"] } flowy-server-pub = { workspace = true } -flowy-encrypt = { workspace = true } +flowy-search-pub = { workspace = true } flowy-storage = { workspace = true } -mime_guess = "2.0" -url = "2.4" +flowy-storage-pub = { workspace = true } +flowy-ai-pub = { workspace = true } tokio-util = "0.7" tokio-stream = { workspace = true, features = ["sync"] } -lib-dispatch = { workspace = true } -yrs.workspace = true rand = "0.8.5" +semver = "1.0.23" +flowy-sqlite = { workspace = true } +flowy-ai = { workspace = true } +chrono.workspace = true [dependencies.client-api] workspace = true features = [ - "collab-sync", - "test_util", - "enable_brotli", - # Uncomment the following line to enable verbose logging for sync - # "sync_verbose_log", + "collab-sync", + "test_util", + "enable_brotli", + # Uncomment the following line to enable verbose logging for sync + # "sync_verbose_log", ] [dev-dependencies] @@ -67,4 +65,4 @@ assert-json-diff = "2.0.2" serde_json.workspace = true [features] -enable_supabase = ["collab-plugins/postgres_plugin"] \ No newline at end of file +enable_supabase = ["collab-plugins/postgres_plugin"] diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs index b0f09b1530..65808e5b6b 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/define.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/define.rs @@ -1,4 +1,11 @@ -use flowy_error::FlowyResult; +use collab_plugins::CollabKVDB; +use flowy_ai::ai_manager::AIUserService; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::DBConnection; +use lib_infra::async_trait::async_trait; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; +use uuid::Uuid; pub const USER_SIGN_IN_URL: &str = "sign_in_url"; pub const USER_UUID: &str = "uuid"; @@ -6,7 +13,51 @@ pub const USER_EMAIL: &str = "email"; pub const USER_DEVICE_ID: &str = "device_id"; /// Represents a user that is currently using the server. -pub trait ServerUser: Send + Sync { +#[async_trait] +pub trait LoggedUser: Send + Sync { /// different user might return different workspace id. - fn workspace_id(&self) -> FlowyResult; + fn workspace_id(&self) -> FlowyResult; + + fn user_id(&self) -> FlowyResult; + async fn is_local_mode(&self) -> FlowyResult; + + fn get_sqlite_db(&self, uid: i64) -> Result; + + fn get_collab_db(&self, uid: i64) -> Result, FlowyError>; + + fn application_root_dir(&self) -> Result; +} + +pub struct AIUserServiceImpl(pub Weak); + +impl AIUserServiceImpl { + fn logged_user(&self) -> FlowyResult> { + self + .0 + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("User is not logged in")) + } +} + +#[async_trait] +impl AIUserService for AIUserServiceImpl { + fn user_id(&self) -> Result { + self.logged_user()?.user_id() + } + + async fn is_local_model(&self) -> FlowyResult { + self.logged_user()?.is_local_mode().await + } + + fn workspace_id(&self) -> Result { + self.logged_user()?.workspace_id() + } + + fn sqlite_connection(&self, uid: i64) -> Result { + self.logged_user()?.get_sqlite_db(uid) + } + + fn application_root_dir(&self) -> Result { + self.logged_user()?.application_root_dir() + } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs new file mode 100644 index 0000000000..6086f7084b --- /dev/null +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs @@ -0,0 +1,270 @@ +#![allow(unused_variables)] +use crate::af_cloud::AFServer; +use client_api::entity::ai_dto::{ + ChatQuestionQuery, CompleteTextParams, RepeatedRelatedQuestion, ResponseFormat, +}; +use client_api::entity::chat_dto::{ + CreateAnswerMessageParams, CreateChatMessageParams, CreateChatParams, MessageCursor, + RepeatedChatMessage, +}; +use flowy_ai_pub::cloud::{ + AIModel, ChatCloudService, ChatMessage, ChatMessageType, ChatSettings, ModelList, StreamAnswer, + StreamComplete, UpdateChatParams, +}; +use flowy_error::FlowyError; +use futures_util::{StreamExt, TryStreamExt}; +use lib_infra::async_trait::async_trait; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; +use tracing::trace; +use uuid::Uuid; + +pub(crate) struct CloudChatServiceImpl { + pub inner: T, +} + +#[async_trait] +impl ChatCloudService for CloudChatServiceImpl +where + T: AFServer, +{ + async fn create_chat( + &self, + uid: &i64, + workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + name: &str, + metadata: serde_json::Value, + ) -> Result<(), FlowyError> { + let chat_id = chat_id.to_string(); + let try_get_client = self.inner.try_get_client(); + let params = CreateChatParams { + chat_id, + name: name.to_string(), + rag_ids, + }; + try_get_client? + .create_chat(workspace_id, params) + .await + .map_err(FlowyError::from)?; + + Ok(()) + } + + async fn create_question( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + message_type: ChatMessageType, + ) -> Result { + let chat_id = chat_id.to_string(); + let try_get_client = self.inner.try_get_client(); + let params = CreateChatMessageParams { + content: message.to_string(), + message_type, + }; + + let message = try_get_client? + .create_question(workspace_id, &chat_id, params) + .await + .map_err(FlowyError::from)?; + Ok(message) + } + + async fn create_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + question_id: i64, + metadata: Option, + ) -> Result { + let try_get_client = self.inner.try_get_client(); + let params = CreateAnswerMessageParams { + content: message.to_string(), + metadata, + question_message_id: question_id, + }; + let message = try_get_client? + .save_answer(workspace_id, chat_id.to_string().as_str(), params) + .await + .map_err(FlowyError::from)?; + Ok(message) + } + + async fn stream_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + format: ResponseFormat, + ai_model: Option, + ) -> Result { + trace!( + "stream_answer: workspace_id={}, chat_id={}, format={:?}, model: {:?}", + workspace_id, + chat_id, + format, + ai_model, + ); + let try_get_client = self.inner.try_get_client(); + let result = try_get_client? + .stream_answer_v3( + workspace_id, + ChatQuestionQuery { + chat_id: chat_id.to_string(), + question_id: message_id, + format, + }, + ai_model.map(|v| v.name), + ) + .await; + + let stream = result.map_err(FlowyError::from)?.map_err(FlowyError::from); + Ok(stream.boxed()) + } + + async fn get_answer( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + ) -> Result { + let try_get_client = self.inner.try_get_client(); + let resp = try_get_client? + .get_answer(workspace_id, chat_id.to_string().as_str(), question_id) + .await + .map_err(FlowyError::from)?; + Ok(resp) + } + + async fn get_chat_messages( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + offset: MessageCursor, + limit: u64, + ) -> Result { + let try_get_client = self.inner.try_get_client(); + let resp = try_get_client? + .get_chat_messages(workspace_id, chat_id.to_string().as_str(), offset, limit) + .await + .map_err(FlowyError::from)?; + + Ok(resp) + } + + async fn get_question_from_answer_id( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, + ) -> Result { + let try_get_client = self.inner.try_get_client()?; + let resp = try_get_client + .get_question_message_from_answer_id( + workspace_id, + chat_id.to_string().as_str(), + answer_message_id, + ) + .await + .map_err(FlowyError::from)? + .ok_or_else(FlowyError::record_not_found)?; + + Ok(resp) + } + + async fn get_related_message( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + ai_model: Option, + ) -> Result { + let try_get_client = self.inner.try_get_client(); + let resp = try_get_client? + .get_chat_related_question(workspace_id, chat_id.to_string().as_str(), message_id) + .await + .map_err(FlowyError::from)?; + + Ok(resp) + } + + async fn stream_complete( + &self, + workspace_id: &Uuid, + params: CompleteTextParams, + ai_model: Option, + ) -> Result { + let stream = self + .inner + .try_get_client()? + .stream_completion_v2(workspace_id, params, ai_model.map(|v| v.name)) + .await + .map_err(FlowyError::from)? + .map_err(FlowyError::from); + + Ok(stream.boxed()) + } + + async fn embed_file( + &self, + workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, + ) -> Result<(), FlowyError> { + Err( + FlowyError::not_support() + .with_context("indexing file with appflowy cloud is not suppotred yet"), + ) + } + + async fn get_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + ) -> Result { + let settings = self + .inner + .try_get_client()? + .get_chat_settings(workspace_id, chat_id.to_string().as_str()) + .await?; + Ok(settings) + } + + async fn update_chat_settings( + &self, + workspace_id: &Uuid, + chat_id: &Uuid, + params: UpdateChatParams, + ) -> Result<(), FlowyError> { + self + .inner + .try_get_client()? + .update_chat_settings(workspace_id, chat_id.to_string().as_str(), params) + .await?; + Ok(()) + } + + async fn get_available_models(&self, workspace_id: &Uuid) -> Result { + let list = self + .inner + .try_get_client()? + .get_model_list(workspace_id) + .await?; + Ok(list) + } + + async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result { + let setting = self + .inner + .try_get_client()? + .get_workspace_settings(workspace_id.to_string().as_str()) + .await?; + Ok(setting.ai_model) + } +} diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs index 6b2a67c67b..f29a7f89ad 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs @@ -1,148 +1,184 @@ -use anyhow::Error; -use client_api::entity::ai_dto::{SummarizeRowData, SummarizeRowParams}; -use client_api::entity::QueryCollabResult::{Failed, Success}; -use client_api::entity::{QueryCollab, QueryCollabParams}; -use client_api::error::ErrorCode::RecordNotFound; -use collab::core::collab::DataSource; -use collab::entity::EncodedCollab; -use collab_entity::CollabType; -use serde_json::{Map, Value}; -use std::sync::Arc; -use tracing::{error, instrument}; - -use flowy_database_pub::cloud::{ - CollabDocStateByOid, DatabaseCloudService, DatabaseSnapshot, SummaryRowContent, -}; -use lib_infra::future::FutureResult; - -use crate::af_cloud::define::ServerUser; +#![allow(unused_variables)] +use crate::af_cloud::define::LoggedUser; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; +use client_api::entity::ai_dto::{ + SummarizeRowData, SummarizeRowParams, TranslateRowData, TranslateRowParams, +}; +use client_api::entity::QueryCollabResult::{Failed, Success}; +use client_api::entity::{CreateCollabParams, QueryCollab, QueryCollabParams}; +use client_api::error::ErrorCode::RecordNotFound; +use collab::entity::EncodedCollab; +use collab_entity::CollabType; +use flowy_database_pub::cloud::{ + DatabaseAIService, DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid, SummaryRowContent, + TranslateRowContent, TranslateRowResponse, +}; +use flowy_error::FlowyError; +use lib_infra::async_trait::async_trait; +use serde_json::{Map, Value}; +use std::sync::Weak; +use tracing::{error, instrument}; +use uuid::Uuid; pub(crate) struct AFCloudDatabaseCloudServiceImpl { pub inner: T, - pub user: Arc, + pub logged_user: Weak, } +#[async_trait] impl DatabaseCloudService for AFCloudDatabaseCloudServiceImpl where T: AFServer, { - #[instrument(level = "debug", skip_all)] - fn get_database_object_doc_state( + #[instrument(level = "debug", skip_all, err)] + #[allow(clippy::blocks_in_conditions)] + async fn get_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - workspace_id: &str, - ) -> FutureResult>, Error> { - let workspace_id = workspace_id.to_string(); - let object_id = object_id.to_string(); + workspace_id: &Uuid, + ) -> Result, FlowyError> { let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); - FutureResult::new(async move { - let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id: object_id.clone(), - collab_type: collab_type.clone(), - }, - }; - match try_get_client?.get_collab(params).await { - Ok(data) => { - check_request_workspace_id_is_match( - &workspace_id, - &cloned_user, - format!("get database object: {}:{}", object_id, collab_type), - )?; - Ok(Some(data.encode_collab.doc_state.to_vec())) - }, - Err(err) => { - if err.code == RecordNotFound { - Ok(None) - } else { - Err(Error::new(err)) - } - }, - } - }) + let params = QueryCollabParams { + workspace_id: *workspace_id, + inner: QueryCollab::new(*object_id, collab_type), + }; + let result = try_get_client?.get_collab(params).await; + match result { + Ok(data) => { + check_request_workspace_id_is_match( + workspace_id, + &self.logged_user, + format!("get database object: {}:{}", object_id, collab_type), + )?; + Ok(Some(data.encode_collab)) + }, + Err(err) => { + if err.code == RecordNotFound { + Ok(None) + } else { + Err(err.into()) + } + }, + } + } + + #[instrument(level = "debug", skip_all, err)] + #[allow(clippy::blocks_in_conditions)] + async fn create_database_encode_collab( + &self, + object_id: &Uuid, + collab_type: CollabType, + workspace_id: &Uuid, + encoded_collab: EncodedCollab, + ) -> Result<(), FlowyError> { + let encoded_collab_v1 = encoded_collab + .encode_to_bytes() + .map_err(|err| FlowyError::internal().with_context(err))?; + let params = CreateCollabParams { + workspace_id: *workspace_id, + object_id: *object_id, + encoded_collab_v1, + collab_type, + }; + self.inner.try_get_client()?.create_collab(params).await?; + Ok(()) } #[instrument(level = "debug", skip_all)] - fn batch_get_database_object_doc_state( + async fn batch_get_database_encode_collab( &self, - object_ids: Vec, + object_ids: Vec, object_ty: CollabType, - workspace_id: &str, - ) -> FutureResult { - let workspace_id = workspace_id.to_string(); + workspace_id: &Uuid, + ) -> Result { let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); - FutureResult::new(async move { - let client = try_get_client?; - let params = object_ids + let client = try_get_client?; + let params = object_ids + .into_iter() + .map(|object_id| QueryCollab::new(object_id, object_ty)) + .collect(); + let results = client.batch_get_collab(workspace_id, params).await?; + check_request_workspace_id_is_match( + workspace_id, + &self.logged_user, + "batch get database object", + )?; + Ok( + results + .0 .into_iter() - .map(|object_id| QueryCollab { - object_id, - collab_type: object_ty.clone(), + .flat_map(|(object_id, result)| match result { + Success { encode_collab_v1 } => { + match EncodedCollab::decode_from_bytes(&encode_collab_v1) { + Ok(encode) => Some((object_id, encode)), + Err(err) => { + error!("Failed to decode collab: {}", err); + None + }, + } + }, + Failed { error } => { + error!("Failed to get {} update: {}", object_id, error); + None + }, }) - .collect(); - let results = client.batch_get_collab(&workspace_id, params).await?; - check_request_workspace_id_is_match( - &workspace_id, - &cloned_user, - "batch get database object", - )?; - Ok( - results - .0 - .into_iter() - .flat_map(|(object_id, result)| match result { - Success { encode_collab_v1 } => { - match EncodedCollab::decode_from_bytes(&encode_collab_v1) { - Ok(encode) => Some((object_id, DataSource::DocStateV1(encode.doc_state.to_vec()))), - Err(err) => { - error!("Failed to decode collab: {}", err); - None - }, - } - }, - Failed { error } => { - error!("Failed to get {} update: {}", object_id, error); - None - }, - }) - .collect::(), - ) - }) + .collect::(), + ) } - fn get_database_collab_object_snapshots( + async fn get_database_collab_object_snapshots( &self, - _object_id: &str, - _limit: usize, - ) -> FutureResult, Error> { - FutureResult::new(async move { Ok(vec![]) }) - } - - fn summary_database_row( - &self, - workspace_id: &str, - _object_id: &str, - summary_row: SummaryRowContent, - ) -> FutureResult { - let workspace_id = workspace_id.to_string(); - let try_get_client = self.inner.try_get_client(); - FutureResult::new(async move { - let map: Map = summary_row - .into_iter() - .map(|(key, value)| (key, Value::String(value))) - .collect(); - let params = SummarizeRowParams { - workspace_id, - data: SummarizeRowData::Content(map), - }; - let data = try_get_client?.summarize_row(params).await?; - Ok(data.text) - }) + object_id: &Uuid, + limit: usize, + ) -> Result, FlowyError> { + Ok(vec![]) + } +} + +#[async_trait] +impl DatabaseAIService for AFCloudDatabaseCloudServiceImpl +where + T: AFServer, +{ + async fn summary_database_row( + &self, + workspace_id: &Uuid, + _object_id: &Uuid, + _summary_row: SummaryRowContent, + ) -> Result { + let try_get_client = self.inner.try_get_client(); + let map: Map = _summary_row + .into_iter() + .map(|(key, value)| (key, Value::String(value))) + .collect(); + let params = SummarizeRowParams { + workspace_id: *workspace_id, + data: SummarizeRowData::Content(map), + }; + let data = try_get_client?.summarize_row(params).await?; + Ok(data.text) + } + + async fn translate_database_row( + &self, + workspace_id: &Uuid, + _translate_row: TranslateRowContent, + _language: &str, + ) -> Result { + let try_get_client = self.inner.try_get_client(); + let data = TranslateRowData { + cells: _translate_row, + language: _language.to_string(), + include_header: false, + }; + + let params = TranslateRowParams { + workspace_id: workspace_id.to_string(), + data, + }; + let data = try_get_client?.translate_row(params).await?; + Ok(data) } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs index c5d88ba15c..1e000d5971 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs @@ -1,111 +1,119 @@ -use anyhow::Error; -use client_api::entity::{QueryCollab, QueryCollabParams}; +#![allow(unused_variables)] +use client_api::entity::{CreateCollabParams, QueryCollab, QueryCollabParams}; use collab::core::collab::DataSource; use collab::core::origin::CollabOrigin; +use collab::entity::EncodedCollab; +use collab::preclude::Collab; use collab_document::document::Document; use collab_entity::CollabType; -use std::sync::Arc; -use tracing::instrument; - use flowy_document_pub::cloud::*; use flowy_error::FlowyError; -use lib_infra::future::FutureResult; +use lib_infra::async_trait::async_trait; +use std::sync::Weak; +use tracing::instrument; +use uuid::Uuid; -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoggedUser; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; pub(crate) struct AFCloudDocumentCloudServiceImpl { pub inner: T, - pub user: Arc, + pub logged_user: Weak, } +#[async_trait] impl DocumentCloudService for AFCloudDocumentCloudServiceImpl where T: AFServer, { #[instrument(level = "debug", skip_all, fields(document_id = %document_id))] - fn get_document_doc_state( + async fn get_document_doc_state( &self, - document_id: &str, - workspace_id: &str, - ) -> FutureResult, FlowyError> { - let workspace_id = workspace_id.to_string(); - let try_get_client = self.inner.try_get_client(); - let document_id = document_id.to_string(); - let cloned_user = self.user.clone(); - FutureResult::new(async move { - let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id: document_id.to_string(), - collab_type: CollabType::Document, - }, - }; - let doc_state = try_get_client? - .get_collab(params) - .await - .map_err(FlowyError::from)? - .encode_collab - .doc_state - .to_vec(); + document_id: &Uuid, + workspace_id: &Uuid, + ) -> Result, FlowyError> { + let params = QueryCollabParams { + workspace_id: *workspace_id, + inner: QueryCollab::new(*document_id, CollabType::Document), + }; + let doc_state = self + .inner + .try_get_client()? + .get_collab(params) + .await + .map_err(FlowyError::from)? + .encode_collab + .doc_state + .to_vec(); - check_request_workspace_id_is_match( - &workspace_id, - &cloned_user, - format!("get document doc state:{}", document_id), - )?; + check_request_workspace_id_is_match( + workspace_id, + &self.logged_user, + format!("get document doc state:{}", document_id), + )?; - Ok(doc_state) - }) + Ok(doc_state) } - fn get_document_snapshots( + async fn get_document_snapshots( &self, - _document_id: &str, - _limit: usize, - _workspace_id: &str, - ) -> FutureResult, Error> { - FutureResult::new(async move { Ok(vec![]) }) + document_id: &Uuid, + limit: usize, + workspace_id: &str, + ) -> Result, FlowyError> { + Ok(vec![]) } #[instrument(level = "debug", skip_all)] - fn get_document_data( + async fn get_document_data( &self, - document_id: &str, - workspace_id: &str, - ) -> FutureResult, Error> { - let try_get_client = self.inner.try_get_client(); - let document_id = document_id.to_string(); - let workspace_id = workspace_id.to_string(); - let cloned_user = self.user.clone(); - FutureResult::new(async move { - let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id: document_id.clone(), - collab_type: CollabType::Document, - }, - }; - let doc_state = try_get_client? - .get_collab(params) - .await - .map_err(FlowyError::from)? - .encode_collab - .doc_state - .to_vec(); - check_request_workspace_id_is_match( - &workspace_id, - &cloned_user, - format!("Get {} document", document_id), - )?; - let document = Document::from_doc_state( - CollabOrigin::Empty, - DataSource::DocStateV1(doc_state), - &document_id, - vec![], - )?; - Ok(document.get_document_data().ok()) - }) + document_id: &Uuid, + workspace_id: &Uuid, + ) -> Result, FlowyError> { + let params = QueryCollabParams { + workspace_id: *workspace_id, + inner: QueryCollab::new(*document_id, CollabType::Document), + }; + let doc_state = self + .inner + .try_get_client()? + .get_collab(params) + .await? + .encode_collab + .doc_state + .to_vec(); + check_request_workspace_id_is_match( + workspace_id, + &self.logged_user, + format!("Get {} document", document_id), + )?; + let collab = Collab::new_with_source( + CollabOrigin::Empty, + document_id.to_string().as_str(), + DataSource::DocStateV1(doc_state), + vec![], + false, + )?; + let document = Document::open(collab)?; + Ok(document.get_document_data().ok()) + } + + async fn create_document_collab( + &self, + workspace_id: &Uuid, + document_id: &Uuid, + encoded_collab: EncodedCollab, + ) -> Result<(), FlowyError> { + let params = CreateCollabParams { + workspace_id: *workspace_id, + object_id: *document_id, + encoded_collab_v1: encoded_collab + .encode_to_bytes() + .map_err(|err| FlowyError::internal().with_context(err))?, + collab_type: CollabType::Document, + }; + self.inner.try_get_client()?.create_collab(params).await?; + Ok(()) } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs index 839a8b5ed1..8db806a0da 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs @@ -1,59 +1,158 @@ -use flowy_error::FlowyError; -use flowy_storage::{ObjectIdentity, ObjectStorageService, ObjectValue}; -use lib_infra::future::FutureResult; - use crate::af_cloud::AFServer; +use client_api::entity::{CompleteUploadRequest, CreateUploadRequest}; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_storage_pub::cloud::{ObjectIdentity, ObjectValue, StorageCloudService}; +use flowy_storage_pub::storage::{CompletedPartRequest, CreateUploadResponse, UploadPartResponse}; +use lib_infra::async_trait::async_trait; +use uuid::Uuid; -pub struct AFCloudFileStorageServiceImpl(pub T); +pub struct AFCloudFileStorageServiceImpl { + pub client: T, + + /// Only use in debug mode + pub maximum_upload_file_size_in_bytes: Option, +} impl AFCloudFileStorageServiceImpl { - pub fn new(client: T) -> Self { - Self(client) + pub fn new(client: T, maximum_upload_file_size_in_bytes: Option) -> Self { + Self { + client, + maximum_upload_file_size_in_bytes, + } } } -impl ObjectStorageService for AFCloudFileStorageServiceImpl +#[async_trait] +impl StorageCloudService for AFCloudFileStorageServiceImpl where T: AFServer, { - fn get_object_url(&self, object_id: ObjectIdentity) -> FutureResult { - let try_get_client = self.0.try_get_client(); - FutureResult::new(async move { - let file_name = format!("{}.{}", object_id.file_id, object_id.ext); - let client = try_get_client?; - let url = client.get_blob_url(&object_id.workspace_id, &file_name); - Ok(url) + async fn get_object_url(&self, object_id: ObjectIdentity) -> Result { + let file_name = format!("{}.{}", object_id.file_id, object_id.ext); + let url = self + .client + .try_get_client()? + .get_blob_url(&object_id.workspace_id, &file_name); + Ok(url) + } + + async fn put_object(&self, url: String, file: ObjectValue) -> Result<(), FlowyError> { + let client = self.client.try_get_client()?; + client.put_blob(&url, file.raw, &file.mime).await?; + Ok(()) + } + + async fn delete_object(&self, url: &str) -> Result<(), FlowyError> { + self.client.try_get_client()?.delete_blob(url).await?; + Ok(()) + } + + async fn get_object(&self, url: String) -> Result { + let (mime, raw) = self.client.try_get_client()?.get_blob(&url).await?; + Ok(ObjectValue { + raw: raw.into(), + mime, }) } - fn put_object(&self, url: String, file: ObjectValue) -> FutureResult<(), FlowyError> { - let try_get_client = self.0.try_get_client(); - let file = file.clone(); - FutureResult::new(async move { - let client = try_get_client?; - client.put_blob(&url, file.raw, &file.mime).await?; - Ok(()) - }) + async fn get_object_url_v1( + &self, + workspace_id: &Uuid, + parent_dir: &str, + file_id: &str, + ) -> FlowyResult { + let url = self + .client + .try_get_client()? + .get_blob_url_v1(workspace_id, parent_dir, file_id); + Ok(url) } - fn delete_object(&self, url: String) -> FutureResult<(), FlowyError> { - let try_get_client = self.0.try_get_client(); - FutureResult::new(async move { - let client = try_get_client?; - client.delete_blob(&url).await?; - Ok(()) - }) + async fn parse_object_url_v1(&self, url: &str) -> Option<(Uuid, String, String)> { + let value = self.client.try_get_client().ok()?.parse_blob_url_v1(url)?; + Some(value) } - fn get_object(&self, url: String) -> FutureResult { - let try_get_client = self.0.try_get_client(); - FutureResult::new(async move { - let client = try_get_client?; - let (mime, raw) = client.get_blob(&url).await?; - Ok(ObjectValue { - raw: raw.into(), - mime, - }) - }) + async fn create_upload( + &self, + workspace_id: &Uuid, + parent_dir: &str, + file_id: &str, + content_type: &str, + file_size: u64, + ) -> Result { + let parent_dir = parent_dir.to_string(); + let content_type = content_type.to_string(); + let file_id = file_id.to_string(); + let try_get_client = self.client.try_get_client(); + let client = try_get_client?; + let req = CreateUploadRequest { + file_id, + parent_dir, + content_type, + file_size: Some(file_size), + }; + + if cfg!(debug_assertions) { + if let Some(maximum_upload_size) = self.maximum_upload_file_size_in_bytes { + if file_size > maximum_upload_size { + return Err(FlowyError::new( + ErrorCode::SingleUploadLimitExceeded, + "File size exceeds the maximum limit", + )); + } + } + } + + let resp = client.create_upload(workspace_id, req).await?; + Ok(resp) + } + + async fn upload_part( + &self, + workspace_id: &Uuid, + parent_dir: &str, + upload_id: &str, + file_id: &str, + part_number: i32, + body: Vec, + ) -> Result { + let try_get_client = self.client.try_get_client(); + let client = try_get_client?; + let resp = client + .upload_part( + workspace_id, + parent_dir, + file_id, + upload_id, + part_number, + body, + ) + .await?; + + Ok(resp) + } + + async fn complete_upload( + &self, + workspace_id: &Uuid, + parent_dir: &str, + upload_id: &str, + file_id: &str, + parts: Vec, + ) -> Result<(), FlowyError> { + let parent_dir = parent_dir.to_string(); + let upload_id = upload_id.to_string(); + let file_id = file_id.to_string(); + let try_get_client = self.client.try_get_client(); + let client = try_get_client?; + let request = CompleteUploadRequest { + file_id, + parent_dir, + upload_id, + parts, + }; + client.complete_upload(workspace_id, request).await?; + Ok(()) } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs index c33569aea8..578f2870c6 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs @@ -1,187 +1,270 @@ -use anyhow::Error; +use client_api::entity::workspace_dto::PublishInfoView; use client_api::entity::{ - workspace_dto::CreateWorkspaceParam, CollabParams, QueryCollab, QueryCollabParams, + CollabParams, PublishCollabItem, PublishCollabMetadata, QueryCollab, QueryCollabParams, }; -use collab::core::collab::DataSource; -use collab::core::origin::CollabOrigin; +use client_api::entity::{PatchPublishedCollab, PublishInfo}; use collab_entity::CollabType; -use collab_folder::RepeatedViewIdentifier; -use std::sync::Arc; -use tracing::instrument; +use serde_json::to_vec; +use std::path::PathBuf; +use std::sync::Weak; +use tracing::{instrument, trace}; +use uuid::Uuid; use flowy_error::FlowyError; use flowy_folder_pub::cloud::{ - Folder, FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, Workspace, - WorkspaceRecord, + FolderCloudService, FolderCollabParams, FolderSnapshot, FullSyncCollabParams, }; -use lib_infra::future::FutureResult; +use flowy_folder_pub::entities::PublishPayload; +use lib_infra::async_trait::async_trait; -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoggedUser; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::AFServer; pub(crate) struct AFCloudFolderCloudServiceImpl { pub inner: T, - pub user: Arc, + pub logged_user: Weak, } +#[async_trait] impl FolderCloudService for AFCloudFolderCloudServiceImpl where T: AFServer, { - fn create_workspace(&self, _uid: i64, name: &str) -> FutureResult { - let try_get_client = self.inner.try_get_client(); - let cloned_name = name.to_string(); - FutureResult::new(async move { - let client = try_get_client?; - let new_workspace = client - .create_workspace(CreateWorkspaceParam { - workspace_name: Some(cloned_name), - }) - .await?; - - Ok(Workspace { - id: new_workspace.workspace_id.to_string(), - name: new_workspace.workspace_name, - created_at: new_workspace.created_at.timestamp(), - child_views: RepeatedViewIdentifier::new(vec![]), - created_by: Some(new_workspace.owner_uid), - last_edited_time: new_workspace.created_at.timestamp(), - last_edited_by: Some(new_workspace.owner_uid), - }) - }) - } - - fn open_workspace(&self, workspace_id: &str) -> FutureResult<(), Error> { - let workspace_id = workspace_id.to_string(); - let try_get_client = self.inner.try_get_client(); - FutureResult::new(async move { - let client = try_get_client?; - let _ = client.open_workspace(&workspace_id).await?; - Ok(()) - }) - } - - fn get_all_workspace(&self) -> FutureResult, Error> { - let try_get_client = self.inner.try_get_client(); - FutureResult::new(async move { - let client = try_get_client?; - let records = client - .get_user_workspace_info() - .await? - .workspaces - .into_iter() - .map(|af_workspace| WorkspaceRecord { - id: af_workspace.workspace_id.to_string(), - name: af_workspace.workspace_name, - created_at: af_workspace.created_at.timestamp(), - }) - .collect::>(); - Ok(records) - }) - } - #[instrument(level = "debug", skip_all)] - fn get_folder_data( - &self, - workspace_id: &str, - uid: &i64, - ) -> FutureResult, Error> { - let uid = *uid; - let workspace_id = workspace_id.to_string(); - let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); - FutureResult::new(async move { - let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id: workspace_id.clone(), - collab_type: CollabType::Folder, - }, - }; - let doc_state = try_get_client? - .get_collab(params) - .await - .map_err(FlowyError::from)? - .encode_collab - .doc_state - .to_vec(); - check_request_workspace_id_is_match(&workspace_id, &cloned_user, "get folder data")?; - let folder = Folder::from_collab_doc_state( - uid, - CollabOrigin::Empty, - DataSource::DocStateV1(doc_state), - &workspace_id, - vec![], - )?; - Ok(folder.get_folder_data(&workspace_id)) - }) - } - - fn get_folder_snapshots( + async fn get_folder_snapshots( &self, _workspace_id: &str, _limit: usize, - ) -> FutureResult, Error> { - FutureResult::new(async move { Ok(vec![]) }) + ) -> Result, FlowyError> { + Ok(vec![]) } #[instrument(level = "debug", skip_all)] - fn get_folder_doc_state( + async fn get_folder_doc_state( &self, - workspace_id: &str, + workspace_id: &Uuid, _uid: i64, collab_type: CollabType, - object_id: &str, - ) -> FutureResult, Error> { - let object_id = object_id.to_string(); - let workspace_id = workspace_id.to_string(); + object_id: &Uuid, + ) -> Result, FlowyError> { let try_get_client = self.inner.try_get_client(); - let cloned_user = self.user.clone(); - FutureResult::new(async move { - let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id, - collab_type, - }, - }; - let doc_state = try_get_client? - .get_collab(params) - .await - .map_err(FlowyError::from)? - .encode_collab - .doc_state - .to_vec(); - check_request_workspace_id_is_match(&workspace_id, &cloned_user, "get folder doc state")?; - Ok(doc_state) - }) + let params = QueryCollabParams { + workspace_id: *workspace_id, + inner: QueryCollab::new(*object_id, collab_type), + }; + let doc_state = try_get_client? + .get_collab(params) + .await + .map_err(FlowyError::from)? + .encode_collab + .doc_state + .to_vec(); + check_request_workspace_id_is_match(workspace_id, &self.logged_user, "get folder doc state")?; + Ok(doc_state) } - fn batch_create_folder_collab_objects( + async fn full_sync_collab_object( &self, - workspace_id: &str, - objects: Vec, - ) -> FutureResult<(), Error> { - let workspace_id = workspace_id.to_string(); + workspace_id: &Uuid, + params: FullSyncCollabParams, + ) -> Result<(), FlowyError> { let try_get_client = self.inner.try_get_client(); - FutureResult::new(async move { - let params = objects - .into_iter() - .map(|object| CollabParams { - object_id: object.object_id, - encoded_collab_v1: object.encoded_collab_v1, - collab_type: object.collab_type, - }) - .collect::>(); - try_get_client? - .create_collab_list(&workspace_id, params) - .await - .map_err(FlowyError::from)?; - Ok(()) - }) + try_get_client? + .collab_full_sync( + workspace_id, + ¶ms.object_id, + params.collab_type, + params.encoded_collab.doc_state.to_vec(), + params.encoded_collab.state_vector.to_vec(), + ) + .await?; + Ok(()) + } + + async fn batch_create_folder_collab_objects( + &self, + workspace_id: &Uuid, + objects: Vec, + ) -> Result<(), FlowyError> { + let try_get_client = self.inner.try_get_client(); + let params = objects + .into_iter() + .map(|object| { + CollabParams::new( + object.object_id, + object.collab_type, + object.encoded_collab_v1, + ) + }) + .collect::>(); + try_get_client? + .create_collab_list(workspace_id, params) + .await?; + Ok(()) } fn service_name(&self) -> String { "AppFlowy Cloud".to_string() } + + async fn publish_view( + &self, + workspace_id: &Uuid, + payload: Vec, + ) -> Result<(), FlowyError> { + let try_get_client = self.inner.try_get_client(); + let params = payload + .into_iter() + .filter_map(|object| { + let (meta, data) = match object { + PublishPayload::Document(payload) => (payload.meta, payload.data), + PublishPayload::Database(payload) => { + (payload.meta, to_vec(&payload.data).unwrap_or_default()) + }, + PublishPayload::Unknown => return None, + }; + Some(PublishCollabItem { + meta: PublishCollabMetadata { + view_id: Uuid::parse_str(&meta.view_id).unwrap_or(Uuid::nil()), + publish_name: meta.publish_name, + metadata: meta.metadata, + }, + data, + comments_enabled: true, + duplicate_enabled: true, + }) + }) + .collect::>(); + try_get_client? + .publish_collabs(workspace_id, params) + .await?; + Ok(()) + } + + async fn unpublish_views( + &self, + workspace_id: &Uuid, + view_ids: Vec, + ) -> Result<(), FlowyError> { + let try_get_client = self.inner.try_get_client(); + try_get_client? + .unpublish_collabs(workspace_id, &view_ids) + .await?; + Ok(()) + } + + async fn get_publish_info(&self, view_id: &Uuid) -> Result { + let try_get_client = self.inner.try_get_client(); + let info = try_get_client? + .get_published_collab_info(view_id) + .await + .map_err(FlowyError::from)?; + Ok(info) + } + + async fn set_publish_name( + &self, + workspace_id: &Uuid, + view_id: Uuid, + new_name: String, + ) -> Result<(), FlowyError> { + let try_get_client = self.inner.try_get_client()?; + try_get_client + .patch_published_collabs( + workspace_id, + &[PatchPublishedCollab { + view_id, + publish_name: Some(new_name), + comments_enabled: Some(true), + duplicate_enabled: Some(true), + }], + ) + .await + .map_err(FlowyError::from)?; + Ok(()) + } + + async fn set_publish_namespace( + &self, + workspace_id: &Uuid, + new_namespace: String, + ) -> Result<(), FlowyError> { + let try_get_client = self.inner.try_get_client(); + try_get_client? + .set_workspace_publish_namespace(workspace_id, new_namespace) + .await?; + Ok(()) + } + + async fn list_published_views( + &self, + workspace_id: &Uuid, + ) -> Result, FlowyError> { + let published_views = self + .inner + .try_get_client()? + .list_published_views(workspace_id) + .await + .map_err(FlowyError::from)?; + Ok(published_views) + } + + async fn get_default_published_view_info( + &self, + workspace_id: &Uuid, + ) -> Result { + let default_published_view_info = self + .inner + .try_get_client()? + .get_default_publish_view_info(workspace_id) + .await + .map_err(FlowyError::from)?; + Ok(default_published_view_info) + } + + async fn set_default_published_view( + &self, + workspace_id: &Uuid, + view_id: uuid::Uuid, + ) -> Result<(), FlowyError> { + self + .inner + .try_get_client()? + .set_default_publish_view(workspace_id, view_id) + .await + .map_err(FlowyError::from)?; + Ok(()) + } + + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { + self + .inner + .try_get_client()? + .delete_default_publish_view(workspace_id) + .await + .map_err(FlowyError::from)?; + Ok(()) + } + + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result { + let namespace = self + .inner + .try_get_client()? + .get_workspace_publish_namespace(workspace_id) + .await?; + Ok(namespace) + } + + async fn import_zip(&self, file_path: &str) -> Result<(), FlowyError> { + let file_path = PathBuf::from(file_path); + let client = self.inner.try_get_client()?; + let url = client.create_import(&file_path).await?.presigned_url; + trace!( + "Importing zip file: {} to url: {}", + file_path.display(), + url + ); + client.upload_import_file(&file_path, &url).await?; + Ok(()) + } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/mod.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/mod.rs index 3ecd839109..105129f131 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/mod.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/mod.rs @@ -1,12 +1,16 @@ +pub(crate) use chat::*; pub(crate) use database::*; pub(crate) use document::*; pub(crate) use file_storage::*; pub(crate) use folder::*; +pub(crate) use search::*; pub(crate) use user::*; +mod chat; mod database; mod document; mod file_storage; mod folder; +mod search; mod user; mod util; diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs new file mode 100644 index 0000000000..1ce0995144 --- /dev/null +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs @@ -0,0 +1,47 @@ +use crate::af_cloud::AFServer; +use flowy_ai_pub::cloud::search_dto::{ + SearchDocumentResponseItem, SearchResult, SearchSummaryResult, +}; +use flowy_error::FlowyError; +use flowy_search_pub::cloud::SearchCloudService; +use lib_infra::async_trait::async_trait; +use uuid::Uuid; + +pub(crate) struct AFCloudSearchCloudServiceImpl { + pub inner: T, +} + +const DEFAULT_PREVIEW: u32 = 80; + +#[async_trait] +impl SearchCloudService for AFCloudSearchCloudServiceImpl +where + T: AFServer, +{ + async fn document_search( + &self, + workspace_id: &Uuid, + query: String, + ) -> Result, FlowyError> { + let client = self.inner.try_get_client()?; + let result = client + .search_documents(workspace_id, &query, 10, DEFAULT_PREVIEW, None) + .await?; + + Ok(result) + } + + async fn generate_search_summary( + &self, + workspace_id: &Uuid, + query: String, + search_results: Vec, + ) -> Result { + let client = self.inner.try_get_client()?; + let result = client + .generate_search_summary(workspace_id, &query, search_results) + .await?; + + Ok(result) + } +} diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index f602b404f8..7cc3f5d88c 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -1,478 +1,586 @@ use std::collections::HashMap; -use std::sync::Arc; +use std::str::FromStr; +use std::sync::{Arc, Weak}; use anyhow::anyhow; +use arc_swap::ArcSwapOption; +use client_api::entity::billing_dto::{ + RecurringInterval, SetSubscriptionRecurringInterval, SubscriptionCancelRequest, SubscriptionPlan, + SubscriptionPlanDetail, WorkspaceSubscriptionStatus, WorkspaceUsageAndLimit, +}; use client_api::entity::workspace_dto::{ - CreateWorkspaceMember, CreateWorkspaceParam, PatchWorkspaceParam, WorkspaceMemberChangeset, + CreateWorkspaceParam, PatchWorkspaceParam, QueryWorkspaceParam, WorkspaceMemberChangeset, WorkspaceMemberInvitation, }; use client_api::entity::{ - AFRole, AFWorkspace, AFWorkspaceInvitation, AuthProvider, CollabParams, CreateCollabParams, + AFWorkspace, AFWorkspaceInvitation, AFWorkspaceSettings, AFWorkspaceSettingsChange, AuthProvider, + CollabParams, CreateCollabParams, GotrueTokenResponse, QueryWorkspaceMember, }; use client_api::entity::{QueryCollab, QueryCollabParams}; use client_api::{Client, ClientConfiguration}; use collab_entity::{CollabObject, CollabType}; -use parking_lot::RwLock; -use tracing::instrument; +use tracing::{instrument, trace}; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_user_pub::cloud::{UserCloudService, UserCollabParams, UserUpdate, UserUpdateReceiver}; -use flowy_user_pub::entities::{ - AFCloudOAuthParams, AuthResponse, Role, UpdateUserProfileParams, UserCredentials, UserProfile, - UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, -}; -use lib_infra::box_any::BoxAny; -use lib_infra::future::FutureResult; -use uuid::Uuid; - -use crate::af_cloud::define::{ServerUser, USER_SIGN_IN_URL}; +use crate::af_cloud::define::{LoggedUser, USER_SIGN_IN_URL}; use crate::af_cloud::impls::user::dto::{ af_update_from_update_params, from_af_workspace_member, to_af_role, user_profile_from_af_profile, }; use crate::af_cloud::impls::user::util::encryption_type_from_profile; use crate::af_cloud::impls::util::check_request_workspace_id_is_match; use crate::af_cloud::{AFCloudClient, AFServer}; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_user_pub::cloud::{UserCloudService, UserCollabParams, UserUpdate, UserUpdateReceiver}; +use flowy_user_pub::entities::{ + AFCloudOAuthParams, AuthResponse, AuthType, Role, UpdateUserProfileParams, UserProfile, + UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, +}; +use flowy_user_pub::sql::select_user_workspace; +use lib_infra::async_trait::async_trait; +use lib_infra::box_any::BoxAny; +use uuid::Uuid; use super::dto::{from_af_workspace_invitation_status, to_workspace_invitation_status}; pub(crate) struct AFCloudUserAuthServiceImpl { server: T, - user_change_recv: RwLock>>, - user: Arc, + user_change_recv: ArcSwapOption>, + logged_user: Weak, } impl AFCloudUserAuthServiceImpl { pub(crate) fn new( server: T, user_change_recv: tokio::sync::mpsc::Receiver, - user: Arc, + logged_user: Weak, ) -> Self { Self { server, - user_change_recv: RwLock::new(Some(user_change_recv)), - user, + user_change_recv: ArcSwapOption::new(Some(Arc::new(user_change_recv))), + logged_user, } } } +#[async_trait] impl UserCloudService for AFCloudUserAuthServiceImpl where T: AFServer, { - fn sign_up(&self, params: BoxAny) -> FutureResult { + async fn sign_up(&self, params: BoxAny) -> Result { let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let params = oauth_params_from_box_any(params)?; - let resp = user_sign_up_request(try_get_client?, params).await?; - Ok(resp) - }) + let params = oauth_params_from_box_any(params)?; + let resp = user_sign_up_request(try_get_client?, params).await?; + Ok(resp) } // Zack: Not sure if this is needed anymore since sign_up handles both cases - fn sign_in(&self, params: BoxAny) -> FutureResult { + async fn sign_in(&self, params: BoxAny) -> Result { let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let client = try_get_client?; - let params = oauth_params_from_box_any(params)?; - let resp = user_sign_in_with_url(client, params).await?; - Ok(resp) - }) + let client = try_get_client?; + let params = oauth_params_from_box_any(params)?; + let resp = user_sign_in_with_url(client, params).await?; + Ok(resp) } - fn sign_out(&self, _token: Option) -> FutureResult<(), FlowyError> { + async fn sign_out(&self, _token: Option) -> Result<(), FlowyError> { // Calling the sign_out method that will revoke all connected devices' refresh tokens. // So do nothing here. - FutureResult::new(async move { Ok(()) }) + Ok(()) } - fn generate_sign_in_url_with_email(&self, email: &str) -> FutureResult { + async fn delete_account(&self) -> Result<(), FlowyError> { + let client = self.server.try_get_client()?; + client.delete_user().await?; + Ok(()) + } + + async fn generate_sign_in_url_with_email(&self, email: &str) -> Result { let email = email.to_string(); let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let client = try_get_client?; - let admin_client = get_admin_client(&client).await?; - let action_link = admin_client.generate_sign_in_action_link(&email).await?; - let sign_in_url = client.extract_sign_in_url(&action_link).await?; - Ok(sign_in_url) - }) + let client = try_get_client?; + let admin_client = get_admin_client(&client).await?; + let action_link = admin_client.generate_sign_in_action_link(&email).await?; + let sign_in_url = client.extract_sign_in_url(&action_link).await?; + Ok(sign_in_url) } - fn create_user(&self, email: &str, password: &str) -> FutureResult<(), FlowyError> { + async fn create_user(&self, email: &str, password: &str) -> Result<(), FlowyError> { let password = password.to_string(); let email = email.to_string(); let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let client = try_get_client?; - let admin_client = get_admin_client(&client).await?; - admin_client - .create_email_verified_user(&email, &password) - .await?; + let client = try_get_client?; + let admin_client = get_admin_client(&client).await?; + admin_client + .create_email_verified_user(&email, &password) + .await?; - Ok(()) - }) + Ok(()) } - fn sign_in_with_password( + async fn sign_in_with_password( &self, email: &str, password: &str, - ) -> FutureResult { + ) -> Result { let password = password.to_string(); let email = email.to_string(); let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let client = try_get_client?; - client.sign_in_password(&email, &password).await?; - let profile = client.get_profile().await?; - let token = client.get_token()?; - let profile = user_profile_from_af_profile(token, profile)?; - Ok(profile) - }) + let client = try_get_client?; + let response = client.sign_in_password(&email, &password).await?; + Ok(response.gotrue_response) } - fn sign_in_with_magic_link( + async fn sign_in_with_magic_link( &self, email: &str, redirect_to: &str, - ) -> FutureResult<(), FlowyError> { + ) -> Result<(), FlowyError> { let email = email.to_owned(); let redirect_to = redirect_to.to_owned(); let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let client = try_get_client?; - client - .sign_in_with_magic_link(&email, Some(redirect_to)) - .await?; - Ok(()) - }) + let client = try_get_client?; + client + .sign_in_with_magic_link(&email, Some(redirect_to)) + .await?; + Ok(()) } - fn generate_oauth_url_with_provider(&self, provider: &str) -> FutureResult { + async fn sign_in_with_passcode( + &self, + email: &str, + passcode: &str, + ) -> Result { + let email = email.to_owned(); + let passcode = passcode.to_owned(); + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let response = client.sign_in_with_passcode(&email, &passcode).await?; + Ok(response) + } + + async fn generate_oauth_url_with_provider(&self, provider: &str) -> Result { let provider = AuthProvider::from(provider); let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let provider = provider.ok_or(anyhow!("invalid provider"))?; - let url = try_get_client? - .generate_oauth_url_with_provider(&provider) - .await?; - Ok(url) - }) + let provider = provider.ok_or(anyhow!("invalid provider"))?; + let url = try_get_client? + .generate_oauth_url_with_provider(&provider) + .await?; + Ok(url) } - fn update_user( - &self, - _credential: UserCredentials, - params: UpdateUserProfileParams, - ) -> FutureResult<(), FlowyError> { + async fn update_user(&self, params: UpdateUserProfileParams) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let client = try_get_client?; - client - .update_user(af_update_from_update_params(params)) - .await?; - Ok(()) - }) + let client = try_get_client?; + client + .update_user(af_update_from_update_params(params)) + .await?; + Ok(()) } #[instrument(level = "debug", skip_all)] - fn get_user_profile( + async fn get_user_profile( &self, - _credential: UserCredentials, - ) -> FutureResult { - let try_get_client = self.server.try_get_client(); - let cloned_user = self.user.clone(); - FutureResult::new(async move { - let expected_workspace_id = cloned_user.workspace_id()?; - let client = try_get_client?; - let profile = client.get_profile().await?; - let token = client.get_token()?; - let profile = user_profile_from_af_profile(token, profile)?; + uid: i64, + workspace_id: &str, + ) -> Result { + let client = self.server.try_get_client()?; + let logged_user = self + .logged_user + .upgrade() + .ok_or_else(FlowyError::user_not_login)?; - // Discard the response if the user has switched to a new workspace. This avoids updating the - // user profile with potentially outdated information when the workspace ID no longer matches. - check_request_workspace_id_is_match( - &expected_workspace_id, - &cloned_user, - "get user profile", - )?; - Ok(profile) - }) + let profile = client.get_profile().await?; + let token = client.get_token()?; + + let mut conn = logged_user.get_sqlite_db(uid)?; + let workspace_auth_type = select_user_workspace(workspace_id, &mut conn) + .map(|row| AuthType::from(row.workspace_type)) + .unwrap_or(AuthType::AppFlowyCloud); + let profile = user_profile_from_af_profile(token, profile, workspace_auth_type)?; + + // Discard the response if the user has switched to a new workspace. This avoids updating the + // user profile with potentially outdated information when the workspace ID no longer matches. + let workspace_id = Uuid::from_str(workspace_id)?; + check_request_workspace_id_is_match(&workspace_id, &self.logged_user, "get user profile")?; + Ok(profile) } - fn open_workspace(&self, workspace_id: &str) -> FutureResult { + async fn open_workspace(&self, workspace_id: &Uuid) -> Result { let try_get_client = self.server.try_get_client(); - let workspace_id = workspace_id.to_string(); - FutureResult::new(async move { - let client = try_get_client?; - let af_workspace = client.open_workspace(&workspace_id).await?; - Ok(to_user_workspace(af_workspace)) - }) + let client = try_get_client?; + let af_workspace = client.open_workspace(workspace_id).await?; + Ok(to_user_workspace(af_workspace)) } - fn get_all_workspace(&self, _uid: i64) -> FutureResult, FlowyError> { + async fn get_all_workspace(&self, _uid: i64) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let workspaces = try_get_client?.get_workspaces().await?; - to_user_workspaces(workspaces.0) - }) + let workspaces = try_get_client? + .get_workspaces_opt(QueryWorkspaceParam { + include_member_count: Some(true), + include_role: Some(true), + }) + .await?; + to_user_workspaces(workspaces) } - #[allow(deprecated)] - fn add_workspace_member( + async fn create_workspace(&self, workspace_name: &str) -> Result { + let workspace_name_owned = workspace_name.to_owned(); + let new_workspace = self + .server + .try_get_client()? + .create_workspace(CreateWorkspaceParam { + workspace_name: Some(workspace_name_owned), + }) + .await?; + Ok(to_user_workspace(new_workspace)) + } + + async fn patch_workspace( &self, - user_email: String, - workspace_id: String, - ) -> FutureResult<(), FlowyError> { - let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - // TODO(zack): add_workspace_members will be deprecated after finishing the invite logic. Don't forget to remove the #[allow(deprecated)] - try_get_client? - .add_workspace_members( - workspace_id, - vec![CreateWorkspaceMember { - email: user_email, - role: AFRole::Member, - }], - ) - .await?; - Ok(()) - }) + workspace_id: &Uuid, + new_workspace_name: Option, + new_workspace_icon: Option, + ) -> Result<(), FlowyError> { + let workspace_id = workspace_id.to_owned(); + self + .server + .try_get_client()? + .patch_workspace(PatchWorkspaceParam { + workspace_id, + workspace_name: new_workspace_name, + workspace_icon: new_workspace_icon, + }) + .await?; + Ok(()) } - fn invite_workspace_member( + async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + client.delete_workspace(workspace_id).await?; + Ok(()) + } + + async fn invite_workspace_member( &self, invitee_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, - ) -> FutureResult<(), FlowyError> { + ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - try_get_client? - .invite_workspace_members( - &workspace_id, - vec![WorkspaceMemberInvitation { - email: invitee_email, - role: to_af_role(role), - }], - ) - .await?; - Ok(()) - }) + try_get_client? + .invite_workspace_members( + &workspace_id, + vec![WorkspaceMemberInvitation { + email: invitee_email, + role: to_af_role(role), + skip_email_send: false, + wait_email_send: false, + }], + ) + .await?; + Ok(()) } - fn list_workspace_invitations( + async fn list_workspace_invitations( &self, filter: Option, - ) -> FutureResult, FlowyError> { + ) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); let filter = filter.map(to_workspace_invitation_status); - FutureResult::new(async move { - let r = try_get_client? - .list_workspace_invitations(filter) - .await? - .into_iter() - .map(to_workspace_invitation) - .collect(); - Ok(r) - }) + let r = try_get_client? + .list_workspace_invitations(filter) + .await? + .into_iter() + .map(to_workspace_invitation) + .collect(); + Ok(r) } - fn accept_workspace_invitations(&self, invite_id: String) -> FutureResult<(), FlowyError> { + async fn accept_workspace_invitations(&self, invite_id: String) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - try_get_client? - .accept_workspace_invitation(&invite_id) - .await?; - Ok(()) - }) + try_get_client? + .accept_workspace_invitation(&invite_id) + .await?; + Ok(()) } - fn remove_workspace_member( + async fn remove_workspace_member( &self, user_email: String, - workspace_id: String, - ) -> FutureResult<(), FlowyError> { + workspace_id: Uuid, + ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - try_get_client? - .remove_workspace_members(workspace_id, vec![user_email]) - .await?; - Ok(()) - }) + try_get_client? + .remove_workspace_members(&workspace_id, vec![user_email]) + .await?; + Ok(()) } - fn update_workspace_member( + async fn update_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, - ) -> FutureResult<(), FlowyError> { + ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let changeset = WorkspaceMemberChangeset::new(user_email).with_role(to_af_role(role)); - try_get_client? - .update_workspace_member(workspace_id, changeset) - .await?; - Ok(()) - }) + let changeset = WorkspaceMemberChangeset::new(user_email).with_role(to_af_role(role)); + try_get_client? + .update_workspace_member(&workspace_id, changeset) + .await?; + Ok(()) } - fn get_workspace_members( + async fn get_workspace_members( &self, - workspace_id: String, - ) -> FutureResult, FlowyError> { + workspace_id: Uuid, + ) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let members = try_get_client? - .get_workspace_members(&workspace_id) - .await? - .into_iter() - .map(from_af_workspace_member) - .collect(); - Ok(members) - }) + let members = try_get_client? + .get_workspace_members(&workspace_id) + .await? + .into_iter() + .map(from_af_workspace_member) + .collect(); + Ok(members) } #[instrument(level = "debug", skip_all)] - fn get_user_awareness_doc_state( + async fn get_user_awareness_doc_state( &self, _uid: i64, - workspace_id: &str, - object_id: &str, - ) -> FutureResult, FlowyError> { - let workspace_id = workspace_id.to_string(); - let object_id = object_id.to_string(); + workspace_id: &Uuid, + object_id: &Uuid, + ) -> Result, FlowyError> { let try_get_client = self.server.try_get_client(); - let cloned_user = self.user.clone(); - FutureResult::new(async move { - let params = QueryCollabParams { - workspace_id: workspace_id.clone(), - inner: QueryCollab { - object_id, - collab_type: CollabType::UserAwareness, - }, - }; - let resp = try_get_client?.get_collab(params).await?; - check_request_workspace_id_is_match( - &workspace_id, - &cloned_user, - "get user awareness object", - )?; - Ok(resp.encode_collab.doc_state.to_vec()) - }) + let cloned_user = self.logged_user.clone(); + let params = QueryCollabParams { + workspace_id: *workspace_id, + inner: QueryCollab::new(*object_id, CollabType::UserAwareness), + }; + let resp = try_get_client?.get_collab(params).await?; + check_request_workspace_id_is_match(workspace_id, &cloned_user, "get user awareness object")?; + Ok(resp.encode_collab.doc_state.to_vec()) } fn subscribe_user_update(&self) -> Option { - self.user_change_recv.write().take() + let rx = self.user_change_recv.swap(None)?; + Arc::into_inner(rx) } - fn reset_workspace(&self, _collab_object: CollabObject) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) - } - - fn create_collab_object( + async fn create_collab_object( &self, collab_object: &CollabObject, data: Vec, - ) -> FutureResult<(), FlowyError> { + ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); let collab_object = collab_object.clone(); - FutureResult::new(async move { - let client = try_get_client?; - let params = CreateCollabParams { - workspace_id: collab_object.workspace_id.clone(), - object_id: collab_object.object_id.clone(), - encoded_collab_v1: data, - collab_type: collab_object.collab_type.clone(), - }; - client.create_collab(params).await?; - Ok(()) - }) + let client = try_get_client?; + let workspace_id = Uuid::from_str(&collab_object.workspace_id)?; + let object_id = Uuid::from_str(&collab_object.object_id)?; + + let params = CreateCollabParams { + workspace_id, + object_id, + collab_type: collab_object.collab_type, + encoded_collab_v1: data, + }; + client.create_collab(params).await?; + Ok(()) } - fn batch_create_collab_object( + async fn batch_create_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, - ) -> FutureResult<(), FlowyError> { - let workspace_id = workspace_id.to_string(); + ) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - FutureResult::new(async move { - let params = objects - .into_iter() - .map(|object| CollabParams { - object_id: object.object_id, - encoded_collab_v1: object.encoded_collab, - collab_type: object.collab_type, - }) - .collect::>(); - try_get_client? - .create_collab_list(&workspace_id, params) - .await - .map_err(FlowyError::from)?; - Ok(()) - }) + let params = objects + .into_iter() + .flat_map(|object| { + Uuid::from_str(&object.object_id) + .map(|object_id| { + CollabParams::new( + object_id, + u8::from(object.collab_type).into(), + object.encoded_collab, + ) + }) + .ok() + }) + .collect::>(); + try_get_client? + .create_collab_list(workspace_id, params) + .await + .map_err(FlowyError::from)?; + Ok(()) } - fn create_workspace(&self, workspace_name: &str) -> FutureResult { + async fn leave_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { let try_get_client = self.server.try_get_client(); - let workspace_name_owned = workspace_name.to_owned(); - FutureResult::new(async move { - let client = try_get_client?; - let new_workspace = client - .create_workspace(CreateWorkspaceParam { - workspace_name: Some(workspace_name_owned), - }) - .await?; - Ok(to_user_workspace(new_workspace)) - }) + let client = try_get_client?; + client.leave_workspace(workspace_id).await?; + Ok(()) } - fn delete_workspace(&self, workspace_id: &str) -> FutureResult<(), FlowyError> { - let try_get_client = self.server.try_get_client(); - let workspace_id_owned = workspace_id.to_owned(); - FutureResult::new(async move { - let client = try_get_client?; - client.delete_workspace(&workspace_id_owned).await?; - Ok(()) - }) - } - - fn patch_workspace( + async fn subscribe_workspace( &self, - workspace_id: &str, - new_workspace_name: Option<&str>, - new_workspace_icon: Option<&str>, - ) -> FutureResult<(), FlowyError> { - let try_get_client = self.server.try_get_client(); - let owned_workspace_id = workspace_id.to_owned(); - let owned_workspace_name = new_workspace_name.map(|s| s.to_owned()); - let owned_workspace_icon = new_workspace_icon.map(|s| s.to_owned()); - FutureResult::new(async move { - let workspace_id: Uuid = owned_workspace_id - .parse() - .map_err(|_| ErrorCode::InvalidParams)?; - let client = try_get_client?; - client - .patch_workspace(PatchWorkspaceParam { - workspace_id, - workspace_name: owned_workspace_name, - workspace_icon: owned_workspace_icon, - }) - .await?; - Ok(()) - }) - } - - fn leave_workspace(&self, workspace_id: &str) -> FutureResult<(), FlowyError> { + workspace_id: Uuid, + recurring_interval: RecurringInterval, + workspace_subscription_plan: SubscriptionPlan, + success_url: String, + ) -> Result { let try_get_client = self.server.try_get_client(); let workspace_id = workspace_id.to_string(); - FutureResult::new(async move { - let client = try_get_client?; - client.leave_workspace(&workspace_id).await?; - Ok(()) - }) + let client = try_get_client?; + let payment_link = client + .create_subscription( + &workspace_id, + recurring_interval, + workspace_subscription_plan, + &success_url, + ) + .await?; + Ok(payment_link) + } + + async fn get_workspace_member( + &self, + workspace_id: &Uuid, + uid: i64, + ) -> Result { + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let params = QueryWorkspaceMember { + workspace_id: *workspace_id, + uid, + }; + let member = client.get_workspace_member(params).await?; + + Ok(from_af_workspace_member(member)) + } + + async fn get_workspace_subscriptions( + &self, + ) -> Result, FlowyError> { + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let workspace_subscriptions = client.list_subscription().await?; + Ok(workspace_subscriptions) + } + + async fn get_workspace_subscription_one( + &self, + workspace_id: &Uuid, + ) -> Result, FlowyError> { + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let workspace_subscriptions = client + .get_workspace_subscriptions(&workspace_id.to_string()) + .await?; + Ok(workspace_subscriptions) + } + + async fn cancel_workspace_subscription( + &self, + workspace_id: String, + plan: SubscriptionPlan, + reason: Option, + ) -> Result<(), FlowyError> { + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + client + .cancel_subscription(&SubscriptionCancelRequest { + workspace_id, + plan, + sync: true, + reason, + }) + .await?; + Ok(()) + } + + async fn get_workspace_plan( + &self, + workspace_id: Uuid, + ) -> Result, FlowyError> { + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let plans = client + .get_active_workspace_subscriptions(&workspace_id.to_string()) + .await?; + Ok(plans) + } + + async fn get_workspace_usage( + &self, + workspace_id: &Uuid, + ) -> Result { + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let usage = client + .get_workspace_usage_and_limit(&workspace_id.to_string()) + .await?; + Ok(usage) + } + + async fn get_billing_portal_url(&self) -> Result { + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let url = client.get_portal_session_link().await?; + Ok(url) + } + + async fn update_workspace_subscription_payment_period( + &self, + workspace_id: &Uuid, + plan: SubscriptionPlan, + recurring_interval: RecurringInterval, + ) -> Result<(), FlowyError> { + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + client + .set_subscription_recurring_interval(&SetSubscriptionRecurringInterval { + workspace_id: workspace_id.to_string(), + plan, + recurring_interval, + }) + .await?; + Ok(()) + } + + async fn get_subscription_plan_details(&self) -> Result, FlowyError> { + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let plan_details = client.get_subscription_plan_details().await?; + Ok(plan_details) + } + + async fn get_workspace_setting( + &self, + workspace_id: &Uuid, + ) -> Result { + let workspace_id = workspace_id.to_string(); + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let settings = client.get_workspace_settings(&workspace_id).await?; + Ok(settings) + } + + async fn update_workspace_setting( + &self, + workspace_id: &Uuid, + workspace_settings: AFWorkspaceSettingsChange, + ) -> Result { + trace!("Sync workspace settings: {:?}", workspace_settings); + let workspace_id = workspace_id.to_string(); + let try_get_client = self.server.try_get_client(); + let client = try_get_client?; + let settings = client + .update_workspace_settings(&workspace_id, &workspace_settings) + .await?; + Ok(settings) } } @@ -489,9 +597,18 @@ async fn get_admin_client(client: &Arc) -> FlowyResult { ClientConfiguration::default(), &client.client_version.to_string(), ); - admin_client + // When multiple admin_client instances attempt to sign in concurrently, multiple admin user + // creation transaction will be created, but only the first attempt will succeed due to the + // unique email constraint. Once the user has been created, admin_client instances can sign in + // concurrently without issue. + let resp = admin_client .sign_in_password(&admin_email, &admin_password) - .await?; + .await; + if resp.is_err() { + admin_client + .sign_in_password(&admin_email, &admin_password) + .await?; + }; Ok(admin_client) } @@ -535,8 +652,10 @@ fn to_user_workspace(af_workspace: AFWorkspace) -> UserWorkspace { id: af_workspace.workspace_id.to_string(), name: af_workspace.workspace_name, created_at: af_workspace.created_at, - database_indexer_id: af_workspace.database_storage_id.to_string(), + workspace_database_id: af_workspace.database_storage_id.to_string(), icon: af_workspace.icon, + member_count: af_workspace.member_count.unwrap_or(0), + role: af_workspace.role.map(|r| r.into()), } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs index c24ddbb51e..838e9dd6ca 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs @@ -3,22 +3,12 @@ use client_api::entity::auth_dto::{UpdateUserParams, UserMetaData}; use client_api::entity::{AFRole, AFUserProfile, AFWorkspaceInvitationStatus, AFWorkspaceMember}; use flowy_user_pub::entities::{ - Authenticator, Role, UpdateUserProfileParams, UserProfile, WorkspaceInvitationStatus, - WorkspaceMember, USER_METADATA_ICON_URL, USER_METADATA_OPEN_AI_KEY, - USER_METADATA_STABILITY_AI_KEY, + AuthType, Role, UpdateUserProfileParams, UserProfile, WorkspaceInvitationStatus, WorkspaceMember, + USER_METADATA_ICON_URL, }; -use crate::af_cloud::impls::user::util::encryption_type_from_profile; - pub fn af_update_from_update_params(update: UpdateUserProfileParams) -> UpdateUserParams { let mut user_metadata = UserMetaData::new(); - if let Some(openai_key) = update.openai_key { - user_metadata.insert(USER_METADATA_OPEN_AI_KEY, openai_key); - } - - if let Some(stability_ai_key) = update.stability_ai_key { - user_metadata.insert(USER_METADATA_STABILITY_AI_KEY, stability_ai_key); - } if let Some(icon_url) = update.icon_url { user_metadata.insert(USER_METADATA_ICON_URL, icon_url); @@ -35,20 +25,14 @@ pub fn af_update_from_update_params(update: UpdateUserProfileParams) -> UpdateUs pub fn user_profile_from_af_profile( token: String, profile: AFUserProfile, + workspace_auth_type: AuthType, ) -> Result { - let encryption_type = encryption_type_from_profile(&profile); - let (icon_url, openai_key, stability_ai_key) = { + let icon_url = { profile .metadata .map(|m| { - ( - m.get(USER_METADATA_ICON_URL) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()), - m.get(USER_METADATA_OPEN_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()), - m.get(USER_METADATA_STABILITY_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()), - ) + m.get(USER_METADATA_ICON_URL) + .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) }) .unwrap_or_default() }; @@ -58,13 +42,10 @@ pub fn user_profile_from_af_profile( name: profile.name.unwrap_or("".to_string()), token, icon_url: icon_url.unwrap_or_default(), - openai_key: openai_key.unwrap_or_default(), - stability_ai_key: stability_ai_key.unwrap_or_default(), - workspace_id: profile.latest_workspace_id.to_string(), - authenticator: Authenticator::AppFlowyCloud, - encryption_type, + auth_type: AuthType::AppFlowyCloud, uid: profile.uid, updated_at: profile.updated_at, + workspace_auth_type, }) } @@ -89,6 +70,7 @@ pub fn from_af_workspace_member(member: AFWorkspaceMember) -> WorkspaceMember { email: member.email, role: from_af_role(member.role), name: member.name, + avatar_url: member.avatar_url, } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs index 4075a5b908..300738c833 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs @@ -1,22 +1,24 @@ -use crate::af_cloud::define::ServerUser; +use crate::af_cloud::define::LoggedUser; use flowy_error::{FlowyError, FlowyResult}; -use std::sync::Arc; +use std::sync::Weak; use tracing::warn; +use uuid::Uuid; /// Validates the workspace_id provided in the request. /// It checks that the workspace_id from the request matches the current user's active workspace_id. /// This ensures that the operation is being performed in the correct workspace context, enhancing security. pub fn check_request_workspace_id_is_match( - expected_workspace_id: &str, - user: &Arc, + expected_workspace_id: &Uuid, + user: &Weak, action: impl AsRef, ) -> FlowyResult<()> { + let user = user.upgrade().ok_or_else(FlowyError::user_not_login)?; let actual_workspace_id = user.workspace_id()?; - if expected_workspace_id != actual_workspace_id { + if expected_workspace_id != &actual_workspace_id { warn!( "{}, expect workspace_id: {}, actual workspace_id: {}", action.as_ref(), - expected_workspace_id, + expected_workspace_id.to_string(), actual_workspace_id ); diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs index 1fd6a5b03f..66abb32031 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs @@ -1,8 +1,10 @@ use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use std::time::Duration; +use crate::af_cloud::define::{AIUserServiceImpl, LoggedUser}; use anyhow::Error; +use arc_swap::ArcSwap; use client_api::collab_sync::ServerCollabMessage; use client_api::entity::UserMessage; use client_api::notify::{TokenState, TokenStateReceiver}; @@ -10,31 +12,37 @@ use client_api::ws::{ ConnectState, WSClient, WSClientConfig, WSConnectStateReceiver, WebSocketChannel, }; use client_api::{Client, ClientConfiguration}; -use flowy_storage::ObjectStorageService; -use rand::Rng; -use tokio::select; -use tokio::sync::{watch, Mutex}; -use tokio_stream::wrappers::WatchStream; -use tokio_util::sync::CancellationToken; -use tracing::{error, event, info, warn}; -use uuid::Uuid; -use crate::af_cloud::define::ServerUser; -use flowy_database_pub::cloud::DatabaseCloudService; +use flowy_ai_pub::cloud::ChatCloudService; +use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; use flowy_document_pub::cloud::DocumentCloudService; use flowy_error::{ErrorCode, FlowyError}; use flowy_folder_pub::cloud::FolderCloudService; +use flowy_search_pub::cloud::SearchCloudService; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; +use flowy_storage_pub::cloud::StorageCloudService; use flowy_user_pub::cloud::{UserCloudService, UserUpdate}; use flowy_user_pub::entities::UserTokenState; -use lib_dispatch::prelude::af_spawn; use crate::af_cloud::impls::{ AFCloudDatabaseCloudServiceImpl, AFCloudDocumentCloudServiceImpl, AFCloudFileStorageServiceImpl, - AFCloudFolderCloudServiceImpl, AFCloudUserAuthServiceImpl, + AFCloudFolderCloudServiceImpl, AFCloudUserAuthServiceImpl, CloudChatServiceImpl, }; +use flowy_ai::offline::offline_message_sync::AutoSyncChatService; +use rand::Rng; +use semver::Version; +use tokio::select; +use tokio::sync::watch; +use tokio::task::JoinHandle; +use tokio_stream::wrappers::WatchStream; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; +use uuid::Uuid; + use crate::AppFlowyServer; +use super::impls::AFCloudSearchCloudServiceImpl; + pub(crate) type AFCloudClient = Client; pub struct AppFlowyCloudServer { @@ -45,7 +53,7 @@ pub struct AppFlowyCloudServer { network_reachable: Arc, pub device_id: String, ws_client: Arc, - user: Arc, + logged_user: Weak, } impl AppFlowyCloudServer { @@ -53,8 +61,8 @@ impl AppFlowyCloudServer { config: AFCloudConfiguration, enable_sync: bool, mut device_id: String, - client_version: &str, - user: Arc, + client_version: Version, + logged_user: Weak, ) -> Self { // The device id can't be empty, so we generate a new one if it is. if device_id.is_empty() { @@ -70,7 +78,7 @@ impl AppFlowyCloudServer { ClientConfiguration::default() .with_compression_buffer_size(10240) .with_compression_quality(8), - client_version, + &client_version.to_string(), ); let token_state_rx = api_client.subscribe_token_state(); let enable_sync = Arc::new(AtomicBool::new(enable_sync)); @@ -83,15 +91,8 @@ impl AppFlowyCloudServer { ); let ws_client = Arc::new(ws_client); let api_client = Arc::new(api_client); - let ws_connect_cancellation_token = Arc::new(Mutex::new(CancellationToken::new())); + spawn_ws_conn(token_state_rx, &ws_client, &api_client, &enable_sync); - spawn_ws_conn( - token_state_rx, - &ws_client, - ws_connect_cancellation_token, - &api_client, - &enable_sync, - ); Self { config, client: api_client, @@ -99,16 +100,17 @@ impl AppFlowyCloudServer { network_reachable, device_id, ws_client, - user, + logged_user, } } - fn get_client(&self) -> Option> { - if self.enable_sync.load(Ordering::SeqCst) { + fn get_server_impl(&self) -> AFServerImpl { + let client = if self.enable_sync.load(Ordering::SeqCst) { Some(self.client.clone()) } else { None - } + }; + AFServerImpl { client } } } @@ -120,17 +122,24 @@ impl AppFlowyServer for AppFlowyCloudServer { .map_err(|err| Error::new(FlowyError::unauthorized().with_context(err))) } + fn set_ai_model(&self, ai_model: &str) -> Result<(), Error> { + self.client.set_ai_model(ai_model.to_string()); + Ok(()) + } + fn subscribe_token_state(&self) -> Option> { let mut token_state_rx = self.client.subscribe_token_state(); let (watch_tx, watch_rx) = watch::channel(UserTokenState::Init); let weak_client = Arc::downgrade(&self.client); - af_spawn(async move { + tokio::spawn(async move { while let Ok(token_state) = token_state_rx.recv().await { if let Some(client) = weak_client.upgrade() { match token_state { TokenState::Refresh => match client.get_token() { Ok(token) => { - let _ = watch_tx.send(UserTokenState::Refresh { token }); + if let Err(err) = watch_tx.send(UserTokenState::Refresh { token }) { + error!("Failed to send token after token state changed: {}", err); + } }, Err(err) => { error!("Failed to get token after token state changed: {}", err); @@ -157,12 +166,9 @@ impl AppFlowyServer for AppFlowyCloudServer { } fn user_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; let mut user_change = self.ws_client.subscribe_user_changed(); let (tx, rx) = tokio::sync::mpsc::channel(1); - af_spawn(async move { + tokio::spawn(async move { while let Ok(user_message) = user_change.recv().await { if let UserMessage::ProfileChange(change) = user_message { let user_update = UserUpdate { @@ -177,42 +183,49 @@ impl AppFlowyServer for AppFlowyCloudServer { }); Arc::new(AFCloudUserAuthServiceImpl::new( - server, + self.get_server_impl(), rx, - self.user.clone(), + self.logged_user.clone(), )) } fn folder_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; Arc::new(AFCloudFolderCloudServiceImpl { - inner: server, - user: self.user.clone(), + inner: self.get_server_impl(), + logged_user: self.logged_user.clone(), }) } fn database_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; Arc::new(AFCloudDatabaseCloudServiceImpl { - inner: server, - user: self.user.clone(), + inner: self.get_server_impl(), + logged_user: self.logged_user.clone(), }) } + fn database_ai_service(&self) -> Option> { + Some(Arc::new(AFCloudDatabaseCloudServiceImpl { + inner: self.get_server_impl(), + logged_user: self.logged_user.clone(), + })) + } + fn document_service(&self) -> Arc { - let server = AFServerImpl { - client: self.get_client(), - }; Arc::new(AFCloudDocumentCloudServiceImpl { - inner: server, - user: self.user.clone(), + inner: self.get_server_impl(), + logged_user: self.logged_user.clone(), }) } + fn chat_service(&self) -> Arc { + Arc::new(AutoSyncChatService::new( + Arc::new(CloudChatServiceImpl { + inner: self.get_server_impl(), + }), + Arc::new(AIUserServiceImpl(self.logged_user.clone())), + )) + } + fn subscribe_ws_state(&self) -> Option { Some(self.ws_client.subscribe_connect_state()) } @@ -239,11 +252,17 @@ impl AppFlowyServer for AppFlowyCloudServer { Ok(channel.map(|c| (c, connect_state_recv, self.ws_client.is_connected()))) } - fn file_storage(&self) -> Option> { - let client = AFServerImpl { - client: self.get_client(), - }; - Some(Arc::new(AFCloudFileStorageServiceImpl::new(client))) + fn file_storage(&self) -> Option> { + Some(Arc::new(AFCloudFileStorageServiceImpl::new( + self.get_server_impl(), + self.config.maximum_upload_file_size_in_bytes, + ))) + } + + fn search_service(&self) -> Option> { + Some(Arc::new(AFCloudSearchCloudServiceImpl { + inner: self.get_server_impl(), + })) } } @@ -254,16 +273,17 @@ impl AppFlowyServer for AppFlowyCloudServer { fn spawn_ws_conn( mut token_state_rx: TokenStateReceiver, ws_client: &Arc, - conn_cancellation_token: Arc>, api_client: &Arc, enable_sync: &Arc, ) { let weak_ws_client = Arc::downgrade(ws_client); let weak_api_client = Arc::downgrade(api_client); let enable_sync = enable_sync.clone(); - let cloned_conn_cancellation_token = conn_cancellation_token.clone(); - af_spawn(async move { + let cancellation_token = Arc::new(ArcSwap::new(Arc::new(CancellationToken::new()))); + let cloned_cancellation_token = cancellation_token.clone(); + + tokio::spawn(async move { if let Some(ws_client) = weak_ws_client.upgrade() { let mut state_recv = ws_client.subscribe_connect_state(); while let Ok(state) = state_recv.recv().await { @@ -272,7 +292,7 @@ fn spawn_ws_conn( ConnectState::PingTimeout | ConnectState::Lost => { // Try to reconnect if the connection is timed out. if weak_api_client.upgrade().is_some() && enable_sync.load(Ordering::SeqCst) { - attempt_reconnect(&ws_client, 2, &cloned_conn_cancellation_token).await; + attempt_reconnect(&ws_client, 2, &cloned_cancellation_token).await; } }, ConnectState::Unauthorized => { @@ -292,13 +312,13 @@ fn spawn_ws_conn( }); let weak_ws_client = Arc::downgrade(ws_client); - af_spawn(async move { + tokio::spawn(async move { while let Ok(token_state) = token_state_rx.recv().await { info!("🟢token state: {:?}", token_state); match token_state { TokenState::Refresh => { if let Some(ws_client) = weak_ws_client.upgrade() { - attempt_reconnect(&ws_client, 5, &conn_cancellation_token).await; + attempt_reconnect(&ws_client, 5, &cancellation_token).await; } }, TokenState::Invalid => { @@ -321,34 +341,29 @@ fn spawn_ws_conn( async fn attempt_reconnect( ws_client: &Arc, minimum_delay_in_secs: u64, - conn_cancellation_token: &Arc>, -) { - // Cancel the previous reconnection attempt - let mut cancel_token_lock = conn_cancellation_token.lock().await; - cancel_token_lock.cancel(); - + cancellation_token: &Arc>, +) -> JoinHandle<()> { + cancellation_token.load_full().cancel(); let new_cancel_token = CancellationToken::new(); - *cancel_token_lock = new_cancel_token.clone(); - drop(cancel_token_lock); + cancellation_token.store(Arc::new(new_cancel_token.clone())); - // randomness in the reconnection attempts to avoid thundering herd problem let delay_seconds = rand::thread_rng().gen_range(minimum_delay_in_secs..10); - let ws_client = ws_client.clone(); + let ws_client_clone = ws_client.clone(); tokio::spawn(async move { select! { - _ = new_cancel_token.cancelled() => { - event!( - tracing::Level::TRACE, - "🟢websocket reconnection attempt cancelled." - ); - }, - _ = tokio::time::sleep(Duration::from_secs(delay_seconds)) => { - if let Err(e) = ws_client.connect().await { - error!("Failed to reconnect websocket: {}", e); + // If the new cancellation token is triggered, log cancellation + _ = new_cancel_token.cancelled() => { + tracing::trace!("🟢 websocket reconnection attempt cancelled."); + }, + _ = tokio::time::sleep(Duration::from_secs(delay_seconds)) => { + if let Err(e) = ws_client_clone.connect().await { + error!("❌ Failed to reconnect websocket: {}", e); + } else { + info!("✅ Reconnected websocket successfully."); + } } - } } - }); + }) } pub trait AFServer: Send + Sync + 'static { diff --git a/frontend/rust-lib/flowy-server/src/lib.rs b/frontend/rust-lib/flowy-server/src/lib.rs index 4e647f4210..034991a984 100644 --- a/frontend/rust-lib/flowy-server/src/lib.rs +++ b/frontend/rust-lib/flowy-server/src/lib.rs @@ -5,7 +5,4 @@ pub mod local_server; mod response; mod server; -#[cfg(feature = "enable_supabase")] -pub mod supabase; - pub mod util; diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs new file mode 100644 index 0000000000..845b6dec1c --- /dev/null +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs @@ -0,0 +1,355 @@ +use crate::af_cloud::define::LoggedUser; +use chrono::{TimeZone, Utc}; +use client_api::entity::ai_dto::RepeatedRelatedQuestion; +use client_api::entity::CompletionStream; +use flowy_ai::local_ai::controller::LocalAIController; +use flowy_ai::local_ai::stream_util::QuestionStream; +use flowy_ai_pub::cloud::chat_dto::{ChatAuthor, ChatAuthorType}; +use flowy_ai_pub::cloud::{ + AIModel, AppErrorCode, AppResponseError, ChatCloudService, ChatMessage, ChatMessageType, + ChatSettings, CompleteTextParams, MessageCursor, ModelList, RelatedQuestion, RepeatedChatMessage, + ResponseFormat, StreamAnswer, StreamComplete, UpdateChatParams, DEFAULT_AI_MODEL_NAME, +}; +use flowy_ai_pub::persistence::{ + deserialize_chat_metadata, deserialize_rag_ids, read_chat, + select_answer_where_match_reply_message_id, select_chat_messages, select_message_content, + serialize_chat_metadata, serialize_rag_ids, update_chat, upsert_chat, upsert_chat_messages, + ChatMessageTable, ChatTable, ChatTableChangeset, +}; +use flowy_error::{FlowyError, FlowyResult}; +use futures_util::{stream, StreamExt, TryStreamExt}; +use lib_infra::async_trait::async_trait; +use lib_infra::util::timestamp; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use tracing::trace; +use uuid::Uuid; + +pub struct LocalChatServiceImpl { + pub logged_user: Arc, + pub local_ai: Arc, +} + +impl LocalChatServiceImpl { + fn get_message_content(&self, message_id: i64) -> FlowyResult { + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + let content = select_message_content(db, message_id)?.ok_or_else(|| { + FlowyError::record_not_found().with_context(format!("Message not found: {}", message_id)) + })?; + Ok(content) + } + + async fn upsert_message(&self, chat_id: &Uuid, message: ChatMessage) -> Result<(), FlowyError> { + let uid = self.logged_user.user_id()?; + let conn = self.logged_user.get_sqlite_db(uid)?; + let row = ChatMessageTable::from_message(chat_id.to_string(), message, true); + upsert_chat_messages(conn, &[row])?; + Ok(()) + } +} + +#[async_trait] +impl ChatCloudService for LocalChatServiceImpl { + async fn create_chat( + &self, + _uid: &i64, + _workspace_id: &Uuid, + chat_id: &Uuid, + rag_ids: Vec, + _name: &str, + metadata: Value, + ) -> Result<(), FlowyError> { + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + let row = ChatTable::new(chat_id.to_string(), metadata, rag_ids, true); + upsert_chat(db, &row)?; + Ok(()) + } + + async fn create_question( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + message_type: ChatMessageType, + ) -> Result { + let message = match message_type { + ChatMessageType::System => ChatMessage::new_system(timestamp(), message.to_string()), + ChatMessageType::User => ChatMessage::new_human(timestamp(), message.to_string(), None), + }; + + self.upsert_message(chat_id, message.clone()).await?; + Ok(message) + } + + async fn create_answer( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + message: &str, + question_id: i64, + metadata: Option, + ) -> Result { + let mut message = ChatMessage::new_ai(timestamp(), message.to_string(), Some(question_id)); + if let Some(metadata) = metadata { + message.metadata = metadata; + } + self.upsert_message(chat_id, message.clone()).await?; + Ok(message) + } + + async fn stream_answer( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + format: ResponseFormat, + _ai_model: Option, + ) -> Result { + if self.local_ai.is_running() { + let content = self.get_message_content(message_id)?; + match self + .local_ai + .stream_question( + &chat_id.to_string(), + &content, + Some(json!(format)), + json!({}), + ) + .await + { + Ok(stream) => Ok(QuestionStream::new(stream).boxed()), + Err(err) => Ok( + stream::once(async { Err(FlowyError::local_ai_unavailable().with_context(err)) }).boxed(), + ), + } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) + } + } + + async fn get_answer( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + question_id: i64, + ) -> Result { + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + + match select_answer_where_match_reply_message_id(db, &chat_id.to_string(), question_id)? { + None => Err(FlowyError::record_not_found()), + Some(message) => Ok(chat_message_from_row(message)), + } + } + + async fn get_chat_messages( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + offset: MessageCursor, + limit: u64, + ) -> Result { + let chat_id = chat_id.to_string(); + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + let result = select_chat_messages(db, &chat_id, limit, offset)?; + + let messages = result + .messages + .into_iter() + .map(chat_message_from_row) + .collect(); + + Ok(RepeatedChatMessage { + messages, + has_more: result.has_more, + total: result.total_count, + }) + } + + async fn get_question_from_answer_id( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + answer_message_id: i64, + ) -> Result { + let chat_id = chat_id.to_string(); + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + let row = select_answer_where_match_reply_message_id(db, &chat_id, answer_message_id)? + .map(chat_message_from_row) + .ok_or_else(FlowyError::record_not_found)?; + Ok(row) + } + + async fn get_related_message( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + message_id: i64, + _ai_model: Option, + ) -> Result { + if self.local_ai.is_running() { + let questions = self + .local_ai + .get_related_question(&chat_id.to_string()) + .await + .map_err(|err| FlowyError::local_ai().with_context(err))?; + trace!("LocalAI related questions: {:?}", questions); + + let items = questions + .into_iter() + .map(|content| RelatedQuestion { + content, + metadata: None, + }) + .collect::>(); + + Ok(RepeatedRelatedQuestion { message_id, items }) + } else { + Ok(RepeatedRelatedQuestion { + message_id, + items: vec![], + }) + } + } + + async fn stream_complete( + &self, + _workspace_id: &Uuid, + params: CompleteTextParams, + _ai_model: Option, + ) -> Result { + if self.local_ai.is_running() { + match self + .local_ai + .complete_text_v2( + ¶ms.text, + params.completion_type.unwrap() as u8, + Some(json!(params.format)), + Some(json!(params.metadata)), + ) + .await + { + Ok(stream) => Ok( + CompletionStream::new( + stream.map_err(|err| AppResponseError::new(AppErrorCode::Internal, err.to_string())), + ) + .map_err(FlowyError::from) + .boxed(), + ), + Err(_) => Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()), + } + } else if self.local_ai.is_enabled() { + Err(FlowyError::local_ai_not_ready()) + } else { + Err(FlowyError::local_ai_disabled()) + } + } + + async fn embed_file( + &self, + _workspace_id: &Uuid, + file_path: &Path, + chat_id: &Uuid, + metadata: Option>, + ) -> Result<(), FlowyError> { + if self.local_ai.is_running() { + self + .local_ai + .embed_file(&chat_id.to_string(), file_path.to_path_buf(), metadata) + .await + .map_err(|err| FlowyError::local_ai().with_context(err))?; + Ok(()) + } else { + Err(FlowyError::local_ai_not_ready()) + } + } + + async fn get_chat_settings( + &self, + _workspace_id: &Uuid, + chat_id: &Uuid, + ) -> Result { + let chat_id = chat_id.to_string(); + let uid = self.logged_user.user_id()?; + let db = self.logged_user.get_sqlite_db(uid)?; + let row = read_chat(db, &chat_id)?; + let rag_ids = deserialize_rag_ids(&row.rag_ids); + let metadata = deserialize_chat_metadata::(&row.metadata); + let setting = ChatSettings { + name: row.name, + rag_ids, + metadata, + }; + + Ok(setting) + } + + async fn update_chat_settings( + &self, + _workspace_id: &Uuid, + id: &Uuid, + s: UpdateChatParams, + ) -> Result<(), FlowyError> { + let uid = self.logged_user.user_id()?; + let mut db = self.logged_user.get_sqlite_db(uid)?; + let changeset = ChatTableChangeset { + chat_id: id.to_string(), + name: s.name, + metadata: s.metadata.map(|s| serialize_chat_metadata(&s)), + rag_ids: s.rag_ids.map(|s| serialize_rag_ids(&s)), + is_sync: None, + }; + + update_chat(&mut db, changeset)?; + Ok(()) + } + + async fn get_available_models(&self, _workspace_id: &Uuid) -> Result { + Ok(ModelList { models: vec![] }) + } + + async fn get_workspace_default_model(&self, _workspace_id: &Uuid) -> Result { + Ok(DEFAULT_AI_MODEL_NAME.to_string()) + } +} + +fn chat_message_from_row(row: ChatMessageTable) -> ChatMessage { + let created_at = Utc + .timestamp_opt(row.created_at, 0) + .single() + .unwrap_or_else(Utc::now); + + let author_id = row.author_id.parse::().unwrap_or_default(); + let author_type = match row.author_type { + 1 => ChatAuthorType::Human, + 2 => ChatAuthorType::System, + 3 => ChatAuthorType::AI, + _ => ChatAuthorType::Unknown, + }; + + let metadata = row + .metadata + .map(|s| deserialize_chat_metadata::(&s)) + .unwrap_or_else(|| json!({})); + + ChatMessage { + author: ChatAuthor { + author_id, + author_type, + meta: None, + }, + message_id: row.message_id, + content: row.content, + created_at, + metadata, + reply_message_id: row.reply_message_id, + } +} diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs index 71fc99b465..ad1184a09a 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs @@ -1,88 +1,65 @@ -use anyhow::Error; -use collab::preclude::Collab; -use collab_entity::define::{DATABASE, DATABASE_ROW_DATA, WORKSPACE_DATABASES}; +#![allow(unused_variables)] + +use crate::af_cloud::define::LoggedUser; +use crate::local_server::util::default_encode_collab_for_collab_type; +use collab::entity::EncodedCollab; use collab_entity::CollabType; -use yrs::{Any, MapPrelim}; +use flowy_database_pub::cloud::{DatabaseCloudService, DatabaseSnapshot, EncodeCollabByOid}; +use flowy_error::{ErrorCode, FlowyError}; +use lib_infra::async_trait::async_trait; +use std::sync::Arc; +use uuid::Uuid; -use flowy_database_pub::cloud::{ - CollabDocStateByOid, DatabaseCloudService, DatabaseSnapshot, SummaryRowContent, -}; -use lib_infra::future::FutureResult; - -pub(crate) struct LocalServerDatabaseCloudServiceImpl(); +pub(crate) struct LocalServerDatabaseCloudServiceImpl { + pub logged_user: Arc, +} +#[async_trait] impl DatabaseCloudService for LocalServerDatabaseCloudServiceImpl { - fn get_database_object_doc_state( + async fn get_database_encode_collab( &self, - object_id: &str, + object_id: &Uuid, collab_type: CollabType, - _workspace_id: &str, - ) -> FutureResult>, Error> { + _workspace_id: &Uuid, // underscore to silence “unused” warning + ) -> Result, FlowyError> { + let uid = self.logged_user.user_id()?; let object_id = object_id.to_string(); - // create the minimal required data for the given collab type - FutureResult::new(async move { - let data = match collab_type { - CollabType::Database => { - let collab = Collab::new(1, object_id, collab_type, vec![], false); - collab.with_origin_transact_mut(|txn| { - collab.insert_map_with_txn(txn, DATABASE); - }); - collab - .encode_collab_v1(|_| Ok::<(), Error>(()))? - .doc_state - .to_vec() - }, - CollabType::WorkspaceDatabase => { - let collab = Collab::new(1, object_id, collab_type, vec![], false); - collab.with_origin_transact_mut(|txn| { - collab.create_array_with_txn::>(txn, WORKSPACE_DATABASES, vec![]); - }); - collab - .encode_collab_v1(|_| Ok::<(), Error>(()))? - .doc_state - .to_vec() - }, - CollabType::DatabaseRow => { - let collab = Collab::new(1, object_id, collab_type, vec![], false); - collab.with_origin_transact_mut(|txn| { - collab.insert_map_with_txn(txn, DATABASE_ROW_DATA); - }); - collab - .encode_collab_v1(|_| Ok::<(), Error>(()))? - .doc_state - .to_vec() - }, - _ => vec![], - }; - - Ok(Some(data)) - }) + default_encode_collab_for_collab_type(uid, &object_id, collab_type) + .await + .map(Some) + .or_else(|err| { + if matches!(err.code, ErrorCode::NotSupportYet) { + Ok(None) + } else { + Err(err) + } + }) } - fn batch_get_database_object_doc_state( + async fn create_database_encode_collab( &self, - _object_ids: Vec, - _object_ty: CollabType, - _workspace_id: &str, - ) -> FutureResult { - FutureResult::new(async move { Ok(CollabDocStateByOid::default()) }) + object_id: &Uuid, + collab_type: CollabType, + workspace_id: &Uuid, + encoded_collab: EncodedCollab, + ) -> Result<(), FlowyError> { + Ok(()) } - fn get_database_collab_object_snapshots( + async fn batch_get_database_encode_collab( &self, - _object_id: &str, - _limit: usize, - ) -> FutureResult, Error> { - FutureResult::new(async move { Ok(vec![]) }) + object_ids: Vec, + object_ty: CollabType, + workspace_id: &Uuid, + ) -> Result { + Ok(EncodeCollabByOid::default()) } - fn summary_database_row( + async fn get_database_collab_object_snapshots( &self, - _workspace_id: &str, - _object_id: &str, - _summary_row: SummaryRowContent, - ) -> FutureResult { - // TODO(lucas): local ai - FutureResult::new(async move { Ok("".to_string()) }) + object_id: &Uuid, + limit: usize, + ) -> Result, FlowyError> { + Ok(vec![]) } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs index bc712d03d0..c553026274 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/document.rs @@ -1,40 +1,50 @@ -use anyhow::Error; - +#![allow(unused_variables)] +use collab::entity::EncodedCollab; use flowy_document_pub::cloud::*; use flowy_error::{ErrorCode, FlowyError}; -use lib_infra::future::FutureResult; +use lib_infra::async_trait::async_trait; +use uuid::Uuid; pub(crate) struct LocalServerDocumentCloudServiceImpl(); +#[async_trait] impl DocumentCloudService for LocalServerDocumentCloudServiceImpl { - fn get_document_doc_state( + async fn get_document_doc_state( &self, - document_id: &str, - _workspace_id: &str, - ) -> FutureResult, FlowyError> { + document_id: &Uuid, + workspace_id: &Uuid, + ) -> Result, FlowyError> { let document_id = document_id.to_string(); - FutureResult::new(async move { - Err(FlowyError::new( - ErrorCode::RecordNotFound, - format!("Document {} not found", document_id), - )) - }) + + Err(FlowyError::new( + ErrorCode::RecordNotFound, + format!("Document {} not found", document_id), + )) } - fn get_document_snapshots( + async fn get_document_snapshots( &self, - _document_id: &str, - _limit: usize, - _workspace_id: &str, - ) -> FutureResult, Error> { - FutureResult::new(async move { Ok(vec![]) }) + document_id: &Uuid, + limit: usize, + workspace_id: &str, + ) -> Result, FlowyError> { + Ok(vec![]) } - fn get_document_data( + async fn get_document_data( &self, - _document_id: &str, - _workspace_id: &str, - ) -> FutureResult, Error> { - FutureResult::new(async move { Ok(None) }) + document_id: &Uuid, + workspace_id: &Uuid, + ) -> Result, FlowyError> { + Ok(None) + } + + async fn create_document_collab( + &self, + workspace_id: &Uuid, + document_id: &Uuid, + encoded_collab: EncodedCollab, + ) -> Result<(), FlowyError> { + Ok(()) } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs index ea0ee027b9..79b1d4be12 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs @@ -1,80 +1,156 @@ -use std::sync::Arc; +#![allow(unused_variables)] -use anyhow::{anyhow, Error}; +use crate::af_cloud::define::LoggedUser; +use crate::local_server::util::default_encode_collab_for_collab_type; +use client_api::entity::workspace_dto::PublishInfoView; +use client_api::entity::PublishInfo; +use collab::core::origin::CollabOrigin; +use collab::preclude::Collab; use collab_entity::CollabType; - +use collab_plugins::local_storage::kv::doc::CollabKVAction; +use collab_plugins::local_storage::kv::KVTransactionDB; +use flowy_error::FlowyError; use flowy_folder_pub::cloud::{ - gen_workspace_id, FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, Workspace, - WorkspaceRecord, + FolderCloudService, FolderCollabParams, FolderSnapshot, FullSyncCollabParams, }; -use lib_infra::future::FutureResult; - -use crate::local_server::LocalServerDB; +use flowy_folder_pub::entities::PublishPayload; +use lib_infra::async_trait::async_trait; +use std::sync::Arc; +use uuid::Uuid; pub(crate) struct LocalServerFolderCloudServiceImpl { #[allow(dead_code)] - pub db: Arc, + pub logged_user: Arc, } +#[async_trait] impl FolderCloudService for LocalServerFolderCloudServiceImpl { - fn create_workspace(&self, uid: i64, name: &str) -> FutureResult { - let name = name.to_string(); - FutureResult::new(async move { - Ok(Workspace::new( - gen_workspace_id().to_string(), - name.to_string(), - uid, - )) - }) - } - - fn open_workspace(&self, _workspace_id: &str) -> FutureResult<(), Error> { - FutureResult::new(async { Ok(()) }) - } - - fn get_all_workspace(&self) -> FutureResult, Error> { - FutureResult::new(async { Ok(vec![]) }) - } - - fn get_folder_data( - &self, - _workspace_id: &str, - _uid: &i64, - ) -> FutureResult, Error> { - FutureResult::new(async move { Ok(None) }) - } - - fn get_folder_snapshots( + async fn get_folder_snapshots( &self, _workspace_id: &str, _limit: usize, - ) -> FutureResult, Error> { - FutureResult::new(async move { Ok(vec![]) }) + ) -> Result, FlowyError> { + Ok(vec![]) } - fn get_folder_doc_state( + async fn get_folder_doc_state( &self, - _workspace_id: &str, - _uid: i64, - _collab_type: CollabType, - _object_id: &str, - ) -> FutureResult, Error> { - FutureResult::new(async { - Err(anyhow!( - "Local server doesn't support get collab doc state from remote" - )) - }) + workspace_id: &Uuid, + uid: i64, + collab_type: CollabType, + object_id: &Uuid, + ) -> Result, FlowyError> { + let object_id = object_id.to_string(); + let workspace_id = workspace_id.to_string(); + let collab_db = self.logged_user.get_collab_db(uid)?.upgrade().unwrap(); + let read_txn = collab_db.read_txn(); + let is_exist = read_txn.is_exist(uid, &workspace_id.to_string(), &object_id.to_string()); + if is_exist { + // load doc + let collab = Collab::new_with_origin(CollabOrigin::Empty, &object_id, vec![], false); + read_txn.load_doc(uid, &workspace_id, &object_id, collab.doc())?; + let data = collab.encode_collab_v1(|c| { + collab_type + .validate_require_data(c) + .map_err(|err| FlowyError::invalid_data().with_context(err))?; + Ok::<_, FlowyError>(()) + })?; + Ok(data.doc_state.to_vec()) + } else { + let data = default_encode_collab_for_collab_type(uid, &object_id, collab_type).await?; + drop(read_txn); + Ok(data.doc_state.to_vec()) + } } - fn batch_create_folder_collab_objects( + async fn full_sync_collab_object( &self, - _workspace_id: &str, - _objects: Vec, - ) -> FutureResult<(), Error> { - FutureResult::new(async { Err(anyhow!("Local server doesn't support create collab")) }) + workspace_id: &Uuid, + params: FullSyncCollabParams, + ) -> Result<(), FlowyError> { + Ok(()) + } + + async fn batch_create_folder_collab_objects( + &self, + workspace_id: &Uuid, + objects: Vec, + ) -> Result<(), FlowyError> { + Ok(()) } fn service_name(&self) -> String { "Local".to_string() } + + async fn publish_view( + &self, + workspace_id: &Uuid, + payload: Vec, + ) -> Result<(), FlowyError> { + Err(FlowyError::local_version_not_support()) + } + + async fn unpublish_views( + &self, + workspace_id: &Uuid, + view_ids: Vec, + ) -> Result<(), FlowyError> { + Ok(()) + } + + async fn get_publish_info(&self, view_id: &Uuid) -> Result { + Err(FlowyError::local_version_not_support()) + } + + async fn set_publish_name( + &self, + workspace_id: &Uuid, + view_id: Uuid, + new_name: String, + ) -> Result<(), FlowyError> { + Err(FlowyError::local_version_not_support()) + } + + async fn set_publish_namespace( + &self, + workspace_id: &Uuid, + new_namespace: String, + ) -> Result<(), FlowyError> { + Err(FlowyError::local_version_not_support()) + } + + async fn list_published_views( + &self, + workspace_id: &Uuid, + ) -> Result, FlowyError> { + Err(FlowyError::local_version_not_support()) + } + + async fn get_default_published_view_info( + &self, + workspace_id: &Uuid, + ) -> Result { + Err(FlowyError::local_version_not_support()) + } + + async fn set_default_published_view( + &self, + workspace_id: &Uuid, + view_id: uuid::Uuid, + ) -> Result<(), FlowyError> { + Err(FlowyError::local_version_not_support()) + } + + async fn remove_default_published_view(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { + Err(FlowyError::local_version_not_support()) + } + + async fn get_publish_namespace(&self, workspace_id: &Uuid) -> Result { + Err(FlowyError::local_version_not_support()) + } + + async fn import_zip(&self, _file_path: &str) -> Result<(), FlowyError> { + Err(FlowyError::local_version_not_support()) + } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs index 0280cfbefb..f63265e734 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs @@ -1,8 +1,10 @@ +pub(crate) use chat::*; pub(crate) use database::*; pub(crate) use document::*; pub(crate) use folder::*; pub(crate) use user::*; +mod chat; mod database; mod document; mod folder; diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs index d5fa1524b6..5a4caeb050 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/user.rs @@ -1,237 +1,328 @@ -use std::sync::Arc; +#![allow(unused_variables)] +use crate::af_cloud::define::LoggedUser; +use crate::local_server::uid::UserIDGenerator; +use client_api::entity::GotrueTokenResponse; +use collab::core::origin::CollabOrigin; +use collab::preclude::Collab; use collab_entity::CollabObject; -use lazy_static::lazy_static; -use parking_lot::Mutex; -use uuid::Uuid; - +use collab_user::core::UserAwareness; +use flowy_ai_pub::cloud::billing_dto::WorkspaceUsageAndLimit; +use flowy_ai_pub::cloud::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; use flowy_error::FlowyError; use flowy_user_pub::cloud::{UserCloudService, UserCollabParams}; use flowy_user_pub::entities::*; +use flowy_user_pub::sql::{ + select_all_user_workspace, select_user_profile, select_user_workspace, select_workspace_member, + select_workspace_setting, update_user_profile, update_workspace_setting, upsert_workspace_member, + upsert_workspace_setting, UserTableChangeset, WorkspaceMemberTable, WorkspaceSettingsChangeset, + WorkspaceSettingsTable, +}; use flowy_user_pub::DEFAULT_USER_NAME; +use lazy_static::lazy_static; +use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; -use lib_infra::future::FutureResult; use lib_infra::util::timestamp; - -use crate::local_server::uid::UserIDGenerator; -use crate::local_server::LocalServerDB; +use std::sync::Arc; +use tokio::sync::Mutex; +use uuid::Uuid; lazy_static! { static ref ID_GEN: Mutex = Mutex::new(UserIDGenerator::new(1)); } -pub(crate) struct LocalServerUserAuthServiceImpl { - #[allow(dead_code)] - pub db: Arc, +pub(crate) struct LocalServerUserServiceImpl { + pub logged_user: Arc, } -impl UserCloudService for LocalServerUserAuthServiceImpl { - fn sign_up(&self, params: BoxAny) -> FutureResult { - FutureResult::new(async move { - let params = params.unbox_or_error::()?; - let uid = ID_GEN.lock().next_id(); - let workspace_id = uuid::Uuid::new_v4().to_string(); - let user_workspace = UserWorkspace::new(&workspace_id, uid); - let user_name = if params.name.is_empty() { - DEFAULT_USER_NAME() - } else { - params.name.clone() - }; - Ok(AuthResponse { - user_id: uid, - user_uuid: Uuid::new_v4(), - name: user_name, - latest_workspace: user_workspace.clone(), - user_workspaces: vec![user_workspace], - is_new_user: true, - email: Some(params.email), - token: None, - encryption_type: EncryptionType::NoEncryption, - updated_at: timestamp(), - metadata: None, - }) +#[async_trait] +impl UserCloudService for LocalServerUserServiceImpl { + async fn sign_up(&self, params: BoxAny) -> Result { + let params = params.unbox_or_error::()?; + let uid = ID_GEN.lock().await.next_id(); + let workspace_id = Uuid::new_v4().to_string(); + let user_workspace = UserWorkspace::new_local(workspace_id, "My Workspace"); + let user_name = if params.name.is_empty() { + DEFAULT_USER_NAME() + } else { + params.name.clone() + }; + Ok(AuthResponse { + user_id: uid, + user_uuid: Uuid::new_v4(), + name: user_name, + latest_workspace: user_workspace.clone(), + user_workspaces: vec![user_workspace], + is_new_user: true, + // Anon user doesn't have email + email: None, + token: None, + encryption_type: EncryptionType::NoEncryption, + updated_at: timestamp(), + metadata: None, }) } - fn sign_in(&self, params: BoxAny) -> FutureResult { - let db = self.db.clone(); - FutureResult::new(async move { - let params: SignInParams = params.unbox_or_error::()?; - let uid = ID_GEN.lock().next_id(); + async fn sign_in(&self, params: BoxAny) -> Result { + let params: SignInParams = params.unbox_or_error::()?; + let uid = ID_GEN.lock().await.next_id(); - let user_workspace = db - .get_user_workspace(uid)? - .unwrap_or_else(make_user_workspace); - Ok(AuthResponse { - user_id: uid, - user_uuid: Uuid::new_v4(), - name: params.name, - latest_workspace: user_workspace.clone(), - user_workspaces: vec![user_workspace], - is_new_user: false, - email: Some(params.email), - token: None, - encryption_type: EncryptionType::NoEncryption, - updated_at: timestamp(), - metadata: None, - }) + let workspace_id = Uuid::new_v4(); + let user_workspace = UserWorkspace::new_local(workspace_id.to_string(), "My Workspace"); + Ok(AuthResponse { + user_id: uid, + user_uuid: Uuid::new_v4(), + name: params.name, + latest_workspace: user_workspace.clone(), + user_workspaces: vec![user_workspace], + is_new_user: false, + email: Some(params.email), + token: None, + encryption_type: EncryptionType::NoEncryption, + updated_at: timestamp(), + metadata: None, }) } - fn sign_out(&self, _token: Option) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) + async fn sign_out(&self, _token: Option) -> Result<(), FlowyError> { + Ok(()) } - fn generate_sign_in_url_with_email(&self, _email: &str) -> FutureResult { - FutureResult::new(async { - Err( - FlowyError::local_version_not_support() - .with_context("Not support generate sign in url with email"), - ) - }) + async fn generate_sign_in_url_with_email(&self, _email: &str) -> Result { + Err( + FlowyError::local_version_not_support() + .with_context("Not support generate sign in url with email"), + ) } - fn create_user(&self, _email: &str, _password: &str) -> FutureResult<(), FlowyError> { - FutureResult::new(async { - Err(FlowyError::local_version_not_support().with_context("Not support create user")) - }) + async fn create_user(&self, _email: &str, _password: &str) -> Result<(), FlowyError> { + Err(FlowyError::local_version_not_support().with_context("Not support create user")) } - fn sign_in_with_password( + async fn sign_in_with_password( &self, _email: &str, _password: &str, - ) -> FutureResult { - FutureResult::new(async { - Err(FlowyError::local_version_not_support().with_context("Not support")) - }) + ) -> Result { + Err(FlowyError::local_version_not_support().with_context("Not support")) } - fn sign_in_with_magic_link( + async fn sign_in_with_magic_link( &self, _email: &str, _redirect_to: &str, - ) -> FutureResult<(), FlowyError> { - FutureResult::new(async { - Err(FlowyError::local_version_not_support().with_context("Not support")) - }) + ) -> Result<(), FlowyError> { + Err(FlowyError::local_version_not_support().with_context("Not support")) } - fn generate_oauth_url_with_provider(&self, _provider: &str) -> FutureResult { - FutureResult::new(async { - Err(FlowyError::internal().with_context("Can't oauth url when using offline mode")) - }) - } - - fn update_user( + async fn sign_in_with_passcode( &self, - _credential: UserCredentials, - _params: UpdateUserProfileParams, - ) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) + _email: &str, + _passcode: &str, + ) -> Result { + Err(FlowyError::local_version_not_support().with_context("Not support")) } - fn get_user_profile(&self, credential: UserCredentials) -> FutureResult { - let result = match credential.uid { - None => Err(FlowyError::record_not_found()), - Some(uid) => { - self.db.get_user_profile(uid).map(|mut profile| { - // We don't want to expose the email in the local server - profile.email = "".to_string(); - profile - }) - }, - }; - FutureResult::new(async { result }) + async fn generate_oauth_url_with_provider(&self, _provider: &str) -> Result { + Err(FlowyError::internal().with_context("Can't oauth url when using offline mode")) } - fn open_workspace(&self, _workspace_id: &str) -> FutureResult { - FutureResult::new(async { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support open workspace"), - ) - }) + async fn update_user(&self, params: UpdateUserProfileParams) -> Result<(), FlowyError> { + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + let changeset = UserTableChangeset::new(params); + update_user_profile(&mut conn, changeset)?; + Ok(()) } - fn get_all_workspace(&self, _uid: i64) -> FutureResult, FlowyError> { - FutureResult::new(async { Ok(vec![]) }) - } - - fn get_user_awareness_doc_state( + async fn get_user_profile( &self, - _uid: i64, - _workspace_id: &str, - _object_id: &str, - ) -> FutureResult, FlowyError> { - // must return record not found error - FutureResult::new(async { Err(FlowyError::record_not_found()) }) + uid: i64, + workspace_id: &str, + ) -> Result { + let mut conn = self.logged_user.get_sqlite_db(uid)?; + let profile = select_user_profile(uid, workspace_id, &mut conn)?; + Ok(profile) } - fn reset_workspace(&self, _collab_object: CollabObject) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) + async fn open_workspace(&self, workspace_id: &Uuid) -> Result { + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + + let workspace = select_user_workspace(&workspace_id.to_string(), &mut conn)?; + Ok(UserWorkspace::from(workspace)) } - fn create_collab_object( + async fn get_all_workspace(&self, uid: i64) -> Result, FlowyError> { + let mut conn = self.logged_user.get_sqlite_db(uid)?; + let workspaces = select_all_user_workspace(uid, &mut conn)?; + Ok(workspaces) + } + + async fn create_workspace(&self, workspace_name: &str) -> Result { + let workspace_id = Uuid::new_v4(); + Ok(UserWorkspace::new_local( + workspace_id.to_string(), + workspace_name, + )) + } + + async fn patch_workspace( + &self, + workspace_id: &Uuid, + new_workspace_name: Option, + new_workspace_icon: Option, + ) -> Result<(), FlowyError> { + Ok(()) + } + + async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { + Ok(()) + } + + async fn get_user_awareness_doc_state( + &self, + uid: i64, + workspace_id: &Uuid, + object_id: &Uuid, + ) -> Result, FlowyError> { + let collab = Collab::new_with_origin( + CollabOrigin::Empty, + object_id.to_string().as_str(), + vec![], + false, + ); + let awareness = UserAwareness::create(collab, None)?; + let encode_collab = awareness.encode_collab_v1(|_collab| Ok::<_, FlowyError>(()))?; + Ok(encode_collab.doc_state.to_vec()) + } + + async fn create_collab_object( &self, _collab_object: &CollabObject, _data: Vec, - ) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) + ) -> Result<(), FlowyError> { + Ok(()) } - fn batch_create_collab_object( + async fn batch_create_collab_object( &self, - _workspace_id: &str, - _objects: Vec, - ) -> FutureResult<(), FlowyError> { - FutureResult::new(async { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support batch create collab object"), - ) - }) + workspace_id: &Uuid, + objects: Vec, + ) -> Result<(), FlowyError> { + Ok(()) } - fn create_workspace(&self, _workspace_name: &str) -> FutureResult { - FutureResult::new(async { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) - }) - } - - fn delete_workspace(&self, _workspace_id: &str) -> FutureResult<(), FlowyError> { - FutureResult::new(async { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) - }) - } - - fn patch_workspace( + async fn get_workspace_member( &self, - _workspace_id: &str, - _new_workspace_name: Option<&str>, - _new_workspace_icon: Option<&str>, - ) -> FutureResult<(), FlowyError> { - FutureResult::new(async { - Err( - FlowyError::local_version_not_support() - .with_context("local server doesn't support multiple workspaces"), - ) + workspace_id: &Uuid, + uid: i64, + ) -> Result { + // For local server, only current user is the member + let conn = self.logged_user.get_sqlite_db(uid)?; + let result = select_workspace_member(conn, &workspace_id.to_string(), uid); + + match result { + Ok(row) => Ok(WorkspaceMember::from(row)), + Err(err) => { + if err.is_record_not_found() { + let mut conn = self.logged_user.get_sqlite_db(uid)?; + let profile = select_user_profile(uid, &workspace_id.to_string(), &mut conn)?; + let row = WorkspaceMemberTable { + email: profile.email.to_string(), + role: 0, + name: profile.name.to_string(), + avatar_url: Some(profile.icon_url), + uid, + workspace_id: workspace_id.to_string(), + updated_at: Default::default(), + }; + + let member = WorkspaceMember::from(row.clone()); + upsert_workspace_member(&mut conn, row)?; + Ok(member) + } else { + Err(err) + } + }, + } + } + + async fn get_workspace_usage( + &self, + workspace_id: &Uuid, + ) -> Result { + Ok(WorkspaceUsageAndLimit { + member_count: 1, + member_count_limit: 1, + storage_bytes: i64::MAX, + storage_bytes_limit: i64::MAX, + storage_bytes_unlimited: true, + single_upload_limit: i64::MAX, + single_upload_unlimited: true, + ai_responses_count: i64::MAX, + ai_responses_count_limit: i64::MAX, + ai_image_responses_count: i64::MAX, + ai_image_responses_count_limit: 0, + local_ai: true, + ai_responses_unlimited: true, + }) + } + + async fn get_workspace_setting( + &self, + workspace_id: &Uuid, + ) -> Result { + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + + // By default, workspace setting is existed in local server + let result = select_workspace_setting(&mut conn, &workspace_id.to_string()); + match result { + Ok(row) => Ok(AFWorkspaceSettings { + disable_search_indexing: row.disable_search_indexing, + ai_model: row.ai_model, + }), + Err(err) => { + if err.is_record_not_found() { + let row = WorkspaceSettingsTable { + id: workspace_id.to_string(), + disable_search_indexing: false, + ai_model: "".to_string(), + }; + let setting = AFWorkspaceSettings { + disable_search_indexing: row.disable_search_indexing, + ai_model: row.ai_model.clone(), + }; + upsert_workspace_setting(&mut conn, row)?; + Ok(setting) + } else { + Err(err) + } + }, + } + } + + async fn update_workspace_setting( + &self, + workspace_id: &Uuid, + workspace_settings: AFWorkspaceSettingsChange, + ) -> Result { + let uid = self.logged_user.user_id()?; + let mut conn = self.logged_user.get_sqlite_db(uid)?; + + let changeset = WorkspaceSettingsChangeset { + id: workspace_id.to_string(), + disable_search_indexing: workspace_settings.disable_search_indexing, + ai_model: workspace_settings.ai_model, + }; + + update_workspace_setting(&mut conn, changeset)?; + let row = select_workspace_setting(&mut conn, &workspace_id.to_string())?; + + Ok(AFWorkspaceSettings { + disable_search_indexing: row.disable_search_indexing, + ai_model: row.ai_model, }) } } - -fn make_user_workspace() -> UserWorkspace { - UserWorkspace { - id: uuid::Uuid::new_v4().to_string(), - name: "My Workspace".to_string(), - created_at: Default::default(), - database_indexer_id: uuid::Uuid::new_v4().to_string(), - icon: "".to_string(), - } -} diff --git a/frontend/rust-lib/flowy-server/src/local_server/mod.rs b/frontend/rust-lib/flowy-server/src/local_server/mod.rs index 6e67356fd9..2b9fe07250 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/mod.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/mod.rs @@ -3,3 +3,4 @@ pub use server::*; pub mod impls; mod server; pub(crate) mod uid; +mod util; diff --git a/frontend/rust-lib/flowy-server/src/local_server/server.rs b/frontend/rust-lib/flowy-server/src/local_server/server.rs index 12c2f47916..8829ded3fc 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/server.rs @@ -1,45 +1,38 @@ -use flowy_storage::ObjectStorageService; -use std::sync::Arc; - -use parking_lot::RwLock; -use tokio::sync::mpsc; - -use flowy_database_pub::cloud::DatabaseCloudService; -use flowy_document_pub::cloud::DocumentCloudService; -use flowy_error::FlowyError; -use flowy_folder_pub::cloud::FolderCloudService; -// use flowy_user::services::database::{ -// get_user_profile, get_user_workspace, open_collab_db, open_user_db, -// }; -use flowy_user_pub::cloud::UserCloudService; -use flowy_user_pub::entities::*; - +use crate::af_cloud::define::LoggedUser; use crate::local_server::impls::{ - LocalServerDatabaseCloudServiceImpl, LocalServerDocumentCloudServiceImpl, - LocalServerFolderCloudServiceImpl, LocalServerUserAuthServiceImpl, + LocalChatServiceImpl, LocalServerDatabaseCloudServiceImpl, LocalServerDocumentCloudServiceImpl, + LocalServerFolderCloudServiceImpl, LocalServerUserServiceImpl, }; use crate::AppFlowyServer; - -pub trait LocalServerDB: Send + Sync + 'static { - fn get_user_profile(&self, uid: i64) -> Result; - fn get_user_workspace(&self, uid: i64) -> Result, FlowyError>; -} +use anyhow::Error; +use flowy_ai::local_ai::controller::LocalAIController; +use flowy_ai_pub::cloud::ChatCloudService; +use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; +use flowy_document_pub::cloud::DocumentCloudService; +use flowy_folder_pub::cloud::FolderCloudService; +use flowy_search_pub::cloud::SearchCloudService; +use flowy_storage_pub::cloud::StorageCloudService; +use flowy_user_pub::cloud::UserCloudService; +use std::sync::Arc; +use tokio::sync::mpsc; pub struct LocalServer { - local_db: Arc, - stop_tx: RwLock>>, + logged_user: Arc, + local_ai: Arc, + stop_tx: Option>, } impl LocalServer { - pub fn new(local_db: Arc) -> Self { + pub fn new(logged_user: Arc, local_ai: Arc) -> Self { Self { - local_db, + logged_user, + local_ai, stop_tx: Default::default(), } } pub async fn stop(&self) { - let sender = self.stop_tx.read().clone(); + let sender = self.stop_tx.clone(); if let Some(stop_tx) = sender { let _ = stop_tx.send(()).await; } @@ -47,27 +40,48 @@ impl LocalServer { } impl AppFlowyServer for LocalServer { + fn set_token(&self, _token: &str) -> Result<(), Error> { + Ok(()) + } + fn user_service(&self) -> Arc { - Arc::new(LocalServerUserAuthServiceImpl { - db: self.local_db.clone(), + Arc::new(LocalServerUserServiceImpl { + logged_user: self.logged_user.clone(), }) } fn folder_service(&self) -> Arc { Arc::new(LocalServerFolderCloudServiceImpl { - db: self.local_db.clone(), + logged_user: self.logged_user.clone(), }) } fn database_service(&self) -> Arc { - Arc::new(LocalServerDatabaseCloudServiceImpl()) + Arc::new(LocalServerDatabaseCloudServiceImpl { + logged_user: self.logged_user.clone(), + }) + } + + fn database_ai_service(&self) -> Option> { + None } fn document_service(&self) -> Arc { Arc::new(LocalServerDocumentCloudServiceImpl()) } - fn file_storage(&self) -> Option> { + fn chat_service(&self) -> Arc { + Arc::new(LocalChatServiceImpl { + logged_user: self.logged_user.clone(), + local_ai: self.local_ai.clone(), + }) + } + + fn search_service(&self) -> Option> { + None + } + + fn file_storage(&self) -> Option> { None } } diff --git a/frontend/rust-lib/flowy-server/src/local_server/util.rs b/frontend/rust-lib/flowy-server/src/local_server/util.rs new file mode 100644 index 0000000000..378ccee6a2 --- /dev/null +++ b/frontend/rust-lib/flowy-server/src/local_server/util.rs @@ -0,0 +1,47 @@ +use collab::core::origin::CollabOrigin; +use collab::entity::EncodedCollab; +use collab::preclude::Collab; +use collab_database::database::default_database_data; +use collab_database::workspace_database::default_workspace_database_data; +use collab_document::document_data::default_document_collab_data; +use collab_entity::CollabType; +use collab_user::core::default_user_awareness_data; +use flowy_error::{FlowyError, FlowyResult}; + +pub async fn default_encode_collab_for_collab_type( + _uid: i64, + object_id: &str, + collab_type: CollabType, +) -> FlowyResult { + match collab_type { + CollabType::Document => { + let encode_collab = default_document_collab_data(object_id)?; + Ok(encode_collab) + }, + CollabType::Database => default_database_data(object_id).await.map_err(Into::into), + CollabType::WorkspaceDatabase => Ok(default_workspace_database_data(object_id)), + CollabType::Folder => { + // let collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false); + // let workspace = Workspace::new(object_id.to_string(), "".to_string(), uid); + // let folder_data = FolderData::new(workspace); + // let folder = Folder::create(uid, collab, None, folder_data); + // let data = folder.encode_collab_v1(|c| { + // collab_type + // .validate_require_data(c) + // .map_err(|err| FlowyError::invalid_data().with_context(err))?; + // Ok::<_, FlowyError>(()) + // })?; + // Ok(data) + Err(FlowyError::not_support().with_context("Can not create default folder")) + }, + CollabType::DatabaseRow => { + Err(FlowyError::not_support().with_context("Can not create default database row")) + }, + CollabType::UserAwareness => Ok(default_user_awareness_data(object_id)), + CollabType::Unknown => { + let collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false); + let data = collab.encode_collab_v1(|_| Ok::<_, FlowyError>(()))?; + Ok(data) + }, + } +} diff --git a/frontend/rust-lib/flowy-server/src/response.rs b/frontend/rust-lib/flowy-server/src/response.rs index 3e58fae69b..dff2faf961 100644 --- a/frontend/rust-lib/flowy-server/src/response.rs +++ b/frontend/rust-lib/flowy-server/src/response.rs @@ -1,13 +1,9 @@ use std::fmt; -use anyhow::Error; use bytes::Bytes; -use reqwest::{Response, StatusCode}; use serde::{Deserialize, Serialize}; -use serde_json::Value; -use flowy_error::{ErrorCode, FlowyError}; -use lib_infra::future::{to_fut, Fut}; +use flowy_error::ErrorCode; #[derive(Debug, Serialize, Deserialize)] pub struct HttpResponse { @@ -34,116 +30,3 @@ impl fmt::Display for HttpError { write!(f, "{:?}: {}", self.code, self.msg) } } - -/// Trait `ExtendedResponse` provides an extension method to handle and transform the response data. -/// -/// This trait introduces a single method: -/// -/// - `get_value`: It extracts the value from the response, and returns it as an instance of a type `T`. -/// This method will return an error if the status code of the response signifies a failure (not success). -/// Otherwise, it attempts to parse the response body into an instance of type `T`, which must implement -/// `serde::de::DeserializeOwned`, `Send`, `Sync`, and have a static lifetime ('static). -pub trait ExtendedResponse { - /// Returns the value of the response as a Future of `Result`. - /// - /// If the status code of the response is not a success, returns an `Error`. - /// Otherwise, attempts to parse the response into an instance of type `T`. - /// - /// # Type Parameters - /// - /// * `T`: The type of the value to be returned. Must implement `serde::de::DeserializeOwned`, - /// `Send`, `Sync`, and have a static lifetime ('static). - fn get_value(self) -> Fut> - where - T: serde::de::DeserializeOwned + Send + Sync + 'static; - - fn get_bytes(self) -> Fut>; - - fn get_json(self) -> Fut>; - - fn success(self) -> Fut>; - - fn success_with_body(self) -> Fut>; -} - -impl ExtendedResponse for Response { - fn get_value(self) -> Fut> - where - T: serde::de::DeserializeOwned + Send + Sync + 'static, - { - to_fut(async move { - let status_code = self.status(); - if !status_code.is_success() { - return Err(parse_response_as_error(self).await.into()); - } - let bytes = self.bytes().await?; - let value = serde_json::from_slice(&bytes).map_err(|e| { - FlowyError::new( - ErrorCode::Serde, - format!( - "failed to parse json: {}, body: {}", - e, - String::from_utf8_lossy(&bytes) - ), - ) - })?; - Ok(value) - }) - } - - fn get_bytes(self) -> Fut> { - to_fut(async move { - let status_code = self.status(); - if !status_code.is_success() { - return Err(parse_response_as_error(self).await.into()); - } - let bytes = self.bytes().await?; - Ok(bytes) - }) - } - - fn get_json(self) -> Fut> { - to_fut(async move { - if !self.status().is_success() { - return Err(parse_response_as_error(self).await.into()); - } - let bytes = self.bytes().await?; - let value = serde_json::from_slice::(&bytes)?; - Ok(value) - }) - } - - fn success(self) -> Fut> { - to_fut(async move { - if !self.status().is_success() { - return Err(parse_response_as_error(self).await.into()); - } - Ok(()) - }) - } - - fn success_with_body(self) -> Fut> { - to_fut(async move { - if !self.status().is_success() { - return Err(parse_response_as_error(self).await.into()); - } - Ok(self.text().await?) - }) - } -} - -async fn parse_response_as_error(response: Response) -> FlowyError { - let status_code = response.status(); - let msg = response.text().await.unwrap_or_default(); - if status_code == StatusCode::CONFLICT { - return FlowyError::new(ErrorCode::Conflict, msg); - } - - FlowyError::new( - ErrorCode::HttpError, - format!( - "expected status code 2XX, but got {}, body: {}", - status_code, msg - ), - ) -} diff --git a/frontend/rust-lib/flowy-server/src/server.rs b/frontend/rust-lib/flowy-server/src/server.rs index 679771d162..2702b4f104 100644 --- a/frontend/rust-lib/flowy-server/src/server.rs +++ b/frontend/rust-lib/flowy-server/src/server.rs @@ -1,19 +1,21 @@ use client_api::ws::ConnectState; use client_api::ws::WSConnectStateReceiver; use client_api::ws::WebSocketChannel; -use flowy_storage::ObjectStorageService; +use flowy_search_pub::cloud::SearchCloudService; use std::sync::Arc; use anyhow::Error; +use arc_swap::ArcSwapOption; use client_api::collab_sync::ServerCollabMessage; -use parking_lot::RwLock; +use flowy_ai_pub::cloud::ChatCloudService; use tokio_stream::wrappers::WatchStream; #[cfg(feature = "enable_supabase")] use {collab_entity::CollabObject, collab_plugins::cloud_storage::RemoteCollabStorage}; -use flowy_database_pub::cloud::DatabaseCloudService; +use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; use flowy_document_pub::cloud::DocumentCloudService; use flowy_folder_pub::cloud::FolderCloudService; +use flowy_storage_pub::cloud::StorageCloudService; use flowy_user_pub::cloud::UserCloudService; use flowy_user_pub::entities::UserTokenState; @@ -39,7 +41,9 @@ where /// and functionalities in AppFlowy. The methods provided ensure efficient, asynchronous operations /// for managing and accessing user data, folders, collaborative objects, and documents in a cloud environment. pub trait AppFlowyServer: Send + Sync + 'static { - fn set_token(&self, _token: &str) -> Result<(), Error> { + fn set_token(&self, _token: &str) -> Result<(), Error>; + + fn set_ai_model(&self, _ai_model: &str) -> Result<(), Error> { Ok(()) } @@ -86,6 +90,8 @@ pub trait AppFlowyServer: Send + Sync + 'static { /// An `Arc` wrapping the `DatabaseCloudService` interface. fn database_service(&self) -> Arc; + fn database_ai_service(&self) -> Option>; + /// Facilitates cloud-based document management. This service offers operations for updating documents, /// fetching snapshots, and accessing primary document data in an asynchronous manner. /// @@ -94,6 +100,12 @@ pub trait AppFlowyServer: Send + Sync + 'static { /// An `Arc` wrapping the `DocumentCloudService` interface. fn document_service(&self) -> Arc; + fn chat_service(&self) -> Arc; + + /// Bridge for the Cloud AI Search features + /// + fn search_service(&self) -> Option>; + /// Manages collaborative objects within a remote storage system. This includes operations such as /// checking storage status, retrieving updates and snapshots, and dispatching updates. The service /// also provides subscription capabilities for real-time updates. @@ -133,27 +145,27 @@ pub trait AppFlowyServer: Send + Sync + 'static { Ok(None) } - fn file_storage(&self) -> Option>; + fn file_storage(&self) -> Option>; } pub struct EncryptionImpl { - secret: RwLock>, + secret: ArcSwapOption, } impl EncryptionImpl { pub fn new(secret: Option) -> Self { Self { - secret: RwLock::new(secret), + secret: ArcSwapOption::from(secret.map(Arc::new)), } } } impl AppFlowyEncryption for EncryptionImpl { fn get_secret(&self) -> Option { - self.secret.read().clone() + self.secret.load().as_ref().map(|s| s.to_string()) } fn set_secret(&self, secret: String) { - *self.secret.write() = Some(secret); + self.secret.store(Some(secret.into())); } } diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/collab_storage.rs b/frontend/rust-lib/flowy-server/src/supabase/api/collab_storage.rs index 388ff184da..bb5705cbc8 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/collab_storage.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/collab_storage.rs @@ -2,6 +2,7 @@ use std::str::FromStr; use std::sync::{Arc, Weak}; use anyhow::Error; +use arc_swap::ArcSwapOption; use chrono::{DateTime, Utc}; use client_api::collab_sync::MsgId; use collab::core::collab::DataSource; @@ -10,7 +11,6 @@ use collab_entity::CollabObject; use collab_plugins::cloud_storage::{ RemoteCollabSnapshot, RemoteCollabState, RemoteCollabStorage, RemoteUpdateReceiver, }; -use parking_lot::Mutex; use tokio::task::spawn_blocking; use lib_infra::async_trait::async_trait; @@ -28,7 +28,7 @@ use crate::AppFlowyEncryption; pub struct SupabaseCollabStorageImpl { server: T, - rx: Mutex>, + rx: ArcSwapOption, encryption: Weak, } @@ -40,7 +40,7 @@ impl SupabaseCollabStorageImpl { ) -> Self { Self { server, - rx: Mutex::new(rx), + rx: ArcSwapOption::new(rx.map(Arc::new)), encryption, } } @@ -186,11 +186,14 @@ where } fn subscribe_remote_updates(&self, _object: &CollabObject) -> Option { - let rx = self.rx.lock().take(); - if rx.is_none() { - tracing::warn!("The receiver is already taken"); + let rx = self.rx.swap(None); + match rx { + Some(rx) => Arc::into_inner(rx), + None => { + tracing::warn!("The receiver is already taken"); + None + }, } - rx } } @@ -278,12 +281,8 @@ fn merge_updates(update_items: Vec, new_update: Vec) -> Result>(); - let new_update = merge_updates_v1(&updates)?; + let new_update = merge_updates_v1(updates)?; Ok(MergeResult { merged_keys, new_update, diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/database.rs b/frontend/rust-lib/flowy-server/src/supabase/api/database.rs index 9e7dd7765d..af1732b500 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/database.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/database.rs @@ -2,10 +2,9 @@ use anyhow::Error; use collab_entity::CollabType; use tokio::sync::oneshot::channel; -use flowy_database_pub::cloud::{ - CollabDocStateByOid, DatabaseCloudService, DatabaseSnapshot, SummaryRowContent, -}; -use lib_dispatch::prelude::af_spawn; +use flowy_database_pub::cloud::{CollabDocStateByOid, DatabaseCloudService, DatabaseSnapshot}; + +use lib_infra::async_trait::async_trait; use lib_infra::future::FutureResult; use crate::supabase::api::request::{ @@ -23,6 +22,7 @@ impl SupabaseDatabaseServiceImpl { } } +#[async_trait] impl DatabaseCloudService for SupabaseDatabaseServiceImpl where T: SupabaseServerService, @@ -36,7 +36,7 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let object_id = object_id.to_string(); let (tx, rx) = channel(); - af_spawn(async move { + tokio::spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; @@ -59,7 +59,7 @@ where ) -> FutureResult { let try_get_postgrest = self.server.try_get_weak_postgrest(); let (tx, rx) = channel(); - af_spawn(async move { + tokio::spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; @@ -96,13 +96,4 @@ where Ok(snapshots) }) } - - fn summary_database_row( - &self, - _workspace_id: &str, - _object_id: &str, - _summary_row: SummaryRowContent, - ) -> FutureResult { - FutureResult::new(async move { Ok("".to_string()) }) - } } diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/document.rs b/frontend/rust-lib/flowy-server/src/supabase/api/document.rs index a0e5087938..b3f45a7670 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/document.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/document.rs @@ -8,7 +8,7 @@ use tokio::sync::oneshot::channel; use flowy_document_pub::cloud::{DocumentCloudService, DocumentSnapshot}; use flowy_error::FlowyError; -use lib_dispatch::prelude::af_spawn; + use lib_infra::future::FutureResult; use crate::supabase::api::request::{get_snapshots_from_server, FetchObjectUpdateAction}; @@ -37,7 +37,7 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let document_id = document_id.to_string(); let (tx, rx) = channel(); - af_spawn(async move { + tokio::spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; @@ -87,14 +87,14 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let document_id = document_id.to_string(); let (tx, rx) = channel(); - af_spawn(async move { + tokio::spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; let action = FetchObjectUpdateAction::new(document_id.clone(), CollabType::Document, postgrest); let doc_state = action.run_with_fix_interval(5, 10).await?; - let document = Document::from_doc_state( + let document = Document::open_with_options( CollabOrigin::Empty, DataSource::DocStateV1(doc_state), &document_id, diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/folder.rs b/frontend/rust-lib/flowy-server/src/supabase/api/folder.rs index ca0957c375..253d11e0d8 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/folder.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/folder.rs @@ -13,7 +13,8 @@ use flowy_folder_pub::cloud::{ gen_workspace_id, Folder, FolderCloudService, FolderCollabParams, FolderData, FolderSnapshot, Workspace, WorkspaceRecord, }; -use lib_dispatch::prelude::af_spawn; +use flowy_folder_pub::entities::PublishPayload; + use lib_infra::future::FutureResult; use lib_infra::util::timestamp; @@ -95,11 +96,8 @@ where if items.is_empty() { return Ok(None); } - let updates = items - .iter() - .map(|update| update.value.as_ref()) - .collect::>(); - let doc_state = merge_updates_v1(&updates) + let updates = items.into_iter().map(|update| update.value); + let doc_state = merge_updates_v1(updates) .map_err(|err| anyhow::anyhow!("merge updates failed: {:?}", err))?; let folder = Folder::from_collab_doc_state( @@ -146,7 +144,7 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let object_id = object_id.to_string(); let (tx, rx) = channel(); - af_spawn(async move { + tokio::spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; @@ -174,6 +172,46 @@ where fn service_name(&self) -> String { "Supabase".to_string() } + + fn publish_view( + &self, + _workspace_id: &str, + _payload: Vec, + ) -> FutureResult<(), Error> { + FutureResult::new(async { Err(anyhow!("supabase server doesn't support publish view")) }) + } + + fn unpublish_views( + &self, + _workspace_id: &str, + _view_ids: Vec, + ) -> FutureResult<(), Error> { + FutureResult::new(async { Err(anyhow!("supabase server doesn't support unpublish views")) }) + } + + fn get_publish_info(&self, _view_id: &str) -> FutureResult { + FutureResult::new(async { Err(anyhow!("supabase server doesn't support publish info")) }) + } + + fn set_publish_namespace( + &self, + _workspace_id: &str, + _new_namespace: &str, + ) -> FutureResult<(), FlowyError> { + FutureResult::new(async { + Err(anyhow!( + "supabase server doesn't support set publish namespace" + )) + }) + } + + fn get_publish_namespace(&self, _workspace_id: &str) -> FutureResult { + FutureResult::new(async { + Err(anyhow!( + "supabase server doesn't support get publish namespace" + )) + }) + } } fn workspace_from_json_value(value: Value) -> Result { diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/postgres_server.rs b/frontend/rust-lib/flowy-server/src/supabase/api/postgres_server.rs index 8db0910896..9ab3379486 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/postgres_server.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/postgres_server.rs @@ -2,7 +2,7 @@ use std::ops::Deref; use std::sync::{Arc, Weak}; use anyhow::Error; -use parking_lot::RwLock; +use arc_swap::ArcSwapOption; use postgrest::Postgrest; use flowy_error::{ErrorCode, FlowyError}; @@ -77,11 +77,11 @@ where } #[derive(Clone)] -pub struct SupabaseServerServiceImpl(pub Arc>>>); +pub struct SupabaseServerServiceImpl(pub Arc>); impl SupabaseServerServiceImpl { pub fn new(postgrest: Arc) -> Self { - Self(Arc::new(RwLock::new(Some(postgrest)))) + Self(Arc::new(ArcSwapOption::from(Some(postgrest)))) } } @@ -89,7 +89,7 @@ impl SupabaseServerService for SupabaseServerServiceImpl { fn get_postgrest(&self) -> Option> { self .0 - .read() + .load() .as_ref() .map(|server| server.postgrest.clone()) } @@ -97,7 +97,7 @@ impl SupabaseServerService for SupabaseServerServiceImpl { fn try_get_postgrest(&self) -> Result, Error> { self .0 - .read() + .load() .as_ref() .map(|server| server.postgrest.clone()) .ok_or_else(|| { diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/request.rs b/frontend/rust-lib/flowy-server/src/supabase/api/request.rs index 326529e06e..fa13c9711b 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/request.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/request.rs @@ -77,11 +77,8 @@ impl Action for FetchObjectUpdateAction { return Ok(vec![]); } - let updates = items - .iter() - .map(|update| update.value.as_ref()) - .collect::>(); - let doc_state = merge_updates_v1(&updates) + let updates = items.into_iter().map(|update| update.value); + let doc_state = merge_updates_v1(updates) .map_err(|err| anyhow::anyhow!("merge updates failed: {:?}", err))?; Ok(doc_state) }, @@ -286,12 +283,9 @@ pub async fn batch_get_updates_from_server( if items.is_empty() { updates_by_oid.insert(oid.to_string(), DataSource::Disk); } else { - let updates = items - .iter() - .map(|update| update.value.as_ref()) - .collect::>(); + let updates = items.into_iter().map(|update| update.value); - let doc_state = merge_updates_v1(&updates) + let doc_state = merge_updates_v1(updates) .map_err(|err| anyhow::anyhow!("merge updates failed: {:?}", err))?; updates_by_oid.insert(oid.to_string(), DataSource::DocStateV1(doc_state)); } diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/user.rs b/frontend/rust-lib/flowy-server/src/supabase/api/user.rs index a691ab36a9..3712307af4 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/user.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/user.rs @@ -6,11 +6,10 @@ use std::sync::{Arc, Weak}; use std::time::Duration; use anyhow::Error; -use collab::core::collab::MutexCollab; +use arc_swap::ArcSwapOption; use collab::core::origin::CollabOrigin; use collab::preclude::Collab; use collab_entity::{CollabObject, CollabType}; -use parking_lot::RwLock; use serde_json::Value; use tokio::sync::oneshot::channel; use tokio_retry::strategy::FixedInterval; @@ -22,7 +21,7 @@ use flowy_folder_pub::cloud::{Folder, FolderData, Workspace}; use flowy_user_pub::cloud::*; use flowy_user_pub::entities::*; use flowy_user_pub::DEFAULT_USER_NAME; -use lib_dispatch::prelude::af_spawn; + use lib_infra::box_any::BoxAny; use lib_infra::future::FutureResult; @@ -44,7 +43,7 @@ use crate::AppFlowyEncryption; pub struct SupabaseUserServiceImpl { server: T, realtime_event_handlers: Vec>, - user_update_rx: RwLock>, + user_update_rx: ArcSwapOption, } impl SupabaseUserServiceImpl { @@ -56,7 +55,7 @@ impl SupabaseUserServiceImpl { Self { server, realtime_event_handlers, - user_update_rx: RwLock::new(user_update_rx), + user_update_rx: ArcSwapOption::from(user_update_rx.map(Arc::new)), } } } @@ -200,6 +199,16 @@ where }) } + fn sign_in_with_passcode( + &self, + _email: &str, + _passcode: &str, + ) -> FutureResult { + FutureResult::new(async { + Err(FlowyError::not_support().with_context("Can't sign in with passcode when using supabase")) + }) + } + fn generate_oauth_url_with_provider(&self, _provider: &str) -> FutureResult { FutureResult::new(async { Err(FlowyError::internal().with_context("Can't generate oauth url when using supabase")) @@ -242,6 +251,7 @@ where authenticator: Authenticator::Supabase, encryption_type: EncryptionType::from_sign(&response.encryption_sign), updated_at: response.updated_at.timestamp(), + ai_model: "".to_string(), }), } }) @@ -271,7 +281,7 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let (tx, rx) = channel(); let object_id = object_id.to_string(); - af_spawn(async move { + tokio::spawn(async move { tx.send( async move { let postgrest = try_get_postgrest?; @@ -305,14 +315,15 @@ where } fn subscribe_user_update(&self) -> Option { - self.user_update_rx.write().take() + let rx = self.user_update_rx.swap(None)?; + Arc::into_inner(rx) } fn reset_workspace(&self, collab_object: CollabObject) -> FutureResult<(), FlowyError> { let try_get_postgrest = self.server.try_get_weak_postgrest(); let (tx, rx) = channel(); let init_update = default_workspace_doc_state(&collab_object); - af_spawn(async move { + tokio::spawn(async move { tx.send( async move { let postgrest = try_get_postgrest? @@ -350,7 +361,7 @@ where let try_get_postgrest = self.server.try_get_weak_postgrest(); let cloned_collab_object = collab_object.clone(); let (tx, rx) = channel(); - af_spawn(async move { + tokio::spawn(async move { tx.send( async move { CreateCollabAction::new(cloned_collab_object, try_get_postgrest?, data) @@ -646,7 +657,7 @@ impl RealtimeEventHandler for RealtimeCollabUpdateHandler { serde_json::from_value::(event.new.clone()) { if let Some(sender_by_oid) = self.sender_by_oid.upgrade() { - if let Some(sender) = sender_by_oid.read().get(collab_update.oid.as_str()) { + if let Some(sender) = sender_by_oid.get(collab_update.oid.as_str()) { tracing::trace!( "current device: {}, event device: {}", self.device_id, @@ -687,15 +698,16 @@ impl RealtimeEventHandler for RealtimeCollabUpdateHandler { fn default_workspace_doc_state(collab_object: &CollabObject) -> Vec { let workspace_id = collab_object.object_id.clone(); - let collab = Arc::new(MutexCollab::new(Collab::new_with_origin( - CollabOrigin::Empty, - &collab_object.object_id, - vec![], - false, - ))); + let collab = + Collab::new_with_origin(CollabOrigin::Empty, &collab_object.object_id, vec![], false); let workspace = Workspace::new(workspace_id, "My workspace".to_string(), collab_object.uid); - let folder = Folder::create(collab_object.uid, collab, None, FolderData::new(workspace)); - folder.encode_collab_v1().unwrap().doc_state.to_vec() + let folder = Folder::open_with( + collab_object.uid, + collab, + None, + Some(FolderData::new(workspace)), + ); + folder.encode_collab().unwrap().doc_state.to_vec() } fn oauth_params_from_box_any(any: BoxAny) -> Result { diff --git a/frontend/rust-lib/flowy-server/src/supabase/file_storage/builder.rs b/frontend/rust-lib/flowy-server/src/supabase/file_storage/builder.rs index 89dfc39971..6db01f1cc6 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/file_storage/builder.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/file_storage/builder.rs @@ -1,7 +1,5 @@ -use std::borrow::Cow; - use anyhow::Error; -use flowy_storage::StorageObject; +use flowy_storage_pub::cloud::StorageObject; use hyper::header::CONTENT_TYPE; use reqwest::header::IntoHeaderName; use reqwest::multipart::{Form, Part}; @@ -9,12 +7,14 @@ use reqwest::{ header::{HeaderMap, HeaderValue}, Client, Method, RequestBuilder, }; +use std::borrow::Cow; use tokio::fs::File; use tokio::io::AsyncReadExt; use url::Url; use crate::supabase::file_storage::{DeleteObjects, FileOptions, NewBucket, RequestBody}; +#[allow(dead_code)] pub struct StorageRequestBuilder { pub url: Url, headers: HeaderMap, @@ -23,6 +23,7 @@ pub struct StorageRequestBuilder { body: RequestBody, } +#[allow(dead_code)] impl StorageRequestBuilder { pub fn new(url: Url, headers: HeaderMap, client: Client) -> Self { Self { diff --git a/frontend/rust-lib/flowy-server/src/supabase/file_storage/core.rs b/frontend/rust-lib/flowy-server/src/supabase/file_storage/core.rs index b00bf8f9a6..f150084c2d 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/file_storage/core.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/file_storage/core.rs @@ -1,141 +1,13 @@ -use std::sync::{Arc, Weak}; +#![allow(clippy::all)] +#![allow(unknown_lints)] +#![allow(unused_attributes)] +use std::sync::Weak; use anyhow::{anyhow, Error}; -use reqwest::{ - header::{HeaderMap, HeaderValue}, - Client, -}; use url::Url; -use flowy_encrypt::{decrypt_data, encrypt_data}; -use flowy_error::FlowyError; -use flowy_server_pub::supabase_config::SupabaseConfiguration; -use flowy_storage::{FileStoragePlan, ObjectStorageService}; -use lib_infra::future::FutureResult; - -use crate::supabase::file_storage::builder::StorageRequestBuilder; use crate::AppFlowyEncryption; - -pub struct SupabaseFileStorage { - url: Url, - headers: HeaderMap, - client: Client, - #[allow(dead_code)] - encryption: ObjectEncryption, - #[allow(dead_code)] - storage_plan: Arc, -} - -impl ObjectStorageService for SupabaseFileStorage { - fn get_object_url( - &self, - _object_id: flowy_storage::ObjectIdentity, - ) -> FutureResult { - todo!() - } - - fn put_object( - &self, - _url: String, - _object_value: flowy_storage::ObjectValue, - ) -> FutureResult<(), FlowyError> { - todo!() - } - - fn delete_object(&self, _url: String) -> FutureResult<(), FlowyError> { - todo!() - } - - fn get_object(&self, _url: String) -> FutureResult { - todo!() - } - - // fn create_object(&self, object: StorageObject) -> FutureResult { - // let mut storage = self.storage(); - // let storage_plan = Arc::downgrade(&self.storage_plan); - - // FutureResult::new(async move { - // let plan = storage_plan - // .upgrade() - // .ok_or(anyhow!("Storage plan is not available"))?; - // plan.check_upload_object(&object).await?; - - // storage = storage.upload_object("data", object); - // let url = storage.url.to_string(); - // storage.build().await?.send().await?.success().await?; - // Ok(url) - // }) - // } - - // fn delete_object_by_url(&self, object_url: String) -> FutureResult<(), FlowyError> { - // let storage = self.storage(); - - // FutureResult::new(async move { - // let url = Url::parse(&object_url)?; - // let location = get_object_location_from(&url)?; - // storage - // .delete_object(location.bucket_id, location.file_name) - // .build() - // .await? - // .send() - // .await? - // .success() - // .await?; - // Ok(()) - // }) - // } - - // fn get_object_by_url(&self, object_url: String) -> FutureResult { - // let storage = self.storage(); - // FutureResult::new(async move { - // let url = Url::parse(&object_url)?; - // let location = get_object_location_from(&url)?; - // let bytes = storage - // .get_object(location.bucket_id, location.file_name) - // .build() - // .await? - // .send() - // .await? - // .get_bytes() - // .await?; - // Ok(bytes) - // }) - // } -} - -impl SupabaseFileStorage { - pub fn new( - config: &SupabaseConfiguration, - encryption: Weak, - storage_plan: Arc, - ) -> Result { - let mut headers = HeaderMap::new(); - let url = format!("{}/storage/v1", config.url); - let auth = format!("Bearer {}", config.anon_key); - - headers.insert( - "Authorization", - HeaderValue::from_str(&auth).expect("Authorization is invalid"), - ); - headers.insert( - "apikey", - HeaderValue::from_str(&config.anon_key).expect("apikey value is invalid"), - ); - - let encryption = ObjectEncryption::new(encryption); - Ok(Self { - url: Url::parse(&url)?, - headers, - client: Client::new(), - encryption, - storage_plan, - }) - } - - pub fn storage(&self) -> StorageRequestBuilder { - StorageRequestBuilder::new(self.url.clone(), self.headers.clone(), self.client.clone()) - } -} +use flowy_encrypt::{decrypt_data, encrypt_data}; #[allow(dead_code)] struct ObjectEncryption { @@ -143,6 +15,7 @@ struct ObjectEncryption { } impl ObjectEncryption { + #[allow(dead_code)] fn new(encryption: Weak) -> Self { Self { encryption } } diff --git a/frontend/rust-lib/flowy-server/src/supabase/file_storage/entities.rs b/frontend/rust-lib/flowy-server/src/supabase/file_storage/entities.rs index 768ae27b3e..ec1ffa6c7b 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/file_storage/entities.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/file_storage/entities.rs @@ -1,8 +1,7 @@ use bytes::Bytes; +use flowy_storage_pub::cloud::ObjectValueSupabase; use serde::{Deserialize, Serialize}; -use flowy_storage::ObjectValueSupabase; - use crate::supabase; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/frontend/rust-lib/flowy-server/src/supabase/file_storage/mod.rs b/frontend/rust-lib/flowy-server/src/supabase/file_storage/mod.rs index ebfc707dcb..5da091c22c 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/file_storage/mod.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/file_storage/mod.rs @@ -1,7 +1,5 @@ pub use entities::*; -pub use plan::*; mod builder; pub mod core; mod entities; -pub mod plan; diff --git a/frontend/rust-lib/flowy-server/src/supabase/file_storage/plan.rs b/frontend/rust-lib/flowy-server/src/supabase/file_storage/plan.rs index 63cf2cb6e0..39a33c8853 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/file_storage/plan.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/file_storage/plan.rs @@ -1,9 +1,7 @@ use std::sync::Weak; -use parking_lot::RwLock; - use flowy_error::FlowyError; -use flowy_storage::{FileStoragePlan, StorageObject}; +use flowy_storage_pub::cloud::{FileStoragePlan, StorageObject}; use lib_infra::future::FutureResult; use crate::supabase::api::RESTfulPostgresServer; @@ -11,16 +9,13 @@ use crate::supabase::api::RESTfulPostgresServer; #[derive(Default)] pub struct FileStoragePlanImpl { #[allow(dead_code)] - uid: Weak>>, + uid: Weak>, #[allow(dead_code)] postgrest: Option>, } impl FileStoragePlanImpl { - pub fn new( - uid: Weak>>, - postgrest: Option>, - ) -> Self { + pub fn new(uid: Weak>, postgrest: Option>) -> Self { Self { uid, postgrest } } } diff --git a/frontend/rust-lib/flowy-server/src/supabase/server.rs b/frontend/rust-lib/flowy-server/src/supabase/server.rs index a9846966a8..00dd46e8ba 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/server.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/server.rs @@ -1,15 +1,16 @@ -use flowy_storage::ObjectStorageService; -use std::collections::HashMap; +use arc_swap::ArcSwapOption; +use flowy_search_pub::cloud::SearchCloudService; use std::sync::{Arc, Weak}; use collab_entity::CollabObject; use collab_plugins::cloud_storage::{RemoteCollabStorage, RemoteUpdateSender}; -use parking_lot::RwLock; +use dashmap::DashMap; -use flowy_database_pub::cloud::DatabaseCloudService; +use flowy_database_pub::cloud::{DatabaseAIService, DatabaseCloudService}; use flowy_document_pub::cloud::DocumentCloudService; use flowy_folder_pub::cloud::FolderCloudService; use flowy_server_pub::supabase_config::SupabaseConfiguration; +use flowy_storage_pub::cloud::StorageCloudService; use flowy_user_pub::cloud::UserCloudService; use crate::supabase::api::{ @@ -17,8 +18,7 @@ use crate::supabase::api::{ SupabaseCollabStorageImpl, SupabaseDatabaseServiceImpl, SupabaseDocumentServiceImpl, SupabaseFolderServiceImpl, SupabaseServerServiceImpl, SupabaseUserServiceImpl, }; -use crate::supabase::file_storage::core::SupabaseFileStorage; -use crate::supabase::file_storage::FileStoragePlanImpl; + use crate::{AppFlowyEncryption, AppFlowyServer}; /// https://www.pgbouncer.org/features.html @@ -55,23 +55,23 @@ impl PgPoolMode { } } -pub type CollabUpdateSenderByOid = RwLock>; +pub type CollabUpdateSenderByOid = DashMap; /// Supabase server is used to provide the implementation of the [AppFlowyServer] trait. /// It contains the configuration of the supabase server and the postgres server. pub struct SupabaseServer { #[allow(dead_code)] config: SupabaseConfiguration, device_id: String, - uid: Arc>>, + #[allow(dead_code)] + uid: Arc>, collab_update_sender: Arc, - restful_postgres: Arc>>>, - file_storage: Arc>>>, + restful_postgres: Arc>, encryption: Weak, } impl SupabaseServer { pub fn new( - uid: Arc>>, + uid: Arc>, config: SupabaseConfiguration, enable_sync: bool, device_id: String, @@ -86,23 +86,11 @@ impl SupabaseServer { } else { None }; - let file_storage = if enable_sync { - let plan = FileStoragePlanImpl::new( - Arc::downgrade(&uid), - restful_postgres.as_ref().map(Arc::downgrade), - ); - Some(Arc::new( - SupabaseFileStorage::new(&config, encryption.clone(), Arc::new(plan)).unwrap(), - )) - } else { - None - }; Self { config, device_id, collab_update_sender, - restful_postgres: Arc::new(RwLock::new(restful_postgres)), - file_storage: Arc::new(RwLock::new(file_storage)), + restful_postgres: Arc::new(ArcSwapOption::from(restful_postgres)), encryption, uid, } @@ -114,23 +102,18 @@ impl AppFlowyServer for SupabaseServer { tracing::info!("{} supabase sync: {}", uid, enable); if enable { - if self.restful_postgres.read().is_none() { - let postgres = RESTfulPostgresServer::new(self.config.clone(), self.encryption.clone()); - *self.restful_postgres.write() = Some(Arc::new(postgres)); - } - - if self.file_storage.read().is_none() { - let plan = FileStoragePlanImpl::new( - Arc::downgrade(&self.uid), - self.restful_postgres.read().as_ref().map(Arc::downgrade), - ); - let file_storage = - SupabaseFileStorage::new(&self.config, self.encryption.clone(), Arc::new(plan)).unwrap(); - *self.file_storage.write() = Some(Arc::new(file_storage)); - } + self.restful_postgres.rcu(|old| match old { + Some(existing) => Some(existing.clone()), + None => { + let postgres = Arc::new(RESTfulPostgresServer::new( + self.config.clone(), + self.encryption.clone(), + )); + Some(postgres) + }, + }); } else { - *self.restful_postgres.write() = None; - *self.file_storage.write() = None; + self.restful_postgres.store(None); } } @@ -167,6 +150,10 @@ impl AppFlowyServer for SupabaseServer { ))) } + fn database_ai_service(&self) -> Option> { + None + } + fn document_service(&self) -> Arc { Arc::new(SupabaseDocumentServiceImpl::new(SupabaseServerServiceImpl( self.restful_postgres.clone(), @@ -177,7 +164,6 @@ impl AppFlowyServer for SupabaseServer { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); self .collab_update_sender - .write() .insert(collab_object.object_id.clone(), tx); Some(Arc::new(SupabaseCollabStorageImpl::new( @@ -187,11 +173,11 @@ impl AppFlowyServer for SupabaseServer { ))) } - fn file_storage(&self) -> Option> { - self - .file_storage - .read() - .clone() - .map(|s| s as Arc) + fn file_storage(&self) -> Option> { + None + } + + fn search_service(&self) -> Option> { + None } } diff --git a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs index 224e10cd95..7e38f423cc 100644 --- a/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/af_cloud_test/util.rs @@ -1,16 +1,18 @@ use client_api::ClientConfiguration; +use collab_plugins::CollabKVDB; +use flowy_error::{FlowyError, FlowyResult}; +use semver::Version; use std::collections::HashMap; -use std::sync::Arc; - -use flowy_error::FlowyResult; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; use uuid::Uuid; -use flowy_server::af_cloud::define::ServerUser; -use flowy_server::af_cloud::AppFlowyCloudServer; -use flowy_server::supabase::define::{USER_DEVICE_ID, USER_SIGN_IN_URL}; -use flowy_server_pub::af_cloud_config::AFCloudConfiguration; - use crate::setup_log; +use flowy_server::af_cloud::define::LoggedUser; +use flowy_server::af_cloud::AppFlowyCloudServer; +use flowy_server_pub::af_cloud_config::AFCloudConfiguration; +use flowy_sqlite::DBConnection; +use lib_infra::async_trait::async_trait; /// To run the test, create a .env.ci file in the 'flowy-server' directory and set the following environment variables: /// @@ -28,18 +30,42 @@ pub fn get_af_cloud_config() -> Option { pub fn af_cloud_server(config: AFCloudConfiguration) -> Arc { let fake_device_id = uuid::Uuid::new_v4().to_string(); + let logged_user = Arc::new(FakeServerUserImpl) as Arc; Arc::new(AppFlowyCloudServer::new( config, true, fake_device_id, - "0.5.1", - Arc::new(FakeServerUserImpl), + Version::new(0, 5, 8), + // do nothing, just for test + Arc::downgrade(&logged_user), )) } struct FakeServerUserImpl; -impl ServerUser for FakeServerUserImpl { - fn workspace_id(&self) -> FlowyResult { + +#[async_trait] +impl LoggedUser for FakeServerUserImpl { + fn workspace_id(&self) -> FlowyResult { + todo!() + } + + fn user_id(&self) -> FlowyResult { + todo!() + } + + async fn is_local_mode(&self) -> FlowyResult { + Ok(true) + } + + fn get_sqlite_db(&self, _uid: i64) -> Result { + todo!() + } + + fn get_collab_db(&self, _uid: i64) -> Result, FlowyError> { + todo!() + } + + fn application_root_dir(&self) -> Result { todo!() } } @@ -81,10 +107,10 @@ pub async fn af_cloud_sign_up_param( ) -> HashMap { let mut params = HashMap::new(); params.insert( - USER_SIGN_IN_URL.to_string(), + "sign_in_url".to_string(), generate_sign_in_url(email, config).await, ); - params.insert(USER_DEVICE_ID.to_string(), Uuid::new_v4().to_string()); + params.insert("device_id".to_string(), Uuid::new_v4().to_string()); params } diff --git a/frontend/rust-lib/flowy-server/tests/main.rs b/frontend/rust-lib/flowy-server/tests/main.rs index cd827b9b9d..fb12ed51b3 100644 --- a/frontend/rust-lib/flowy-server/tests/main.rs +++ b/frontend/rust-lib/flowy-server/tests/main.rs @@ -5,7 +5,7 @@ use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::EnvFilter; mod af_cloud_test; -mod supabase_test; +// mod supabase_test; pub fn setup_log() { static START: Once = Once::new(); diff --git a/frontend/rust-lib/flowy-server/tests/supabase_test/folder_test.rs b/frontend/rust-lib/flowy-server/tests/supabase_test/folder_test.rs index 5d7922a1e4..a9037caa6c 100644 --- a/frontend/rust-lib/flowy-server/tests/supabase_test/folder_test.rs +++ b/frontend/rust-lib/flowy-server/tests/supabase_test/folder_test.rs @@ -194,7 +194,7 @@ async fn supabase_duplicate_updates_test() { { let mut txn = doc_2.transact_mut(); let update = Update::decode_v1(&second_init_sync_update).unwrap(); - txn.apply_update(update); + txn.apply_update(update).unwrap(); } { let txn = doc_2.transact(); @@ -282,7 +282,7 @@ async fn supabase_diff_state_vector_test() { { let mut txn = old_version_doc.transact_mut(); let update = Update::decode_v1(&doc_state).unwrap(); - txn.apply_update(update); + txn.apply_update(update).unwrap(); } let txn = old_version_doc.transact(); let json = map.to_json(&txn); diff --git a/frontend/rust-lib/flowy-server/tests/supabase_test/util.rs b/frontend/rust-lib/flowy-server/tests/supabase_test/util.rs index a0f3d1fbdc..7fba91fe9a 100644 --- a/frontend/rust-lib/flowy-server/tests/supabase_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/supabase_test/util.rs @@ -1,4 +1,3 @@ -use flowy_storage::ObjectStorageService; use std::collections::HashMap; use std::sync::Arc; @@ -16,10 +15,8 @@ use flowy_server::supabase::api::{ SupabaseFolderServiceImpl, SupabaseServerServiceImpl, SupabaseUserServiceImpl, }; use flowy_server::supabase::define::{USER_DEVICE_ID, USER_EMAIL, USER_UUID}; -use flowy_server::supabase::file_storage::core::SupabaseFileStorage; use flowy_server::{AppFlowyEncryption, EncryptionImpl}; use flowy_server_pub::supabase_config::SupabaseConfiguration; -use flowy_storage::{FileStoragePlan, StorageObject}; use flowy_user_pub::cloud::UserCloudService; use lib_infra::future::FutureResult; @@ -63,7 +60,7 @@ pub fn folder_service() -> Arc { } #[allow(dead_code)] -pub fn file_storage_service() -> Arc { +pub fn file_storage_service() -> Arc { let encryption_impl: Arc = Arc::new(EncryptionImpl::new(None)); let config = SupabaseConfiguration::from_env().unwrap(); Arc::new( @@ -163,19 +160,3 @@ pub fn third_party_sign_up_param(uuid: String) -> HashMap { } pub struct TestFileStoragePlan; - -impl FileStoragePlan for TestFileStoragePlan { - fn storage_size(&self) -> FutureResult { - // 1 GB - FutureResult::new(async { Ok(1024 * 1024 * 1024) }) - } - - fn maximum_file_size(&self) -> FutureResult { - // 5 MB - FutureResult::new(async { Ok(5 * 1024 * 1024) }) - } - - fn check_upload_object(&self, _object: &StorageObject) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) - } -} diff --git a/frontend/rust-lib/flowy-sqlite/Cargo.toml b/frontend/rust-lib/flowy-sqlite/Cargo.toml index e49452df75..345b05f903 100644 --- a/frontend/rust-lib/flowy-sqlite/Cargo.toml +++ b/frontend/rust-lib/flowy-sqlite/Cargo.toml @@ -7,13 +7,12 @@ edition = "2018" [dependencies] diesel.workspace = true -diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } +diesel_derives = { workspace = true, features = ["sqlite", "r2d2"] } diesel_migrations = { version = "2.1.0", features = ["sqlite"] } tracing.workspace = true serde.workspace = true serde_json.workspace = true anyhow.workspace = true -parking_lot.workspace = true r2d2 = "0.8.10" libsqlite3-sys = { version = "0.27.0", features = ["bundled"] } diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/down.sql new file mode 100644 index 0000000000..943e78adf2 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +drop table chat_table; +drop table chat_message_table; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/up.sql new file mode 100644 index 0000000000..192e7cf763 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/up.sql @@ -0,0 +1,20 @@ +-- Create table for chat documents +CREATE TABLE chat_table +( + chat_id TEXT PRIMARY KEY NOT NULL, + created_at BIGINT NOT NULL, + name TEXT NOT NULL DEFAULT '' +); + +-- Create table for chat messages +CREATE TABLE chat_message_table +( + message_id BIGINT PRIMARY KEY NOT NULL, + chat_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at BIGINT NOT NULL, + author_type BIGINT NOT NULL, + author_id TEXT NOT NULL, + reply_message_id BIGINT +); +CREATE INDEX idx_chat_messages_chat_id_message_id ON chat_message_table (chat_id, message_id); diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-06-14-020242_workspace_member/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-14-020242_workspace_member/down.sql new file mode 100644 index 0000000000..d9a93fe9a1 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-14-020242_workspace_member/down.sql @@ -0,0 +1 @@ +-- This file should undo anything in `up.sql` diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-06-14-020242_workspace_member/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-14-020242_workspace_member/up.sql new file mode 100644 index 0000000000..189cebe267 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-14-020242_workspace_member/up.sql @@ -0,0 +1,11 @@ +-- Your SQL goes here +CREATE TABLE workspace_members_table ( + email TEXT KEY NOT NULL, + role INTEGER NOT NULL, + name TEXT NOT NULL, + avatar_url TEXT, + uid BIGINT NOT NULL, + workspace_id TEXT NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (email, workspace_id) +); \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-06-16-131359_file_upload/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-16-131359_file_upload/down.sql new file mode 100644 index 0000000000..dddef11ffa --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-16-131359_file_upload/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +drop table upload_file_table; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-06-16-131359_file_upload/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-16-131359_file_upload/up.sql new file mode 100644 index 0000000000..13c7b01e9a --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-16-131359_file_upload/up.sql @@ -0,0 +1,20 @@ +-- Your SQL goes here +CREATE TABLE upload_file_table ( + workspace_id TEXT NOT NULL, + file_id TEXT NOT NULL, + parent_dir TEXT NOT NULL, + local_file_path TEXT NOT NULL, + content_type TEXT NOT NULL, + chunk_size INTEGER NOT NULL, + num_chunk INTEGER NOT NULL, + upload_id TEXT NOT NULL DEFAULT '', + created_at BIGINT NOT NULL, + PRIMARY KEY (workspace_id, parent_dir, file_id) +); + +CREATE TABLE upload_file_part ( + upload_id TEXT NOT NULL, + e_tag TEXT NOT NULL, + part_num INTEGER NOT NULL, + PRIMARY KEY (upload_id, e_tag) +); \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-06-22-082201_user_ai_model/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-22-082201_user_ai_model/down.sql new file mode 100644 index 0000000000..d9a93fe9a1 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-22-082201_user_ai_model/down.sql @@ -0,0 +1 @@ +-- This file should undo anything in `up.sql` diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-06-22-082201_user_ai_model/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-22-082201_user_ai_model/up.sql new file mode 100644 index 0000000000..7143a90355 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-22-082201_user_ai_model/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE user_table ADD COLUMN ai_model TEXT NOT NULL DEFAULT ''; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-06-26-015936_chat_setting/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-26-015936_chat_setting/down.sql new file mode 100644 index 0000000000..d9a93fe9a1 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-26-015936_chat_setting/down.sql @@ -0,0 +1 @@ +-- This file should undo anything in `up.sql` diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-06-26-015936_chat_setting/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-26-015936_chat_setting/up.sql new file mode 100644 index 0000000000..2361adeb34 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-06-26-015936_chat_setting/up.sql @@ -0,0 +1,13 @@ +-- Your SQL goes here +ALTER TABLE chat_table ADD COLUMN local_model_path TEXT NOT NULL DEFAULT ''; +ALTER TABLE chat_table ADD COLUMN local_model_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE chat_table ADD COLUMN local_enabled BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE chat_table ADD COLUMN sync_to_cloud BOOLEAN NOT NULL DEFAULT TRUE; + + +CREATE TABLE chat_local_setting_table +( + chat_id TEXT PRIMARY KEY NOT NULL, + local_model_path TEXT NOT NULL, + local_model_name TEXT NOT NULL DEFAULT '' +); \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-08-05-024351_chat_message_metadata/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-05-024351_chat_message_metadata/down.sql new file mode 100644 index 0000000000..c19ec5e34e --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-05-024351_chat_message_metadata/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE chat_message_table DROP COLUMN metadata; + diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-08-05-024351_chat_message_metadata/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-05-024351_chat_message_metadata/up.sql new file mode 100644 index 0000000000..d184c20b6f --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-05-024351_chat_message_metadata/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE chat_message_table ADD COLUMN metadata TEXT; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-08-07-093650_chat_metadata/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-07-093650_chat_metadata/down.sql new file mode 100644 index 0000000000..d9a93fe9a1 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-07-093650_chat_metadata/down.sql @@ -0,0 +1 @@ +-- This file should undo anything in `up.sql` diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-08-07-093650_chat_metadata/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-07-093650_chat_metadata/up.sql new file mode 100644 index 0000000000..948cf866a7 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-07-093650_chat_metadata/up.sql @@ -0,0 +1,4 @@ +-- Your SQL goes here +ALTER TABLE chat_table RENAME COLUMN local_model_path TO local_files; +ALTER TABLE chat_table RENAME COLUMN local_model_name TO metadata; + diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-08-20-061727_file_upload_finish/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-20-061727_file_upload_finish/down.sql new file mode 100644 index 0000000000..8c072ae1ce --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-20-061727_file_upload_finish/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE upload_file_table DROP COLUMN is_finish; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-08-20-061727_file_upload_finish/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-20-061727_file_upload_finish/up.sql new file mode 100644 index 0000000000..088564dca4 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-08-20-061727_file_upload_finish/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE upload_file_table ADD COLUMN is_finish BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-11-08-102351_workspace_member_count/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-11-08-102351_workspace_member_count/down.sql new file mode 100644 index 0000000000..88bc9048aa --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-11-08-102351_workspace_member_count/down.sql @@ -0,0 +1 @@ +ALTER TABLE user_workspace_table DROP COLUMN member_count; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-11-08-102351_workspace_member_count/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-11-08-102351_workspace_member_count/up.sql new file mode 100644 index 0000000000..8198d4f3f5 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-11-08-102351_workspace_member_count/up.sql @@ -0,0 +1 @@ +ALTER TABLE user_workspace_table ADD COLUMN member_count BIGINT NOT NULL DEFAULT 1; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-12-12-102351_workspace_role/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-12-12-102351_workspace_role/down.sql new file mode 100644 index 0000000000..da7c4c54cc --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-12-12-102351_workspace_role/down.sql @@ -0,0 +1 @@ +ALTER TABLE user_workspace_table DROP COLUMN role; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-12-12-102351_workspace_role/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-12-12-102351_workspace_role/up.sql new file mode 100644 index 0000000000..38842fde4b --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-12-12-102351_workspace_role/up.sql @@ -0,0 +1 @@ +ALTER TABLE user_workspace_table ADD COLUMN role INT; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-12-29-061706_collab_metadata/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-12-29-061706_collab_metadata/down.sql new file mode 100644 index 0000000000..ebe24324fe --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-12-29-061706_collab_metadata/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE af_collab_metadata; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-12-29-061706_collab_metadata/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-12-29-061706_collab_metadata/up.sql new file mode 100644 index 0000000000..84668f23fa --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-12-29-061706_collab_metadata/up.sql @@ -0,0 +1,7 @@ +-- Your SQL goes here +CREATE TABLE af_collab_metadata ( + object_id TEXT PRIMARY KEY NOT NULL, + updated_at BIGINT NOT NULL, + prev_sync_state_vector BLOB NOT NULL, + collab_type INTEGER NOT NULL +); \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql new file mode 100644 index 0000000000..8b07e6189d --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql @@ -0,0 +1,9 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE chat_table + ADD COLUMN local_enabled INTEGER; +ALTER TABLE chat_table + ADD COLUMN sync_to_cloud INTEGER; +ALTER TABLE chat_table + ADD COLUMN local_files TEXT; + +ALTER TABLE chat_table DROP COLUMN rag_ids; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/up.sql new file mode 100644 index 0000000000..0604601486 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE chat_table DROP COLUMN local_enabled; +ALTER TABLE chat_table DROP COLUMN local_files; +ALTER TABLE chat_table DROP COLUMN sync_to_cloud; +ALTER TABLE chat_table ADD COLUMN rag_ids TEXT; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/down.sql new file mode 100644 index 0000000000..65dec0f30a --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE chat_table DROP COLUMN is_sync; +ALTER TABLE chat_message_table DROP COLUMN is_sync; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/up.sql new file mode 100644 index 0000000000..ff8dce94bc --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/up.sql @@ -0,0 +1,5 @@ +-- Your SQL goes here +ALTER TABLE chat_table + ADD COLUMN is_sync BOOLEAN DEFAULT TRUE NOT NULL; +ALTER TABLE chat_message_table + ADD COLUMN is_sync BOOLEAN DEFAULT TRUE NOT NULL; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/down.sql new file mode 100644 index 0000000000..50602eb129 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE user_workspace_table +DROP COLUMN auth_type; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql new file mode 100644 index 0000000000..7d986e3e57 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql @@ -0,0 +1,24 @@ +-- Your SQL goes here +ALTER TABLE user_workspace_table + ADD COLUMN workspace_type INTEGER NOT NULL DEFAULT 1; + +-- 2. Back‑fill from user_table.auth_type +UPDATE user_workspace_table +SET workspace_type = (SELECT ut.auth_type + FROM user_table ut + WHERE ut.id = CAST(user_workspace_table.uid AS TEXT)) +WHERE EXISTS (SELECT 1 + FROM user_table ut + WHERE ut.id = CAST(user_workspace_table.uid AS TEXT)); + +ALTER TABLE user_table DROP COLUMN stability_ai_key; +ALTER TABLE user_table DROP COLUMN openai_key; +ALTER TABLE user_table DROP COLUMN workspace; +ALTER TABLE user_table DROP COLUMN encryption_type; +ALTER TABLE user_table DROP COLUMN ai_model; + +CREATE TABLE workspace_setting_table ( + id TEXT PRIMARY KEY NOT NULL , + disable_search_indexing BOOLEAN DEFAULT FALSE NOT NULL , + ai_model TEXT DEFAULT "" NOT NULL +); \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/src/kv/kv.rs b/frontend/rust-lib/flowy-sqlite/src/kv/kv.rs index 1ec71688c5..799f5b0666 100644 --- a/frontend/rust-lib/flowy-sqlite/src/kv/kv.rs +++ b/frontend/rust-lib/flowy-sqlite/src/kv/kv.rs @@ -11,13 +11,13 @@ use crate::sqlite_impl::{Database, PoolConfig}; const DB_NAME: &str = "cache.db"; -/// [StorePreferences] uses a sqlite database to store key value pairs. +/// [KVStorePreferences] uses a sqlite database to store key value pairs. /// Most of the time, it used to storage AppFlowy configuration. #[derive(Clone)] -pub struct StorePreferences { +pub struct KVStorePreferences { database: Option, } -impl StorePreferences { +impl KVStorePreferences { #[tracing::instrument(level = "trace", err)] pub fn new(root: &str) -> Result { if !Path::new(root).exists() { @@ -46,8 +46,8 @@ impl StorePreferences { } /// Set a object that implements [Serialize] trait of a key - pub fn set_object(&self, key: &str, value: T) -> Result<(), anyhow::Error> { - let value = serde_json::to_string(&value)?; + pub fn set_object(&self, key: &str, value: &T) -> Result<(), anyhow::Error> { + let value = serde_json::to_string(value)?; self.set_key_value(key, Some(value))?; Ok(()) } @@ -63,7 +63,7 @@ impl StorePreferences { } /// Get a bool value of a key - pub fn get_bool(&self, key: &str) -> bool { + pub fn get_bool_or_default(&self, key: &str) -> bool { self .get_key_value(key) .and_then(|kv| kv.value) @@ -71,6 +71,13 @@ impl StorePreferences { .unwrap_or(false) } + pub fn get_bool(&self, key: &str) -> Option { + self + .get_key_value(key) + .and_then(|kv| kv.value) + .and_then(|v| v.parse::().ok()) + } + /// Get a i64 value of a key pub fn get_i64(&self, key: &str) -> Option { self @@ -138,7 +145,7 @@ mod tests { use serde::{Deserialize, Serialize}; use tempfile::TempDir; - use crate::kv::StorePreferences; + use crate::kv::KVStorePreferences; #[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug)] struct Person { @@ -150,15 +157,15 @@ mod tests { fn kv_store_test() { let tempdir = TempDir::new().unwrap(); let path = tempdir.into_path(); - let store = StorePreferences::new(path.to_str().unwrap()).unwrap(); + let store = KVStorePreferences::new(path.to_str().unwrap()).unwrap(); store.set_str("1", "hello".to_string()); assert_eq!(store.get_str("1").unwrap(), "hello"); assert_eq!(store.get_str("2"), None); store.set_bool("1", true).unwrap(); - assert!(store.get_bool("1")); - assert!(!store.get_bool("2")); + assert!(store.get_bool_or_default("1")); + assert!(!store.get_bool_or_default("2")); store.set_i64("1", 1).unwrap(); assert_eq!(store.get_i64("1").unwrap(), 1); @@ -168,7 +175,7 @@ mod tests { name: "nathan".to_string(), age: 30, }; - store.set_object("1", person.clone()).unwrap(); + store.set_object("1", &person.clone()).unwrap(); assert_eq!(store.get_object::("1").unwrap(), person); } } diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index 37c2ff8bbd..f91d187b75 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -1,5 +1,47 @@ // @generated automatically by Diesel CLI. +diesel::table! { + af_collab_metadata (object_id) { + object_id -> Text, + updated_at -> BigInt, + prev_sync_state_vector -> Binary, + collab_type -> Integer, + } +} + +diesel::table! { + chat_local_setting_table (chat_id) { + chat_id -> Text, + local_model_path -> Text, + local_model_name -> Text, + } +} + +diesel::table! { + chat_message_table (message_id) { + message_id -> BigInt, + chat_id -> Text, + content -> Text, + created_at -> BigInt, + author_type -> BigInt, + author_id -> Text, + reply_message_id -> Nullable, + metadata -> Nullable, + is_sync -> Bool, + } +} + +diesel::table! { + chat_table (chat_id) { + chat_id -> Text, + created_at -> BigInt, + name -> Text, + metadata -> Text, + rag_ids -> Nullable, + is_sync -> Bool, + } +} + diesel::table! { collab_snapshot (id) { id -> Text, @@ -12,6 +54,29 @@ diesel::table! { } } +diesel::table! { + upload_file_part (upload_id, e_tag) { + upload_id -> Text, + e_tag -> Text, + part_num -> Integer, + } +} + +diesel::table! { + upload_file_table (workspace_id, file_id, parent_dir) { + workspace_id -> Text, + file_id -> Text, + parent_dir -> Text, + local_file_path -> Text, + content_type -> Text, + chunk_size -> Integer, + num_chunk -> Integer, + upload_id -> Text, + created_at -> BigInt, + is_finish -> Bool, + } +} + diesel::table! { user_data_migration_records (id) { id -> Integer, @@ -24,14 +89,10 @@ diesel::table! { user_table (id) { id -> Text, name -> Text, - workspace -> Text, icon_url -> Text, - openai_key -> Text, token -> Text, email -> Text, auth_type -> Integer, - encryption_type -> Text, - stability_ai_key -> Text, updated_at -> BigInt, } } @@ -44,12 +105,43 @@ diesel::table! { created_at -> BigInt, database_storage_id -> Text, icon -> Text, + member_count -> BigInt, + role -> Nullable, + workspace_type -> Integer, + } +} + +diesel::table! { + workspace_members_table (email, workspace_id) { + email -> Text, + role -> Integer, + name -> Text, + avatar_url -> Nullable, + uid -> BigInt, + workspace_id -> Text, + updated_at -> Timestamp, + } +} + +diesel::table! { + workspace_setting_table (id) { + id -> Text, + disable_search_indexing -> Bool, + ai_model -> Text, } } diesel::allow_tables_to_appear_in_same_query!( + af_collab_metadata, + chat_local_setting_table, + chat_message_table, + chat_table, collab_snapshot, + upload_file_part, + upload_file_table, user_data_migration_records, user_table, user_workspace_table, + workspace_members_table, + workspace_setting_table, ); diff --git a/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pragma.rs b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pragma.rs index 05054a6406..10dfda7a9d 100644 --- a/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pragma.rs +++ b/frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pragma.rs @@ -51,6 +51,7 @@ pub trait PragmaExtension: ConnectionExtension { self.query::(&query) } + #[allow(dead_code)] fn pragma_get<'query, ST, T>(&mut self, key: &str, schema: Option<&str>) -> Result where SqlLiteral: LoadQuery<'query, SqliteConnection, T>, @@ -64,10 +65,12 @@ pub trait PragmaExtension: ConnectionExtension { self.query::(&query) } + #[allow(dead_code)] fn pragma_set_busy_timeout(&mut self, timeout_ms: i32) -> Result { self.pragma_ret::("busy_timeout", timeout_ms, None) } + #[allow(dead_code)] fn pragma_get_busy_timeout(&mut self) -> Result { self.pragma_get::("busy_timeout", None) } @@ -80,12 +83,14 @@ pub trait PragmaExtension: ConnectionExtension { self.pragma_ret::("journal_mode", mode, schema) } + #[allow(dead_code)] fn pragma_get_journal_mode(&mut self, schema: Option<&str>) -> Result { self .pragma_get::("journal_mode", schema)? .parse() } + #[allow(dead_code)] fn pragma_set_synchronous( &mut self, synchronous: SQLiteSynchronous, @@ -94,6 +99,7 @@ pub trait PragmaExtension: ConnectionExtension { self.pragma("synchronous", synchronous as u8, schema) } + #[allow(dead_code)] fn pragma_get_synchronous(&mut self, schema: Option<&str>) -> Result { self .pragma_get::("synchronous", schema)? diff --git a/frontend/rust-lib/flowy-storage-pub/Cargo.toml b/frontend/rust-lib/flowy-storage-pub/Cargo.toml new file mode 100644 index 0000000000..d36c997432 --- /dev/null +++ b/frontend/rust-lib/flowy-storage-pub/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "flowy-storage-pub" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +lib-infra.workspace = true +serde.workspace = true +async-trait.workspace = true +mime = "0.3.17" +flowy-error = { workspace = true, features = ["impl_from_reqwest"] } +bytes.workspace = true +mime_guess = "2.0.4" +client-api-entity = { workspace = true } +tokio = { workspace = true, features = ["sync", "io-util"] } +anyhow = "1.0.86" +uuid.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-storage-pub/src/chunked_byte.rs b/frontend/rust-lib/flowy-storage-pub/src/chunked_byte.rs new file mode 100644 index 0000000000..e5a78e2974 --- /dev/null +++ b/frontend/rust-lib/flowy-storage-pub/src/chunked_byte.rs @@ -0,0 +1,417 @@ +use anyhow::anyhow; +use bytes::Bytes; +use std::fmt::Display; +use std::path::Path; +use tokio::fs::File; +use tokio::io::AsyncReadExt; +use tokio::io::SeekFrom; +use tokio::io::{self, AsyncSeekExt}; + +/// In Amazon S3, the minimum chunk size for multipart uploads is 5 MB,except for the last part, +/// which can be smaller.(https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html) +pub const MIN_CHUNK_SIZE: usize = 5 * 1024 * 1024; // Minimum Chunk Size 5 MB +#[derive(Debug)] +pub struct ChunkedBytes { + file: File, + chunk_size: usize, + file_size: u64, + current_offset: u64, +} + +impl ChunkedBytes { + /// Create a `ChunkedBytes` instance from a file. + pub async fn from_file>( + file_path: P, + chunk_size: usize, + ) -> Result { + if chunk_size < MIN_CHUNK_SIZE { + return Err(anyhow!( + "Chunk size should be greater than or equal to {} bytes", + MIN_CHUNK_SIZE + )); + } + + let file = File::open(file_path).await?; + let file_size = file.metadata().await?.len(); + + Ok(ChunkedBytes { + file, + chunk_size, + file_size, + current_offset: 0, + }) + } + + /// Read the next chunk from the file. + pub async fn next_chunk(&mut self) -> Option> { + if self.current_offset >= self.file_size { + return None; // End of file + } + + let mut buffer = vec![0u8; self.chunk_size]; + let mut total_bytes_read = 0; + + // Loop to ensure the buffer is filled or EOF is reached + while total_bytes_read < self.chunk_size { + let read_result = self.file.read(&mut buffer[total_bytes_read..]).await; + match read_result { + Ok(0) => break, // EOF + Ok(n) => total_bytes_read += n, + Err(e) => return Some(Err(e)), + } + } + + if total_bytes_read == 0 { + return None; // EOF + } + + self.current_offset += total_bytes_read as u64; + Some(Ok(Bytes::from(buffer[..total_bytes_read].to_vec()))) + } + + /// Set the offset for the next chunk to be read. + pub async fn set_offset(&mut self, offset: u64) -> Result<(), io::Error> { + if offset > self.file_size { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Offset out of range", + )); + } + self.current_offset = offset; + self.file.seek(SeekFrom::Start(offset)).await?; + Ok(()) + } + + /// Get the total number of chunks in the file. + pub fn total_chunks(&self) -> usize { + ((self.file_size + self.chunk_size as u64 - 1) / self.chunk_size as u64) as usize + } + + /// Get the current offset in the file. + pub fn current_offset(&self) -> u64 { + self.current_offset + } +} + +impl Display for ChunkedBytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "file_size: {}, chunk_size: {}, total_chunks: {}, current_offset: {}", + self.file_size, + self.chunk_size, + self.total_chunks(), + self.current_offset + ) + } +} + +// Function to split input bytes into several chunks and return offsets +pub fn split_into_chunks(data: &Bytes, chunk_size: usize) -> Vec<(usize, usize)> { + calculate_offsets(data.len(), chunk_size) +} + +pub fn calculate_offsets(data_len: usize, chunk_size: usize) -> Vec<(usize, usize)> { + let mut offsets = Vec::new(); + let mut start = 0; + + while start < data_len { + let end = std::cmp::min(start + chunk_size, data_len); + offsets.push((start, end)); + start = end; + } + + offsets +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env::temp_dir; + use tokio::io::AsyncWriteExt; + + #[tokio::test] + async fn test_chunked_bytes_small_file() { + // Create a small file of 1 MB + let mut file_path = temp_dir(); + file_path.push("test_small_file"); + + let mut file = File::create(&file_path).await.unwrap(); + file.write_all(&vec![0; 1024 * 1024]).await.unwrap(); // 1 MB + file.flush().await.unwrap(); + + // Create ChunkedBytes instance + let mut chunked_bytes = ChunkedBytes::from_file(&file_path, MIN_CHUNK_SIZE) + .await + .unwrap(); + + // Validate total chunks and read the data + assert_eq!(chunked_bytes.total_chunks(), 1); // Only 1 chunk due to file size + let chunk = chunked_bytes.next_chunk().await.unwrap().unwrap(); + assert_eq!(chunk.len(), 1024 * 1024); // The full 1 MB + + // Ensure no more chunks are available + assert!(chunked_bytes.next_chunk().await.is_none()); + + tokio::fs::remove_file(file_path).await.unwrap(); + } + + #[tokio::test] + async fn test_chunked_bytes_large_file() { + // Create a large file of 15 MB + let mut file_path = temp_dir(); + file_path.push("test_large_file"); + + let mut file = File::create(&file_path).await.unwrap(); + file.write_all(&vec![0; 15 * 1024 * 1024]).await.unwrap(); // 15 MB + file.flush().await.unwrap(); + + // Create ChunkedBytes instance + let mut chunked_bytes = ChunkedBytes::from_file(&file_path, MIN_CHUNK_SIZE) + .await + .unwrap(); + + // Validate total chunks + assert_eq!(chunked_bytes.total_chunks(), 3); // 15 MB split into 3 chunks of 5 MB + + // Read and validate all chunks + let mut chunk_sizes = vec![]; + while let Some(chunk_result) = chunked_bytes.next_chunk().await { + chunk_sizes.push(chunk_result.unwrap().len()); + } + assert_eq!( + chunk_sizes, + vec![5 * 1024 * 1024, 5 * 1024 * 1024, 5 * 1024 * 1024] + ); + + tokio::fs::remove_file(file_path).await.unwrap(); + } + + #[tokio::test] + async fn test_set_offset() { + // Create a file of 10 MB + let mut file_path = temp_dir(); + file_path.push("test_offset_file"); + + let mut file = File::create(&file_path).await.unwrap(); + file.write_all(&vec![0; 10 * 1024 * 1024]).await.unwrap(); // 10 MB + file.flush().await.unwrap(); + + // Create ChunkedBytes instance + let mut chunked_bytes = ChunkedBytes::from_file(&file_path, MIN_CHUNK_SIZE) + .await + .unwrap(); + + // Set the offset to 5 MB and read the next chunk + chunked_bytes.set_offset(5 * 1024 * 1024).await.unwrap(); + let chunk = chunked_bytes.next_chunk().await.unwrap().unwrap(); + assert_eq!(chunk.len(), 5 * 1024 * 1024); // Read the second chunk + + // Ensure no more chunks are available + assert!(chunked_bytes.next_chunk().await.is_none()); + + tokio::fs::remove_file(file_path).await.unwrap(); + } + + #[tokio::test] + async fn test_partial_chunk() { + // Create a file of 6 MB (one full chunk and one partial chunk) + let mut file_path = temp_dir(); + file_path.push("test_partial_chunk_file"); + + let mut file = File::create(&file_path).await.unwrap(); + file.write_all(&vec![0; 6 * 1024 * 1024]).await.unwrap(); // 6 MB + file.flush().await.unwrap(); + + // Create ChunkedBytes instance + let mut chunked_bytes = ChunkedBytes::from_file(&file_path, MIN_CHUNK_SIZE) + .await + .unwrap(); + + // Validate total chunks + assert_eq!(chunked_bytes.total_chunks(), 2); // 6 MB split into 1 full chunk and 1 partial chunk + + // Read the first chunk + let chunk1 = chunked_bytes.next_chunk().await.unwrap().unwrap(); + assert_eq!(chunk1.len(), 5 * 1024 * 1024); // Full chunk + + // Read the second chunk + let chunk2 = chunked_bytes.next_chunk().await.unwrap().unwrap(); + assert_eq!(chunk2.len(), 1024 * 1024); // Partial chunk + + // Ensure no more chunks are available + assert!(chunked_bytes.next_chunk().await.is_none()); + + tokio::fs::remove_file(file_path).await.unwrap(); + } + + #[tokio::test] + async fn test_invalid_offset() { + // Create a file of 5 MB + let mut file_path = temp_dir(); + file_path.push("test_invalid_offset_file"); + + let mut file = File::create(&file_path).await.unwrap(); + file.write_all(&vec![0; 5 * 1024 * 1024]).await.unwrap(); // 5 MB + file.flush().await.unwrap(); + + // Create ChunkedBytes instance + let mut chunked_bytes = ChunkedBytes::from_file(&file_path, MIN_CHUNK_SIZE) + .await + .unwrap(); + + // Try setting an invalid offset + let result = chunked_bytes.set_offset(10 * 1024 * 1024).await; + assert!(result.is_err()); // Offset out of range + + tokio::fs::remove_file(file_path).await.unwrap(); + } + + #[tokio::test] + async fn test_exact_multiple_chunk_file() { + // Create a file of 10 MB (exact multiple of 5 MB) + let mut file_path = temp_dir(); + file_path.push("test_exact_multiple_chunk_file"); + + let mut file = File::create(&file_path).await.unwrap(); + file.write_all(&vec![0; 10 * 1024 * 1024]).await.unwrap(); // 10 MB + file.flush().await.unwrap(); + + // Create ChunkedBytes instance + let mut chunked_bytes = ChunkedBytes::from_file(&file_path, MIN_CHUNK_SIZE) + .await + .unwrap(); + + // Validate total chunks + let expected_offsets = calculate_offsets(10 * 1024 * 1024, MIN_CHUNK_SIZE); + assert_eq!(chunked_bytes.total_chunks(), expected_offsets.len()); // 2 chunks + + // Read and validate all chunks + let mut chunk_sizes = vec![]; + while let Some(chunk_result) = chunked_bytes.next_chunk().await { + chunk_sizes.push(chunk_result.unwrap().len()); + } + assert_eq!(chunk_sizes, vec![5 * 1024 * 1024, 5 * 1024 * 1024]); // 2 full chunks + + tokio::fs::remove_file(file_path).await.unwrap(); + } + + #[tokio::test] + async fn test_small_file_less_than_chunk_size() { + // Create a file of 2 MB (smaller than 5 MB) + let mut file_path = temp_dir(); + file_path.push("test_small_file_less_than_chunk_size"); + + let mut file = File::create(&file_path).await.unwrap(); + file.write_all(&vec![0; 2 * 1024 * 1024]).await.unwrap(); // 2 MB + file.flush().await.unwrap(); + + // Create ChunkedBytes instance + let mut chunked_bytes = ChunkedBytes::from_file(&file_path, MIN_CHUNK_SIZE) + .await + .unwrap(); + + // Validate total chunks + let expected_offsets = calculate_offsets(2 * 1024 * 1024, MIN_CHUNK_SIZE); + assert_eq!(chunked_bytes.total_chunks(), expected_offsets.len()); // 1 chunk + + // Read and validate all chunks + let mut chunk_sizes = vec![]; + while let Some(chunk_result) = chunked_bytes.next_chunk().await { + chunk_sizes.push(chunk_result.unwrap().len()); + } + assert_eq!(chunk_sizes, vec![2 * 1024 * 1024]); // 1 partial chunk + + tokio::fs::remove_file(file_path).await.unwrap(); + } + + #[tokio::test] + async fn test_file_slightly_larger_than_chunk_size() { + // Create a file of 5.5 MB (slightly larger than 1 chunk) + let mut file_path = temp_dir(); + file_path.push("test_file_slightly_larger_than_chunk_size"); + + let mut file = File::create(&file_path).await.unwrap(); + file + .write_all(&vec![0; 5 * 1024 * 1024 + 512 * 1024]) + .await + .unwrap(); // 5.5 MB + file.flush().await.unwrap(); + + // Create ChunkedBytes instance + let mut chunked_bytes = ChunkedBytes::from_file(&file_path, MIN_CHUNK_SIZE) + .await + .unwrap(); + + // Validate total chunks + let expected_offsets = calculate_offsets(5 * 1024 * 1024 + 512 * 1024, MIN_CHUNK_SIZE); + assert_eq!(chunked_bytes.total_chunks(), expected_offsets.len()); // 2 chunks + + // Read and validate all chunks + let mut chunk_sizes = vec![]; + while let Some(chunk_result) = chunked_bytes.next_chunk().await { + chunk_sizes.push(chunk_result.unwrap().len()); + } + assert_eq!(chunk_sizes, vec![5 * 1024 * 1024, 512 * 1024]); // 1 full chunk, 1 partial chunk + + tokio::fs::remove_file(file_path).await.unwrap(); + } + + #[tokio::test] + async fn test_large_file_with_many_chunks() { + // Create a file of 50 MB (10 chunks of 5 MB) + let mut file_path = temp_dir(); + file_path.push("test_large_file_with_many_chunks"); + + let mut file = File::create(&file_path).await.unwrap(); + file.write_all(&vec![0; 50 * 1024 * 1024]).await.unwrap(); // 50 MB + file.flush().await.unwrap(); + + // Create ChunkedBytes instance + let mut chunked_bytes = ChunkedBytes::from_file(&file_path, MIN_CHUNK_SIZE) + .await + .unwrap(); + + // Validate total chunks + let expected_offsets = calculate_offsets(50 * 1024 * 1024, MIN_CHUNK_SIZE); + assert_eq!(chunked_bytes.total_chunks(), expected_offsets.len()); // 10 chunks + + // Read and validate all chunks + let mut chunk_sizes = vec![]; + while let Some(chunk_result) = chunked_bytes.next_chunk().await { + chunk_sizes.push(chunk_result.unwrap().len()); + } + assert_eq!(chunk_sizes, vec![5 * 1024 * 1024; 10]); // 10 full chunks + + tokio::fs::remove_file(file_path).await.unwrap(); + } + + #[tokio::test] + async fn test_file_with_exact_chunk_size() { + // Create a file of exactly 5 MB + let mut file_path = temp_dir(); + file_path.push("test_file_with_exact_chunk_size"); + + let mut file = File::create(&file_path).await.unwrap(); + file.write_all(&vec![0; 5 * 1024 * 1024]).await.unwrap(); // 5 MB + file.flush().await.unwrap(); + + // Create ChunkedBytes instance + let mut chunked_bytes = ChunkedBytes::from_file(&file_path, MIN_CHUNK_SIZE) + .await + .unwrap(); + + // Validate total chunks + let expected_offsets = calculate_offsets(5 * 1024 * 1024, MIN_CHUNK_SIZE); + assert_eq!(chunked_bytes.total_chunks(), expected_offsets.len()); // 1 chunk + + // Read and validate all chunks + let mut chunk_sizes = vec![]; + while let Some(chunk_result) = chunked_bytes.next_chunk().await { + chunk_sizes.push(chunk_result.unwrap().len()); + } + assert_eq!(chunk_sizes, vec![5 * 1024 * 1024]); // 1 full chunk + + tokio::fs::remove_file(file_path).await.unwrap(); + } +} diff --git a/frontend/rust-lib/flowy-storage-pub/src/cloud.rs b/frontend/rust-lib/flowy-storage-pub/src/cloud.rs new file mode 100644 index 0000000000..5a72262ac9 --- /dev/null +++ b/frontend/rust-lib/flowy-storage-pub/src/cloud.rs @@ -0,0 +1,173 @@ +use crate::storage::{CompletedPartRequest, CreateUploadResponse, UploadPartResponse}; +use async_trait::async_trait; +use bytes::Bytes; +use flowy_error::{FlowyError, FlowyResult}; +use mime::Mime; +use uuid::Uuid; + +#[async_trait] +pub trait StorageCloudService: Send + Sync { + /// Creates a new storage object. + /// + /// # Parameters + /// - `url`: url of the object to be created. + /// + /// # Returns + /// - `Ok()` + /// - `Err(Error)`: An error occurred during the operation. + async fn get_object_url(&self, object_id: ObjectIdentity) -> Result; + + /// Creates a new storage object. + /// + /// # Parameters + /// - `url`: url of the object to be created. + /// + /// # Returns + /// - `Ok()` + /// - `Err(Error)`: An error occurred during the operation. + async fn put_object(&self, url: String, object_value: ObjectValue) -> Result<(), FlowyError>; + + /// Deletes a storage object by its URL. + /// + /// # Parameters + /// - `url`: url of the object to be deleted. + /// + /// # Returns + /// - `Ok()` + /// - `Err(Error)`: An error occurred during the operation. + async fn delete_object(&self, url: &str) -> Result<(), FlowyError>; + + /// Fetches a storage object by its URL. + /// + /// # Parameters + /// - `url`: url of the object + /// + /// # Returns + /// - `Ok(File)`: The returned file object. + /// - `Err(Error)`: An error occurred during the operation. + async fn get_object(&self, url: String) -> Result; + async fn get_object_url_v1( + &self, + workspace_id: &Uuid, + parent_dir: &str, + file_id: &str, + ) -> FlowyResult; + + /// Return workspace_id, parent_dir, file_id + async fn parse_object_url_v1(&self, url: &str) -> Option<(Uuid, String, String)>; + + async fn create_upload( + &self, + workspace_id: &Uuid, + parent_dir: &str, + file_id: &str, + content_type: &str, + file_size: u64, + ) -> Result; + + async fn upload_part( + &self, + workspace_id: &Uuid, + parent_dir: &str, + upload_id: &str, + file_id: &str, + part_number: i32, + body: Vec, + ) -> Result; + + async fn complete_upload( + &self, + workspace_id: &Uuid, + parent_dir: &str, + upload_id: &str, + file_id: &str, + parts: Vec, + ) -> Result<(), FlowyError>; +} + +pub struct ObjectIdentity { + pub workspace_id: Uuid, + pub file_id: String, + pub ext: String, +} + +#[derive(Clone)] +pub struct ObjectValue { + pub raw: Bytes, + pub mime: Mime, +} + +pub struct StorageObject { + pub workspace_id: Uuid, + pub file_name: String, + pub value: ObjectValueSupabase, +} + +pub enum ObjectValueSupabase { + File { file_path: String }, + Bytes { bytes: Bytes, mime: String }, +} + +impl ObjectValueSupabase { + pub fn mime_type(&self) -> String { + match self { + ObjectValueSupabase::File { file_path } => mime_guess::from_path(file_path) + .first_or_octet_stream() + .to_string(), + ObjectValueSupabase::Bytes { mime, .. } => mime.clone(), + } + } +} + +impl StorageObject { + /// Creates a `StorageObject` from a file. + /// + /// # Parameters + /// + /// * `name`: The name of the storage object. + /// * `file_path`: The file path to the storage object's data. + /// + pub fn from_file(workspace_id: &Uuid, file_name: &str, file_path: T) -> Self { + Self { + workspace_id: *workspace_id, + file_name: file_name.to_string(), + value: ObjectValueSupabase::File { + file_path: file_path.to_string(), + }, + } + } + + /// Creates a `StorageObject` from bytes. + /// + /// # Parameters + /// + /// * `name`: The name of the storage object. + /// * `bytes`: The byte data of the storage object. + /// * `mime`: The MIME type of the storage object. + /// + pub fn from_bytes>( + workspace_id: &Uuid, + file_name: &str, + bytes: B, + mime: String, + ) -> Self { + let bytes = bytes.into(); + Self { + workspace_id: *workspace_id, + file_name: file_name.to_string(), + value: ObjectValueSupabase::Bytes { bytes, mime }, + } + } + + /// Gets the file size of the `StorageObject`. + /// + /// # Returns + /// + /// The file size in bytes. + pub fn file_size(&self) -> u64 { + match &self.value { + ObjectValueSupabase::File { file_path } => std::fs::metadata(file_path).unwrap().len(), + ObjectValueSupabase::Bytes { bytes, .. } => bytes.len() as u64, + } + } +} diff --git a/frontend/rust-lib/flowy-storage-pub/src/lib.rs b/frontend/rust-lib/flowy-storage-pub/src/lib.rs new file mode 100644 index 0000000000..fa646847a8 --- /dev/null +++ b/frontend/rust-lib/flowy-storage-pub/src/lib.rs @@ -0,0 +1,3 @@ +pub mod chunked_byte; +pub mod cloud; +pub mod storage; diff --git a/frontend/rust-lib/flowy-storage-pub/src/storage.rs b/frontend/rust-lib/flowy-storage-pub/src/storage.rs new file mode 100644 index 0000000000..061584dc6d --- /dev/null +++ b/frontend/rust-lib/flowy-storage-pub/src/storage.rs @@ -0,0 +1,133 @@ +use async_trait::async_trait; +pub use client_api_entity::{CompletedPartRequest, CreateUploadResponse, UploadPartResponse}; +use flowy_error::{FlowyError, FlowyResult}; +use lib_infra::box_any::BoxAny; +use serde::Serialize; +use std::fmt::Display; +use std::ops::{Deref, DerefMut}; +use tokio::sync::broadcast; + +#[async_trait] +pub trait StorageService: Send + Sync { + async fn delete_object(&self, url: String) -> FlowyResult<()>; + + fn download_object(&self, url: String, local_file_path: String) -> FlowyResult<()>; + + async fn create_upload( + &self, + workspace_id: &str, + parent_dir: &str, + local_file_path: &str, + ) -> Result<(CreatedUpload, Option), FlowyError>; + + async fn start_upload(&self, record: &BoxAny) -> Result<(), FlowyError>; + + async fn resume_upload( + &self, + workspace_id: &str, + parent_dir: &str, + file_id: &str, + ) -> Result<(), FlowyError>; + + async fn subscribe_file_progress( + &self, + parent_idr: &str, + file_id: &str, + ) -> Result, FlowyError>; +} + +pub struct FileProgressReceiver { + pub rx: broadcast::Receiver, + pub file_id: String, +} + +impl Deref for FileProgressReceiver { + type Target = broadcast::Receiver; + + fn deref(&self) -> &Self::Target { + &self.rx + } +} + +impl DerefMut for FileProgressReceiver { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.rx + } +} + +#[derive(Clone, Debug)] +pub enum FileUploadState { + NotStarted, + Uploading { progress: f64 }, + Finished { file_id: String }, +} + +#[derive(Clone, Debug, Serialize)] +pub struct FileProgress { + pub file_url: String, + pub file_id: String, + pub progress: f64, + pub error: Option, +} + +impl FileProgress { + pub fn new_progress(file_url: String, file_id: String, progress: f64) -> Self { + FileProgress { + file_url, + file_id, + progress: (progress * 10.0).round() / 10.0, + error: None, + } + } + + pub fn new_error(file_url: String, file_id: String, error: String) -> Self { + FileProgress { + file_url, + file_id, + progress: 0.0, + error: Some(error), + } + } +} + +impl Display for FileProgress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FileProgress: {} - {}", self.file_id, self.progress) + } +} + +#[derive(Debug)] +pub struct ProgressNotifier { + file_id: String, + tx: broadcast::Sender, + pub current_value: Option, +} + +impl ProgressNotifier { + pub fn new(file_id: String) -> Self { + let (tx, _) = broadcast::channel(100); + ProgressNotifier { + file_id, + tx, + current_value: None, + } + } + + pub fn subscribe(&self) -> FileProgressReceiver { + FileProgressReceiver { + rx: self.tx.subscribe(), + file_id: self.file_id.clone(), + } + } + + pub async fn notify(&mut self, progress: FileUploadState) { + self.current_value = Some(progress.clone()); + let _ = self.tx.send(progress); + } +} + +#[derive(Clone)] +pub struct CreatedUpload { + pub url: String, + pub file_id: String, +} diff --git a/frontend/rust-lib/flowy-storage/Cargo.toml b/frontend/rust-lib/flowy-storage/Cargo.toml index d35c17565e..add7996439 100644 --- a/frontend/rust-lib/flowy-storage/Cargo.toml +++ b/frontend/rust-lib/flowy-storage/Cargo.toml @@ -3,21 +3,37 @@ name = "flowy-storage" version = "0.1.0" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[lib] -crate-type = ["cdylib", "rlib"] - [dependencies] -reqwest = { version = "0.11", features = ["json", "stream"] } +lib-dispatch = { workspace = true } +flowy-storage-pub.workspace = true serde_json.workspace = true serde.workspace = true async-trait.workspace = true bytes.workspace = true -mime_guess = "2.0" lib-infra = { workspace = true } url = "2.2.2" -flowy-error = { workspace = true, features = ["impl_from_reqwest"] } -mime = "0.3.17" -tokio = { workspace = true, features = ["sync", "io-util"]} +flowy-error = { workspace = true, features = ["impl_from_reqwest", "impl_from_sqlite"] } +tokio = { workspace = true, features = ["sync", "io-util"] } tracing.workspace = true -fxhash = "0.2.1" \ No newline at end of file +flowy-sqlite.workspace = true +mime_guess = "2.0.4" +chrono = "0.4.33" +flowy-notification = { workspace = true } +flowy-derive.workspace = true +protobuf = { workspace = true } +dashmap.workspace = true +strum_macros = "0.25.2" +allo-isolate = { version = "^0.1", features = ["catch-unwind"] } +collab-importer = { workspace = true } +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } +uuid = "1.6.1" +rand = { version = "0.8", features = ["std_rng"] } + +[features] +dart = ["flowy-codegen/dart", "flowy-notification/dart"] + +[build-dependencies] +flowy-codegen.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-storage/Flowy.toml b/frontend/rust-lib/flowy-storage/Flowy.toml new file mode 100644 index 0000000000..b0097e98d2 --- /dev/null +++ b/frontend/rust-lib/flowy-storage/Flowy.toml @@ -0,0 +1,3 @@ +# Check out the FlowyConfig (located in flowy_toml.rs) for more details. +proto_input = ["src/entities.rs", "src/event_map.rs", "src/notification.rs"] +event_files = ["src/event_map.rs"] diff --git a/frontend/rust-lib/flowy-storage/build.rs b/frontend/rust-lib/flowy-storage/build.rs new file mode 100644 index 0000000000..77c0c8125b --- /dev/null +++ b/frontend/rust-lib/flowy-storage/build.rs @@ -0,0 +1,7 @@ +fn main() { + #[cfg(feature = "dart")] + { + flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); + flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); + } +} diff --git a/frontend/rust-lib/flowy-storage/src/entities.rs b/frontend/rust-lib/flowy-storage/src/entities.rs new file mode 100644 index 0000000000..1decf5bc66 --- /dev/null +++ b/frontend/rust-lib/flowy-storage/src/entities.rs @@ -0,0 +1,22 @@ +use flowy_derive::ProtoBuf; + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct RegisterStreamPB { + #[pb(index = 1)] + pub port: i64, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct QueryFilePB { + #[pb(index = 1)] + pub url: String, +} + +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct FileStatePB { + #[pb(index = 1)] + pub file_id: String, + + #[pb(index = 2)] + pub is_finish: bool, +} diff --git a/frontend/rust-lib/flowy-storage/src/event_handler.rs b/frontend/rust-lib/flowy-storage/src/event_handler.rs new file mode 100644 index 0000000000..8b34918e6b --- /dev/null +++ b/frontend/rust-lib/flowy-storage/src/event_handler.rs @@ -0,0 +1,38 @@ +use crate::entities::{FileStatePB, QueryFilePB, RegisterStreamPB}; +use crate::manager::StorageManager; +use flowy_error::{FlowyError, FlowyResult}; +use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; +use std::sync::{Arc, Weak}; + +fn upgrade_storage_manager( + ai_manager: AFPluginState>, +) -> FlowyResult> { + let manager = ai_manager + .upgrade() + .ok_or(FlowyError::internal().with_context("The storage manager is already dropped"))?; + Ok(manager) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn register_stream_handler( + data: AFPluginData, + storage_manager: AFPluginState>, +) -> Result<(), FlowyError> { + let manager = upgrade_storage_manager(storage_manager)?; + let data = data.into_inner(); + manager.register_file_progress_stream(data.port).await; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn query_file_handler( + data: AFPluginData, + storage_manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_storage_manager(storage_manager)?; + let data = data.into_inner(); + let pb = manager.query_file_state(&data.url).await.ok_or_else(|| { + FlowyError::record_not_found().with_context(format!("File not found: {}", data.url)) + })?; + data_result_ok(pb) +} diff --git a/frontend/rust-lib/flowy-storage/src/event_map.rs b/frontend/rust-lib/flowy-storage/src/event_map.rs new file mode 100644 index 0000000000..0509f26884 --- /dev/null +++ b/frontend/rust-lib/flowy-storage/src/event_map.rs @@ -0,0 +1,25 @@ +use crate::event_handler::{query_file_handler, register_stream_handler}; +use crate::manager::StorageManager; +use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; +use lib_dispatch::prelude::*; +use std::sync::Weak; +use strum_macros::Display; + +pub fn init(manager: Weak) -> AFPlugin { + AFPlugin::new() + .name("file-storage") + .state(manager) + .event(FileStorageEvent::RegisterStream, register_stream_handler) + .event(FileStorageEvent::QueryFile, query_file_handler) +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] +#[event_err = "FlowyError"] +pub enum FileStorageEvent { + /// Create a new workspace + #[event(input = "RegisterStreamPB")] + RegisterStream = 0, + + #[event(input = "QueryFilePB", output = "FileStatePB")] + QueryFile = 1, +} diff --git a/frontend/rust-lib/flowy-storage/src/file_cache.rs b/frontend/rust-lib/flowy-storage/src/file_cache.rs new file mode 100644 index 0000000000..2a71c5f72f --- /dev/null +++ b/frontend/rust-lib/flowy-storage/src/file_cache.rs @@ -0,0 +1,89 @@ +use std::path::{Path, PathBuf}; +use tokio::fs::{self, File}; +use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; +use tracing::error; + +/// [FileTempStorage] is used to store the temporary files for uploading. After the file is uploaded, +/// the file will be deleted. +pub struct FileTempStorage { + storage_dir: PathBuf, +} + +impl FileTempStorage { + /// Creates a new `FileTempStorage` with the specified temporary directory. + pub fn new(storage_dir: PathBuf) -> Self { + if !storage_dir.exists() { + if let Err(err) = std::fs::create_dir_all(&storage_dir) { + error!("Failed to create temporary storage directory: {:?}", err); + } + } + + FileTempStorage { storage_dir } + } + + /// Generates a temporary file path using the given file name. + fn generate_temp_file_path_with_name(&self, file_name: &str) -> PathBuf { + self.storage_dir.join(file_name) + } + + /// Creates a temporary file from an existing local file path. + #[allow(dead_code)] + pub async fn create_temp_file_from_existing( + &self, + existing_file_path: &Path, + ) -> io::Result { + let file_name = existing_file_path + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid file name"))? + .to_str() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid file name"))?; + + let temp_file_path = self.generate_temp_file_path_with_name(file_name); + fs::copy(existing_file_path, &temp_file_path).await?; + Ok( + temp_file_path + .to_str() + .ok_or(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid file path", + ))? + .to_owned(), + ) + } + + /// Creates a temporary file from bytes and a specified file name. + #[allow(dead_code)] + pub async fn create_temp_file_from_bytes( + &self, + file_name: &str, + data: &[u8], + ) -> io::Result { + let temp_file_path = self.generate_temp_file_path_with_name(file_name); + let mut file = File::create(&temp_file_path).await?; + file.write_all(data).await?; + Ok(temp_file_path) + } + + /// Writes data to the specified temporary file. + #[allow(dead_code)] + pub async fn write_to_temp_file(&self, file_path: &Path, data: &[u8]) -> io::Result<()> { + let mut file = File::create(file_path).await?; + file.write_all(data).await?; + Ok(()) + } + + /// Reads data from the specified temporary file. + #[allow(dead_code)] + pub async fn read_from_temp_file(&self, file_path: &Path) -> io::Result> { + let mut file = File::open(file_path).await?; + let mut data = Vec::new(); + file.read_to_end(&mut data).await?; + Ok(data) + } + + /// Deletes the specified temporary file. + pub async fn delete_temp_file>(&self, file_path: T) -> io::Result<()> { + fs::remove_file(file_path).await?; + Ok(()) + } +} diff --git a/frontend/rust-lib/flowy-storage/src/lib.rs b/frontend/rust-lib/flowy-storage/src/lib.rs index b318b55cb5..ca0b5d05ce 100644 --- a/frontend/rust-lib/flowy-storage/src/lib.rs +++ b/frontend/rust-lib/flowy-storage/src/lib.rs @@ -1,154 +1,9 @@ -if_native! { - mod native; - pub use native::*; -} - -if_wasm! { - mod wasm; - pub use wasm::*; -} - -use bytes::Bytes; - -use flowy_error::FlowyError; -use lib_infra::future::FutureResult; -use lib_infra::{conditional_send_sync_trait, if_native, if_wasm}; -use mime::Mime; - -pub struct ObjectIdentity { - pub workspace_id: String, - pub file_id: String, - pub ext: String, -} - -#[derive(Clone)] -pub struct ObjectValue { - pub raw: Bytes, - pub mime: Mime, -} -conditional_send_sync_trait! { - "Provides a service for object storage. The trait includes methods for CRUD operations on storage objects."; - ObjectStorageService { - /// Creates a new storage object. - /// - /// # Parameters - /// - `url`: url of the object to be created. - /// - /// # Returns - /// - `Ok()` - /// - `Err(Error)`: An error occurred during the operation. - fn get_object_url(&self, object_id: ObjectIdentity) -> FutureResult; - - /// Creates a new storage object. - /// - /// # Parameters - /// - `url`: url of the object to be created. - /// - /// # Returns - /// - `Ok()` - /// - `Err(Error)`: An error occurred during the operation. - fn put_object(&self, url: String, object_value: ObjectValue) -> FutureResult<(), FlowyError>; - - /// Deletes a storage object by its URL. - /// - /// # Parameters - /// - `url`: url of the object to be deleted. - /// - /// # Returns - /// - `Ok()` - /// - `Err(Error)`: An error occurred during the operation. - fn delete_object(&self, url: String) -> FutureResult<(), FlowyError>; - - /// Fetches a storage object by its URL. - /// - /// # Parameters - /// - `url`: url of the object - /// - /// # Returns - /// - `Ok(File)`: The returned file object. - /// - `Err(Error)`: An error occurred during the operation. - fn get_object(&self, url: String) -> FutureResult; - } -} - -pub trait FileStoragePlan: Send + Sync + 'static { - fn storage_size(&self) -> FutureResult; - fn maximum_file_size(&self) -> FutureResult; - - fn check_upload_object(&self, object: &StorageObject) -> FutureResult<(), FlowyError>; -} - -pub struct StorageObject { - pub workspace_id: String, - pub file_name: String, - pub value: ObjectValueSupabase, -} - -pub enum ObjectValueSupabase { - File { file_path: String }, - Bytes { bytes: Bytes, mime: String }, -} - -impl ObjectValueSupabase { - pub fn mime_type(&self) -> String { - match self { - ObjectValueSupabase::File { file_path } => mime_guess::from_path(file_path) - .first_or_octet_stream() - .to_string(), - ObjectValueSupabase::Bytes { mime, .. } => mime.clone(), - } - } -} - -impl StorageObject { - /// Creates a `StorageObject` from a file. - /// - /// # Parameters - /// - /// * `name`: The name of the storage object. - /// * `file_path`: The file path to the storage object's data. - /// - pub fn from_file(workspace_id: &str, file_name: &str, file_path: T) -> Self { - Self { - workspace_id: workspace_id.to_string(), - file_name: file_name.to_string(), - value: ObjectValueSupabase::File { - file_path: file_path.to_string(), - }, - } - } - - /// Creates a `StorageObject` from bytes. - /// - /// # Parameters - /// - /// * `name`: The name of the storage object. - /// * `bytes`: The byte data of the storage object. - /// * `mime`: The MIME type of the storage object. - /// - pub fn from_bytes>( - workspace_id: &str, - file_name: &str, - bytes: B, - mime: String, - ) -> Self { - let bytes = bytes.into(); - Self { - workspace_id: workspace_id.to_string(), - file_name: file_name.to_string(), - value: ObjectValueSupabase::Bytes { bytes, mime }, - } - } - - /// Gets the file size of the `StorageObject`. - /// - /// # Returns - /// - /// The file size in bytes. - pub fn file_size(&self) -> u64 { - match &self.value { - ObjectValueSupabase::File { file_path } => std::fs::metadata(file_path).unwrap().len(), - ObjectValueSupabase::Bytes { bytes, .. } => bytes.len() as u64, - } - } -} +mod entities; +mod event_handler; +pub mod event_map; +mod file_cache; +pub mod manager; +mod notification; +mod protobuf; +pub mod sqlite_sql; +mod uploader; diff --git a/frontend/rust-lib/flowy-storage/src/manager.rs b/frontend/rust-lib/flowy-storage/src/manager.rs new file mode 100644 index 0000000000..0dd729b087 --- /dev/null +++ b/frontend/rust-lib/flowy-storage/src/manager.rs @@ -0,0 +1,895 @@ +use crate::entities::FileStatePB; +use crate::file_cache::FileTempStorage; +use crate::notification::{make_notification, StorageNotification}; +use crate::sqlite_sql::{ + batch_select_upload_file, delete_all_upload_parts, delete_upload_file, + delete_upload_file_by_file_id, insert_upload_file, insert_upload_part, is_upload_completed, + is_upload_exist, select_upload_file, select_upload_parts, update_upload_file_completed, + update_upload_file_upload_id, UploadFilePartTable, UploadFileTable, +}; +use crate::uploader::{FileUploader, FileUploaderRunner, Signal, UploadTask, UploadTaskQueue}; +use allo_isolate::Isolate; +use async_trait::async_trait; +use collab_importer::util::FileId; +use dashmap::DashMap; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_sqlite::DBConnection; +use flowy_storage_pub::chunked_byte::{calculate_offsets, ChunkedBytes, MIN_CHUNK_SIZE}; +use flowy_storage_pub::cloud::StorageCloudService; +use flowy_storage_pub::storage::{ + CompletedPartRequest, CreatedUpload, FileProgress, FileProgressReceiver, FileUploadState, + ProgressNotifier, StorageService, UploadPartResponse, +}; +use lib_infra::box_any::BoxAny; +use lib_infra::isolate_stream::{IsolateSink, SinkExt}; +use lib_infra::util::timestamp; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use tokio::io::AsyncWriteExt; +use tokio::sync::{broadcast, watch}; +use tracing::{debug, error, info, instrument, trace}; +use uuid::Uuid; + +pub trait StorageUserService: Send + Sync + 'static { + fn user_id(&self) -> Result; + fn workspace_id(&self) -> Result; + fn sqlite_connection(&self, uid: i64) -> Result; + fn get_application_root_dir(&self) -> &str; +} + +type GlobalNotifier = broadcast::Sender; +pub struct StorageManager { + pub storage_service: Arc, + cloud_service: Arc, + user_service: Arc, + uploader: Arc, + progress_notifiers: Arc>, + global_notifier: GlobalNotifier, +} + +impl Drop for StorageManager { + fn drop(&mut self) { + info!("[File] StorageManager is dropped"); + } +} + +impl StorageManager { + pub fn new( + cloud_service: Arc, + user_service: Arc, + ) -> Self { + let is_exceed_storage_limit = Arc::new(AtomicBool::new(false)); + let temp_storage_path = PathBuf::from(format!( + "{}/cache_files", + user_service.get_application_root_dir() + )); + let (global_notifier, _) = broadcast::channel(2000); + let temp_storage = Arc::new(FileTempStorage::new(temp_storage_path)); + let (notifier, notifier_rx) = watch::channel(Signal::Proceed); + let task_queue = Arc::new(UploadTaskQueue::new(notifier)); + let progress_notifiers = Arc::new(DashMap::new()); + let storage_service = Arc::new(StorageServiceImpl { + cloud_service: cloud_service.clone(), + user_service: user_service.clone(), + temp_storage, + task_queue: task_queue.clone(), + is_exceed_storage_limit: is_exceed_storage_limit.clone(), + progress_notifiers: progress_notifiers.clone(), + global_notifier: global_notifier.clone(), + }); + + let uploader = Arc::new(FileUploader::new( + storage_service.clone(), + task_queue, + is_exceed_storage_limit, + )); + tokio::spawn(FileUploaderRunner::run( + Arc::downgrade(&uploader), + notifier_rx, + )); + + let weak_uploader = Arc::downgrade(&uploader); + let cloned_user_service = user_service.clone(); + tokio::spawn(async move { + if let Some(uploader) = weak_uploader.upgrade() { + if let Err(err) = prepare_upload_task(uploader, cloned_user_service).await { + error!("prepare upload task failed: {}", err); + } + } + }); + + let mut rx = global_notifier.subscribe(); + let weak_notifier = Arc::downgrade(&progress_notifiers); + tokio::spawn(async move { + while let Ok(progress) = rx.recv().await { + if let Some(notifiers) = weak_notifier.upgrade() { + if let Some(mut notifier) = notifiers.get_mut(&progress.file_id) { + if progress.progress >= 1.0 { + let finish = FileUploadState::Finished { + file_id: progress.file_id, + }; + notifier.notify(finish).await; + } else { + let progress = FileUploadState::Uploading { + progress: progress.progress, + }; + notifier.notify(progress).await; + } + } + } else { + info!("progress notifiers is dropped"); + break; + } + } + }); + + Self { + storage_service, + cloud_service, + user_service, + uploader, + progress_notifiers, + global_notifier, + } + } + + pub async fn register_file_progress_stream(&self, port: i64) { + info!("register file progress stream: {}", port); + let mut sink = IsolateSink::new(Isolate::new(port)); + let mut rx = self.global_notifier.subscribe(); + tokio::spawn(async move { + while let Ok(progress) = rx.recv().await { + if let Ok(s) = serde_json::to_string(&progress) { + if let Err(err) = sink.send(s).await { + error!("[File]: send file progress failed: {}", err); + } + } + } + }); + } + + pub async fn query_file_state(&self, url: &str) -> Option { + let (workspace_id, parent_dir, file_id) = self.cloud_service.parse_object_url_v1(url).await?; + let current_workspace_id = self.user_service.workspace_id().ok()?; + if workspace_id != current_workspace_id { + return None; + } + + let uid = self.user_service.user_id().ok()?; + let mut conn = self.user_service.sqlite_connection(uid).ok()?; + let is_finish = + is_upload_completed(&mut conn, &workspace_id.to_string(), &parent_dir, &file_id).ok()?; + + if let Err(err) = self.global_notifier.send(FileProgress::new_progress( + url.to_string(), + file_id.clone(), + if is_finish { 1.0 } else { 0.0 }, + )) { + error!("[File] send global notifier failed: {}", err); + } + + Some(FileStatePB { file_id, is_finish }) + } + + pub async fn initialize(&self, workspace_id: &str) { + self.enable_storage_write_access(); + + if let Err(err) = prepare_upload_task(self.uploader.clone(), self.user_service.clone()).await { + error!("prepare {} upload task failed: {}", workspace_id, err); + } + } + + pub async fn initialize_after_open_workspace(&self, workspace_id: &Uuid) { + self.enable_storage_write_access(); + + if let Err(err) = prepare_upload_task(self.uploader.clone(), self.user_service.clone()).await { + error!("prepare {} upload task failed: {}", workspace_id, err); + } + } + + pub fn update_network_reachable(&self, reachable: bool) { + if reachable { + self.uploader.resume(); + } else { + self.uploader.pause(); + } + } + + pub fn disable_storage_write_access(&self) { + // when storage is purchased, resume the uploader + self.uploader.disable_storage_write(); + } + + pub fn enable_storage_write_access(&self) { + // when storage is purchased, resume the uploader + self.uploader.enable_storage_write(); + } + + pub async fn subscribe_file_state( + &self, + parent_dir: &str, + file_id: &str, + ) -> Result, FlowyError> { + self + .storage_service + .subscribe_file_progress(parent_dir, file_id) + .await + } + + /// Returns None if the file with given file_id is not exist + /// When delete a file, the progress notifier for given file_id will be deleted too + pub async fn get_file_state(&self, file_id: &str) -> Option { + self + .progress_notifiers + .get(file_id) + .and_then(|notifier| notifier.value().current_value.clone()) + } + + pub async fn get_all_tasks(&self) -> FlowyResult> { + let tasks = self.uploader.all_tasks().await; + Ok(tasks) + } +} + +async fn prepare_upload_task( + uploader: Arc, + user_service: Arc, +) -> FlowyResult<()> { + if let Ok(uid) = user_service.user_id() { + let workspace_id = user_service.workspace_id()?; + let conn = user_service.sqlite_connection(uid)?; + let upload_files = batch_select_upload_file(conn, &workspace_id.to_string(), 100, false)?; + let tasks = upload_files + .into_iter() + .map(|upload_file| UploadTask::BackgroundTask { + workspace_id: upload_file.workspace_id, + file_id: upload_file.file_id, + parent_dir: upload_file.parent_dir, + created_at: upload_file.created_at, + retry_count: 0, + }) + .collect::>(); + info!("[File] prepare upload task: {}", tasks.len()); + uploader.queue_tasks(tasks).await; + } + Ok(()) +} + +pub struct StorageServiceImpl { + cloud_service: Arc, + user_service: Arc, + temp_storage: Arc, + task_queue: Arc, + is_exceed_storage_limit: Arc, + progress_notifiers: Arc>, + global_notifier: GlobalNotifier, +} + +#[async_trait] +impl StorageService for StorageServiceImpl { + async fn delete_object(&self, url: String) -> FlowyResult<()> { + if let Some((workspace_id, parent_dir, file_id)) = + self.cloud_service.parse_object_url_v1(&url).await + { + info!( + "[File] delete object: workspace: {}, parent_dir: {}, file_id: {}", + workspace_id, parent_dir, file_id + ); + + self + .task_queue + .remove_task(&workspace_id.to_string(), &parent_dir, &file_id) + .await; + + trace!("[File] delete progress notifier: {}", file_id); + self.progress_notifiers.remove(&file_id); + match delete_upload_file_by_file_id( + self + .user_service + .sqlite_connection(self.user_service.user_id()?)?, + &workspace_id.to_string(), + &parent_dir, + &file_id, + ) { + Ok(Some(file)) => { + let file_path = file.local_file_path; + match tokio::fs::remove_file(&file_path).await { + Ok(_) => debug!("[File] deleted file from local disk: {}", file_path), + Err(err) => { + error!("[File] delete file at {} failed: {}", file_path, err); + }, + } + }, + Ok(None) => { + info!( + "[File]: can not find file record for url: {} when delete", + url + ); + }, + Err(err) => { + error!("[File] delete upload file failed: {}", err); + }, + } + } + + let _ = self.cloud_service.delete_object(&url).await; + Ok(()) + } + + fn download_object(&self, url: String, local_file_path: String) -> FlowyResult<()> { + let cloud_service = self.cloud_service.clone(); + tokio::spawn(async move { + if tokio::fs::metadata(&local_file_path).await.is_ok() { + tracing::warn!("file already exist in user local disk: {}", local_file_path); + return Ok(()); + } + let object_value = cloud_service.get_object(url).await?; + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&local_file_path) + .await?; + + match file.write(&object_value.raw).await { + Ok(n) => { + info!("downloaded {} bytes to file: {}", n, local_file_path); + }, + Err(err) => { + error!("write file failed: {}", err); + }, + } + Ok::<_, FlowyError>(()) + }); + Ok(()) + } + + async fn create_upload( + &self, + workspace_id: &str, + parent_dir: &str, + file_path: &str, + ) -> Result<(CreatedUpload, Option), FlowyError> { + if workspace_id.is_empty() { + return Err(FlowyError::internal().with_context("workspace id is empty")); + } + + if parent_dir.is_empty() { + return Err(FlowyError::internal().with_context("parent dir is empty")); + } + + if file_path.is_empty() { + return Err(FlowyError::internal().with_context("local file path is empty")); + } + + let workspace_id = workspace_id.to_string(); + let parent_dir = parent_dir.to_string(); + let file_path = file_path.to_string(); + + let is_exceed_limit = self + .is_exceed_storage_limit + .load(std::sync::atomic::Ordering::Relaxed); + if is_exceed_limit { + make_notification(StorageNotification::FileStorageLimitExceeded) + .payload(FlowyError::file_storage_limit()) + .send(); + + return Err(FlowyError::file_storage_limit()); + } + + let local_file_path = self + .temp_storage + .create_temp_file_from_existing(Path::new(&file_path)) + .await + .map_err(|err| { + error!("[File] create temp file failed: {}", err); + FlowyError::internal() + .with_context(format!("create temp file for upload file failed: {}", err)) + })?; + + // 1. create a file record and chunk the file + let record = create_upload_record(workspace_id, parent_dir, local_file_path.clone()).await?; + // 2. save the record to sqlite + let conn = self + .user_service + .sqlite_connection(self.user_service.user_id()?)?; + let workspace_id = Uuid::from_str(&record.workspace_id)?; + let url = self + .cloud_service + .get_object_url_v1(&workspace_id, &record.parent_dir, &record.file_id) + .await?; + let file_id = record.file_id.clone(); + match insert_upload_file(conn, &record) { + Ok(_) => { + // 3. generate url for given file + self + .task_queue + .queue_task(UploadTask::Task { + local_file_path, + record, + retry_count: 3, + }) + .await; + + let notifier = ProgressNotifier::new(file_id.to_string()); + let receiver = notifier.subscribe(); + trace!("[File] create upload progress notifier: {}", file_id); + self + .progress_notifiers + .insert(file_id.to_string(), notifier); + Ok::<_, FlowyError>((CreatedUpload { url, file_id }, Some(receiver))) + }, + Err(err) => { + if matches!(err.code, ErrorCode::DuplicateSqliteRecord) { + info!("[File] upload record already exists, skip creating new upload task"); + Ok::<_, FlowyError>((CreatedUpload { url, file_id }, None)) + } else { + Err(err) + } + }, + } + } + + async fn start_upload(&self, record: &BoxAny) -> Result<(), FlowyError> { + let file_record = record.downcast_ref::().ok_or_else(|| { + FlowyError::internal().with_context("failed to downcast record to UploadFileTable") + })?; + + // If the file is already uploaded, skip the upload process + if !is_upload_exist( + self + .user_service + .sqlite_connection(self.user_service.user_id()?)?, + &file_record.upload_id, + )? { + info!( + "[File] skip upload, {} was deleted", + file_record.local_file_path + ); + return Ok(()); + } + + start_upload(self, file_record).await?; + + Ok(()) + } + + async fn resume_upload( + &self, + workspace_id: &str, + parent_dir: &str, + file_id: &str, + ) -> Result<(), FlowyError> { + // Gathering the upload record and parts from the sqlite database. + let mut conn = self + .user_service + .sqlite_connection(self.user_service.user_id()?)?; + + if let Some(upload_file) = select_upload_file(&mut conn, workspace_id, parent_dir, file_id)? { + resume_upload(self, upload_file).await?; + } else { + error!( + "[File] resume upload failed: can not found {}:{}", + parent_dir, file_id + ); + } + Ok(()) + } + + async fn subscribe_file_progress( + &self, + parent_idr: &str, + file_id: &str, + ) -> Result, FlowyError> { + trace!("[File]: subscribe file progress: {}", file_id); + + let is_completed = { + let mut conn = self + .user_service + .sqlite_connection(self.user_service.user_id()?)?; + let workspace_id = self.user_service.workspace_id()?; + is_upload_completed(&mut conn, &workspace_id.to_string(), parent_idr, file_id) + .unwrap_or(false) + }; + + if is_completed { + return Ok(None); + } + + let notifier = self + .progress_notifiers + .entry(file_id.to_string()) + .or_insert_with(|| ProgressNotifier::new(file_id.to_string())); + Ok(Some(notifier.subscribe())) + } +} + +async fn create_upload_record( + workspace_id: String, + parent_dir: String, + local_file_path: String, +) -> FlowyResult { + let file_path = Path::new(&local_file_path); + let file = tokio::fs::File::open(&file_path).await?; + let metadata = file.metadata().await?; + let file_size = metadata.len() as usize; + + // Calculate the total number of chunks + let num_chunk = calculate_offsets(file_size, MIN_CHUNK_SIZE).len(); + let content_type = mime_guess::from_path(file_path) + .first_or_octet_stream() + .to_string(); + let file_id = FileId::from_path(&file_path.to_path_buf()).await?; + let record = UploadFileTable { + workspace_id, + file_id, + // When the upload_id is empty string, we will create a new upload using [Self::start_upload] method + upload_id: "".to_string(), + parent_dir, + local_file_path, + content_type, + chunk_size: MIN_CHUNK_SIZE as i32, + num_chunk: num_chunk as i32, + created_at: timestamp(), + is_finish: false, + }; + Ok(record) +} + +#[instrument(level = "debug", skip_all, err)] +async fn start_upload( + storage_service: &StorageServiceImpl, + upload_file: &UploadFileTable, +) -> FlowyResult<()> { + let temp_storage = &storage_service.temp_storage; + let user_service = &storage_service.user_service; + let global_notifier = storage_service.global_notifier.clone(); + let cloud_service = &storage_service.cloud_service; + + // 4. gather existing completed parts + let mut conn = user_service.sqlite_connection(user_service.user_id()?)?; + let mut completed_parts = select_upload_parts(&mut conn, &upload_file.upload_id) + .unwrap_or_default() + .into_iter() + .map(|part| CompletedPartRequest { + e_tag: part.e_tag, + part_number: part.part_num, + }) + .collect::>(); + let upload_offset = completed_parts.len() as u64; + + let file_path = Path::new(&upload_file.local_file_path); + if !file_path.exists() { + error!("[File] file not found: {}", upload_file.local_file_path); + if let Ok(uid) = user_service.user_id() { + if let Ok(conn) = user_service.sqlite_connection(uid) { + delete_upload_file(conn, &upload_file.upload_id)?; + } + } + } + let file_size = file_path + .metadata() + .map(|metadata| metadata.len()) + .unwrap_or(0); + + let mut chunked_bytes = + ChunkedBytes::from_file(&upload_file.local_file_path, MIN_CHUNK_SIZE).await?; + let total_parts = chunked_bytes.total_chunks(); + if let Err(err) = chunked_bytes.set_offset(upload_offset).await { + error!( + "[File] set offset failed: {} for file: {}", + err, upload_file.local_file_path + ); + if let Ok(uid) = user_service.user_id() { + if let Ok(conn) = user_service.sqlite_connection(uid) { + delete_upload_file(conn, &upload_file.upload_id)?; + } + } + } + + info!( + "[File] start upload: workspace: {}, parent_dir: {}, file_id: {}, chunk: {}", + upload_file.workspace_id, upload_file.parent_dir, upload_file.file_id, chunked_bytes, + ); + + let mut upload_file = upload_file.clone(); + // 1. create upload + trace!( + "[File] create upload for workspace: {}, parent_dir: {}, file_id: {}", + upload_file.workspace_id, + upload_file.parent_dir, + upload_file.file_id + ); + + let workspace_id = Uuid::from_str(&upload_file.workspace_id)?; + let create_upload_resp_result = cloud_service + .create_upload( + &workspace_id, + &upload_file.parent_dir, + &upload_file.file_id, + &upload_file.content_type, + file_size, + ) + .await; + + let file_url = cloud_service + .get_object_url_v1(&workspace_id, &upload_file.parent_dir, &upload_file.file_id) + .await?; + + if let Err(err) = create_upload_resp_result.as_ref() { + handle_upload_error(storage_service, err, &file_url).await; + } + let create_upload_resp = create_upload_resp_result?; + + // 2. update upload_id + let conn = user_service.sqlite_connection(user_service.user_id()?)?; + update_upload_file_upload_id( + conn, + &upload_file.workspace_id, + &upload_file.parent_dir, + &upload_file.file_id, + &create_upload_resp.upload_id, + )?; + + trace!( + "[File] {} update upload_id: {}", + upload_file.file_id, + create_upload_resp.upload_id + ); + upload_file.upload_id = create_upload_resp.upload_id; + + // 3. start uploading parts + info!( + "[File] {} start uploading parts:{}, offset:{}", + upload_file.file_id, + chunked_bytes.total_chunks(), + upload_offset, + ); + + let mut part_number = upload_offset + 1; + while let Some(chunk_result) = chunked_bytes.next_chunk().await { + match chunk_result { + Ok(chunk_bytes) => { + info!( + "[File] {} uploading {}th part, size:{}KB", + upload_file.file_id, + part_number, + chunk_bytes.len() / 1000, + ); + + // start uploading parts + match upload_part( + cloud_service, + user_service, + &workspace_id, + &upload_file.parent_dir, + &upload_file.upload_id, + &upload_file.file_id, + part_number as i32, + chunk_bytes.to_vec(), + ) + .await + { + Ok(resp) => { + trace!( + "[File] {} part {} uploaded", + upload_file.file_id, + part_number + ); + let mut progress_value = (part_number as f64 / total_parts as f64).clamp(0.0, 1.0); + // The 0.1 is reserved for the complete_upload progress + if progress_value >= 0.9 { + progress_value = 0.9; + } + let progress = FileProgress::new_progress( + file_url.clone(), + upload_file.file_id.clone(), + progress_value, + ); + trace!("[File] upload progress: {}", progress); + + if let Err(err) = global_notifier.send(progress) { + error!("[File] send global notifier failed: {}", err); + } + + // gather completed part + completed_parts.push(CompletedPartRequest { + e_tag: resp.e_tag, + part_number: resp.part_num, + }); + }, + Err(err) => { + error!( + "[File] {} failed to upload part: {}", + upload_file.file_id, err + ); + handle_upload_error(storage_service, &err, &file_url).await; + if let Err(err) = global_notifier.send(FileProgress::new_error( + file_url.clone(), + upload_file.file_id.clone(), + err.msg.clone(), + )) { + error!("[File] send global notifier failed: {}", err); + } + return Err(err); + }, + } + part_number += 1; // Increment part number + }, + Err(e) => { + error!( + "[File] {} failed to read chunk: {:?}", + upload_file.file_id, e + ); + break; + }, + } + } + + // mark it as completed + let complete_upload_result = complete_upload( + cloud_service, + user_service, + temp_storage, + &upload_file, + completed_parts, + &global_notifier, + ) + .await; + if let Err(err) = complete_upload_result { + handle_upload_error(storage_service, &err, &file_url).await; + return Err(err); + } + + Ok(()) +} + +async fn handle_upload_error( + storage_service: &StorageServiceImpl, + err: &FlowyError, + file_url: &str, +) { + if err.is_file_limit_exceeded() { + make_notification(StorageNotification::FileStorageLimitExceeded) + .payload(err.clone()) + .send(); + } + + if err.is_single_file_limit_exceeded() { + info!("[File] file exceed limit:{}", file_url); + if let Err(err) = storage_service.delete_object(file_url.to_string()).await { + error!("[File] delete upload file:{} error:{}", file_url, err); + } + + make_notification(StorageNotification::SingleFileLimitExceeded) + .payload(err.clone()) + .send(); + } +} + +#[instrument(level = "debug", skip_all, err)] +async fn resume_upload( + storage_service: &StorageServiceImpl, + upload_file: UploadFileTable, +) -> FlowyResult<()> { + trace!( + "[File] resume upload for workspace: {}, parent_dir: {}, file_id: {}, local_file_path:{}", + upload_file.workspace_id, + upload_file.parent_dir, + upload_file.file_id, + upload_file.local_file_path + ); + + start_upload(storage_service, &upload_file).await?; + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +#[instrument(level = "debug", skip_all)] +async fn upload_part( + cloud_service: &Arc, + user_service: &Arc, + workspace_id: &Uuid, + parent_dir: &str, + upload_id: &str, + file_id: &str, + part_number: i32, + body: Vec, +) -> Result { + let resp = cloud_service + .upload_part( + workspace_id, + parent_dir, + upload_id, + file_id, + part_number, + body, + ) + .await?; + + // save uploaded part to sqlite + let conn = user_service.sqlite_connection(user_service.user_id()?)?; + insert_upload_part( + conn, + &UploadFilePartTable { + upload_id: upload_id.to_string(), + e_tag: resp.e_tag.clone(), + part_num: resp.part_num, + }, + )?; + + Ok(resp) +} + +async fn complete_upload( + cloud_service: &Arc, + user_service: &Arc, + temp_storage: &Arc, + upload_file: &UploadFileTable, + parts: Vec, + global_notifier: &GlobalNotifier, +) -> Result<(), FlowyError> { + let workspace_id = Uuid::from_str(&upload_file.workspace_id)?; + let file_url = cloud_service + .get_object_url_v1(&workspace_id, &upload_file.parent_dir, &upload_file.file_id) + .await?; + + info!( + "[File]: completing file upload: {}, num parts: {}, url:{}", + upload_file.file_id, + parts.len(), + file_url + ); + match cloud_service + .complete_upload( + &workspace_id, + &upload_file.parent_dir, + &upload_file.upload_id, + &upload_file.file_id, + parts, + ) + .await + { + Ok(_) => { + info!("[File] completed upload file: {}", upload_file.file_id); + let progress = FileProgress::new_progress(file_url, upload_file.file_id.clone(), 1.0); + info!( + "[File]: notify upload progress:{}, {}", + upload_file.file_id, progress + ); + + if let Err(err) = global_notifier.send(progress) { + error!("[File] send global notifier failed: {}", err); + } + + let conn = user_service.sqlite_connection(user_service.user_id()?)?; + update_upload_file_completed(conn, &upload_file.upload_id)?; + + if let Err(err) = temp_storage + .delete_temp_file(&upload_file.local_file_path) + .await + { + trace!("[File] delete temp file failed: {}", err); + } + }, + Err(err) => { + error!("[File] complete upload failed: {}", err); + + let progress = + FileProgress::new_error(file_url, upload_file.file_id.clone(), err.msg.clone()); + if let Err(send_err) = global_notifier.send(progress) { + error!("[File] send global notifier failed: {}", send_err); + } + + let mut conn = user_service.sqlite_connection(user_service.user_id()?)?; + if let Err(err) = delete_all_upload_parts(&mut conn, &upload_file.upload_id) { + error!("[File] delete all upload parts failed: {}", err); + } + return Err(err); + }, + } + Ok(()) +} diff --git a/frontend/rust-lib/flowy-storage/src/native/mod.rs b/frontend/rust-lib/flowy-storage/src/native/mod.rs deleted file mode 100644 index 777a8b08dc..0000000000 --- a/frontend/rust-lib/flowy-storage/src/native/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -use crate::{ObjectIdentity, ObjectValue}; -use flowy_error::FlowyError; -use std::path::Path; -use tokio::io::AsyncReadExt; -use tracing::info; - -pub async fn object_from_disk( - workspace_id: &str, - local_file_path: &str, -) -> Result<(ObjectIdentity, ObjectValue), FlowyError> { - let ext = Path::new(local_file_path) - .extension() - .and_then(std::ffi::OsStr::to_str) - .unwrap_or("") - .to_owned(); - let mut file = tokio::fs::File::open(local_file_path).await?; - let mut content = Vec::new(); - let n = file.read_to_end(&mut content).await?; - info!("read {} bytes from file: {}", n, local_file_path); - let mime = mime_guess::from_path(local_file_path).first_or_octet_stream(); - let hash = fxhash::hash(&content); - - Ok(( - ObjectIdentity { - workspace_id: workspace_id.to_owned(), - file_id: hash.to_string(), - ext, - }, - ObjectValue { - raw: content.into(), - mime, - }, - )) -} diff --git a/frontend/rust-lib/flowy-storage/src/notification.rs b/frontend/rust-lib/flowy-storage/src/notification.rs new file mode 100644 index 0000000000..86af2d222c --- /dev/null +++ b/frontend/rust-lib/flowy-storage/src/notification.rs @@ -0,0 +1,23 @@ +use flowy_derive::ProtoBuf_Enum; +use flowy_notification::NotificationBuilder; + +const OBSERVABLE_SOURCE: &str = "storage"; + +#[derive(ProtoBuf_Enum, Debug, Default)] +pub(crate) enum StorageNotification { + #[default] + FileStorageLimitExceeded = 0, + + SingleFileLimitExceeded = 1, +} + +impl std::convert::From for i32 { + fn from(notification: StorageNotification) -> Self { + notification as i32 + } +} + +#[tracing::instrument(level = "trace")] +pub(crate) fn make_notification(ty: StorageNotification) -> NotificationBuilder { + NotificationBuilder::new("appflowy_file_storage_notification", ty, OBSERVABLE_SOURCE) +} diff --git a/frontend/rust-lib/flowy-storage/src/sqlite_sql.rs b/frontend/rust-lib/flowy-storage/src/sqlite_sql.rs new file mode 100644 index 0000000000..36e24b7ef9 --- /dev/null +++ b/frontend/rust-lib/flowy-storage/src/sqlite_sql.rs @@ -0,0 +1,258 @@ +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::result::DatabaseErrorKind; +use flowy_sqlite::result::Error::DatabaseError; +use flowy_sqlite::schema::{upload_file_part, upload_file_table}; +use flowy_sqlite::{ + diesel, AsChangeset, BoolExpressionMethods, DBConnection, ExpressionMethods, Identifiable, + Insertable, OptionalExtension, QueryDsl, Queryable, RunQueryDsl, SqliteConnection, +}; +use tracing::{trace, warn}; + +#[derive(Queryable, Insertable, AsChangeset, Identifiable, Debug, Clone)] +#[diesel(table_name = upload_file_table)] +#[diesel(primary_key(workspace_id, parent_dir, file_id))] +pub struct UploadFileTable { + pub workspace_id: String, + pub file_id: String, + pub parent_dir: String, + pub local_file_path: String, + pub content_type: String, + pub chunk_size: i32, + pub num_chunk: i32, + pub upload_id: String, + pub created_at: i64, + pub is_finish: bool, +} + +#[derive(Queryable, Insertable, AsChangeset, Identifiable, Debug)] +#[diesel(table_name = upload_file_part)] +#[diesel(primary_key(upload_id, part_num))] +pub struct UploadFilePartTable { + pub upload_id: String, + pub e_tag: String, + pub part_num: i32, +} + +pub fn is_upload_file_exist( + conn: &mut SqliteConnection, + workspace_id: &str, + parent_dir: &str, + file_id: &str, +) -> FlowyResult { + let result = upload_file_table::dsl::upload_file_table + .filter( + upload_file_table::workspace_id + .eq(workspace_id) + .and(upload_file_table::parent_dir.eq(parent_dir)) + .and(upload_file_table::file_id.eq(file_id)), + ) + .first::(conn) + .optional()?; + Ok(result.is_some()) +} + +pub fn insert_upload_file( + mut conn: DBConnection, + upload_file: &UploadFileTable, +) -> FlowyResult<()> { + trace!("[File]: insert upload file: {:?}", upload_file); + match diesel::insert_into(upload_file_table::table) + .values(upload_file) + .execute(&mut *conn) + { + Ok(_) => Ok(()), + Err(DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => Err(FlowyError::new( + flowy_error::ErrorCode::DuplicateSqliteRecord, + "Upload file already exists", + )), + Err(e) => Err(e.into()), + } +} + +pub fn update_upload_file_upload_id( + mut conn: DBConnection, + workspace_id: &str, + parent_dir: &str, + file_id: &str, + upload_id: &str, +) -> FlowyResult<()> { + diesel::update( + upload_file_table::dsl::upload_file_table.filter( + upload_file_table::workspace_id + .eq(workspace_id) + .and(upload_file_table::parent_dir.eq(parent_dir)) + .and(upload_file_table::file_id.eq(file_id)), + ), + ) + .set(upload_file_table::upload_id.eq(upload_id)) + .execute(&mut *conn)?; + Ok(()) +} + +pub fn update_upload_file_completed(mut conn: DBConnection, upload_id: &str) -> FlowyResult<()> { + diesel::update( + upload_file_table::dsl::upload_file_table.filter(upload_file_table::upload_id.eq(upload_id)), + ) + .set(upload_file_table::is_finish.eq(true)) + .execute(&mut *conn)?; + Ok(()) +} + +pub fn is_upload_exist(mut conn: DBConnection, upload_id: &str) -> FlowyResult { + let result = upload_file_table::dsl::upload_file_table + .filter(upload_file_table::upload_id.eq(upload_id)) + .first::(&mut *conn) + .optional()?; + Ok(result.is_some()) +} + +pub fn is_upload_completed( + conn: &mut SqliteConnection, + workspace_id: &str, + parent_dir: &str, + file_id: &str, +) -> FlowyResult { + let result = upload_file_table::dsl::upload_file_table + .filter( + upload_file_table::workspace_id + .eq(workspace_id) + .and(upload_file_table::parent_dir.eq(parent_dir)) + .and(upload_file_table::file_id.eq(file_id)) + .and(upload_file_table::is_finish.eq(true)), + ) + .first::(conn) + .optional()?; + Ok(result.is_some()) +} + +/// Delete upload file and its parts +pub fn delete_upload_file(mut conn: DBConnection, upload_id: &str) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + _delete_upload_file(upload_id, conn)?; + Ok::<_, FlowyError>(()) + })?; + Ok(()) +} + +pub fn delete_upload_file_by_file_id( + mut conn: DBConnection, + workspace_id: &str, + parent_dir: &str, + file_id: &str, +) -> FlowyResult> { + let file = conn.immediate_transaction(|conn| { + let file = select_upload_file(&mut *conn, workspace_id, parent_dir, file_id)?; + + if let Some(file) = &file { + // when the upload is not started, the upload id will be empty + if file.upload_id.is_empty() { + diesel::delete( + upload_file_table::dsl::upload_file_table.filter( + upload_file_table::workspace_id + .eq(workspace_id) + .and(upload_file_table::parent_dir.eq(parent_dir)) + .and(upload_file_table::file_id.eq(file_id)), + ), + ) + .execute(&mut *conn)?; + } else { + _delete_upload_file(&file.upload_id, &mut *conn)?; + } + } + + Ok::<_, FlowyError>(file) + })?; + + Ok(file) +} + +fn _delete_upload_file(upload_id: &str, conn: &mut SqliteConnection) -> Result<(), FlowyError> { + if upload_id.is_empty() { + warn!("[File]: upload_id is empty when delete upload file"); + return Ok(()); + } + + trace!("[File]: delete upload file: {}", upload_id); + diesel::delete( + upload_file_table::dsl::upload_file_table.filter(upload_file_table::upload_id.eq(upload_id)), + ) + .execute(&mut *conn)?; + if let Err(err) = delete_all_upload_parts(&mut *conn, upload_id) { + warn!("Failed to delete upload parts: {:?}", err) + } + Ok(()) +} + +pub fn delete_all_upload_parts(conn: &mut SqliteConnection, upload_id: &str) -> FlowyResult<()> { + diesel::delete( + upload_file_part::dsl::upload_file_part.filter(upload_file_part::upload_id.eq(upload_id)), + ) + .execute(&mut *conn)?; + Ok(()) +} + +pub fn insert_upload_part( + mut conn: DBConnection, + upload_part: &UploadFilePartTable, +) -> FlowyResult<()> { + diesel::insert_into(upload_file_part::table) + .values(upload_part) + .execute(&mut *conn)?; + Ok(()) +} + +pub fn select_latest_upload_part( + mut conn: DBConnection, + upload_id: &str, +) -> FlowyResult> { + let result = upload_file_part::dsl::upload_file_part + .filter(upload_file_part::upload_id.eq(upload_id)) + .order(upload_file_part::part_num.desc()) + .first::(&mut *conn) + .optional()?; + Ok(result) +} + +pub fn select_upload_parts( + conn: &mut SqliteConnection, + upload_id: &str, +) -> FlowyResult> { + let results = upload_file_part::dsl::upload_file_part + .filter(upload_file_part::upload_id.eq(upload_id)) + .load::(conn)?; + Ok(results) +} + +pub fn batch_select_upload_file( + mut conn: DBConnection, + workspace_id: &str, + limit: i32, + is_finish: bool, +) -> FlowyResult> { + let results = upload_file_table::dsl::upload_file_table + .filter(upload_file_table::workspace_id.eq(workspace_id)) + .filter(upload_file_table::is_finish.eq(is_finish)) + .order(upload_file_table::created_at.desc()) + .limit(limit.into()) + .load::(&mut *conn)?; + + Ok(results) +} + +pub fn select_upload_file( + conn: &mut SqliteConnection, + workspace_id: &str, + parent_dir: &str, + file_id: &str, +) -> FlowyResult> { + let result = upload_file_table::dsl::upload_file_table + .filter( + upload_file_table::workspace_id + .eq(workspace_id) + .and(upload_file_table::parent_dir.eq(parent_dir)) + .and(upload_file_table::file_id.eq(file_id)), + ) + .first::(conn) + .optional()?; + Ok(result) +} diff --git a/frontend/rust-lib/flowy-storage/src/uploader.rs b/frontend/rust-lib/flowy-storage/src/uploader.rs new file mode 100644 index 0000000000..a230b3de83 --- /dev/null +++ b/frontend/rust-lib/flowy-storage/src/uploader.rs @@ -0,0 +1,380 @@ +use crate::sqlite_sql::UploadFileTable; +use crate::uploader::UploadTask::BackgroundTask; +use flowy_storage_pub::storage::StorageService; +use lib_infra::box_any::BoxAny; +use std::cmp::Ordering; +use std::collections::BinaryHeap; +use std::fmt::Display; +use std::sync::atomic::{AtomicBool, AtomicU8}; +use std::sync::{Arc, Weak}; +use std::time::Duration; +use tokio::sync::{watch, RwLock}; +use tracing::{error, info, instrument, trace, warn}; + +#[derive(Clone)] +pub enum Signal { + Stop, + Proceed, + ProceedAfterSecs(u64), +} + +pub struct UploadTaskQueue { + tasks: RwLock>, + notifier: watch::Sender, +} + +impl UploadTaskQueue { + pub fn new(notifier: watch::Sender) -> Self { + Self { + tasks: Default::default(), + notifier, + } + } + pub async fn queue_task(&self, task: UploadTask) { + trace!("[File] Queued task: {}", task); + self.tasks.write().await.push(task); + let _ = self.notifier.send_replace(Signal::Proceed); + } + + pub async fn remove_task(&self, workspace_id: &str, parent_dir: &str, file_id: &str) { + let mut tasks = self.tasks.write().await; + + tasks.retain(|task| match task { + UploadTask::BackgroundTask { + workspace_id: w_id, + parent_dir: p_dir, + file_id: f_id, + .. + } => !(w_id == workspace_id && p_dir == parent_dir && f_id == file_id), + UploadTask::Task { record, .. } => { + !(record.workspace_id == workspace_id + && record.parent_dir == parent_dir + && record.file_id == file_id) + }, + }); + } +} + +pub struct FileUploader { + storage_service: Arc, + queue: Arc, + max_uploads: u8, + current_uploads: AtomicU8, + pause_sync: AtomicBool, + disable_upload: Arc, +} + +impl Drop for FileUploader { + fn drop(&mut self) { + let _ = self.queue.notifier.send(Signal::Stop); + } +} + +impl FileUploader { + pub fn new( + storage_service: Arc, + queue: Arc, + is_exceed_limit: Arc, + ) -> Self { + Self { + storage_service, + queue, + max_uploads: 3, + current_uploads: Default::default(), + pause_sync: Default::default(), + disable_upload: is_exceed_limit, + } + } + + pub async fn all_tasks(&self) -> Vec { + let tasks = self.queue.tasks.read().await; + tasks.iter().cloned().collect() + } + + pub async fn queue_tasks(&self, tasks: Vec) { + let mut queue_lock = self.queue.tasks.write().await; + for task in tasks { + queue_lock.push(task); + } + let _ = self.queue.notifier.send(Signal::Proceed); + } + + pub fn pause(&self) { + self + .pause_sync + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + pub fn disable_storage_write(&self) { + self + .disable_upload + .store(true, std::sync::atomic::Ordering::SeqCst); + self.pause(); + } + + pub fn enable_storage_write(&self) { + self + .disable_upload + .store(false, std::sync::atomic::Ordering::SeqCst); + self.resume(); + } + + pub fn resume(&self) { + self + .pause_sync + .store(false, std::sync::atomic::Ordering::SeqCst); + trace!("[File] Uploader resumed"); + let _ = self.queue.notifier.send(Signal::ProceedAfterSecs(3)); + } + + #[instrument(name = "[File]: process next", level = "debug", skip(self))] + pub async fn process_next(&self) -> Option<()> { + // Do not proceed if the uploader is paused. + if self.pause_sync.load(std::sync::atomic::Ordering::Relaxed) { + info!("[File] Uploader is paused"); + return None; + } + + let current_uploads = self + .current_uploads + .load(std::sync::atomic::Ordering::SeqCst); + if current_uploads > 0 { + trace!("[File] current upload tasks: {}", current_uploads) + } + + if self + .current_uploads + .load(std::sync::atomic::Ordering::SeqCst) + >= self.max_uploads + { + // If the current uploads count is greater than or equal to the max uploads, do not proceed. + let _ = self.queue.notifier.send(Signal::ProceedAfterSecs(10)); + trace!("[File] max uploads reached, process_next after 10 seconds"); + return None; + } + + if self + .disable_upload + .load(std::sync::atomic::Ordering::SeqCst) + { + // If the storage limitation is enabled, do not proceed. + error!("[File] storage limit exceeded, uploader is disabled"); + return None; + } + + let task = self.queue.tasks.write().await.pop()?; + if task.retry_count() > 5 { + // If the task has been retried more than 5 times, we should not retry it anymore. + let _ = self.queue.notifier.send(Signal::ProceedAfterSecs(2)); + warn!("[File] Task has been retried more than 5 times: {}", task); + return None; + } + + // increment the current uploads count + self + .current_uploads + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + match task { + UploadTask::Task { + local_file_path, + record, + mut retry_count, + } => { + let record = BoxAny::new(record); + if let Err(err) = self.storage_service.start_upload(&record).await { + if err.is_file_limit_exceeded() { + self.disable_storage_write(); + } + + if err.should_retry_upload() { + info!( + "[File] Failed to upload file: {}, retry_count:{}", + err, retry_count + ); + let record = record.unbox_or_error().unwrap(); + retry_count += 1; + self.queue.tasks.write().await.push(UploadTask::Task { + local_file_path, + record, + retry_count, + }); + } + } + }, + UploadTask::BackgroundTask { + workspace_id, + parent_dir, + file_id, + created_at, + mut retry_count, + } => { + if let Err(err) = self + .storage_service + .resume_upload(&workspace_id, &parent_dir, &file_id) + .await + { + if err.is_file_limit_exceeded() { + error!("[File] failed to upload file: {}", err); + self.disable_storage_write(); + } + + if err.should_retry_upload() { + info!( + "[File] failed to resume upload file: {}, retry_count:{}", + err, retry_count + ); + retry_count += 1; + self.queue.tasks.write().await.push(BackgroundTask { + workspace_id, + parent_dir, + file_id, + created_at, + retry_count, + }); + } + } + }, + } + + self + .current_uploads + .fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + trace!("[File] process_next after 2 seconds"); + self + .queue + .notifier + .send_replace(Signal::ProceedAfterSecs(2)); + None + } +} + +pub struct FileUploaderRunner; + +impl FileUploaderRunner { + pub async fn run(weak_uploader: Weak, mut notifier: watch::Receiver) { + // Start uploading after 20 seconds + tokio::time::sleep(Duration::from_secs(20)).await; + + loop { + // stops the runner if the notifier was closed. + if notifier.changed().await.is_err() { + info!("[File]:Uploader runner stopped, notifier closed"); + break; + } + + if let Some(uploader) = weak_uploader.upgrade() { + let value = notifier.borrow().clone(); + trace!( + "[File]: Uploader runner received signal, thread_id: {:?}", + std::thread::current().id() + ); + match value { + Signal::Stop => { + info!("[File]:Uploader runner stopped, stop signal received"); + break; + }, + Signal::Proceed => { + tokio::spawn(async move { + uploader.process_next().await; + }); + }, + Signal::ProceedAfterSecs(secs) => { + tokio::time::sleep(Duration::from_secs(secs)).await; + tokio::spawn(async move { + uploader.process_next().await; + }); + }, + } + } else { + info!("[File]:Uploader runner stopped, uploader dropped"); + break; + } + } + } +} + +#[derive(Clone)] +pub enum UploadTask { + Task { + local_file_path: String, + record: UploadFileTable, + retry_count: u8, + }, + BackgroundTask { + workspace_id: String, + file_id: String, + parent_dir: String, + created_at: i64, + retry_count: u8, + }, +} + +impl UploadTask { + pub fn retry_count(&self) -> u8 { + match self { + UploadTask::Task { retry_count, .. } => *retry_count, + UploadTask::BackgroundTask { retry_count, .. } => *retry_count, + } + } +} + +impl Display for UploadTask { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UploadTask::Task { record, .. } => write!(f, "Task: {}", record.file_id), + UploadTask::BackgroundTask { file_id, .. } => write!(f, "BackgroundTask: {}", file_id), + } + } +} + +impl Eq for UploadTask {} + +impl PartialEq for UploadTask { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Task { record: lhs, .. }, Self::Task { record: rhs, .. }) => { + lhs.local_file_path == rhs.local_file_path + }, + ( + Self::BackgroundTask { + workspace_id: l_workspace_id, + file_id: l_file_id, + .. + }, + Self::BackgroundTask { + workspace_id: r_workspace_id, + file_id: r_file_id, + .. + }, + ) => l_workspace_id == r_workspace_id && l_file_id == r_file_id, + _ => false, + } + } +} + +impl PartialOrd for UploadTask { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for UploadTask { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (Self::Task { record: lhs, .. }, Self::Task { record: rhs, .. }) => { + lhs.created_at.cmp(&rhs.created_at) + }, + (_, Self::Task { .. }) => Ordering::Less, + (Self::Task { .. }, _) => Ordering::Greater, + ( + Self::BackgroundTask { + created_at: lhs, .. + }, + Self::BackgroundTask { + created_at: rhs, .. + }, + ) => lhs.cmp(rhs), + } + } +} diff --git a/frontend/rust-lib/flowy-storage/src/wasm/mod.rs b/frontend/rust-lib/flowy-storage/src/wasm/mod.rs deleted file mode 100644 index 8d4d3b1bfc..0000000000 --- a/frontend/rust-lib/flowy-storage/src/wasm/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -use crate::{ObjectIdentity, ObjectValue}; -use flowy_error::FlowyError; - -pub async fn object_from_disk( - _workspace_id: &str, - _local_file_path: &str, -) -> Result<(ObjectIdentity, ObjectValue), FlowyError> { - Err( - FlowyError::not_support() - .with_context(format!("object_from_disk is not implemented for wasm32")), - ) -} diff --git a/frontend/rust-lib/flowy-storage/tests/multiple_part_upload_test.rs b/frontend/rust-lib/flowy-storage/tests/multiple_part_upload_test.rs new file mode 100644 index 0000000000..4fddb78458 --- /dev/null +++ b/frontend/rust-lib/flowy-storage/tests/multiple_part_upload_test.rs @@ -0,0 +1,190 @@ +use collab_importer::util::FileId; +use flowy_sqlite::Database; +use flowy_storage::sqlite_sql::{ + batch_select_upload_file, delete_upload_file, insert_upload_file, insert_upload_part, + select_latest_upload_part, select_upload_parts, UploadFilePartTable, UploadFileTable, +}; +use flowy_storage_pub::chunked_byte::{ChunkedBytes, MIN_CHUNK_SIZE}; +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; +use std::env::temp_dir; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use std::time::Duration; + +pub fn test_database() -> (Database, PathBuf) { + let db_path = temp_dir().join(format!("test-{}.db", generate_random_string(8))); + (flowy_sqlite::init(&db_path).unwrap(), db_path) +} + +#[tokio::test] +async fn test_insert_new_upload() { + let (db, _) = test_database(); + + let workspace_id = uuid::Uuid::new_v4().to_string(); + + // test insert one upload file record + let mut upload_ids = vec![]; + for _i in 0..5 { + let upload_id = uuid::Uuid::new_v4().to_string(); + let local_file_path = create_temp_file_with_random_content(8 * 1024 * 1024).unwrap(); + let upload_file = + create_upload_file_record(workspace_id.clone(), upload_id.clone(), local_file_path).await; + upload_ids.push(upload_file.upload_id.clone()); + + // insert + let conn = db.get_connection().unwrap(); + insert_upload_file(conn, &upload_file).unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + } + upload_ids.reverse(); + + // select + let conn = db.get_connection().unwrap(); + let records = batch_select_upload_file(conn, &workspace_id, 100, false).unwrap(); + + assert_eq!(records.len(), 5); + // compare the upload id order is the same as upload_ids + for i in 0..5 { + assert_eq!(records[i].upload_id, upload_ids[i]); + + // delete + let conn = db.get_connection().unwrap(); + delete_upload_file(conn, &records[i].upload_id).unwrap(); + } + + let conn = db.get_connection().unwrap(); + let records = batch_select_upload_file(conn, &workspace_id, 100, false).unwrap(); + assert!(records.is_empty()); +} + +#[tokio::test] +async fn test_upload_part_test() { + let (db, _) = test_database(); + + let workspace_id = uuid::Uuid::new_v4().to_string(); + + // test insert one upload file record + let upload_id = uuid::Uuid::new_v4().to_string(); + let local_file_path = create_temp_file_with_random_content(20 * 1024 * 1024).unwrap(); + let upload_file = + create_upload_file_record(workspace_id.clone(), upload_id.clone(), local_file_path).await; + + // insert + let conn = db.get_connection().unwrap(); + insert_upload_file(conn, &upload_file).unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + + // insert uploaded part 1 + let part = UploadFilePartTable { + upload_id: upload_id.clone(), + e_tag: "1".to_string(), + part_num: 1, + }; + let conn = db.get_connection().unwrap(); + insert_upload_part(conn, &part).unwrap(); + + // insert uploaded part 2 + let part = UploadFilePartTable { + upload_id: upload_id.clone(), + e_tag: "2".to_string(), + part_num: 2, + }; + let conn = db.get_connection().unwrap(); + insert_upload_part(conn, &part).unwrap(); + + // get latest part + let conn = db.get_connection().unwrap(); + let part = select_latest_upload_part(conn, &upload_id) + .unwrap() + .unwrap(); + assert_eq!(part.part_num, 2); + + // get all existing parts + let mut conn = db.get_connection().unwrap(); + let parts = select_upload_parts(&mut conn, &upload_id).unwrap(); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0].part_num, 1); + assert_eq!(parts[1].part_num, 2); + + // delete upload file and then all existing parts will be deleted + let conn = db.get_connection().unwrap(); + delete_upload_file(conn, &upload_id).unwrap(); + + let mut conn = db.get_connection().unwrap(); + let parts = select_upload_parts(&mut conn, &upload_id).unwrap(); + assert!(parts.is_empty()) +} + +pub fn generate_random_string(len: usize) -> String { + let rng = thread_rng(); + rng + .sample_iter(&Alphanumeric) + .take(len) + .map(char::from) + .collect() +} + +fn create_temp_file_with_random_content( + size_in_bytes: usize, +) -> Result> { + // Generate a random string of the specified size + let content: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(size_in_bytes) + .map(char::from) + .collect(); + + // Create a temporary file path + let file_path = std::env::temp_dir().join("test.txt"); + + // Write the content to the temporary file + let mut file = File::create(&file_path)?; + file.write_all(content.as_bytes())?; + + // Return the file path + Ok(file_path.to_str().unwrap().to_string()) +} + +pub async fn create_upload_file_record( + workspace_id: String, + upload_id: String, + local_file_path: String, +) -> UploadFileTable { + // Create ChunkedBytes from file + let chunked_bytes = ChunkedBytes::from_file(&local_file_path, MIN_CHUNK_SIZE) + .await + .unwrap(); + + // Determine content type + let content_type = mime_guess::from_path(&local_file_path) + .first_or_octet_stream() + .to_string(); + + // let mut file_path = temp_dir(); + // file_path.push("test_large_file_with_many_chunks"); + // let mut file = File::create(&file_path).await.unwrap(); + // file.write_all(&vec![0; 50 * 1024 * 1024]).await.unwrap(); // 50 MB + // file.flush().await.unwrap(); + + // Calculate file ID + let file_id = FileId::from_path(&PathBuf::from(&local_file_path)) + .await + .unwrap(); + let num_chunk = chunked_bytes.total_chunks(); + + // Create UploadFileTable record + UploadFileTable { + workspace_id, + file_id, + upload_id, + parent_dir: "test".to_string(), + local_file_path, + content_type, + chunk_size: MIN_CHUNK_SIZE as i32, + num_chunk: num_chunk as i32, + created_at: chrono::Utc::now().timestamp(), + is_finish: false, + } +} diff --git a/frontend/rust-lib/flowy-user-pub/Cargo.toml b/frontend/rust-lib/flowy-user-pub/Cargo.toml index f70e53c248..f8a673e918 100644 --- a/frontend/rust-lib/flowy-user-pub/Cargo.toml +++ b/frontend/rust-lib/flowy-user-pub/Cargo.toml @@ -15,9 +15,11 @@ collab-entity = { workspace = true } serde_json.workspace = true serde_repr.workspace = true chrono = { workspace = true, default-features = false, features = ["clock", "serde"] } -anyhow.workspace = true tokio = { workspace = true, features = ["sync"] } tokio-stream = "0.1.14" flowy-folder-pub.workspace = true +collab-folder = { workspace = true } tracing.workspace = true base64 = "0.21" +client-api = { workspace = true } +flowy-sqlite.workspace = true \ No newline at end of file diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index e789dd8c1a..9b223178ff 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -1,8 +1,15 @@ +use client_api::entity::billing_dto::RecurringInterval; +use client_api::entity::billing_dto::SubscriptionPlan; +use client_api::entity::billing_dto::SubscriptionPlanDetail; +pub use client_api::entity::billing_dto::SubscriptionStatus; +use client_api::entity::billing_dto::WorkspaceSubscriptionStatus; +use client_api::entity::billing_dto::WorkspaceUsageAndLimit; +use client_api::entity::GotrueTokenResponse; +pub use client_api::entity::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; use collab_entity::{CollabObject, CollabType}; use flowy_error::{internal_error, ErrorCode, FlowyError}; +use lib_infra::async_trait::async_trait; use lib_infra::box_any::BoxAny; -use lib_infra::conditional_send_sync_trait; -use lib_infra::future::FutureResult; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; @@ -13,8 +20,8 @@ use tokio_stream::wrappers::WatchStream; use uuid::Uuid; use crate::entities::{ - AuthResponse, Authenticator, Role, UpdateUserProfileParams, UserCredentials, UserProfile, - UserTokenState, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, + AuthResponse, AuthType, Role, UpdateUserProfileParams, UserProfile, UserTokenState, + UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -52,12 +59,7 @@ impl Display for UserCloudConfig { } } -conditional_send_sync_trait! { - "This trait is intended for implementation by providers that offer cloud-based services for users. - It includes methods for handling authentication tokens, enabling/disabling synchronization, - setting network reachability, managing encryption secrets, and accessing user-specific cloud services."; - - UserCloudServiceProvider { +pub trait UserCloudServiceProvider: Send + Sync { /// Sets the authentication token for the cloud service. /// /// # Arguments @@ -66,6 +68,7 @@ conditional_send_sync_trait! { /// # Returns /// A `Result` which is `Ok` if the token is successfully set, or a `FlowyError` otherwise. fn set_token(&self, token: &str) -> Result<(), FlowyError>; + fn set_ai_model(&self, ai_model: &str) -> Result<(), FlowyError>; /// Subscribes to the state of the authentication token. /// @@ -81,13 +84,9 @@ conditional_send_sync_trait! { /// * `enable_sync`: A boolean indicating whether synchronization should be enabled or disabled. fn set_enable_sync(&self, uid: i64, enable_sync: bool); - /// Sets the authenticator when user sign in or sign up. - /// - /// # Arguments - /// * `authenticator`: An `Authenticator` object. - fn set_user_authenticator(&self, authenticator: &Authenticator); + fn set_server_auth_type(&self, auth_type: &AuthType); - fn get_user_authenticator(&self) -> Authenticator; + fn get_server_auth_type(&self) -> AuthType; /// Sets the network reachability /// @@ -112,136 +111,136 @@ conditional_send_sync_trait! { /// # Returns /// A `String` representing the service URL. fn service_url(&self) -> String; - } } + /// Provide the generic interface for the user cloud service /// The user cloud service is responsible for the user authentication and user profile management #[allow(unused_variables)] +#[async_trait] pub trait UserCloudService: Send + Sync + 'static { /// Sign up a new account. /// The type of the params is defined the this trait's implementation. /// Use the `unbox_or_error` of the [BoxAny] to get the params. - fn sign_up(&self, params: BoxAny) -> FutureResult; + async fn sign_up(&self, params: BoxAny) -> Result; /// Sign in an account /// The type of the params is defined the this trait's implementation. - fn sign_in(&self, params: BoxAny) -> FutureResult; + async fn sign_in(&self, params: BoxAny) -> Result; /// Sign out an account - fn sign_out(&self, token: Option) -> FutureResult<(), FlowyError>; + async fn sign_out(&self, token: Option) -> Result<(), FlowyError>; + + /// Delete an account and all the data associated with the account + async fn delete_account(&self) -> Result<(), FlowyError> { + Ok(()) + } /// Generate a sign in url for the user with the given email /// Currently, only use the admin client for testing - fn generate_sign_in_url_with_email(&self, email: &str) -> FutureResult; + async fn generate_sign_in_url_with_email(&self, email: &str) -> Result; - fn create_user(&self, email: &str, password: &str) -> FutureResult<(), FlowyError>; + async fn create_user(&self, email: &str, password: &str) -> Result<(), FlowyError>; - fn sign_in_with_password( + async fn sign_in_with_password( &self, email: &str, password: &str, - ) -> FutureResult; + ) -> Result; - fn sign_in_with_magic_link(&self, email: &str, redirect_to: &str) - -> FutureResult<(), FlowyError>; + async fn sign_in_with_magic_link(&self, email: &str, redirect_to: &str) + -> Result<(), FlowyError>; + + async fn sign_in_with_passcode( + &self, + email: &str, + passcode: &str, + ) -> Result; /// When the user opens the OAuth URL, it redirects to the corresponding provider's OAuth web page. /// After the user is authenticated, the browser will open a deep link to the AppFlowy app (iOS, macOS, etc.), /// which will call [Client::sign_in_with_url]generate_sign_in_url_with_email to sign in. /// /// For example, the OAuth URL on Google looks like `https://appflowy.io/authorize?provider=google`. - fn generate_oauth_url_with_provider(&self, provider: &str) -> FutureResult; + async fn generate_oauth_url_with_provider(&self, provider: &str) -> Result; /// Using the user's token to update the user information - fn update_user( - &self, - credential: UserCredentials, - params: UpdateUserProfileParams, - ) -> FutureResult<(), FlowyError>; + async fn update_user(&self, params: UpdateUserProfileParams) -> Result<(), FlowyError>; /// Get the user information using the user's token or uid /// return None if the user is not found - fn get_user_profile(&self, credential: UserCredentials) -> FutureResult; + async fn get_user_profile(&self, uid: i64, workspace_id: &str) + -> Result; - fn open_workspace(&self, workspace_id: &str) -> FutureResult; + async fn open_workspace(&self, workspace_id: &Uuid) -> Result; /// Return the all the workspaces of the user - fn get_all_workspace(&self, uid: i64) -> FutureResult, FlowyError>; + async fn get_all_workspace(&self, uid: i64) -> Result, FlowyError>; /// Creates a new workspace for the user. /// Returns the new workspace if successful - fn create_workspace(&self, workspace_name: &str) -> FutureResult; + async fn create_workspace(&self, workspace_name: &str) -> Result; // Updates the workspace name and icon - fn patch_workspace( + async fn patch_workspace( &self, - workspace_id: &str, - new_workspace_name: Option<&str>, - new_workspace_icon: Option<&str>, - ) -> FutureResult<(), FlowyError>; + workspace_id: &Uuid, + new_workspace_name: Option, + new_workspace_icon: Option, + ) -> Result<(), FlowyError>; /// Deletes a workspace owned by the user. - fn delete_workspace(&self, workspace_id: &str) -> FutureResult<(), FlowyError>; + async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError>; - // Deprecated, use invite instead - fn add_workspace_member( - &self, - user_email: String, - workspace_id: String, - ) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) - } - - fn invite_workspace_member( + async fn invite_workspace_member( &self, invitee_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, - ) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) + ) -> Result<(), FlowyError> { + Ok(()) } - fn list_workspace_invitations( + async fn list_workspace_invitations( &self, filter: Option, - ) -> FutureResult, FlowyError> { - FutureResult::new(async { Ok(vec![]) }) + ) -> Result, FlowyError> { + Ok(vec![]) } - fn accept_workspace_invitations(&self, invite_id: String) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) + async fn accept_workspace_invitations(&self, invite_id: String) -> Result<(), FlowyError> { + Ok(()) } - fn remove_workspace_member( + async fn remove_workspace_member( &self, user_email: String, - workspace_id: String, - ) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) + workspace_id: Uuid, + ) -> Result<(), FlowyError> { + Ok(()) } - fn update_workspace_member( + async fn update_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, - ) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) + ) -> Result<(), FlowyError> { + Ok(()) } - fn get_workspace_members( + async fn get_workspace_members( &self, - workspace_id: String, - ) -> FutureResult, FlowyError> { - FutureResult::new(async { Ok(vec![]) }) + workspace_id: Uuid, + ) -> Result, FlowyError> { + Ok(vec![]) } - fn get_user_awareness_doc_state( + async fn get_user_awareness_doc_state( &self, uid: i64, - workspace_id: &str, - object_id: &str, - ) -> FutureResult, FlowyError>; + workspace_id: &Uuid, + object_id: &Uuid, + ) -> Result, FlowyError>; fn receive_realtime_event(&self, _json: Value) {} @@ -249,23 +248,100 @@ pub trait UserCloudService: Send + Sync + 'static { None } - fn reset_workspace(&self, collab_object: CollabObject) -> FutureResult<(), FlowyError>; - - fn create_collab_object( + async fn create_collab_object( &self, collab_object: &CollabObject, data: Vec, - ) -> FutureResult<(), FlowyError>; + ) -> Result<(), FlowyError>; - fn batch_create_collab_object( + async fn batch_create_collab_object( &self, - workspace_id: &str, + workspace_id: &Uuid, objects: Vec, - ) -> FutureResult<(), FlowyError>; + ) -> Result<(), FlowyError>; - fn leave_workspace(&self, workspace_id: &str) -> FutureResult<(), FlowyError> { - FutureResult::new(async { Ok(()) }) + async fn leave_workspace(&self, workspace_id: &Uuid) -> Result<(), FlowyError> { + Ok(()) } + + async fn subscribe_workspace( + &self, + workspace_id: Uuid, + recurring_interval: RecurringInterval, + workspace_subscription_plan: SubscriptionPlan, + success_url: String, + ) -> Result { + Err(FlowyError::not_support()) + } + + async fn get_workspace_member( + &self, + workspace_id: &Uuid, + uid: i64, + ) -> Result; + /// Get all subscriptions for all workspaces for a user (email) + async fn get_workspace_subscriptions( + &self, + ) -> Result, FlowyError> { + Ok(vec![]) + } + + /// Get the workspace subscriptions for a workspace + async fn get_workspace_subscription_one( + &self, + workspace_id: &Uuid, + ) -> Result, FlowyError> { + Ok(vec![]) + } + + async fn cancel_workspace_subscription( + &self, + workspace_id: String, + plan: SubscriptionPlan, + reason: Option, + ) -> Result<(), FlowyError> { + Ok(()) + } + + async fn get_workspace_plan( + &self, + workspace_id: Uuid, + ) -> Result, FlowyError> { + Ok(vec![]) + } + + async fn get_workspace_usage( + &self, + workspace_id: &Uuid, + ) -> Result; + + async fn get_billing_portal_url(&self) -> Result { + Err(FlowyError::not_support()) + } + + async fn update_workspace_subscription_payment_period( + &self, + workspace_id: &Uuid, + plan: SubscriptionPlan, + recurring_interval: RecurringInterval, + ) -> Result<(), FlowyError> { + Ok(()) + } + + async fn get_subscription_plan_details(&self) -> Result, FlowyError> { + Ok(vec![]) + } + + async fn get_workspace_setting( + &self, + workspace_id: &Uuid, + ) -> Result; + + async fn update_workspace_setting( + &self, + workspace_id: &Uuid, + workspace_settings: AFWorkspaceSettingsChange, + ) -> Result; } pub type UserUpdateReceiver = tokio::sync::mpsc::Receiver; diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index db9a81e8d8..061bda56f5 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -1,13 +1,15 @@ +use std::fmt::{Display, Formatter}; use std::str::FromStr; use chrono::{DateTime, Utc}; +pub use client_api::entity::billing_dto::RecurringInterval; +use client_api::entity::AFRole; +use flowy_error::FlowyResult; use serde::{Deserialize, Serialize}; use serde_json::Value; use serde_repr::*; use uuid::Uuid; -pub const USER_METADATA_OPEN_AI_KEY: &str = "openai_key"; -pub const USER_METADATA_STABILITY_AI_KEY: &str = "stability_ai_key"; pub const USER_METADATA_ICON_URL: &str = "icon_url"; pub const USER_METADATA_UPDATE_AT: &str = "updated_at"; @@ -29,7 +31,7 @@ pub struct SignInParams { pub email: String, pub password: String, pub name: String, - pub auth_type: Authenticator, + pub auth_type: AuthType, } #[derive(Serialize, Deserialize, Default, Debug)] @@ -37,7 +39,7 @@ pub struct SignUpParams { pub email: String, pub name: String, pub password: String, - pub auth_type: Authenticator, + pub auth_type: AuthType, pub device_id: String, } @@ -98,40 +100,6 @@ impl UserAuthResponse for AuthResponse { } } -#[derive(Clone, Debug)] -pub struct UserCredentials { - /// Currently, the token is only used when the [Authenticator] is AppFlowyCloud - pub token: Option, - - /// The user id - pub uid: Option, - - /// The user id - pub uuid: Option, -} - -impl UserCredentials { - pub fn from_uid(uid: i64) -> Self { - Self { - token: None, - uid: Some(uid), - uuid: None, - } - } - - pub fn from_uuid(uuid: String) -> Self { - Self { - token: None, - uid: None, - uuid: Some(uuid), - } - } - - pub fn new(token: Option, uid: Option, uuid: Option) -> Self { - Self { token, uid, uuid } - } -} - #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UserWorkspace { pub id: String, @@ -139,37 +107,43 @@ pub struct UserWorkspace { pub created_at: DateTime, /// The database storage id is used indexing all the database views in current workspace. #[serde(rename = "database_storage_id")] - pub database_indexer_id: String, + pub workspace_database_id: String, #[serde(default)] pub icon: String, + #[serde(default)] + pub member_count: i64, + #[serde(default)] + pub role: Option, } impl UserWorkspace { - pub fn new(workspace_id: &str, _uid: i64) -> Self { + pub fn workspace_id(&self) -> FlowyResult { + let id = Uuid::from_str(&self.id)?; + Ok(id) + } + + pub fn new_local(workspace_id: String, name: &str) -> Self { Self { - id: workspace_id.to_string(), - name: "".to_string(), + id: workspace_id, + name: name.to_string(), created_at: Utc::now(), - database_indexer_id: Uuid::new_v4().to_string(), + workspace_database_id: Uuid::new_v4().to_string(), icon: "".to_string(), + member_count: 1, + role: Some(Role::Owner), } } } -#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct UserProfile { - #[serde(rename = "id")] pub uid: i64, pub email: String, pub name: String, pub token: String, pub icon_url: String, - pub openai_key: String, - pub stability_ai_key: String, - pub workspace_id: String, - pub authenticator: Authenticator, - // If the encryption_sign is not empty, which means the user has enabled the encryption. - pub encryption_type: EncryptionType, + pub auth_type: AuthType, + pub workspace_auth_type: AuthType, pub updated_at: i64, } @@ -212,42 +186,29 @@ impl FromStr for EncryptionType { } } -impl From<(&T, &Authenticator)> for UserProfile +impl From<(&T, &AuthType)> for UserProfile where T: UserAuthResponse, { - fn from(params: (&T, &Authenticator)) -> Self { + fn from(params: (&T, &AuthType)) -> Self { let (value, auth_type) = params; - let (icon_url, openai_key, stability_ai_key) = { - value - .metadata() - .as_ref() - .map(|m| { - ( - m.get(USER_METADATA_ICON_URL) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) - .unwrap_or_default(), - m.get(USER_METADATA_OPEN_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) - .unwrap_or_default(), - m.get(USER_METADATA_STABILITY_AI_KEY) - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) - .unwrap_or_default(), - ) - }) - .unwrap_or_default() - }; + let icon_url = value + .metadata() + .as_ref() + .map(|m| { + m.get(USER_METADATA_ICON_URL) + .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_default()) + .unwrap_or_default() + }) + .unwrap_or_default(); Self { uid: value.user_id(), email: value.user_email().unwrap_or_default(), name: value.user_name().to_owned(), token: value.user_token().unwrap_or_default(), icon_url, - openai_key, - workspace_id: value.latest_workspace().id.to_owned(), - authenticator: auth_type.clone(), - encryption_type: value.encryption_type(), - stability_ai_key, + auth_type: *auth_type, + workspace_auth_type: *auth_type, updated_at: value.updated_at(), } } @@ -260,9 +221,6 @@ pub struct UpdateUserProfileParams { pub email: Option, pub password: Option, pub icon_url: Option, - pub openai_key: Option, - pub stability_ai_key: Option, - pub encryption_sign: Option, pub token: Option, } @@ -298,72 +256,49 @@ impl UpdateUserProfileParams { self.icon_url = Some(icon_url.to_string()); self } - - pub fn with_openai_key(mut self, openai_key: &str) -> Self { - self.openai_key = Some(openai_key.to_owned()); - self - } - - pub fn with_stability_ai_key(mut self, stability_ai_key: &str) -> Self { - self.stability_ai_key = Some(stability_ai_key.to_owned()); - self - } - - pub fn with_encryption_type(mut self, encryption_type: EncryptionType) -> Self { - let sign = match encryption_type { - EncryptionType::NoEncryption => "".to_string(), - EncryptionType::SelfEncryption(sign) => sign, - }; - self.encryption_sign = Some(sign); - self - } - - pub fn is_empty(&self) -> bool { - self.name.is_none() - && self.email.is_none() - && self.password.is_none() - && self.icon_url.is_none() - && self.openai_key.is_none() - && self.encryption_sign.is_none() - && self.stability_ai_key.is_none() - } } -#[derive(Debug, Clone, Hash, Serialize_repr, Deserialize_repr, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Hash, Serialize_repr, Deserialize_repr, Eq, PartialEq)] #[repr(u8)] -pub enum Authenticator { +pub enum AuthType { /// It's a local server, we do fake sign in default. Local = 0, /// Currently not supported. It will be supported in the future when the /// [AppFlowy-Server](https://github.com/AppFlowy-IO/AppFlowy-Server) ready. AppFlowyCloud = 1, - /// It uses Supabase as the backend. - Supabase = 2, } -impl Default for Authenticator { +impl Display for AuthType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + AuthType::Local => write!(f, "Local"), + AuthType::AppFlowyCloud => write!(f, "AppFlowyCloud"), + } + } +} + +impl Default for AuthType { fn default() -> Self { Self::Local } } -impl Authenticator { +impl AuthType { pub fn is_local(&self) -> bool { - matches!(self, Authenticator::Local) + matches!(self, AuthType::Local) } pub fn is_appflowy_cloud(&self) -> bool { - matches!(self, Authenticator::AppFlowyCloud) + matches!(self, AuthType::AppFlowyCloud) } } -impl From for Authenticator { +impl From for AuthType { fn from(value: i32) -> Self { match value { - 0 => Authenticator::Local, - 1 => Authenticator::AppFlowyCloud, - 2 => Authenticator::Supabase, - _ => Authenticator::Local, + 0 => AuthType::Local, + 1 => AuthType::AppFlowyCloud, + _ => AuthType::Local, } } } @@ -384,17 +319,50 @@ pub enum UserTokenState { } // Workspace Role -#[derive(Clone, Debug)] +#[derive(Clone, Copy, Debug, Serialize_repr, Deserialize_repr, Eq, PartialEq)] +#[repr(u8)] pub enum Role { - Owner, - Member, - Guest, + Owner = 0, + Member = 1, + Guest = 2, +} + +impl From for Role { + fn from(value: i32) -> Self { + match value { + 0 => Role::Owner, + 1 => Role::Member, + 2 => Role::Guest, + _ => Role::Guest, + } + } +} + +impl From for i32 { + fn from(value: Role) -> Self { + match value { + Role::Owner => 0, + Role::Member => 1, + Role::Guest => 2, + } + } +} + +impl From for Role { + fn from(value: AFRole) -> Self { + match value { + AFRole::Owner => Role::Owner, + AFRole::Member => Role::Member, + AFRole::Guest => Role::Guest, + } + } } pub struct WorkspaceMember { pub email: String, pub role: Role, pub name: String, + pub avatar_url: Option, } /// represent the user awareness object id for the workspace. diff --git a/frontend/rust-lib/flowy-user-pub/src/lib.rs b/frontend/rust-lib/flowy-user-pub/src/lib.rs index 2e51ecc626..773ae96a9a 100644 --- a/frontend/rust-lib/flowy-user-pub/src/lib.rs +++ b/frontend/rust-lib/flowy-user-pub/src/lib.rs @@ -1,6 +1,7 @@ pub mod cloud; pub mod entities; pub mod session; +pub mod sql; pub mod workspace_service; pub const DEFAULT_USER_NAME: fn() -> String = || "Me".to_string(); diff --git a/frontend/rust-lib/flowy-user-pub/src/session.rs b/frontend/rust-lib/flowy-user-pub/src/session.rs index 82306e6e72..4c2668477a 100644 --- a/frontend/rust-lib/flowy-user-pub/src/session.rs +++ b/frontend/rust-lib/flowy-user-pub/src/session.rs @@ -73,8 +73,10 @@ impl<'de> Visitor<'de> for SessionVisitor { name: "My Workspace".to_string(), created_at: Utc::now(), // For historical reasons, the database_storage_id is constructed by the user_id. - database_indexer_id: STANDARD.encode(format!("{}:user:database", user_id)), + workspace_database_id: STANDARD.encode(format!("{}:user:database", user_id)), icon: "".to_owned(), + member_count: 1, + role: None, }) } } diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs new file mode 100644 index 0000000000..58ca65e732 --- /dev/null +++ b/frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs @@ -0,0 +1,62 @@ +use crate::entities::{Role, WorkspaceMember}; +use diesel::{insert_into, RunQueryDsl}; +use flowy_error::FlowyResult; +use flowy_sqlite::schema::workspace_members_table; +use flowy_sqlite::schema::workspace_members_table::dsl; +use flowy_sqlite::{prelude::*, DBConnection, ExpressionMethods}; + +#[derive(Queryable, Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = workspace_members_table)] +#[diesel(primary_key(email, workspace_id))] +pub struct WorkspaceMemberTable { + pub email: String, + pub role: i32, + pub name: String, + pub avatar_url: Option, + pub uid: i64, + pub workspace_id: String, + pub updated_at: chrono::NaiveDateTime, +} + +impl From for WorkspaceMember { + fn from(value: WorkspaceMemberTable) -> Self { + Self { + email: value.email, + role: Role::from(value.role), + name: value.name, + avatar_url: value.avatar_url, + } + } +} + +pub fn upsert_workspace_member>( + conn: &mut SqliteConnection, + member: T, +) -> FlowyResult<()> { + let member = member.into(); + + insert_into(workspace_members_table::table) + .values(&member) + .on_conflict(( + workspace_members_table::email, + workspace_members_table::workspace_id, + )) + .do_update() + .set(&member) + .execute(conn)?; + + Ok(()) +} + +pub fn select_workspace_member( + mut conn: DBConnection, + workspace_id: &str, + uid: i64, +) -> FlowyResult { + let member = dsl::workspace_members_table + .filter(workspace_members_table::workspace_id.eq(workspace_id)) + .filter(workspace_members_table::uid.eq(uid)) + .first::(&mut conn)?; + + Ok(member) +} diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs b/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs new file mode 100644 index 0000000000..2a5f7bf891 --- /dev/null +++ b/frontend/rust-lib/flowy-user-pub/src/sql/mod.rs @@ -0,0 +1,9 @@ +mod member_sql; +mod user_sql; +mod workspace_setting_sql; +mod workspace_sql; + +pub use member_sql::*; +pub use user_sql::*; +pub use workspace_setting_sql::*; +pub use workspace_sql::*; diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs new file mode 100644 index 0000000000..384b66d51b --- /dev/null +++ b/frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs @@ -0,0 +1,165 @@ +use crate::cloud::UserUpdate; +use crate::entities::{AuthType, UpdateUserProfileParams, UserProfile}; +use crate::sql::select_user_workspace; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::schema::user_table; +use flowy_sqlite::{prelude::*, DBConnection, ExpressionMethods, RunQueryDsl}; +use tracing::{trace, warn}; + +/// The order of the fields in the struct must be the same as the order of the fields in the table. +/// Check out the [schema.rs] for table schema. +#[derive(Clone, Default, Queryable, Identifiable, Insertable)] +#[diesel(table_name = user_table)] +pub struct UserTable { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) icon_url: String, + pub(crate) token: String, + pub(crate) email: String, + pub(crate) auth_type: i32, + pub(crate) updated_at: i64, +} + +#[allow(deprecated)] +impl From<(UserProfile, AuthType)> for UserTable { + fn from(value: (UserProfile, AuthType)) -> Self { + let (user_profile, auth_type) = value; + UserTable { + id: user_profile.uid.to_string(), + name: user_profile.name, + #[allow(deprecated)] + icon_url: user_profile.icon_url, + token: user_profile.token, + email: user_profile.email, + auth_type: auth_type as i32, + updated_at: user_profile.updated_at, + } + } +} + +#[derive(AsChangeset, Identifiable, Default, Debug)] +#[diesel(table_name = user_table)] +pub struct UserTableChangeset { + pub id: String, + pub name: Option, + pub email: Option, + pub icon_url: Option, + pub token: Option, +} + +impl UserTableChangeset { + pub fn new(params: UpdateUserProfileParams) -> Self { + UserTableChangeset { + id: params.uid.to_string(), + name: params.name, + email: params.email, + icon_url: params.icon_url, + token: params.token, + } + } + + pub fn from_user_profile(user_profile: UserProfile) -> Self { + UserTableChangeset { + id: user_profile.uid.to_string(), + name: Some(user_profile.name), + email: Some(user_profile.email), + icon_url: Some(user_profile.icon_url), + token: Some(user_profile.token), + } + } +} + +impl From for UserTableChangeset { + fn from(value: UserUpdate) -> Self { + UserTableChangeset { + id: value.uid.to_string(), + name: value.name, + email: value.email, + ..Default::default() + } + } +} + +pub fn update_user_profile( + conn: &mut SqliteConnection, + changeset: UserTableChangeset, +) -> Result<(), FlowyError> { + trace!("update user profile: {:?}", changeset); + let user_id = changeset.id.clone(); + update(user_table::dsl::user_table.filter(user_table::id.eq(&user_id))) + .set(changeset) + .execute(conn)?; + Ok(()) +} + +fn select_user_table_row(uid: i64, conn: &mut SqliteConnection) -> Result { + let row = user_table::dsl::user_table + .filter(user_table::id.eq(&uid.to_string())) + .first::(conn) + .map_err(|err| { + FlowyError::record_not_found().with_context(format!( + "Can't find the user profile for user id: {}, error: {:?}", + uid, err + )) + })?; + Ok(row) +} + +pub fn select_user_profile( + uid: i64, + workspace_id: &str, + conn: &mut SqliteConnection, +) -> Result { + let workspace = select_user_workspace(workspace_id, conn)?; + let workspace_auth_type = AuthType::from(workspace.workspace_type); + let row = select_user_table_row(uid, conn)?; + + let user = UserProfile { + uid: row.id.parse::().unwrap_or(0), + email: row.email, + name: row.name, + token: row.token, + icon_url: row.icon_url, + auth_type: AuthType::from(row.auth_type), + workspace_auth_type, + updated_at: row.updated_at, + }; + + Ok(user) +} + +pub fn select_workspace_auth_type( + uid: i64, + workspace_id: &str, + conn: &mut SqliteConnection, +) -> Result { + match select_user_workspace(workspace_id, conn) { + Ok(workspace) => Ok(AuthType::from(workspace.workspace_type)), + Err(err) => { + if err.is_record_not_found() { + let row = select_user_table_row(uid, conn)?; + warn!( + "user user auth type:{} as workspace auth type", + row.auth_type + ); + Ok(AuthType::from(row.auth_type)) + } else { + Err(err) + } + }, + } +} + +pub fn upsert_user(user: UserTable, mut conn: DBConnection) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + // delete old user if exists + diesel::delete(user_table::dsl::user_table.filter(user_table::dsl::id.eq(&user.id))) + .execute(conn)?; + + let _ = diesel::insert_into(user_table::table) + .values(user) + .execute(conn)?; + Ok::<(), FlowyError>(()) + })?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs new file mode 100644 index 0000000000..7eeafaf1e4 --- /dev/null +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs @@ -0,0 +1,72 @@ +use client_api::entity::AFWorkspaceSettings; +use flowy_error::FlowyError; +use flowy_sqlite::schema::workspace_setting_table; +use flowy_sqlite::schema::workspace_setting_table::dsl; +use flowy_sqlite::DBConnection; +use flowy_sqlite::{prelude::*, ExpressionMethods}; +use uuid::Uuid; + +#[derive(Clone, Default, Queryable, Identifiable, Insertable)] +#[diesel(table_name = workspace_setting_table)] +pub struct WorkspaceSettingsTable { + pub id: String, + pub disable_search_indexing: bool, + pub ai_model: String, +} + +#[derive(AsChangeset, Identifiable, Default, Debug)] +#[diesel(table_name = workspace_setting_table)] +pub struct WorkspaceSettingsChangeset { + pub id: String, + pub disable_search_indexing: Option, + pub ai_model: Option, +} + +impl WorkspaceSettingsTable { + pub fn from_workspace_settings(workspace_id: &Uuid, settings: &AFWorkspaceSettings) -> Self { + Self { + id: workspace_id.to_string(), + disable_search_indexing: settings.disable_search_indexing, + ai_model: settings.ai_model.clone(), + } + } +} + +pub fn update_workspace_setting( + conn: &mut DBConnection, + changeset: WorkspaceSettingsChangeset, +) -> Result<(), FlowyError> { + diesel::update(dsl::workspace_setting_table) + .filter(workspace_setting_table::id.eq(changeset.id.clone())) + .set(changeset) + .execute(conn)?; + Ok(()) +} + +/// Upserts a workspace setting into the database. +pub fn upsert_workspace_setting( + conn: &mut SqliteConnection, + settings: WorkspaceSettingsTable, +) -> Result<(), FlowyError> { + diesel::insert_into(dsl::workspace_setting_table) + .values(settings.clone()) + .on_conflict(workspace_setting_table::id) + .do_update() + .set(( + workspace_setting_table::disable_search_indexing.eq(settings.disable_search_indexing), + workspace_setting_table::ai_model.eq(settings.ai_model), + )) + .execute(conn)?; + Ok(()) +} + +/// Selects a workspace setting by id from the database. +pub fn select_workspace_setting( + conn: &mut SqliteConnection, + workspace_id: &str, +) -> Result { + let setting = dsl::workspace_setting_table + .filter(workspace_setting_table::id.eq(workspace_id)) + .first::(conn)?; + Ok(setting) +} diff --git a/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs new file mode 100644 index 0000000000..80c99eb7e6 --- /dev/null +++ b/frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs @@ -0,0 +1,264 @@ +use crate::entities::{AuthType, UserWorkspace}; +use chrono::{TimeZone, Utc}; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sqlite::schema::user_workspace_table; +use flowy_sqlite::schema::user_workspace_table::dsl; +use flowy_sqlite::DBConnection; +use flowy_sqlite::{prelude::*, ExpressionMethods, RunQueryDsl, SqliteConnection}; +use std::collections::{HashMap, HashSet}; +use tracing::{info, warn}; + +#[derive(Clone, Default, Queryable, Identifiable, Insertable)] +#[diesel(table_name = user_workspace_table)] +pub struct UserWorkspaceTable { + pub id: String, + pub name: String, + pub uid: i64, + pub created_at: i64, + pub database_storage_id: String, + pub icon: String, + pub member_count: i64, + pub role: Option, + pub workspace_type: i32, +} + +#[derive(AsChangeset, Identifiable, Default, Debug)] +#[diesel(table_name = user_workspace_table)] +pub struct UserWorkspaceChangeset { + pub id: String, + pub name: Option, + pub icon: Option, + pub role: Option, + pub member_count: Option, +} + +impl UserWorkspaceChangeset { + pub fn has_changes(&self) -> bool { + self.name.is_some() || self.icon.is_some() || self.role.is_some() || self.member_count.is_some() + } + pub fn from_version(old: &UserWorkspace, new: &UserWorkspace) -> Self { + let mut changeset = Self { + id: new.id.clone(), + name: None, + icon: None, + role: None, + member_count: None, + }; + + if old.name != new.name { + changeset.name = Some(new.name.clone()); + } + if old.icon != new.icon { + changeset.icon = Some(new.icon.clone()); + } + if old.role != new.role { + changeset.role = new.role.map(|v| v as i32); + } + if old.member_count != new.member_count { + changeset.member_count = Some(new.member_count); + } + + changeset + } +} + +impl UserWorkspaceTable { + pub fn from_workspace( + uid_val: i64, + workspace: &UserWorkspace, + auth_type: AuthType, + ) -> Result { + if workspace.id.is_empty() { + return Err(FlowyError::invalid_data().with_context("The id is empty")); + } + if workspace.workspace_database_id.is_empty() { + return Err(FlowyError::invalid_data().with_context("The database storage id is empty")); + } + + Ok(Self { + id: workspace.id.clone(), + name: workspace.name.clone(), + uid: uid_val, + created_at: workspace.created_at.timestamp(), + database_storage_id: workspace.workspace_database_id.clone(), + icon: workspace.icon.clone(), + member_count: workspace.member_count, + role: workspace.role.map(|v| v as i32), + workspace_type: auth_type as i32, + }) + } +} + +pub fn select_user_workspace( + workspace_id: &str, + conn: &mut SqliteConnection, +) -> FlowyResult { + let row = dsl::user_workspace_table + .filter(user_workspace_table::id.eq(workspace_id)) + .first::(conn)?; + Ok(row) +} + +pub fn select_all_user_workspace( + uid: i64, + conn: &mut SqliteConnection, +) -> Result, FlowyError> { + let rows = user_workspace_table::dsl::user_workspace_table + .filter(user_workspace_table::uid.eq(uid)) + .order(user_workspace_table::created_at.desc()) + .load::(conn)?; + Ok(rows.into_iter().map(UserWorkspace::from).collect()) +} + +pub fn update_user_workspace( + mut conn: DBConnection, + changeset: UserWorkspaceChangeset, +) -> Result<(), FlowyError> { + diesel::update(user_workspace_table::dsl::user_workspace_table) + .filter(user_workspace_table::id.eq(changeset.id.clone())) + .set(changeset) + .execute(&mut conn)?; + + Ok(()) +} + +pub fn delete_user_workspace(mut conn: DBConnection, workspace_id: &str) -> FlowyResult<()> { + let n = conn.immediate_transaction(|conn| { + let rows_affected: usize = + diesel::delete(user_workspace_table::table.filter(user_workspace_table::id.eq(workspace_id))) + .execute(conn)?; + Ok::(rows_affected) + })?; + + if n != 1 { + warn!("expected to delete 1 row, but deleted {} rows", n); + } + Ok(()) +} + +impl From for UserWorkspace { + fn from(value: UserWorkspaceTable) -> Self { + Self { + id: value.id, + name: value.name, + created_at: Utc + .timestamp_opt(value.created_at, 0) + .single() + .unwrap_or_default(), + workspace_database_id: value.database_storage_id, + icon: value.icon, + member_count: value.member_count, + role: value.role.map(|v| v.into()), + } + } +} + +/// Delete all user workspaces for the given user and auth type. +pub fn delete_user_all_workspace( + uid: i64, + auth_type: AuthType, + conn: &mut SqliteConnection, +) -> FlowyResult<()> { + let n = diesel::delete( + dsl::user_workspace_table + .filter(user_workspace_table::uid.eq(uid)) + .filter(user_workspace_table::workspace_type.eq(auth_type as i32)), + ) + .execute(conn)?; + info!( + "Delete {} workspaces for user {} and auth type {:?}", + n, uid, auth_type + ); + Ok(()) +} + +#[derive(Debug)] +pub enum WorkspaceChange { + Inserted(String), + Updated(String), +} + +pub fn upsert_user_workspace( + uid_val: i64, + auth_type: AuthType, + user_workspace: UserWorkspace, + conn: &mut SqliteConnection, +) -> Result { + let row = UserWorkspaceTable::from_workspace(uid_val, &user_workspace, auth_type)?; + let n = insert_into(user_workspace_table::table) + .values(row.clone()) + .on_conflict(user_workspace_table::id) + .do_update() + .set(( + user_workspace_table::name.eq(row.name), + user_workspace_table::uid.eq(row.uid), + user_workspace_table::created_at.eq(row.created_at), + user_workspace_table::database_storage_id.eq(row.database_storage_id), + user_workspace_table::icon.eq(row.icon), + user_workspace_table::member_count.eq(row.member_count), + user_workspace_table::role.eq(row.role), + )) + .execute(conn)?; + + Ok(n) +} + +pub fn sync_user_workspaces_with_diff( + uid_val: i64, + auth_type: AuthType, + user_workspaces: &[UserWorkspace], + conn: &mut SqliteConnection, +) -> FlowyResult> { + let diff = conn.immediate_transaction(|conn| { + // 1) Load all existing workspaces into a map + let existing_rows: Vec = dsl::user_workspace_table + .filter(user_workspace_table::uid.eq(uid_val)) + .filter(user_workspace_table::workspace_type.eq(auth_type as i32)) + .load(conn)?; + let mut existing_map: HashMap = existing_rows + .into_iter() + .map(|r| (r.id.clone(), r)) + .collect(); + + // 2) Build incoming ID set and delete any stale ones + let incoming_ids: HashSet = user_workspaces.iter().map(|uw| uw.id.clone()).collect(); + let to_delete: Vec = existing_map + .keys() + .filter(|id| !incoming_ids.contains(*id)) + .cloned() + .collect(); + + if !to_delete.is_empty() { + diesel::delete(dsl::user_workspace_table.filter(user_workspace_table::id.eq_any(&to_delete))) + .execute(conn)?; + } + + // 3) For each incoming workspace, either INSERT or UPDATE if changed + let mut diffs = Vec::new(); + for uw in user_workspaces { + match existing_map.remove(&uw.id) { + None => { + // new workspace → insert + let new_row = UserWorkspaceTable::from_workspace(uid_val, uw, auth_type)?; + diesel::insert_into(user_workspace_table::table) + .values(new_row) + .execute(conn)?; + diffs.push(WorkspaceChange::Inserted(uw.id.clone())); + }, + + Some(old) => { + let changes = UserWorkspaceChangeset::from_version(&UserWorkspace::from(old), uw); + if changes.has_changes() { + diesel::update(dsl::user_workspace_table.find(&uw.id)) + .set(&changes) + .execute(conn)?; + diffs.push(WorkspaceChange::Updated(uw.id.clone())); + } + }, + } + } + + Ok::<_, FlowyError>(diffs) + })?; + Ok(diff) +} diff --git a/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs b/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs index 7938f8a862..84185d310f 100644 --- a/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs +++ b/frontend/rust-lib/flowy-user-pub/src/workspace_service.rs @@ -1,13 +1,24 @@ +use collab_folder::hierarchy_builder::ParentChildViews; use flowy_error::FlowyResult; -use flowy_folder_pub::folder_builder::ParentChildViews; +use flowy_folder_pub::entities::ImportFrom; use lib_infra::async_trait::async_trait; use std::collections::HashMap; +use uuid::Uuid; #[async_trait] pub trait UserWorkspaceService: Send + Sync { - async fn did_import_views(&self, views: Vec) -> FlowyResult<()>; - async fn did_import_database_views( + async fn import_views( + &self, + source: &ImportFrom, + views: Vec, + orphan_views: Vec, + parent_view_id: Option, + ) -> FlowyResult<()>; + async fn import_database_views( &self, ids_by_database_id: HashMap>, ) -> FlowyResult<()>; + + /// Removes local indexes when a workspace is left/deleted + async fn did_delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()>; } diff --git a/frontend/rust-lib/flowy-user/Cargo.toml b/frontend/rust-lib/flowy-user/Cargo.toml index f206e8d4a8..65be4cc3f9 100644 --- a/frontend/rust-lib/flowy-user/Cargo.toml +++ b/frontend/rust-lib/flowy-user/Cargo.toml @@ -8,10 +8,9 @@ edition = "2018" [dependencies] flowy-derive.workspace = true flowy-sqlite = { workspace = true } -flowy-encrypt = { workspace = true } -flowy-error = { workspace = true, features = ["impl_from_dispatch_error", "impl_from_sqlite", "impl_from_collab_folder", "impl_from_collab_persistence"] } +flowy-error = { workspace = true, features = ["impl_from_dispatch_error", "impl_from_sqlite", "impl_from_collab_folder", "impl_from_collab_persistence", "impl_from_collab_document"] } flowy-folder-pub = { workspace = true } -lib-infra = { workspace = true } +lib-infra = { workspace = true, features = ["encryption"] } flowy-notification = { workspace = true } flowy-server-pub = { workspace = true } lib-dispatch = { workspace = true } @@ -24,22 +23,20 @@ collab-user = { workspace = true } collab-entity = { workspace = true } collab-plugins = { workspace = true } flowy-user-pub = { workspace = true } +client-api = { workspace = true } anyhow.workspace = true +arc-swap.workspace = true +dashmap.workspace = true tracing.workspace = true bytes.workspace = true -serde.workspace = true +serde = { workspace = true, features = ["rc"] } serde_json.workspace = true -serde_repr.workspace = true protobuf.workspace = true lazy_static = "1.4.0" diesel.workspace = true -diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } -once_cell = "1.17.1" -parking_lot.workspace = true strum = "0.25" strum_macros = "0.25.2" tokio = { workspace = true, features = ["rt"] } -validator = "0.16.0" unicode-segmentation = "1.10" fancy-regex = "0.11.0" uuid.workspace = true @@ -47,9 +44,10 @@ chrono = { workspace = true, default-features = false, features = ["clock"] } base64 = "^0.21" tokio-stream = "0.1.14" semver = "1.0.22" +validator = { workspace = true, features = ["derive"] } +rayon = "1.10.0" [dev-dependencies] -nanoid = "0.4.0" fake = "2.0.0" rand = "0.8.4" quickcheck = "1.0.3" @@ -58,7 +56,6 @@ quickcheck_macros = "1.0" [features] dart = ["flowy-codegen/dart", "flowy-notification/dart"] -tauri_ts = ["flowy-codegen/ts", "flowy-notification/tauri_ts"] [build-dependencies] flowy-codegen.workspace = true diff --git a/frontend/rust-lib/flowy-user/build.rs b/frontend/rust-lib/flowy-user/build.rs index e015eb2580..77c0c8125b 100644 --- a/frontend/rust-lib/flowy-user/build.rs +++ b/frontend/rust-lib/flowy-user/build.rs @@ -4,20 +4,4 @@ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } - - #[cfg(feature = "tauri_ts")] - { - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::Tauri, - ); - flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::TauriApp); - flowy_codegen::protobuf_file::ts_gen( - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_NAME"), - flowy_codegen::Project::TauriApp, - ); - } } diff --git a/frontend/rust-lib/flowy-user/src/anon_user/migrate_anon_user_collab.rs b/frontend/rust-lib/flowy-user/src/anon_user/migrate_anon_user_collab.rs deleted file mode 100644 index a7adcbe803..0000000000 --- a/frontend/rust-lib/flowy-user/src/anon_user/migrate_anon_user_collab.rs +++ /dev/null @@ -1,502 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::ops::{Deref, DerefMut}; -use std::sync::Arc; - -use anyhow::anyhow; -use collab::core::collab::{DataSource, MutexCollab}; -use collab::core::origin::{CollabClient, CollabOrigin}; -use collab::preclude::Collab; -use collab_database::database::{ - is_database_collab, mut_database_views_with_collab, reset_inline_view_id, -}; -use collab_database::rows::{database_row_document_id_from_row_id, mut_row_with_collab, RowId}; -use collab_database::workspace_database::DatabaseMetaList; -use collab_folder::{Folder, UserId}; -use collab_plugins::local_storage::kv::KVTransactionDB; -use parking_lot::{Mutex, RwLock}; -use tracing::info; - -use collab_integrate::{CollabKVAction, CollabKVDB, PersistenceError}; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_folder_pub::cloud::gen_view_id; - -use crate::migrations::AnonUser; -use flowy_user_pub::session::Session; - -/// Migration the collab objects of the old user to new user. Currently, it only happens when -/// the user is a local user and try to use AppFlowy cloud service. -pub fn migration_anon_user_on_sign_up( - old_user: &AnonUser, - old_collab_db: &Arc, - new_user_session: &Session, - new_collab_db: &Arc, -) -> FlowyResult<()> { - new_collab_db - .with_write_txn(|new_collab_w_txn| { - let old_collab_r_txn = old_collab_db.read_txn(); - let old_to_new_id_map = Arc::new(Mutex::new(OldToNewIdMap::new())); - - migrate_user_awareness( - old_to_new_id_map.lock().deref_mut(), - old_user, - new_user_session, - )?; - - migrate_database_with_views_object( - &mut old_to_new_id_map.lock(), - old_user, - &old_collab_r_txn, - new_user_session, - new_collab_w_txn, - )?; - - let mut object_ids = old_collab_r_txn - .get_all_docs() - .map(|iter| iter.collect::>()) - .unwrap_or_default(); - - // Migration of all objects except the folder and database_with_views - object_ids.retain(|id| { - id != &old_user.session.user_workspace.id - && id != &old_user.session.user_workspace.database_indexer_id - }); - - info!("migrate collab objects: {:?}", object_ids.len()); - let collab_by_oid = make_collab_by_oid(old_user, &old_collab_r_txn, &object_ids); - migrate_databases( - &old_to_new_id_map, - new_user_session, - new_collab_w_txn, - &mut object_ids, - &collab_by_oid, - )?; - - // Migrates the folder, replacing all existing view IDs with new ones. - // This function handles the process of migrating folder data between two users. As a part of this migration, - // all existing view IDs associated with the old user will be replaced by new IDs relevant to the new user. - migrate_workspace_folder( - &mut old_to_new_id_map.lock(), - old_user, - &old_collab_r_txn, - new_user_session, - new_collab_w_txn, - )?; - - // Migrate other collab objects - for object_id in &object_ids { - if let Some(collab) = collab_by_oid.get(object_id) { - let new_object_id = old_to_new_id_map.lock().exchange_new_id(object_id); - tracing::debug!("migrate from: {}, to: {}", object_id, new_object_id,); - migrate_collab_object( - collab, - new_user_session.user_id, - &new_object_id, - new_collab_w_txn, - ); - } - } - - Ok(()) - }) - .map_err(|err| FlowyError::new(ErrorCode::Internal, err))?; - - Ok(()) -} - -#[derive(Default)] -pub struct OldToNewIdMap(HashMap); - -impl OldToNewIdMap { - fn new() -> Self { - Self::default() - } - fn exchange_new_id(&mut self, old_id: &str) -> String { - let view_id = self - .0 - .entry(old_id.to_string()) - .or_insert(gen_view_id().to_string()); - (*view_id).clone() - } -} - -impl Deref for OldToNewIdMap { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for OldToNewIdMap { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -fn migrate_database_with_views_object<'a, 'b, W, R>( - old_to_new_id_map: &mut OldToNewIdMap, - old_user: &AnonUser, - old_collab_r_txn: &R, - new_user_session: &Session, - new_collab_w_txn: &W, -) -> Result<(), PersistenceError> -where - 'a: 'b, - W: CollabKVAction<'a>, - R: CollabKVAction<'b>, - PersistenceError: From, - PersistenceError: From, -{ - let database_with_views_collab = Collab::new( - old_user.session.user_id, - &old_user.session.user_workspace.database_indexer_id, - "phantom", - vec![], - false, - ); - database_with_views_collab.with_origin_transact_mut(|txn| { - old_collab_r_txn.load_doc_with_txn( - old_user.session.user_id, - &old_user.session.user_workspace.database_indexer_id, - txn, - ) - })?; - - let new_uid = new_user_session.user_id; - let new_object_id = &new_user_session.user_workspace.database_indexer_id; - - let array = DatabaseMetaList::from_collab(&database_with_views_collab); - for database_meta in array.get_all_database_meta() { - array.update_database(&database_meta.database_id, |update| { - let new_linked_views = update - .linked_views - .iter() - .map(|view_id| old_to_new_id_map.exchange_new_id(view_id)) - .collect(); - update.database_id = old_to_new_id_map.exchange_new_id(&update.database_id); - update.linked_views = new_linked_views; - }) - } - - let txn = database_with_views_collab.transact(); - if let Err(err) = new_collab_w_txn.create_new_doc(new_uid, new_object_id, &txn) { - tracing::error!("🔴migrate database storage failed: {:?}", err); - } - drop(txn); - Ok(()) -} - -fn migrate_collab_object<'a, W>(collab: &Collab, new_uid: i64, new_object_id: &str, w_txn: &'a W) -where - W: CollabKVAction<'a>, - PersistenceError: From, -{ - let txn = collab.transact(); - if let Err(err) = w_txn.create_new_doc(new_uid, &new_object_id, &txn) { - tracing::error!("🔴migrate collab failed: {:?}", err); - } -} - -fn migrate_workspace_folder<'a, 'b, W, R>( - old_to_new_id_map: &mut OldToNewIdMap, - old_user: &AnonUser, - old_collab_r_txn: &R, - new_user_session: &Session, - new_collab_w_txn: &W, -) -> Result<(), PersistenceError> -where - 'a: 'b, - W: CollabKVAction<'a>, - R: CollabKVAction<'b>, - PersistenceError: From, - PersistenceError: From, -{ - let old_uid = old_user.session.user_id; - let old_workspace_id = &old_user.session.user_workspace.id; - let new_uid = new_user_session.user_id; - let new_workspace_id = &new_user_session.user_workspace.id; - - let old_folder_collab = Collab::new(old_uid, old_workspace_id, "phantom", vec![], false); - old_folder_collab.with_origin_transact_mut(|txn| { - old_collab_r_txn.load_doc_with_txn(old_uid, old_workspace_id, txn) - })?; - let old_user_id = UserId::from(old_uid); - let old_folder = Folder::open( - old_user_id.clone(), - Arc::new(MutexCollab::new(old_folder_collab)), - None, - ) - .map_err(|err| PersistenceError::InvalidData(err.to_string()))?; - let mut folder_data = - old_folder - .get_folder_data(old_workspace_id) - .ok_or(PersistenceError::Internal(anyhow!( - "Can't migrate the folder data" - )))?; - - if let Some(old_fav_map) = folder_data.favorites.remove(&old_user_id) { - let fav_map = old_fav_map - .into_iter() - .map(|mut item| { - let new_view_id = old_to_new_id_map.exchange_new_id(&item.id); - item.id = new_view_id; - item - }) - .collect(); - folder_data.favorites.insert(UserId::from(new_uid), fav_map); - } - if let Some(old_trash_map) = folder_data.trash.remove(&old_user_id) { - let trash_map = old_trash_map - .into_iter() - .map(|mut item| { - let new_view_id = old_to_new_id_map.exchange_new_id(&item.id); - item.id = new_view_id; - item - }) - .collect(); - folder_data.trash.insert(UserId::from(new_uid), trash_map); - } - - if let Some(old_recent_map) = folder_data.recent.remove(&old_user_id) { - let recent_map = old_recent_map - .into_iter() - .map(|mut item| { - let new_view_id = old_to_new_id_map.exchange_new_id(&item.id); - item.id = new_view_id; - item - }) - .collect(); - folder_data.recent.insert(UserId::from(new_uid), recent_map); - } - - old_to_new_id_map - .0 - .insert(old_workspace_id.to_string(), new_workspace_id.to_string()); - - // 1. Replace the workspace views id to new id - folder_data.workspace.id = new_workspace_id.clone(); - folder_data - .workspace - .child_views - .iter_mut() - .for_each(|view_identifier| { - view_identifier.id = old_to_new_id_map.exchange_new_id(&view_identifier.id); - }); - - folder_data.views.iter_mut().for_each(|view| { - // 2. replace the old parent view id of the view - view.parent_view_id = old_to_new_id_map.exchange_new_id(&view.parent_view_id); - - // 3. replace the old id of the view - view.id = old_to_new_id_map.exchange_new_id(&view.id); - - // 4. replace the old id of the children views - view.children.iter_mut().for_each(|view_identifier| { - view_identifier.id = old_to_new_id_map.exchange_new_id(&view_identifier.id); - }); - }); - - match old_to_new_id_map.get(&folder_data.current_view) { - Some(new_view_id) => { - folder_data.current_view = new_view_id.clone(); - }, - None => { - tracing::error!("🔴migrate folder: current view id not found"); - folder_data.current_view = "".to_string(); - }, - } - - let origin = CollabOrigin::Client(CollabClient::new(new_uid, "phantom")); - let new_folder_collab = - Collab::new_with_source(origin, new_workspace_id, DataSource::Disk, vec![], false) - .map_err(|err| PersistenceError::Internal(err.into()))?; - let mutex_collab = Arc::new(MutexCollab::new(new_folder_collab)); - let new_user_id = UserId::from(new_uid); - info!("migrated folder: {:?}", folder_data); - let _ = Folder::create(new_user_id, mutex_collab.clone(), None, folder_data); - - { - let mutex_collab = mutex_collab.lock(); - let txn = mutex_collab.transact(); - if let Err(err) = new_collab_w_txn.create_new_doc(new_uid, new_workspace_id, &txn) { - tracing::error!("🔴migrate folder failed: {:?}", err); - } - } - Ok(()) -} - -fn migrate_user_awareness( - old_to_new_id_map: &mut OldToNewIdMap, - old_user: &AnonUser, - new_user_session: &Session, -) -> Result<(), PersistenceError> { - let old_uid = old_user.session.user_id; - let new_uid = new_user_session.user_id; - tracing::debug!("migrate user awareness from: {}, to: {}", old_uid, new_uid); - old_to_new_id_map.insert(old_uid.to_string(), new_uid.to_string()); - Ok(()) -} - -fn migrate_databases<'a, W>( - old_to_new_id_map: &Arc>, - new_user_session: &Session, - new_collab_w_txn: &'a W, - object_ids: &mut Vec, - collab_by_oid: &HashMap, -) -> Result<(), PersistenceError> -where - W: CollabKVAction<'a>, - PersistenceError: From, -{ - // Migrate databases - let mut database_object_ids = vec![]; - let imported_database_row_object_ids: RwLock>> = - RwLock::new(HashMap::new()); - - for object_id in &mut *object_ids { - if let Some(collab) = collab_by_oid.get(object_id) { - if !is_database_collab(collab) { - continue; - } - - database_object_ids.push(object_id.clone()); - reset_inline_view_id(collab, |old_inline_view_id| { - old_to_new_id_map - .lock() - .exchange_new_id(&old_inline_view_id) - }); - - mut_database_views_with_collab(collab, |database_view| { - let old_database_id = database_view.database_id.clone(); - let new_view_id = old_to_new_id_map.lock().exchange_new_id(&database_view.id); - let new_database_id = old_to_new_id_map - .lock() - .exchange_new_id(&database_view.database_id); - - tracing::trace!( - "migrate database view id from: {}, to: {}", - database_view.id, - new_view_id, - ); - tracing::trace!( - "migrate database view database id from: {}, to: {}", - database_view.database_id, - new_database_id, - ); - - database_view.id = new_view_id; - database_view.database_id = new_database_id; - database_view.row_orders.iter_mut().for_each(|row_order| { - let old_row_id = String::from(row_order.id.clone()); - let old_row_document_id = database_row_document_id_from_row_id(&old_row_id); - let new_row_id = old_to_new_id_map.lock().exchange_new_id(&old_row_id); - let new_row_document_id = database_row_document_id_from_row_id(&new_row_id); - tracing::debug!("migrate row id: {} to {}", row_order.id, new_row_id); - tracing::debug!( - "migrate row document id: {} to {}", - old_row_document_id, - new_row_document_id - ); - old_to_new_id_map - .lock() - .insert(old_row_document_id, new_row_document_id); - - row_order.id = RowId::from(new_row_id); - imported_database_row_object_ids - .write() - .entry(old_database_id.clone()) - .or_default() - .insert(old_row_id); - }); - }); - - let new_object_id = old_to_new_id_map.lock().exchange_new_id(object_id); - tracing::debug!( - "migrate database from: {}, to: {}", - object_id, - new_object_id, - ); - migrate_collab_object( - collab, - new_user_session.user_id, - &new_object_id, - new_collab_w_txn, - ); - } - } - - let imported_database_row_object_ids = imported_database_row_object_ids.read(); - // remove the database object ids from the object ids - object_ids.retain(|id| !database_object_ids.contains(id)); - - // remove database row object ids from the imported object ids - object_ids.retain(|id| { - !imported_database_row_object_ids - .values() - .flatten() - .any(|row_id| row_id == id) - }); - for (database_id, imported_row_ids) in &*imported_database_row_object_ids { - for imported_row_id in imported_row_ids { - if let Some(imported_collab) = collab_by_oid.get(imported_row_id) { - let new_database_id = old_to_new_id_map.lock().exchange_new_id(database_id); - let new_row_id = old_to_new_id_map.lock().exchange_new_id(imported_row_id); - info!( - "import database row from: {}, to: {}", - imported_row_id, new_row_id, - ); - mut_row_with_collab(imported_collab, |row_update| { - row_update.set_row_id(RowId::from(new_row_id.clone()), new_database_id.clone()); - }); - migrate_collab_object( - imported_collab, - new_user_session.user_id, - &new_row_id, - new_collab_w_txn, - ); - } - - // imported_collab_by_oid contains all the collab object ids, including the row document collab object ids. - // So, if the id exist in the imported_collab_by_oid, it means the row document collab object is exist. - let imported_row_document_id = database_row_document_id_from_row_id(imported_row_id); - if collab_by_oid.get(&imported_row_document_id).is_some() { - let _ = old_to_new_id_map - .lock() - .exchange_new_id(&imported_row_document_id); - } - } - } - - Ok(()) -} - -fn make_collab_by_oid<'a, R>( - old_user: &AnonUser, - old_collab_r_txn: &R, - object_ids: &[String], -) -> HashMap -where - R: CollabKVAction<'a>, - PersistenceError: From, -{ - let mut collab_by_oid = HashMap::new(); - for object_id in object_ids { - let collab = Collab::new( - old_user.session.user_id, - object_id, - "migrate_device", - vec![], - false, - ); - match collab.with_origin_transact_mut(|txn| { - old_collab_r_txn.load_doc_with_txn(old_user.session.user_id, &object_id, txn) - }) { - Ok(_) => { - collab_by_oid.insert(object_id.clone(), collab); - }, - Err(err) => tracing::error!("🔴Initialize migration collab failed: {:?} ", err), - } - } - - collab_by_oid -} diff --git a/frontend/rust-lib/flowy-user/src/anon_user/mod.rs b/frontend/rust-lib/flowy-user/src/anon_user/mod.rs deleted file mode 100644 index 974850755f..0000000000 --- a/frontend/rust-lib/flowy-user/src/anon_user/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub use migrate_anon_user_collab::*; -pub use sync_supabase_user_collab::*; - -mod migrate_anon_user_collab; -mod sync_supabase_user_collab; diff --git a/frontend/rust-lib/flowy-user/src/anon_user/sync_supabase_user_collab.rs b/frontend/rust-lib/flowy-user/src/anon_user/sync_supabase_user_collab.rs deleted file mode 100644 index c6435531f3..0000000000 --- a/frontend/rust-lib/flowy-user/src/anon_user/sync_supabase_user_collab.rs +++ /dev/null @@ -1,381 +0,0 @@ -use std::future::Future; -use std::ops::Deref; -use std::pin::Pin; -use std::sync::Arc; - -use anyhow::{anyhow, Error}; -use collab::core::collab::MutexCollab; -use collab::preclude::Collab; -use collab_database::database::get_database_row_ids; -use collab_database::rows::database_row_document_id_from_row_id; -use collab_database::workspace_database::{get_all_database_meta, DatabaseMeta}; -use collab_entity::{CollabObject, CollabType}; -use collab_folder::{Folder, View, ViewLayout}; -use collab_plugins::local_storage::kv::KVTransactionDB; -use parking_lot::Mutex; - -use collab_integrate::{CollabKVAction, CollabKVDB, PersistenceError}; -use flowy_error::FlowyResult; -use flowy_user_pub::cloud::UserCloudService; -use flowy_user_pub::session::Session; - -#[tracing::instrument(level = "info", skip_all, err)] -pub async fn sync_supabase_user_data_to_cloud( - user_service: Arc, - device_id: &str, - new_user_session: &Session, - collab_db: &Arc, -) -> FlowyResult<()> { - let workspace_id = new_user_session.user_workspace.id.clone(); - let uid = new_user_session.user_id; - let folder = Arc::new( - sync_folder( - uid, - &workspace_id, - device_id, - collab_db, - user_service.clone(), - ) - .await?, - ); - - let database_records = sync_database_views( - uid, - &workspace_id, - device_id, - &new_user_session.user_workspace.database_indexer_id, - collab_db, - user_service.clone(), - ) - .await; - - let views = folder.lock().get_views_belong_to(&workspace_id); - for view in views { - let view_id = view.id.clone(); - if let Err(err) = sync_view( - uid, - folder.clone(), - database_records.clone(), - workspace_id.to_string(), - device_id.to_string(), - view, - collab_db.clone(), - user_service.clone(), - ) - .await - { - tracing::error!("🔴sync {} failed: {:?}", view_id, err); - } - } - tokio::task::yield_now().await; - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -fn sync_view( - uid: i64, - folder: Arc, - database_metas: Vec>, - workspace_id: String, - device_id: String, - view: Arc, - collab_db: Arc, - user_service: Arc, -) -> Pin> + Send + Sync>> { - Box::pin(async move { - let collab_type = collab_type_from_view_layout(&view.layout); - let object_id = object_id_from_view(&view, &database_metas)?; - tracing::debug!( - "sync view: {:?}:{} with object_id: {}", - view.layout, - view.id, - object_id - ); - - let collab_object = CollabObject::new( - uid, - object_id, - collab_type, - workspace_id.to_string(), - device_id.clone(), - ); - - match view.layout { - ViewLayout::Document => { - let doc_state = get_collab_doc_state(uid, &collab_object, &collab_db)?; - tracing::info!( - "sync object: {} with update: {}", - collab_object, - doc_state.len() - ); - user_service - .create_collab_object(&collab_object, doc_state) - .await?; - }, - ViewLayout::Grid | ViewLayout::Board | ViewLayout::Calendar => { - let (database_doc_state, row_ids) = - get_database_doc_state(uid, &collab_object, &collab_db)?; - tracing::info!( - "sync object: {} with update: {}", - collab_object, - database_doc_state.len() - ); - user_service - .create_collab_object(&collab_object, database_doc_state) - .await?; - - // sync database's row - for row_id in row_ids { - tracing::debug!("sync row: {}", row_id); - let document_id = database_row_document_id_from_row_id(&row_id); - - let database_row_collab_object = CollabObject::new( - uid, - row_id, - CollabType::DatabaseRow, - workspace_id.to_string(), - device_id.clone(), - ); - let database_row_doc_state = - get_collab_doc_state(uid, &database_row_collab_object, &collab_db)?; - tracing::info!( - "sync object: {} with update: {}", - database_row_collab_object, - database_row_doc_state.len() - ); - - let _ = user_service - .create_collab_object(&database_row_collab_object, database_row_doc_state) - .await; - - let database_row_document = CollabObject::new( - uid, - document_id, - CollabType::Document, - workspace_id.to_string(), - device_id.to_string(), - ); - // sync document in the row if exist - if let Ok(document_doc_state) = - get_collab_doc_state(uid, &database_row_document, &collab_db) - { - tracing::info!( - "sync database row document: {} with update: {}", - database_row_document, - document_doc_state.len() - ); - let _ = user_service - .create_collab_object(&database_row_document, document_doc_state) - .await; - } - } - }, - } - - tokio::task::yield_now().await; - - let child_views = folder.lock().views.get_views_belong_to(&view.id); - for child_view in child_views { - let cloned_child_view = child_view.clone(); - if let Err(err) = Box::pin(sync_view( - uid, - folder.clone(), - database_metas.clone(), - workspace_id.clone(), - device_id.to_string(), - child_view, - collab_db.clone(), - user_service.clone(), - )) - .await - { - tracing::error!( - "🔴sync {:?}:{} failed: {:?}", - cloned_child_view.layout, - cloned_child_view.id, - err - ) - } - tokio::task::yield_now().await; - } - Ok(()) - }) -} - -fn get_collab_doc_state( - uid: i64, - collab_object: &CollabObject, - collab_db: &Arc, -) -> Result, PersistenceError> { - let collab = Collab::new(uid, &collab_object.object_id, "phantom", vec![], false); - let _ = collab.with_origin_transact_mut(|txn| { - collab_db - .read_txn() - .load_doc_with_txn(uid, &collab_object.object_id, txn) - })?; - let doc_state = collab - .encode_collab_v1(|_| Ok::<(), PersistenceError>(()))? - .doc_state; - if doc_state.is_empty() { - return Err(PersistenceError::UnexpectedEmptyUpdates); - } - - Ok(doc_state.to_vec()) -} - -fn get_database_doc_state( - uid: i64, - collab_object: &CollabObject, - collab_db: &Arc, -) -> Result<(Vec, Vec), PersistenceError> { - let collab = Collab::new(uid, &collab_object.object_id, "phantom", vec![], false); - let _ = collab.with_origin_transact_mut(|txn| { - collab_db - .read_txn() - .load_doc_with_txn(uid, &collab_object.object_id, txn) - })?; - - let row_ids = get_database_row_ids(&collab).unwrap_or_default(); - let doc_state = collab - .encode_collab_v1(|_| Ok::<(), PersistenceError>(()))? - .doc_state; - if doc_state.is_empty() { - return Err(PersistenceError::UnexpectedEmptyUpdates); - } - - Ok((doc_state.to_vec(), row_ids)) -} - -async fn sync_folder( - uid: i64, - workspace_id: &str, - device_id: &str, - collab_db: &Arc, - user_service: Arc, -) -> Result { - let (folder, update) = { - let collab = Collab::new(uid, workspace_id, "phantom", vec![], false); - // Use the temporary result to short the lifetime of the TransactionMut - collab.with_origin_transact_mut(|txn| { - collab_db - .read_txn() - .load_doc_with_txn(uid, workspace_id, txn) - })?; - let doc_state = collab - .encode_collab_v1(|_| Ok::<(), PersistenceError>(()))? - .doc_state; - ( - MutexFolder::new(Folder::open(uid, Arc::new(MutexCollab::new(collab)), None)?), - doc_state, - ) - }; - - let collab_object = CollabObject::new( - uid, - workspace_id.to_string(), - CollabType::Folder, - workspace_id.to_string(), - device_id.to_string(), - ); - tracing::info!( - "sync object: {} with update: {}", - collab_object, - update.len() - ); - if let Err(err) = user_service - .create_collab_object(&collab_object, update.to_vec()) - .await - { - tracing::error!("🔴sync folder failed: {:?}", err); - } - - Ok(folder) -} - -async fn sync_database_views( - uid: i64, - workspace_id: &str, - device_id: &str, - database_views_aggregate_id: &str, - collab_db: &Arc, - user_service: Arc, -) -> Vec> { - let collab_object = CollabObject::new( - uid, - database_views_aggregate_id.to_string(), - CollabType::WorkspaceDatabase, - workspace_id.to_string(), - device_id.to_string(), - ); - - // Use the temporary result to short the lifetime of the TransactionMut - let result = { - let collab = Collab::new(uid, database_views_aggregate_id, "phantom", vec![], false); - collab - .with_origin_transact_mut(|txn| { - collab_db - .read_txn() - .load_doc_with_txn(uid, database_views_aggregate_id, txn) - }) - .map(|_| { - ( - get_all_database_meta(&collab), - collab - .encode_collab_v1(|_| Ok::<(), PersistenceError>(())) - .unwrap() - .doc_state, - ) - }) - }; - - if let Ok((records, doc_state)) = result { - let _ = user_service - .create_collab_object(&collab_object, doc_state.to_vec()) - .await; - records.into_iter().map(Arc::new).collect() - } else { - vec![] - } -} - -struct MutexFolder(Mutex); -impl MutexFolder { - pub fn new(folder: Folder) -> Self { - Self(Mutex::new(folder)) - } -} -impl Deref for MutexFolder { - type Target = Mutex; - fn deref(&self) -> &Self::Target { - &self.0 - } -} -unsafe impl Sync for MutexFolder {} -unsafe impl Send for MutexFolder {} - -fn collab_type_from_view_layout(view_layout: &ViewLayout) -> CollabType { - match view_layout { - ViewLayout::Document => CollabType::Document, - ViewLayout::Grid | ViewLayout::Board | ViewLayout::Calendar => CollabType::Database, - } -} - -fn object_id_from_view( - view: &Arc, - database_records: &[Arc], -) -> Result { - if view.layout.is_database() { - match database_records - .iter() - .find(|record| record.linked_views.contains(&view.id)) - { - None => Err(anyhow!( - "🔴sync view: {} failed: no database for this view", - view.id - )), - Some(record) => Ok(record.database_id.clone()), - } - } else { - Ok(view.id.clone()) - } -} diff --git a/frontend/rust-lib/flowy-user/src/entities/auth.rs b/frontend/rust-lib/flowy-user/src/entities/auth.rs index edad70387b..a61ba5cc96 100644 --- a/frontend/rust-lib/flowy-user/src/entities/auth.rs +++ b/frontend/rust-lib/flowy-user/src/entities/auth.rs @@ -1,12 +1,13 @@ use std::collections::HashMap; use std::convert::TryInto; +use crate::entities::parser::*; +use crate::entities::AuthTypePB; +use crate::errors::ErrorCode; +use client_api::entity::GotrueTokenResponse; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::entities::*; -use crate::entities::parser::*; -use crate::errors::ErrorCode; - #[derive(ProtoBuf, Default)] pub struct SignInPayloadPB { #[pb(index = 1)] @@ -19,7 +20,7 @@ pub struct SignInPayloadPB { pub name: String, #[pb(index = 4)] - pub auth_type: AuthenticatorPB, + pub auth_type: AuthTypePB, #[pb(index = 5)] pub device_id: String, @@ -30,11 +31,10 @@ impl TryInto for SignInPayloadPB { fn try_into(self) -> Result { let email = UserEmail::parse(self.email)?; - let password = UserPassword::parse(self.password)?; Ok(SignInParams { email: email.0, - password: password.0, + password: self.password, name: self.name, auth_type: self.auth_type.into(), }) @@ -53,7 +53,7 @@ pub struct SignUpPayloadPB { pub password: String, #[pb(index = 4)] - pub auth_type: AuthenticatorPB, + pub auth_type: AuthTypePB, #[pb(index = 5)] pub device_id: String, @@ -64,13 +64,13 @@ impl TryInto for SignUpPayloadPB { fn try_into(self) -> Result { let email = UserEmail::parse(self.email)?; - let password = UserPassword::parse(self.password)?; + let password = self.password; let name = UserName::parse(self.name)?; Ok(SignUpParams { email: email.0, name: name.0, - password: password.0, + password, auth_type: self.auth_type.into(), device_id: self.device_id, }) @@ -86,6 +86,53 @@ pub struct MagicLinkSignInPB { pub redirect_to: String, } +#[derive(ProtoBuf, Default)] +pub struct PasscodeSignInPB { + #[pb(index = 1)] + pub email: String, + + #[pb(index = 2)] + pub passcode: String, +} + +#[derive(ProtoBuf, Default, Debug, Clone)] +pub struct GotrueTokenResponsePB { + #[pb(index = 1)] + pub access_token: String, + + #[pb(index = 2)] + pub token_type: String, + + #[pb(index = 3)] + pub expires_in: i64, + + #[pb(index = 4)] + pub expires_at: i64, + + #[pb(index = 5)] + pub refresh_token: String, + + #[pb(index = 6, one_of)] + pub provider_access_token: Option, + + #[pb(index = 7, one_of)] + pub provider_refresh_token: Option, +} + +impl From for GotrueTokenResponsePB { + fn from(response: GotrueTokenResponse) -> Self { + Self { + access_token: response.access_token, + token_type: response.token_type, + expires_in: response.expires_in, + expires_at: response.expires_at, + refresh_token: response.refresh_token, + provider_access_token: response.provider_access_token, + provider_refresh_token: response.provider_refresh_token, + } + } +} + #[derive(ProtoBuf, Default)] pub struct OauthSignInPB { /// Use this field to store the third party auth information. @@ -97,7 +144,7 @@ pub struct OauthSignInPB { pub map: HashMap, #[pb(index = 2)] - pub authenticator: AuthenticatorPB, + pub authenticator: AuthTypePB, } #[derive(ProtoBuf, Default)] @@ -106,7 +153,7 @@ pub struct SignInUrlPayloadPB { pub email: String, #[pb(index = 2)] - pub authenticator: AuthenticatorPB, + pub authenticator: AuthTypePB, } #[derive(ProtoBuf, Default)] @@ -181,87 +228,10 @@ pub struct OauthProviderDataPB { pub oauth_url: String, } -#[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] -pub enum AuthenticatorPB { - Local = 0, - Supabase = 1, - AppFlowyCloud = 2, -} - -impl From for AuthenticatorPB { - fn from(auth_type: Authenticator) -> Self { - match auth_type { - Authenticator::Supabase => AuthenticatorPB::Supabase, - Authenticator::Local => AuthenticatorPB::Local, - Authenticator::AppFlowyCloud => AuthenticatorPB::AppFlowyCloud, - } - } -} - -impl From for Authenticator { - fn from(pb: AuthenticatorPB) -> Self { - match pb { - AuthenticatorPB::Supabase => Authenticator::Supabase, - AuthenticatorPB::Local => Authenticator::Local, - AuthenticatorPB::AppFlowyCloud => Authenticator::AppFlowyCloud, - } - } -} - -impl Default for AuthenticatorPB { - fn default() -> Self { - Self::Local - } -} - -#[derive(Debug, ProtoBuf, Default)] -pub struct UserCredentialsPB { - #[pb(index = 1, one_of)] - pub uid: Option, - - #[pb(index = 2, one_of)] - pub uuid: Option, - - #[pb(index = 3, one_of)] - pub token: Option, -} - -impl UserCredentialsPB { - pub fn from_uid(uid: i64) -> Self { - Self { - uid: Some(uid), - uuid: None, - token: None, - } - } - - pub fn from_token(token: &str) -> Self { - Self { - uid: None, - uuid: None, - token: Some(token.to_owned()), - } - } - - pub fn from_uuid(uuid: &str) -> Self { - Self { - uid: None, - uuid: Some(uuid.to_owned()), - token: None, - } - } -} - -impl From for UserCredentials { - fn from(value: UserCredentialsPB) -> Self { - Self::new(value.token, value.uid, value.uuid) - } -} - #[derive(Default, ProtoBuf)] pub struct UserStatePB { #[pb(index = 1)] - pub auth_type: AuthenticatorPB, + pub auth_type: AuthTypePB, } #[derive(ProtoBuf, Debug, Default, Clone)] diff --git a/frontend/rust-lib/flowy-user/src/entities/import_data.rs b/frontend/rust-lib/flowy-user/src/entities/import_data.rs index 023e3f9cfd..687b65835e 100644 --- a/frontend/rust-lib/flowy-user/src/entities/import_data.rs +++ b/frontend/rust-lib/flowy-user/src/entities/import_data.rs @@ -1,12 +1,16 @@ use flowy_derive::ProtoBuf; +use lib_infra::validator_fn::required_not_empty_str; use validator::Validate; #[derive(ProtoBuf, Validate, Default)] pub struct ImportAppFlowyDataPB { #[pb(index = 1)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub path: String, #[pb(index = 2, one_of)] pub import_container_name: Option, + + #[pb(index = 3, one_of)] + pub parent_view_id: Option, } diff --git a/frontend/rust-lib/flowy-user/src/entities/parser/user_email.rs b/frontend/rust-lib/flowy-user/src/entities/parser/user_email.rs index a3ec143317..f9a1979c53 100644 --- a/frontend/rust-lib/flowy-user/src/entities/parser/user_email.rs +++ b/frontend/rust-lib/flowy-user/src/entities/parser/user_email.rs @@ -1,5 +1,5 @@ use flowy_error::ErrorCode; -use validator::validate_email; +use validator::ValidateEmail; #[derive(Debug)] pub struct UserEmail(pub String); @@ -10,7 +10,7 @@ impl UserEmail { return Err(ErrorCode::EmailIsEmpty); } - if validate_email(&s) { + if ValidateEmail::validate_email(&s) { Ok(Self(s)) } else { Err(ErrorCode::EmailFormatInvalid) diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index 80dcfd1b7f..a117d0839e 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -1,14 +1,13 @@ -use std::convert::TryInto; -use validator::Validate; - +use super::AFRolePB; +use crate::entities::parser::{UserEmail, UserIcon, UserName}; +use crate::entities::AuthTypePB; +use crate::errors::ErrorCode; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_user_pub::entities::*; - -use crate::entities::parser::{UserEmail, UserIcon, UserName, UserOpenaiKey, UserPassword}; -use crate::entities::AuthenticatorPB; -use crate::errors::ErrorCode; - -use super::parser::UserStabilityAIKey; +use flowy_user_pub::sql::UserWorkspaceTable; +use lib_infra::validator_fn::required_not_empty_str; +use std::convert::TryInto; +use validator::Validate; #[derive(Default, ProtoBuf)] pub struct UserTokenPB { @@ -40,22 +39,10 @@ pub struct UserProfilePB { pub icon_url: String, #[pb(index = 6)] - pub openai_key: String, + pub user_auth_type: AuthTypePB, #[pb(index = 7)] - pub authenticator: AuthenticatorPB, - - #[pb(index = 8)] - pub encryption_sign: String, - - #[pb(index = 9)] - pub encryption_type: EncryptionTypePB, - - #[pb(index = 10)] - pub workspace_id: String, - - #[pb(index = 11)] - pub stability_ai_key: String, + pub workspace_auth_type: AuthTypePB, } #[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)] @@ -72,22 +59,14 @@ impl Default for EncryptionTypePB { impl From for UserProfilePB { fn from(user_profile: UserProfile) -> Self { - let (encryption_sign, encryption_ty) = match user_profile.encryption_type { - EncryptionType::NoEncryption => ("".to_string(), EncryptionTypePB::NoEncryption), - EncryptionType::SelfEncryption(sign) => (sign, EncryptionTypePB::Symmetric), - }; Self { id: user_profile.uid, email: user_profile.email, name: user_profile.name, token: user_profile.token, icon_url: user_profile.icon_url, - openai_key: user_profile.openai_key, - authenticator: user_profile.authenticator.into(), - encryption_sign, - encryption_type: encryption_ty, - workspace_id: user_profile.workspace_id, - stability_ai_key: user_profile.stability_ai_key, + user_auth_type: user_profile.auth_type.into(), + workspace_auth_type: user_profile.workspace_auth_type.into(), } } } @@ -108,12 +87,6 @@ pub struct UpdateUserProfilePayloadPB { #[pb(index = 5, one_of)] pub icon_url: Option, - - #[pb(index = 6, one_of)] - pub openai_key: Option, - - #[pb(index = 7, one_of)] - pub stability_ai_key: Option, } impl UpdateUserProfilePayloadPB { @@ -143,16 +116,6 @@ impl UpdateUserProfilePayloadPB { self.icon_url = Some(icon_url.to_owned()); self } - - pub fn openai_key(mut self, openai_key: &str) -> Self { - self.openai_key = Some(openai_key.to_owned()); - self - } - - pub fn stability_ai_key(mut self, stability_ai_key: &str) -> Self { - self.stability_ai_key = Some(stability_ai_key.to_owned()); - self - } } impl TryInto for UpdateUserProfilePayloadPB { @@ -169,36 +132,20 @@ impl TryInto for UpdateUserProfilePayloadPB { Some(email) => Some(UserEmail::parse(email)?.0), }; - let password = match self.password { - None => None, - Some(password) => Some(UserPassword::parse(password)?.0), - }; + let password = self.password; let icon_url = match self.icon_url { None => None, Some(icon_url) => Some(UserIcon::parse(icon_url)?.0), }; - let openai_key = match self.openai_key { - None => None, - Some(openai_key) => Some(UserOpenaiKey::parse(openai_key)?.0), - }; - - let stability_ai_key = match self.stability_ai_key { - None => None, - Some(stability_ai_key) => Some(UserStabilityAIKey::parse(stability_ai_key)?.0), - }; - Ok(UpdateUserProfileParams { uid: self.id, name, email, password, icon_url, - openai_key, - encryption_sign: None, token: None, - stability_ai_key, }) } } @@ -209,10 +156,14 @@ pub struct RepeatedUserWorkspacePB { pub items: Vec, } -impl From> for RepeatedUserWorkspacePB { - fn from(workspaces: Vec) -> Self { +impl From<(AuthType, Vec)> for RepeatedUserWorkspacePB { + fn from(value: (AuthType, Vec)) -> Self { + let (auth_type, workspaces) = value; Self { - items: workspaces.into_iter().map(UserWorkspacePB::from).collect(), + items: workspaces + .into_iter() + .map(|w| UserWorkspacePB::from((auth_type, w))) + .collect(), } } } @@ -220,7 +171,7 @@ impl From> for RepeatedUserWorkspacePB { #[derive(ProtoBuf, Default, Debug, Clone, Validate)] pub struct UserWorkspacePB { #[pb(index = 1)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub workspace_id: String, #[pb(index = 2)] @@ -231,15 +182,41 @@ pub struct UserWorkspacePB { #[pb(index = 4)] pub icon: String, + + #[pb(index = 5)] + pub member_count: i64, + + #[pb(index = 6, one_of)] + pub role: Option, + + #[pb(index = 7)] + pub workspace_auth_type: AuthTypePB, } -impl From for UserWorkspacePB { - fn from(value: UserWorkspace) -> Self { +impl From<(AuthType, UserWorkspace)> for UserWorkspacePB { + fn from(value: (AuthType, UserWorkspace)) -> Self { + Self { + workspace_id: value.1.id, + name: value.1.name, + created_at_timestamp: value.1.created_at.timestamp(), + icon: value.1.icon, + member_count: value.1.member_count, + role: value.1.role.map(AFRolePB::from), + workspace_auth_type: AuthTypePB::from(value.0), + } + } +} + +impl From for UserWorkspacePB { + fn from(value: UserWorkspaceTable) -> Self { Self { workspace_id: value.id, name: value.name, - created_at_timestamp: value.created_at.timestamp(), + created_at_timestamp: value.created_at, icon: value.icon, + member_count: value.member_count, + role: value.role.map(AFRolePB::from), + workspace_auth_type: AuthTypePB::from(value.workspace_type), } } } diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index cdbd928fe0..99544eede4 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -1,7 +1,14 @@ +use client_api::entity::billing_dto::{ + Currency, RecurringInterval, SubscriptionPlan, SubscriptionPlanDetail, + WorkspaceSubscriptionStatus, WorkspaceUsageAndLimit, +}; +use serde::{Deserialize, Serialize}; use validator::Validate; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; -use flowy_user_pub::entities::{Role, WorkspaceInvitation, WorkspaceMember}; +use flowy_user_pub::cloud::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; +use flowy_user_pub::entities::{AuthType, Role, WorkspaceInvitation, WorkspaceMember}; +use flowy_user_pub::sql::WorkspaceSettingsTable; use lib_infra::validator_fn::required_not_empty_str; #[derive(ProtoBuf, Default, Clone)] @@ -14,6 +21,9 @@ pub struct WorkspaceMemberPB { #[pb(index = 3)] pub role: AFRolePB, + + #[pb(index = 4, one_of)] + pub avatar_url: Option, } impl From for WorkspaceMemberPB { @@ -22,6 +32,7 @@ impl From for WorkspaceMemberPB { email: value.email, name: value.name, role: value.role.into(), + avatar_url: value.avatar_url, } } } @@ -35,7 +46,7 @@ pub struct RepeatedWorkspaceMemberPB { #[derive(ProtoBuf, Default, Clone, Validate)] pub struct WorkspaceMemberInvitationPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub workspace_id: String, #[pb(index = 2)] @@ -87,14 +98,15 @@ impl From for WorkspaceInvitationPB { #[derive(ProtoBuf, Default, Clone, Validate)] pub struct AcceptWorkspaceInvitationPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub invite_id: String, } +// Deprecated #[derive(ProtoBuf, Default, Clone, Validate)] pub struct AddWorkspaceMemberPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub workspace_id: String, #[pb(index = 2)] @@ -105,14 +117,14 @@ pub struct AddWorkspaceMemberPB { #[derive(ProtoBuf, Default, Clone, Validate)] pub struct QueryWorkspacePB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub workspace_id: String, } #[derive(ProtoBuf, Default, Clone, Validate)] pub struct RemoveWorkspaceMemberPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub workspace_id: String, #[pb(index = 2)] @@ -123,7 +135,7 @@ pub struct RemoveWorkspaceMemberPB { #[derive(ProtoBuf, Default, Clone, Validate)] pub struct UpdateWorkspaceMemberPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub workspace_id: String, #[pb(index = 2)] @@ -135,7 +147,7 @@ pub struct UpdateWorkspaceMemberPB { } // Workspace Role -#[derive(ProtoBuf_Enum, Clone, Default)] +#[derive(Debug, ProtoBuf_Enum, Clone, Default)] pub enum AFRolePB { Owner = 0, Member = 1, @@ -143,6 +155,17 @@ pub enum AFRolePB { Guest = 2, } +impl From for AFRolePB { + fn from(value: i32) -> Self { + match value { + 0 => AFRolePB::Owner, + 1 => AFRolePB::Member, + 2 => AFRolePB::Guest, + _ => AFRolePB::Guest, + } + } +} + impl From for Role { fn from(value: AFRolePB) -> Self { match value { @@ -166,34 +189,542 @@ impl From for AFRolePB { #[derive(ProtoBuf, Default, Clone, Validate)] pub struct UserWorkspaceIdPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub workspace_id: String, } +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct OpenUserWorkspacePB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub workspace_id: String, + + #[pb(index = 2)] + pub auth_type: AuthTypePB, +} + +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct CancelWorkspaceSubscriptionPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub workspace_id: String, + + #[pb(index = 2)] + pub plan: SubscriptionPlanPB, + + #[pb(index = 3)] + pub reason: String, +} + +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct SuccessWorkspaceSubscriptionPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub workspace_id: String, + + #[pb(index = 2, one_of)] + pub plan: Option, +} + +#[derive(ProtoBuf, Default, Clone)] +pub struct WorkspaceMemberIdPB { + #[pb(index = 1)] + pub uid: i64, +} + #[derive(ProtoBuf, Default, Clone, Validate)] pub struct CreateWorkspacePB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub name: String, + + #[pb(index = 2)] + pub auth_type: AuthTypePB, +} + +#[derive(ProtoBuf_Enum, Default, Debug, Clone, Eq, PartialEq)] +#[repr(u8)] +pub enum AuthTypePB { + #[default] + Local = 0, + Server = 1, +} + +impl From for AuthTypePB { + fn from(value: i32) -> Self { + match value { + 0 => AuthTypePB::Local, + 1 => AuthTypePB::Server, + _ => AuthTypePB::Server, + } + } +} + +impl From for AuthTypePB { + fn from(value: AuthType) -> Self { + match value { + AuthType::Local => AuthTypePB::Local, + AuthType::AppFlowyCloud => AuthTypePB::Server, + } + } +} + +impl From for AuthType { + fn from(value: AuthTypePB) -> Self { + match value { + AuthTypePB::Local => AuthType::Local, + AuthTypePB::Server => AuthType::AppFlowyCloud, + } + } } #[derive(ProtoBuf, Default, Clone, Validate)] pub struct RenameWorkspacePB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub workspace_id: String, #[pb(index = 2)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub new_name: String, } #[derive(ProtoBuf, Default, Clone, Validate)] pub struct ChangeWorkspaceIconPB { #[pb(index = 1)] - #[validate(custom = "required_not_empty_str")] + #[validate(custom(function = "required_not_empty_str"))] pub workspace_id: String, #[pb(index = 2)] pub new_icon: String, } + +#[derive(ProtoBuf, Default, Clone, Validate, Debug)] +pub struct SubscribeWorkspacePB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub workspace_id: String, + + #[pb(index = 2)] + pub recurring_interval: RecurringIntervalPB, + + #[pb(index = 3)] + pub workspace_subscription_plan: SubscriptionPlanPB, + + #[pb(index = 4)] + pub success_url: String, +} + +#[derive(ProtoBuf_Enum, Clone, Default, Debug, Serialize, Deserialize)] +pub enum RecurringIntervalPB { + #[default] + Month = 0, + Year = 1, +} + +impl From for RecurringInterval { + fn from(r: RecurringIntervalPB) -> Self { + match r { + RecurringIntervalPB::Month => RecurringInterval::Month, + RecurringIntervalPB::Year => RecurringInterval::Year, + } + } +} + +impl From for RecurringIntervalPB { + fn from(r: RecurringInterval) -> Self { + match r { + RecurringInterval::Month => RecurringIntervalPB::Month, + RecurringInterval::Year => RecurringIntervalPB::Year, + } + } +} + +#[derive(ProtoBuf_Enum, Clone, Default, Debug, Serialize, Deserialize)] +pub enum SubscriptionPlanPB { + #[default] + Free = 0, + Pro = 1, + Team = 2, + + // Add-ons + AiMax = 3, + AiLocal = 4, +} + +impl From for SubscriptionPlanPB { + fn from(value: WorkspacePlanPB) -> Self { + match value { + WorkspacePlanPB::FreePlan => SubscriptionPlanPB::Free, + WorkspacePlanPB::ProPlan => SubscriptionPlanPB::Pro, + WorkspacePlanPB::TeamPlan => SubscriptionPlanPB::Team, + } + } +} + +impl From for SubscriptionPlan { + fn from(value: SubscriptionPlanPB) -> Self { + match value { + SubscriptionPlanPB::Pro => SubscriptionPlan::Pro, + SubscriptionPlanPB::Team => SubscriptionPlan::Team, + SubscriptionPlanPB::Free => SubscriptionPlan::Free, + SubscriptionPlanPB::AiMax => SubscriptionPlan::AiMax, + SubscriptionPlanPB::AiLocal => SubscriptionPlan::AiLocal, + } + } +} + +impl From for SubscriptionPlanPB { + fn from(value: SubscriptionPlan) -> Self { + match value { + SubscriptionPlan::Pro => SubscriptionPlanPB::Pro, + SubscriptionPlan::Team => SubscriptionPlanPB::Team, + SubscriptionPlan::Free => SubscriptionPlanPB::Free, + SubscriptionPlan::AiMax => SubscriptionPlanPB::AiMax, + SubscriptionPlan::AiLocal => SubscriptionPlanPB::AiLocal, + } + } +} + +#[derive(Debug, ProtoBuf, Default, Clone)] +pub struct PaymentLinkPB { + #[pb(index = 1)] + pub payment_link: String, +} + +#[derive(Debug, ProtoBuf, Default, Clone)] +pub struct WorkspaceUsagePB { + #[pb(index = 1)] + pub member_count: u64, + #[pb(index = 2)] + pub member_count_limit: u64, + #[pb(index = 3)] + pub storage_bytes: u64, + #[pb(index = 4)] + pub storage_bytes_limit: u64, + #[pb(index = 5)] + pub storage_bytes_unlimited: bool, + #[pb(index = 6)] + pub ai_responses_count: u64, + #[pb(index = 7)] + pub ai_responses_count_limit: u64, + #[pb(index = 8)] + pub ai_responses_unlimited: bool, + #[pb(index = 9)] + pub local_ai: bool, +} + +impl From for WorkspaceUsagePB { + fn from(workspace_usage: WorkspaceUsageAndLimit) -> Self { + WorkspaceUsagePB { + member_count: workspace_usage.member_count as u64, + member_count_limit: workspace_usage.member_count_limit as u64, + storage_bytes: workspace_usage.storage_bytes as u64, + storage_bytes_limit: workspace_usage.storage_bytes_limit as u64, + storage_bytes_unlimited: workspace_usage.storage_bytes_unlimited, + ai_responses_count: workspace_usage.ai_responses_count as u64, + ai_responses_count_limit: workspace_usage.ai_responses_count_limit as u64, + ai_responses_unlimited: workspace_usage.ai_responses_unlimited, + local_ai: workspace_usage.local_ai, + } + } +} + +#[derive(Debug, ProtoBuf, Default, Clone)] +pub struct BillingPortalPB { + #[pb(index = 1)] + pub url: String, +} + +#[derive(ProtoBuf, Default, Clone, Validate, Eq, PartialEq)] +pub struct WorkspaceSettingsPB { + #[pb(index = 1)] + pub disable_search_indexing: bool, + + #[pb(index = 2)] + pub ai_model: String, +} + +impl From<&AFWorkspaceSettings> for WorkspaceSettingsPB { + fn from(value: &AFWorkspaceSettings) -> Self { + Self { + disable_search_indexing: value.disable_search_indexing, + ai_model: value.ai_model.clone(), + } + } +} + +impl From for WorkspaceSettingsPB { + fn from(value: WorkspaceSettingsTable) -> Self { + Self { + disable_search_indexing: value.disable_search_indexing, + ai_model: value.ai_model, + } + } +} + +#[derive(ProtoBuf, Default, Clone, Validate, Debug)] +pub struct UpdateUserWorkspaceSettingPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub workspace_id: String, + + #[pb(index = 2, one_of)] + pub disable_search_indexing: Option, + + #[pb(index = 3, one_of)] + pub ai_model: Option, +} + +impl From for AFWorkspaceSettingsChange { + fn from(value: UpdateUserWorkspaceSettingPB) -> Self { + let mut change = AFWorkspaceSettingsChange::new(); + if let Some(disable_search_indexing) = value.disable_search_indexing { + change.disable_search_indexing = Some(disable_search_indexing); + } + if let Some(ai_model) = value.ai_model { + change.ai_model = Some(ai_model); + } + change + } +} + +#[derive(Debug, ProtoBuf, Default, Clone)] +pub struct WorkspaceSubscriptionInfoPB { + #[pb(index = 1)] + pub plan: WorkspacePlanPB, + #[pb(index = 2)] + pub plan_subscription: WorkspaceSubscriptionV2PB, // valid if plan is not WorkspacePlanFree + #[pb(index = 3)] + pub add_ons: Vec, +} + +impl WorkspaceSubscriptionInfoPB { + pub fn default_from_workspace_id(workspace_id: String) -> Self { + Self { + plan: WorkspacePlanPB::FreePlan, + plan_subscription: WorkspaceSubscriptionV2PB { + workspace_id, + subscription_plan: SubscriptionPlanPB::Free, + status: WorkspaceSubscriptionStatusPB::Active, + end_date: 0, + interval: RecurringIntervalPB::Month, + }, + add_ons: Vec::new(), + } + } +} + +impl From> for WorkspaceSubscriptionInfoPB { + fn from(subs: Vec) -> Self { + let mut plan = WorkspacePlanPB::FreePlan; + let mut plan_subscription = WorkspaceSubscriptionV2PB::default(); + let mut add_ons = Vec::new(); + for sub in subs { + match sub.workspace_plan { + SubscriptionPlan::Free => { + plan = WorkspacePlanPB::FreePlan; + }, + SubscriptionPlan::Pro => { + plan = WorkspacePlanPB::ProPlan; + plan_subscription = sub.into(); + }, + SubscriptionPlan::Team => { + plan = WorkspacePlanPB::TeamPlan; + }, + SubscriptionPlan::AiMax => { + if plan_subscription.workspace_id.is_empty() { + plan_subscription = + WorkspaceSubscriptionV2PB::default_with_workspace_id(sub.workspace_id.clone()); + } + + add_ons.push(WorkspaceAddOnPB { + type_: WorkspaceAddOnPBType::AddOnAiMax, + add_on_subscription: sub.into(), + }); + }, + SubscriptionPlan::AiLocal => { + if plan_subscription.workspace_id.is_empty() { + plan_subscription = + WorkspaceSubscriptionV2PB::default_with_workspace_id(sub.workspace_id.clone()); + } + + add_ons.push(WorkspaceAddOnPB { + type_: WorkspaceAddOnPBType::AddOnAiLocal, + add_on_subscription: sub.into(), + }); + }, + } + } + + WorkspaceSubscriptionInfoPB { + plan, + plan_subscription, + add_ons, + } + } +} + +#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default)] +pub enum WorkspacePlanPB { + #[default] + FreePlan = 0, + ProPlan = 1, + TeamPlan = 2, +} + +impl From for i64 { + fn from(val: WorkspacePlanPB) -> Self { + val as i64 + } +} + +impl From for WorkspacePlanPB { + fn from(value: i64) -> Self { + match value { + 0 => WorkspacePlanPB::FreePlan, + 1 => WorkspacePlanPB::ProPlan, + 2 => WorkspacePlanPB::TeamPlan, + _ => WorkspacePlanPB::FreePlan, + } + } +} + +#[derive(Debug, ProtoBuf, Default, Clone, Serialize, Deserialize)] +pub struct WorkspaceAddOnPB { + #[pb(index = 1)] + type_: WorkspaceAddOnPBType, + #[pb(index = 2)] + add_on_subscription: WorkspaceSubscriptionV2PB, +} + +#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] +pub enum WorkspaceAddOnPBType { + #[default] + AddOnAiLocal = 0, + AddOnAiMax = 1, +} + +#[derive(Debug, ProtoBuf, Default, Clone, Serialize, Deserialize)] +pub struct WorkspaceSubscriptionV2PB { + #[pb(index = 1)] + pub workspace_id: String, + + #[pb(index = 2)] + pub subscription_plan: SubscriptionPlanPB, + + #[pb(index = 3)] + pub status: WorkspaceSubscriptionStatusPB, + + #[pb(index = 4)] + pub end_date: i64, // Unix timestamp of when this subscription cycle ends + + #[pb(index = 5)] + pub interval: RecurringIntervalPB, +} + +impl WorkspaceSubscriptionV2PB { + pub fn default_with_workspace_id(workspace_id: String) -> Self { + Self { + workspace_id, + subscription_plan: SubscriptionPlanPB::Free, + status: WorkspaceSubscriptionStatusPB::Active, + end_date: 0, + interval: RecurringIntervalPB::Month, + } + } +} + +impl From for WorkspaceSubscriptionV2PB { + fn from(sub: WorkspaceSubscriptionStatus) -> Self { + Self { + workspace_id: sub.workspace_id, + subscription_plan: sub.workspace_plan.clone().into(), + status: if sub.cancel_at.is_some() { + WorkspaceSubscriptionStatusPB::Canceled + } else { + WorkspaceSubscriptionStatusPB::Active + }, + interval: sub.recurring_interval.into(), + end_date: sub.current_period_end, + } + } +} + +#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] +pub enum WorkspaceSubscriptionStatusPB { + #[default] + Active = 0, + Canceled = 1, +} + +impl From for i64 { + fn from(val: WorkspaceSubscriptionStatusPB) -> Self { + val as i64 + } +} + +impl From for WorkspaceSubscriptionStatusPB { + fn from(value: i64) -> Self { + match value { + 0 => WorkspaceSubscriptionStatusPB::Active, + _ => WorkspaceSubscriptionStatusPB::Canceled, + } + } +} + +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct UpdateWorkspaceSubscriptionPaymentPeriodPB { + #[pb(index = 1)] + #[validate(custom(function = "required_not_empty_str"))] + pub workspace_id: String, + + #[pb(index = 2)] + pub plan: SubscriptionPlanPB, + + #[pb(index = 3)] + pub recurring_interval: RecurringIntervalPB, +} + +#[derive(ProtoBuf, Default, Clone)] +pub struct RepeatedSubscriptionPlanDetailPB { + #[pb(index = 1)] + pub items: Vec, +} + +#[derive(ProtoBuf, Default, Clone)] +pub struct SubscriptionPlanDetailPB { + #[pb(index = 1)] + pub currency: CurrencyPB, + #[pb(index = 2)] + pub price_cents: i64, + #[pb(index = 3)] + pub recurring_interval: RecurringIntervalPB, + #[pb(index = 4)] + pub plan: SubscriptionPlanPB, +} + +impl From for SubscriptionPlanDetailPB { + fn from(value: SubscriptionPlanDetail) -> Self { + Self { + currency: value.currency.into(), + price_cents: value.price_cents, + recurring_interval: value.recurring_interval.into(), + plan: value.plan.into(), + } + } +} + +#[derive(ProtoBuf_Enum, Clone, Default)] +pub enum CurrencyPB { + #[default] + USD = 0, +} + +impl From for CurrencyPB { + fn from(value: Currency) -> Self { + match value { + Currency::USD => CurrencyPB::USD, + } + } +} diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index 34389b8793..bf865f24f0 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -1,14 +1,3 @@ -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_sqlite::kv::StorePreferences; -use flowy_user_pub::cloud::UserCloudConfig; -use flowy_user_pub::entities::*; -use lib_dispatch::prelude::*; -use lib_infra::box_any::BoxAny; -use serde_json::Value; -use std::sync::Weak; -use std::{convert::TryInto, sync::Arc}; -use tracing::{event, trace}; - use crate::entities::*; use crate::notification::{send_notification, UserNotification}; use crate::services::cloud_config::{ @@ -16,6 +5,18 @@ use crate::services::cloud_config::{ }; use crate::services::data_import::prepare_import; use crate::user_manager::UserManager; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_sqlite::kv::KVStorePreferences; +use flowy_user_pub::entities::*; +use flowy_user_pub::sql::UserWorkspaceChangeset; +use lib_dispatch::prelude::*; +use lib_infra::box_any::BoxAny; +use serde_json::Value; +use std::str::FromStr; +use std::sync::Weak; +use std::{convert::TryInto, sync::Arc}; +use tracing::{event, trace}; +use uuid::Uuid; fn upgrade_manager(manager: AFPluginState>) -> FlowyResult> { let manager = manager @@ -25,32 +26,30 @@ fn upgrade_manager(manager: AFPluginState>) -> FlowyResult>, -) -> FlowyResult> { + store: AFPluginState>, +) -> FlowyResult> { let store = store .upgrade() .ok_or(FlowyError::internal().with_context("The store preferences is already drop"))?; Ok(store) } -#[tracing::instrument(level = "debug", name = "sign_in", skip(data, manager), fields(email = %data.email), err)] +#[tracing::instrument(level = "debug", name = "sign_in", skip(data, manager), fields( + email = % data.email +), err)] pub async fn sign_in_with_email_password_handler( data: AFPluginData, manager: AFPluginState>, -) -> DataResult { +) -> DataResult { let manager = upgrade_manager(manager)?; let params: SignInParams = data.into_inner().try_into()?; - let auth_type = params.auth_type.clone(); - let old_authenticator = manager.cloud_services.get_user_authenticator(); - match manager.sign_in(params, auth_type).await { - Ok(profile) => data_result_ok(UserProfilePB::from(profile)), - Err(err) => { - manager - .cloud_services - .set_user_authenticator(&old_authenticator); - return Err(err); - }, + match manager + .sign_in_with_password(¶ms.email, ¶ms.password) + .await + { + Ok(token) => data_result_ok(token.into()), + Err(err) => Err(err), } } @@ -59,8 +58,8 @@ pub async fn sign_in_with_email_password_handler( name = "sign_up", skip(data, manager), fields( - email = %data.email, - name = %data.name, + email = % data.email, + name = % data.name, ), err )] @@ -70,17 +69,11 @@ pub async fn sign_up( ) -> DataResult { let manager = upgrade_manager(manager)?; let params: SignUpParams = data.into_inner().try_into()?; - let authenticator = params.auth_type.clone(); + let auth_type = params.auth_type; - let old_authenticator = manager.cloud_services.get_user_authenticator(); - match manager.sign_up(authenticator, BoxAny::new(params)).await { + match manager.sign_up(auth_type, BoxAny::new(params)).await { Ok(profile) => data_result_ok(UserProfilePB::from(profile)), - Err(err) => { - manager - .cloud_services - .set_user_authenticator(&old_authenticator); - return Err(err); - }, + Err(err) => Err(err), } } @@ -98,22 +91,28 @@ pub async fn get_user_profile_handler( manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; - let uid = manager.get_session()?.user_id; - let mut user_profile = manager.get_user_profile_from_disk(uid).await?; + let session = manager.get_session()?; + + let mut user_profile = manager + .get_user_profile_from_disk(session.user_id, &session.user_workspace.id) + .await?; let weak_manager = Arc::downgrade(&manager); let cloned_user_profile = user_profile.clone(); + let workspace_id = session.user_workspace.id.clone(); // Refresh the user profile in the background - af_spawn(async move { + tokio::spawn(async move { if let Some(manager) = weak_manager.upgrade() { - let _ = manager.refresh_user_profile(&cloned_user_profile).await; + let _ = manager + .refresh_user_profile(&cloned_user_profile, &workspace_id) + .await; } }); // When the user is logged in with a local account, the email field is a placeholder and should // not be exposed to the client. So we set the email field to an empty string. - if user_profile.authenticator == Authenticator::Local { + if user_profile.auth_type == AuthType::Local { user_profile.email = "".to_string(); } @@ -136,6 +135,15 @@ pub async fn sign_out_handler(manager: AFPluginState>) -> Resu Ok(()) } +#[tracing::instrument(level = "debug", skip(manager))] +pub async fn delete_account_handler( + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let manager = upgrade_manager(manager)?; + manager.delete_account().await?; + Ok(()) +} + #[tracing::instrument(level = "debug", skip(data, manager))] pub async fn update_user_profile_handler( data: AFPluginData, @@ -151,7 +159,7 @@ const APPEARANCE_SETTING_CACHE_KEY: &str = "appearance_settings"; #[tracing::instrument(level = "debug", skip_all, err)] pub async fn set_appearance_setting( - store_preferences: AFPluginState>, + store_preferences: AFPluginState>, data: AFPluginData, ) -> Result<(), FlowyError> { let store_preferences = upgrade_store_preferences(store_preferences)?; @@ -159,13 +167,13 @@ pub async fn set_appearance_setting( if setting.theme.is_empty() { setting.theme = APPEARANCE_DEFAULT_THEME.to_string(); } - store_preferences.set_object(APPEARANCE_SETTING_CACHE_KEY, setting)?; + store_preferences.set_object(APPEARANCE_SETTING_CACHE_KEY, &setting)?; Ok(()) } #[tracing::instrument(level = "debug", skip_all, err)] pub async fn get_appearance_setting( - store_preferences: AFPluginState>, + store_preferences: AFPluginState>, ) -> DataResult { let store_preferences = upgrade_store_preferences(store_preferences)?; match store_preferences.get_str(APPEARANCE_SETTING_CACHE_KEY) { @@ -187,7 +195,7 @@ const DATE_TIME_SETTINGS_CACHE_KEY: &str = "date_time_settings"; #[tracing::instrument(level = "debug", skip_all, err)] pub async fn set_date_time_settings( - store_preferences: AFPluginState>, + store_preferences: AFPluginState>, data: AFPluginData, ) -> Result<(), FlowyError> { let store_preferences = upgrade_store_preferences(store_preferences)?; @@ -196,13 +204,13 @@ pub async fn set_date_time_settings( setting.timezone_id = "".to_string(); } - store_preferences.set_object(DATE_TIME_SETTINGS_CACHE_KEY, setting)?; + store_preferences.set_object(DATE_TIME_SETTINGS_CACHE_KEY, &setting)?; Ok(()) } #[tracing::instrument(level = "debug", skip_all, err)] pub async fn get_date_time_settings( - store_preferences: AFPluginState>, + store_preferences: AFPluginState>, ) -> DataResult { let store_preferences = upgrade_store_preferences(store_preferences)?; match store_preferences.get_str(DATE_TIME_SETTINGS_CACHE_KEY) { @@ -227,18 +235,18 @@ const NOTIFICATION_SETTINGS_CACHE_KEY: &str = "notification_settings"; #[tracing::instrument(level = "debug", skip_all, err)] pub async fn set_notification_settings( - store_preferences: AFPluginState>, + store_preferences: AFPluginState>, data: AFPluginData, ) -> Result<(), FlowyError> { let store_preferences = upgrade_store_preferences(store_preferences)?; let setting = data.into_inner(); - store_preferences.set_object(NOTIFICATION_SETTINGS_CACHE_KEY, setting)?; + store_preferences.set_object(NOTIFICATION_SETTINGS_CACHE_KEY, &setting)?; Ok(()) } #[tracing::instrument(level = "debug", skip_all, err)] pub async fn get_notification_settings( - store_preferences: AFPluginState>, + store_preferences: AFPluginState>, ) -> DataResult { let store_preferences = upgrade_store_preferences(store_preferences)?; match store_preferences.get_str(NOTIFICATION_SETTINGS_CACHE_KEY) { @@ -263,12 +271,16 @@ pub async fn import_appflowy_data_folder_handler( ) -> Result<(), FlowyError> { let data = data.try_into_inner()?; let (tx, rx) = tokio::sync::oneshot::channel(); - af_spawn(async move { + tokio::spawn(async move { let result = async { let manager = upgrade_manager(manager)?; - let imported_folder = prepare_import(&data.path) - .map_err(|err| FlowyError::new(ErrorCode::AppFlowyDataFolderImportError, err.to_string()))? - .with_container_name(data.import_container_name); + let imported_folder = prepare_import( + &data.path, + data.parent_view_id, + &manager.authenticate_user.user_config.app_version, + ) + .map_err(|err| FlowyError::new(ErrorCode::AppFlowyDataFolderImportError, err.to_string()))? + .with_container_name(data.import_container_name); manager.perform_import(imported_folder).await?; Ok::<(), FlowyError>(()) @@ -302,6 +314,19 @@ pub async fn sign_in_with_magic_link_handler( Ok(()) } +#[tracing::instrument(level = "debug", skip(data, manager), err)] +pub async fn sign_in_with_passcode_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let params = data.into_inner(); + let response = manager + .sign_in_with_passcode(¶ms.email, ¶ms.passcode) + .await?; + data_result_ok(response.into()) +} + #[tracing::instrument(level = "debug", skip(data, manager), err)] pub async fn oauth_sign_in_handler( data: AFPluginData, @@ -309,7 +334,7 @@ pub async fn oauth_sign_in_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params = data.into_inner(); - let authenticator: Authenticator = params.authenticator.into(); + let authenticator: AuthType = params.authenticator.into(); let user_profile = manager .sign_up(authenticator, BoxAny::new(params.map)) .await?; @@ -323,7 +348,7 @@ pub async fn gen_sign_in_url_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let params = data.into_inner(); - let authenticator: Authenticator = params.authenticator.into(); + let authenticator: AuthType = params.authenticator.into(); let sign_in_url = manager .generate_sign_in_url_with_email(&authenticator, ¶ms.email) .await?; @@ -344,71 +369,11 @@ pub async fn sign_in_with_provider_handler( }) } -#[tracing::instrument(level = "debug", skip_all, err)] -pub async fn set_encrypt_secret_handler( - manager: AFPluginState>, - data: AFPluginData, - store_preferences: AFPluginState>, -) -> Result<(), FlowyError> { - let manager = upgrade_manager(manager)?; - let store_preferences = upgrade_store_preferences(store_preferences)?; - let data = data.into_inner(); - match data.encryption_type { - EncryptionTypePB::NoEncryption => { - tracing::error!("Encryption type is NoEncryption, but set encrypt secret"); - }, - EncryptionTypePB::Symmetric => { - manager.check_encryption_sign_with_secret( - data.user_id, - &data.encryption_sign, - &data.encryption_secret, - )?; - - let config = UserCloudConfig::new(data.encryption_secret).with_enable_encrypt(true); - manager - .set_encrypt_secret( - data.user_id, - config.encrypt_secret.clone(), - EncryptionType::SelfEncryption(data.encryption_sign), - ) - .await?; - save_cloud_config(data.user_id, &store_preferences, config)?; - }, - } - - manager.resume_sign_up().await?; - Ok(()) -} - -#[tracing::instrument(level = "debug", skip_all, err)] -pub async fn check_encrypt_secret_handler( - manager: AFPluginState>, -) -> DataResult { - let manager = upgrade_manager(manager)?; - let uid = manager.get_session()?.user_id; - let profile = manager.get_user_profile_from_disk(uid).await?; - - let is_need_secret = match profile.encryption_type { - EncryptionType::NoEncryption => false, - EncryptionType::SelfEncryption(sign) => { - if sign.is_empty() { - false - } else { - manager.check_encryption_sign(uid, &sign).is_err() - } - }, - }; - - data_result_ok(UserEncryptionConfigurationPB { - require_secret: is_need_secret, - }) -} - #[tracing::instrument(level = "debug", skip_all, err)] pub async fn set_cloud_config_handler( manager: AFPluginState>, data: AFPluginData, - store_preferences: AFPluginState>, + store_preferences: AFPluginState>, ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let session = manager.get_session()?; @@ -419,40 +384,18 @@ pub async fn set_cloud_config_handler( if let Some(enable_sync) = update.enable_sync { manager - .cloud_services + .cloud_service .set_enable_sync(session.user_id, enable_sync); config.enable_sync = enable_sync; } - if let Some(enable_encrypt) = update.enable_encrypt { - debug_assert!(enable_encrypt, "Disable encryption is not supported"); - - if enable_encrypt { - tracing::info!("Enable encryption for user: {}", session.user_id); - config = config.with_enable_encrypt(enable_encrypt); - let encrypt_secret = config.encrypt_secret.clone(); - - // The encryption secret is generated when the user first enables encryption and will be - // used to validate the encryption secret is correct when the user logs in. - let encryption_sign = manager.generate_encryption_sign(session.user_id, &encrypt_secret)?; - let encryption_type = EncryptionType::SelfEncryption(encryption_sign); - manager - .set_encrypt_secret(session.user_id, encrypt_secret, encryption_type.clone()) - .await?; - - let params = - UpdateUserProfileParams::new(session.user_id).with_encryption_type(encryption_type); - manager.update_user_profile(params).await?; - } - } - - save_cloud_config(session.user_id, &store_preferences, config.clone())?; + save_cloud_config(session.user_id, &store_preferences, &config)?; let payload = CloudSettingPB { enable_sync: config.enable_sync, enable_encrypt: config.enable_encrypt, encrypt_secret: config.encrypt_secret, - server_url: manager.cloud_services.service_url(), + server_url: manager.cloud_service.service_url(), }; send_notification( @@ -468,7 +411,7 @@ pub async fn set_cloud_config_handler( #[tracing::instrument(level = "info", skip_all, err)] pub async fn get_cloud_config_handler( manager: AFPluginState>, - store_preferences: AFPluginState>, + store_preferences: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; let session = manager.get_session()?; @@ -479,7 +422,7 @@ pub async fn get_cloud_config_handler( enable_sync: config.enable_sync, enable_encrypt: config.enable_encrypt, encrypt_secret: config.encrypt_secret, - server_url: manager.cloud_services.service_url(), + server_url: manager.cloud_service.service_url(), }) } @@ -488,22 +431,47 @@ pub async fn get_all_workspace_handler( manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; - let uid = manager.get_session()?.user_id; - let user_workspaces = manager.get_all_user_workspaces(uid).await?; - data_result_ok(user_workspaces.into()) + let session = manager.get_session()?; + let profile = manager + .get_user_profile_from_disk(session.user_id, &session.user_workspace.id) + .await?; + let user_workspaces = manager + .get_all_user_workspaces(profile.uid, profile.auth_type) + .await?; + + data_result_ok(RepeatedUserWorkspacePB::from(( + profile.auth_type, + user_workspaces, + ))) } #[tracing::instrument(level = "info", skip(data, manager), err)] pub async fn open_workspace_handler( - data: AFPluginData, + data: AFPluginData, manager: AFPluginState>, ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let params = data.try_into_inner()?; - manager.open_workspace(¶ms.workspace_id).await?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + manager + .open_workspace(&workspace_id, AuthType::from(params.auth_type)) + .await?; Ok(()) } +#[tracing::instrument(level = "info", skip(data, manager), err)] +pub async fn get_user_workspace_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let params = data.try_into_inner()?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + let uid = manager.user_id()?; + let user_workspace = manager.get_user_workspace_from_db(uid, &workspace_id)?; + data_result_ok(UserWorkspacePB::from(user_workspace)) +} + #[tracing::instrument(level = "debug", skip(data, manager), err)] pub async fn update_network_state_handler( data: AFPluginData, @@ -511,12 +479,12 @@ pub async fn update_network_state_handler( ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; let reachable = data.into_inner().ty.is_reachable(); - manager.cloud_services.set_network_reachable(reachable); + manager.cloud_service.set_network_reachable(reachable); manager .user_status_callback .read() .await - .did_update_network(reachable); + .on_network_status_changed(reachable); Ok(()) } @@ -581,24 +549,6 @@ pub async fn get_all_reminder_event_handler( data_result_ok(reminders.into()) } -#[tracing::instrument(level = "debug", skip_all, err)] -pub async fn reset_workspace_handler( - data: AFPluginData, - manager: AFPluginState>, -) -> Result<(), FlowyError> { - let manager = upgrade_manager(manager)?; - let reset_pb = data.into_inner(); - if reset_pb.workspace_id.is_empty() { - return Err(FlowyError::new( - ErrorCode::WorkspaceInitializeError, - "The workspace id is empty", - )); - } - let _session = manager.get_session()?; - manager.reset_workspace(reset_pb).await?; - Ok(()) -} - #[tracing::instrument(level = "debug", skip_all, err)] pub async fn remove_reminder_event_handler( data: AFPluginData, @@ -623,19 +573,6 @@ pub async fn update_reminder_event_handler( Ok(()) } -#[tracing::instrument(level = "debug", skip_all, err)] -pub async fn add_workspace_member_handler( - data: AFPluginData, - manager: AFPluginState>, -) -> Result<(), FlowyError> { - let data = data.try_into_inner()?; - let manager = upgrade_manager(manager)?; - manager - .add_workspace_member(data.email, data.workspace_id) - .await?; - Ok(()) -} - #[tracing::instrument(level = "debug", skip_all, err)] pub async fn delete_workspace_member_handler( data: AFPluginData, @@ -643,21 +580,23 @@ pub async fn delete_workspace_member_handler( ) -> Result<(), FlowyError> { let data = data.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&data.workspace_id)?; manager - .remove_workspace_member(data.email, data.workspace_id) + .remove_workspace_member(data.email, workspace_id) .await?; Ok(()) } #[tracing::instrument(level = "debug", skip_all, err)] -pub async fn get_workspace_member_handler( +pub async fn get_workspace_members_handler( data: AFPluginData, manager: AFPluginState>, ) -> DataResult { let data = data.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&data.workspace_id)?; let members = manager - .get_workspace_members(data.workspace_id) + .get_workspace_members(workspace_id) .await? .into_iter() .map(WorkspaceMemberPB::from) @@ -672,8 +611,9 @@ pub async fn update_workspace_member_handler( ) -> Result<(), FlowyError> { let data = data.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&data.workspace_id)?; manager - .update_workspace_member(data.email, data.workspace_id, data.role.into()) + .update_workspace_member(data.email, workspace_id, data.role.into()) .await?; Ok(()) } @@ -684,9 +624,10 @@ pub async fn create_workspace_handler( manager: AFPluginState>, ) -> DataResult { let data = data.try_into_inner()?; + let auth_type = AuthType::from(data.auth_type); let manager = upgrade_manager(manager)?; - let new_workspace = manager.add_workspace(&data.name).await?; - data_result_ok(new_workspace.into()) + let new_workspace = manager.create_workspace(&data.name, auth_type).await?; + data_result_ok(UserWorkspacePB::from((auth_type, new_workspace))) } #[tracing::instrument(level = "debug", skip_all, err)] @@ -696,6 +637,7 @@ pub async fn delete_workspace_handler( ) -> Result<(), FlowyError> { let workspace_id = delete_workspace_param.try_into_inner()?.workspace_id; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(&workspace_id)?; manager.delete_workspace(&workspace_id).await?; Ok(()) } @@ -707,9 +649,15 @@ pub async fn rename_workspace_handler( ) -> Result<(), FlowyError> { let params = rename_workspace_param.try_into_inner()?; let manager = upgrade_manager(manager)?; - manager - .patch_workspace(¶ms.workspace_id, Some(¶ms.new_name), None) - .await?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + let changeset = UserWorkspaceChangeset { + id: params.workspace_id, + name: Some(params.new_name), + icon: None, + role: None, + member_count: None, + }; + manager.patch_workspace(&workspace_id, changeset).await?; Ok(()) } @@ -720,9 +668,15 @@ pub async fn change_workspace_icon_handler( ) -> Result<(), FlowyError> { let params = change_workspace_icon_param.try_into_inner()?; let manager = upgrade_manager(manager)?; - manager - .patch_workspace(¶ms.workspace_id, None, Some(¶ms.new_icon)) - .await?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + let changeset = UserWorkspaceChangeset { + id: workspace_id.to_string(), + name: None, + icon: Some(params.new_icon), + role: None, + member_count: None, + }; + manager.patch_workspace(&workspace_id, changeset).await?; Ok(()) } @@ -733,8 +687,9 @@ pub async fn invite_workspace_member_handler( ) -> Result<(), FlowyError> { let param = param.try_into_inner()?; let manager = upgrade_manager(manager)?; + let workspace_id = Uuid::from_str(¶m.workspace_id)?; manager - .invite_member_to_workspace(param.workspace_id, param.invitee_email, param.role.into()) + .invite_member_to_workspace(workspace_id, param.invitee_email, param.role.into()) .await?; Ok(()) } @@ -770,7 +725,143 @@ pub async fn leave_workspace_handler( manager: AFPluginState>, ) -> Result<(), FlowyError> { let workspace_id = param.into_inner().workspace_id; + let workspace_id = Uuid::from_str(&workspace_id)?; let manager = upgrade_manager(manager)?; manager.leave_workspace(&workspace_id).await?; Ok(()) } + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn subscribe_workspace_handler( + params: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let params = params.try_into_inner()?; + let manager = upgrade_manager(manager)?; + let payment_link = manager.subscribe_workspace(params).await?; + data_result_ok(PaymentLinkPB { payment_link }) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn get_workspace_subscription_info_handler( + params: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let params = params.try_into_inner()?; + let manager = upgrade_manager(manager)?; + let subs = manager + .get_workspace_subscription_info(params.workspace_id) + .await?; + data_result_ok(subs) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn cancel_workspace_subscription_handler( + param: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let params = param.into_inner(); + let manager = upgrade_manager(manager)?; + manager + .cancel_workspace_subscription(params.workspace_id, params.plan.into(), Some(params.reason)) + .await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn get_workspace_usage_handler( + param: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let workspace_id = Uuid::from_str(¶m.into_inner().workspace_id)?; + let manager = upgrade_manager(manager)?; + let workspace_usage = manager.get_workspace_usage(&workspace_id).await?; + data_result_ok(WorkspaceUsagePB::from(workspace_usage)) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn get_billing_portal_handler( + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let url = manager.get_billing_portal_url().await?; + data_result_ok(BillingPortalPB { url }) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn update_workspace_subscription_payment_period_handler( + params: AFPluginData, + manager: AFPluginState>, +) -> FlowyResult<()> { + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + let params = params.try_into_inner()?; + let manager = upgrade_manager(manager)?; + manager + .update_workspace_subscription_payment_period( + &workspace_id, + params.plan.into(), + params.recurring_interval.into(), + ) + .await +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn get_subscription_plan_details_handler( + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let plans = manager + .get_subscription_plan_details() + .await? + .into_iter() + .map(SubscriptionPlanDetailPB::from) + .collect::>(); + data_result_ok(RepeatedSubscriptionPlanDetailPB { items: plans }) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn get_workspace_member_info( + param: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let workspace_id = manager.get_session()?.user_workspace.workspace_id()?; + let member = manager + .get_workspace_member_info(param.uid, &workspace_id) + .await?; + data_result_ok(member.into()) +} + +#[tracing::instrument(level = "info", skip_all, err)] +pub async fn update_workspace_setting_handler( + params: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let params = params.try_into_inner()?; + let manager = upgrade_manager(manager)?; + manager.update_workspace_setting(params).await?; + Ok(()) +} + +#[tracing::instrument(level = "info", skip_all, err)] +pub async fn get_workspace_setting_handler( + params: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let params = params.try_into_inner()?; + let workspace_id = Uuid::from_str(¶ms.workspace_id)?; + let manager = upgrade_manager(manager)?; + let pb = manager.get_workspace_settings(&workspace_id).await?; + data_result_ok(pb) +} + +#[tracing::instrument(level = "info", skip_all, err)] +pub async fn notify_did_switch_plan_handler( + params: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let success = params.into_inner(); + let manager = upgrade_manager(manager)?; + manager.notify_did_switch_plan(success).await?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 1c1530dbd5..fbee0a96d9 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -1,13 +1,13 @@ -use std::sync::Weak; - -use strum_macros::Display; - +use client_api::entity::billing_dto::SubscriptionPlan; use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; use flowy_error::FlowyResult; use flowy_user_pub::cloud::UserCloudConfig; use flowy_user_pub::entities::*; use lib_dispatch::prelude::*; -use lib_infra::future::{to_fut, Fut}; +use lib_infra::async_trait::async_trait; +use std::sync::Weak; +use strum_macros::Display; +use uuid::Uuid; use crate::event_handler::*; use crate::user_manager::UserManager; @@ -28,18 +28,18 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::InitUser, init_user_handler) .event(UserEvent::GetUserProfile, get_user_profile_handler) .event(UserEvent::SignOut, sign_out_handler) + .event(UserEvent::DeleteAccount, delete_account_handler) .event(UserEvent::UpdateUserProfile, update_user_profile_handler) .event(UserEvent::SetAppearanceSetting, set_appearance_setting) .event(UserEvent::GetAppearanceSetting, get_appearance_setting) .event(UserEvent::GetUserSetting, get_user_setting) .event(UserEvent::SetCloudConfig, set_cloud_config_handler) .event(UserEvent::GetCloudConfig, get_cloud_config_handler) - .event(UserEvent::SetEncryptionSecret, set_encrypt_secret_handler) - .event(UserEvent::CheckEncryptionSign, check_encrypt_secret_handler) .event(UserEvent::OauthSignIn, oauth_sign_in_handler) .event(UserEvent::GenerateSignInURL, gen_sign_in_url_handler) .event(UserEvent::GetOauthURLWithProvider, sign_in_with_provider_handler) .event(UserEvent::OpenWorkspace, open_workspace_handler) + .event(UserEvent::GetUserWorkspace, get_user_workspace_handler) .event(UserEvent::UpdateNetworkState, update_network_state_handler) .event(UserEvent::OpenAnonUser, open_anon_user_handler) .event(UserEvent::GetAnonUser, get_anon_user_handler) @@ -48,18 +48,14 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::GetAllReminders, get_all_reminder_event_handler) .event(UserEvent::RemoveReminder, remove_reminder_event_handler) .event(UserEvent::UpdateReminder, update_reminder_event_handler) - .event(UserEvent::ResetWorkspace, reset_workspace_handler) .event(UserEvent::SetDateTimeSettings, set_date_time_settings) .event(UserEvent::GetDateTimeSettings, get_date_time_settings) .event(UserEvent::SetNotificationSettings, set_notification_settings) .event(UserEvent::GetNotificationSettings, get_notification_settings) .event(UserEvent::ImportAppFlowyDataFolder, import_appflowy_data_folder_handler) - // Workspace member - .event(UserEvent::AddWorkspaceMember, add_workspace_member_handler) // deprecated, use invite - // instead - + .event(UserEvent::GetMemberInfo, get_workspace_member_info) .event(UserEvent::RemoveWorkspaceMember, delete_workspace_member_handler) - .event(UserEvent::GetWorkspaceMember, get_workspace_member_handler) + .event(UserEvent::GetWorkspaceMembers, get_workspace_members_handler) .event(UserEvent::UpdateWorkspaceMember, update_workspace_member_handler) // Workspace .event(UserEvent::GetAllWorkspace, get_all_workspace_handler) @@ -71,17 +67,30 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::InviteWorkspaceMember, invite_workspace_member_handler) .event(UserEvent::ListWorkspaceInvitations, list_workspace_invitations_handler) .event(UserEvent::AcceptWorkspaceInvitation, accept_workspace_invitations_handler) + // Billing + .event(UserEvent::SubscribeWorkspace, subscribe_workspace_handler) + .event(UserEvent::GetWorkspaceSubscriptionInfo, get_workspace_subscription_info_handler) + .event(UserEvent::CancelWorkspaceSubscription, cancel_workspace_subscription_handler) + .event(UserEvent::GetWorkspaceUsage, get_workspace_usage_handler) + .event(UserEvent::GetBillingPortal, get_billing_portal_handler) + .event(UserEvent::UpdateWorkspaceSubscriptionPaymentPeriod, update_workspace_subscription_payment_period_handler) + .event(UserEvent::GetSubscriptionPlanDetails, get_subscription_plan_details_handler) + // Workspace Setting + .event(UserEvent::UpdateWorkspaceSetting, update_workspace_setting_handler) + .event(UserEvent::GetWorkspaceSetting, get_workspace_setting_handler) + .event(UserEvent::NotifyDidSwitchPlan, notify_did_switch_plan_handler) + .event(UserEvent::PasscodeSignIn, sign_in_with_passcode_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] #[event_err = "FlowyError"] pub enum UserEvent { - /// Only use when the [Authenticator] is Local or SelfHosted + /// Only use when the [AuthType] is Local or SelfHosted /// Logging into an account using a register email and password - #[event(input = "SignInPayloadPB", output = "UserProfilePB")] + #[event(input = "SignInPayloadPB", output = "GotrueTokenResponsePB")] SignInWithEmailPassword = 0, - /// Only use when the [Authenticator] is Local or SelfHosted + /// Only use when the [AuthType] is Local or SelfHosted /// Creating a new account #[event(input = "SignUpPayloadPB", output = "UserProfilePB")] SignUp = 1, @@ -119,7 +128,7 @@ pub enum UserEvent { OauthSignIn = 10, /// Get the OAuth callback url - /// Only use when the [Authenticator] is AFCloud + /// Only use when the [AuthType] is AFCloud #[event(input = "SignInUrlPayloadPB", output = "SignInUrlPB")] GenerateSignInURL = 11, @@ -132,19 +141,16 @@ pub enum UserEvent { #[event(output = "CloudSettingPB")] GetCloudConfig = 14, - #[event(input = "UserSecretPB")] - SetEncryptionSecret = 15, - - #[event(output = "UserEncryptionConfigurationPB")] - CheckEncryptionSign = 16, - /// Return the all the workspaces of the user #[event(output = "RepeatedUserWorkspacePB")] GetAllWorkspace = 17, - #[event(input = "UserWorkspaceIdPB")] + #[event(input = "OpenUserWorkspacePB")] OpenWorkspace = 21, + #[event(input = "UserWorkspaceIdPB", output = "UserWorkspacePB")] + GetUserWorkspace = 22, + #[event(input = "NetworkStatePB")] UpdateNetworkState = 24, @@ -155,7 +161,7 @@ pub enum UserEvent { OpenAnonUser = 26, /// Push a realtime event to the user. Currently, the realtime event - /// is only used when the auth type is: [Authenticator::Supabase]. + /// is only used when the auth type is: [AuthType::Supabase]. /// #[event(input = "RealtimePayloadPB")] PushRealtimeEvent = 27, @@ -172,9 +178,6 @@ pub enum UserEvent { #[event(input = "ReminderPB")] UpdateReminder = 31, - #[event(input = "ResetWorkspacePB")] - ResetWorkspace = 32, - /// Change the Date/Time formats globally #[event(input = "DateTimeSettingsPB")] SetDateTimeSettings = 33, @@ -189,6 +192,7 @@ pub enum UserEvent { #[event(output = "NotificationSettingsPB")] GetNotificationSettings = 36, + // Deprecated #[event(input = "AddWorkspaceMemberPB")] AddWorkspaceMember = 37, @@ -199,7 +203,7 @@ pub enum UserEvent { UpdateWorkspaceMember = 39, #[event(input = "QueryWorkspacePB", output = "RepeatedWorkspaceMemberPB")] - GetWorkspaceMember = 40, + GetWorkspaceMembers = 40, #[event(input = "ImportAppFlowyDataPB")] ImportAppFlowyDataFolder = 41, @@ -230,81 +234,106 @@ pub enum UserEvent { #[event(input = "MagicLinkSignInPB", output = "UserProfilePB")] MagicLinkSignIn = 50, + + #[event(input = "SubscribeWorkspacePB", output = "PaymentLinkPB")] + SubscribeWorkspace = 51, + + #[event(input = "CancelWorkspaceSubscriptionPB")] + CancelWorkspaceSubscription = 53, + + #[event(input = "UserWorkspaceIdPB", output = "WorkspaceUsagePB")] + GetWorkspaceUsage = 54, + + #[event(output = "BillingPortalPB")] + GetBillingPortal = 55, + + #[event(input = "WorkspaceMemberIdPB", output = "WorkspaceMemberPB")] + GetMemberInfo = 56, + + #[event(input = "UpdateUserWorkspaceSettingPB")] + UpdateWorkspaceSetting = 57, + + #[event(input = "UserWorkspaceIdPB", output = "WorkspaceSettingsPB")] + GetWorkspaceSetting = 58, + + #[event(input = "UserWorkspaceIdPB", output = "WorkspaceSubscriptionInfoPB")] + GetWorkspaceSubscriptionInfo = 59, + + #[event(input = "UpdateWorkspaceSubscriptionPaymentPeriodPB")] + UpdateWorkspaceSubscriptionPaymentPeriod = 61, + + #[event(output = "RepeatedSubscriptionPlanDetailPB")] + GetSubscriptionPlanDetails = 62, + + #[event(input = "SuccessWorkspaceSubscriptionPB")] + NotifyDidSwitchPlan = 63, + + #[event()] + DeleteAccount = 64, + + #[event(input = "PasscodeSignInPB", output = "GotrueTokenResponsePB")] + PasscodeSignIn = 65, } +#[async_trait] pub trait UserStatusCallback: Send + Sync + 'static { - /// When the [Authenticator] changed, this method will be called. Currently, the auth type + /// When the [AuthType] changed, this method will be called. Currently, the auth type /// will be changed when the user sign in or sign up. - fn authenticator_did_changed(&self, _authenticator: Authenticator) {} - /// This will be called after the application launches if the user is already signed in. - /// If the user is not signed in, this method will not be called - fn did_init( - &self, - user_id: i64, - user_authenticator: &Authenticator, - cloud_config: &Option, - user_workspace: &UserWorkspace, - device_id: &str, - ) -> Fut>; - /// Will be called after the user signed in. - fn did_sign_in( - &self, - user_id: i64, - user_workspace: &UserWorkspace, - device_id: &str, - ) -> Fut>; - /// Will be called after the user signed up. - fn did_sign_up( - &self, - is_new_user: bool, - user_profile: &UserProfile, - user_workspace: &UserWorkspace, - device_id: &str, - ) -> Fut>; - - fn did_expired(&self, token: &str, user_id: i64) -> Fut>; - fn open_workspace(&self, user_id: i64, user_workspace: &UserWorkspace) -> Fut>; - fn did_update_network(&self, _reachable: bool) {} -} - -/// Acts as a placeholder [UserStatusCallback] for the user session, but does not perform any function -pub(crate) struct DefaultUserStatusCallback; -impl UserStatusCallback for DefaultUserStatusCallback { - fn did_init( + fn on_auth_type_changed(&self, _authenticator: AuthType) {} + /// Fires on app launch, but only if the user is already signed in. + async fn on_launch_if_authenticated( &self, _user_id: i64, - _authenticator: &Authenticator, _cloud_config: &Option, _user_workspace: &UserWorkspace, _device_id: &str, - ) -> Fut> { - to_fut(async { Ok(()) }) + _auth_type: &AuthType, + ) -> FlowyResult<()> { + Ok(()) } - - fn did_sign_in( + /// Fires right after the user successfully signs in. + async fn on_sign_in( &self, _user_id: i64, _user_workspace: &UserWorkspace, _device_id: &str, - ) -> Fut> { - to_fut(async { Ok(()) }) + _auth_type: &AuthType, + ) -> FlowyResult<()> { + Ok(()) } - fn did_sign_up( + /// Fires right after the user successfully signs up. + async fn on_sign_up( &self, _is_new_user: bool, _user_profile: &UserProfile, _user_workspace: &UserWorkspace, _device_id: &str, - ) -> Fut> { - to_fut(async { Ok(()) }) + _auth_type: &AuthType, + ) -> FlowyResult<()> { + Ok(()) } - fn did_expired(&self, _token: &str, _user_id: i64) -> Fut> { - to_fut(async { Ok(()) }) + /// Fires when an authentication token has expired. + async fn on_token_expired(&self, _token: &str, _user_id: i64) -> FlowyResult<()> { + Ok(()) } - fn open_workspace(&self, _user_id: i64, _user_workspace: &UserWorkspace) -> Fut> { - to_fut(async { Ok(()) }) + /// Fires when a workspace is opened by the user. + async fn on_workspace_opened( + &self, + _user_id: i64, + _workspace_id: &Uuid, + _user_workspace: &UserWorkspace, + _auth_type: &AuthType, + ) -> FlowyResult<()> { + Ok(()) } + fn on_network_status_changed(&self, _reachable: bool) {} + fn on_subscription_plans_updated(&self, _plans: Vec) {} + fn on_storage_permission_updated(&self, _can_write: bool) {} } + +/// Acts as a placeholder [UserStatusCallback] for the user session, but does not perform any function +pub(crate) struct DefaultUserStatusCallback; +impl UserStatusCallback for DefaultUserStatusCallback {} diff --git a/frontend/rust-lib/flowy-user/src/lib.rs b/frontend/rust-lib/flowy-user/src/lib.rs index 08fd0a7ff8..9a456b01e5 100644 --- a/frontend/rust-lib/flowy-user/src/lib.rs +++ b/frontend/rust-lib/flowy-user/src/lib.rs @@ -1,7 +1,6 @@ #[macro_use] extern crate flowy_sqlite; -mod anon_user; pub mod entities; mod event_handler; pub mod event_map; diff --git a/frontend/rust-lib/flowy-user/src/migrations/anon_user_workspace.rs b/frontend/rust-lib/flowy-user/src/migrations/anon_user_workspace.rs new file mode 100644 index 0000000000..7c806d3aaf --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/migrations/anon_user_workspace.rs @@ -0,0 +1,58 @@ +use diesel::SqliteConnection; +use semver::Version; +use std::sync::Arc; +use tracing::{info, instrument}; + +use collab_integrate::CollabKVDB; +use flowy_error::FlowyResult; +use flowy_user_pub::entities::AuthType; + +use crate::migrations::migration::UserDataMigration; +use flowy_user_pub::session::Session; +use flowy_user_pub::sql::{select_user_workspace, upsert_user_workspace}; + +pub struct AnonUserWorkspaceTableMigration; + +impl UserDataMigration for AnonUserWorkspaceTableMigration { + fn name(&self) -> &str { + "anon_user_workspace_table_migration" + } + + fn run_when( + &self, + first_installed_version: &Option, + _current_version: &Version, + ) -> bool { + match first_installed_version { + None => true, + Some(version) => version <= &Version::new(0, 8, 10), + } + } + + #[instrument(name = "AnonUserWorkspaceTableMigration", skip_all, err)] + fn run( + &self, + session: &Session, + _collab_db: &Arc, + auth_type: &AuthType, + db: &mut SqliteConnection, + ) -> FlowyResult<()> { + // For historical reason, anon user doesn't have a workspace in user_workspace_table. + // So we need to create a new entry for the anon user in the user_workspace_table. + if matches!(auth_type, AuthType::Local) { + let user_workspace = &session.user_workspace; + let result = select_user_workspace(&user_workspace.id, db); + if let Err(e) = result { + if e.is_record_not_found() { + info!( + "Anon user workspace not found in the database, creating a new entry for user_id: {}", + session.user_id + ); + upsert_user_workspace(session.user_id, *auth_type, user_workspace.clone(), db)?; + } + } + } + + Ok(()) + } +} diff --git a/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs b/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs new file mode 100644 index 0000000000..84acc0b56a --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs @@ -0,0 +1,56 @@ +use std::sync::Arc; + +use collab_plugins::local_storage::kv::doc::migrate_old_keys; +use collab_plugins::local_storage::kv::KVTransactionDB; +use diesel::SqliteConnection; +use semver::Version; +use tracing::{instrument, trace}; + +use collab_integrate::CollabKVDB; +use flowy_error::FlowyResult; +use flowy_user_pub::entities::AuthType; + +use crate::migrations::migration::UserDataMigration; +use flowy_user_pub::session::Session; + +pub struct CollabDocKeyWithWorkspaceIdMigration; + +impl UserDataMigration for CollabDocKeyWithWorkspaceIdMigration { + fn name(&self) -> &str { + "collab_doc_key_with_workspace_id" + } + + fn run_when( + &self, + first_installed_version: &Option, + _current_version: &Version, + ) -> bool { + match first_installed_version { + None => { + // The user's initial installed version is None if they were using an AppFlowy version + // lower than 0.7.3 and then upgraded to the latest version. + true + }, + Some(version) => version < &Version::new(0, 7, 3), + } + } + + #[instrument(name = "CollabDocKeyWithWorkspaceIdMigration", skip_all, err)] + fn run( + &self, + session: &Session, + collab_db: &Arc, + _authenticator: &AuthType, + _db: &mut SqliteConnection, + ) -> FlowyResult<()> { + trace!( + "migrate key with workspace id:{}", + session.user_workspace.id + ); + collab_db.with_write_txn(|txn| { + migrate_old_keys(txn, &session.user_workspace.id)?; + Ok(()) + })?; + Ok(()) + } +} diff --git a/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs b/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs index bdcd2e5a59..2e4581f7ec 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs @@ -1,18 +1,18 @@ use std::sync::Arc; -use collab::core::collab::MutexCollab; use collab::core::origin::{CollabClient, CollabOrigin}; use collab::preclude::Collab; use collab_document::document::Document; use collab_document::document_data::default_document_data; use collab_folder::{Folder, View}; use collab_plugins::local_storage::kv::KVTransactionDB; +use diesel::SqliteConnection; use semver::Version; use tracing::{event, instrument}; use collab_integrate::{CollabKVAction, CollabKVDB, PersistenceError}; use flowy_error::{FlowyError, FlowyResult}; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::migration::UserDataMigration; use crate::migrations::util::load_collab; @@ -26,8 +26,15 @@ impl UserDataMigration for HistoricalEmptyDocumentMigration { "historical_empty_document" } - fn applies_to_version(&self, _version: &Version) -> bool { - true + fn run_when( + &self, + first_installed_version: &Option, + _current_version: &Version, + ) -> bool { + match first_installed_version { + None => true, + Some(version) => version < &Version::new(0, 4, 0), + } } #[instrument(name = "HistoricalEmptyDocumentMigration", skip_all, err)] @@ -35,30 +42,43 @@ impl UserDataMigration for HistoricalEmptyDocumentMigration { &self, session: &Session, collab_db: &Arc, - authenticator: &Authenticator, + authenticator: &AuthType, + _db: &mut SqliteConnection, ) -> FlowyResult<()> { // - The `empty document` struct has already undergone refactoring prior to the launch of the AppFlowy cloud version. // - Consequently, if a user is utilizing the AppFlowy cloud version, there is no need to perform any migration for the `empty document` struct. // - This migration step is only necessary for users who are transitioning from a local version of AppFlowy to the cloud version. - if !matches!(authenticator, Authenticator::Local) { + if !matches!(authenticator, AuthType::Local) { return Ok(()); } collab_db.with_write_txn(|write_txn| { let origin = CollabOrigin::Client(CollabClient::new(session.user_id, "phantom")); - let folder_collab = match load_collab(session.user_id, write_txn, &session.user_workspace.id) - { + let folder_collab = match load_collab( + session.user_id, + write_txn, + &session.user_workspace.id, + &session.user_workspace.id, + ) { Ok(fc) => fc, Err(_) => return Ok(()), }; let folder = Folder::open(session.user_id, folder_collab, None) .map_err(|err| PersistenceError::Internal(err.into()))?; - if let Ok(workspace_id) = folder.try_get_workspace_id() { - let migration_views = folder.views.get_views_belong_to(&workspace_id); + if let Some(workspace_id) = folder.get_workspace_id() { + let migration_views = folder.get_views_belong_to(&workspace_id); // For historical reasons, the first level documents are empty. So migrate them by inserting // the default document data. for view in migration_views { - if migrate_empty_document(write_txn, &origin, &view, session.user_id).is_err() { + if migrate_empty_document( + write_txn, + &origin, + &view, + session.user_id, + &session.user_workspace.id, + ) + .is_err() + { event!( tracing::Level::ERROR, "Failed to migrate document {}", @@ -80,25 +100,24 @@ fn migrate_empty_document<'a, W>( origin: &CollabOrigin, view: &View, user_id: i64, + workspace_id: &str, ) -> Result<(), FlowyError> where W: CollabKVAction<'a>, PersistenceError: From, { // If the document is not exist, we don't need to migrate it. - if load_collab(user_id, write_txn, &view.id).is_err() { - let collab = Arc::new(MutexCollab::new(Collab::new_with_origin( - origin.clone(), + if load_collab(user_id, write_txn, workspace_id, &view.id).is_err() { + let collab = Collab::new_with_origin(origin.clone(), &view.id, vec![], false); + let document = Document::create_with_data(collab, default_document_data(&view.id))?; + let encode = document.encode_collab_v1(|_| Ok::<(), PersistenceError>(()))?; + write_txn.flush_doc( + user_id, + workspace_id, &view.id, - vec![], - false, - ))); - let document = Document::create_with_data(collab, default_document_data())?; - let encode = document - .get_collab() - .lock() - .encode_collab_v1(|_| Ok::<(), PersistenceError>(()))?; - write_txn.flush_doc_with(user_id, &view.id, &encode.doc_state, &encode.state_vector)?; + encode.state_vector.to_vec(), + encode.doc_state.to_vec(), + )?; event!( tracing::Level::INFO, "Did migrate empty document {}", diff --git a/frontend/rust-lib/flowy-user/src/migrations/migration.rs b/frontend/rust-lib/flowy-user/src/migrations/migration.rs index 26be72707a..0f5c2c2624 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/migration.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/migration.rs @@ -1,21 +1,26 @@ use std::sync::Arc; use chrono::NaiveDateTime; -use diesel::{RunQueryDsl, SqliteConnection}; -use semver::Version; - use collab_integrate::CollabKVDB; +use diesel::{RunQueryDsl, SqliteConnection}; use flowy_error::FlowyResult; +use flowy_sqlite::kv::KVStorePreferences; use flowy_sqlite::schema::user_data_migration_records; use flowy_sqlite::ConnectionPool; -use flowy_user_pub::entities::Authenticator; - +use flowy_user_pub::entities::AuthType; use flowy_user_pub::session::Session; +use semver::Version; +use tracing::info; + +/// Store the version that user first time install AppFlowy. For old user, this value will be set +/// to the version when upgrade to the newest version. +pub const FIRST_TIME_INSTALL_VERSION: &str = "first_install_version"; pub struct UserLocalDataMigration { session: Session, collab_db: Arc, sqlite_pool: Arc, + kv: Arc, } impl UserLocalDataMigration { @@ -23,11 +28,13 @@ impl UserLocalDataMigration { session: Session, collab_db: Arc, sqlite_pool: Arc, + kv: Arc, ) -> Self { Self { session, collab_db, sqlite_pool, + kv, } } @@ -47,32 +54,33 @@ impl UserLocalDataMigration { pub fn run( self, migrations: Vec>, - authenticator: &Authenticator, - app_version: Option, + auth_type: &AuthType, + app_version: &Version, ) -> FlowyResult> { let mut applied_migrations = vec![]; let mut conn = self.sqlite_pool.get()?; let record = get_all_records(&mut conn)?; + let install_version = self.kv.get_object::(FIRST_TIME_INSTALL_VERSION); + + info!("[Migration] Install app version: {:?}", install_version); let mut duplicated_names = vec![]; for migration in migrations { if !record .iter() .any(|record| record.migration_name == migration.name()) { - if let Some(app_version) = app_version.as_ref() { - if !migration.applies_to_version(app_version) { - continue; - } + if !migration.run_when(&install_version, app_version) { + continue; } let migration_name = migration.name().to_string(); if !duplicated_names.contains(&migration_name) { - migration.run(&self.session, &self.collab_db, authenticator)?; + migration.run(&self.session, &self.collab_db, auth_type, &mut conn)?; applied_migrations.push(migration.name().to_string()); save_migration_record(&mut conn, &migration_name); duplicated_names.push(migration_name); } else { - tracing::error!("Duplicated migration name: {}", migration_name); + tracing::error!("[Migration] Duplicated migration name: {}", migration_name); } } } @@ -83,14 +91,15 @@ impl UserLocalDataMigration { pub trait UserDataMigration { /// Migration with the same name will be skipped fn name(&self) -> &str; - /// Returns bool value whether the migration should be applied to the current app version - /// true if the migration should be applied, false otherwise - fn applies_to_version(&self, app_version: &Version) -> bool; + // The user's initial installed version is None if they were using an AppFlowy version lower than 0.7.3 + // Because we store the first time installed version after version 0.7.3. + fn run_when(&self, first_installed_version: &Option, current_version: &Version) -> bool; fn run( &self, user: &Session, collab_db: &Arc, - authenticator: &Authenticator, + authenticator: &AuthType, + db: &mut SqliteConnection, ) -> FlowyResult<()>; } @@ -112,7 +121,7 @@ fn get_all_records(conn: &mut SqliteConnection) -> FlowyResult, } diff --git a/frontend/rust-lib/flowy-user/src/migrations/session_migration.rs b/frontend/rust-lib/flowy-user/src/migrations/session_migration.rs index 77376e1c6c..df477f4a33 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/session_migration.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/session_migration.rs @@ -1,4 +1,4 @@ -use flowy_sqlite::kv::StorePreferences; +use flowy_sqlite::kv::KVStorePreferences; use flowy_user_pub::session::Session; use serde_json::{json, Value}; use std::sync::Arc; @@ -8,9 +8,9 @@ const MIGRATION_USER_NO_USER_UUID: &str = "migration_user_no_user_uuid"; pub fn migrate_session_with_user_uuid( session_cache_key: &str, - store_preferences: &Arc, + store_preferences: &Arc, ) -> Option { - if !store_preferences.get_bool(MIGRATION_USER_NO_USER_UUID) + if !store_preferences.get_bool_or_default(MIGRATION_USER_NO_USER_UUID) && store_preferences .set_bool(MIGRATION_USER_NO_USER_UUID, true) .is_ok() diff --git a/frontend/rust-lib/flowy-user/src/migrations/util.rs b/frontend/rust-lib/flowy-user/src/migrations/util.rs index f0c4c3f7f7..9508228c6f 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/util.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/util.rs @@ -1,6 +1,3 @@ -use std::sync::Arc; - -use collab::core::collab::MutexCollab; use collab::preclude::Collab; use collab_integrate::{CollabKVAction, PersistenceError}; @@ -9,13 +6,14 @@ use flowy_error::FlowyResult; pub(crate) fn load_collab<'a, R>( uid: i64, collab_r_txn: &R, + workspace_id: &str, object_id: &str, -) -> FlowyResult> +) -> FlowyResult where R: CollabKVAction<'a>, PersistenceError: From, { - let collab = Collab::new(uid, object_id, "phantom", vec![], false); - collab.with_origin_transact_mut(|txn| collab_r_txn.load_doc_with_txn(uid, &object_id, txn))?; - Ok(Arc::new(MutexCollab::new(collab))) + let mut collab = Collab::new(uid, object_id, "phantom", vec![], false); + collab_r_txn.load_doc_with_txn(uid, workspace_id, object_id, &mut collab.transact_mut())?; + Ok(collab) } diff --git a/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs b/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs index b6d5e3e8ff..5f14051e26 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs @@ -2,12 +2,13 @@ use std::sync::Arc; use collab_folder::Folder; use collab_plugins::local_storage::kv::{KVTransactionDB, PersistenceError}; +use diesel::SqliteConnection; use semver::Version; use tracing::instrument; use collab_integrate::{CollabKVAction, CollabKVDB}; use flowy_error::FlowyResult; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::migration::UserDataMigration; use crate::migrations::util::load_collab; @@ -15,7 +16,7 @@ use flowy_user_pub::session::Session; /// 1. Migrate the workspace: { favorite: [view_id] } to { favorite: { uid: [view_id] } } /// 2. Migrate { workspaces: [workspace object] } to { views: { workspace object } }. Make each folder -/// only have one workspace. +/// only have one workspace. pub struct FavoriteV1AndWorkspaceArrayMigration; impl UserDataMigration for FavoriteV1AndWorkspaceArrayMigration { @@ -23,8 +24,15 @@ impl UserDataMigration for FavoriteV1AndWorkspaceArrayMigration { "workspace_favorite_v1_and_workspace_array_migration" } - fn applies_to_version(&self, _app_version: &Version) -> bool { - true + fn run_when( + &self, + first_installed_version: &Option, + _current_version: &Version, + ) -> bool { + match first_installed_version { + None => true, + Some(version) => version < &Version::new(0, 4, 0), + } } #[instrument(name = "FavoriteV1AndWorkspaceArrayMigration", skip_all, err)] @@ -32,13 +40,21 @@ impl UserDataMigration for FavoriteV1AndWorkspaceArrayMigration { &self, session: &Session, collab_db: &Arc, - _authenticator: &Authenticator, + _authenticator: &AuthType, + _db: &mut SqliteConnection, ) -> FlowyResult<()> { collab_db.with_write_txn(|write_txn| { - if let Ok(collab) = load_collab(session.user_id, write_txn, &session.user_workspace.id) { - let folder = Folder::open(session.user_id, collab, None) + if let Ok(collab) = load_collab( + session.user_id, + write_txn, + &session.user_workspace.id, + &session.user_workspace.id, + ) { + let mut folder = Folder::open(session.user_id, collab, None) .map_err(|err| PersistenceError::Internal(err.into()))?; - folder.migrate_workspace_to_view(); + folder + .body + .migrate_workspace_to_view(&mut folder.collab.transact_mut()); let favorite_view_ids = folder .get_favorite_v1() @@ -51,13 +67,14 @@ impl UserDataMigration for FavoriteV1AndWorkspaceArrayMigration { } let encode = folder - .encode_collab_v1() + .encode_collab() .map_err(|err| PersistenceError::Internal(err.into()))?; - write_txn.flush_doc_with( + write_txn.flush_doc( session.user_id, &session.user_workspace.id, - &encode.doc_state, - &encode.state_vector, + &session.user_workspace.id, + encode.state_vector.to_vec(), + encode.doc_state.to_vec(), )?; } Ok(()) diff --git a/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs b/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs index e15f2597b4..b5eeead8c6 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs @@ -2,12 +2,13 @@ use std::sync::Arc; use collab_folder::Folder; use collab_plugins::local_storage::kv::{KVTransactionDB, PersistenceError}; +use diesel::SqliteConnection; use semver::Version; use tracing::instrument; use collab_integrate::{CollabKVAction, CollabKVDB}; use flowy_error::FlowyResult; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::migration::UserDataMigration; use crate::migrations::util::load_collab; @@ -21,8 +22,15 @@ impl UserDataMigration for WorkspaceTrashMapToSectionMigration { "workspace_trash_map_to_section_migration" } - fn applies_to_version(&self, _app_version: &Version) -> bool { - true + fn run_when( + &self, + first_installed_version: &Option, + _current_version: &Version, + ) -> bool { + match first_installed_version { + None => true, + Some(version) => version < &Version::new(0, 4, 0), + } } #[instrument(name = "WorkspaceTrashMapToSectionMigration", skip_all, err)] @@ -30,11 +38,17 @@ impl UserDataMigration for WorkspaceTrashMapToSectionMigration { &self, session: &Session, collab_db: &Arc, - _authenticator: &Authenticator, + _authenticator: &AuthType, + _db: &mut SqliteConnection, ) -> FlowyResult<()> { collab_db.with_write_txn(|write_txn| { - if let Ok(collab) = load_collab(session.user_id, write_txn, &session.user_workspace.id) { - let folder = Folder::open(session.user_id, collab, None) + if let Ok(collab) = load_collab( + session.user_id, + write_txn, + &session.user_workspace.id, + &session.user_workspace.id, + ) { + let mut folder = Folder::open(session.user_id, collab, None) .map_err(|err| PersistenceError::Internal(err.into()))?; let trash_ids = folder .get_trash_v1() @@ -47,13 +61,14 @@ impl UserDataMigration for WorkspaceTrashMapToSectionMigration { } let encode = folder - .encode_collab_v1() + .encode_collab() .map_err(|err| PersistenceError::Internal(err.into()))?; - write_txn.flush_doc_with( + write_txn.flush_doc( session.user_id, &session.user_workspace.id, - &encode.doc_state, - &encode.state_vector, + &session.user_workspace.id, + encode.state_vector.to_vec(), + encode.doc_state.to_vec(), )?; } Ok(()) diff --git a/frontend/rust-lib/flowy-user/src/notification.rs b/frontend/rust-lib/flowy-user/src/notification.rs index 4b6792c8d4..dd93593468 100644 --- a/frontend/rust-lib/flowy-user/src/notification.rs +++ b/frontend/rust-lib/flowy-user/src/notification.rs @@ -13,6 +13,8 @@ pub(crate) enum UserNotification { DidUpdateUserProfile = 2, DidUpdateUserWorkspaces = 3, DidUpdateCloudConfig = 4, + DidUpdateUserWorkspace = 5, + DidUpdateWorkspaceSetting = 6, } impl std::convert::From for i32 { diff --git a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs index c9d779a232..84c1e9afe9 100644 --- a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs +++ b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs @@ -1,55 +1,44 @@ use crate::migrations::session_migration::migrate_session_with_user_uuid; use crate::services::db::UserDB; use crate::services::entities::{UserConfig, UserPaths}; -use crate::services::sqlite_sql::user_sql::vacuum_database; use collab_integrate::CollabKVDB; +use crate::user_manager::manager_history_user::ANON_USER; +use arc_swap::ArcSwapOption; +use collab_plugins::local_storage::kv::doc::CollabKVAction; +use collab_plugins::local_storage::kv::KVTransactionDB; use flowy_error::{internal_error, ErrorCode, FlowyError, FlowyResult}; -use flowy_sqlite::kv::StorePreferences; +use flowy_sqlite::kv::KVStorePreferences; use flowy_sqlite::DBConnection; use flowy_user_pub::entities::UserWorkspace; use flowy_user_pub::session::Session; use std::path::PathBuf; +use std::str::FromStr; use std::sync::{Arc, Weak}; -use tracing::{error, info}; - -const SQLITE_VACUUM_042: &str = "sqlite_vacuum_042_version"; +use tracing::info; +use uuid::Uuid; pub struct AuthenticateUser { pub user_config: UserConfig, pub(crate) database: Arc, pub(crate) user_paths: UserPaths, - store_preferences: Arc, - session: Arc>>, + store_preferences: Arc, + session: ArcSwapOption, } impl AuthenticateUser { - pub fn new(user_config: UserConfig, store_preferences: Arc) -> Self { + pub fn new(user_config: UserConfig, store_preferences: Arc) -> Self { let user_paths = UserPaths::new(user_config.storage_path.clone()); let database = Arc::new(UserDB::new(user_paths.clone())); - let session = Arc::new(parking_lot::RwLock::new(None)); - *session.write() = - migrate_session_with_user_uuid(&user_config.session_cache_key, &store_preferences); + let session = + migrate_session_with_user_uuid(&user_config.session_cache_key, &store_preferences) + .map(Arc::new); Self { user_config, database, user_paths, store_preferences, - session, - } - } - - pub fn vacuum_database_if_need(&self) { - if !self.store_preferences.get_bool(SQLITE_VACUUM_042) { - if let Ok(session) = self.get_session() { - let _ = self.store_preferences.set_bool(SQLITE_VACUUM_042, true); - if let Ok(conn) = self.database.get_connection(session.user_id) { - info!("vacuum database 042"); - if let Err(err) = vacuum_database(conn) { - error!("vacuum database error: {:?}", err); - } - } - } + session: ArcSwapOption::from(session), } } @@ -58,18 +47,31 @@ impl AuthenticateUser { Ok(session.user_id) } + pub async fn is_local_mode(&self) -> FlowyResult { + let uid = self.user_id()?; + if let Ok(anon_user) = self.get_anon_user().await { + if anon_user == uid { + return Ok(true); + } + } + + Ok(false) + } + pub fn device_id(&self) -> FlowyResult { Ok(self.user_config.device_id.to_string()) } - pub fn workspace_id(&self) -> FlowyResult { + pub fn workspace_id(&self) -> FlowyResult { let session = self.get_session()?; - Ok(session.user_workspace.id) + let workspace_uuid = Uuid::from_str(&session.user_workspace.id)?; + Ok(workspace_uuid) } - pub fn workspace_database_object_id(&self) -> FlowyResult { + pub fn workspace_database_object_id(&self) -> FlowyResult { let session = self.get_session()?; - Ok(session.user_workspace.database_indexer_id.clone()) + let id = Uuid::from_str(&session.user_workspace.workspace_database_id)?; + Ok(id) } pub fn get_collab_db(&self, uid: i64) -> FlowyResult> { @@ -83,9 +85,18 @@ impl AuthenticateUser { self.database.get_connection(uid) } - pub fn get_index_path(&self) -> PathBuf { - let uid = self.user_id().unwrap_or(0); - PathBuf::from(self.user_paths.user_data_dir(uid)).join("indexes") + pub fn get_index_path(&self) -> FlowyResult { + let uid = self.user_id()?; + Ok(PathBuf::from(self.user_paths.user_data_dir(uid)).join("indexes")) + } + + pub fn get_user_data_dir(&self) -> FlowyResult { + let uid = self.user_id()?; + Ok(PathBuf::from(self.user_paths.user_data_dir(uid))) + } + + pub fn get_application_root_dir(&self) -> &str { + self.user_paths.root() } pub fn close_db(&self) -> FlowyResult<()> { @@ -95,51 +106,72 @@ impl AuthenticateUser { Ok(()) } - pub fn set_session(&self, session: Option) -> Result<(), FlowyError> { - match &session { + pub fn is_collab_on_disk(&self, uid: i64, object_id: &str) -> FlowyResult { + let session = self.get_session()?; + let collab_db = self.database.get_collab_db(uid)?; + let read_txn = collab_db.read_txn(); + Ok(read_txn.is_exist(uid, session.user_workspace.id.as_str(), object_id)) + } + + pub fn set_session(&self, session: Option>) -> Result<(), FlowyError> { + match session { None => { - let removed_session = self.session.write().take(); - info!("remove session: {:?}", removed_session); + let previous = self.session.swap(session); + info!("remove session: {:?}", previous); self .store_preferences .remove(self.user_config.session_cache_key.as_ref()); - Ok(()) }, Some(session) => { + self.session.swap(Some(session.clone())); info!("Set current session: {:?}", session); - self.session.write().replace(session.clone()); self .store_preferences - .set_object(&self.user_config.session_cache_key, session.clone()) + .set_object(&self.user_config.session_cache_key, &session) .map_err(internal_error)?; - Ok(()) }, } + Ok(()) } pub fn set_user_workspace(&self, user_workspace: UserWorkspace) -> FlowyResult<()> { - let mut session = self.get_session()?; - session.user_workspace = user_workspace; - self.set_session(Some(session)) + let session = self.get_session()?; + self.set_session(Some(Arc::new(Session { + user_id: session.user_id, + user_uuid: session.user_uuid, + user_workspace, + }))) } - pub fn get_session(&self) -> FlowyResult { - if let Some(session) = (self.session.read()).clone() { + pub fn get_session(&self) -> FlowyResult> { + if let Some(session) = self.session.load_full() { return Ok(session); } match self .store_preferences - .get_object::(&self.user_config.session_cache_key) + .get_object::>(&self.user_config.session_cache_key) { None => Err(FlowyError::new( ErrorCode::RecordNotFound, "User is not logged in", )), Some(session) => { - self.session.write().replace(session.clone()); + self.session.store(Some(session.clone())); Ok(session) }, } } + + async fn get_anon_user(&self) -> FlowyResult { + let anon_session = self + .store_preferences + .get_object::(ANON_USER) + .ok_or(FlowyError::new( + ErrorCode::RecordNotFound, + "Anon user not found", + ))?; + + Ok(anon_session.user_id) + } } diff --git a/frontend/rust-lib/flowy-user/src/services/billing_check.rs b/frontend/rust-lib/flowy-user/src/services/billing_check.rs new file mode 100644 index 0000000000..ea5bc65b75 --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/services/billing_check.rs @@ -0,0 +1,89 @@ +use crate::services::authenticate_user::AuthenticateUser; +use client_api::entity::billing_dto::SubscriptionPlan; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_user_pub::cloud::UserCloudServiceProvider; +use std::sync::Weak; +use std::time::Duration; +use uuid::Uuid; + +/// `PeriodicallyCheckBillingState` is designed to periodically verify the subscription +/// plan of a given workspace. It utilizes a cloud service provider to fetch the current +/// subscription plans and compares them with an expected plan. +/// +/// If the expected plan is found, the check stops. Otherwise, it continues to check +/// at specified intervals until the expected plan is found or the maximum number of +/// attempts is reached. +pub struct PeriodicallyCheckBillingState { + workspace_id: Uuid, + cloud_service: Weak, + expected_plan: Option, + user: Weak, +} + +impl PeriodicallyCheckBillingState { + pub fn new( + workspace_id: Uuid, + expected_plan: Option, + cloud_service: Weak, + user: Weak, + ) -> Self { + Self { + workspace_id, + cloud_service, + expected_plan, + user, + } + } + + pub async fn start(&self) -> FlowyResult> { + let cloud_service = self + .cloud_service + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Cloud service is not available"))?; + + let mut attempts = 0; + let max_attempts = 5; + let delay_duration = Duration::from_secs(4); + while attempts < max_attempts { + let plans = cloud_service + .get_user_service()? + .get_workspace_plan(self.workspace_id) + .await?; + + // If the expected plan is not set, return the plans immediately. Otherwise, + // check if the expected plan is found in the list of plans. + if let Some(expected_plan) = &self.expected_plan { + if plans.contains(expected_plan) { + return Ok(plans); + } + attempts += 1; + } else { + attempts += 2; + } + + tokio::time::sleep(delay_duration).await; + if let Some(user) = self.user.upgrade() { + if let Ok(current_workspace_id) = user.workspace_id() { + if current_workspace_id != self.workspace_id { + return Err( + FlowyError::internal() + .with_context("Workspace ID has changed while checking the billing state"), + ); + } + } else { + break; + } + } + + // After last retry, return plans even if the expected plan is not found + if attempts >= max_attempts { + return Ok(plans); + } + } + + Err( + FlowyError::response_timeout() + .with_context("Exceeded maximum number of checks without finding the expected plan"), + ) + } +} diff --git a/frontend/rust-lib/flowy-user/src/services/cloud_config.rs b/frontend/rust-lib/flowy-user/src/services/cloud_config.rs index 6772ac7ab5..30f5725f9c 100644 --- a/frontend/rust-lib/flowy-user/src/services/cloud_config.rs +++ b/frontend/rust-lib/flowy-user/src/services/cloud_config.rs @@ -1,23 +1,23 @@ use std::sync::Arc; -use flowy_encrypt::generate_encryption_secret; use flowy_error::FlowyResult; -use flowy_sqlite::kv::StorePreferences; +use flowy_sqlite::kv::KVStorePreferences; use flowy_user_pub::cloud::UserCloudConfig; +use lib_infra::encryption::generate_encryption_secret; const CLOUD_CONFIG_KEY: &str = "af_user_cloud_config"; -fn generate_cloud_config(uid: i64, store_preference: &Arc) -> UserCloudConfig { +fn generate_cloud_config(uid: i64, store_preference: &Arc) -> UserCloudConfig { let config = UserCloudConfig::new(generate_encryption_secret()); let key = cache_key_for_cloud_config(uid); - store_preference.set_object(&key, config.clone()).unwrap(); + store_preference.set_object(&key, &config).unwrap(); config } pub fn save_cloud_config( uid: i64, - store_preference: &Arc, - config: UserCloudConfig, + store_preference: &Arc, + config: &UserCloudConfig, ) -> FlowyResult<()> { tracing::info!("save user:{} cloud config: {}", uid, config); let key = cache_key_for_cloud_config(uid); @@ -31,7 +31,7 @@ fn cache_key_for_cloud_config(uid: i64) -> String { pub fn get_cloud_config( uid: i64, - store_preference: &Arc, + store_preference: &Arc, ) -> Option { let key = cache_key_for_cloud_config(uid); store_preference.get_object::(&key) @@ -39,7 +39,7 @@ pub fn get_cloud_config( pub fn get_or_create_cloud_config( uid: i64, - store_preferences: &Arc, + store_preferences: &Arc, ) -> UserCloudConfig { let key = cache_key_for_cloud_config(uid); store_preferences @@ -47,7 +47,7 @@ pub fn get_or_create_cloud_config( .unwrap_or_else(|| generate_cloud_config(uid, store_preferences)) } -pub fn get_encrypt_secret(uid: i64, store_preference: &Arc) -> Option { +pub fn get_encrypt_secret(uid: i64, store_preference: &Arc) -> Option { let key = cache_key_for_cloud_config(uid); store_preference .get_object::(&key) diff --git a/frontend/rust-lib/flowy-user/src/services/collab_interact.rs b/frontend/rust-lib/flowy-user/src/services/collab_interact.rs index d01d3cffde..90e507517c 100644 --- a/frontend/rust-lib/flowy-user/src/services/collab_interact.rs +++ b/frontend/rust-lib/flowy-user/src/services/collab_interact.rs @@ -1,25 +1,21 @@ use anyhow::Error; use collab_entity::reminder::Reminder; +use lib_infra::async_trait::async_trait; -use lib_infra::future::FutureResult; - -pub trait CollabInteract: Send + Sync + 'static { - fn add_reminder(&self, reminder: Reminder) -> FutureResult<(), Error>; - fn remove_reminder(&self, reminder_id: &str) -> FutureResult<(), Error>; - fn update_reminder(&self, reminder: Reminder) -> FutureResult<(), Error>; +#[async_trait] +pub trait UserReminder: Send + Sync + 'static { + async fn add_reminder(&self, _reminder: Reminder) -> Result<(), Error> { + Ok(()) + } + async fn remove_reminder(&self, _reminder_id: &str) -> Result<(), Error> { + Ok(()) + } + async fn update_reminder(&self, _reminder: Reminder) -> Result<(), Error> { + Ok(()) + } } pub struct DefaultCollabInteract; -impl CollabInteract for DefaultCollabInteract { - fn add_reminder(&self, _reminder: Reminder) -> FutureResult<(), Error> { - FutureResult::new(async { Ok(()) }) - } - fn remove_reminder(&self, _reminder_id: &str) -> FutureResult<(), Error> { - FutureResult::new(async { Ok(()) }) - } - - fn update_reminder(&self, _reminder: Reminder) -> FutureResult<(), Error> { - FutureResult::new(async { Ok(()) }) - } -} +#[async_trait] +impl UserReminder for DefaultCollabInteract {} diff --git a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs index f60f07deac..20b0c26368 100644 --- a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs +++ b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs @@ -1,46 +1,55 @@ use crate::migrations::session_migration::migrate_session_with_user_uuid; -use crate::services::data_import::importer::load_collab_by_oid; +use crate::services::data_import::importer::load_collab_by_object_ids; use crate::services::db::UserDBPath; use crate::services::entities::UserPaths; -use crate::services::sqlite_sql::user_sql::select_user_profile; use crate::user_manager::run_collab_data_migration; use anyhow::anyhow; -use collab::core::collab::{DataSource, MutexCollab}; +use collab::core::collab::DataSource; use collab::core::origin::CollabOrigin; -use collab::core::transaction::DocTransactionExtension; + use collab::preclude::updates::decoder::Decode; -use collab::preclude::{Collab, Doc, Transact, Update}; +use collab::preclude::updates::encoder::Encode; +use collab::preclude::{Any, Collab, Doc, ReadTxn, StateVector, Transact, Update}; use collab_database::database::{ is_database_collab, mut_database_views_with_collab, reset_inline_view_id, }; use collab_database::rows::{database_row_document_id_from_row_id, mut_row_with_collab, RowId}; -use collab_database::workspace_database::DatabaseMetaList; +use collab_database::workspace_database::WorkspaceDatabase; use collab_document::document_data::default_document_collab_data; use collab_entity::CollabType; +use collab_folder::hierarchy_builder::{NestedChildViewBuilder, NestedViews, ParentChildViews}; use collab_folder::{Folder, UserId, View, ViewIdentifier, ViewLayout}; use collab_integrate::{CollabKVAction, CollabKVDB, PersistenceError}; use collab_plugins::local_storage::kv::KVTransactionDB; - -use flowy_error::FlowyError; +use flowy_error::{FlowyError, FlowyResult}; use flowy_folder_pub::cloud::gen_view_id; -use flowy_folder_pub::entities::{AppFlowyData, ImportData}; -use flowy_folder_pub::folder_builder::{ParentChildViews, ViewBuilder}; -use flowy_sqlite::kv::StorePreferences; +use flowy_folder_pub::entities::{ + ImportFrom, ImportedAppFlowyData, ImportedCollabData, ImportedFolderData, +}; +use flowy_sqlite::kv::KVStorePreferences; use flowy_user_pub::cloud::{UserCloudService, UserCollabParams}; -use flowy_user_pub::entities::{user_awareness_object_id, Authenticator}; +use flowy_user_pub::entities::{user_awareness_object_id, AuthType}; use flowy_user_pub::session::Session; -use parking_lot::{Mutex, RwLock}; +use rayon::prelude::*; use std::collections::{HashMap, HashSet}; + +use collab_document::blocks::TextDelta; +use collab_document::document::Document; +use flowy_user_pub::sql::{select_user_profile, select_workspace_auth_type}; +use semver::Version; +use serde_json::json; use std::ops::{Deref, DerefMut}; use std::path::Path; use std::sync::{Arc, Weak}; -use tracing::{debug, error, event, info, instrument, warn}; +use tracing::{error, event, info, instrument, warn}; +use uuid::Uuid; pub(crate) struct ImportedFolder { pub imported_session: Session, pub imported_collab_db: Arc, pub container_name: Option, + pub parent_view_id: Option, pub source: ImportedSource, } @@ -57,12 +66,20 @@ impl ImportedFolder { } } -pub(crate) fn prepare_import(path: &str) -> anyhow::Result { +pub(crate) fn prepare_import( + path: &str, + parent_view_id: Option, + app_version: &Version, +) -> anyhow::Result { + info!( + "[AppflowyData]:importing data from path: {}, parent_view_id:{:?}", + path, parent_view_id + ); if !Path::new(path).exists() { return Err(anyhow!("The path: {} is not exist", path)); } let user_paths = UserPaths::new(path.to_string()); - let other_store_preferences = Arc::new(StorePreferences::new(path)?); + let other_store_preferences = Arc::new(KVStorePreferences::new(path)?); migrate_session_with_user_uuid("appflowy_session_cache", &other_store_preferences); let imported_session = other_store_preferences .get_object::("appflowy_session_cache") @@ -72,30 +89,48 @@ pub(crate) fn prepare_import(path: &str) -> anyhow::Result { ))?; let collab_db_path = user_paths.collab_db_path(imported_session.user_id); + info!("[AppflowyData]: collab db path: {:?}", collab_db_path); + let sqlite_db_path = user_paths.sqlite_db_path(imported_session.user_id); + info!("[AppflowyData]: sqlite db path: {:?}", sqlite_db_path); + let imported_sqlite_db = flowy_sqlite::init(sqlite_db_path) - .map_err(|err| anyhow!("open import collab db failed: {:?}", err))?; + .map_err(|err| anyhow!("[AppflowyData]: open import collab db failed: {:?}", err))?; + let imported_collab_db = Arc::new( CollabKVDB::open(collab_db_path) - .map_err(|err| anyhow!("open import collab db failed: {:?}", err))?, + .map_err(|err| anyhow!("[AppflowyData]: open import collab db failed: {:?}", err))?, ); - let imported_user = select_user_profile( + + let mut conn = imported_sqlite_db.get_connection()?; + let imported_workspace_auth_type = select_user_profile( imported_session.user_id, - imported_sqlite_db.get_connection()?, - )?; + &imported_session.user_workspace.id, + &mut conn, + ) + .map(|v| v.workspace_auth_type) + .or_else(|_| { + select_workspace_auth_type( + imported_session.user_id, + &imported_session.user_workspace.id, + &mut conn, + ) + })?; run_collab_data_migration( &imported_session, - &imported_user, + &imported_workspace_auth_type, imported_collab_db.clone(), imported_sqlite_db.get_pool(), - None, + other_store_preferences.clone(), + app_version, ); Ok(ImportedFolder { imported_session, imported_collab_db, container_name: None, + parent_view_id, source: ImportedSource::ExternalFolder, }) } @@ -118,20 +153,27 @@ fn migrate_user_awareness( /// - log (log files with unique identifiers) /// - 2761499 (other relevant files or directories, identified by unique numbers) +#[instrument(level = "debug", skip_all, err)] pub(crate) fn generate_import_data( current_session: &Session, - workspace_id: &str, - collab_db: &Arc, + user_collab_db: &Arc, imported_folder: ImportedFolder, -) -> anyhow::Result { +) -> anyhow::Result { + info!( + "[AppflowyData]:importing workspace: {}:{}", + imported_folder.imported_session.user_workspace.name, + imported_folder.imported_session.user_workspace.id, + ); + let workspace_id = current_session.user_workspace.id.clone(); + let imported_workspace_id = imported_folder.imported_session.user_workspace.id.clone(); let imported_session = imported_folder.imported_session.clone(); let imported_collab_db = imported_folder.imported_collab_db.clone(); let imported_container_view_name = imported_folder.container_name.clone(); let mut database_view_ids_by_database_id: HashMap> = HashMap::new(); - let row_object_ids = Mutex::new(HashSet::new()); - let document_object_ids = Mutex::new(HashSet::new()); - let database_object_ids = Mutex::new(HashSet::new()); + let mut row_object_ids = HashSet::new(); + let mut document_object_ids = HashSet::new(); + let mut database_object_ids = HashSet::new(); // All the imported views will be attached to the container view. If the container view name is not provided, // the container view will be the workspace, which mean the root of the workspace. @@ -143,51 +185,46 @@ pub(crate) fn generate_import_data( ImportedSource::AnonUser => workspace_id.to_string(), }; - let views = collab_db.with_write_txn(|collab_write_txn| { - let imported_collab_read_txn = imported_collab_db.read_txn(); + let (views, orphan_views) = user_collab_db.with_write_txn(|current_collab_db_write_txn| { + let imported_uid = imported_folder.imported_session.user_id; + let imported_collab_db_read_txn = imported_collab_db.read_txn(); // use the old_to_new_id_map to keep track of the other collab object id and the new collab object id - let old_to_new_id_map = Arc::new(Mutex::new(OldToNewIdMap::new())); + let mut old_to_new_id_map = OldToNewIdMap::new(); // 1. Get all the imported collab object ids - let mut all_imported_object_ids = imported_collab_read_txn - .get_all_docs() + let mut all_imported_object_ids = imported_collab_db_read_txn + .get_all_object_ids(imported_uid, imported_workspace_id.as_str()) .map(|iter| iter.collect::>()) .unwrap_or_default(); + info!( + "[AppflowyData]: {} has {} collab objects", + imported_uid, + all_imported_object_ids.len() + ); + // when doing import, we don't want to import these objects: // 1. user workspace - // 2. database view tracker - // 3. the user awareness + // 2. workspace database views + // 3. user awareness // So we remove these object ids from the list let user_workspace_id = &imported_session.user_workspace.id; - let database_indexer_id = &imported_session.user_workspace.database_indexer_id; + let workspace_database_id = &imported_session.user_workspace.workspace_database_id; let user_awareness_id = user_awareness_object_id(&imported_session.user_uuid, user_workspace_id).to_string(); all_imported_object_ids.retain(|id| { - id != user_workspace_id && id != database_indexer_id && id != &user_awareness_id + id != user_workspace_id && id != workspace_database_id && id != &user_awareness_id }); - match imported_folder.source { - ImportedSource::ExternalFolder => { - // 2. mapping the database indexer ids - mapping_database_indexer_ids( - &mut old_to_new_id_map.lock(), - &imported_session, - &imported_collab_read_txn, - &mut database_view_ids_by_database_id, - &database_object_ids, - )?; - }, - ImportedSource::AnonUser => { - // 2. migrate the database with views object - migrate_database_with_views_object( - &mut old_to_new_id_map.lock(), - &imported_session, - &imported_collab_read_txn, - current_session, - collab_write_txn, - )?; - }, + // 2. mapping the workspace database ids + if let Err(err) = mapping_workspace_database_ids( + &mut old_to_new_id_map, + &imported_session, + &imported_collab_db_read_txn, + &mut database_view_ids_by_database_id, + &mut database_object_ids, + ) { + error!("[AppflowyData]:import workspace database fail: {}", err); } // remove the database view ids from the object ids. Because there are no physical collab object @@ -200,100 +237,239 @@ pub(crate) fn generate_import_data( all_imported_object_ids.retain(|id| !database_view_ids.contains(id)); // 3. load imported collab objects data. - let imported_collab_by_oid = load_collab_by_oid( + let (mut imported_collab_by_oid, invalid_object_ids) = load_collab_by_object_ids( imported_session.user_id, - &imported_collab_read_txn, + &imported_workspace_id, + &imported_collab_db_read_txn, &all_imported_object_ids, ); + // remove the invalid object ids from the object ids + info!( + "[AppflowyData]: invalid object ids: {:?}", + invalid_object_ids + ); + all_imported_object_ids.retain(|id| !invalid_object_ids.contains(id)); + // the object ids now only contains the document collab object ids + all_imported_object_ids.iter().for_each(|object_id| { + old_to_new_id_map.exchange_new_id(object_id); + }); + // import the database - migrate_databases( - &old_to_new_id_map, + let MigrateDatabase { + database_view_ids, + database_row_ids, + } = migrate_databases( + &mut old_to_new_id_map, current_session, - collab_write_txn, + current_collab_db_write_txn, &mut all_imported_object_ids, - &imported_collab_by_oid, - &row_object_ids, + &mut imported_collab_by_oid, + &mut row_object_ids, )?; - // the object ids now only contains the document collab object ids - for object_id in &all_imported_object_ids { - if let Some(imported_collab) = imported_collab_by_oid.get(object_id) { - let new_object_id = old_to_new_id_map.lock().exchange_new_id(object_id); - document_object_ids.lock().insert(new_object_id.clone()); - debug!("import from: {}, to: {}", object_id, new_object_id,); - write_collab_object( - imported_collab, - current_session.user_id, - &new_object_id, - collab_write_txn, - ); - } - } + migrate_database_rows( + &mut old_to_new_id_map, + current_session, + current_collab_db_write_txn, + &mut imported_collab_by_oid, + database_row_ids, + )?; // Update the parent view IDs of all top-level views to match the new container view ID, making // them child views of the container. This ensures that the hierarchy within the imported // structure is correctly maintained. - let (mut child_views, orphan_views) = mapping_folder_views( + let MigrateViews { + child_views, + orphan_views, + mut invalid_orphan_views, + not_exist_parent_view_ids, + } = migrate_folder_views( &import_container_view_id, - &mut old_to_new_id_map.lock(), + &mut old_to_new_id_map, &imported_session, - &imported_collab_read_txn, + &imported_collab_db_read_txn, + &imported_collab_by_oid, + &database_view_ids, )?; - match imported_folder.source { + // Create collaboration documents for views that don't have existing collaboration objects. + // Currently, when importing AppFlowy data, the view which is a space doesn't have a corresponding + // collab data in disk. + for view_id in not_exist_parent_view_ids { + if let Err(err) = create_empty_document_for_view( + current_session.user_id, + ¤t_session.user_workspace.id, + &view_id, + current_collab_db_write_txn, + ) { + error!( + "[AppflowyData]: create empty document for view failed: {}", + err + ); + } + } + + let gen_document_collabs = all_imported_object_ids + .par_iter() + .filter_map(|object_id| { + let f = || { + let imported_collab = imported_collab_by_oid.get(object_id)?; + let new_object_id = old_to_new_id_map.get_exchanged_id(object_id)?; + gen_sv_and_doc_state( + current_session.user_id, + new_object_id, + imported_collab, + CollabType::Document, + &old_to_new_id_map, + ) + }; + f() + }) + .collect::>(); + + for document_collab in gen_document_collabs { + document_object_ids.insert(document_collab.object_id.clone()); + write_gen_collab(&workspace_id, document_collab, current_collab_db_write_txn); + } + + let (mut views, orphan_views) = match imported_folder.source { ImportedSource::ExternalFolder => match imported_container_view_name { - None => { - child_views.extend(orphan_views); - Ok(child_views) - }, + None => Ok::<(Vec, Vec), anyhow::Error>(( + child_views, + orphan_views, + )), Some(container_name) => { // create a new view with given name and then attach views to it - attach_to_new_view( + let child_views = vec![create_new_container_view( current_session, - &document_object_ids, + &mut document_object_ids, &import_container_view_id, - collab_write_txn, + current_collab_db_write_txn, child_views, - orphan_views, container_name, - ) + )?]; + Ok((child_views, orphan_views)) }, }, - ImportedSource::AnonUser => { - child_views.extend(orphan_views); - Ok(child_views) - }, + ImportedSource::AnonUser => Ok((child_views, orphan_views)), + }?; + + if !invalid_orphan_views.is_empty() { + let other_view_id = gen_view_id().to_string(); + invalid_orphan_views + .iter_mut() + .for_each(|parent_child_views| { + parent_child_views + .view + .parent_view_id + .clone_from(&other_view_id); + }); + let mut other_view = create_new_container_view( + current_session, + &mut document_object_ids, + &other_view_id, + current_collab_db_write_txn, + invalid_orphan_views, + "Others".to_string(), + )?; + + // if the views is empty, the other view is the top level view + // otherwise, the other view is the child view of the first view + if views.is_empty() { + views.push(other_view); + } else { + let first_view = views.first_mut().unwrap(); + other_view + .view + .parent_view_id + .clone_from(&first_view.view.id); + first_view.children.push(other_view); + } } + + Ok((views, orphan_views)) })?; - Ok(ImportData::AppFlowyDataFolder { - items: vec![ - AppFlowyData::Folder { - views, - database_view_ids_by_database_id, - }, - AppFlowyData::CollabObject { - row_object_ids: row_object_ids.into_inner().into_iter().collect(), - database_object_ids: database_object_ids.into_inner().into_iter().collect(), - document_object_ids: document_object_ids.into_inner().into_iter().collect(), - }, - ], + let source = match imported_folder.source { + ImportedSource::ExternalFolder => ImportFrom::AppFlowyDataFolder, + ImportedSource::AnonUser => ImportFrom::AnonUser, + }; + + Ok(ImportedAppFlowyData { + source, + parent_view_id: imported_folder.parent_view_id, + folder_data: ImportedFolderData { + views, + orphan_views, + database_view_ids_by_database_id, + }, + collab_data: ImportedCollabData { + row_object_ids: row_object_ids.into_iter().collect(), + database_object_ids: database_object_ids.into_iter().collect(), + document_object_ids: document_object_ids.into_iter().collect(), + }, }) } -fn attach_to_new_view<'a, W>( - current_session: &Session, - document_object_ids: &Mutex>, - import_container_view_id: &str, + +fn create_empty_document_for_view<'a, W>( + uid: i64, + workspace_id: &str, + view_id: &str, collab_write_txn: &'a W, - child_views: Vec, - orphan_views: Vec, - container_name: String, -) -> Result, PersistenceError> +) -> FlowyResult<()> where W: CollabKVAction<'a>, PersistenceError: From, { + let import_container_doc_state = default_document_collab_data(view_id) + .map_err(|err| PersistenceError::InvalidData(err.to_string()))? + .doc_state + .to_vec(); + + let collab = Collab::new_with_source( + CollabOrigin::Empty, + view_id, + DataSource::DocStateV1(import_container_doc_state), + vec![], + false, + ) + .map_err(|err| PersistenceError::InvalidData(err.to_string()))?; + + write_collab_object( + &collab, + uid, + workspace_id, + view_id, + collab_write_txn, + CollabType::Document, + ); + Ok(()) +} + +#[instrument(level = "debug", skip_all, err)] +fn create_new_container_view<'a, W>( + current_session: &Session, + document_object_ids: &mut HashSet, + import_container_view_id: &str, + collab_write_txn: &'a W, + mut child_views: Vec, + container_name: String, +) -> Result +where + W: CollabKVAction<'a>, + PersistenceError: From, +{ + child_views.iter_mut().for_each(|parent_child_views| { + if parent_child_views.view.parent_view_id != import_container_view_id { + warn!( + "[AppflowyData]: The parent view id of the child views is not the import container view id: {}", + import_container_view_id + ); + parent_child_views.view.parent_view_id = import_container_view_id.to_string(); + } + }); + let name = if container_name.is_empty() { format!( "import_{}", @@ -308,58 +484,69 @@ where .map_err(|err| PersistenceError::InvalidData(err.to_string()))? .doc_state .to_vec(); - import_collab_object_with_doc_state( - import_container_doc_state, + + let collab = Collab::new_with_source( + CollabOrigin::Empty, + import_container_view_id, + DataSource::DocStateV1(import_container_doc_state), + vec![], + false, + )?; + write_collab_object( + &collab, current_session.user_id, + current_session.user_workspace.id.as_str(), import_container_view_id, collab_write_txn, - )?; + CollabType::Document, + ); - document_object_ids - .lock() - .insert(import_container_view_id.to_string()); - let mut import_container_views = vec![ViewBuilder::new( + document_object_ids.insert(import_container_view_id.to_string()); + + let import_container_views = NestedChildViewBuilder::new( current_session.user_id, current_session.user_workspace.id.clone(), ) .with_view_id(import_container_view_id) .with_layout(ViewLayout::Document) - .with_name(name) - .with_child_views(child_views) - .build()]; + .with_name(&name) + .with_children(child_views) + .build(); - import_container_views.extend(orphan_views); Ok(import_container_views) } -fn mapping_database_indexer_ids<'a, W>( +#[instrument(level = "debug", skip_all, err)] +fn mapping_workspace_database_ids<'a, W>( old_to_new_id_map: &mut OldToNewIdMap, imported_session: &Session, - imported_collab_read_txn: &W, + imported_collab_db_read_txn: &W, database_view_ids_by_database_id: &mut HashMap>, - database_object_ids: &Mutex>, + database_object_ids: &mut HashSet, ) -> Result<(), PersistenceError> where W: CollabKVAction<'a>, PersistenceError: From, { - let imported_database_indexer = Collab::new( + let mut workspace_database_collab = Collab::new( imported_session.user_id, - &imported_session.user_workspace.database_indexer_id, + &imported_session.user_workspace.workspace_database_id, "import_device", vec![], false, ); - imported_database_indexer.with_origin_transact_mut(|txn| { - imported_collab_read_txn.load_doc_with_txn( - imported_session.user_id, - &imported_session.user_workspace.database_indexer_id, - txn, - ) - })?; + imported_collab_db_read_txn.load_doc_with_txn( + imported_session.user_id, + &imported_session.user_workspace.id, + &imported_session.user_workspace.workspace_database_id, + &mut workspace_database_collab.transact_mut(), + )?; - let array = DatabaseMetaList::from_collab(&imported_database_indexer); - for database_meta_list in array.get_all_database_meta() { + let workspace_database = init_workspace_database( + &imported_session.user_workspace.workspace_database_id, + workspace_database_collab, + ); + for database_meta_list in workspace_database.get_all_database_meta() { database_view_ids_by_database_id.insert( old_to_new_id_map.exchange_new_id(&database_meta_list.database_id), database_meta_list @@ -369,7 +556,7 @@ where .collect(), ); } - database_object_ids.lock().extend( + database_object_ids.extend( database_view_ids_by_database_id .keys() .cloned() @@ -378,115 +565,90 @@ where Ok(()) } -fn migrate_database_with_views_object<'a, 'b, W, R>( - old_to_new_id_map: &mut OldToNewIdMap, - old_user_session: &Session, - old_collab_r_txn: &R, - new_user_session: &Session, - new_collab_w_txn: &W, -) -> Result<(), PersistenceError> -where - 'a: 'b, - W: CollabKVAction<'a>, - R: CollabKVAction<'b>, - PersistenceError: From, - PersistenceError: From, -{ - let database_with_views_collab = Collab::new( - old_user_session.user_id, - &old_user_session.user_workspace.database_indexer_id, - "migrate_device", - vec![], - false, - ); - database_with_views_collab.with_origin_transact_mut(|txn| { - old_collab_r_txn.load_doc_with_txn( - old_user_session.user_id, - &old_user_session.user_workspace.database_indexer_id, - txn, - ) - })?; - - let new_uid = new_user_session.user_id; - let new_object_id = &new_user_session.user_workspace.database_indexer_id; - - let array = DatabaseMetaList::from_collab(&database_with_views_collab); - for database_meta in array.get_all_database_meta() { - array.update_database(&database_meta.database_id, |update| { - let new_linked_views = update - .linked_views - .iter() - .map(|view_id| old_to_new_id_map.exchange_new_id(view_id)) - .collect(); - update.database_id = old_to_new_id_map.exchange_new_id(&update.database_id); - update.linked_views = new_linked_views; - }) +fn init_workspace_database(object_id: &str, collab: Collab) -> WorkspaceDatabase { + match WorkspaceDatabase::open(collab) { + Ok(body) => body, + Err(err) => { + error!( + "[AppflowyData]:init workspace database body failed: {:?}, create a new one", + err + ); + let collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false); + WorkspaceDatabase::create(collab) + }, } - - let txn = database_with_views_collab.transact(); - if let Err(err) = new_collab_w_txn.create_new_doc(new_uid, new_object_id, &txn) { - error!("🔴migrate database storage failed: {:?}", err); - } - drop(txn); - Ok(()) } +struct MigrateDatabase { + database_view_ids: HashSet, + database_row_ids: HashMap>, +} + +#[instrument(level = "debug", skip_all, err)] fn migrate_databases<'a, W>( - old_to_new_id_map: &Arc>, + old_to_new_id_map: &mut OldToNewIdMap, session: &Session, collab_write_txn: &'a W, - imported_object_ids: &mut Vec, - imported_collab_by_oid: &HashMap, - row_object_ids: &Mutex>, -) -> Result<(), PersistenceError> + all_imported_object_ids: &mut Vec, + imported_collab_by_oid: &mut HashMap, + row_object_ids: &mut HashSet, +) -> Result where W: CollabKVAction<'a>, PersistenceError: From, { // Migrate databases - let row_document_object_ids = Mutex::new(HashSet::new()); let mut database_object_ids = vec![]; - let imported_database_row_object_ids: RwLock>> = - RwLock::new(HashMap::new()); - - for object_id in &mut *imported_object_ids { - if let Some(database_collab) = imported_collab_by_oid.get(object_id) { + let mut imported_database_row_ids: HashMap> = HashMap::new(); + let mut imported_database_view_ids = HashSet::new(); + let mut database_row_document_ids_pair = HashSet::new(); + let all_imported_keys = imported_collab_by_oid + .keys() + .cloned() + .collect::>(); + for object_id in all_imported_object_ids.iter() { + if let Some(database_collab) = imported_collab_by_oid.get_mut(object_id) { if !is_database_collab(database_collab) { continue; } database_object_ids.push(object_id.clone()); - reset_inline_view_id(database_collab, |old_inline_view_id| { - old_to_new_id_map - .lock() - .exchange_new_id(&old_inline_view_id) - }); + if reset_inline_view_id(database_collab, |old_inline_view_id| { + old_to_new_id_map.exchange_new_id(&old_inline_view_id) + }) + .is_err() + { + error!( + "[AppflowyData]: reset inline view id failed for database: {}", + object_id + ); + continue; + } mut_database_views_with_collab(database_collab, |database_view| { - let new_view_id = old_to_new_id_map.lock().exchange_new_id(&database_view.id); + let new_view_id = old_to_new_id_map.exchange_new_id(&database_view.id); let old_database_id = database_view.database_id.clone(); - let new_database_id = old_to_new_id_map - .lock() - .exchange_new_id(&database_view.database_id); + let new_database_id = old_to_new_id_map.exchange_new_id(&database_view.database_id); + imported_database_view_ids.insert(new_view_id.clone()); database_view.id = new_view_id; database_view.database_id = new_database_id; + database_view + .row_orders + .retain(|row_order| all_imported_keys.contains(&row_order.id)); database_view.row_orders.iter_mut().for_each(|row_order| { let old_row_id = String::from(row_order.id.clone()); let old_row_document_id = database_row_document_id_from_row_id(&old_row_id); - let new_row_id = old_to_new_id_map.lock().exchange_new_id(&old_row_id); + let new_row_id = old_to_new_id_map.exchange_new_id(&old_row_id); // The row document might not exist in the database row. But by querying the old_row_document_id, // we can know the document of the row is exist or not. let new_row_document_id = database_row_document_id_from_row_id(&new_row_id); - - old_to_new_id_map - .lock() - .insert(old_row_document_id.clone(), new_row_document_id); - + database_row_document_ids_pair + .insert((old_row_document_id.clone(), new_row_document_id.clone())); + old_to_new_id_map.insert(old_row_document_id.clone(), new_row_document_id); row_order.id = RowId::from(new_row_id); - imported_database_row_object_ids - .write() + imported_database_row_ids .entry(old_database_id.clone()) .or_default() .insert(old_row_id); @@ -498,54 +660,85 @@ where .iter() .map(|order| order.id.clone().into_inner()) .collect::>(); - row_object_ids.lock().extend(new_row_ids); + row_object_ids.extend(new_row_ids); }); - let new_object_id = old_to_new_id_map.lock().exchange_new_id(object_id); - debug!( - "migrate database from: {}, to: {}", - object_id, new_object_id, - ); + let new_database_object_id = old_to_new_id_map.exchange_new_id(object_id); write_collab_object( database_collab, session.user_id, - &new_object_id, + session.user_workspace.id.as_str(), + &new_database_object_id, collab_write_txn, + CollabType::Database, ); } } - let imported_database_row_object_ids = imported_database_row_object_ids.read(); + + // write database rows into disk + let gen_database_row_document_collabs = database_row_document_ids_pair + .par_iter() + .filter_map(|(old_object_id, new_object_id)| { + let imported_collab = imported_collab_by_oid.get(old_object_id)?; + gen_sv_and_doc_state( + session.user_id, + new_object_id, + imported_collab, + CollabType::Document, + old_to_new_id_map, + ) + }) + .collect::>(); + for gen_collab in gen_database_row_document_collabs { + write_gen_collab(&session.user_workspace.id, gen_collab, collab_write_txn); + } // remove the database object ids from the object ids - imported_object_ids.retain(|id| !database_object_ids.contains(id)); + all_imported_object_ids.retain(|id| !database_object_ids.contains(id)); // remove database row object ids from the imported object ids - imported_object_ids.retain(|id| { - !imported_database_row_object_ids + all_imported_object_ids.retain(|id| { + !imported_database_row_ids .values() .flatten() .any(|row_id| row_id == id) }); - for (database_id, imported_row_ids) in &*imported_database_row_object_ids { - for imported_row_id in imported_row_ids { - if let Some(imported_collab) = imported_collab_by_oid.get(imported_row_id) { - let new_database_id = old_to_new_id_map.lock().exchange_new_id(database_id); - let new_row_id = old_to_new_id_map.lock().exchange_new_id(imported_row_id); - info!( - "import database row from: {}, to: {}", - imported_row_id, new_row_id, - ); + let database_row_document_ids = database_row_document_ids_pair + .iter() + .map(|(old_object_id, _)| old_object_id.clone()) + .collect::>(); + all_imported_object_ids.retain(|id| !database_row_document_ids.contains(id)); + Ok(MigrateDatabase { + database_view_ids: imported_database_view_ids, + database_row_ids: imported_database_row_ids, + }) +} + +#[instrument(level = "debug", skip_all, err)] +fn migrate_database_rows<'a, W>( + old_to_new_id_map: &mut OldToNewIdMap, + session: &Session, + collab_write_txn: &'a W, + imported_collab_by_oid: &mut HashMap, + imported_database_row_object_ids: HashMap>, +) -> Result<(), PersistenceError> +where + W: CollabKVAction<'a>, + PersistenceError: From, +{ + let mut row_document_ids = HashSet::new(); + for (database_id, imported_row_ids) in imported_database_row_object_ids { + imported_row_ids.iter().for_each(|imported_row_id| { + if let Some(imported_collab) = imported_collab_by_oid.get_mut(imported_row_id) { + let new_database_id = old_to_new_id_map.exchange_new_id(&database_id); + let new_row_id = old_to_new_id_map.exchange_new_id(imported_row_id); mut_row_with_collab(imported_collab, |row_update| { - row_update.set_row_id(RowId::from(new_row_id.clone()), new_database_id.clone()); + row_update + .set_row_id(RowId::from(new_row_id.clone())) + .set_database_id(new_database_id.clone()); }); - write_collab_object( - imported_collab, - session.user_id, - &new_row_id, - collab_write_txn, - ); } // imported_collab_by_oid contains all the collab object ids, including the row document collab object ids. @@ -555,52 +748,85 @@ where .get(&imported_row_document_id) .is_some() { - let new_row_document_id = old_to_new_id_map - .lock() - .exchange_new_id(&imported_row_document_id); - row_document_object_ids.lock().insert(new_row_document_id); + let new_row_document_id = old_to_new_id_map.exchange_new_id(&imported_row_document_id); + row_document_ids.insert(new_row_document_id); } + }); + + let gen_database_row_collabs = imported_row_ids + .par_iter() + .filter_map(|imported_row_id| { + let imported_collab = imported_collab_by_oid.get(imported_row_id)?; + match old_to_new_id_map.get_exchanged_id(imported_row_id) { + None => { + error!( + "[AppflowyData]: Can't find the new id for the imported row:{}", + imported_row_id + ); + None + }, + Some(new_row_id) => gen_sv_and_doc_state( + session.user_id, + new_row_id, + imported_collab, + CollabType::DatabaseRow, + old_to_new_id_map, + ), + } + }) + .collect::>(); + + for gen_collab in gen_database_row_collabs { + write_gen_collab(&session.user_workspace.id, gen_collab, collab_write_txn); } } - debug!( - "import row document ids: {:?}", - row_document_object_ids - .lock() - .iter() - .collect::>() - ); - Ok(()) } -fn write_collab_object<'a, W>(collab: &Collab, new_uid: i64, new_object_id: &str, w_txn: &'a W) -where +fn write_collab_object<'a, W>( + collab: &Collab, + new_uid: i64, + new_workspace_id: &str, + new_object_id: &str, + w_txn: &'a W, + collab_type: CollabType, +) where W: CollabKVAction<'a>, PersistenceError: From, { - if let Ok(encode_collab) = collab.encode_collab_v1(|_| Ok::<(), PersistenceError>(())) { + if let Ok(encode_collab) = + collab.encode_collab_v1(|collab| collab_type.validate_require_data(collab)) + { if let Ok(update) = Update::decode_v1(&encode_collab.doc_state) { let doc = Doc::new(); { let mut txn = doc.transact_mut(); - txn.apply_update(update); + if let Err(e) = txn.apply_update(update) { + error!( + "Collab {} failed to apply update: {}", + collab.object_id(), + e + ); + return; + } drop(txn); } - let encoded_collab = doc.get_encoded_collab_v1(); - info!( - "import collab:{} with len: {}", - new_object_id, - encoded_collab.doc_state.len() - ); + let txn = doc.transact(); + let state_vector = txn.state_vector(); + let doc_state = txn.encode_state_as_update_v1(&StateVector::default()); if let Err(err) = w_txn.flush_doc( new_uid, - &new_object_id, - encoded_collab.state_vector.to_vec(), - encoded_collab.doc_state.to_vec(), + new_workspace_id, + new_object_id, + state_vector.encode_v1(), + doc_state, ) { - error!("import collab:{} failed: {:?}", new_object_id, err); + error!( + "[AppflowyData]:import collab:{} failed: {:?}", + new_object_id, err + ); } } } else { @@ -608,65 +834,181 @@ where } } -fn import_collab_object_with_doc_state<'a, W>( +struct GenCollab { + uid: i64, + sv: Vec, doc_state: Vec, - new_uid: i64, - new_object_id: &str, - w_txn: &'a W, -) -> Result<(), anyhow::Error> + object_id: String, +} + +fn write_gen_collab<'a, W>(workspace_id: &str, collab: GenCollab, w_txn: &'a W) where W: CollabKVAction<'a>, PersistenceError: From, { - let collab = Collab::new_with_source( - CollabOrigin::Empty, - new_object_id, - DataSource::DocStateV1(doc_state), - vec![], - false, - )?; - write_collab_object(&collab, new_uid, new_object_id, w_txn); - Ok(()) + if let Err(err) = w_txn.flush_doc( + collab.uid, + workspace_id, + collab.object_id.as_str(), + collab.sv, + collab.doc_state, + ) { + error!( + "[AppflowyData]:import collab:{} failed: {:?}", + collab.object_id, err + ); + } } -fn mapping_folder_views<'a, W>( +fn gen_sv_and_doc_state( + uid: i64, + object_id: &str, + collab: &Collab, + collab_type: CollabType, + ids_map: &OldToNewIdMap, +) -> Option { + let encoded_collab = collab + .encode_collab_v1(|collab| collab_type.validate_require_data(collab)) + .ok()?; + + let (state_vector, doc_state) = match collab_type { + CollabType::Document => { + let collab = Collab::new_with_source( + CollabOrigin::Empty, + object_id, + encoded_collab.into(), + vec![], + false, + ) + .ok()?; + let mut document = Document::open(collab).ok()?; + if let Err(err) = replace_document_ref_ids(&mut document, ids_map) { + error!("[AppFlowyData]: replace document ref ids failed: {}", err); + } + let encode_collab = document.encode_collab().ok()?; + ( + encode_collab.state_vector.to_vec(), + encode_collab.doc_state.to_vec(), + ) + }, + _ => { + let update = Update::decode_v1(&encoded_collab.doc_state).ok()?; + let doc = Doc::new(); + let mut txn = doc.transact_mut(); + if let Err(e) = txn.apply_update(update) { + error!( + "Collab {} failed to apply update: {}", + collab.object_id(), + e + ); + return None; + } + drop(txn); + let txn = doc.transact(); + let state_vector = txn.state_vector(); + let doc_state = txn.encode_state_as_update_v1(&StateVector::default()); + (state_vector.encode_v1(), doc_state) + }, + }; + + Some(GenCollab { + uid, + sv: state_vector, + doc_state, + object_id: object_id.to_string(), + }) +} + +struct MigrateViews { + child_views: Vec, + orphan_views: Vec, + invalid_orphan_views: Vec, + #[allow(dead_code)] + not_exist_parent_view_ids: Vec, +} + +#[instrument(level = "debug", skip_all, err)] +fn migrate_folder_views<'a, W>( root_view_id: &str, old_to_new_id_map: &mut OldToNewIdMap, imported_session: &Session, - imported_collab_read_txn: &W, -) -> Result<(Vec, Vec), PersistenceError> + imported_collab_db_read_txn: &W, + imported_collab_by_oid: &HashMap, + database_view_ids: &HashSet, +) -> Result where W: CollabKVAction<'a>, PersistenceError: From, { - let imported_folder_collab = Collab::new( + let mut imported_folder_collab = Collab::new( imported_session.user_id, &imported_session.user_workspace.id, "migrate_device", vec![], false, ); - imported_folder_collab.with_origin_transact_mut(|txn| { - imported_collab_read_txn.load_doc_with_txn( + + imported_collab_db_read_txn + .load_doc_with_txn( imported_session.user_id, &imported_session.user_workspace.id, - txn, + &imported_session.user_workspace.id, + &mut imported_folder_collab.transact_mut(), ) - })?; + .map_err(|err| { + PersistenceError::Internal(anyhow!( + "[AppflowyData]: Can't load the user:{} folder:{}. {}", + imported_session.user_id, + imported_session.user_workspace.id, + err + )) + })?; let other_user_id = UserId::from(imported_session.user_id); - let imported_folder = Folder::open( - other_user_id, - Arc::new(MutexCollab::new(imported_folder_collab)), - None, - ) - .map_err(|err| PersistenceError::InvalidData(err.to_string()))?; + let imported_folder = + Folder::open(other_user_id, imported_folder_collab, None).map_err(|err| { + PersistenceError::Internal(anyhow!("[AppflowyData]:Can't open folder:{}", err)) + })?; - let imported_folder_data = imported_folder + let mut imported_folder_data = imported_folder .get_folder_data(&imported_session.user_workspace.id) .ok_or(PersistenceError::Internal(anyhow!( - "Can't read the folder data" + "[AppflowyData]: Can't read the folder data" )))?; + // Only import views whose collab data is available + imported_folder_data.views.iter_mut().for_each(|view| { + view.children.retain(|view_identifier| { + let new_view_id = old_to_new_id_map.exchange_new_id(&view_identifier.id); + if database_view_ids.contains(&new_view_id) { + true + } else { + imported_collab_by_oid.contains_key(&view_identifier.id) + } + }); + }); + let mut not_exist_parent_view_ids = vec![]; + imported_folder_data.views.retain(|view| { + let new_view_id = old_to_new_id_map.exchange_new_id(&view.id); + // If the view is a database view, it should be in the database view ids + if view.layout.is_database() && !database_view_ids.contains(&new_view_id) { + error!( + "[AppflowyData]: The database view:{} is not in the database view ids", + view.id + ); + } + + if database_view_ids.contains(&new_view_id) { + true + } else if view.space_info().is_some() { + if !imported_collab_by_oid.contains_key(&view.id) { + not_exist_parent_view_ids.push(new_view_id); + } + true + } else { + imported_collab_by_oid.contains_key(&view.id) + } + }); + // replace the old parent view id of the workspace old_to_new_id_map.0.insert( imported_session.user_workspace.id.clone(), @@ -681,7 +1023,7 @@ where .collect::>(); // 1. Replace the views id with new view id - let mut first_level_views = imported_folder_data + let mut views_not_in_trash = imported_folder_data .workspace .child_views .items @@ -689,7 +1031,7 @@ where .filter(|view| !trash_ids.contains(&view.id)) .collect::>(); - first_level_views.iter_mut().for_each(|view_identifier| { + views_not_in_trash.iter_mut().for_each(|view_identifier| { view_identifier.id = old_to_new_id_map.exchange_new_id(&view_identifier.id); }); @@ -714,27 +1056,74 @@ where .collect::>(); // 5. create the parent views. Each parent view contains the children views. - let parent_views = first_level_views + let parent_views = views_not_in_trash .into_iter() .flat_map( |view_identifier| match all_views_map.remove(&view_identifier.id) { - None => None, + None => { + warn!( + "[AppflowyData]: Can't find the view:{} in the all views map", + view_identifier.id + ); + None + }, Some(view) => parent_view_from_view(view, &mut all_views_map), }, ) .collect::>(); // 6. after the parent views are created, the all_views_map only contains the orphan views - debug!("create orphan views: {:?}", all_views_map.keys()); + info!( + "[AppflowyData]: create orphan views: {:?}", + all_views_map.keys() + ); + let parent_views = NestedViews { + views: parent_views, + }; + let mut orphan_views = vec![]; + let mut invalid_orphan_views = vec![]; for orphan_view in all_views_map.into_values() { - orphan_views.push(ParentChildViews { - parent_view: orphan_view, - child_views: vec![], - }); + // when the view's parent view id is the same as its view id, it will be considered as the orphan view + // currently, only the document in database row follows this rule + if orphan_view.id == orphan_view.parent_view_id { + orphan_views.push(ParentChildViews { + view: orphan_view, + children: vec![], + }); + continue; + } + + if parent_views + .find_view(&orphan_view.parent_view_id) + .is_none() + { + invalid_orphan_views.push(ParentChildViews { + view: orphan_view, + children: vec![], + }); + } else { + orphan_views.push(ParentChildViews { + view: orphan_view, + children: vec![], + }); + } } - Ok((parent_views, orphan_views)) + info!( + "[AppflowyData]: parent views: {}, orphan views: {}, invalid orphan views: {}, views without collab data: {}", + parent_views.len(), + orphan_views.len(), + invalid_orphan_views.len(), + not_exist_parent_view_ids.len() + ); + + Ok(MigrateViews { + child_views: parent_views.views, + orphan_views, + invalid_orphan_views, + not_exist_parent_view_ids, + }) } fn parent_view_from_view( @@ -753,8 +1142,8 @@ fn parent_view_from_view( .collect::>(); Some(ParentChildViews { - parent_view, - child_views, + view: parent_view, + children: child_views, }) } @@ -772,6 +1161,10 @@ impl OldToNewIdMap { .or_insert(gen_view_id().to_string()); (*view_id).clone() } + + fn get_exchanged_id(&self, old_id: &str) -> Option<&String> { + self.0.get(old_id) + } } impl Deref for OldToNewIdMap { @@ -792,9 +1185,9 @@ impl DerefMut for OldToNewIdMap { pub async fn upload_collab_objects_data( uid: i64, user_collab_db: Weak, - workspace_id: &str, - user_authenticator: &Authenticator, - appflowy_data: AppFlowyData, + workspace_id: &Uuid, + user_authenticator: &AuthType, + collab_data: ImportedCollabData, user_cloud_service: Arc, ) -> Result<(), FlowyError> { // Only support uploading the collab data when the current server is AppFlowy Cloud server @@ -802,72 +1195,92 @@ pub async fn upload_collab_objects_data( return Ok(()); } - match appflowy_data { - AppFlowyData::Folder { .. } => {}, - AppFlowyData::CollabObject { - row_object_ids, - document_object_ids, - database_object_ids, - } => { - let object_by_collab_type = tokio::task::spawn_blocking(move || { - let user_collab_db = user_collab_db.upgrade().ok_or_else(|| { - FlowyError::internal().with_context("The collab db has been dropped, indicating that the user has switched to a new account") - })?; - - let collab_read = user_collab_db.read_txn(); - let mut object_by_collab_type = HashMap::new(); - - event!(tracing::Level::DEBUG, "upload database collab data"); - object_by_collab_type.insert( - CollabType::Database, - load_and_process_collab_data(uid, &collab_read, &database_object_ids), - ); - - event!(tracing::Level::DEBUG, "upload document collab data"); - object_by_collab_type.insert( - CollabType::Document, - load_and_process_collab_data(uid, &collab_read, &document_object_ids), - ); - - event!(tracing::Level::DEBUG, "upload database row collab data"); - object_by_collab_type.insert( - CollabType::DatabaseRow, - load_and_process_collab_data(uid, &collab_read, &row_object_ids), - ); - Ok::<_, FlowyError>(object_by_collab_type) - }) - .await??; - - let mut size_counter = 0; - let mut objects: Vec = vec![]; - for (collab_type, encoded_collab_by_oid) in object_by_collab_type { - for (oid, encoded_collab) in encoded_collab_by_oid { - let obj_size = encoded_collab.len(); - // Add the current object to the batch. - objects.push(UserCollabParams { - object_id: oid, - encoded_collab, - collab_type: collab_type.clone(), - }); - size_counter += obj_size; - } - } - - // Spawn a new task to upload the collab objects data in the background. If the - // upload fails, we will retry the upload later. - // af_spawn(async move { - if !objects.is_empty() { - batch_create( - uid, - workspace_id, - &user_cloud_service, - &size_counter, - objects, + let ImportedCollabData { + row_object_ids, + document_object_ids, + database_object_ids, + } = collab_data; + { + let cloned_workspace_id = workspace_id.to_string(); + let object_by_collab_type = tokio::task::spawn_blocking(move || { + let user_collab_db = user_collab_db.upgrade().ok_or_else(|| { + FlowyError::internal().with_context( + "The collab db has been dropped, indicating that the user has switched to a new account", ) - .await; + })?; + + let collab_read = user_collab_db.read_txn(); + let mut object_by_collab_type = HashMap::new(); + + event!( + tracing::Level::DEBUG, + "[AppflowyData]:upload database collab data" + ); + object_by_collab_type.insert( + CollabType::Database, + load_and_process_collab_data( + uid, + &cloned_workspace_id, + &collab_read, + &database_object_ids, + ), + ); + + event!( + tracing::Level::DEBUG, + "[AppflowyData]:upload document collab data" + ); + object_by_collab_type.insert( + CollabType::Document, + load_and_process_collab_data( + uid, + &cloned_workspace_id, + &collab_read, + &document_object_ids, + ), + ); + + event!( + tracing::Level::DEBUG, + "[AppflowyData]:upload database row collab data" + ); + object_by_collab_type.insert( + CollabType::DatabaseRow, + load_and_process_collab_data(uid, &cloned_workspace_id, &collab_read, &row_object_ids), + ); + Ok::<_, FlowyError>(object_by_collab_type) + }) + .await??; + + let mut size_counter = 0; + let mut objects: Vec = vec![]; + for (collab_type, encoded_collab_by_oid) in object_by_collab_type { + for (oid, encoded_collab) in encoded_collab_by_oid { + let obj_size = encoded_collab.len(); + // Add the current object to the batch. + objects.push(UserCollabParams { + object_id: oid, + encoded_collab, + collab_type, + }); + size_counter += obj_size; } - // }); - }, + } + + // Spawn a new task to upload the collab objects data in the background. If the + // upload fails, we will retry the upload later. + // tokio::spawn(async move { + if !objects.is_empty() { + batch_create( + uid, + workspace_id, + &user_cloud_service, + &size_counter, + objects, + ) + .await; + } + // }); } Ok(()) @@ -875,30 +1288,25 @@ pub async fn upload_collab_objects_data( async fn batch_create( uid: i64, - workspace_id: &str, + workspace_id: &Uuid, user_cloud_service: &Arc, size_counter: &usize, objects: Vec, ) { - let ids = objects - .iter() - .map(|o| o.object_id.clone()) - .collect::>() - .join(", "); match user_cloud_service .batch_create_collab_object(workspace_id, objects) .await { Ok(_) => { info!( - "Batch creating collab objects success, origin payload size: {}", + "[AppflowyData]:Batch creating collab objects success, origin payload size: {}", size_counter ); }, Err(err) => { error!( - "Batch creating collab objects fail:{}, origin payload size: {}, workspace_id:{}, uid: {}, error: {:?}", - ids, size_counter, workspace_id, uid,err + "[AppflowyData]:Batch creating collab objects fail, origin payload size: {}, workspace_id:{}, uid: {}, error: {:?}", + size_counter, workspace_id, uid,err ); }, } @@ -907,6 +1315,7 @@ async fn batch_create( #[instrument(level = "debug", skip_all)] fn load_and_process_collab_data<'a, R>( uid: i64, + workspace_id: &str, collab_read: &R, object_ids: &[String], ) -> HashMap> @@ -914,7 +1323,8 @@ where R: CollabKVAction<'a>, PersistenceError: From, { - load_collab_by_oid(uid, collab_read, object_ids) + load_collab_by_object_ids(uid, workspace_id, collab_read, object_ids) + .0 .into_iter() .filter_map(|(oid, collab)| { collab @@ -926,3 +1336,70 @@ where }) .collect() } + +fn replace_document_ref_ids( + document: &mut Document, + ids_map: &OldToNewIdMap, +) -> Result<(), anyhow::Error> { + if let Some(page_id) = document.get_page_id() { + // Get all block children and process them + let block_ids = document.get_block_children_ids(&page_id); + + for block_id in block_ids.iter() { + // Process block deltas + + let block_delta = document.get_block_delta(block_id).map(|t| t.1); + if let Some(mut block_deltas) = block_delta { + let mut is_change = false; + for d in block_deltas.iter_mut() { + if let TextDelta::Inserted(_, Some(attrs)) = d { + if let Some(Any::Map(mention)) = attrs.get_mut("mention") { + if let Some(page_id) = mention.get("page_id").map(|v| v.to_string()) { + if let Some(new_page_id) = ids_map.get_exchanged_id(&page_id) { + let mention = Arc::make_mut(mention); + mention.insert("page_id".to_string(), Any::from(new_page_id.clone())); + is_change = true; + } + } + } + } + } + + if is_change { + let _ = document.set_block_delta(block_id, block_deltas); + } + } + + // Process block data + if let Some((_block_type, mut data)) = document.get_block_data(block_id) { + println!("block data: {:?}", data); + let mut updated = false; + if let Some(view_id) = data.get("view_id").and_then(|v| v.as_str()) { + if let Some(new_view_id) = ids_map.get_exchanged_id(view_id) { + data.insert("view_id".to_string(), json!(new_view_id)); + updated = true; + } + } + + if let Some(parent_id) = data.get("parent_id").and_then(|v| v.as_str()) { + if let Some(new_parent_id) = ids_map.get_exchanged_id(parent_id) { + data.insert("parent_id".to_string(), json!(new_parent_id)); + updated = true; + } + } + + // Apply updates only if any changes were made + if updated { + println!("update block data: {:?}", data); + document.update_block(block_id, data).map_err(|err| { + anyhow::Error::msg(format!( + "[AppFlowyData]: update document block data: {}", + err + )) + })?; + } + } + } + } + Ok(()) +} diff --git a/frontend/rust-lib/flowy-user/src/services/data_import/importer.rs b/frontend/rust-lib/flowy-user/src/services/data_import/importer.rs index b1dee99774..3f01934ab7 100644 --- a/frontend/rust-lib/flowy-user/src/services/data_import/importer.rs +++ b/frontend/rust-lib/flowy-user/src/services/data_import/importer.rs @@ -1,30 +1,47 @@ use collab::preclude::Collab; use collab_integrate::{CollabKVAction, PersistenceError}; use std::collections::HashMap; -use tracing::instrument; -#[instrument(level = "debug", skip_all)] -pub fn load_collab_by_oid<'a, R>( +/// This function loads collab objects by their object_ids. +pub fn load_collab_by_object_ids<'a, R>( uid: i64, + workspace_id: &str, collab_read_txn: &R, object_ids: &[String], -) -> HashMap +) -> (HashMap, Vec) where R: CollabKVAction<'a>, PersistenceError: From, { + let mut invalid_object_ids = vec![]; let mut collab_by_oid = HashMap::new(); for object_id in object_ids { - let collab = Collab::new(uid, object_id, "phantom", vec![], false); - match collab - .with_origin_transact_mut(|txn| collab_read_txn.load_doc_with_txn(uid, &object_id, txn)) - { - Ok(_) => { + match load_collab_by_object_id(uid, collab_read_txn, workspace_id, object_id) { + Ok(collab) => { collab_by_oid.insert(object_id.clone(), collab); }, - Err(err) => tracing::error!("🔴import collab:{} failed: {:?} ", object_id, err), + Err(err) => { + invalid_object_ids.push(object_id.clone()); + tracing::error!("🔴load collab: {} failed: {:?} ", object_id, err) + }, } } - collab_by_oid + (collab_by_oid, invalid_object_ids) +} + +/// This function loads single collab object by its object_id. +pub fn load_collab_by_object_id<'a, R>( + uid: i64, + collab_read_txn: &R, + workspace_id: &str, + object_id: &str, +) -> Result +where + R: CollabKVAction<'a>, + PersistenceError: From, +{ + let mut collab = Collab::new(uid, object_id, "phantom", vec![], false); + collab_read_txn.load_doc_with_txn(uid, workspace_id, object_id, &mut collab.transact_mut())?; + Ok(collab) } diff --git a/frontend/rust-lib/flowy-user/src/services/data_import/mod.rs b/frontend/rust-lib/flowy-user/src/services/data_import/mod.rs index 2e5ddf9603..9fec671ade 100644 --- a/frontend/rust-lib/flowy-user/src/services/data_import/mod.rs +++ b/frontend/rust-lib/flowy-user/src/services/data_import/mod.rs @@ -2,4 +2,5 @@ mod appflowy_data_import; pub use appflowy_data_import::*; pub(crate) mod importer; -pub use importer::load_collab_by_oid; +pub use importer::load_collab_by_object_id; +pub use importer::load_collab_by_object_ids; diff --git a/frontend/rust-lib/flowy-user/src/services/db.rs b/frontend/rust-lib/flowy-user/src/services/db.rs index 3305fca41a..15126558d7 100644 --- a/frontend/rust-lib/flowy-user/src/services/db.rs +++ b/frontend/rust-lib/flowy-user/src/services/db.rs @@ -1,26 +1,19 @@ use std::path::{Path, PathBuf}; -use std::{collections::HashMap, fs, io, sync::Arc, time::Duration}; +use std::{fs, io, sync::Arc}; use chrono::{Days, Local}; use collab_integrate::{CollabKVAction, CollabKVDB, PersistenceError}; use collab_plugins::local_storage::kv::KVTransactionDB; +use dashmap::mapref::entry::Entry; +use dashmap::DashMap; use flowy_error::FlowyError; -use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::ConnectionPool; -use flowy_sqlite::{ - query_dsl::*, - schema::{user_table, user_table::dsl}, - DBConnection, Database, ExpressionMethods, -}; -use flowy_user_pub::entities::{UserProfile, UserWorkspace}; -use lib_dispatch::prelude::af_spawn; +use flowy_sqlite::{DBConnection, Database}; +use flowy_user_pub::entities::UserProfile; +use flowy_user_pub::sql::select_user_profile; use lib_infra::file_util::{unzip_and_replace, zip_folder}; -use parking_lot::RwLock; use tracing::{error, event, info, instrument}; -use crate::services::sqlite_sql::user_sql::UserTable; -use crate::services::sqlite_sql::workspace_sql::UserWorkspaceTable; - pub trait UserDBPath: Send + Sync + 'static { fn sqlite_db_path(&self, uid: i64) -> PathBuf; fn collab_db_path(&self, uid: i64) -> PathBuf; @@ -29,8 +22,8 @@ pub trait UserDBPath: Send + Sync + 'static { pub struct UserDB { paths: Box, - sqlite_map: RwLock>, - collab_db_map: RwLock>>, + sqlite_map: DashMap, + collab_db_map: DashMap>, } impl UserDB { @@ -43,18 +36,8 @@ impl UserDB { } /// Performs a conditional backup or restoration of the collaboration database (CollabDB) for a specific user. - /// - /// This function takes a user ID and conducts the following operations: - /// - /// **Backup or Restoration**: - /// - If the CollabDB exists, it tries to open the database: - /// - **Successful Open**: If the database opens successfully, it attempts to back it up. - /// - **Failed Open**: If the database cannot be opened, it indicates a potential issue, and the function - /// attempts to restore the database from the latest backup. - /// - If the CollabDB does not exist, it immediately attempts to restore from the latest backup. - /// #[instrument(level = "debug", skip_all)] - pub fn backup_or_restore(&self, uid: i64, workspace_id: &str) { + pub fn backup(&self, uid: i64, workspace_id: &str) { // Obtain the path for the collaboration database. let collab_db_path = self.paths.collab_db_path(uid); @@ -62,23 +45,18 @@ impl UserDB { if let Ok(history_folder) = self.paths.collab_db_history(uid, true) { // Initialize the backup utility for the collaboration database. let zip_backup = CollabDBZipBackup::new(collab_db_path.clone(), history_folder); - if collab_db_path.exists() { // Validate the existing collaboration database. let result = self.open_collab_db(collab_db_path, uid); let is_ok = validate_collab_db(result, uid, workspace_id); - if is_ok { // If database is valid, update the shared map and initiate backup. // Asynchronous backup operation. - af_spawn(async move { + tokio::spawn(async move { if let Err(err) = tokio::task::spawn_blocking(move || zip_backup.backup()).await { error!("Backup of collab db failed: {:?}", err); } }); - } else if let Err(err) = zip_backup.restore_latest_backup() { - // If validation fails, attempt to restore from the latest backup. - error!("Restoring collab db failed: {:?}", err); } } } @@ -95,35 +73,16 @@ impl UserDB { vec![] } - #[instrument(level = "debug", skip_all)] - pub fn restore_if_need(&self, uid: i64, workspace_id: &str) { - if let Ok(history_folder) = self.paths.collab_db_history(uid, false) { - let collab_db_path = self.paths.collab_db_path(uid); - let result = self.open_collab_db(&collab_db_path, uid); - let is_ok = validate_collab_db(result, uid, workspace_id); - if !is_ok { - let zip_backup = CollabDBZipBackup::new(collab_db_path, history_folder); - if let Err(err) = zip_backup.restore_latest_backup() { - error!("restore collab db failed, {:?}", err); - } - } - } - } - /// Close the database connection for the user. pub(crate) fn close(&self, user_id: i64) -> Result<(), FlowyError> { - if let Some(mut sqlite_dbs) = self.sqlite_map.try_write_for(Duration::from_millis(300)) { - if sqlite_dbs.remove(&user_id).is_some() { - tracing::trace!("close sqlite db for user {}", user_id); - } + if self.sqlite_map.remove(&user_id).is_some() { + tracing::trace!("close sqlite db for user {}", user_id); } - if let Some(mut collab_dbs) = self.collab_db_map.try_write_for(Duration::from_millis(300)) { - if let Some(db) = collab_dbs.remove(&user_id) { - tracing::trace!("close collab db for user {}", user_id); - let _ = db.flush(); - drop(db); - } + if let Some((_, db)) = self.collab_db_map.remove(&user_id) { + tracing::trace!("close collab db for user {}", user_id); + let _ = db.flush(); + drop(db); } Ok(()) } @@ -148,44 +107,29 @@ impl UserDB { db_path: impl AsRef, user_id: i64, ) -> Result, FlowyError> { - if let Some(database) = self.sqlite_map.read().get(&user_id) { - return Ok(database.get_pool()); + match self.sqlite_map.entry(user_id) { + Entry::Occupied(e) => Ok(e.get().get_pool()), + Entry::Vacant(e) => { + tracing::debug!("open sqlite db {} at path: {:?}", user_id, db_path.as_ref()); + let db = flowy_sqlite::init(&db_path).map_err(|e| { + FlowyError::internal().with_context(format!("open user db failed, {:?}", e)) + })?; + let pool = db.get_pool(); + e.insert(db); + Ok(pool) + }, } - - let mut write_guard = self.sqlite_map.write(); - tracing::debug!("open sqlite db {} at path: {:?}", user_id, db_path.as_ref()); - let db = flowy_sqlite::init(&db_path) - .map_err(|e| FlowyError::internal().with_context(format!("open user db failed, {:?}", e)))?; - let pool = db.get_pool(); - write_guard.insert(user_id.to_owned(), db); - drop(write_guard); - Ok(pool) } pub fn get_user_profile( &self, pool: &Arc, uid: i64, + workspace_id: &str, ) -> Result { - let uid = uid.to_string(); let mut conn = pool.get()?; - let user = dsl::user_table - .filter(user_table::id.eq(&uid)) - .first::(&mut *conn)?; - - Ok(user.into()) - } - - pub fn get_user_workspace( - &self, - pool: &Arc, - uid: i64, - ) -> Result, FlowyError> { - let mut conn = pool.get()?; - let row = user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::uid.eq(uid)) - .first::(&mut *conn)?; - Ok(Some(UserWorkspace::from(row))) + let profile = select_user_profile(uid, workspace_id, &mut conn)?; + Ok(profile) } /// Open a collab db for the user. If the db is already opened, return the opened db. @@ -195,28 +139,27 @@ impl UserDB { collab_db_path: impl AsRef, uid: i64, ) -> Result, PersistenceError> { - if let Some(collab_db) = self.collab_db_map.read().get(&uid) { - return Ok(collab_db.clone()); - } + match self.collab_db_map.entry(uid) { + Entry::Occupied(e) => Ok(e.get().clone()), + Entry::Vacant(e) => { + info!( + "open collab db for user {} at path: {:?}", + uid, + collab_db_path.as_ref() + ); + let db = match CollabKVDB::open(&collab_db_path) { + Ok(db) => Ok(db), + Err(err) => { + error!("open collab db error, {:?}", err); + Err(err) + }, + }?; - let mut write_guard = self.collab_db_map.write(); - info!( - "open collab db for user {} at path: {:?}", - uid, - collab_db_path.as_ref() - ); - let db = match CollabKVDB::open(&collab_db_path) { - Ok(db) => Ok(db), - Err(err) => { - error!("open collab db error, {:?}", err); - Err(err) + let db = Arc::new(db); + e.insert(db.clone()); + Ok(db) }, - }?; - - let db = Arc::new(db); - write_guard.insert(uid.to_owned(), db.clone()); - drop(write_guard); - Ok(db) + } } } @@ -281,8 +224,9 @@ impl CollabDBZipBackup { Ok(backups) } - #[instrument(skip_all, err)] - pub fn restore_latest_backup(&self) -> io::Result<()> { + #[deprecated(note = "This function is deprecated", since = "0.7.1")] + #[allow(dead_code)] + fn restore_latest_backup(&self) -> io::Result<()> { let mut latest_zip: Option<(String, PathBuf)> = None; // When the history folder does not exist, there is no backup to restore if !self.history_folder.exists() { @@ -393,7 +337,7 @@ pub(crate) fn validate_collab_db( match result { Ok(db) => { let read_txn = db.read_txn(); - read_txn.is_exist(uid, workspace_id) + read_txn.is_exist(uid, workspace_id, workspace_id) }, Err(err) => { error!("open collab db error, {:?}", err); diff --git a/frontend/rust-lib/flowy-user/src/services/entities.rs b/frontend/rust-lib/flowy-user/src/services/entities.rs index 831ef10751..4e034b3bdb 100644 --- a/frontend/rust-lib/flowy-user/src/services/entities.rs +++ b/frontend/rust-lib/flowy-user/src/services/entities.rs @@ -63,6 +63,11 @@ impl UserPaths { pub(crate) fn user_data_dir(&self, uid: i64) -> String { format!("{}/{}", self.root, uid) } + + /// The root directory of the application + pub(crate) fn root(&self) -> &str { + &self.root + } } impl UserDBPath for UserPaths { diff --git a/frontend/rust-lib/flowy-user/src/services/mod.rs b/frontend/rust-lib/flowy-user/src/services/mod.rs index f7fc8ae7b6..ab4b3bea37 100644 --- a/frontend/rust-lib/flowy-user/src/services/mod.rs +++ b/frontend/rust-lib/flowy-user/src/services/mod.rs @@ -1,7 +1,7 @@ pub mod authenticate_user; +pub(crate) mod billing_check; pub mod cloud_config; pub mod collab_interact; pub mod data_import; pub mod db; pub mod entities; -pub mod sqlite_sql; diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs deleted file mode 100644 index b3ef842a91..0000000000 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub(crate) mod user_sql; -pub(crate) mod workspace_sql; diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs deleted file mode 100644 index 71d3fd50b1..0000000000 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/user_sql.rs +++ /dev/null @@ -1,154 +0,0 @@ -use diesel::{sql_query, RunQueryDsl}; -use flowy_error::{internal_error, FlowyError}; -use std::str::FromStr; - -use flowy_user_pub::cloud::UserUpdate; -use flowy_user_pub::entities::*; - -use flowy_sqlite::schema::user_table; - -use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; -/// The order of the fields in the struct must be the same as the order of the fields in the table. -/// Check out the [schema.rs] for table schema. -#[derive(Clone, Default, Queryable, Identifiable, Insertable)] -#[diesel(table_name = user_table)] -pub struct UserTable { - pub(crate) id: String, - pub(crate) name: String, - pub(crate) workspace: String, - pub(crate) icon_url: String, - pub(crate) openai_key: String, - pub(crate) token: String, - pub(crate) email: String, - pub(crate) auth_type: i32, - pub(crate) encryption_type: String, - pub(crate) stability_ai_key: String, - pub(crate) updated_at: i64, -} - -impl UserTable { - pub fn set_workspace(mut self, workspace: String) -> Self { - self.workspace = workspace; - self - } -} - -impl From<(UserProfile, Authenticator)> for UserTable { - fn from(value: (UserProfile, Authenticator)) -> Self { - let (user_profile, auth_type) = value; - let encryption_type = serde_json::to_string(&user_profile.encryption_type).unwrap_or_default(); - UserTable { - id: user_profile.uid.to_string(), - name: user_profile.name, - workspace: user_profile.workspace_id, - icon_url: user_profile.icon_url, - openai_key: user_profile.openai_key, - token: user_profile.token, - email: user_profile.email, - auth_type: auth_type as i32, - encryption_type, - stability_ai_key: user_profile.stability_ai_key, - updated_at: user_profile.updated_at, - } - } -} - -impl From for UserProfile { - fn from(table: UserTable) -> Self { - UserProfile { - uid: table.id.parse::().unwrap_or(0), - email: table.email, - name: table.name, - token: table.token, - icon_url: table.icon_url, - openai_key: table.openai_key, - workspace_id: table.workspace, - authenticator: Authenticator::from(table.auth_type), - encryption_type: EncryptionType::from_str(&table.encryption_type).unwrap_or_default(), - stability_ai_key: table.stability_ai_key, - updated_at: table.updated_at, - } - } -} - -#[derive(AsChangeset, Identifiable, Default, Debug)] -#[diesel(table_name = user_table)] -pub struct UserTableChangeset { - pub id: String, - pub workspace: Option, // deprecated - pub name: Option, - pub email: Option, - pub icon_url: Option, - pub openai_key: Option, - pub encryption_type: Option, - pub token: Option, - pub stability_ai_key: Option, -} - -impl UserTableChangeset { - pub fn new(params: UpdateUserProfileParams) -> Self { - let encryption_type = params.encryption_sign.map(|sign| { - let ty = EncryptionType::from_sign(&sign); - serde_json::to_string(&ty).unwrap_or_default() - }); - UserTableChangeset { - id: params.uid.to_string(), - workspace: None, - name: params.name, - email: params.email, - icon_url: params.icon_url, - openai_key: params.openai_key, - encryption_type, - token: params.token, - stability_ai_key: params.stability_ai_key, - } - } - - pub fn from_user_profile(user_profile: UserProfile) -> Self { - let encryption_type = serde_json::to_string(&user_profile.encryption_type).unwrap_or_default(); - UserTableChangeset { - id: user_profile.uid.to_string(), - workspace: None, - name: Some(user_profile.name), - email: Some(user_profile.email), - icon_url: Some(user_profile.icon_url), - openai_key: Some(user_profile.openai_key), - encryption_type: Some(encryption_type), - token: Some(user_profile.token), - stability_ai_key: Some(user_profile.stability_ai_key), - } - } -} - -impl From for UserTableChangeset { - fn from(value: UserUpdate) -> Self { - UserTableChangeset { - id: value.uid.to_string(), - name: value.name, - email: value.email, - ..Default::default() - } - } -} - -pub fn select_user_profile(uid: i64, mut conn: DBConnection) -> Result { - let user: UserProfile = user_table::dsl::user_table - .filter(user_table::id.eq(&uid.to_string())) - .first::(&mut *conn) - .map_err(|err| { - FlowyError::record_not_found().with_context(format!( - "Can't find the user profile for user id: {}, error: {:?}", - uid, err - )) - })? - .into(); - - Ok(user) -} - -pub(crate) fn vacuum_database(mut conn: DBConnection) -> Result<(), FlowyError> { - sql_query("VACUUM") - .execute(&mut *conn) - .map_err(internal_error)?; - Ok(()) -} diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs deleted file mode 100644 index 6f438d82e1..0000000000 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs +++ /dev/null @@ -1,112 +0,0 @@ -use chrono::{TimeZone, Utc}; -use diesel::{RunQueryDsl, SqliteConnection}; -use flowy_error::FlowyError; -use flowy_sqlite::schema::user_workspace_table; -use flowy_sqlite::DBConnection; -use flowy_sqlite::{query_dsl::*, ExpressionMethods}; -use flowy_user_pub::entities::UserWorkspace; -use std::convert::TryFrom; - -#[derive(Clone, Default, Queryable, Identifiable, Insertable)] -#[diesel(table_name = user_workspace_table)] -pub struct UserWorkspaceTable { - pub id: String, - pub name: String, - pub uid: i64, - pub created_at: i64, - pub database_storage_id: String, - pub icon: String, -} - -pub fn get_user_workspace_op(workspace_id: &str, mut conn: DBConnection) -> Option { - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::id.eq(workspace_id)) - .first::(&mut *conn) - .ok() - .map(UserWorkspace::from) -} - -pub fn get_all_user_workspace_op( - user_id: i64, - mut conn: DBConnection, -) -> Result, FlowyError> { - let rows = user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::uid.eq(user_id)) - .load::(&mut *conn)?; - Ok(rows.into_iter().map(UserWorkspace::from).collect()) -} - -/// Remove all existing workspaces for given user and insert the new ones. -/// -#[allow(dead_code)] -pub fn save_user_workspaces_op( - uid: i64, - mut conn: DBConnection, - user_workspaces: &[UserWorkspace], -) -> Result<(), FlowyError> { - conn.immediate_transaction(|conn| { - delete_existing_workspaces(uid, conn)?; - insert_new_workspaces_op(uid, user_workspaces, conn)?; - Ok(()) - }) -} - -#[allow(dead_code)] -fn delete_existing_workspaces(uid: i64, conn: &mut SqliteConnection) -> Result<(), FlowyError> { - diesel::delete( - user_workspace_table::dsl::user_workspace_table.filter(user_workspace_table::uid.eq(uid)), - ) - .execute(conn)?; - Ok(()) -} - -pub fn insert_new_workspaces_op( - uid: i64, - user_workspaces: &[UserWorkspace], - conn: &mut SqliteConnection, -) -> Result<(), FlowyError> { - for user_workspace in user_workspaces { - let new_record = UserWorkspaceTable::try_from((uid, user_workspace))?; - diesel::insert_into(user_workspace_table::table) - .values(new_record) - .execute(conn)?; - } - Ok(()) -} - -impl TryFrom<(i64, &UserWorkspace)> for UserWorkspaceTable { - type Error = FlowyError; - - fn try_from(value: (i64, &UserWorkspace)) -> Result { - if value.1.id.is_empty() { - return Err(FlowyError::invalid_data().with_context("The id is empty")); - } - if value.1.database_indexer_id.is_empty() { - return Err(FlowyError::invalid_data().with_context("The database storage id is empty")); - } - - Ok(Self { - id: value.1.id.clone(), - name: value.1.name.clone(), - uid: value.0, - created_at: value.1.created_at.timestamp(), - database_storage_id: value.1.database_indexer_id.clone(), - icon: value.1.icon.clone(), - }) - } -} - -impl From for UserWorkspace { - fn from(value: UserWorkspaceTable) -> Self { - Self { - id: value.id, - name: value.name, - created_at: Utc - .timestamp_opt(value.created_at, 0) - .single() - .unwrap_or_default(), - database_indexer_id: value.database_storage_id, - icon: value.icon, - } - } -} diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 878eb364ae..b9af5c330d 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -1,67 +1,66 @@ +use client_api::entity::GotrueTokenResponse; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::CollabKVDB; -use collab_user::core::MutexUserAwareness; -use flowy_error::{internal_error, ErrorCode, FlowyResult}; +use flowy_error::FlowyResult; +use arc_swap::ArcSwapOption; +use collab::lock::RwLock; +use collab_user::core::UserAwareness; +use dashmap::DashMap; use flowy_server_pub::AuthenticatorType; -use flowy_sqlite::kv::StorePreferences; +use flowy_sqlite::kv::KVStorePreferences; use flowy_sqlite::schema::user_table; use flowy_sqlite::ConnectionPool; use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; use flowy_user_pub::cloud::{UserCloudServiceProvider, UserUpdate}; use flowy_user_pub::entities::*; use flowy_user_pub::workspace_service::UserWorkspaceService; +use lib_infra::box_any::BoxAny; use semver::Version; use serde_json::Value; use std::string::ToString; -use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::{Arc, Weak}; -use tokio::sync::{Mutex, RwLock}; use tokio_stream::StreamExt; -use tracing::{debug, error, event, info, instrument, trace, warn}; +use tracing::{debug, error, event, info, instrument, warn}; +use uuid::Uuid; -use lib_dispatch::prelude::af_spawn; -use lib_infra::box_any::BoxAny; - -use crate::anon_user::{migration_anon_user_on_sign_up, sync_supabase_user_data_to_cloud}; use crate::entities::{AuthStateChangedPB, AuthStatePB, UserProfilePB, UserSettingPB}; use crate::event_map::{DefaultUserStatusCallback, UserStatusCallback}; use crate::migrations::document_empty_content::HistoricalEmptyDocumentMigration; use crate::migrations::migration::{ - save_migration_record, UserDataMigration, UserLocalDataMigration, + save_migration_record, UserDataMigration, UserLocalDataMigration, FIRST_TIME_INSTALL_VERSION, }; use crate::migrations::workspace_and_favorite_v1::FavoriteV1AndWorkspaceArrayMigration; use crate::migrations::workspace_trash_v1::WorkspaceTrashMapToSectionMigration; use crate::migrations::AnonUser; use crate::services::authenticate_user::AuthenticateUser; use crate::services::cloud_config::get_cloud_config; -use crate::services::collab_interact::{CollabInteract, DefaultCollabInteract}; +use crate::services::collab_interact::{DefaultCollabInteract, UserReminder}; -use crate::services::sqlite_sql::user_sql::{select_user_profile, UserTable, UserTableChangeset}; -use crate::user_manager::manager_user_encryption::validate_encryption_sign; -use crate::user_manager::manager_user_workspace::save_user_workspaces; -use crate::user_manager::user_login_state::UserAuthProcess; +use crate::migrations::anon_user_workspace::AnonUserWorkspaceTableMigration; +use crate::migrations::doc_key_with_workspace::CollabDocKeyWithWorkspaceIdMigration; use crate::{errors::FlowyError, notification::*}; use flowy_user_pub::session::Session; +use flowy_user_pub::sql::*; pub struct UserManager { - pub(crate) cloud_services: Arc, - pub(crate) store_preferences: Arc, - pub(crate) user_awareness: Arc>>, + pub(crate) cloud_service: Arc, + pub(crate) store_preferences: Arc, + pub(crate) user_awareness: Arc>>, pub(crate) user_status_callback: RwLock>, pub(crate) collab_builder: Weak, - pub(crate) collab_interact: RwLock>, + pub(crate) collab_interact: RwLock>, pub(crate) user_workspace_service: Arc, - auth_process: Mutex>, pub(crate) authenticate_user: Arc, refresh_user_profile_since: AtomicI64, - pub(crate) is_loading_awareness: Arc, + pub(crate) is_loading_awareness: Arc>, } impl UserManager { pub fn new( cloud_services: Arc, - store_preferences: Arc, + store_preferences: Arc, collab_builder: Weak, authenticate_user: Arc, user_workspace_service: Arc, @@ -71,23 +70,22 @@ impl UserManager { let refresh_user_profile_since = AtomicI64::new(0); let user_manager = Arc::new(Self { - cloud_services, + cloud_service: cloud_services, store_preferences, - user_awareness: Arc::new(Default::default()), + user_awareness: Default::default(), user_status_callback, collab_builder, collab_interact: RwLock::new(Arc::new(DefaultCollabInteract)), - auth_process: Default::default(), authenticate_user, refresh_user_profile_since, user_workspace_service, - is_loading_awareness: Arc::new(AtomicBool::new(false)), + is_loading_awareness: Arc::new(Default::default()), }); let weak_user_manager = Arc::downgrade(&user_manager); - if let Ok(user_service) = user_manager.cloud_services.get_user_service() { + if let Ok(user_service) = user_manager.cloud_service.get_user_service() { if let Some(mut rx) = user_service.subscribe_user_update() { - af_spawn(async move { + tokio::spawn(async move { while let Some(update) = rx.recv().await { if let Some(user_manager) = weak_user_manager.upgrade() { if let Err(err) = user_manager.handler_user_update(update).await { @@ -108,7 +106,7 @@ impl UserManager { } } - pub fn get_store_preferences(&self) -> Weak { + pub fn get_store_preferences(&self) -> Weak { Arc::downgrade(&self.store_preferences) } @@ -120,7 +118,7 @@ impl UserManager { /// the function will set up the collaboration configuration and initialize the user's awareness. Upon successful /// completion, a user status callback is invoked to signify that the initialization process is complete. #[instrument(level = "debug", skip_all, err)] - pub async fn init_with_callback( + pub async fn init_with_callback( &self, user_status_callback: C, collab_interact: I, @@ -130,19 +128,22 @@ impl UserManager { *self.collab_interact.write().await = Arc::new(collab_interact); if let Ok(session) = self.get_session() { - let user = self.get_user_profile_from_disk(session.user_id).await?; + let user = self + .get_user_profile_from_disk(session.user_id, &session.user_workspace.id) + .await?; + self.cloud_service.set_server_auth_type(&user.auth_type); // Get the current authenticator from the environment variable - let current_authenticator = current_authenticator(); + let env_auth_type = current_authenticator(); // If the current authenticator is different from the authenticator in the session and it's // not a local authenticator, we need to sign out the user. - if user.authenticator != Authenticator::Local && user.authenticator != current_authenticator { + if user.auth_type != AuthType::Local && user.auth_type != env_auth_type { event!( tracing::Level::INFO, - "Authenticator changed from {:?} to {:?}", - user.authenticator, - current_authenticator + "Auth type changed from {:?} to {:?}", + user.auth_type, + env_auth_type ); self.sign_out().await?; return Ok(()); @@ -150,10 +151,10 @@ impl UserManager { event!( tracing::Level::INFO, - "init user session: {}:{}, authenticator: {:?}", + "init user session: {}:{}, auth type: {:?}", user.uid, user.email, - user.authenticator, + user.auth_type, ); self.prepare_user(&session).await; @@ -162,21 +163,22 @@ impl UserManager { // Set the token if the current cloud service using token to authenticate // Currently, only the AppFlowy cloud using token to init the client api. // TODO(nathan): using trait to separate the init process for different cloud service - if user.authenticator.is_appflowy_cloud() { - if let Err(err) = self.cloud_services.set_token(&user.token) { + if user.auth_type.is_appflowy_cloud() { + if let Err(err) = self.cloud_service.set_token(&user.token) { error!("Set token failed: {}", err); } // Subscribe the token state - let weak_cloud_services = Arc::downgrade(&self.cloud_services); + let weak_cloud_services = Arc::downgrade(&self.cloud_service); let weak_authenticate_user = Arc::downgrade(&self.authenticate_user); let weak_pool = Arc::downgrade(&self.db_pool(user.uid)?); let cloned_session = session.clone(); - if let Some(mut token_state_rx) = self.cloud_services.subscribe_token_state() { + if let Some(mut token_state_rx) = self.cloud_service.subscribe_token_state() { event!(tracing::Level::DEBUG, "Listen token state change"); let user_uid = user.uid; let local_token = user.token.clone(); - af_spawn(async move { + let workspace_id = session.user_workspace.id.clone(); + tokio::spawn(async move { while let Some(token_state) = token_state_rx.next().await { debug!("Token state changed: {:?}", token_state); match token_state { @@ -185,7 +187,7 @@ impl UserManager { if new_token != local_token { if let Some(conn) = weak_pool.upgrade().and_then(|pool| pool.get().ok()) { // Save the new token - if let Err(err) = save_user_token(user_uid, conn, new_token) { + if let Err(err) = save_user_token(user_uid, &workspace_id, conn, new_token) { error!("Save user token failed: {}", err); } } @@ -239,7 +241,7 @@ impl UserManager { } } - // Do the user data migration if needed + // Do the user data migration if needed. event!(tracing::Level::INFO, "Prepare user data migration"); match ( self @@ -251,33 +253,57 @@ impl UserManager { (Ok(collab_db), Ok(sqlite_pool)) => { run_collab_data_migration( &session, - &user, + &user.auth_type, collab_db, sqlite_pool, - Some(self.authenticate_user.user_config.app_version.clone()), + self.store_preferences.clone(), + &self.authenticate_user.user_config.app_version, ); }, _ => error!("Failed to get collab db or sqlite pool"), } - self.authenticate_user.vacuum_database_if_need(); + + // migrations should run before set the first time installed version + self.set_first_time_installed_version(); let cloud_config = get_cloud_config(session.user_id, &self.store_preferences); - // Init the user awareness - self.initialize_user_awareness(&session).await; + // Init the user awareness. here we ignore the error + let _ = self.initial_user_awareness(&session, &user.auth_type).await; user_status_callback - .did_init( + .on_launch_if_authenticated( user.uid, - &user.authenticator, &cloud_config, &session.user_workspace, &self.authenticate_user.user_config.device_id, + &user.auth_type, ) .await?; + } else { + self.set_first_time_installed_version(); } Ok(()) } - pub fn get_session(&self) -> FlowyResult { + fn set_first_time_installed_version(&self) { + if self + .store_preferences + .get_str(FIRST_TIME_INSTALL_VERSION) + .is_none() + { + info!( + "Set install version: {:?}", + self.authenticate_user.user_config.app_version + ); + if let Err(err) = self.store_preferences.set_object( + FIRST_TIME_INSTALL_VERSION, + &self.authenticate_user.user_config.app_version, + ) { + error!("Set install version error: {:?}", err); + } + } + } + + pub fn get_session(&self) -> FlowyResult> { self.authenticate_user.get_session() } @@ -314,12 +340,12 @@ impl UserManager { pub async fn sign_in( &self, params: SignInParams, - authenticator: Authenticator, + auth_type: AuthType, ) -> Result { - self.cloud_services.set_user_authenticator(&authenticator); + self.cloud_service.set_server_auth_type(&auth_type); let response: AuthResponse = self - .cloud_services + .cloud_service .get_user_service()? .sign_in(BoxAny::new(params)) .await?; @@ -327,20 +353,21 @@ impl UserManager { self.prepare_user(&session).await; let latest_workspace = response.latest_workspace.clone(); - let user_profile = UserProfile::from((&response, &authenticator)); - self - .save_auth_data(&response, &authenticator, &session) - .await?; + let user_profile = UserProfile::from((&response, &auth_type)); + self.save_auth_data(&response, auth_type, &session).await?; - let _ = self.initialize_user_awareness(&session).await; + let _ = self + .initial_user_awareness(&session, &user_profile.auth_type) + .await; self .user_status_callback .read() .await - .did_sign_in( + .on_sign_in( user_profile.uid, &latest_workspace, &self.authenticate_user.user_config.device_id, + &auth_type, ) .await?; send_auth_state_notification(AuthStateChangedPB { @@ -360,51 +387,20 @@ impl UserManager { #[tracing::instrument(level = "info", skip(self, params))] pub async fn sign_up( &self, - authenticator: Authenticator, + auth_type: AuthType, params: BoxAny, ) -> Result { + self.cloud_service.set_server_auth_type(&auth_type); + // sign out the current user if there is one - let migration_user = self.get_migration_user(&authenticator).await; - - self.cloud_services.set_user_authenticator(&authenticator); - let auth_service = self.cloud_services.get_user_service()?; + let migration_user = self.get_migration_user(&auth_type).await; + let auth_service = self.cloud_service.get_user_service()?; let response: AuthResponse = auth_service.sign_up(params).await?; - let new_user_profile = UserProfile::from((&response, &authenticator)); - if new_user_profile.encryption_type.require_encrypt_secret() { - self.auth_process.lock().await.replace(UserAuthProcess { - user_profile: new_user_profile.clone(), - migration_user, - response, - authenticator, - }); - } else { - self - .continue_sign_up(&new_user_profile, migration_user, response, &authenticator) - .await?; - } - Ok(new_user_profile) - } - - #[tracing::instrument(level = "info", skip(self))] - pub async fn resume_sign_up(&self) -> Result<(), FlowyError> { - let UserAuthProcess { - user_profile, - migration_user, - response, - authenticator, - } = self - .auth_process - .lock() - .await - .clone() - .ok_or(FlowyError::new( - ErrorCode::Internal, - "No resumable sign up data", - ))?; + let new_user_profile = UserProfile::from((&response, &auth_type)); self - .continue_sign_up(&user_profile, migration_user, response, &authenticator) + .continue_sign_up(&new_user_profile, migration_user, response, &auth_type) .await?; - Ok(()) + Ok(new_user_profile) } #[tracing::instrument(level = "info", skip_all, err)] @@ -413,23 +409,24 @@ impl UserManager { new_user_profile: &UserProfile, migration_user: Option, response: AuthResponse, - authenticator: &Authenticator, + auth_type: &AuthType, ) -> FlowyResult<()> { let new_session = Session::from(&response); self.prepare_user(&new_session).await; self - .save_auth_data(&response, authenticator, &new_session) + .save_auth_data(&response, *auth_type, &new_session) .await?; - let _ = self.try_initial_user_awareness(&new_session).await; + let _ = self.initial_user_awareness(&new_session, auth_type).await; self .user_status_callback .read() .await - .did_sign_up( + .on_sign_up( response.is_new_user, new_user_profile, &new_session.user_workspace, &self.authenticate_user.user_config.device_id, + auth_type, ) .await?; @@ -453,7 +450,7 @@ impl UserManager { new_user_profile.uid ); self - .migrate_anon_user_data_to_cloud(&old_user, &new_session, authenticator) + .migrate_anon_user_data_to_cloud(&old_user, &new_session, auth_type) .await?; self.remove_anon_user(); let _ = self @@ -474,7 +471,7 @@ impl UserManager { pub async fn sign_out(&self) -> Result<(), FlowyError> { if let Ok(session) = self.get_session() { sign_out( - &self.cloud_services, + &self.cloud_service, &session, &self.authenticate_user, self.db_connection(session.user_id)?, @@ -484,6 +481,16 @@ impl UserManager { Ok(()) } + #[tracing::instrument(level = "info", skip(self))] + pub async fn delete_account(&self) -> Result<(), FlowyError> { + self + .cloud_service + .get_user_service()? + .delete_account() + .await?; + Ok(()) + } + /// Updates the user's profile with the given parameters. /// /// This function modifies the user's profile based on the provided update parameters. After updating, it @@ -499,14 +506,16 @@ impl UserManager { let session = self.get_session()?; upsert_user_profile_change( session.user_id, + &session.user_workspace.id, self.db_connection(session.user_id)?, changeset, )?; - - let profile = self.get_user_profile_from_disk(session.user_id).await?; self - .update_user(session.user_id, profile.token, params) + .cloud_service + .get_user_service()? + .update_user(params) .await?; + Ok(()) } @@ -527,18 +536,27 @@ impl UserManager { self .authenticate_user .database - .backup_or_restore(session.user_id, &session.user_workspace.id); + .backup(session.user_id, &session.user_workspace.id); } /// Fetches the user profile for the given user ID. - pub async fn get_user_profile_from_disk(&self, uid: i64) -> Result { - select_user_profile(uid, self.db_connection(uid)?) + pub async fn get_user_profile_from_disk( + &self, + uid: i64, + workspace_id: &str, + ) -> Result { + let mut conn = self.db_connection(uid)?; + select_user_profile(uid, workspace_id, &mut conn) } #[tracing::instrument(level = "info", skip_all, err)] - pub async fn refresh_user_profile(&self, old_user_profile: &UserProfile) -> FlowyResult<()> { + pub async fn refresh_user_profile( + &self, + old_user_profile: &UserProfile, + workspace_id: &str, + ) -> FlowyResult<()> { // If the user is a local user, no need to refresh the user profile - if old_user_profile.authenticator.is_local() { + if old_user_profile.auth_type.is_local() { return Ok(()); } @@ -551,20 +569,20 @@ impl UserManager { let uid = old_user_profile.uid; let result: Result = self - .cloud_services + .cloud_service .get_user_service()? - .get_user_profile(UserCredentials::from_uid(uid)) + .get_user_profile(uid, workspace_id) .await; match result { Ok(new_user_profile) => { // If the user profile is updated, save the new user profile if new_user_profile.updated_at > old_user_profile.updated_at { - validate_encryption_sign(old_user_profile, &new_user_profile.encryption_type.sign()); // Save the new user profile let changeset = UserTableChangeset::from_user_profile(new_user_profile); let _ = upsert_user_profile_change( uid, + workspace_id, self.authenticate_user.database.get_connection(uid)?, changeset, ); @@ -611,83 +629,92 @@ impl UserManager { } pub fn workspace_id(&self) -> Result { - Ok(self.get_session()?.user_workspace.id) + let session = self.get_session()?; + Ok(session.user_workspace.id.clone()) } pub fn token(&self) -> Result, FlowyError> { Ok(None) } - async fn update_user( - &self, - uid: i64, - token: String, - params: UpdateUserProfileParams, - ) -> Result<(), FlowyError> { - let server = self.cloud_services.get_user_service()?; - af_spawn(async move { - let credentials = UserCredentials::new(Some(token), Some(uid), None); - server.update_user(credentials, params).await - }) - .await - .map_err(internal_error)??; - Ok(()) - } - async fn save_user(&self, uid: i64, user: UserTable) -> Result<(), FlowyError> { - let mut conn = self.db_connection(uid)?; - conn.immediate_transaction(|conn| { - // delete old user if exists - diesel::delete(user_table::dsl::user_table.filter(user_table::dsl::id.eq(&user.id))) - .execute(conn)?; - - let _ = diesel::insert_into(user_table::table) - .values(user) - .execute(conn)?; - Ok::<(), FlowyError>(()) - })?; - + let conn = self.db_connection(uid)?; + upsert_user(user, conn)?; Ok(()) } pub async fn receive_realtime_event(&self, json: Value) { - if let Ok(user_service) = self.cloud_services.get_user_service() { + if let Ok(user_service) = self.cloud_service.get_user_service() { user_service.receive_realtime_event(json) } } + #[instrument(level = "info", skip_all)] pub(crate) async fn generate_sign_in_url_with_email( &self, - authenticator: &Authenticator, + authenticator: &AuthType, email: &str, ) -> Result { - self.cloud_services.set_user_authenticator(authenticator); + self.cloud_service.set_server_auth_type(authenticator); - let auth_service = self.cloud_services.get_user_service()?; + let auth_service = self.cloud_service.get_user_service()?; let url = auth_service.generate_sign_in_url_with_email(email).await?; Ok(url) } + #[instrument(level = "info", skip_all)] + pub(crate) async fn sign_in_with_password( + &self, + email: &str, + password: &str, + ) -> Result { + self + .cloud_service + .set_server_auth_type(&AuthType::AppFlowyCloud); + let auth_service = self.cloud_service.get_user_service()?; + let response = auth_service.sign_in_with_password(email, password).await?; + Ok(response) + } + + #[instrument(level = "info", skip_all)] pub(crate) async fn sign_in_with_magic_link( &self, email: &str, redirect_to: &str, ) -> Result<(), FlowyError> { - let auth_service = self.cloud_services.get_user_service()?; + self + .cloud_service + .set_server_auth_type(&AuthType::AppFlowyCloud); + let auth_service = self.cloud_service.get_user_service()?; auth_service .sign_in_with_magic_link(email, redirect_to) .await?; Ok(()) } + #[instrument(level = "info", skip_all)] + pub(crate) async fn sign_in_with_passcode( + &self, + email: &str, + passcode: &str, + ) -> Result { + self + .cloud_service + .set_server_auth_type(&AuthType::AppFlowyCloud); + let auth_service = self.cloud_service.get_user_service()?; + let response = auth_service.sign_in_with_passcode(email, passcode).await?; + Ok(response) + } + + #[instrument(level = "info", skip_all)] pub(crate) async fn generate_oauth_url( &self, oauth_provider: &str, ) -> Result { self - .cloud_services - .set_user_authenticator(&Authenticator::AppFlowyCloud); - let auth_service = self.cloud_services.get_user_service()?; + .cloud_service + .set_server_auth_type(&AuthType::AppFlowyCloud); + let auth_service = self.cloud_service.get_user_service()?; let url = auth_service .generate_oauth_url_with_provider(oauth_provider) .await?; @@ -698,25 +725,29 @@ impl UserManager { async fn save_auth_data( &self, response: &impl UserAuthResponse, - authenticator: &Authenticator, + auth_type: AuthType, session: &Session, ) -> Result<(), FlowyError> { - let user_profile = UserProfile::from((response, authenticator)); + let user_profile = UserProfile::from((response, &auth_type)); let uid = user_profile.uid; - if authenticator.is_local() { + + if auth_type.is_local() { event!(tracing::Level::DEBUG, "Save new anon user: {:?}", uid); - self.set_anon_user(session.clone()); + self.set_anon_user(session); } - save_user_workspaces(uid, self.db_connection(uid)?, response.user_workspaces())?; + let mut conn = self.db_connection(uid)?; + sync_user_workspaces_with_diff(uid, auth_type, response.user_workspaces(), &mut conn)?; info!( "Save new user profile to disk, authenticator: {:?}", - authenticator + auth_type ); - self.authenticate_user.set_session(Some(session.clone()))?; self - .save_user(uid, (user_profile, authenticator.clone()).into()) + .authenticate_user + .set_session(Some(session.clone().into()))?; + self + .save_user(uid, (user_profile, auth_type).into()) .await?; Ok(()) } @@ -725,14 +756,10 @@ impl UserManager { let session = self.get_session()?; if session.user_id == user_update.uid { debug!("Receive user update: {:?}", user_update); - let user_profile = self.get_user_profile_from_disk(user_update.uid).await?; - if !validate_encryption_sign(&user_profile, &user_update.encryption_sign) { - return Ok(()); - } - // Save the user profile change upsert_user_profile_change( user_update.uid, + &session.user_workspace.id, self.db_connection(user_update.uid)?, UserTableChangeset::from(user_update), )?; @@ -744,63 +771,45 @@ impl UserManager { async fn migrate_anon_user_data_to_cloud( &self, old_user: &AnonUser, - new_user_session: &Session, - authenticator: &Authenticator, + _new_user_session: &Session, + auth_type: &AuthType, ) -> Result<(), FlowyError> { let old_collab_db = self .authenticate_user .database .get_collab_db(old_user.session.user_id)?; - let new_collab_db = self - .authenticate_user - .database - .get_collab_db(new_user_session.user_id)?; - match authenticator { - Authenticator::Supabase => { - migration_anon_user_on_sign_up(old_user, &old_collab_db, new_user_session, &new_collab_db)?; - if let Err(err) = sync_supabase_user_data_to_cloud( - self.cloud_services.get_user_service()?, - &self.authenticate_user.user_config.device_id, - new_user_session, - &new_collab_db, - ) - .await - { - error!("Sync user data to cloud failed: {:?}", err); - } - }, - Authenticator::AppFlowyCloud => { - self - .migration_anon_user_on_appflowy_cloud_sign_up(old_user, &old_collab_db) - .await?; - }, - _ => {}, + if auth_type == &AuthType::AppFlowyCloud { + self + .migration_anon_user_on_appflowy_cloud_sign_up(old_user, &old_collab_db) + .await?; } // Save the old user workspace setting. - save_user_workspaces( + let mut conn = self + .authenticate_user + .database + .get_connection(old_user.session.user_id)?; + upsert_user_workspace( old_user.session.user_id, - self - .authenticate_user - .database - .get_connection(old_user.session.user_id)?, - &[old_user.session.user_workspace.clone()], + *auth_type, + old_user.session.user_workspace.clone(), + &mut conn, )?; Ok(()) } } -fn current_authenticator() -> Authenticator { +fn current_authenticator() -> AuthType { match AuthenticatorType::from_env() { - AuthenticatorType::Local => Authenticator::Local, - AuthenticatorType::Supabase => Authenticator::Supabase, - AuthenticatorType::AppFlowyCloud => Authenticator::AppFlowyCloud, + AuthenticatorType::Local => AuthType::Local, + AuthenticatorType::AppFlowyCloud => AuthType::AppFlowyCloud, } } -fn upsert_user_profile_change( +pub fn upsert_user_profile_change( uid: i64, + workspace_id: &str, mut conn: DBConnection, changeset: UserTableChangeset, ) -> FlowyResult<()> { @@ -809,11 +818,8 @@ fn upsert_user_profile_change( "Update user profile with changeset: {:?}", changeset ); - diesel_update_table!(user_table, changeset, &mut *conn); - let user: UserProfile = user_table::dsl::user_table - .filter(user_table::id.eq(&uid.to_string())) - .first::(&mut *conn)? - .into(); + update_user_profile(&mut conn, changeset)?; + let user = select_user_profile(uid, workspace_id, &mut conn)?; send_notification(&uid.to_string(), UserNotification::DidUpdateUserProfile) .payload(UserProfilePB::from(user)) .send(); @@ -821,10 +827,15 @@ fn upsert_user_profile_change( } #[instrument(level = "info", skip_all, err)] -fn save_user_token(uid: i64, conn: DBConnection, token: String) -> FlowyResult<()> { +fn save_user_token( + uid: i64, + workspace_id: &str, + conn: DBConnection, + token: String, +) -> FlowyResult<()> { let params = UpdateUserProfileParams::new(uid).with_token(token); let changeset = UserTableChangeset::new(params); - upsert_user_profile_change(uid, conn, changeset) + upsert_user_profile_change(uid, workspace_id, conn, changeset) } #[instrument(level = "info", skip_all, err)] @@ -842,6 +853,8 @@ fn collab_migration_list() -> Vec> { Box::new(HistoricalEmptyDocumentMigration), Box::new(FavoriteV1AndWorkspaceArrayMigration), Box::new(WorkspaceTrashMapToSectionMigration), + Box::new(CollabDocKeyWithWorkspaceIdMigration), + Box::new(AnonUserWorkspaceTableMigration), ] } @@ -856,27 +869,31 @@ fn mark_all_migrations_as_applied(sqlite_pool: &Arc) { pub(crate) fn run_collab_data_migration( session: &Session, - user: &UserProfile, + auth_type: &AuthType, collab_db: Arc, sqlite_pool: Arc, - version: Option, + kv: Arc, + app_version: &Version, ) { - trace!("Run collab data migration: {:?}", version); let migrations = collab_migration_list(); - match UserLocalDataMigration::new(session.clone(), collab_db, sqlite_pool).run( + match UserLocalDataMigration::new(session.clone(), collab_db, sqlite_pool, kv).run( migrations, - &user.authenticator, - version, + auth_type, + app_version, ) { Ok(applied_migrations) => { if !applied_migrations.is_empty() { - info!("Did apply migrations: {:?}", applied_migrations); + info!( + "[Migration]: did apply migrations: {:?}", + applied_migrations + ); } }, - Err(e) => error!("User data migration failed: {:?}", e), + Err(e) => error!("[AppflowyData]:User data migration failed: {:?}", e), } } +#[instrument(level = "info", skip_all, err)] pub async fn sign_out( cloud_services: &Arc, session: &Session, diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs index 251a77bd98..a7191f0509 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs @@ -1,20 +1,18 @@ +use std::sync::Arc; use tracing::instrument; use crate::entities::UserProfilePB; use crate::user_manager::UserManager; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_user_pub::entities::Authenticator; +use flowy_user_pub::entities::AuthType; use crate::migrations::AnonUser; use flowy_user_pub::session::Session; -const ANON_USER: &str = "anon_user"; +pub const ANON_USER: &str = "anon_user"; impl UserManager { #[instrument(skip_all)] - pub async fn get_migration_user( - &self, - current_authenticator: &Authenticator, - ) -> Option { + pub async fn get_migration_user(&self, current_authenticator: &AuthType) -> Option { // No need to migrate if the user is already local if current_authenticator.is_local() { return None; @@ -22,18 +20,18 @@ impl UserManager { let session = self.get_session().ok()?; let user_profile = self - .get_user_profile_from_disk(session.user_id) + .get_user_profile_from_disk(session.user_id, &session.user_workspace.id) .await .ok()?; - if user_profile.authenticator.is_local() { + if user_profile.auth_type.is_local() { Some(AnonUser { session }) } else { None } } - pub fn set_anon_user(&self, session: Session) { + pub fn set_anon_user(&self, session: &Session) { let _ = self.store_preferences.set_object(ANON_USER, session); } @@ -50,7 +48,7 @@ impl UserManager { "Anon user not found", ))?; let profile = self - .get_user_profile_from_disk(anon_session.user_id) + .get_user_profile_from_disk(anon_session.user_id, &anon_session.user_workspace.id) .await?; Ok(UserProfilePB::from(profile)) } @@ -63,7 +61,7 @@ impl UserManager { pub async fn open_anon_user(&self) -> FlowyResult<()> { let anon_session = self .store_preferences - .get_object::(ANON_USER) + .get_object::>(ANON_USER) .ok_or(FlowyError::new( ErrorCode::RecordNotFound, "Anon user not found", diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs index 826d665b39..47826054bf 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs @@ -1,17 +1,20 @@ -use std::sync::atomic::Ordering; use std::sync::{Arc, Weak}; use anyhow::Context; -use collab::core::collab::{DataSource, MutexCollab}; +use collab::core::collab::DataSource; +use collab::lock::RwLock; use collab_entity::reminder::Reminder; use collab_entity::CollabType; -use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabBuilderConfig}; -use collab_user::core::{MutexUserAwareness, UserAwareness}; -use tracing::{debug, error, info, instrument, trace}; - +use collab_integrate::collab_builder::{ + AppFlowyCollabBuilder, CollabBuilderConfig, CollabPersistenceImpl, +}; use collab_integrate::CollabKVDB; +use collab_user::core::{UserAwareness, UserAwarenessNotifier}; +use dashmap::try_result::TryResult; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_user_pub::entities::user_awareness_object_id; +use flowy_user_pub::entities::{user_awareness_object_id, AuthType}; +use tracing::{error, info, instrument, trace}; +use uuid::Uuid; use crate::entities::ReminderPB; use crate::user_manager::UserManager; @@ -34,10 +37,10 @@ impl UserManager { pub async fn add_reminder(&self, reminder_pb: ReminderPB) -> FlowyResult<()> { let reminder = Reminder::from(reminder_pb); self - .with_awareness((), |user_awareness| { + .mut_awareness(|user_awareness| { user_awareness.add_reminder(reminder.clone()); }) - .await; + .await?; self .collab_interact .read() @@ -51,10 +54,10 @@ impl UserManager { /// pub async fn remove_reminder(&self, reminder_id: &str) -> FlowyResult<()> { self - .with_awareness((), |user_awareness| { + .mut_awareness(|user_awareness| { user_awareness.remove_reminder(reminder_id); }) - .await; + .await?; self .collab_interact .read() @@ -69,12 +72,20 @@ impl UserManager { pub async fn update_reminder(&self, reminder_pb: ReminderPB) -> FlowyResult<()> { let reminder = Reminder::from(reminder_pb); self - .with_awareness((), |user_awareness| { - user_awareness.update_reminder(&reminder.id, |new_reminder| { - new_reminder.clone_from(&reminder) + .mut_awareness(|user_awareness| { + user_awareness.update_reminder(&reminder.id, |update| { + update + .set_object_id(&reminder.object_id) + .set_title(&reminder.title) + .set_message(&reminder.message) + .set_is_ack(reminder.is_ack) + .set_is_read(reminder.is_read) + .set_scheduled_at(reminder.scheduled_at) + .set_type(reminder.ty) + .set_meta(reminder.meta.clone().into_inner()); }); }) - .await; + .await?; self .collab_interact .read() @@ -95,117 +106,210 @@ impl UserManager { /// - Returns a vector of `Reminder` objects containing all reminders for the user. /// pub async fn get_all_reminders(&self) -> Vec { - self - .with_awareness(vec![], |user_awareness| user_awareness.get_all_reminders()) - .await + let reminders = self + .mut_awareness(|user_awareness| user_awareness.get_all_reminders()) + .await; + reminders.unwrap_or_default() } - pub async fn initialize_user_awareness(&self, session: &Session) { - match self.try_initial_user_awareness(session).await { - Ok(_) => {}, - Err(e) => error!("Failed to initialize user awareness: {:?}", e), - } - } - - /// Initializes the user's awareness based on the specified data source. - /// - /// This asynchronous function attempts to initialize the user's awareness from either a local or remote data source. - /// Depending on the chosen source, it will either construct the user awareness from an empty dataset or fetch it - /// from a remote service. Once obtained, the user's awareness is stored in a shared mutex-protected structure. - /// - /// # Parameters - /// - `session`: The current user's session data. - /// - `source`: The source from which the user's awareness data should be obtained, either local or remote. - /// - /// # Returns - /// - Returns `Ok(())` if the user's awareness is successfully initialized. - /// - May return errors of type `FlowyError` if any issues arise during the initialization. + /// Init UserAwareness for user + /// 1. check if user awareness exists on disk. If yes init awareness from disk + /// 2. If not, init awareness from server. #[instrument(level = "info", skip(self, session), err)] - pub(crate) async fn try_initial_user_awareness(&self, session: &Session) -> FlowyResult<()> { - if self.is_loading_awareness.load(Ordering::SeqCst) { - return Ok(()); - } - self.is_loading_awareness.store(true, Ordering::SeqCst); + pub(crate) async fn initial_user_awareness( + &self, + session: &Session, + auth_type: &AuthType, + ) -> FlowyResult<()> { + let object_id = user_awareness_object_id(&session.user_uuid, &session.user_workspace.id); - if let Some(old_user_awareness) = self.user_awareness.lock().await.take() { - debug!("Closing old user awareness"); - old_user_awareness.lock().close(); - drop(old_user_awareness); - } + // Try to acquire mutable access to `is_loading_awareness`. + // Thread-safety is ensured by DashMap + let should_init = match self.is_loading_awareness.try_get_mut(&object_id) { + TryResult::Present(mut is_loading) => { + if *is_loading { + false + } else { + *is_loading = true; + true + } + }, + TryResult::Absent => true, + TryResult::Locked => { + return Err(FlowyError::new( + ErrorCode::Internal, + format!( + "Failed to lock is_loading_awareness for object: {}", + object_id + ), + )); + }, + }; - let object_id = - user_awareness_object_id(&session.user_uuid, &session.user_workspace.id).to_string(); - trace!("Initializing user awareness {}", object_id); - let collab_db = self.get_collab_db(session.user_id)?; - let weak_cloud_services = Arc::downgrade(&self.cloud_services); - let weak_user_awareness = Arc::downgrade(&self.user_awareness); - let weak_builder = self.collab_builder.clone(); - let cloned_is_loading = self.is_loading_awareness.clone(); - let session = session.clone(); - let workspace_id = session.user_workspace.id.clone(); - tokio::spawn(async move { - if cloned_is_loading.load(Ordering::SeqCst) { - return Ok(()); + if should_init { + if let Some(old_user_awareness) = self.user_awareness.swap(None) { + info!("Closing previous user awareness"); + old_user_awareness.read().await.close(); // Ensure that old awareness is closed } - if let (Some(cloud_services), Some(user_awareness)) = - (weak_cloud_services.upgrade(), weak_user_awareness.upgrade()) - { + let is_exist_on_disk = self + .authenticate_user + .is_collab_on_disk(session.user_id, &object_id.to_string())?; + if auth_type.is_local() || is_exist_on_disk { + trace!( + "Initializing new user awareness from disk:{}, {:?}", + object_id, + auth_type + ); + let collab_db = self.get_collab_db(session.user_id)?; + let workspace_id = session.user_workspace.workspace_id()?; + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), session.user_id, workspace_id) + .into_data_source(); + let awareness = Self::collab_for_user_awareness( + &self.collab_builder.clone(), + &workspace_id, + session.user_id, + &object_id, + collab_db, + doc_state, + None, + ) + .await?; + info!("User awareness initialized successfully"); + self.user_awareness.store(Some(awareness)); + if let Some(mut is_loading) = self.is_loading_awareness.get_mut(&object_id) { + *is_loading = false; + } + } else { + info!( + "Initializing new user awareness from server:{}, {:?}", + object_id, auth_type + ); + self.load_awareness_from_server(session, object_id, *auth_type)?; + } + } else { + return Err(FlowyError::new( + ErrorCode::Internal, + format!( + "User awareness is already being loaded for object: {}", + object_id + ), + )); + } + + Ok(()) + } + + /// Initialize UserAwareness from server. + /// It will spawn a task in the background in order to no block the caller. This functions is + /// designed to be thread safe. + fn load_awareness_from_server( + &self, + session: &Session, + object_id: Uuid, + authenticator: AuthType, + ) -> FlowyResult<()> { + // Clone necessary data + let session = session.clone(); + let collab_db = self.get_collab_db(session.user_id)?; + let weak_builder = self.collab_builder.clone(); + let user_awareness = Arc::downgrade(&self.user_awareness); + let cloud_services = self.cloud_service.clone(); + let authenticate_user = self.authenticate_user.clone(); + let is_loading_awareness = self.is_loading_awareness.clone(); + + // Spawn an async task to fetch or create user awareness + tokio::spawn(async move { + let set_is_loading_false = || { + if let Some(mut is_loading) = is_loading_awareness.get_mut(&object_id) { + *is_loading = false; + } + }; + + let workspace_id = session.user_workspace.workspace_id()?; + let create_awareness = if authenticator.is_local() { + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), session.user_id, workspace_id) + .into_data_source(); + Self::collab_for_user_awareness( + &weak_builder, + &workspace_id, + session.user_id, + &object_id, + collab_db, + doc_state, + None, + ) + .await + } else { let result = cloud_services .get_user_service()? - .get_user_awareness_doc_state(session.user_id, &session.user_workspace.id, &object_id) + .get_user_awareness_doc_state(session.user_id, &workspace_id, &object_id) .await; - let mut lock_awareness = user_awareness - .try_lock() - .map_err(|err| FlowyError::internal().with_context(err))?; - if lock_awareness.is_some() { - return Ok(()); - } - - let awareness = match result { + match result { Ok(data) => { - trace!("Get user awareness collab from remote: {}", data.len()); - let collab = Self::collab_for_user_awareness( - &workspace_id, + trace!("Fetched user awareness collab from remote: {}", data.len()); + Self::collab_for_user_awareness( &weak_builder, + &workspace_id, session.user_id, &object_id, collab_db, DataSource::DocStateV1(data), + None, ) - .await?; - MutexUserAwareness::new(UserAwareness::create(collab, None)) + .await }, Err(err) => { if err.is_record_not_found() { info!("User awareness not found, creating new"); - let collab = Self::collab_for_user_awareness( - &workspace_id, + let doc_state = + CollabPersistenceImpl::new(collab_db.clone(), session.user_id, workspace_id) + .into_data_source(); + Self::collab_for_user_awareness( &weak_builder, + &workspace_id, session.user_id, &object_id, collab_db, - DataSource::Disk, + doc_state, + None, ) - .await?; - MutexUserAwareness::new(UserAwareness::create(collab, None)) + .await } else { - error!("Failed to fetch user awareness: {:?}", err); - return Err(err); + Err(err) } }, - }; + } + }; - trace!("User awareness initialized"); - lock_awareness.replace(awareness); + match create_awareness { + Ok(new_user_awareness) => { + // Validate session before storing the awareness + if let Ok(current_session) = authenticate_user.get_session() { + if current_session.user_workspace.id == session.user_workspace.id { + if let Some(user_awareness) = user_awareness.upgrade() { + info!("User awareness initialized successfully"); + user_awareness.store(Some(new_user_awareness)); + } else { + error!("Failed to upgrade user awareness"); + } + } else { + info!("User awareness is outdated, ignoring"); + } + } + set_is_loading_false(); + Ok(()) + }, + Err(err) => { + error!("Error while creating user awareness: {:?}", err); + set_is_loading_false(); + Err(err) + }, } - Ok(()) }); - - // mark the user awareness as not loading - self.is_loading_awareness.store(false, Ordering::SeqCst); - Ok(()) } @@ -215,26 +319,27 @@ impl UserManager { /// using a collaboration builder. This instance is specifically geared towards handling /// user awareness. async fn collab_for_user_awareness( - workspace_id: &str, collab_builder: &Weak, + workspace_id: &Uuid, uid: i64, - object_id: &str, + object_id: &Uuid, collab_db: Weak, doc_state: DataSource, - ) -> Result, FlowyError> { + notifier: Option, + ) -> Result>, FlowyError> { let collab_builder = collab_builder.upgrade().ok_or(FlowyError::new( ErrorCode::Internal, "Unexpected error: collab builder is not available", ))?; + let collab_object = + collab_builder.collab_object(workspace_id, uid, object_id, CollabType::UserAwareness)?; let collab = collab_builder - .build( - workspace_id, - uid, - object_id, - CollabType::UserAwareness, + .create_user_awareness( + collab_object, doc_state, collab_db, CollabBuilderConfig::default().sync_enable(true), + notifier, ) .await .context("Build collab for user awareness failed")?; @@ -252,19 +357,40 @@ impl UserManager { /// # Parameters /// - `default_value`: A default value to return if the user awareness is `None` and cannot be initialized. /// - `f`: The asynchronous closure to execute with the user awareness. - async fn with_awareness(&self, default_value: Output, f: F) -> Output + async fn mut_awareness(&self, f: F) -> FlowyResult where - F: FnOnce(&UserAwareness) -> Output, + F: FnOnce(&mut UserAwareness) -> Output, { - let user_awareness = self.user_awareness.lock().await; - match &*user_awareness { + match self.user_awareness.load_full() { None => { - if let Ok(session) = self.get_session() { - self.initialize_user_awareness(&session).await; + info!("User awareness is not loaded when trying to access it"); + + let session = self.get_session()?; + let object_id = user_awareness_object_id(&session.user_uuid, &session.user_workspace.id); + let is_loading = self + .is_loading_awareness + .get(&object_id) + .map(|r| *r.value()) + .unwrap_or(false); + + if !is_loading { + let user_profile = self + .get_user_profile_from_disk(session.user_id, &session.user_workspace.id) + .await?; + self + .initial_user_awareness(&session, &user_profile.workspace_auth_type) + .await?; } - default_value + + Err(FlowyError::new( + ErrorCode::InProgress, + "User awareness is loading", + )) + }, + Some(lock) => { + let mut user_awareness = lock.write().await; + Ok(f(&mut user_awareness)) }, - Some(user_awareness) => f(&user_awareness.lock()), } } } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs index 4288260899..1462d1f019 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs @@ -1,32 +1,9 @@ -use crate::entities::{AuthStateChangedPB, AuthStatePB}; -use crate::user_manager::UserManager; -use flowy_encrypt::{decrypt_text, encrypt_text}; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_user_pub::entities::{ - EncryptionType, UpdateUserProfileParams, UserCredentials, UserProfile, -}; - -use crate::notification::send_auth_state_notification; use crate::services::cloud_config::get_encrypt_secret; +use crate::user_manager::UserManager; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use lib_infra::encryption::{decrypt_text, encrypt_text}; impl UserManager { - pub async fn set_encrypt_secret( - &self, - uid: i64, - secret: String, - encryption_type: EncryptionType, - ) -> FlowyResult<()> { - let params = UpdateUserProfileParams::new(uid).with_encryption_type(encryption_type); - self - .cloud_services - .get_user_service()? - .update_user(UserCredentials::from_uid(uid), params.clone()) - .await?; - self.cloud_services.set_encrypt_secret(secret); - - Ok(()) - } - pub fn generate_encryption_sign(&self, uid: i64, encrypt_secret: &str) -> FlowyResult { let encrypt_sign = encrypt_text(uid.to_string(), encrypt_secret)?; Ok(encrypt_sign) @@ -64,16 +41,3 @@ impl UserManager { } } } - -pub(crate) fn validate_encryption_sign(user_profile: &UserProfile, encryption_sign: &str) -> bool { - // If the local user profile's encryption sign is not equal to the user update's encryption sign, - // which means the user enable encryption in another device, we should logout the current user. - let is_valid = user_profile.encryption_type.sign() == encryption_sign; - if !is_valid { - send_auth_state_notification(AuthStateChangedPB { - state: AuthStatePB::InvalidAuth, - message: "Encryption configuration was changed".to_string(), - }); - } - is_valid -} diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index a6021c2ce3..c641fde831 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -1,30 +1,34 @@ -use std::convert::TryFrom; +use chrono::{Duration, NaiveDateTime, Utc}; +use client_api::entity::billing_dto::{RecurringInterval, SubscriptionPlanDetail}; +use client_api::entity::billing_dto::{SubscriptionPlan, WorkspaceUsageAndLimit}; + +use std::str::FromStr; use std::sync::Arc; -use collab_entity::{CollabObject, CollabType}; -use collab_integrate::CollabKVDB; -use tracing::{error, info, instrument, warn}; - -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_folder_pub::entities::{AppFlowyData, ImportData}; -use flowy_sqlite::schema::user_workspace_table; -use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; -use flowy_user_pub::entities::{ - Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, +use crate::entities::{ + RepeatedUserWorkspacePB, SubscribeWorkspacePB, SuccessWorkspaceSubscriptionPB, + UpdateUserWorkspaceSettingPB, UserWorkspacePB, WorkspaceSettingsPB, WorkspaceSubscriptionInfoPB, }; -use lib_dispatch::prelude::af_spawn; - -use crate::entities::{RepeatedUserWorkspacePB, ResetWorkspacePB}; use crate::migrations::AnonUser; use crate::notification::{send_notification, UserNotification}; +use crate::services::billing_check::PeriodicallyCheckBillingState; use crate::services::data_import::{ generate_import_data, upload_collab_objects_data, ImportedFolder, ImportedSource, }; -use crate::services::sqlite_sql::workspace_sql::{ - get_all_user_workspace_op, get_user_workspace_op, insert_new_workspaces_op, UserWorkspaceTable, -}; + use crate::user_manager::UserManager; +use collab_integrate::CollabKVDB; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_folder_pub::entities::{ImportFrom, ImportedCollabData, ImportedFolderData}; +use flowy_sqlite::ConnectionPool; +use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; +use flowy_user_pub::entities::{ + AuthType, Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember, +}; use flowy_user_pub::session::Session; +use flowy_user_pub::sql::*; +use tracing::{error, info, instrument, trace}; +use uuid::Uuid; impl UserManager { /// Import appflowy data from the given path. @@ -40,99 +44,92 @@ impl UserManager { let cloned_current_session = current_session.clone(); let import_data = tokio::task::spawn_blocking(move || { - generate_import_data( - &cloned_current_session, - &cloned_current_session.user_workspace.id, - &user_collab_db, - imported_folder, - ) - .map_err(|err| FlowyError::new(ErrorCode::AppFlowyDataFolderImportError, err.to_string())) + generate_import_data(&cloned_current_session, &user_collab_db, imported_folder) + .map_err(|err| FlowyError::new(ErrorCode::AppFlowyDataFolderImportError, err.to_string())) }) .await??; - match import_data { - ImportData::AppFlowyDataFolder { items } => { - for item in items { - self - .upload_appflowy_data_item(¤t_session, item) - .await?; - } - }, - } + info!( + "[AppflowyData]: upload {} document, {} database, {}, rows", + import_data.collab_data.document_object_ids.len(), + import_data.collab_data.database_object_ids.len(), + import_data.collab_data.row_object_ids.len() + ); + self + .upload_collab_data(¤t_session, import_data.collab_data) + .await?; + + self + .upload_folder_data( + ¤t_session, + &import_data.source, + import_data.parent_view_id, + import_data.folder_data, + ) + .await?; + Ok(()) } - async fn upload_appflowy_data_item( + async fn upload_folder_data( + &self, + _current_session: &Session, + source: &ImportFrom, + parent_view_id: Option, + folder_data: ImportedFolderData, + ) -> Result<(), FlowyError> { + let ImportedFolderData { + views, + orphan_views, + database_view_ids_by_database_id, + } = folder_data; + self + .user_workspace_service + .import_database_views(database_view_ids_by_database_id) + .await?; + self + .user_workspace_service + .import_views(source, views, orphan_views, parent_view_id) + .await?; + + Ok(()) + } + + async fn upload_collab_data( &self, current_session: &Session, - item: AppFlowyData, + collab_data: ImportedCollabData, ) -> Result<(), FlowyError> { - match item { - AppFlowyData::Folder { - views, - database_view_ids_by_database_id, - } => { - // Since `async_trait` does not implement `Sync`, and the handler requires `Sync`, we use a - // channel to synchronize the operation. This approach allows asynchronous trait methods to be compatible - // with synchronous handler requirements." - let (tx, rx) = tokio::sync::oneshot::channel(); - let cloned_workspace_service = self.user_workspace_service.clone(); - af_spawn(async move { - let result = async { - cloned_workspace_service - .did_import_database_views(database_view_ids_by_database_id) - .await?; - cloned_workspace_service.did_import_views(views).await?; - Ok::<(), FlowyError>(()) - } - .await; - let _ = tx.send(result); - }) - .await?; - rx.await??; - }, - AppFlowyData::CollabObject { - row_object_ids, - document_object_ids, - database_object_ids, - } => { - let user = self - .get_user_profile_from_disk(current_session.user_id) - .await?; - let user_collab_db = self - .get_collab_db(current_session.user_id)? - .upgrade() - .ok_or_else(|| FlowyError::internal().with_context("Collab db not found"))?; + let user = self + .get_user_profile_from_disk(current_session.user_id, ¤t_session.user_workspace.id) + .await?; + let user_collab_db = self + .get_collab_db(current_session.user_id)? + .upgrade() + .ok_or_else(|| FlowyError::internal().with_context("Collab db not found"))?; - let user_id = current_session.user_id; - let weak_user_collab_db = Arc::downgrade(&user_collab_db); - let weak_user_cloud_service = self.cloud_services.get_user_service()?; - match upload_collab_objects_data( - user_id, - weak_user_collab_db, - &user.workspace_id, - &user.authenticator, - AppFlowyData::CollabObject { - row_object_ids, - document_object_ids, - database_object_ids, - }, - weak_user_cloud_service, - ) - .await - { - Ok(_) => info!( - "Successfully uploaded collab objects data for user:{}", - user_id - ), - Err(err) => { - error!( - "Failed to upload collab objects data: {:?} for user:{}", - err, user_id - ); - // TODO(nathan): retry uploading the collab objects data. - }, - } + let user_id = current_session.user_id; + let weak_user_collab_db = Arc::downgrade(&user_collab_db); + let weak_user_cloud_service = self.cloud_service.get_user_service()?; + match upload_collab_objects_data( + user_id, + weak_user_collab_db, + ¤t_session.user_workspace.workspace_id()?, + &user.workspace_auth_type, + collab_data, + weak_user_cloud_service, + ) + .await + { + Ok(_) => info!( + "Successfully uploaded collab objects data for user:{}", + user_id + ), + Err(err) => { + error!( + "Failed to upload collab objects data: {:?} for user:{}", + err, user_id + ); }, } Ok(()) @@ -144,9 +141,10 @@ impl UserManager { old_collab_db: &Arc, ) -> FlowyResult<()> { let import_context = ImportedFolder { - imported_session: old_user.session.clone(), + imported_session: old_user.session.as_ref().clone(), imported_collab_db: old_collab_db.clone(), container_name: None, + parent_view_id: None, source: ImportedSource::AnonUser, }; self.perform_import(import_context).await?; @@ -154,99 +152,131 @@ impl UserManager { } #[instrument(skip(self), err)] - pub async fn open_workspace(&self, workspace_id: &str) -> FlowyResult<()> { - info!("open workspace: {}", workspace_id); - let user_workspace = self - .cloud_services - .get_user_service()? - .open_workspace(workspace_id) + pub async fn open_workspace(&self, workspace_id: &Uuid, auth_type: AuthType) -> FlowyResult<()> { + info!("open workspace: {}, auth_type:{}", workspace_id, auth_type); + let workspace_id_str = workspace_id.to_string(); + self.cloud_service.set_server_auth_type(&auth_type); + + let uid = self.user_id()?; + let profile = self + .get_user_profile_from_disk(uid, &workspace_id_str) .await?; + if let Err(err) = self.cloud_service.set_token(&profile.token) { + error!("Set token failed: {}", err); + } + + let mut conn = self.db_connection(self.user_id()?)?; + let user_workspace = match select_user_workspace(&workspace_id_str, &mut conn) { + Err(err) => { + if err.is_record_not_found() { + sync_workspace( + workspace_id, + self.cloud_service.get_user_service()?, + uid, + auth_type, + self.db_pool(uid)?, + ) + .await? + } else { + return Err(err); + } + }, + Ok(row) => { + let user_workspace = UserWorkspace::from(row); + let workspace_id = *workspace_id; + let user_service = self.cloud_service.get_user_service()?; + let pool = self.db_pool(uid)?; + tokio::spawn(async move { + let _ = sync_workspace(&workspace_id, user_service, uid, auth_type, pool).await; + }); + user_workspace + }, + }; self .authenticate_user .set_user_workspace(user_workspace.clone())?; - if let Err(err) = self.try_initial_user_awareness(&self.get_session()?).await { - error!( - "Failed to initialize user awareness when opening workspace: {:?}", - err - ); - } - let uid = self.user_id()?; if let Err(err) = self .user_status_callback .read() .await - .open_workspace(uid, &user_workspace) + .on_workspace_opened(uid, workspace_id, &user_workspace, &auth_type) .await { error!("Open workspace failed: {:?}", err); } + if let Err(err) = self + .initial_user_awareness(self.get_session()?.as_ref(), &auth_type) + .await + { + error!( + "Failed to initialize user awareness when opening workspace: {:?}", + err + ); + } + Ok(()) } #[instrument(level = "info", skip(self), err)] - pub async fn add_workspace(&self, workspace_name: &str) -> FlowyResult { + pub async fn create_workspace( + &self, + workspace_name: &str, + auth_type: AuthType, + ) -> FlowyResult { + self.cloud_service.set_server_auth_type(&auth_type); + let new_workspace = self - .cloud_services + .cloud_service .get_user_service()? .create_workspace(workspace_name) .await?; info!( - "new workspace: {}, name:{}", - new_workspace.id, new_workspace.name + "create workspace: {}, name:{}, auth_type: {}", + new_workspace.id, new_workspace.name, auth_type ); // save the workspace to sqlite db let uid = self.user_id()?; let mut conn = self.db_connection(uid)?; - insert_new_workspaces_op(uid, &[new_workspace.clone()], &mut conn)?; + upsert_user_workspace(uid, auth_type, new_workspace.clone(), &mut conn)?; Ok(new_workspace) } pub async fn patch_workspace( &self, - workspace_id: &str, - new_workspace_name: Option<&str>, - new_workspace_icon: Option<&str>, + workspace_id: &Uuid, + changeset: UserWorkspaceChangeset, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? - .patch_workspace(workspace_id, new_workspace_name, new_workspace_icon) + .patch_workspace(workspace_id, changeset.name.clone(), changeset.icon.clone()) .await?; // save the icon and name to sqlite db let uid = self.user_id()?; let conn = self.db_connection(uid)?; - let mut user_workspace = match self.get_user_workspace(uid, workspace_id) { - Some(user_workspace) => user_workspace, - None => { - return Err(FlowyError::record_not_found().with_context(format!( - "Expected to find user workspace with id: {}, but not found", - workspace_id - ))); - }, - }; + update_user_workspace(conn, changeset)?; - if let Some(new_workspace_name) = new_workspace_name { - user_workspace.name = new_workspace_name.to_string(); - } - if let Some(new_workspace_icon) = new_workspace_icon { - user_workspace.icon = new_workspace_icon.to_string(); - } + let row = self.get_user_workspace_from_db(uid, workspace_id)?; + let payload = UserWorkspacePB::from(row); + send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspace) + .payload(payload) + .send(); - save_user_workspaces(uid, conn, &[user_workspace]) + Ok(()) } #[instrument(level = "info", skip(self), err)] - pub async fn leave_workspace(&self, workspace_id: &str) -> FlowyResult<()> { + pub async fn leave_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { info!("leave workspace: {}", workspace_id); self - .cloud_services + .cloud_service .get_user_service()? .leave_workspace(workspace_id) .await?; @@ -254,31 +284,42 @@ impl UserManager { // delete workspace from local sqlite db let uid = self.user_id()?; let conn = self.db_connection(uid)?; - delete_user_workspaces(conn, workspace_id) + delete_user_workspace(conn, workspace_id.to_string().as_str())?; + + self + .user_workspace_service + .did_delete_workspace(workspace_id) + .await } #[instrument(level = "info", skip(self), err)] - pub async fn delete_workspace(&self, workspace_id: &str) -> FlowyResult<()> { + pub async fn delete_workspace(&self, workspace_id: &Uuid) -> FlowyResult<()> { info!("delete workspace: {}", workspace_id); self - .cloud_services + .cloud_service .get_user_service()? .delete_workspace(workspace_id) .await?; let uid = self.user_id()?; let conn = self.db_connection(uid)?; - delete_user_workspaces(conn, workspace_id)?; + delete_user_workspace(conn, workspace_id.to_string().as_str())?; + + self + .user_workspace_service + .did_delete_workspace(workspace_id) + .await?; + Ok(()) } pub async fn invite_member_to_workspace( &self, - workspace_id: String, + workspace_id: Uuid, invitee_email: String, role: Role, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .invite_workspace_member(invitee_email, workspace_id, role) .await?; @@ -288,7 +329,7 @@ impl UserManager { pub async fn list_pending_workspace_invitations(&self) -> FlowyResult> { let status = Some(WorkspaceInvitationStatus::Pending); let invitations = self - .cloud_services + .cloud_service .get_user_service()? .list_workspace_invitations(status) .await?; @@ -297,34 +338,20 @@ impl UserManager { pub async fn accept_workspace_invitation(&self, invite_id: String) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .accept_workspace_invitations(invite_id) .await?; Ok(()) } - // deprecated, use invite instead - pub async fn add_workspace_member( - &self, - user_email: String, - workspace_id: String, - ) -> FlowyResult<()> { - self - .cloud_services - .get_user_service()? - .add_workspace_member(user_email, workspace_id) - .await?; - Ok(()) - } - pub async fn remove_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .remove_workspace_member(user_email, workspace_id) .await?; @@ -333,139 +360,423 @@ impl UserManager { pub async fn get_workspace_members( &self, - workspace_id: String, + workspace_id: Uuid, ) -> FlowyResult> { let members = self - .cloud_services + .cloud_service .get_user_service()? .get_workspace_members(workspace_id) .await?; Ok(members) } + pub async fn get_workspace_member( + &self, + workspace_id: Uuid, + uid: i64, + ) -> FlowyResult { + let member = self + .cloud_service + .get_user_service()? + .get_workspace_member(&workspace_id, uid) + .await?; + Ok(member) + } + pub async fn update_workspace_member( &self, user_email: String, - workspace_id: String, + workspace_id: Uuid, role: Role, ) -> FlowyResult<()> { self - .cloud_services + .cloud_service .get_user_service()? .update_workspace_member(user_email, workspace_id, role) .await?; Ok(()) } - pub fn get_user_workspace(&self, uid: i64, workspace_id: &str) -> Option { - let conn = self.db_connection(uid).ok()?; - get_user_workspace_op(workspace_id, conn) + pub fn get_user_workspace_from_db( + &self, + uid: i64, + workspace_id: &Uuid, + ) -> FlowyResult { + let mut conn = self.db_connection(uid)?; + select_user_workspace(workspace_id.to_string().as_str(), &mut conn) } - pub async fn get_all_user_workspaces(&self, uid: i64) -> FlowyResult> { - let conn = self.db_connection(uid)?; - let workspaces = get_all_user_workspace_op(uid, conn)?; + pub async fn get_all_user_workspaces( + &self, + uid: i64, + auth_type: AuthType, + ) -> FlowyResult> { + // 1) Load & return the local copy immediately + let mut conn = self.db_connection(uid)?; + let local_workspaces = select_all_user_workspace(uid, &mut conn)?; - if let Ok(service) = self.cloud_services.get_user_service() { - if let Ok(pool) = self.db_pool(uid) { - af_spawn(async move { - if let Ok(new_user_workspaces) = service.get_all_workspace(uid).await { - if let Ok(conn) = pool.get() { - let _ = save_user_workspaces(uid, conn, &new_user_workspaces); - let repeated_workspace_pbs = RepeatedUserWorkspacePB::from(new_user_workspaces); + // 2) If both cloud service and pool are available, fire off a background sync + if let (Ok(service), Ok(pool)) = (self.cloud_service.get_user_service(), self.db_pool(uid)) { + // capture only what we need + let auth_copy = auth_type; + + tokio::spawn(async move { + // fetch remote list + let new_ws = match service.get_all_workspace(uid).await { + Ok(ws) => ws, + Err(e) => { + trace!("failed to fetch remote workspaces for {}: {:?}", uid, e); + return; + }, + }; + + // get a pooled DB connection + let mut conn = match pool.get() { + Ok(c) => c, + Err(e) => { + trace!("failed to get DB connection for {}: {:?}", uid, e); + return; + }, + }; + + // sync + diff + match sync_user_workspaces_with_diff(uid, auth_copy, &new_ws, &mut conn) { + Ok(changes) if !changes.is_empty() => { + info!( + "synced {} workspaces for user {} and auth type {:?}. changes: {:?}", + changes.len(), + uid, + auth_copy, + changes + ); + // only send notification if there were real changes + if let Ok(updated_list) = select_all_user_workspace(uid, &mut conn) { + let repeated_pb = RepeatedUserWorkspacePB::from((auth_copy, updated_list)); send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspaces) - .payload(repeated_workspace_pbs) + .payload(repeated_pb) .send(); } - } - }); - } + }, + Ok(_) => trace!("no workspaces updated for {}", uid), + Err(e) => trace!("sync error for {}: {:?}", uid, e), + } + }); } - Ok(workspaces) + + Ok(local_workspaces) } - /// Reset the remote workspace using local workspace data. This is useful when a user wishes to - /// open a workspace on a new device that hasn't fully synchronized with the server. - pub async fn reset_workspace(&self, reset: ResetWorkspacePB) -> FlowyResult<()> { - let collab_object = CollabObject::new( - reset.uid, - reset.workspace_id.clone(), - CollabType::Folder, - reset.workspace_id.clone(), - self.authenticate_user.user_config.device_id.clone(), - ); - self - .cloud_services + #[instrument(level = "info", skip(self), err)] + pub async fn subscribe_workspace( + &self, + workspace_subscription: SubscribeWorkspacePB, + ) -> FlowyResult { + let workspace_id = Uuid::from_str(&workspace_subscription.workspace_id)?; + let payment_link = self + .cloud_service .get_user_service()? - .reset_workspace(collab_object) + .subscribe_workspace( + workspace_id, + workspace_subscription.recurring_interval.into(), + workspace_subscription.workspace_subscription_plan.into(), + workspace_subscription.success_url, + ) .await?; + + Ok(payment_link) + } + + #[instrument(level = "info", skip(self), err)] + pub async fn get_workspace_subscription_info( + &self, + workspace_id: String, + ) -> FlowyResult { + let workspace_id = Uuid::from_str(&workspace_id)?; + let subscriptions = self + .cloud_service + .get_user_service()? + .get_workspace_subscription_one(&workspace_id) + .await?; + + Ok(WorkspaceSubscriptionInfoPB::from(subscriptions)) + } + + #[instrument(level = "info", skip(self), err)] + pub async fn cancel_workspace_subscription( + &self, + workspace_id: String, + plan: SubscriptionPlan, + reason: Option, + ) -> FlowyResult<()> { + self + .cloud_service + .get_user_service()? + .cancel_workspace_subscription(workspace_id, plan, reason) + .await?; + Ok(()) + } + + #[instrument(level = "info", skip(self), err)] + pub async fn update_workspace_subscription_payment_period( + &self, + workspace_id: &Uuid, + plan: SubscriptionPlan, + recurring_interval: RecurringInterval, + ) -> FlowyResult<()> { + self + .cloud_service + .get_user_service()? + .update_workspace_subscription_payment_period(workspace_id, plan, recurring_interval) + .await?; + Ok(()) + } + + #[instrument(level = "info", skip(self), err)] + pub async fn get_subscription_plan_details(&self) -> FlowyResult> { + let plan_details = self + .cloud_service + .get_user_service()? + .get_subscription_plan_details() + .await?; + Ok(plan_details) + } + + #[instrument(level = "info", skip(self), err)] + pub async fn get_workspace_usage( + &self, + workspace_id: &Uuid, + ) -> FlowyResult { + let workspace_usage = self + .cloud_service + .get_user_service()? + .get_workspace_usage(workspace_id) + .await?; + + // Check if the current workspace storage is not unlimited. If it is not unlimited, + // verify whether the storage bytes exceed the storage limit. + // If the storage is unlimited, allow writing. Otherwise, allow writing only if + // the storage bytes are less than the storage limit. + let can_write = if workspace_usage.storage_bytes_unlimited { + true + } else { + workspace_usage.storage_bytes < workspace_usage.storage_bytes_limit + }; + self + .user_status_callback + .read() + .await + .on_storage_permission_updated(can_write); + + Ok(workspace_usage) + } + + #[instrument(level = "info", skip(self), err)] + pub async fn get_billing_portal_url(&self) -> FlowyResult { + let url = self + .cloud_service + .get_user_service()? + .get_billing_portal_url() + .await?; + Ok(url) + } + + pub async fn update_workspace_setting( + &self, + updated_settings: UpdateUserWorkspaceSettingPB, + ) -> FlowyResult<()> { + let workspace_id = Uuid::from_str(&updated_settings.workspace_id)?; + let cloud_service = self.cloud_service.get_user_service()?; + let settings = cloud_service + .update_workspace_setting(&workspace_id, updated_settings.clone().into()) + .await?; + + let changeset = WorkspaceSettingsChangeset { + id: workspace_id.to_string(), + disable_search_indexing: updated_settings.disable_search_indexing, + ai_model: updated_settings.ai_model.clone(), + }; + + let uid = self.user_id()?; + let mut conn = self.db_connection(uid)?; + update_workspace_setting(&mut conn, changeset)?; + + let pb = WorkspaceSettingsPB::from(&settings); + send_notification( + &uid.to_string(), + UserNotification::DidUpdateWorkspaceSetting, + ) + .payload(pb) + .send(); + Ok(()) + } + + pub async fn get_workspace_settings( + &self, + workspace_id: &Uuid, + ) -> FlowyResult { + let uid = self.user_id()?; + let mut conn = self.db_connection(uid)?; + match select_workspace_setting(&mut conn, &workspace_id.to_string()) { + Ok(workspace_settings) => { + trace!("workspace settings found in local db"); + let pb = WorkspaceSettingsPB::from(workspace_settings); + let old_pb = pb.clone(); + let workspace_id = *workspace_id; + + // Spawn a task to sync remote settings using the helper + let pool = self.db_pool(uid)?; + let cloud_service = self.cloud_service.clone(); + tokio::spawn(async move { + let _ = sync_workspace_settings(cloud_service, workspace_id, old_pb, uid, pool).await; + }); + Ok(pb) + }, + Err(err) => { + if err.is_record_not_found() { + trace!("No workspace settings found, fetch from remote"); + let service = self.cloud_service.get_user_service()?; + let settings = service.get_workspace_setting(workspace_id).await?; + let pb = WorkspaceSettingsPB::from(&settings); + let mut conn = self.db_connection(uid)?; + upsert_workspace_setting( + &mut conn, + WorkspaceSettingsTable::from_workspace_settings(workspace_id, &settings), + )?; + Ok(pb) + } else { + Err(err) + } + }, + } + } + + pub async fn get_workspace_member_info( + &self, + uid: i64, + workspace_id: &Uuid, + ) -> FlowyResult { + let db = self.authenticate_user.get_sqlite_connection(uid)?; + // Can opt in using memory cache + if let Ok(member_record) = select_workspace_member(db, &workspace_id.to_string(), uid) { + if is_older_than_n_minutes(member_record.updated_at, 10) { + self + .get_workspace_member_info_from_remote(workspace_id, uid) + .await?; + } + + return Ok(WorkspaceMember { + email: member_record.email, + role: member_record.role.into(), + name: member_record.name, + avatar_url: member_record.avatar_url, + }); + } + + let member = self + .get_workspace_member_info_from_remote(workspace_id, uid) + .await?; + + Ok(member) + } + + async fn get_workspace_member_info_from_remote( + &self, + workspace_id: &Uuid, + uid: i64, + ) -> FlowyResult { + trace!("get workspace member info from remote: {}", workspace_id); + let member = self + .cloud_service + .get_user_service()? + .get_workspace_member(workspace_id, uid) + .await?; + + let record = WorkspaceMemberTable { + email: member.email.clone(), + role: member.role.into(), + name: member.name.clone(), + avatar_url: member.avatar_url.clone(), + uid, + workspace_id: workspace_id.to_string(), + updated_at: Utc::now().naive_utc(), + }; + + let mut db = self.authenticate_user.get_sqlite_connection(uid)?; + upsert_workspace_member(&mut db, record)?; + Ok(member) + } + + pub async fn notify_did_switch_plan( + &self, + success: SuccessWorkspaceSubscriptionPB, + ) -> FlowyResult<()> { + // periodically check the billing state + let workspace_id = Uuid::from_str(&success.workspace_id)?; + let plans = PeriodicallyCheckBillingState::new( + workspace_id, + success.plan.map(SubscriptionPlan::from), + Arc::downgrade(&self.cloud_service), + Arc::downgrade(&self.authenticate_user), + ) + .start() + .await?; + + trace!("Current plans: {:?}", plans); + self + .user_status_callback + .read() + .await + .on_subscription_plans_updated(plans); Ok(()) } } -pub fn save_user_workspaces( - uid: i64, - mut conn: DBConnection, - user_workspaces: &[UserWorkspace], -) -> FlowyResult<()> { - let user_workspaces = user_workspaces - .iter() - .map(|user_workspace| UserWorkspaceTable::try_from((uid, user_workspace))) - .collect::, _>>()?; - - conn.immediate_transaction(|conn| { - let existing_ids = user_workspace_table::dsl::user_workspace_table - .select(user_workspace_table::id) - .load::(conn)?; - let new_ids: Vec = user_workspaces.iter().map(|w| w.id.clone()).collect(); - let ids_to_delete: Vec = existing_ids - .into_iter() - .filter(|id| !new_ids.contains(id)) - .collect(); - - // insert or update the user workspaces - for user_workspace in &user_workspaces { - let affected_rows = diesel::update( - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::id.eq(&user_workspace.id)), - ) - .set(( - user_workspace_table::name.eq(&user_workspace.name), - user_workspace_table::created_at.eq(&user_workspace.created_at), - user_workspace_table::database_storage_id.eq(&user_workspace.database_storage_id), - user_workspace_table::icon.eq(&user_workspace.icon), - )) - .execute(conn)?; - - if affected_rows == 0 { - diesel::insert_into(user_workspace_table::table) - .values(user_workspace) - .execute(conn)?; - } - } - - // delete the user workspaces that are not in the new list - if !ids_to_delete.is_empty() { - diesel::delete( - user_workspace_table::dsl::user_workspace_table - .filter(user_workspace_table::id.eq_any(ids_to_delete)), - ) - .execute(conn)?; - } - - Ok::<(), FlowyError>(()) - }) +fn is_older_than_n_minutes(updated_at: NaiveDateTime, minutes: i64) -> bool { + let current_time: NaiveDateTime = Utc::now().naive_utc(); + match current_time.checked_sub_signed(Duration::minutes(minutes)) { + Some(five_minutes_ago) => updated_at < five_minutes_ago, + None => false, + } } -pub fn delete_user_workspaces(mut conn: DBConnection, workspace_id: &str) -> FlowyResult<()> { - let n = conn.immediate_transaction(|conn| { - let rows_affected: usize = - diesel::delete(user_workspace_table::table.filter(user_workspace_table::id.eq(workspace_id))) - .execute(conn)?; - Ok::(rows_affected) - })?; - if n != 1 { - warn!("expected to delete 1 row, but deleted {} rows", n); +async fn sync_workspace_settings( + cloud_service: Arc, + workspace_id: Uuid, + old_pb: WorkspaceSettingsPB, + uid: i64, + pool: Arc, +) -> FlowyResult<()> { + let service = cloud_service.get_user_service()?; + let settings = service.get_workspace_setting(&workspace_id).await?; + let new_pb = WorkspaceSettingsPB::from(&settings); + if new_pb != old_pb { + trace!("workspace settings updated"); + send_notification( + &uid.to_string(), + UserNotification::DidUpdateWorkspaceSetting, + ) + .payload(new_pb) + .send(); + if let Ok(mut conn) = pool.get() { + upsert_workspace_setting( + &mut conn, + WorkspaceSettingsTable::from_workspace_settings(&workspace_id, &settings), + )?; + } } Ok(()) } + +async fn sync_workspace( + workspace_id: &Uuid, + user_service: Arc, + uid: i64, + auth_type: AuthType, + pool: Arc, +) -> FlowyResult { + let user_workspace = user_service.open_workspace(workspace_id).await?; + if let Ok(mut conn) = pool.get() { + upsert_user_workspace(uid, auth_type, user_workspace.clone(), &mut conn)?; + } + Ok(user_workspace) +} diff --git a/frontend/rust-lib/flowy-user/src/user_manager/mod.rs b/frontend/rust-lib/flowy-user/src/user_manager/mod.rs index 3ce66227c5..23c050c1f2 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/mod.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/mod.rs @@ -3,6 +3,5 @@ pub(crate) mod manager_history_user; pub(crate) mod manager_user_awareness; pub(crate) mod manager_user_encryption; pub(crate) mod manager_user_workspace; -mod user_login_state; pub use manager::*; diff --git a/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs b/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs deleted file mode 100644 index 906002ad10..0000000000 --- a/frontend/rust-lib/flowy-user/src/user_manager/user_login_state.rs +++ /dev/null @@ -1,11 +0,0 @@ -use crate::migrations::AnonUser; -use flowy_user_pub::entities::{AuthResponse, Authenticator, UserProfile}; - -/// recording the intermediate state of the sign-in/sign-up process -#[derive(Clone)] -pub struct UserAuthProcess { - pub user_profile: UserProfile, - pub response: AuthResponse, - pub authenticator: Authenticator, - pub migration_user: Option, -} diff --git a/frontend/rust-lib/lib-dispatch/Cargo.toml b/frontend/rust-lib/lib-dispatch/Cargo.toml index 48a7aba816..1630d3bc47 100644 --- a/frontend/rust-lib/lib-dispatch/Cargo.toml +++ b/frontend/rust-lib/lib-dispatch/Cargo.toml @@ -13,8 +13,7 @@ futures-core = { version = "0.3", default-features = false } futures-channel = "0.3.26" futures.workspace = true futures-util = "0.3.26" -bytes = {version = "1.4", features = ["serde"]} -tokio = { workspace = true, features = ["rt", "sync"] } +bytes = { version = "1.4", features = ["serde"] } nanoid = "0.4.0" dyn-clone = "1.0" @@ -22,28 +21,27 @@ derivative = "2.2.0" serde_json = { workspace = true, optional = true } serde = { version = "1.0", features = ["derive"], optional = true } serde_repr = { workspace = true, optional = true } -validator = "0.16.1" +validator = { workspace = true, features = ["derive"] } tracing.workspace = true -parking_lot = "0.12" -bincode = { version = "1.3", optional = true} +bincode = { version = "1.3", optional = true } protobuf = { workspace = true, optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] thread-id = "3.3.0" +tokio = { workspace = true, features = ["full", "rt-multi-thread"] } [target.'cfg(target_arch = "wasm32")'.dependencies] -getrandom = { version = "0.2", features = ["js"]} +getrandom = { version = "0.2", features = ["js"] } wasm-bindgen = { version = "0.2.89" } wasm-bindgen-futures = "0.4" +tokio = { workspace = true, features = ["rt", "sync"] } [dev-dependencies] tokio = { workspace = true, features = ["rt"] } futures-util = "0.3.26" [features] -default = ["use_protobuf"] +default = ["local_set", "use_protobuf"] use_serde = ["bincode", "serde_json", "serde", "serde_repr"] -use_protobuf= ["protobuf"] +use_protobuf = ["protobuf"] local_set = [] - - diff --git a/frontend/rust-lib/lib-dispatch/src/data.rs b/frontend/rust-lib/lib-dispatch/src/data.rs index 5d73bf87df..e3989adfce 100644 --- a/frontend/rust-lib/lib-dispatch/src/data.rs +++ b/frontend/rust-lib/lib-dispatch/src/data.rs @@ -2,7 +2,7 @@ use std::fmt::{Debug, Formatter}; use std::ops; use bytes::Bytes; -use validator::ValidationErrors; +use validator::{Validate, ValidationErrors}; use crate::{ byte_trait::*, @@ -28,7 +28,7 @@ impl AFPluginData { impl AFPluginData where - T: validator::Validate, + T: Validate, { pub fn try_into_inner(self) -> Result { self.0.validate()?; diff --git a/frontend/rust-lib/lib-dispatch/src/dispatcher.rs b/frontend/rust-lib/lib-dispatch/src/dispatcher.rs index eb55bfc4fa..0e8e84fa6b 100644 --- a/frontend/rust-lib/lib-dispatch/src/dispatcher.rs +++ b/frontend/rust-lib/lib-dispatch/src/dispatcher.rs @@ -1,10 +1,10 @@ -use std::any::Any; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::{future::Future, sync::Arc}; - use derivative::*; use pin_project::pin_project; +use std::any::Any; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; use tracing::event; use crate::module::AFPluginStateMap; @@ -16,70 +16,53 @@ use crate::{ service::{AFPluginServiceFactory, Service}, }; -#[cfg(any(target_arch = "wasm32", feature = "local_set"))] -pub trait AFConcurrent {} +#[cfg(feature = "local_set")] +pub trait AFConcurrent: Send {} -#[cfg(any(target_arch = "wasm32", feature = "local_set"))] -impl AFConcurrent for T where T: ?Sized {} +#[cfg(feature = "local_set")] +impl AFConcurrent for T where T: Send + ?Sized {} -#[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] +#[cfg(not(feature = "local_set"))] pub trait AFConcurrent: Send + Sync {} -#[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] +#[cfg(not(feature = "local_set"))] impl AFConcurrent for T where T: Send + Sync {} -#[cfg(any(target_arch = "wasm32", feature = "local_set"))] +#[cfg(feature = "local_set")] pub type AFBoxFuture<'a, T> = futures_core::future::LocalBoxFuture<'a, T>; -#[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] +#[cfg(not(feature = "local_set"))] pub type AFBoxFuture<'a, T> = futures_core::future::BoxFuture<'a, T>; pub type AFStateMap = std::sync::Arc; -#[cfg(any(target_arch = "wasm32", feature = "local_set"))] +#[cfg(feature = "local_set")] pub(crate) fn downcast_owned(boxed: AFBox) -> Option { boxed.downcast().ok().map(|boxed| *boxed) } -#[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] +#[cfg(not(feature = "local_set"))] pub(crate) fn downcast_owned(boxed: AFBox) -> Option { boxed.downcast().ok().map(|boxed| *boxed) } -#[cfg(any(target_arch = "wasm32", feature = "local_set"))] -pub(crate) type AFBox = Box; - -#[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] +#[cfg(feature = "local_set")] pub(crate) type AFBox = Box; -#[cfg(any(target_arch = "wasm32", feature = "local_set"))] +#[cfg(not(feature = "local_set"))] +pub(crate) type AFBox = Box; + +#[cfg(feature = "local_set")] pub type BoxFutureCallback = Box AFBoxFuture<'static, ()> + 'static>; -#[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] +#[cfg(not(feature = "local_set"))] pub type BoxFutureCallback = Box AFBoxFuture<'static, ()> + Send + Sync + 'static>; -#[cfg(any(target_arch = "wasm32", feature = "local_set"))] -pub fn af_spawn(future: T) -> tokio::task::JoinHandle -where - T: Future + 'static, - T::Output: 'static, -{ - tokio::task::spawn_local(future) -} - -#[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] -pub fn af_spawn(future: T) -> tokio::task::JoinHandle -where - T: Future + Send + 'static, - T::Output: Send + 'static, -{ - tokio::spawn(future) -} - pub struct AFPluginDispatcher { plugins: AFPluginMap, + #[allow(dead_code)] runtime: Arc, } @@ -92,13 +75,62 @@ impl AFPluginDispatcher { } } + #[cfg(feature = "local_set")] pub async fn async_send(dispatch: &AFPluginDispatcher, request: Req) -> AFPluginEventResponse where - Req: Into, + Req: Into + 'static, { AFPluginDispatcher::async_send_with_callback(dispatch, request, |_| Box::pin(async {})).await } + #[cfg(feature = "local_set")] + pub async fn async_send_with_callback( + dispatch: &AFPluginDispatcher, + request: Req, + callback: Callback, + ) -> AFPluginEventResponse + where + Req: Into + 'static, + Callback: FnOnce(AFPluginEventResponse) -> AFBoxFuture<'static, ()> + AFConcurrent + 'static, + { + Self::boxed_async_send_with_callback(dispatch, request, callback).await + } + #[cfg(feature = "local_set")] + pub async fn boxed_async_send_with_callback( + dispatch: &AFPluginDispatcher, + request: Req, + callback: Callback, + ) -> AFPluginEventResponse + where + Req: Into + 'static, + Callback: FnOnce(AFPluginEventResponse) -> AFBoxFuture<'static, ()> + AFConcurrent + 'static, + { + let request: AFPluginRequest = request.into(); + let plugins = dispatch.plugins.clone(); + let service = Box::new(DispatchService { plugins }); + tracing::trace!("[dispatch]: Async event: {:?}", &request.event); + let service_ctx = DispatchContext { + request, + callback: Some(Box::new(callback)), + }; + + let result = tokio::task::spawn_local(async move { + service.call(service_ctx).await.unwrap_or_else(|e| { + tracing::error!("Dispatch runtime error: {:?}", e); + InternalError::Other(format!("{:?}", e)).as_response() + }) + }) + .await; + + result.unwrap_or_else(|e| { + let msg = format!("EVENT_DISPATCH join error: {:?}", e); + tracing::error!("{}", msg); + let error = InternalError::JoinError(msg); + error.as_response() + }) + } + + #[cfg(not(feature = "local_set"))] pub async fn async_send_with_callback( dispatch: &AFPluginDispatcher, request: Req, @@ -117,42 +149,25 @@ impl AFPluginDispatcher { callback: Some(Box::new(callback)), }; - // Spawns a future onto the runtime. - // - // This spawns the given future onto the runtime's executor, usually a - // thread pool. The thread pool is then responsible for polling the future - // until it completes. - // - // The provided future will start running in the background immediately - // when `spawn` is called, even if you don't await the returned - // `JoinHandle`. - let handle = dispatch.runtime.spawn(async move { - service.call(service_ctx).await.unwrap_or_else(|e| { - tracing::error!("Dispatch runtime error: {:?}", e); - InternalError::Other(format!("{:?}", e)).as_response() + dispatch + .runtime + .spawn(async move { + service.call(service_ctx).await.unwrap_or_else(|e| { + tracing::error!("Dispatch runtime error: {:?}", e); + InternalError::Other(format!("{:?}", e)).as_response() + }) + }) + .await + .unwrap_or_else(|e| { + let msg = format!("EVENT_DISPATCH join error: {:?}", e); + tracing::error!("{}", msg); + let error = InternalError::JoinError(msg); + error.as_response() }) - }); - - let result = dispatch.runtime.run_until(handle).await; - result.unwrap_or_else(|e| { - let msg = format!("EVENT_DISPATCH join error: {:?}", e); - tracing::error!("{}", msg); - let error = InternalError::JoinError(msg); - error.as_response() - }) } - pub fn box_async_send( - dispatch: &AFPluginDispatcher, - request: Req, - ) -> DispatchFuture - where - Req: Into + 'static, - { - AFPluginDispatcher::boxed_async_send_with_callback(dispatch, request, |_| Box::pin(async {})) - } - - pub fn boxed_async_send_with_callback( + #[cfg(not(feature = "local_set"))] + pub async fn boxed_async_send_with_callback( dispatch: &AFPluginDispatcher, request: Req, callback: Callback, @@ -177,39 +192,21 @@ impl AFPluginDispatcher { }) }); - #[cfg(any(target_arch = "wasm32", feature = "local_set"))] - { - let result = dispatch.runtime.block_on(handle); - DispatchFuture { - fut: Box::pin(async move { - result.unwrap_or_else(|e| { - let msg = format!("EVENT_DISPATCH join error: {:?}", e); - tracing::error!("{}", msg); - let error = InternalError::JoinError(msg); - error.as_response() - }) - }), - } - } - - #[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] - { - let runtime = dispatch.runtime.clone(); - DispatchFuture { - fut: Box::pin(async move { - let result = runtime.run_until(handle).await; - result.unwrap_or_else(|e| { - let msg = format!("EVENT_DISPATCH join error: {:?}", e); - tracing::error!("{}", msg); - let error = InternalError::JoinError(msg); - error.as_response() - }) - }), - } + let runtime = dispatch.runtime.clone(); + DispatchFuture { + fut: Box::pin(async move { + let result = runtime.spawn(handle).await.unwrap(); + result.unwrap_or_else(|e| { + let msg = format!("EVENT_DISPATCH join error: {:?}", e); + tracing::error!("{}", msg); + let error = InternalError::JoinError(msg); + error.as_response() + }) + }), } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "local_set")] pub fn sync_send( dispatch: Arc, request: AFPluginRequest, @@ -220,43 +217,6 @@ impl AFPluginDispatcher { |_| Box::pin(async {}), )) } - - #[cfg(any(target_arch = "wasm32", feature = "local_set"))] - #[track_caller] - pub fn spawn(&self, future: F) -> tokio::task::JoinHandle - where - F: Future + 'static, - { - self.runtime.spawn(future) - } - - #[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] - #[track_caller] - pub fn spawn(&self, future: F) -> tokio::task::JoinHandle - where - F: Future + Send + 'static, - ::Output: Send + 'static, - { - self.runtime.spawn(future) - } - - #[cfg(any(target_arch = "wasm32", feature = "local_set"))] - pub async fn run_until(&self, future: F) -> F::Output - where - F: Future + 'static, - { - let handle = self.runtime.spawn(future); - self.runtime.run_until(handle).await.unwrap() - } - - #[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] - pub async fn run_until<'a, F>(&self, future: F) -> F::Output - where - F: Future + Send + 'a, - ::Output: Send + 'a, - { - self.runtime.run_until(future).await - } } #[derive(Derivative)] diff --git a/frontend/rust-lib/lib-dispatch/src/module/container.rs b/frontend/rust-lib/lib-dispatch/src/module/container.rs index d6fdf24d67..4082590345 100644 --- a/frontend/rust-lib/lib-dispatch/src/module/container.rs +++ b/frontend/rust-lib/lib-dispatch/src/module/container.rs @@ -13,7 +13,7 @@ impl AFPluginStateMap { pub fn insert(&mut self, val: T) -> Option where - T: 'static + AFConcurrent, + T: 'static + Send + Sync, { self .0 diff --git a/frontend/rust-lib/lib-dispatch/src/module/data.rs b/frontend/rust-lib/lib-dispatch/src/module/data.rs index 520c3e2494..3cf8f23d51 100644 --- a/frontend/rust-lib/lib-dispatch/src/module/data.rs +++ b/frontend/rust-lib/lib-dispatch/src/module/data.rs @@ -53,7 +53,7 @@ where impl FromAFPluginRequest for AFPluginState where - T: ?Sized + AFConcurrent + 'static, + T: ?Sized + Send + Sync + 'static, { type Error = DispatchError; type Future = Ready>; diff --git a/frontend/rust-lib/lib-dispatch/src/module/module.rs b/frontend/rust-lib/lib-dispatch/src/module/module.rs index a5b2df234a..d0a146da7a 100644 --- a/frontend/rust-lib/lib-dispatch/src/module/module.rs +++ b/frontend/rust-lib/lib-dispatch/src/module/module.rs @@ -1,18 +1,3 @@ -use std::sync::Arc; -use std::{ - collections::HashMap, - fmt, - fmt::{Debug, Display}, - future::Future, - hash::Hash, - pin::Pin, - task::{Context, Poll}, -}; - -use futures_core::ready; -use nanoid::nanoid; -use pin_project::pin_project; - use crate::dispatcher::AFConcurrent; use crate::prelude::{AFBoxFuture, AFStateMap}; use crate::service::AFPluginHandler; @@ -25,21 +10,39 @@ use crate::{ Service, ServiceRequest, ServiceResponse, }, }; +use futures_core::ready; +use nanoid::nanoid; +use pin_project::pin_project; +use std::sync::Arc; +use std::{ + collections::HashMap, + fmt, + fmt::{Debug, Display}, + future::Future, + hash::Hash, + pin::Pin, + task::{Context, Poll}, +}; pub type AFPluginMap = Arc>>; pub(crate) fn plugin_map_or_crash(plugins: Vec) -> AFPluginMap { let mut plugin_map: HashMap> = HashMap::new(); plugins.into_iter().for_each(|m| { let events = m.events(); + #[allow(clippy::arc_with_non_send_sync)] let plugins = Arc::new(m); events.into_iter().for_each(|e| { if plugin_map.contains_key(&e) { let plugin_name = plugin_map.get(&e).map(|p| &p.name); - panic!("⚠️⚠️⚠️Error: {:?} is already defined in {:?}", &e, plugin_name,); + panic!( + "⚠️⚠️⚠️Error: {:?} is already defined in {:?}", + &e, plugin_name, + ); } plugin_map.insert(e, plugins.clone()); }); }); + #[allow(clippy::arc_with_non_send_sync)] Arc::new(plugin_map) } @@ -77,6 +80,7 @@ impl std::default::Default for AFPlugin { Self { name: "".to_owned(), states: Default::default(), + #[allow(clippy::arc_with_non_send_sync)] event_service_factory: Arc::new(HashMap::new()), } } @@ -88,11 +92,11 @@ impl AFPlugin { } pub fn name(mut self, s: &str) -> Self { - self.name = s.to_owned(); + s.clone_into(&mut self.name); self } - pub fn state(mut self, data: D) -> Self { + pub fn state(mut self, data: D) -> Self { Arc::get_mut(&mut self.states) .unwrap() .insert(crate::module::AFPluginState::new(data)); diff --git a/frontend/rust-lib/lib-dispatch/src/request/request.rs b/frontend/rust-lib/lib-dispatch/src/request/request.rs index c62950f65d..68aab764d4 100644 --- a/frontend/rust-lib/lib-dispatch/src/request/request.rs +++ b/frontend/rust-lib/lib-dispatch/src/request/request.rs @@ -8,7 +8,7 @@ use std::{ use derivative::*; use futures_core::ready; -use crate::prelude::{AFConcurrent, AFStateMap}; +use crate::prelude::AFStateMap; use crate::{ errors::{DispatchError, InternalError}, module::AFPluginEvent, @@ -39,7 +39,7 @@ impl AFPluginEventRequest { pub fn get_state(&self) -> Option where - T: AFConcurrent + 'static + Clone, + T: Send + Sync + 'static + Clone, { if let Some(data) = self.states.get::() { return Some(data.clone()); diff --git a/frontend/rust-lib/lib-dispatch/src/runtime.rs b/frontend/rust-lib/lib-dispatch/src/runtime.rs index fd3658517c..e2f5cd56c3 100644 --- a/frontend/rust-lib/lib-dispatch/src/runtime.rs +++ b/frontend/rust-lib/lib-dispatch/src/runtime.rs @@ -7,17 +7,15 @@ use tokio::runtime::Runtime; use tokio::task::JoinHandle; pub struct AFPluginRuntime { - inner: Runtime, - #[cfg(any(target_arch = "wasm32", feature = "local_set"))] - local: tokio::task::LocalSet, + pub(crate) inner: Runtime, } impl Display for AFPluginRuntime { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if cfg!(any(target_arch = "wasm32", feature = "local_set")) { - write!(f, "Runtime(current_thread)") + write!(f, "Runtime(local_set)") } else { - write!(f, "Runtime(multi_thread)") + write!(f, "Runtime") } } } @@ -25,23 +23,9 @@ impl Display for AFPluginRuntime { impl AFPluginRuntime { pub fn new() -> io::Result { let inner = default_tokio_runtime()?; - Ok(Self { - inner, - #[cfg(any(target_arch = "wasm32", feature = "local_set"))] - local: tokio::task::LocalSet::new(), - }) + Ok(Self { inner }) } - #[cfg(any(target_arch = "wasm32", feature = "local_set"))] - #[track_caller] - pub fn spawn(&self, future: F) -> JoinHandle - where - F: Future + 'static, - { - self.local.spawn_local(future) - } - - #[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] #[track_caller] pub fn spawn(&self, future: F) -> JoinHandle where @@ -51,32 +35,6 @@ impl AFPluginRuntime { self.inner.spawn(future) } - #[cfg(any(target_arch = "wasm32", feature = "local_set"))] - pub async fn run_until(&self, future: F) -> F::Output - where - F: Future, - { - self.local.run_until(future).await - } - - #[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] - pub async fn run_until(&self, future: F) -> F::Output - where - F: Future, - { - future.await - } - - #[cfg(any(target_arch = "wasm32", feature = "local_set"))] - #[track_caller] - pub fn block_on(&self, f: F) -> F::Output - where - F: Future, - { - self.local.block_on(&self.inner, f) - } - - #[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] #[track_caller] pub fn block_on(&self, f: F) -> F::Output where @@ -86,14 +44,16 @@ impl AFPluginRuntime { } } -#[cfg(any(target_arch = "wasm32", feature = "local_set"))] +#[cfg(feature = "local_set")] pub fn default_tokio_runtime() -> io::Result { - runtime::Builder::new_current_thread() + runtime::Builder::new_multi_thread() + .enable_io() + .enable_time() .thread_name("dispatch-rt-st") .build() } -#[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] +#[cfg(not(feature = "local_set"))] pub fn default_tokio_runtime() -> io::Result { runtime::Builder::new_multi_thread() .thread_name("dispatch-rt-mt") diff --git a/frontend/rust-lib/lib-dispatch/src/service/boxed.rs b/frontend/rust-lib/lib-dispatch/src/service/boxed.rs index 7ff7a7c116..811b995082 100644 --- a/frontend/rust-lib/lib-dispatch/src/service/boxed.rs +++ b/frontend/rust-lib/lib-dispatch/src/service/boxed.rs @@ -16,7 +16,7 @@ where BoxServiceFactory(Box::new(FactoryWrapper(factory))) } -#[cfg(any(target_arch = "wasm32", feature = "local_set"))] +#[cfg(feature = "local_set")] type Inner = Box< dyn AFPluginServiceFactory< Req, @@ -27,7 +27,7 @@ type Inner = Box< Future = AFBoxFuture<'static, Result, Err>>, >, >; -#[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] +#[cfg(not(feature = "local_set"))] type Inner = Box< dyn AFPluginServiceFactory< Req, @@ -58,12 +58,12 @@ where } } -#[cfg(any(target_arch = "wasm32", feature = "local_set"))] +#[cfg(feature = "local_set")] pub type BoxService = Box< dyn Service>>, >; -#[cfg(all(not(target_arch = "wasm32"), not(feature = "local_set")))] +#[cfg(not(feature = "local_set"))] pub type BoxService = Box< dyn Service>> + Sync diff --git a/frontend/rust-lib/lib-dispatch/tests/api/module.rs b/frontend/rust-lib/lib-dispatch/tests/api/module.rs index 2c4539bd7e..214eda32fa 100644 --- a/frontend/rust-lib/lib-dispatch/tests/api/module.rs +++ b/frontend/rust-lib/lib-dispatch/tests/api/module.rs @@ -1,7 +1,7 @@ -use std::sync::Arc; - use lib_dispatch::prelude::*; use lib_dispatch::runtime::AFPluginRuntime; +use std::sync::Arc; +use tokio::task::LocalSet; pub async fn hello() -> String { "say hello".to_string() @@ -11,17 +11,24 @@ pub async fn hello() -> String { async fn test() { let event = "1"; let runtime = Arc::new(AFPluginRuntime::new().unwrap()); + #[allow(clippy::arc_with_non_send_sync)] let dispatch = Arc::new(AFPluginDispatcher::new( runtime, vec![AFPlugin::new().event(event, hello)], )); let request = AFPluginRequest::new(event); - let _ = AFPluginDispatcher::async_send_with_callback(dispatch.as_ref(), request, |resp| { - Box::pin(async move { - dbg!(&resp); - }) - }) - .await; + let local_set = LocalSet::new(); + local_set + .run_until(AFPluginDispatcher::async_send_with_callback( + dispatch.as_ref(), + request, + |resp| { + Box::pin(async move { + dbg!(&resp); + }) + }, + )) + .await; std::mem::forget(dispatch); } diff --git a/frontend/rust-lib/lib-infra/Cargo.toml b/frontend/rust-lib/lib-infra/Cargo.toml index 82f39cbf1b..a07a3413f4 100644 --- a/frontend/rust-lib/lib-infra/Cargo.toml +++ b/frontend/rust-lib/lib-infra/Cargo.toml @@ -8,7 +8,7 @@ edition = "2018" crate-type = ["cdylib", "rlib"] [dependencies] -chrono = { workspace = true, default-features = false, features = ["clock"] } +chrono = { workspace = true, default-features = false, features = ["clock"] } bytes = { version = "1.5" } pin-project = "1.1.3" futures-core = { version = "0.3" } @@ -18,17 +18,31 @@ md5 = "0.7.0" anyhow.workspace = true walkdir = "2.4.0" tempfile = "3.8.1" -validator = "0.16.0" +validator = { workspace = true, features = ["derive"] } tracing.workspace = true atomic_refcell = "0.1" +allo-isolate = { version = "^0.1", features = ["catch-unwind"], optional = true } +futures = "0.3.31" +cfg-if = "1.0.0" +futures-util = "0.3.30" + + +aes-gcm = { version = "0.10.2", optional = true } +rand = { version = "0.8.5", optional = true } +pbkdf2 = { version = "0.12.2", optional = true } +hmac = { version = "0.12.1", optional = true } +sha2 = { version = "0.10.7", optional = true } +base64 = { version = "0.22.1" } [dev-dependencies] rand = "0.8.5" -futures = "0.3.30" +futures = "0.3.31" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -zip = { version = "0.6.6", features = ["deflate"] } -brotli = { version = "3.4.0", optional = true } +zip = { version = "2.2.0", features = ["deflate"] } +brotli = { version = "3.4.0", optional = true } [features] -compression = ["brotli"] \ No newline at end of file +compression = ["brotli"] +isolate_flutter = ["allo-isolate"] +encryption = ["aes-gcm", "rand", "pbkdf2", "hmac", "sha2"] \ No newline at end of file diff --git a/frontend/rust-lib/lib-infra/src/encryption/mod.rs b/frontend/rust-lib/lib-infra/src/encryption/mod.rs new file mode 100644 index 0000000000..6caabdc5cc --- /dev/null +++ b/frontend/rust-lib/lib-infra/src/encryption/mod.rs @@ -0,0 +1,177 @@ +use aes_gcm::aead::generic_array::GenericArray; +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, KeyInit}; +use anyhow::Result; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use pbkdf2::hmac::Hmac; +use pbkdf2::pbkdf2; +use rand::distributions::Alphanumeric; +use rand::Rng; +use sha2::Sha256; + +/// The length of the salt in bytes. +const SALT_LENGTH: usize = 16; + +/// The length of the derived encryption key in bytes. +const KEY_LENGTH: usize = 32; + +/// The number of iterations for the PBKDF2 key derivation. +const ITERATIONS: u32 = 1000; + +/// The length of the nonce for AES-GCM encryption. +const NONCE_LENGTH: usize = 12; + +/// Delimiter used to concatenate the passphrase and salt. +const CONCATENATED_DELIMITER: &str = "$"; + +/// Generate a new encryption secret consisting of a passphrase and a salt. +pub fn generate_encryption_secret() -> String { + let passphrase = generate_random_passphrase(); + let salt = generate_random_salt(); + combine_passphrase_and_salt(&passphrase, &salt) +} + +/// Encrypt a byte slice using AES-GCM. +/// +/// # Arguments +/// * `data`: The data to encrypt. +/// * `combined_passphrase_salt`: The concatenated passphrase and salt. +pub fn encrypt_data>(data: T, combined_passphrase_salt: &str) -> Result> { + let (passphrase, salt) = split_passphrase_and_salt(combined_passphrase_salt)?; + let key = derive_key(passphrase, &salt)?; + let cipher = Aes256Gcm::new(GenericArray::from_slice(&key)); + let nonce: [u8; NONCE_LENGTH] = rand::thread_rng().gen(); + let ciphertext = cipher + .encrypt(GenericArray::from_slice(&nonce), data.as_ref()) + .unwrap(); + + let result = nonce + .iter() + .copied() + .chain(ciphertext.iter().copied()) + .collect(); + Ok(result) +} + +/// Decrypt a byte slice using AES-GCM. +/// +/// # Arguments +/// * `data`: The data to decrypt. +/// * `combined_passphrase_salt`: The concatenated passphrase and salt. +pub fn decrypt_data>(data: T, combined_passphrase_salt: &str) -> Result> { + if data.as_ref().len() <= NONCE_LENGTH { + return Err(anyhow::anyhow!("Ciphertext too short to include nonce.")); + } + let (passphrase, salt) = split_passphrase_and_salt(combined_passphrase_salt)?; + let key = derive_key(passphrase, &salt)?; + let cipher = Aes256Gcm::new(GenericArray::from_slice(&key)); + let (nonce, cipher_data) = data.as_ref().split_at(NONCE_LENGTH); + cipher + .decrypt(GenericArray::from_slice(nonce), cipher_data) + .map_err(|e| anyhow::anyhow!("Decryption error: {:?}", e)) +} + +/// Encrypt a string using AES-GCM and return the result as a base64 encoded string. +/// +/// # Arguments +/// * `data`: The string data to encrypt. +/// * `combined_passphrase_salt`: The concatenated passphrase and salt. +pub fn encrypt_text>(data: T, combined_passphrase_salt: &str) -> Result { + let encrypted = encrypt_data(data.as_ref(), combined_passphrase_salt)?; + Ok(STANDARD.encode(encrypted)) +} + +/// Decrypt a base64 encoded string using AES-GCM. +/// +/// # Arguments +/// * `data`: The base64 encoded string to decrypt. +/// * `combined_passphrase_salt`: The concatenated passphrase and salt. +pub fn decrypt_text>(data: T, combined_passphrase_salt: &str) -> Result { + let encrypted = STANDARD.decode(data)?; + let decrypted = decrypt_data(encrypted, combined_passphrase_salt)?; + Ok(String::from_utf8(decrypted)?) +} + +/// Generates a random passphrase consisting of alphanumeric characters. +/// +/// This function creates a passphrase with both uppercase and lowercase letters +/// as well as numbers. The passphrase is 30 characters in length. +/// +/// # Returns +/// +/// A `String` representing the generated passphrase. +/// +/// # Security Considerations +/// +/// The passphrase is derived from the `Alphanumeric` character set which includes 62 possible +/// characters (26 lowercase letters, 26 uppercase letters, 10 numbers). This results in a total +/// of `62^30` possible combinations, making it strong against brute force attacks. +/// +fn generate_random_passphrase() -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(30) // e.g., 30 characters + .map(char::from) + .collect() +} + +fn generate_random_salt() -> [u8; SALT_LENGTH] { + let mut rng = rand::thread_rng(); + let salt: [u8; SALT_LENGTH] = rng.gen(); + salt +} + +fn combine_passphrase_and_salt(passphrase: &str, salt: &[u8; SALT_LENGTH]) -> String { + let salt_base64 = STANDARD.encode(salt); + format!("{}{}{}", passphrase, CONCATENATED_DELIMITER, salt_base64) +} + +fn split_passphrase_and_salt(combined: &str) -> Result<(&str, [u8; SALT_LENGTH]), anyhow::Error> { + let parts: Vec<&str> = combined.split(CONCATENATED_DELIMITER).collect(); + if parts.len() != 2 { + return Err(anyhow::anyhow!("Invalid combined format")); + } + let passphrase = parts[0]; + let salt = STANDARD.decode(parts[1])?; + if salt.len() != SALT_LENGTH { + return Err(anyhow::anyhow!("Incorrect salt length")); + } + let mut salt_array = [0u8; SALT_LENGTH]; + salt_array.copy_from_slice(&salt); + Ok((passphrase, salt_array)) +} + +fn derive_key(passphrase: &str, salt: &[u8; SALT_LENGTH]) -> Result<[u8; KEY_LENGTH]> { + let mut key = [0u8; KEY_LENGTH]; + pbkdf2::>(passphrase.as_bytes(), salt, ITERATIONS, &mut key)?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_test() { + let secret = generate_encryption_secret(); + let data = b"hello world"; + let encrypted = encrypt_data(data, &secret).unwrap(); + let decrypted = decrypt_data(encrypted, &secret).unwrap(); + assert_eq!(data, decrypted.as_slice()); + + let s = "123".to_string(); + let encrypted = encrypt_text(&s, &secret).unwrap(); + let decrypted_str = decrypt_text(encrypted, &secret).unwrap(); + assert_eq!(s, decrypted_str); + } + + #[test] + fn decrypt_with_invalid_secret_test() { + let secret = generate_encryption_secret(); + let data = b"hello world"; + let encrypted = encrypt_data(data, &secret).unwrap(); + let decrypted = decrypt_data(encrypted, "invalid secret"); + assert!(decrypted.is_err()) + } +} diff --git a/frontend/rust-lib/lib-infra/src/file_util.rs b/frontend/rust-lib/lib-infra/src/file_util.rs index 2186c71eaa..6de14b2304 100644 --- a/frontend/rust-lib/lib-infra/src/file_util.rs +++ b/frontend/rust-lib/lib-infra/src/file_util.rs @@ -4,12 +4,11 @@ use std::fs::File; use std::path::{Path, PathBuf}; use std::time::SystemTime; use std::{fs, io}; - use tempfile::tempdir; use walkdir::WalkDir; use zip::write::FileOptions; -use zip::ZipArchive; use zip::ZipWriter; +use zip::{CompressionMethod, ZipArchive}; pub fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) { @@ -69,48 +68,67 @@ where } pub fn zip_folder(src_path: impl AsRef, dest_path: &Path) -> io::Result<()> { - if !src_path.as_ref().exists() { - return Err(io::ErrorKind::NotFound.into()); + let src_path = src_path.as_ref(); + + // Check if source path exists + if !src_path.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Source path not found", + )); } - if src_path.as_ref() == dest_path { - return Err(io::ErrorKind::InvalidInput.into()); + // Check if source and destination paths are the same + if src_path == dest_path { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Source and destination paths cannot be the same", + )); } + // Create a file at the destination path to write the ZIP file let file = File::create(dest_path)?; let mut zip = ZipWriter::new(file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Deflated); - for entry in WalkDir::new(&src_path) { - let entry = entry?; + // Traverse through the source directory recursively + for entry in WalkDir::new(src_path).into_iter().filter_map(|e| e.ok()) { let path = entry.path(); - let name = match path.strip_prefix(&src_path) { - Ok(n) => n, - Err(_) => return Err(io::Error::new(io::ErrorKind::Other, "Invalid path")), - }; + let name = path + .strip_prefix(src_path) + .map_err(|_| io::Error::new(io::ErrorKind::Other, "Invalid path"))?; + // If the entry is a file, add it to the ZIP archive if path.is_file() { - zip.start_file( - name - .to_str() - .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Invalid file name"))?, - options, + let file_name = name + .to_str() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Invalid file name"))?; + zip.start_file::<_, ()>( + file_name, + FileOptions::default().compression_method(CompressionMethod::Deflated), )?; + + // Instead of loading the entire file into memory, use `copy` for efficient writing let mut f = File::open(path)?; io::copy(&mut f, &mut zip)?; - } else if !name.as_os_str().is_empty() { - zip.add_directory( - name - .to_str() - .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Invalid directory name"))?, - options, - )?; + + // If the entry is a directory, add it to the ZIP archive + } else if path.is_dir() { + let dir_name = name + .to_str() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Invalid directory name"))?; + if !dir_name.is_empty() { + zip.add_directory::<_, ()>( + dir_name, + FileOptions::default().compression_method(CompressionMethod::Deflated), + )?; + } } } + + // Finish the ZIP archive zip.finish()?; Ok(()) } - pub fn unzip_and_replace( zip_path: impl AsRef, target_folder: &Path, diff --git a/frontend/rust-lib/lib-infra/src/isolate_stream.rs b/frontend/rust-lib/lib-infra/src/isolate_stream.rs new file mode 100644 index 0000000000..cebc2b7d10 --- /dev/null +++ b/frontend/rust-lib/lib-infra/src/isolate_stream.rs @@ -0,0 +1,47 @@ +use allo_isolate::{IntoDart, Isolate}; +use anyhow::anyhow; +use futures::Sink; +pub use futures_util::sink::SinkExt; +use pin_project::pin_project; +use std::pin::Pin; +use std::task::{Context, Poll}; + +#[pin_project] +#[derive(Clone, Debug)] +pub struct IsolateSink { + isolate: Isolate, +} + +impl IsolateSink { + pub fn new(isolate: Isolate) -> Self { + Self { isolate } + } +} + +impl Sink for IsolateSink +where + T: IntoDart, +{ + type Error = anyhow::Error; + + fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, item: T) -> Result<(), Self::Error> { + let this = self.project(); + if this.isolate.post(item) { + Ok(()) + } else { + Err(anyhow!("failed to post message")) + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} diff --git a/frontend/rust-lib/lib-infra/src/lib.rs b/frontend/rust-lib/lib-infra/src/lib.rs index f6f1b4b1b0..dc2ed5263c 100644 --- a/frontend/rust-lib/lib-infra/src/lib.rs +++ b/frontend/rust-lib/lib-infra/src/lib.rs @@ -19,7 +19,12 @@ if_wasm! { } } +#[cfg(feature = "encryption")] +pub mod encryption; +#[cfg(feature = "isolate_flutter")] +pub mod isolate_stream; pub mod priority_task; pub mod ref_map; +pub mod stream_util; pub mod util; pub mod validator_fn; diff --git a/frontend/rust-lib/lib-infra/src/native/future.rs b/frontend/rust-lib/lib-infra/src/native/future.rs index 4d918d7e7c..0f1c174c55 100644 --- a/frontend/rust-lib/lib-infra/src/native/future.rs +++ b/frontend/rust-lib/lib-infra/src/native/future.rs @@ -2,7 +2,6 @@ use futures_core::future::BoxFuture; use futures_core::ready; use pin_project::pin_project; use std::{ - fmt::Debug, future::Future, pin::Pin, task::{Context, Poll}, @@ -33,33 +32,4 @@ where } } -#[pin_project] -pub struct FutureResult { - #[pin] - pub fut: Pin> + Sync + Send>>, -} - -impl FutureResult { - pub fn new(f: F) -> Self - where - F: Future> + Send + Sync + 'static, - { - Self { fut: Box::pin(f) } - } -} - -impl Future for FutureResult -where - T: Send + Sync, - E: Debug, -{ - type Output = Result; - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.as_mut().project(); - let result = ready!(this.fut.poll(cx)); - Poll::Ready(result) - } -} - pub type BoxResultFuture<'a, T, E> = BoxFuture<'a, Result>; diff --git a/frontend/rust-lib/lib-infra/src/priority_task/queue.rs b/frontend/rust-lib/lib-infra/src/priority_task/queue.rs index 2123ef15ca..2b5e6e598d 100644 --- a/frontend/rust-lib/lib-infra/src/priority_task/queue.rs +++ b/frontend/rust-lib/lib-infra/src/priority_task/queue.rs @@ -34,7 +34,8 @@ impl TaskQueue { match self.index_tasks.entry(task.handler_id.clone()) { Entry::Occupied(entry) => { let mut list = entry.get().borrow_mut(); - assert!(list + + debug_assert!(list .peek() .map(|old_id| pending_task.id >= old_id.id) .unwrap_or(true)); diff --git a/frontend/rust-lib/lib-infra/src/priority_task/scheduler.rs b/frontend/rust-lib/lib-infra/src/priority_task/scheduler.rs index 1e1bb33c2d..96798b742a 100644 --- a/frontend/rust-lib/lib-infra/src/priority_task/scheduler.rs +++ b/frontend/rust-lib/lib-infra/src/priority_task/scheduler.rs @@ -2,13 +2,14 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use crate::future::BoxResultFuture; use crate::priority_task::queue::TaskQueue; use crate::priority_task::store::TaskStore; use crate::priority_task::{Task, TaskContent, TaskId, TaskState}; use anyhow::Error; +use async_trait::async_trait; use tokio::sync::{watch, RwLock}; use tokio::time::interval; +use tracing::trace; pub struct TaskDispatcher { queue: TaskQueue, @@ -105,6 +106,11 @@ impl TaskDispatcher { return; } + trace!( + "Add task: handler:{}, task:{:?}", + task.handler_id, + task.content + ); self.queue.push(&task); self.store.insert_task(task); self.notify(); @@ -160,6 +166,7 @@ impl TaskRunner { } } +#[async_trait] pub trait TaskHandler: Send + Sync + 'static { fn handler_id(&self) -> &str; @@ -167,9 +174,10 @@ pub trait TaskHandler: Send + Sync + 'static { "" } - fn run(&self, content: TaskContent) -> BoxResultFuture<(), Error>; + async fn run(&self, content: TaskContent) -> Result<(), Error>; } +#[async_trait] impl TaskHandler for Box where T: TaskHandler, @@ -182,11 +190,12 @@ where (**self).handler_name() } - fn run(&self, content: TaskContent) -> BoxResultFuture<(), Error> { - (**self).run(content) + async fn run(&self, content: TaskContent) -> Result<(), Error> { + (**self).run(content).await } } +#[async_trait] impl TaskHandler for Arc where T: TaskHandler, @@ -199,7 +208,7 @@ where (**self).handler_name() } - fn run(&self, content: TaskContent) -> BoxResultFuture<(), Error> { - (**self).run(content) + async fn run(&self, content: TaskContent) -> Result<(), Error> { + (**self).run(content).await } } diff --git a/frontend/rust-lib/lib-infra/src/priority_task/store.rs b/frontend/rust-lib/lib-infra/src/priority_task/store.rs index b9b2c8599e..ed3401fd9b 100644 --- a/frontend/rust-lib/lib-infra/src/priority_task/store.rs +++ b/frontend/rust-lib/lib-infra/src/priority_task/store.rs @@ -13,7 +13,7 @@ impl TaskStore { pub fn new() -> Self { Self { tasks: HashMap::new(), - task_id_counter: AtomicU32::new(0), + task_id_counter: AtomicU32::new(1), } } @@ -45,7 +45,6 @@ impl TaskStore { } pub(crate) fn next_task_id(&self) -> TaskId { - let _ = self.task_id_counter.fetch_add(1, SeqCst); - self.task_id_counter.load(SeqCst) + self.task_id_counter.fetch_add(1, SeqCst) } } diff --git a/frontend/rust-lib/lib-infra/src/stream_util.rs b/frontend/rust-lib/lib-infra/src/stream_util.rs new file mode 100644 index 0000000000..41c747d26a --- /dev/null +++ b/frontend/rust-lib/lib-infra/src/stream_util.rs @@ -0,0 +1,21 @@ +use futures_core::Stream; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::sync::mpsc; +use tokio::sync::mpsc::{Receiver, Sender}; + +struct BoundedStream { + recv: Receiver, +} +impl Stream for BoundedStream { + type Item = T; + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::into_inner(self).recv.poll_recv(cx) + } +} + +pub fn mpsc_channel_stream(size: usize) -> (Sender, impl Stream) { + let (tx, rx) = mpsc::channel(size); + let stream = BoundedStream { recv: rx }; + (tx, stream) +} diff --git a/frontend/rust-lib/lib-infra/src/util.rs b/frontend/rust-lib/lib-infra/src/util.rs index 823095a26f..18ff068a73 100644 --- a/frontend/rust-lib/lib-infra/src/util.rs +++ b/frontend/rust-lib/lib-infra/src/util.rs @@ -1,3 +1,6 @@ +#[allow(unused_imports)] +use tracing::info; + #[macro_export] macro_rules! if_native { ($($item:item)*) => {$( @@ -67,9 +70,8 @@ pub fn md5>(data: T) -> String { let md5 = format!("{:x}", md5::compute(data)); md5 } - #[derive(Debug, Clone, PartialEq, Eq)] -pub enum Platform { +pub enum OperatingSystem { Unknown, Windows, Linux, @@ -78,33 +80,69 @@ pub enum Platform { Android, } -impl Platform { +impl OperatingSystem { pub fn is_not_ios(&self) -> bool { - !matches!(self, Platform::IOS) + !matches!(self, OperatingSystem::IOS) + } + + pub fn is_desktop(&self) -> bool { + matches!( + self, + OperatingSystem::Windows | OperatingSystem::Linux | OperatingSystem::MacOS + ) + } + + pub fn is_not_desktop(&self) -> bool { + !self.is_desktop() } } -impl From for Platform { +impl From for OperatingSystem { fn from(s: String) -> Self { - Platform::from(s.as_str()) + OperatingSystem::from(s.as_str()) } } -impl From<&String> for Platform { +impl From<&String> for OperatingSystem { fn from(s: &String) -> Self { - Platform::from(s.as_str()) + OperatingSystem::from(s.as_str()) } } -impl From<&str> for Platform { +impl From<&str> for OperatingSystem { fn from(s: &str) -> Self { match s { - "windows" => Platform::Windows, - "linux" => Platform::Linux, - "macos" => Platform::MacOS, - "ios" => Platform::IOS, - "android" => Platform::Android, - _ => Platform::Unknown, + "windows" => OperatingSystem::Windows, + "linux" => OperatingSystem::Linux, + "macos" => OperatingSystem::MacOS, + "ios" => OperatingSystem::IOS, + "android" => OperatingSystem::Android, + _ => OperatingSystem::Unknown, } } } + +pub fn get_operating_system() -> OperatingSystem { + cfg_if::cfg_if! { + if #[cfg(target_os = "android")] { + OperatingSystem::Android + } else if #[cfg(target_os = "ios")] { + OperatingSystem::IOS + } else if #[cfg(target_os = "macos")] { + OperatingSystem::MacOS + } else if #[cfg(target_os = "windows")] { + OperatingSystem::Windows + } else if #[cfg(target_os = "linux")] { + OperatingSystem::Linux + } else { + OperatingSystem::Unknown + } + } +} + +#[macro_export] +macro_rules! sync_trace { + ($($arg:tt)*) => { + tracing::info!(target: "sync_trace_log", $($arg)*); + }; +} diff --git a/frontend/rust-lib/lib-infra/tests/task_test/script.rs b/frontend/rust-lib/lib-infra/tests/task_test/script.rs index c419d981f2..28e64d85bf 100644 --- a/frontend/rust-lib/lib-infra/tests/task_test/script.rs +++ b/frontend/rust-lib/lib-infra/tests/task_test/script.rs @@ -2,7 +2,7 @@ use anyhow::Error; use futures::stream::FuturesUnordered; use futures::StreamExt; use lib_infra::async_trait::async_trait; -use lib_infra::future::BoxResultFuture; + use lib_infra::priority_task::{ Task, TaskContent, TaskDispatcher, TaskHandler, TaskId, TaskResult, TaskRunner, TaskState, }; @@ -132,23 +132,25 @@ impl RefCountValue for MockTextTaskHandler { async fn did_remove(&self) {} } +#[async_trait] impl TaskHandler for MockTextTaskHandler { fn handler_id(&self) -> &str { "1" } - fn run(&self, content: TaskContent) -> BoxResultFuture<(), Error> { - let mut rng = rand::thread_rng(); - let millisecond = rng.gen_range(1..50); - Box::pin(async move { - match content { - TaskContent::Text(_s) => { - tokio::time::sleep(Duration::from_millis(millisecond)).await; - }, - TaskContent::Blob(_) => panic!("Only support text"), - } - Ok(()) - }) + async fn run(&self, content: TaskContent) -> Result<(), Error> { + let millisecond = { + let mut rng = rand::thread_rng(); + rng.gen_range(1..50) + }; + + match content { + TaskContent::Text(_s) => { + tokio::time::sleep(Duration::from_millis(millisecond)).await; + Ok(()) + }, + TaskContent::Blob(_) => panic!("Only support text"), + } } } @@ -170,42 +172,40 @@ impl RefCountValue for MockBlobTaskHandler { async fn did_remove(&self) {} } +#[async_trait] impl TaskHandler for MockBlobTaskHandler { fn handler_id(&self) -> &str { "2" } - fn run(&self, content: TaskContent) -> BoxResultFuture<(), Error> { - Box::pin(async move { - match content { - TaskContent::Text(_) => panic!("Only support blob"), - TaskContent::Blob(bytes) => { - let _msg = String::from_utf8(bytes).unwrap(); - tokio::time::sleep(Duration::from_millis(20)).await; - }, - } - Ok(()) - }) + async fn run(&self, content: TaskContent) -> Result<(), Error> { + match content { + TaskContent::Text(_) => panic!("Only support blob"), + TaskContent::Blob(bytes) => { + let _msg = String::from_utf8(bytes).unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + }, + } + Ok(()) } } pub struct MockTimeoutTaskHandler(); +#[async_trait] impl TaskHandler for MockTimeoutTaskHandler { fn handler_id(&self) -> &str { "3" } - fn run(&self, content: TaskContent) -> BoxResultFuture<(), Error> { - Box::pin(async move { - match content { - TaskContent::Text(_) => panic!("Only support blob"), - TaskContent::Blob(_bytes) => { - tokio::time::sleep(Duration::from_millis(2000)).await; - }, - } - Ok(()) - }) + async fn run(&self, content: TaskContent) -> Result<(), Error> { + match content { + TaskContent::Text(_) => panic!("Only support blob"), + TaskContent::Blob(_bytes) => { + tokio::time::sleep(Duration::from_millis(2000)).await; + }, + } + Ok(()) } } diff --git a/frontend/rust-lib/lib-log/Cargo.toml b/frontend/rust-lib/lib-log/Cargo.toml index 1b8ebcb2bc..4316192f71 100644 --- a/frontend/rust-lib/lib-log/Cargo.toml +++ b/frontend/rust-lib/lib-log/Cargo.toml @@ -6,8 +6,8 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tracing-subscriber = { version = "0.3.17", features = ["registry", "env-filter", "ansi", "json"] } -tracing-bunyan-formatter = "0.3.9" +tracing-subscriber = { version = "0.3.19", features = ["registry", "env-filter", "ansi", "json"] } +tracing-bunyan-formatter = "0.3.10" tracing-appender = "0.2.3" tracing-core = "0.1" tracing.workspace = true diff --git a/frontend/rust-lib/lib-log/src/layer.rs b/frontend/rust-lib/lib-log/src/layer.rs index 28bd54b01f..945db52a8b 100644 --- a/frontend/rust-lib/lib-log/src/layer.rs +++ b/frontend/rust-lib/lib-log/src/layer.rs @@ -7,6 +7,7 @@ use tracing::{Event, Id, Subscriber}; use tracing_bunyan_formatter::JsonStorage; use tracing_core::metadata::Level; use tracing_core::span::Attributes; +use tracing_core::Metadata; use tracing_subscriber::{fmt::MakeWriter, layer::Context, registry::SpanRef, Layer}; const LEVEL: &str = "level"; @@ -22,6 +23,8 @@ const IGNORE_FIELDS: [&str; 2] = [LOG_MODULE_PATH, LOG_TARGET_PATH]; pub struct FlowyFormattingLayer<'a, W: MakeWriter<'static> + 'static> { make_writer: W, with_target: bool, + #[allow(clippy::type_complexity)] + target_filter: Option bool + Send + Sync>>, phantom: std::marker::PhantomData<&'a ()>, } @@ -34,10 +37,26 @@ where Self { make_writer, with_target: true, + target_filter: None, phantom: std::marker::PhantomData, } } + pub fn with_target_filter(mut self, filter: F) -> Self + where + F: Fn(&str) -> bool + Send + Sync + 'static, + { + self.target_filter = Some(Box::new(filter)); + self + } + + fn should_log(&self, metadata: &Metadata<'_>) -> bool { + self + .target_filter + .as_ref() + .map_or(true, |f| f(metadata.target())) + } + fn serialize_fields( &self, map_serializer: &mut impl SerializeMap, @@ -45,7 +64,6 @@ where _level: &Level, ) -> Result<(), std::io::Error> { map_serializer.serialize_entry(MESSAGE, &message)?; - // map_serializer.serialize_entry(LEVEL, &format!("{}", level))?; map_serializer.serialize_entry(TIME, &Local::now().format("%m-%d %H:%M:%S").to_string())?; Ok(()) } @@ -160,6 +178,10 @@ where W: for<'writer> MakeWriter<'writer> + 'static, { fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { + if !self.should_log(event.metadata()) { + return; + } + // Events do not necessarily happen in the context of a span, hence // lookup_current returns an `Option>` instead of a // `SpanRef<_>`. @@ -177,14 +199,9 @@ where let message = format_event_message(¤t_span, event, &event_visitor, &ctx); self.serialize_fields(&mut map_serializer, &message, event.metadata().level())?; - // Additional metadata useful for debugging - // They should be nested under `src` (see https://github.com/trentm/node-bunyan#src ) - // but `tracing` does not support nested values yet - if self.with_target { map_serializer.serialize_entry("target", event.metadata().target())?; } - // map_serializer.serialize_entry("line", &event.metadata().line())?; // map_serializer.serialize_entry("file", &event.metadata().file())?; @@ -224,6 +241,9 @@ where fn on_new_span(&self, _attrs: &Attributes, id: &Id, ctx: Context<'_, S>) { let span = ctx.span(id).expect("Span not found, this is a bug"); + if !self.should_log(span.metadata()) { + return; + } if let Ok(serialized) = self.serialize_span(&span, Type::EnterSpan, &ctx) { let _ = self.emit(serialized); } @@ -231,6 +251,9 @@ where fn on_close(&self, id: Id, ctx: Context<'_, S>) { let span = ctx.span(&id).expect("Span not found, this is a bug"); + if !self.should_log(span.metadata()) { + return; + } if let Ok(serialized) = self.serialize_span(&span, Type::ExitSpan, &ctx) { let _ = self.emit(serialized); } diff --git a/frontend/rust-lib/lib-log/src/lib.rs b/frontend/rust-lib/lib-log/src/lib.rs index a328be254c..07145b27ff 100644 --- a/frontend/rust-lib/lib-log/src/lib.rs +++ b/frontend/rust-lib/lib-log/src/lib.rs @@ -2,9 +2,11 @@ use std::io; use std::io::Write; use std::sync::{Arc, RwLock}; +use crate::layer::FlowyFormattingLayer; +use crate::stream_log::{StreamLog, StreamLogSender}; use chrono::Local; use lazy_static::lazy_static; -use lib_infra::util::Platform; +use lib_infra::util::OperatingSystem; use tracing::subscriber::set_global_default; use tracing_appender::rolling::Rotation; use tracing_appender::{non_blocking::WorkerGuard, rolling::RollingFileAppender}; @@ -13,58 +15,77 @@ use tracing_subscriber::fmt::format::Writer; use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::{layer::SubscriberExt, EnvFilter}; -use crate::layer::FlowyFormattingLayer; -use crate::stream_log::{StreamLog, StreamLogSender}; - mod layer; pub mod stream_log; lazy_static! { - static ref LOG_GUARD: RwLock> = RwLock::new(None); + static ref APP_LOG_GUARD: RwLock> = RwLock::new(None); + static ref COLLAB_SYNC_LOG_GUARD: RwLock> = RwLock::new(None); } pub struct Builder { #[allow(dead_code)] name: String, env_filter: String, - file_appender: RollingFileAppender, + app_log_appender: RollingFileAppender, + sync_log_appender: RollingFileAppender, #[allow(dead_code)] - platform: Platform, + platform: OperatingSystem, stream_log_sender: Option>, } +const SYNC_TARGET: &str = "sync_trace_log"; impl Builder { pub fn new( name: &str, directory: &str, - platform: &Platform, + platform: &OperatingSystem, stream_log_sender: Option>, ) -> Self { - let file_appender = RollingFileAppender::builder() + let app_log_appender = RollingFileAppender::builder() .rotation(Rotation::DAILY) .filename_prefix(name) .max_log_files(6) .build(directory) .unwrap_or(tracing_appender::rolling::daily(directory, name)); + let sync_log_name = "log.sync"; + let sync_log_appender = RollingFileAppender::builder() + .rotation(Rotation::HOURLY) + .filename_prefix(sync_log_name) + .max_log_files(24) + .build(directory) + .unwrap_or(tracing_appender::rolling::hourly(directory, sync_log_name)); + Builder { name: name.to_owned(), env_filter: "info".to_owned(), - file_appender, + app_log_appender, + sync_log_appender, platform: platform.clone(), stream_log_sender, } } pub fn env_filter(mut self, env_filter: &str) -> Self { - self.env_filter = env_filter.to_owned(); + env_filter.clone_into(&mut self.env_filter); self } pub fn build(self) -> Result<(), String> { let env_filter = EnvFilter::new(self.env_filter); - let (non_blocking, guard) = tracing_appender::non_blocking(self.file_appender); - let file_layer = FlowyFormattingLayer::new(non_blocking); + let (appflowy_log_non_blocking, app_log_guard) = + tracing_appender::non_blocking(self.app_log_appender); + *APP_LOG_GUARD.write().unwrap() = Some(app_log_guard); + let app_file_layer = FlowyFormattingLayer::new(appflowy_log_non_blocking) + .with_target_filter(|target| target != SYNC_TARGET); + + let (sync_log_non_blocking, sync_log_guard) = + tracing_appender::non_blocking(self.sync_log_appender); + *COLLAB_SYNC_LOG_GUARD.write().unwrap() = Some(sync_log_guard); + + let collab_sync_file_layer = FlowyFormattingLayer::new(sync_log_non_blocking) + .with_target_filter(|target| target == SYNC_TARGET); if let Some(stream_log_sender) = &self.stream_log_sender { let subscriber = tracing_subscriber::fmt() @@ -79,7 +100,8 @@ impl Builder { .with_env_filter(env_filter) .finish() .with(JsonStorageLayer) - .with(file_layer); + .with(app_file_layer) + .with(collab_sync_file_layer); set_global_default(subscriber).map_err(|e| format!("{:?}", e))?; } else { let subscriber = tracing_subscriber::fmt() @@ -92,11 +114,11 @@ impl Builder { .finish() .with(FlowyFormattingLayer::new(DebugStdoutWriter)) .with(JsonStorageLayer) - .with(file_layer); + .with(app_file_layer) + .with(collab_sync_file_layer); set_global_default(subscriber).map_err(|e| format!("{:?}", e))?; }; - *LOG_GUARD.write().unwrap() = Some(guard); Ok(()) } } diff --git a/frontend/rust-lib/rust-toolchain.toml b/frontend/rust-lib/rust-toolchain.toml index 6f14058b2e..1de01fa45c 100644 --- a/frontend/rust-lib/rust-toolchain.toml +++ b/frontend/rust-lib/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.77.2" +channel = "1.81.0" diff --git a/frontend/scripts/code_generation/flowy_icons/generate_flowy_icons.sh b/frontend/scripts/code_generation/flowy_icons/generate_flowy_icons.sh index 30538def96..bff26c5b4b 100755 --- a/frontend/scripts/code_generation/flowy_icons/generate_flowy_icons.sh +++ b/frontend/scripts/code_generation/flowy_icons/generate_flowy_icons.sh @@ -1,6 +1,40 @@ -#!/bin/bash +#!/usr/bin/env bash -echo "Generating flowy icon files" +# check the cost time +start_time=$(date +%s) + +# read the arguments to skip the pub get and package get +skip_pub_get=false +skip_pub_packages_get=false +verbose=false +include_packages=false + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-pub-get) + skip_pub_get=true + shift + ;; + --skip-pub-packages-get) + skip_pub_packages_get=true + shift + ;; + --verbose) + verbose=true + shift + ;; + --exclude-packages) + shift + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +echo "📷 Start generating image/svg files" # Store the current working directory original_dir=$(pwd) @@ -14,13 +48,34 @@ rm -rf assets/flowy_icons/ mkdir -p assets/flowy_icons/ rsync -r ../resources/flowy_icons/ assets/flowy_icons/ -flutter pub get -flutter packages pub get +if [ "$skip_pub_get" = false ]; then + if [ "$verbose" = true ]; then + flutter pub get + else + flutter pub get >/dev/null 2>&1 + fi +fi -echo "Generating FlowySvg classes" -dart run flowy_svg +if [ "$include_packages" = true ]; then + if [ "$verbose" = true ]; then + flutter packages pub get + else + flutter packages pub get >/dev/null 2>&1 + fi +fi -echo "Done generating icon files." +if [ "$verbose" = true ]; then + dart run flowy_svg +else + dart run flowy_svg >/dev/null 2>&1 +fi # Return to the original directory cd "$original_dir" + +echo "📷 Done generating image/svg files." + +# echo the cost time +end_time=$(date +%s) +cost_time=$((end_time - start_time)) +echo "📷 Image/svg files generation cost $cost_time seconds." diff --git a/frontend/scripts/code_generation/freezed/generate_freezed.cmd b/frontend/scripts/code_generation/freezed/generate_freezed.cmd index f543e08fd5..798c3bc1dc 100644 --- a/frontend/scripts/code_generation/freezed/generate_freezed.cmd +++ b/frontend/scripts/code_generation/freezed/generate_freezed.cmd @@ -26,6 +26,8 @@ for /D %%d in (*) do ( if exist "pubspec.yaml" ( echo Generating freezed files in %%d... echo Please wait while we clean the project and fetch the dependencies. + call flutter packages pub get + call flutter pub get call dart run build_runner clean && call dart run build_runner build -d echo Done running build command in %%d ) else ( diff --git a/frontend/scripts/code_generation/freezed/generate_freezed.sh b/frontend/scripts/code_generation/freezed/generate_freezed.sh index 24c90650d2..216a01b232 100755 --- a/frontend/scripts/code_generation/freezed/generate_freezed.sh +++ b/frontend/scripts/code_generation/freezed/generate_freezed.sh @@ -1,4 +1,44 @@ -#!/bin/bash +#!/usr/bin/env bash + +# check the cost time +start_time=$(date +%s) + +# read the arguments to skip the pub get and package get +skip_pub_get=false +skip_pub_packages_get=false +verbose=false +exclude_packages=false +show_loading=false + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-pub-get) + skip_pub_get=true + shift + ;; + --skip-pub-packages-get) + skip_pub_packages_get=true + shift + ;; + --verbose) + verbose=true + shift + ;; + --exclude-packages) + exclude_packages=true + shift + ;; + --show-loading) + show_loading=true + shift + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done # Store the current working directory original_dir=$(pwd) @@ -8,34 +48,93 @@ cd "$(dirname "$0")" # Navigate to the project root cd ../../../appflowy_flutter -# Navigate to the appflowy_flutter directory and generate files -echo "Generating files for appflowy_flutter" +if [ "$exclude_packages" = false ]; then + # Navigate to the packages directory + cd packages + for d in */; do + # Navigate into the subdirectory + cd "$d" -flutter packages pub get >/dev/null 2>&1 + # Check if the pubspec.yaml file exists and contains the freezed dependency + if [ -f "pubspec.yaml" ] && grep -q "build_runner" pubspec.yaml; then + echo "🧊 Start generating freezed files ($d)." + if [ "$skip_pub_packages_get" = false ]; then + if [ "$verbose" = true ]; then + flutter packages pub get + else + flutter packages pub get >/dev/null 2>&1 + fi + fi + if [ "$verbose" = true ]; then + dart run build_runner build --delete-conflicting-outputs + else + dart run build_runner build --delete-conflicting-outputs >/dev/null 2>&1 + fi + echo "🧊 Done generating freezed files ($d)." + fi -dart run build_runner build -d -echo "Done generating files for appflowy_flutter" + # Navigate back to the packages directory + cd .. + done -echo "Generating files for packages" -cd packages -for d in */; do - # Navigate into the subdirectory - cd "$d" - - # Check if the subdirectory contains a pubspec.yaml file - if [ -f "pubspec.yaml" ]; then - echo "Generating freezed files in $d..." - echo "Please wait while we clean the project and fetch the dependencies." - flutter packages pub get >/dev/null 2>&1 - dart run build_runner build -d - echo "Done running build command in $d" - else - echo "No pubspec.yaml found in $d, it can\'t be a Dart project. Skipping." - fi - - # Navigate back to the packages directory cd .. -done +fi + +# Function to display animated loading text +display_loading() { + local pid=$1 + local delay=0.5 + local spinstr='|/-\' + while [ "$(ps a | awk '{print $1}' | grep $pid)" ]; do + local temp=${spinstr#?} + printf " [%c] Generating freezed files..." "$spinstr" + local spinstr=$temp${spinstr%"$temp"} + sleep $delay + printf "\r" + done + printf " \r" +} + +# Navigate to the appflowy_flutter directory and generate files +echo "🧊 Start generating freezed files (AppFlowy)." + +if [ "$skip_pub_packages_get" = false ]; then + if [ "$verbose" = true ]; then + flutter packages pub get + else + flutter packages pub get >/dev/null 2>&1 + fi +fi + +# Start the build_runner in the background +if [ "$verbose" = true ]; then + dart run build_runner build --delete-conflicting-outputs & +else + dart run build_runner build --delete-conflicting-outputs >/dev/null 2>&1 & +fi + +# Get the PID of the background process +build_pid=$! + +if [ "$show_loading" = true ]; then + # Start the loading animation + display_loading $build_pid & + + # Get the PID of the loading animation + loading_pid=$! +fi + +# Wait for the build_runner to finish +wait $build_pid + +# Clear the line +printf "\r%*s\r" $(($(tput cols))) "" -# Return to the original directory cd "$original_dir" + +echo "🧊 Done generating freezed files." + +# echo the cost time +end_time=$(date +%s) +cost_time=$((end_time - start_time)) +echo "🧊 Freezed files generation cost $cost_time seconds." diff --git a/frontend/scripts/code_generation/generate.sh b/frontend/scripts/code_generation/generate.sh index f71ceba2df..fd2edab785 100755 --- a/frontend/scripts/code_generation/generate.sh +++ b/frontend/scripts/code_generation/generate.sh @@ -1,4 +1,39 @@ -#!/bin/bash +#!/usr/bin/env bash + +args=("$@") + +# check the cost time +start_time=$(date +%s) + +# read the arguments to skip the pub get and package get +skip_pub_get=false +skip_pub_packages_get=false +verbose=false + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-pub-get) + skip_pub_get=true + shift + ;; + --skip-pub-packages-get) + skip_pub_packages_get=true + shift + ;; + --verbose) + verbose=true + shift + ;; + --exclude-packages) + shift + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done # Store the current working directory original_dir=$(pwd) @@ -7,30 +42,34 @@ original_dir=$(pwd) cd "$(dirname "$0")" # Call the script in the 'language_files' folder -echo "Generating files using easy_localization" cd language_files # Allow execution permissions on CI chmod +x ./generate_language_files.sh -./generate_language_files.sh "$@" +# Pass the arguments to the script +./generate_language_files.sh "${args[@]}" + +# Return to the main script directory +cd .. + +# Call the script in the 'flowy_icons' folder +cd flowy_icons +# Allow execution permissions on CI +chmod +x ./generate_flowy_icons.sh +./generate_flowy_icons.sh "${args[@]}" # Return to the main script directory cd .. # Call the script in the 'freezed' folder -echo "Generating files using build_runner" cd freezed # Allow execution permissions on CI chmod +x ./generate_freezed.sh -./generate_freezed.sh "$@" - -# Return to the main script directory -cd .. - -echo "Generating svg files using flowy_svg" -cd flowy_icons -# Allow execution permissions on CI -chmod +x ./generate_flowy_icons.sh -./generate_flowy_icons.sh "$@" +./generate_freezed.sh "${args[@]}" --show-loading --verbose # Return to the original directory cd "$original_dir" + +# echo the cost time +end_time=$(date +%s) +cost_time=$((end_time - start_time)) +echo "✅ Code generation cost $cost_time seconds." diff --git a/frontend/scripts/code_generation/language_files/generate_language_files.cmd b/frontend/scripts/code_generation/language_files/generate_language_files.cmd index 7b14503810..9536e4359c 100644 --- a/frontend/scripts/code_generation/language_files/generate_language_files.cmd +++ b/frontend/scripts/code_generation/language_files/generate_language_files.cmd @@ -16,6 +16,8 @@ echo Copying resources/translations to appflowy_flutter/assets/translations xcopy /E /Y /I ..\resources\translations assets\translations REM call flutter packages pub get +call flutter pub get +call flutter packages pub get echo Specifying source directory for AppFlowy Localizations. call dart run easy_localization:generate -S assets/translations/ diff --git a/frontend/scripts/code_generation/language_files/generate_language_files.sh b/frontend/scripts/code_generation/language_files/generate_language_files.sh index 8aa403d1f2..5e51b5cdba 100755 --- a/frontend/scripts/code_generation/language_files/generate_language_files.sh +++ b/frontend/scripts/code_generation/language_files/generate_language_files.sh @@ -1,6 +1,41 @@ -#!/bin/bash +#!/usr/bin/env bash -echo "Generating language files" +set -e + +# check the cost time +start_time=$(date +%s) + +# read the arguments to skip the pub get and package get +skip_pub_get=false +skip_pub_packages_get=false +verbose=false + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-pub-get) + skip_pub_get=true + shift + ;; + --skip-pub-packages-get) + skip_pub_packages_get=true + shift + ;; + --verbose) + verbose=true + shift + ;; + --exclude-packages) + shift + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +echo "🌍 Start generating language files." # Store the current working directory original_dir=$(pwd) @@ -19,16 +54,35 @@ cp -f ../resources/translations/*.json assets/translations/ # the ci alwayas return a 'null check operator used on a null value' error. # so we force to exec the below command to avoid the error. # https://github.com/dart-lang/pub/issues/3314 -flutter pub get -flutter packages pub get +if [ "$skip_pub_get" = false ]; then + if [ "$verbose" = true ]; then + flutter pub get + else + flutter pub get >/dev/null 2>&1 + fi +fi +if [ "$skip_pub_packages_get" = false ]; then + if [ "$verbose" = true ]; then + flutter packages pub get + else + flutter packages pub get >/dev/null 2>&1 + fi +fi -echo "Specifying source directory for AppFlowy Localizations." -dart run easy_localization:generate -S assets/translations/ +if [ "$verbose" = true ]; then + dart run easy_localization:generate -S assets/translations/ + dart run easy_localization:generate -f keys -o locale_keys.g.dart -S assets/translations/ -s en.json +else + dart run easy_localization:generate -S assets/translations/ >/dev/null 2>&1 + dart run easy_localization:generate -f keys -o locale_keys.g.dart -S assets/translations/ -s en.json >/dev/null 2>&1 +fi -echo "Generating language files for AppFlowy." -dart run easy_localization:generate -f keys -o locale_keys.g.dart -S assets/translations/ -s en.json - -echo "Done generating language files." +echo "🌍 Done generating language files." # Return to the original directory cd "$original_dir" + +# echo the cost time +end_time=$(date +%s) +cost_time=$((end_time - start_time)) +echo "🌍 Language files generation cost $cost_time seconds." diff --git a/frontend/scripts/docker-buildfiles/Dockerfile b/frontend/scripts/docker-buildfiles/Dockerfile index 0624ec053b..9e422f80ca 100644 --- a/frontend/scripts/docker-buildfiles/Dockerfile +++ b/frontend/scripts/docker-buildfiles/Dockerfile @@ -17,40 +17,31 @@ ENV PATH="/home/$user/.pub-cache/bin:/home/$user/flutter/bin:/home/$user/flutter USER $user WORKDIR /home/$user -# Install yay -RUN sudo pacman -S --needed --noconfirm curl tar -RUN curl -sSfL \ - --output yay.tar.gz \ - https://github.com/Jguer/yay/releases/download/v12.3.3/yay_12.3.3_x86_64.tar.gz && \ - tar -xf yay.tar.gz && \ - sudo mv yay_12.3.3_x86_64/yay /bin && \ - rm -rf yay_12.3.3_x86_64 && \ - yay --version - -# Install Rust -RUN yay -S --noconfirm curl base-devel openssl clang cmake ninja pkg-config xdg-user-dirs +# Install Rust and dependencies using pacman +RUN sudo pacman -S --needed --noconfirm curl base-devel openssl clang cmake ninja pkg-config xdg-user-dirs RUN xdg-user-dirs-update RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y RUN source ~/.cargo/env && \ - rustup toolchain install 1.75 && \ - rustup default 1.75 + rustup toolchain install 1.81 && \ + rustup default 1.81 # Install Flutter RUN sudo pacman -S --noconfirm git tar gtk3 RUN curl -sSfL \ - --output flutter.tar.xz \ - https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.19.0-stable.tar.xz && \ + --output flutter.tar.xz \ + https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.27.4-stable.tar.xz && \ tar -xf flutter.tar.xz && \ rm flutter.tar.xz RUN flutter config --enable-linux-desktop RUN flutter doctor RUN dart pub global activate protoc_plugin 21.1.2 -# Install build dependencies for AppFlowy -RUN yay -S --noconfirm jemalloc4 cargo-make cargo-binstall -RUN sudo pacman -S --noconfirm git libkeybinder3 sqlite clang rsync libnotify rocksdb zstd +# Install build dependencies for AppFlowy using pacman +RUN sudo pacman -S --needed --noconfirm jemalloc git libkeybinder3 sqlite clang rsync libnotify rocksdb zstd mpv RUN sudo ln -s /usr/bin/sha1sum /usr/bin/shasum -RUN source ~/.cargo/env && cargo binstall duckscript_cli -y +RUN source ~/.cargo/env && cargo install cargo-make --version 0.37.18 --locked +RUN source ~/.cargo/env && cargo install cargo-binstall --version 1.10.17 --locked +RUN source ~/.cargo/env && cargo binstall duckscript_cli --locked -y # Build AppFlowy COPY . /appflowy @@ -73,7 +64,7 @@ FROM archlinux/archlinux RUN pacman -Syyu --noconfirm # Install runtime dependencies -RUN pacman -S --noconfirm xdg-user-dirs gtk3 libkeybinder3 && \ +RUN pacman -S --noconfirm xdg-user-dirs gtk3 libkeybinder3 libnotify rocksdb && \ pacman -Scc --noconfirm # Set up appflowy user diff --git a/frontend/scripts/flatpack-buildfiles/io.appflowy.AppFlowy.desktop b/frontend/scripts/flatpack-buildfiles/io.appflowy.AppFlowy.desktop index f8f2c06842..9076493bb8 100644 --- a/frontend/scripts/flatpack-buildfiles/io.appflowy.AppFlowy.desktop +++ b/frontend/scripts/flatpack-buildfiles/io.appflowy.AppFlowy.desktop @@ -2,7 +2,7 @@ Type=Application Name=AppFlowy Icon=io.appflowy.AppFlowy -Exec=env GDK_GL=gles AppFlowy %U +Exec=AppFlowy %U Categories=Network;Productivity; Keywords=Notes DBusActivatable=true diff --git a/frontend/scripts/flatpack-buildfiles/launcher.sh b/frontend/scripts/flatpack-buildfiles/launcher.sh index c7e7b9ee4a..24b4fdbea4 100644 --- a/frontend/scripts/flatpack-buildfiles/launcher.sh +++ b/frontend/scripts/flatpack-buildfiles/launcher.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash gdbus call --session --dest io.appflowy.AppFlowy \ --object-path /io/appflowy/AppFlowy/Object \ --method io.appflowy.AppFlowy.Open "['$1']" {} diff --git a/frontend/scripts/install_dev_env/install_ios.sh b/frontend/scripts/install_dev_env/install_ios.sh index 0ce5cfb5d4..1c31696f39 100644 --- a/frontend/scripts/install_dev_env/install_ios.sh +++ b/frontend/scripts/install_dev_env/install_ios.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash YELLOW="\e[93m" GREEN="\e[32m" @@ -44,9 +44,9 @@ printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oE 'Flutter [^ ]+' | grep -oE '[^ ]+$') -# Check if the current version is 3.19.0 -if [ "$FLUTTER_VERSION" = "3.19.0" ]; then - echo "Flutter version is already 3.19.0" +# Check if the current version is 3.27.4 +if [ "$FLUTTER_VERSION" = "3.27.4" ]; then + echo "Flutter version is already 3.27.4" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -55,12 +55,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.19.0 of Flutter - git checkout 3.19.0 + # Use git to checkout version 3.27.4 of Flutter + git checkout 3.27.4 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.19.0" + echo "Switched to Flutter version 3.27.4" fi # Enable linux desktop @@ -85,11 +85,15 @@ cd frontend || exit 1 # Install cargo make printMessage "Installing cargo-make." -cargo install --force cargo-make +cargo install --force --locked cargo-make # Install duckscript printMessage "Installing duckscript." -cargo install --force duckscript_cli +cargo install --force --locked duckscript_cli + +# Install cargo-lipo +printMessage "Installing cargo-lipo." +cargo install --force --locked cargo-lipo # Check prerequisites printMessage "Checking prerequisites." diff --git a/frontend/scripts/install_dev_env/install_linux.sh b/frontend/scripts/install_dev_env/install_linux.sh index d1f85445a2..57db01a73d 100755 --- a/frontend/scripts/install_dev_env/install_linux.sh +++ b/frontend/scripts/install_dev_env/install_linux.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash YELLOW="\e[93m" GREEN="\e[32m" @@ -38,9 +38,9 @@ fi printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oP 'Flutter \K\S+') -# Check if the current version is 3.19.0 -if [ "$FLUTTER_VERSION" = "3.19.0" ]; then - echo "Flutter version is already 3.19.0" +# Check if the current version is 3.27.4 +if [ "$FLUTTER_VERSION" = "3.27.4" ]; then + echo "Flutter version is already 3.27.4" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -49,12 +49,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.19.0 of Flutter - git checkout 3.19.0 + # Use git to checkout version 3.27.4 of Flutter + git checkout 3.27.4 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.19.0" + echo "Switched to Flutter version 3.27.4" fi # Enable linux desktop @@ -78,7 +78,17 @@ if command apt-get &>/dev/null; then elif command dnf &>/dev/null; then sudo dnf install libnotify-dev else - echo 'Your system is not supported, please install libnotify manually.' + echo 'Your system is not supported, please install libnotify-dev manually.' +fi + +# For Video Block support +printMessage "Installing libmpv-dev mpv" +if command apt-get &>/dev/null; then + sudo apt-get install libmpv-dev mpv +elif command dnf &>/dev/null; then + sudo dnf install libmpv-dev mpv +else + echo 'Your system is not supported, please install libmpv-dev mpv manually.' fi # Add the githooks directory to your git configuration @@ -101,7 +111,7 @@ cargo install --force cargo-make # Install duckscript printMessage "Installing duckscript." -cargo install --force duckscript_cli +cargo install --force --locked duckscript_cli # Check prerequisites printMessage "Checking prerequisites." diff --git a/frontend/scripts/install_dev_env/install_macos.sh b/frontend/scripts/install_dev_env/install_macos.sh index 10f894e13c..463071e2af 100755 --- a/frontend/scripts/install_dev_env/install_macos.sh +++ b/frontend/scripts/install_dev_env/install_macos.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash YELLOW="\e[93m" GREEN="\e[32m" @@ -41,9 +41,9 @@ printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oE 'Flutter [^ ]+' | grep -oE '[^ ]+$') -# Check if the current version is 3.19.0 -if [ "$FLUTTER_VERSION" = "3.19.0" ]; then - echo "Flutter version is already 3.19.0" +# Check if the current version is 3.27.4 +if [ "$FLUTTER_VERSION" = "3.27.4" ]; then + echo "Flutter version is already 3.27.4" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -52,12 +52,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.19.0 of Flutter - git checkout 3.19.0 + # Use git to checkout version 3.27.4 of Flutter + git checkout 3.27.4 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.19.0" + echo "Switched to Flutter version 3.27.4" fi # Enable linux desktop @@ -86,8 +86,8 @@ cargo install --force cargo-make # Install duckscript printMessage "Installing duckscript." -cargo install --force duckscript_cli +cargo install --force --locked duckscript_cli # Check prerequisites printMessage "Checking prerequisites." -cargo make appflowy-flutter-deps-tools \ No newline at end of file +cargo make appflowy-flutter-deps-tools diff --git a/frontend/scripts/install_dev_env/install_windows.sh b/frontend/scripts/install_dev_env/install_windows.sh index aef80844a0..3cceec5bb0 100644 --- a/frontend/scripts/install_dev_env/install_windows.sh +++ b/frontend/scripts/install_dev_env/install_windows.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash YELLOW="\e[93m" GREEN="\e[32m" @@ -48,9 +48,9 @@ fi printMessage "Setting up Flutter" # Get the current Flutter version FLUTTER_VERSION=$(flutter --version | grep -oP 'Flutter \K\S+') -# Check if the current version is 3.19.0 -if [ "$FLUTTER_VERSION" = "3.19.0" ]; then - echo "Flutter version is already 3.19.0" +# Check if the current version is 3.27.4 +if [ "$FLUTTER_VERSION" = "3.27.4" ]; then + echo "Flutter version is already 3.27.4" else # Get the path to the Flutter SDK FLUTTER_PATH=$(which flutter) @@ -59,12 +59,12 @@ else current_dir=$(pwd) cd $FLUTTER_PATH - # Use git to checkout version 3.19.0 of Flutter - git checkout 3.19.0 + # Use git to checkout version 3.27.4 of Flutter + git checkout 3.27.4 # Get back to current working directory cd "$current_dir" - echo "Switched to Flutter version 3.19.0" + echo "Switched to Flutter version 3.27.4" fi # Add pub cache and cargo to PATH @@ -100,7 +100,7 @@ $USERPROFILE/.cargo/bin/cargo install --force cargo-make # Install duckscript printMessage "Installing duckscript." -$USERPROFILE/.cargo/bin/cargo install --force duckscript_cli +$USERPROFILE/.cargo/bin/cargo install --force --locked duckscript_cli # Enable vcpkg integration # Note: Requires admin diff --git a/frontend/scripts/linux_distribution/appimage/AppImageBuilder.yml b/frontend/scripts/linux_distribution/appimage/AppImageBuilder.yml index c205814401..cd8103df9e 100644 --- a/frontend/scripts/linux_distribution/appimage/AppImageBuilder.yml +++ b/frontend/scripts/linux_distribution/appimage/AppImageBuilder.yml @@ -19,47 +19,54 @@ AppDir: exec_args: $@ apt: arch: - - amd64 + - amd64 allow_unauthenticated: true sources: - - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy main restricted - - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy-updates main restricted - - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy universe - - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy-updates universe - - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy multiverse - - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy-updates multiverse - - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy-backports main restricted - universe multiverse - - sourceline: deb http://security.ubuntu.com/ubuntu jammy-security main restricted - - sourceline: deb http://security.ubuntu.com/ubuntu jammy-security universe - - sourceline: deb http://security.ubuntu.com/ubuntu jammy-security multiverse - - sourceline: deb https://ppa.launchpadcontent.net/touchegg/stable/ubuntu/ jammy - main - - sourceline: deb https://packagecloud.io/slacktechnologies/slack/debian/ jessie - main - - sourceline: deb [arch=amd64 signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] - https://cli.github.com/packages stable main - - sourceline: deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_16.x - jammy main - - sourceline: deb [arch=amd64,arm64,armhf] http://packages.microsoft.com/repos/code - stable main - - sourceline: deb [arch=amd64] https://dl.google.com/linux/chrome/deb/ stable - main + - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy main restricted + - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy-updates main restricted + - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy universe + - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy-updates universe + - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy multiverse + - sourceline: deb http://id.archive.ubuntu.com/ubuntu/ jammy-updates multiverse + - sourceline: + deb http://id.archive.ubuntu.com/ubuntu/ jammy-backports main restricted + universe multiverse + - sourceline: deb http://security.ubuntu.com/ubuntu jammy-security main restricted + - sourceline: deb http://security.ubuntu.com/ubuntu jammy-security universe + - sourceline: deb http://security.ubuntu.com/ubuntu jammy-security multiverse + - sourceline: + deb https://ppa.launchpadcontent.net/touchegg/stable/ubuntu/ jammy + main + - sourceline: + deb https://packagecloud.io/slacktechnologies/slack/debian/ jessie + main + - sourceline: + deb [arch=amd64 signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] + https://cli.github.com/packages stable main + - sourceline: + deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_16.x + jammy main + - sourceline: + deb [arch=amd64,arm64,armhf] http://packages.microsoft.com/repos/code + stable main + - sourceline: + deb [arch=amd64] https://dl.google.com/linux/chrome/deb/ stable + main include: - - libc6:amd64 - - libnotify4:amd64 - - libkeybinder-3.0-0:amd64 - - libwayland-cursor0:amd64 - - libwayland-client0:amd64 - - libwayland-egl1:amd64 + - libc6:amd64 + - libnotify4:amd64 + - libkeybinder-3.0-0:amd64 + - libwayland-cursor0:amd64 + - libwayland-client0:amd64 + - libwayland-egl1:amd64 files: include: [] exclude: - - usr/share/man - - usr/share/doc/*/README.* - - usr/share/doc/*/changelog.* - - usr/share/doc/*/NEWS.* - - usr/share/doc/*/TODO.* + - usr/share/man + - usr/share/doc/*/README.* + - usr/share/doc/*/changelog.* + - usr/share/doc/*/NEWS.* + - usr/share/doc/*/TODO.* test: fedora-30: image: appimagecrafters/tests-env:fedora-30 @@ -78,4 +85,4 @@ AppDir: command: ./AppRun AppImage: arch: x86_64 - update-information: guess \ No newline at end of file + update-information: guess diff --git a/frontend/scripts/linux_distribution/appimage/build_appimage.sh b/frontend/scripts/linux_distribution/appimage/build_appimage.sh index a7e4d1b11b..73deb45edd 100644 --- a/frontend/scripts/linux_distribution/appimage/build_appimage.sh +++ b/frontend/scripts/linux_distribution/appimage/build_appimage.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash VERSION=$1 diff --git a/frontend/scripts/linux_distribution/deb/AppFlowy.desktop b/frontend/scripts/linux_distribution/deb/AppFlowy.desktop index 0a57970027..e6851f9f42 100644 --- a/frontend/scripts/linux_distribution/deb/AppFlowy.desktop +++ b/frontend/scripts/linux_distribution/deb/AppFlowy.desktop @@ -2,7 +2,7 @@ Type=Application Name=AppFlowy Icon=/usr/share/icons/hicolor/scalable/apps/appflowy.svg -Exec=env GDK_GL=gles /usr/bin/AppFlowy %U +Exec=/usr/bin/AppFlowy %U Categories=Network;Productivity; Keywords=Notes Terminal=false diff --git a/frontend/scripts/linux_distribution/deb/DEBIAN/postinst b/frontend/scripts/linux_distribution/deb/DEBIAN/postinst index 56186649d4..bf2f79fa97 100755 --- a/frontend/scripts/linux_distribution/deb/DEBIAN/postinst +++ b/frontend/scripts/linux_distribution/deb/DEBIAN/postinst @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [ -e /usr/bin/AppFlowy ]; then echo "Symlink already exists, skipping." else diff --git a/frontend/scripts/linux_distribution/deb/DEBIAN/postrm b/frontend/scripts/linux_distribution/deb/DEBIAN/postrm index f815d1bb5c..59a680e767 100755 --- a/frontend/scripts/linux_distribution/deb/DEBIAN/postrm +++ b/frontend/scripts/linux_distribution/deb/DEBIAN/postrm @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [ -e /usr/bin/AppFlowy ]; then rm /usr/bin/AppFlowy rm /usr/bin/AppFlowyLauncher.sh diff --git a/frontend/scripts/linux_distribution/deb/build_deb.sh b/frontend/scripts/linux_distribution/deb/build_deb.sh index 35fe9dbbaf..42fbf7346d 100644 --- a/frontend/scripts/linux_distribution/deb/build_deb.sh +++ b/frontend/scripts/linux_distribution/deb/build_deb.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash LINUX_RELEASE_PRODUCTION=$1 VERSION=$2 diff --git a/frontend/scripts/linux_distribution/packaging/launcher.sh b/frontend/scripts/linux_distribution/packaging/launcher.sh index c7e7b9ee4a..24b4fdbea4 100644 --- a/frontend/scripts/linux_distribution/packaging/launcher.sh +++ b/frontend/scripts/linux_distribution/packaging/launcher.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash gdbus call --session --dest io.appflowy.AppFlowy \ --object-path /io/appflowy/AppFlowy/Object \ --method io.appflowy.AppFlowy.Open "['$1']" {} diff --git a/frontend/scripts/linux_installer/postinst b/frontend/scripts/linux_installer/postinst index 4f495f86a2..83e1a1043e 100644 --- a/frontend/scripts/linux_installer/postinst +++ b/frontend/scripts/linux_installer/postinst @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [ -e /usr/local/bin/AppFlowy ]; then echo "Symlink already exists, skipping." else diff --git a/frontend/scripts/linux_installer/postrm b/frontend/scripts/linux_installer/postrm index 53304b1b48..7927bc56e5 100644 --- a/frontend/scripts/linux_installer/postrm +++ b/frontend/scripts/linux_installer/postrm @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [ -e /usr/local/bin/appflowy ]; then rm /usr/local/bin/appflowy -fi \ No newline at end of file +fi diff --git a/frontend/scripts/makefile/desktop.toml b/frontend/scripts/makefile/desktop.toml index f1f0aa0219..f972aa5cd4 100644 --- a/frontend/scripts/makefile/desktop.toml +++ b/frontend/scripts/makefile/desktop.toml @@ -1,4 +1,3 @@ - [tasks.env_check] dependencies = ["echo_env", "install_flutter_protobuf"] condition = { env_set = [ @@ -100,7 +99,7 @@ dependencies = ["set-app-version"] script = [ """ cd rust-lib/ - cargo build --profile ${CARGO_PROFILE} --${BUILD_FLAG} --package=dart-ffi --target ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}" + cargo build --profile ${CARGO_PROFILE} --package=dart-ffi --target ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}" cd ../ """, ] @@ -111,7 +110,7 @@ dependencies = ["set-app-version"] script = [ """ cd rust-lib/ - cargo build --profile ${CARGO_PROFILE} --${BUILD_FLAG} --package=dart-ffi --target ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}" + cargo build --profile ${CARGO_PROFILE} --package=dart-ffi --target ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}" cd ../ """, ] diff --git a/frontend/scripts/makefile/env.toml b/frontend/scripts/makefile/env.toml index 30de7e138b..96a632f8ab 100644 --- a/frontend/scripts/makefile/env.toml +++ b/frontend/scripts/makefile/env.toml @@ -1,17 +1,11 @@ [tasks.appflowy-flutter-deps-tools] run_task = { name = ["install_flutter_prerequests"] } -[tasks.appflowy-tauri-deps-tools] -run_task = { name = ["install_tauri_prerequests"] } - [tasks.appflowy-flutter-dev-tools] -run_task = { name = ["appflowy-flutter-deps-tools","install_diesel"] } - -[tasks.appflowy-tauri-dev-tools] -run_task = { name = ["appflowy-tauri-deps-tools","install_diesel"] } +run_task = { name = ["appflowy-flutter-deps-tools", "install_diesel"] } [tasks.install_windows_deps.windows] -dependencies=["check_duckscript_installation", "check_vcpkg", "install_vcpkg_sqlite", "install_rust_vcpkg_cli"] +dependencies = ["check_duckscript_installation", "check_vcpkg", "install_vcpkg_sqlite", "install_rust_vcpkg_cli"] [tasks.check_visual_studio_installation.windows] script = """ @@ -37,7 +31,7 @@ script = """ @echo off @duck -h > nul if %errorlevel% GTR 0 ( - echo Please install duckscript at first: cargo install --force duckscript_cli + echo Please install duckscript at first: cargo install --force --locked duckscript_cli exit -1 ) """ @@ -100,14 +94,11 @@ script = """ rustup target add x86_64-unknown-linux-gnu """ -[tasks.install_tauri_prerequests] -dependencies=["install_targets", "install_web_protobuf"] - [tasks.install_flutter_prerequests] -dependencies=["install_targets", "install_flutter_protobuf"] +dependencies = ["install_targets", "install_flutter_protobuf"] [tasks.install_flutter_prerequests.windows] -dependencies=["install_targets", "install_windows_deps"] +dependencies = ["install_targets", "install_windows_deps"] [tasks.install_tools] script = """ @@ -148,7 +139,7 @@ script_runner = "@duckscript" [tasks.enable_git_hook] -dependencies=["download_gitlint"] +dependencies = ["download_gitlint"] script = """ git config core.hooksPath .githooks """ diff --git a/frontend/scripts/makefile/flutter.toml b/frontend/scripts/makefile/flutter.toml index 4203ce678d..e62acac3f6 100644 --- a/frontend/scripts/makefile/flutter.toml +++ b/frontend/scripts/makefile/flutter.toml @@ -301,7 +301,7 @@ script = [""" [tasks.code_generation] script_runner = "@shell" script = [""" - sh scripts/code_generation/generate.sh + ./scripts/code_generation/generate.sh """] [tasks.code_generation.windows] diff --git a/frontend/scripts/makefile/mobile.toml b/frontend/scripts/makefile/mobile.toml index 8e89e4c2ed..56329a2936 100644 --- a/frontend/scripts/makefile/mobile.toml +++ b/frontend/scripts/makefile/mobile.toml @@ -26,7 +26,6 @@ private = true script = [ """ cd rust-lib/ - rustup show if [ "${BUILD_FLAG}" = "debug" ]; then echo "🚀 🚀 🚀 Building iOS SDK for debug" cargo lipo --targets ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi @@ -68,10 +67,10 @@ script = [ cd rust-lib/ if [ "${BUILD_FLAG}" = "debug" ]; then echo "🚀 🚀 🚀 Building Android SDK for debug" - cargo ndk -t arm64-v8a -t armeabi-v7a -o ./jniLibs build --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi + cargo ndk -t arm64-v8a -o ./jniLibs build --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi else echo "🚀 🚀 🚀 Building Android SDK for release" - cargo ndk -t arm64-v8a -t armeabi-v7a -o ./jniLibs build --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi --release + cargo ndk -t arm64-v8a -o ./jniLibs build --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi --release fi cd ../ """, @@ -99,9 +98,6 @@ script = [ dart_ffi_dir= set ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/appflowy_flutter/packages/appflowy_backend/${TARGET_OS} lib = set lib${LIB_NAME}.${LIB_EXT} - ls -a ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/target/${RUST_COMPILE_TARGET}/${BUILD_FLAG} - - echo "💻 💻 💻 Copying ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/target/${RUST_COMPILE_TARGET}/${BUILD_FLAG}/${lib} to ${dart_ffi_dir}/${lib}" rm -f ${dart_ffi_dir}/${lib} cp ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/target/${RUST_COMPILE_TARGET}/${BUILD_FLAG}/${lib} \ diff --git a/frontend/scripts/makefile/tauri.toml b/frontend/scripts/makefile/tauri.toml index 4fe6b19fd7..549c7e250e 100644 --- a/frontend/scripts/makefile/tauri.toml +++ b/frontend/scripts/makefile/tauri.toml @@ -1,10 +1,3 @@ -[tasks.tauri_build] -description = "Build the Tauri backend" -script = [""" - cd appflowy_tauri/src-tauri - cargo build - """] -script_runner = "@shell" [tasks.tauri_dev] env = { RUST_LOG = "debug" } diff --git a/frontend/scripts/makefile/web.toml b/frontend/scripts/makefile/web.toml index 40162c9e6c..52c09ea6bb 100644 --- a/frontend/scripts/makefile/web.toml +++ b/frontend/scripts/makefile/web.toml @@ -1,43 +1,33 @@ - [tasks.wasm_build] script_runner = "bash" script = [ - """ - #!/bin/bash - BASE_DIR=$(pwd) - crates=("lib-dispatch" "flowy-encrypt" "lib-infra" "flowy-notification" "flowy-date" "flowy-error" "collab-integrate" "flowy-document") + """ + #!/usr/bin/env bash + BASE_DIR=$(pwd) + crates=("lib-dispatch" "lib-infra" "flowy-notification" "flowy-date" "flowy-error" "collab-integrate" "flowy-document") - # Iterate over each crate and build it - for crate in "${crates[@]}"; do - echo "🔥🔥🔥 Building $crate with wasm-pack..." - cd "$BASE_DIR/rust-lib/$crate" || { echo "Failed to enter directory $crate"; exit 1; } + # Iterate over each crate and build it + for crate in "${crates[@]}"; do + echo "🔥🔥🔥 Building $crate with wasm-pack..." + cd "$BASE_DIR/rust-lib/$crate" || { echo "Failed to enter directory $crate"; exit 1; } - wasm-pack build || { echo "Build failed for $crate"; exit 1; } - done - """ + wasm-pack build || { echo "Build failed for $crate"; exit 1; } + done + """ ] [tasks.web_clean] description = "Remove all the building artifacts" run_task = { name = [ - "rust_lib_clean", - "rm_macro_build_cache", - "rm_rust_generated_files", - "rm_web_generated_protobuf_files", - "rm_web_generated_event_files", - "rm_pkg", + "rust_lib_clean", + "rm_macro_build_cache", + "rm_rust_generated_files", + "rm_web_generated_protobuf_files", + "rm_web_generated_event_files", + "rm_pkg", ] } - -[tasks.rm_pkg] -private = true -script = [""" -cd ${WEB_LIB_PATH} -rimraf dist pkg -"""] -script_runner = "@duckscript" - [tasks.rm_web_generated_protobuf_files] private = true script = [""" @@ -66,4 +56,4 @@ script = [""" end end """] -script_runner = "@duckscript" \ No newline at end of file +script_runner = "@duckscript" diff --git a/frontend/scripts/tool/update_client_api_rev.sh b/frontend/scripts/tool/update_client_api_rev.sh index 1af8987922..9146e6e2d8 100755 --- a/frontend/scripts/tool/update_client_api_rev.sh +++ b/frontend/scripts/tool/update_client_api_rev.sh @@ -8,7 +8,7 @@ fi NEW_REV="$1" echo "New revision: $NEW_REV" -directories=("rust-lib" "appflowy_tauri/src-tauri" "appflowy_web/wasm-libs" "appflowy_web_app/src-tauri") +directories=("rust-lib") for dir in "${directories[@]}"; do echo "Updating $dir" diff --git a/frontend/scripts/tool/update_collab_rev.sh b/frontend/scripts/tool/update_collab_rev.sh index 26076df248..aa228a0eef 100755 --- a/frontend/scripts/tool/update_collab_rev.sh +++ b/frontend/scripts/tool/update_collab_rev.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Ensure a new revision ID is provided if [ "$#" -ne 1 ]; then @@ -8,7 +8,7 @@ fi NEW_REV="$1" echo "New revision: $NEW_REV" -directories=("rust-lib" "appflowy_tauri/src-tauri" "appflowy_web/wasm-libs" "appflowy_web_app/src-tauri") +directories=("rust-lib") for dir in "${directories[@]}"; do echo "Updating $dir" @@ -28,7 +28,7 @@ for dir in "${directories[@]}"; do # Update all the specified crates at once echo "Updating crates: $crates_to_update" - cargo update $crates_to_update + cargo update $crates_to_update 2> /dev/null popd > /dev/null done diff --git a/frontend/scripts/tool/update_collab_source.sh b/frontend/scripts/tool/update_collab_source.sh index 29892de1de..697d293e31 100755 --- a/frontend/scripts/tool/update_collab_source.sh +++ b/frontend/scripts/tool/update_collab_source.sh @@ -1,13 +1,10 @@ -#!/bin/bash +#!/usr/bin/env bash # Paths to your Cargo.toml files REPO_PATH="./AppFlowy-Collab" CARGO_TOML_1="./rust-lib/Cargo.toml" REPO_RELATIVE_PATH_1="../AppFlowy-Collab" -CARGO_TOML_2="./appflowy_tauri/src-tauri/Cargo.toml" -REPO_RELATIVE_PATH_2="../../AppFlowy-Collab" - # Function to switch dependencies in a given Cargo.toml switch_deps() { local cargo_toml="$1" @@ -15,9 +12,9 @@ switch_deps() { if grep -q 'git = "https://github.com/AppFlowy-IO/AppFlowy-Collab"' "$cargo_toml"; then cp "$cargo_toml" "$cargo_toml.bak" # Switch to local paths - for crate in collab collab-folder collab-document collab-database collab-plugins collab-user collab-entity collab-sync-protocol collab-persistence; do + for crate in collab collab-folder collab-document collab-database collab-plugins collab-user collab-entity collab-sync-protocol collab-persistence collab-importer; do sed -i '' \ - -e "s#${crate} = { git = \"https://github.com/AppFlowy-IO/AppFlowy-Collab\", rev = \"[a-f0-9]*\" }#${crate} = { path = \"$repo_path/$crate\" }#g" \ + -e "s#${crate} = { .*git = \"https://github.com/AppFlowy-IO/AppFlowy-Collab\".* }#${crate} = { path = \"$repo_path/$crate\" }#g" \ "$cargo_toml" done echo "Switched to local paths in $cargo_toml." @@ -38,4 +35,3 @@ fi # Switch dependencies in both Cargo.toml files switch_deps "$CARGO_TOML_1" "$REPO_RELATIVE_PATH_1" -switch_deps "$CARGO_TOML_2" "$REPO_RELATIVE_PATH_2" diff --git a/frontend/scripts/tool/update_local_ai_rev.sh b/frontend/scripts/tool/update_local_ai_rev.sh new file mode 100755 index 0000000000..af24e0ba9f --- /dev/null +++ b/frontend/scripts/tool/update_local_ai_rev.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Ensure a new revision ID is provided +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +NEW_REV="$1" +echo "New revision: $NEW_REV" +directories=("rust-lib") + +for dir in "${directories[@]}"; do + echo "Updating $dir" + pushd "$dir" > /dev/null + + # Define the crates to update + crates=("af-local-ai" "af-plugin" "af-mcp") + + for crate in "${crates[@]}"; do + sed -i.bak "/^${crate}[[:alnum:]-]*[[:space:]]*=/s/rev = \"[a-fA-F0-9]\{6,40\}\"/rev = \"$NEW_REV\"/g" Cargo.toml + done + + # Construct the crates_to_update variable + crates_to_update="" + for crate in "${crates[@]}"; do + crates_to_update="$crates_to_update -p $crate" + done + + # Update all the specified crates at once + if [ -n "$crates_to_update" ]; then + echo "Updating crates: $crates_to_update" + cargo update $crates_to_update 2> /dev/null + fi + + popd > /dev/null +done diff --git a/frontend/scripts/white_label/code_white_label.sh b/frontend/scripts/white_label/code_white_label.sh new file mode 100644 index 0000000000..1123a394ee --- /dev/null +++ b/frontend/scripts/white_label/code_white_label.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --company-name Set the custom company name" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --company-name \"MyCompany Ltd.\"" +} + +CUSTOM_COMPANY_NAME="" +CODE_FILE="appflowy_flutter/lib/workspace/application/notification/notification_service.dart" + +while [[ $# -gt 0 ]]; do + case $1 in + --company-name) + CUSTOM_COMPANY_NAME="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$CUSTOM_COMPANY_NAME" ]; then + echo "Error: Company name is required" + show_usage + exit 1 +fi + +if [ ! -f "$CODE_FILE" ]; then + echo "Error: Code file not found at $CODE_FILE" + exit 1 +fi + +echo "Replacing '_localNotifierAppName' value with '$CUSTOM_COMPANY_NAME' in code file..." + +if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" +else + SED_INPLACE="-i ''" +fi + +echo "Processing code file..." +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # First, escape any special characters in the company name + ESCAPED_COMPANY_NAME=$(echo "$CUSTOM_COMPANY_NAME" | sed 's/[\/&]/\\&/g') + # Replace the _localNotifierAppName value with the custom company name + sed $SED_INPLACE "s/const _localNotifierAppName = 'AppFlowy'/const _localNotifierAppName = '$ESCAPED_COMPANY_NAME'/" "$CODE_FILE" + if [ $? -ne 0 ]; then + echo "Error: Failed to process $CODE_FILE with sed" + exit 1 + fi +else + # For Unix-like systems + sed $SED_INPLACE "s/const _localNotifierAppName = 'AppFlowy'/const _localNotifierAppName = '$CUSTOM_COMPANY_NAME'/" "$CODE_FILE" + if [ $? -ne 0 ]; then + echo "Error: Failed to process $CODE_FILE with sed" + exit 1 + fi +fi + +echo "Replacement complete!" diff --git a/frontend/scripts/white_label/font_white_label.sh b/frontend/scripts/white_label/font_white_label.sh new file mode 100644 index 0000000000..412ee6b062 --- /dev/null +++ b/frontend/scripts/white_label/font_white_label.sh @@ -0,0 +1,198 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --font-path Set the path to the folder containing font files (.ttf or .otf files)" + echo " --font-family Set the name of the font family" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --font-path \"/path/to/fonts\" --font-family \"CustomFont\"" +} + +FONT_PATH="" +FONT_FAMILY="" +TARGET_FONT_DIR="appflowy_flutter/assets/fonts/" +PUBSPEC_FILE="appflowy_flutter/pubspec.yaml" +BASE_APPEARANCE_FILE="appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart" + +while [[ $# -gt 0 ]]; do + case $1 in + --font-path) + FONT_PATH="$2" + shift 2 + ;; + --font-family) + FONT_FAMILY="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +# Validate required arguments +if [ -z "$FONT_PATH" ]; then + echo "Error: Font path is required" + show_usage + exit 1 +fi + +if [ -z "$FONT_FAMILY" ]; then + echo "Error: Font family name is required" + show_usage + exit 1 +fi + +# Check if source directory exists +if [ ! -d "$FONT_PATH" ]; then + echo "Error: Font directory not found at $FONT_PATH" + exit 1 +fi + +# Create target directory if it doesn't exist +mkdir -p "$TARGET_FONT_DIR" + +# Clean existing fonts in target directory +echo "Cleaning existing fonts in $TARGET_FONT_DIR..." +rm -rf "$TARGET_FONT_DIR"/* + +# Copy font files to target directory +echo "Copying font files from $FONT_PATH to $TARGET_FONT_DIR..." +found_fonts=false +for ext in ttf otf; do + if ls "$FONT_PATH"/*."$ext" >/dev/null 2>&1; then + cp "$FONT_PATH"/*."$ext" "$TARGET_FONT_DIR"/ 2>/dev/null && found_fonts=true + fi +done + +if [ "$found_fonts" = false ]; then + echo "Error: No font files (.ttf or .otf) found in source directory" + exit 1 +fi + +# Generate font configuration for pubspec.yaml +echo "Generating font configuration..." + +# Create temporary file for font configuration +TEMP_FILE=$(mktemp) + +{ + echo " # BEGIN: WHITE_LABEL_FONT" + echo " - family: $FONT_FAMILY" + echo " fonts:" + + # Generate entries for each font file + for font_file in "$TARGET_FONT_DIR"/*; do + filename=$(basename "$font_file") + echo " - asset: assets/fonts/$filename" + + # Try to detect font weight from filename + if [[ $filename =~ (Thin|ExtraLight|Light|Regular|Medium|SemiBold|Bold|ExtraBold|Black) ]]; then + case ${BASH_REMATCH[1]} in + "Thin") echo " weight: 100";; + "ExtraLight") echo " weight: 200";; + "Light") echo " weight: 300";; + "Regular") echo " weight: 400";; + "Medium") echo " weight: 500";; + "SemiBold") echo " weight: 600";; + "Bold") echo " weight: 700";; + "ExtraBold") echo " weight: 800";; + "Black") echo " weight: 900";; + esac + fi + + # Try to detect italic style from filename + if [[ $filename =~ Italic ]]; then + echo " style: italic" + fi + done + echo " # END: WHITE_LABEL_FONT" +} > "$TEMP_FILE" + +# Update pubspec.yaml +echo "Updating pubspec.yaml..." +if [ -f "$PUBSPEC_FILE" ]; then + # Create a backup of the original file + cp "$PUBSPEC_FILE" "${PUBSPEC_FILE}.bak" + + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # Windows-specific handling + # First, remove existing white label font configuration + awk '/# BEGIN: WHITE_LABEL_FONT/,/# END: WHITE_LABEL_FONT/{ next } /# White-label font configuration will be added here/{ print; system("cat '"$TEMP_FILE"'"); next } 1' "$PUBSPEC_FILE" > "${PUBSPEC_FILE}.tmp" + + if [ $? -eq 0 ]; then + mv "${PUBSPEC_FILE}.tmp" "$PUBSPEC_FILE" + rm -f "${PUBSPEC_FILE}.bak" + else + echo "Error: Failed to update pubspec.yaml" + mv "${PUBSPEC_FILE}.bak" "$PUBSPEC_FILE" + rm -f "${PUBSPEC_FILE}.tmp" + rm -f "$TEMP_FILE" + exit 1 + fi + else + # Unix-like systems handling + if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" + else + SED_INPLACE="-i ''" + fi + + # Remove existing white label font configuration + sed $SED_INPLACE '/# BEGIN: WHITE_LABEL_FONT/,/# END: WHITE_LABEL_FONT/d' "$PUBSPEC_FILE" + + # Add new font configuration + sed $SED_INPLACE "/# White-label font configuration will be added here/r $TEMP_FILE" "$PUBSPEC_FILE" + + if [ $? -ne 0 ]; then + echo "Error: Failed to update pubspec.yaml" + mv "${PUBSPEC_FILE}.bak" "$PUBSPEC_FILE" + rm -f "$TEMP_FILE" + exit 1 + fi + rm -f "${PUBSPEC_FILE}.bak" + fi +else + echo "Error: pubspec.yaml not found at $PUBSPEC_FILE" + rm -f "$TEMP_FILE" + exit 1 +fi + +# Update base_appearance.dart +echo "Updating base_appearance.dart..." +if [ -f "$BASE_APPEARANCE_FILE" ]; then + # Create a backup of the original file + cp "$BASE_APPEARANCE_FILE" "${BASE_APPEARANCE_FILE}.bak" + + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # Windows-specific handling + sed -i "s/const defaultFontFamily = '.*'/const defaultFontFamily = '$FONT_FAMILY'/" "$BASE_APPEARANCE_FILE" + else + # Unix-like systems handling + sed -i '' "s/const defaultFontFamily = '.*'/const defaultFontFamily = '$FONT_FAMILY'/" "$BASE_APPEARANCE_FILE" + fi + + if [ $? -ne 0 ]; then + echo "Error: Failed to update base_appearance.dart" + mv "${BASE_APPEARANCE_FILE}.bak" "$BASE_APPEARANCE_FILE" + exit 1 + fi + rm -f "${BASE_APPEARANCE_FILE}.bak" +else + echo "Error: base_appearance.dart not found at $BASE_APPEARANCE_FILE" + exit 1 +fi + +# Cleanup +rm -f "$TEMP_FILE" + +echo "Font white labeling completed successfully!" diff --git a/frontend/scripts/white_label/i18n_white_label.sh b/frontend/scripts/white_label/i18n_white_label.sh new file mode 100644 index 0000000000..60152d1630 --- /dev/null +++ b/frontend/scripts/white_label/i18n_white_label.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --company-name Set the custom company name" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --company-name \"MyCompany Ltd.\"" +} + +CUSTOM_COMPANY_NAME="" +I18N_DIR="resources/translations" + +while [[ $# -gt 0 ]]; do + case $1 in + --company-name) + CUSTOM_COMPANY_NAME="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$CUSTOM_COMPANY_NAME" ]; then + echo "Error: Company name is required" + show_usage + exit 1 +fi + +if [ ! -d "$I18N_DIR" ]; then + echo "Error: Translation directory not found at $I18N_DIR" + exit 1 +fi + +echo "Replacing 'AppFlowy' with '$CUSTOM_COMPANY_NAME' in translation files..." + +if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" +else + SED_INPLACE="-i ''" +fi + +echo "Processing translation files..." +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + # Check if directory exists and has JSON files + if [ ! -d "$I18N_DIR" ] || [ -z "$(ls -A "$I18N_DIR"/*.json 2>/dev/null)" ]; then + echo "Error: No JSON files found in $I18N_DIR directory" + exit 1 + fi + + # Process each JSON file in the directory + for file in "$I18N_DIR"/*.json; do + echo "Updating $(basename "$file")" + # Use jq to replace AppFlowy with custom company name in values only + if command -v jq >/dev/null 2>&1; then + # Create a temporary file for the transformation + jq --arg company "$CUSTOM_COMPANY_NAME" 'walk(if type == "string" then gsub("AppFlowy"; $company) else . end)' "$file" > "${file}.tmp" + # Check if transformation was successful + if [ $? -eq 0 ]; then + mv "${file}.tmp" "$file" + else + echo "Error: Failed to process $file with jq" + rm -f "${file}.tmp" + exit 1 + fi + else + # Fallback to sed if jq is not available + # First, escape any special characters in the company name + ESCAPED_COMPANY_NAME=$(echo "$CUSTOM_COMPANY_NAME" | sed 's/[\/&]/\\&/g') + # Replace AppFlowy with the custom company name in JSON values + sed $SED_INPLACE 's/\(".*"\): *"\(.*\)AppFlowy\(.*\)"/\1: "\2'"$ESCAPED_COMPANY_NAME"'\3"/g' "$file" + if [ $? -ne 0 ]; then + echo "Error: Failed to process $file with sed" + exit 1 + fi + fi + done +else + for file in $(find "$I18N_DIR" -name "*.json" -type f); do + echo "Updating $(basename "$file")" + # Use jq to only replace values, not keys + if command -v jq >/dev/null 2>&1; then + jq 'walk(if type == "string" then gsub("AppFlowy"; "'"$CUSTOM_COMPANY_NAME"'") else . end)' "$file" > "$file.tmp" && mv "$file.tmp" "$file" + else + # Fallback to sed with a more specific pattern that targets values but not keys + sed $SED_INPLACE 's/: *"[^"]*AppFlowy[^"]*"/: "&"/g; s/: *"&"/: "'"$CUSTOM_COMPANY_NAME"'"/g' "$file" + # Fix any double colons that might have been introduced + sed $SED_INPLACE 's/: *: */: /g' "$file" + fi + done +fi + +echo "Replacement complete!" diff --git a/frontend/scripts/white_label/icon_white_label.sh b/frontend/scripts/white_label/icon_white_label.sh new file mode 100644 index 0000000000..ca70bc1661 --- /dev/null +++ b/frontend/scripts/white_label/icon_white_label.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --icon-path Set the path to the folder containing application icons (.svg files)" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --icon-path \"/path/to/icons_folder\"" +} + +NEW_ICON_PATH="" +ICON_DIR="resources/flowy_icons" +ICON_NAME_NEED_REPLACE=("app_logo.svg" "ai_chat_logo.svg" "app_logo_with_text_light.svg" "app_logo_with_text_dark.svg") + +while [[ $# -gt 0 ]]; do + case $1 in + --icon-path) + NEW_ICON_PATH="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$NEW_ICON_PATH" ]; then + echo "Error: Icon path is required" + show_usage + exit 1 +fi + +if [ ! -d "$NEW_ICON_PATH" ]; then + echo "Error: New icon directory not found at $NEW_ICON_PATH" + exit 1 +fi + +if [ ! -d "$ICON_DIR" ]; then + echo "Error: Icon directory not found at $ICON_DIR" + exit 1 +fi + +echo "Replacing icons..." + +echo "Processing icon files..." +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + for subdir in "${ICON_DIR}"/*/; do + if [ -d "$subdir" ]; then + echo "Checking subdirectory: $(basename "$subdir")" + for file in "${subdir}"*.svg; do + if [ -f "$file" ] && [[ " ${ICON_NAME_NEED_REPLACE[@]} " =~ " $(basename "$file") " ]]; then + new_icon="${NEW_ICON_PATH}/$(basename "$file")" + if [ -f "$new_icon" ]; then + echo "Updating: $(basename "$subdir")/$(basename "$file")" + cp "$new_icon" "$file" + if [ $? -eq 0 ]; then + echo "Successfully replaced $(basename "$file") in $(basename "$subdir") with new icon" + else + echo "Error: Failed to replace $(basename "$file") in $(basename "$subdir")" + exit 1 + fi + else + echo "Warning: New icon file $(basename "$file") not found in $NEW_ICON_PATH" + fi + fi + done + fi + done +else + for file in $(find "$ICON_DIR" -name "*.svg" -type f); do + if [[ " ${ICON_NAME_NEED_REPLACE[@]} " =~ " $(basename "$file") " ]]; then + new_icon="${NEW_ICON_PATH}/$(basename "$file")" + if [ -f "$new_icon" ]; then + echo "Updating: $(basename "$file")" + cp "$new_icon" "$file" + if [ $? -eq 0 ]; then + echo "Successfully replaced $(basename "$file") with new icon" + else + echo "Error: Failed to replace $(basename "$file")" + exit 1 + fi + else + echo "Warning: New icon file $(basename "$file") not found in $NEW_ICON_PATH" + fi + fi + done +fi + +echo "Replacement complete!" diff --git a/frontend/scripts/white_label/resources/my_company_logo.ico b/frontend/scripts/white_label/resources/my_company_logo.ico new file mode 100644 index 0000000000..c922a6b36d Binary files /dev/null and b/frontend/scripts/white_label/resources/my_company_logo.ico differ diff --git a/frontend/scripts/white_label/resources/my_company_logo.png b/frontend/scripts/white_label/resources/my_company_logo.png new file mode 100644 index 0000000000..8f50872743 Binary files /dev/null and b/frontend/scripts/white_label/resources/my_company_logo.png differ diff --git a/frontend/scripts/white_label/resources/my_company_logo.svg b/frontend/scripts/white_label/resources/my_company_logo.svg new file mode 100644 index 0000000000..c06bf17cb4 --- /dev/null +++ b/frontend/scripts/white_label/resources/my_company_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/scripts/white_label/white_label.sh b/frontend/scripts/white_label/white_label.sh new file mode 100644 index 0000000000..8ecd187210 --- /dev/null +++ b/frontend/scripts/white_label/white_label.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +# Default values +APP_NAME="AppFlowy" +APP_IDENTIFIER="com.appflowy.appflowy" +COMPANY_NAME="AppFlowy Inc." +COPYRIGHT="Copyright © 2025 AppFlowy Inc." +ICON_PATH="" +WINDOWS_ICON_PATH="" +FONT_PATH="" +FONT_FAMILY="" +PLATFORMS=("windows" "linux" "macos" "ios" "android") + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --app-name Set the application name" + echo " --app-identifier Set the application identifier" + echo " --company-name Set the company name" + echo " --copyright Set the copyright information" + echo " --icon-path Set the path to the application icon (.svg)" + echo " --windows-icon-path Set the path to the windows application icon (.ico)" + echo " --font-path Set the path to the folder containing font files (.ttf or .otf files)" + echo " --font-family Set the name of the font family" + echo " --platforms Comma-separated list of platforms to white label (windows,linux,macos,ios,android)" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --app-name \"MyCompany\" --app-identifier \"com.mycompany.mycompany\" \\" + echo " --company-name \"MyCompany Ltd.\" --copyright \"Copyright © 2025 MyCompany Ltd.\" \\" + echo " --platforms \"windows,linux,macos\" \\" + echo " --windows-icon-path \"./assets/icons/mycompany.ico\" \\" + echo " --icon-path \"./assets/icons/\" \\" + echo " --font-path \"./assets/fonts/\" --font-family \"CustomFont\"" +} + +while [[ $# -gt 0 ]]; do + case $1 in + --app-name) + APP_NAME="$2" + shift 2 + ;; + --app-identifier) + APP_IDENTIFIER="$2" + shift 2 + ;; + --company-name) + COMPANY_NAME="$2" + shift 2 + ;; + --copyright) + COPYRIGHT="$2" + shift 2 + ;; + --icon-path) + ICON_PATH="$2" + shift 2 + ;; + --windows-icon-path) + WINDOWS_ICON_PATH="$2" + shift 2 + ;; + --font-path) + FONT_PATH="$2" + shift 2 + ;; + --font-family) + FONT_FAMILY="$2" + shift 2 + ;; + --platforms) + IFS=',' read -ra PLATFORMS <<< "$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$APP_NAME" ] || [ -z "$APP_IDENTIFIER" ] || [ -z "$COMPANY_NAME" ] || [ -z "$COPYRIGHT" ] || [ -z "$ICON_PATH" ]; then + echo "Error: All parameters are required" + show_usage + exit 1 +fi + +if [ ! -d "$ICON_PATH" ]; then + echo "Error: Icon directory not found at $ICON_PATH" + exit 1 +fi + +if [ ! -f "$WINDOWS_ICON_PATH" ]; then + echo "Error: Windows icon file not found at $WINDOWS_ICON_PATH" + exit 1 +fi + +run_platform_script() { + local platform=$1 + local script_path="scripts/white_label/${platform}_white_label.sh" + + if [ ! -f "$script_path" ]; then + echo -e "\033[31mWarning: White label script not found for platform: $platform\033[0m" + return + fi + + echo -e "\033[32mRunning white label script for $platform...\033[0m" + bash "$script_path" \ + --app-name "$APP_NAME" \ + --app-identifier "$APP_IDENTIFIER" \ + --company-name "$COMPANY_NAME" \ + --copyright "$COPYRIGHT" \ + --icon-path "$WINDOWS_ICON_PATH" +} + +echo -e "\033[32mRunning i18n white label script...\033[0m" +bash "scripts/white_label/i18n_white_label.sh" --company-name "$COMPANY_NAME" + +echo -e "\033[32mRunning icon white label script...\033[0m" +bash "scripts/white_label/icon_white_label.sh" --icon-path "$ICON_PATH" + +echo -e "\033[32mRunning code white label script...\033[0m" +bash "scripts/white_label/code_white_label.sh" --company-name "$COMPANY_NAME" + +# Run font white label script if font parameters are provided +if [ ! -z "$FONT_PATH" ] && [ ! -z "$FONT_FAMILY" ]; then + echo -e "\033[32mRunning font white label script...\033[0m" + bash "scripts/white_label/font_white_label.sh" \ + --font-path "$FONT_PATH" \ + --font-family "$FONT_FAMILY" +fi + +for platform in "${PLATFORMS[@]}"; do + run_platform_script "$platform" +done + +echo -e "\033[32mWhite labeling process completed successfully!\033[0m" diff --git a/frontend/scripts/white_label/windows_white_label.sh b/frontend/scripts/white_label/windows_white_label.sh new file mode 100644 index 0000000000..58801424ff --- /dev/null +++ b/frontend/scripts/white_label/windows_white_label.sh @@ -0,0 +1,151 @@ +#!/bin/bash + +APP_NAME="AppFlowy" +APP_IDENTIFIER="com.appflowy.appflowy" +COMPANY_NAME="AppFlowy Inc." +COPYRIGHT="Copyright © 2025 AppFlowy Inc." +ICON_PATH="" + +show_usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --app-name Set the application name" + echo " --app-identifier Set the application identifier" + echo " --company-name Set the company name" + echo " --copyright Set the copyright information" + echo " --icon-path Set the path to the application icon (.ico file)" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --app-name \"MyCompany\" --app-identifier \"com.mycompany.mycompany\" \\" + echo " --company-name \"MyCompany Ltd.\" --copyright \"Copyright © 2025 MyCompany Ltd.\" \\" + echo " --icon-path \"./assets/icons/company.ico\"" +} + +while [[ $# -gt 0 ]]; do + case $1 in + --app-name) + APP_NAME="$2" + shift 2 + ;; + --app-identifier) + APP_IDENTIFIER="$2" + shift 2 + ;; + --company-name) + COMPANY_NAME="$2" + shift 2 + ;; + --copyright) + COPYRIGHT="$2" + shift 2 + ;; + --icon-path) + ICON_PATH="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac +done + +if [ -z "$APP_NAME" ]; then + echo -e "\033[31mError: Application name is required\033[0m" + exit 1 +fi + +if [ -z "$APP_IDENTIFIER" ]; then + echo -e "\033[31mError: Application identifier is required\033[0m" + exit 1 +fi + +if [ -z "$COMPANY_NAME" ]; then + echo -e "\033[31mError: Company name is required\033[0m" + exit 1 +fi + +if [ -z "$COPYRIGHT" ]; then + echo -e "\033[31mError: Copyright information is required\033[0m" + exit 1 +fi + +if [ -z "$ICON_PATH" ]; then + echo -e "\033[31mError: Icon path is required\033[0m" + exit 1 +fi + +echo "Starting Windows application customization..." + +if sed --version >/dev/null 2>&1; then + SED_INPLACE="-i" +else + SED_INPLACE="-i ''" +fi + +update_runner_files() { + runner_dir="appflowy_flutter/windows/runner" + + if [ -f "$runner_dir/Runner.rc" ]; then + sed $SED_INPLACE "s/VALUE \"CompanyName\", .*$/VALUE \"CompanyName\", \"$COMPANY_NAME\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"FileDescription\", .*$/VALUE \"FileDescription\", \"$APP_NAME\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"InternalName\", .*$/VALUE \"InternalName\", \"$APP_NAME\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"OriginalFilename\", .*$/VALUE \"OriginalFilename\", \"$APP_NAME.exe\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"LegalCopyright\", .*$/VALUE \"LegalCopyright\", \"$COPYRIGHT\"/" "$runner_dir/Runner.rc" + sed $SED_INPLACE "s/VALUE \"ProductName\", .*$/VALUE \"ProductName\", \"$APP_NAME\"/" "$runner_dir/Runner.rc" + echo -e "Runner.rc updated successfully" + else + echo -e "\033[31mRunner.rc file not found\033[0m" + fi +} + +update_icon() { + if [ ! -z "$ICON_PATH" ] && [ -f "$ICON_PATH" ]; then + app_icon_path="appflowy_flutter/windows/runner/resources/app_icon.ico" + cp "$ICON_PATH" "$app_icon_path" + echo -e "Application icon updated successfully" + else + echo -e "\033[31mApplication icon file not found\033[0m" + fi +} + +update_cmake_lists() { + cmake_file="appflowy_flutter/windows/CMakeLists.txt" + if [ -f "$cmake_file" ]; then + sed $SED_INPLACE "s/set(BINARY_NAME .*)$/set(BINARY_NAME \"$APP_NAME\")/" "$cmake_file" + echo -e "CMake configuration updated successfully" + else + echo -e "\033[31mCMake configuration file not found\033[0m" + fi +} + +update_main_cpp() { + main_cpp_file="appflowy_flutter/windows/runner/main.cpp" + if [ -f "$main_cpp_file" ]; then + sed $SED_INPLACE "s/HANDLE hMutexInstance = CreateMutex(NULL, TRUE, L\"AppFlowyMutex\");/HANDLE hMutexInstance = CreateMutex(NULL, TRUE, L\"${APP_NAME}Mutex\");/" "$main_cpp_file" + sed $SED_INPLACE "s/HWND handle = FindWindowA(NULL, \"AppFlowy\");/HWND handle = FindWindowA(NULL, \"$APP_NAME\");/" "$main_cpp_file" + sed $SED_INPLACE "s/if (window.SendAppLinkToInstance(L\"AppFlowy\")) {/if (window.SendAppLinkToInstance(L\"$APP_NAME\")) {/" "$main_cpp_file" + sed $SED_INPLACE "s/if (!window.Create(L\"AppFlowy\", origin, size)) {/if (!window.Create(L\"$APP_NAME\", origin, size)) {/" "$main_cpp_file" + echo -e "main.cpp updated successfully" + else + echo -e "\033[31mMain.cpp file not found\033[0m" + fi +} + +echo "Applying customizations..." +update_runner_files +update_icon +update_cmake_lists +update_main_cpp + +echo "Windows application customization completed successfully!" diff --git a/frontend/scripts/windows_installer/inno_setup_config.iss b/frontend/scripts/windows_installer/inno_setup_config.iss index b584f8df53..ab16c55ffb 100644 --- a/frontend/scripts/windows_installer/inno_setup_config.iss +++ b/frontend/scripts/windows_installer/inno_setup_config.iss @@ -14,7 +14,7 @@ VersionInfoVersion={#AppVersion} UsePreviousAppDir=no [Files] -Source: "AppFlowy\AppFlowy.exe";DestDir: "{app}";DestName: "AppFlowy.exe" +Source: "AppFlowy\AppFlowy.exe"; DestDir: "{app}"; DestName: "AppFlowy.exe"; Flags: ignoreversion Source: "AppFlowy\*";DestDir: "{app}" Source: "AppFlowy\data\*";DestDir: "{app}\data\"; Flags: recursesubdirs diff --git a/project.inlang/settings.json b/project.inlang/settings.json index c49b392792..1341b40643 100644 --- a/project.inlang/settings.json +++ b/project.inlang/settings.json @@ -13,6 +13,8 @@ "fa", "fr-CA", "fr-FR", + "ga-IE", + "he", "hu-HU", "id-ID", "it-IT", @@ -23,7 +25,9 @@ "pt-PT", "ru-RU", "sv-SE", + "th-TH", "tr-TR", + "uk-UA", "vi", "vi-VN", "zh-CN", @@ -42,4 +46,4 @@ "@:" ] } -} +} \ No newline at end of file